    /* TOKENS. Two axes, orthogonal on purpose (founder ruling,
    2026-08-16): SKIN (classical/utilitarian, a body class) is what look the
    chrome wears; light/dark (a `data-theme` attribute, set by hand —
    nothing auto-detects it; see the dark-variant block below for why) is
    how bright the screen is. Every colour in this
    stylesheet is one of these custom properties — enforced by
    tests_hygiene.NoRawHexOutsideTokensTests, which fails on any literal
    hex code below this block. The founder's rule for what MUST stay
    colour, verbatim: "ink and paper for the chrome, pigment for the
    data." So --bg/--card-bg/--ink/--muted/--border/--accent (the chrome)
    repaint completely between skins; --proven/--probable/--weak/
    --undocumented/--conflicted (confidence badges), --rcat-* (the four
    record categories), --fk-* (the two fact kinds) and --att-* (attach
    states) are RETUNED, never desaturated, because those colours are
    load-bearing information, not decoration. Everything else (the long
    tail of one-off tint/shadow colours below) derives from these via
    color-mix() so it warms up automatically with the skin instead of
    needing 60 individually-approved hex codes.

    CLASSICAL IS THE DEFAULT, held right here in :root — founder,
    2026-08-16, after seeing the real-page mockups: "Full classical is
    beautiful, I strongly prefer it." Values are the mockup's approved
    palette (scratchpad/classical.css, an !important-laden injection hack
    used only to produce that screenshot round); this block is the real
    implementation of it, not a copy of the hack. :root is the honest
    fallback too — any template that ever renders <body> without the skin
    class still gets the product's actual default look, not the old one.
    Utilitarian (today's pre-2026-08-16 look) is opt-in — see
    body.skin-utilitarian below. */
    :root {
      /* --- type: the other half of "ink and paper for the chrome" — a
      classical page reads as classical mostly through its type, not its
      colour. --font-prose is what the skin repaints (serif here, sans in
      utilitarian, see below); --font-ui does NOT vary by skin — form
      controls (button/select/input/textarea) and the status chips
      (fk-chip/rcat/att/badge) stay sans in BOTH skins, on purpose: they
      are read at a glance for their COLOUR and shape, not their prose,
      and a serif numeral or an all-caps serif chip label is exactly the
      kind of decorative flourish that costs the dense work screens
      (tree, record) their scanability — the one thing the founder's
      ruling explicitly protects. Declared once, here, with no
      skin-specific override anywhere, because it is not supposed to have
      one. */
      --font-prose: Georgia, "Iowan Old Style", "Palatino", serif;
      --font-ui: -apple-system, "Segoe UI", Roboto, sans-serif;

      /* --- chrome: repaints completely between skins --- */
      --bg: #faf6ee;
      --card-bg: #fffdf7;
      --ink: #2b2620;
      --muted: #57606a;
      --muted-icon: #8c959f;
      --border: #d8cfbc;
      --border-light: #e8e0d0;
      --subtle-bg: #f3ecdd;
      --accent: #7a4d2e;
      --accent-purple: #8250df;
      --accent-purple-2: #6639ba;
      --focus-ring: #7a4d2e33;

      /* --- confidence badges: pigment for the data, retuned to
      manuscript tones, never desaturated — the founder's one condition
      on going classical. --- */
      --proven: #4a6741;
      --probable: #8a6a1f;
      --weak: #a05c2c;
      --undocumented: #5a5751;
      --conflicted: #8c3b34;

      /* --- north-star record categories (2026-08-12) — birth/life/death
      converge with proven/weak/undocumented under this retint, so those
      three reuse the badge tokens directly at the point of use; marriage
      has no badge counterpart and gets its own. --- */
      --rcat-marriage: #5c4470;

      /* --- fact-kind chips (relationship vs life) — diverge from
      --accent/--proven, so these are NOT aliases of them. --- */
      --fk-relationship: #3a4e7a;
      --fk-life: #274e36;

      /* --- attach states (record page: filed vs proposed vs blocked) --- */
      --att-ok: #3f6136;
      --att-ok-bg: rgba(63,97,54,.14);
      --att-pending: #8a6a1f;
      --att-pending-bg: rgba(138,106,31,.14);
      --att-hold: #5a5751;
      --att-hold-bg: rgba(90,87,81,.14);

      /* --- minor one-off UI colours the founder's ruling doesn't name
      (a spend meter, an info pill) — tokenised for the hygiene test, not
      part of the pigment system, so they hold steady across skins. --- */
      --spend-over-border: #ea580c;
      --spend-over-text: #9a3412;
      --info-pill-bg: #dbe5ee;
      --info-pill-text: #33415c;

      /* --- tints: the long tail of light background/border washes,
      each named for its own formula (var + percent) rather than for
      the rule that first used it, since several rules share one.
      Dynamic here — derives from the pigment above it, so it warms up
      with classical automatically — and PINNED to today's exact
      literal in body.skin-utilitarian below (review round,
      2026-08-16: color-mix() alone left utilitarian a few values/255
      off its own baseline; sub-perceptual, but the founder asked for
      today's look back exactly, not merely close). --- */
      --tint-proven-6: color-mix(in srgb, var(--proven) 6%, var(--card-bg));
      --tint-proven-8: color-mix(in srgb, var(--proven) 8%, var(--card-bg));
      --tint-border-40: color-mix(in srgb, var(--border) 40%, var(--card-bg));
      --tint-probable-55: color-mix(in srgb, var(--probable) 55%, var(--card-bg));
      --tint-probable-8: color-mix(in srgb, var(--probable) 8%, var(--card-bg));
      --tint-conflicted-4: color-mix(in srgb, var(--conflicted) 4%, var(--card-bg));
      --tint-probable-6: color-mix(in srgb, var(--probable) 6%, var(--card-bg));
      --tint-purple-3: color-mix(in srgb, var(--accent-purple) 3%, var(--card-bg));
      --tint-purple-40: color-mix(in srgb, var(--accent-purple) 40%, var(--card-bg));
      --tint-muted-45: color-mix(in srgb, var(--muted) 45%, var(--card-bg));
      --tint-probable-3: color-mix(in srgb, var(--probable) 3%, var(--card-bg));
      --tint-conflicted-3: color-mix(in srgb, var(--conflicted) 3%, var(--card-bg));
      --tint-probable-12: color-mix(in srgb, var(--probable) 12%, var(--card-bg));
      --tint-probable-14: color-mix(in srgb, var(--probable) 14%, var(--card-bg));
      --tint-conflicted-6: color-mix(in srgb, var(--conflicted) 6%, var(--card-bg));
      --tint-weak-8: color-mix(in srgb, var(--weak) 8%, var(--card-bg));
      --tint-probable-16: color-mix(in srgb, var(--probable) 16%, var(--card-bg));
      --tint-probable-5: color-mix(in srgb, var(--probable) 5%, var(--card-bg));
      --tint-border-5: color-mix(in srgb, var(--border) 5%, var(--card-bg));
      --tint-border-6: color-mix(in srgb, var(--border) 6%, var(--card-bg));
      --tint-border-8: color-mix(in srgb, var(--border) 8%, var(--card-bg));
      --tint-purple-4: color-mix(in srgb, var(--accent-purple) 4%, var(--card-bg));
      --tint-proven-7: color-mix(in srgb, var(--proven) 7%, var(--card-bg));
      --tint-probable-35: color-mix(in srgb, var(--probable) 35%, var(--card-bg));
      --tint-proven-18: color-mix(in srgb, var(--proven) 18%, var(--card-bg));
      --tint-border-70: color-mix(in srgb, var(--border) 70%, var(--card-bg));
      --tint-muted-30: color-mix(in srgb, var(--muted) 30%, var(--card-bg));
      --tint-accent-22: color-mix(in srgb, var(--accent) 22%, var(--card-bg));
      --tint-muted-35: color-mix(in srgb, var(--muted) 35%, var(--card-bg));
      --tint-muted-38: color-mix(in srgb, var(--muted) 38%, var(--card-bg));
      --tint-probable-strong: color-mix(in srgb, var(--probable) 75%, black);
      --tint-conflicted-strong: color-mix(in srgb, var(--conflicted) 75%, black);
    }
    /* UTILITARIAN: the pre-2026-08-16 look, still fully available (the
    founder asked for it selectable, not retired). Every value here is
    exactly what :root held before this change — nothing about the
    utilitarian skin's own appearance moved, only which one answers by
    default when nobody has chosen. */
    body.skin-utilitarian {
      /* today's font, restated as the token rather than left to fall
      through — --font-ui is already this exact stack, but --font-prose
      is the one that has to be an explicit override here, or utilitarian
      prose would silently go serif along with everything else. */
      --font-prose: -apple-system, "Segoe UI", Roboto, sans-serif;
      --bg: #ffffff;
      --card-bg: #ffffff;
      --ink: #1f2328;
      --border: #d1d9e0;
      --border-light: #eaeef2;
      --subtle-bg: #f6f8fa;
      --accent: #0969da;
      --focus-ring: #0969da33;

      --proven: #1a7f37;
      --probable: #9a6700;
      --weak: #bc4c00;
      --undocumented: #57606a;
      --conflicted: #cf222e;
      --rcat-marriage: #8250df;
      --fk-relationship: #0969da;
      --fk-life: #1a7f37;
      --att-ok: #1a7f37;
      --att-ok-bg: rgba(26,127,55,.12);
      --att-pending: #9a6700;
      --att-pending-bg: rgba(154,103,0,.12);
      --att-hold: #57606a;
      --att-hold-bg: rgba(87,96,106,.12);

      /* Exact pins, not color-mix() — these are literally what each
      formula above evaluated to before the token system existed, so
      utilitarian is bit-for-bit its old self (two pairs of tokens
      collapse two originally-distinct-but-≤3/255-apart hexes into one
      shared formula; those two get the average of the pair, still
      well inside the sub-perceptual range). */
      --tint-proven-6: #f3faf4;
      --tint-proven-8: #edf8f0;
      --tint-border-40: #eef1f4;
      --tint-probable-55: #d4a72c;
      --tint-probable-8: #fff8e5;
      --tint-conflicted-4: #fff5f5;
      --tint-probable-6: #fdf6e3;
      --tint-purple-3: #faf9ff;
      --tint-purple-40: #d0bfff;
      --tint-muted-45: #afb8c1;
      --tint-probable-3: #fffaf0;
      --tint-conflicted-3: #fff8f8;
      --tint-probable-12: #fff8c5;
      --tint-probable-14: #fff3bf;
      --tint-conflicted-6: #fff1f0;
      --tint-weak-8: #fff1e5;
      --tint-probable-16: #fff1d6;
      --tint-probable-5: #fdf8ee;
      --tint-border-5: #fcfcfd;
      --tint-border-6: #fbfcfd;
      --tint-border-8: #fafbfc;
      --tint-purple-4: #faf7ff;
      --tint-proven-7: #f2fbf4;
      --tint-probable-35: #e0d4b0;
      --tint-proven-18: #dbe7dd;
      --tint-border-70: #dbe1e6;
      --tint-muted-30: #d0d7de;
      --tint-accent-22: #c3d4e4;
      --tint-muted-35: #c3cad1;
      --tint-muted-38: #b0bcc7;
      --tint-probable-strong: #8a6d00;
      --tint-conflicted-strong: #82071e;
    }
    /* DARK VARIANTS, orthogonal to skin — INERT BY DEFAULT. A first cut of
    this block auto-activated on the CSS media query for the OS's own dark
    preference, which review caught and blocked (2026-08-16 fix round) on
    two grounds:
    it changed every OS-dark visitor's rendering with no request from them
    and no opt-out, and it broke the one thing this whole token system
    exists to protect — a confidence badge's label text is --card-bg, which
    the dark variant also darkens, so a badge became dark text on a
    midtone pill exactly where colour is meant to be trusted at a glance.
    `:root[data-theme="dark"]` stays as a manual-override hook — dark mode
    is real future work, just not wired to anything live yet, so nothing
    about today's rendering moves until a switch is built and reviewed on
    its own terms (including the badge-contrast question above). */
    :root[data-theme="dark"] body:not(.skin-utilitarian) {
      --bg: #181410; --card-bg: #211c14; --ink: #e8e0d0; --muted: #b0a48c;
      --muted-icon: #8f8368; --border: #4a4030; --border-light: #5a4e3a;
      --subtle-bg: #241f16; --accent: #c99a6c;
    }
    :root[data-theme="dark"] body.skin-utilitarian {
      --bg: #0d1117; --card-bg: #161b22; --ink: #e6edf3; --muted: #8b949e;
      --muted-icon: #6e7681; --border: #30363d; --border-light: #3d444d;
      --subtle-bg: #1c2128; --accent: #58a6ff; --accent-purple: #a371f7;
      --accent-purple-2: #8957e5;
    }
    /* Left edge is 1.5rem on EVERY page, has-dock included below —
    a centered body puts the nav at a different x on every page whose
    max-width differs (860 here, 1280 on .wide, full width on .has-dock), so
    "Tree" could sit 280px from where it was on the page you came from. No
    reading-width page needs centering enough to make the nav wander for
    it. */
    body { font-family: var(--font-prose); max-width: 860px; margin: 2rem 0; padding: 0 1.5rem; color: var(--ink); background: var(--bg); }
    a { color: var(--accent); text-decoration: none; } a:hover { text-decoration: underline; }
    /* The wordmark: lowercase lineaige, the ai italic in the accent blue —
       the pun carried by type, never by caps. */
    .wordmark em { font-style: italic; color: var(--accent); }
    nav { margin-bottom: 1.5rem; font-size: .9rem; }
    .card { border: 1px solid var(--border); border-radius: 8px; padding: .9rem 1.1rem; margin: .6rem 0; }
    /* SANS, ALWAYS — form controls and the status chips below are read for
    their shape and colour at a glance, never set as prose, so they do not
    follow --font-prose's swing to serif in classical. One rule, ahead of
    every selector it covers, rather than repeating font-family on each —
    see the --font-ui declaration's own comment for why this list is what
    it is (and is not, e.g., every small/muted caption — those stay
    prose). */
    button, select, input, textarea,
    .fk-chip, .rcat, .att, .badge, .count,
    .ledger .when, .src, ul.doomed strong, a.spend, .story-year
      { font-family: var(--font-ui); }
    /* CLASSICAL HEAD TREATMENT (2026-08-16 fix round — the first cut of
    this work tokenised colour only, so a classical page was parchment
    WITHOUT serif, not the mockup the founder approved). Scoped entirely
    under body.skin-classical: utilitarian's heads do not move a pixel.
    CSS on the EXISTING markup only — no template changed for this. h1
    is deliberately NOT double-ruled here: it is the site wordmark on
    most pages (tree.html) and the bare record title on source_detail's
    (_record_header.html) — neither reads as a "major section head" the
    way an h2 like "Who this record names" does, and a rule under a small
    logotype would look like a mistake, not an engraving. */
    body.skin-classical h1, body.skin-classical h2, body.skin-classical h3 {
      font-weight: 600; letter-spacing: .01em;
    }
    body.skin-classical h2 {
      border-bottom: 3px double var(--border); padding-bottom: .25rem;
    }
    /* The "The life of X" kicker (person_detail.html, _detail_box.html):
    centred, small-caps-by-letterspacing, in the sienna accent — the
    engraved-title-page feel from the approved mockup. .detail-head is a
    baseline flex row (kicker, name, gender tag, the ⋯ menu, all on one
    line in utilitarian); giving the kicker a 100% flex-basis wraps it
    onto its own centred line above the rest ONLY once the row is allowed
    to wrap — both declarations are classical-only, so utilitarian's row
    never wraps and never needs to. */
    body.skin-classical .detail-head { flex-wrap: wrap; }
    body.skin-classical .detail-kicker {
      flex: 1 0 100%; text-align: center; text-transform: uppercase;
      font-style: normal; font-size: .7rem; letter-spacing: .14em;
      color: var(--accent); margin-bottom: .15rem;
    }
    /* A person's years, wherever they ride inside the kicker'd head
    (only person_detail.html's own copy of .detail-head nests one) —
    italic, in the sienna accent, the mockup's "years in accent" note. */
    body.skin-classical .detail-head strong .muted {
      font-style: italic; color: var(--accent);
    }
    .badge { display: inline-block; padding: .1rem .55rem; border-radius: 999px; color: var(--card-bg); font-size: .75rem; font-weight: 600; }
    .badge.proven { background: var(--proven); } .badge.probable { background: var(--probable); }
    .badge.weak { background: var(--weak); } .badge.undocumented { background: var(--undocumented); }
    .badge.conflicted { background: var(--conflicted); }
    .ladder { border-left: 3px solid var(--border); margin-left: .6rem; padding-left: 1.2rem; }
    .claims { font-size: .85rem; color: var(--muted); margin: .3rem 0 0; }
    /* By LEVEL. Every message rendered in the "proven" green, errors
       included — so "That didn't match the tree's name — nothing was
       deleted." arrived in the colour this app uses to mean a fact is well
       documented. In an application whose whole premise is that the colours
       can be trusted, that is the worst available default. Django has
       exposed m.tags all along. */
    .messages li { color: var(--proven); }
    /* The record's own drawing: the no-man's-land and its open spaces.
       Colours are the EXISTING variables, never a new palette — "committing
       it should match the standardized colors/experience from doing so on
       the tree itself". A dotted outline means a place you may drop into;
       amber is the same undecided/probable it means everywhere else. */
    .arranging { display: flex; gap: 1.2rem; flex-wrap: wrap; align-items: flex-start; }
    .noman { flex: 1 1 16rem; min-width: 15rem; }
    .noman-title { margin: 0 0 .2rem; font-size: 1rem; }
    .noman-list { list-style: none; padding: 0; margin: .4rem 0; }
    .noman-person { border: 1px solid var(--border); border-radius: 6px;
                    padding: .35rem .6rem; margin: .3rem 0; background: var(--card-bg); }
    .noman-person[draggable="true"] { cursor: grab; }
    .noman-person.is-placed { border-style: solid; border-color: var(--probable); }
    .slot-list { list-style: none; padding: 0; margin: .3rem 0; }
    .slot { display: block; width: 100%; text-align: left;
            border: 2px dashed var(--probable); background: transparent;
            color: var(--ink); border-radius: 6px; padding: .45rem .6rem;
            margin: .3rem 0; cursor: pointer; }
    /* The give is generous by design, so the target has room to grow into
       without the layout shifting under a drag. */
    .slot.is-near { background: var(--tint-probable-8); border-style: solid; }
    .slot-spare { border-color: var(--undocumented); opacity: .75; }
    .arranging.is-dragging .slot { border-style: solid; }
    /* THE TRAY, HOT (deferred drag polish, ARRANGE-PORT.md): the tray is
       the one drop target that isn't a .slot button, so it earns the same
       "about to catch this drop" tint on its own border rather than
       inheriting .slot's dashed-to-solid treatment. */
    .arrange-tray.is-near { background: var(--tint-probable-8);
      border-radius: 8px; outline: 2px solid var(--probable); }
    /* A connected box a drag can pick up (deferred drag polish): the same
       grab affordance the no-man's-land's own draggable rows already show,
       so "this can be dragged" reads identically whether the person is
       floating or already drawn in the tree. */
    .pbox[draggable="true"] { cursor: grab; }
    /* B1, the founder-approved direct-manipulation pedigree: a box a human
       moved keeps a visible amber cast — the same --probable amber every
       other undecided/human-made mark on this page already wears — plus
       its own "was: X / now: Y" sentence and, on the most-recently-moved
       box only, an inline undo (see _pedigree_node.html's own comment on
       why only one box gets it). */
    .pbox:has(> .arrange-moved-chip) { border-color: var(--probable);
      background: var(--tint-probable-8); }
    .arrange-moved-chip { display: flex; align-items: center; gap: .4rem;
      flex-wrap: wrap; }
    .arrange-inline-undo { padding: .05rem .5rem; }
    /* THE PENDING-CHANGES BAR (B1): "amber is the same undecided/probable
       it means everywhere else" — reused here rather than a new colour,
       for the same reason the slots and the moved chip both already do. */
    .pending-changes-bar { border: 1px solid var(--probable);
      background: var(--tint-probable-8); border-radius: 6px;
      padding: .6rem .9rem; margin: .6rem 0; }
    .pending-changes-tally { margin: 0 0 .4rem; }
    .pending-changes-actions { display: flex; gap: .5rem; flex-wrap: wrap;
      margin: 0; }
    /* The record's joined family drawing (2026-08-11: couples joined,
       parents above, children below, "just like the full tree"). Blocks
       stack; a nested block sits inside a children row like any child box.
       The couple bar on the tree page is measured by alignCoupleBar() —
       this page has no such script, so a static centred bar joins the two
       stubs instead (record families are narrow; 50% reads correctly). */
    .record-drawing { display: flex; flex-direction: column; gap: 1.4rem;
      align-items: flex-start; }
    .record-family { display: flex; flex-direction: column;
      align-items: center; }
    .record-family .couple-pedigrees:has(> .tree-body ~ .tree-body)::before {
      left: 25%; width: 50%; }
    /* A slot button inside a children row is a dotted child-shaped box,
       like the tree page's "+ child" addslot, not a full-width bar. */
    .record-family .children .slot { display: inline-block; width: auto; }
    /* FAMILY-UNIT SEAMS (F1, READER-SHAKEDOWN): each TOP-LEVEL block gets
       a bounded card of its own — border + a little breathing room — so a
       record naming several unrelated families stops reading as one
       continuous chain. CSS custom properties only, so this repaints for
       both skins and both themes with no override needed. */
    .record-family.family-group { border: 1px solid var(--border);
      border-radius: 10px; padding: 1rem .8rem .6rem; background: var(--subtle-bg); }
    .family-group-header { margin: 0 0 .6rem; font-size: .85rem;
      font-weight: 600; color: var(--muted); text-align: center; }
    /* Match lines under record-only boxes (round 2.3). */
    .match-line { font-size: .72rem; margin-top: .15rem; max-width: 170px; }
    .match-line.match-matched, .match-line.match-confident { color: var(--proven); }
    .match-line.match-possible { color: var(--probable); }
    .match-line.match-new { color: var(--muted); }
    /* The People | Proposed-tree tabs on the record page (round 2.1). */
    .pb-head { display: flex; align-items: baseline; gap: .8rem;
      flex-wrap: wrap; }
    .pb-head h2 { margin-bottom: .3rem; }
    .pb-tabs { display: flex; gap: 4px; }
    .pbt { font-size: .8rem; border: 1px solid var(--border); background: var(--subtle-bg);
      color: var(--ink); border-radius: 6px; padding: .2em .8em; cursor: pointer; }
    .pbt.active { background: var(--accent); border-color: var(--accent); color: var(--card-bg);
      font-weight: 600; }
    .ann-entry { white-space: nowrap; }
    .ann-change { display: inline-block; }
    .ann-change summary.linkish { display: inline; cursor: pointer;
      color: var(--accent); font-size: .8rem; list-style: none; }
    .ann-change summary.linkish::-webkit-details-marker { display: none; }
    .ann-change form { margin-top: .25rem; white-space: normal; }
    /* The clerk opt-in (north-star round 2.2): one explicit button. */
    .clerk-optin-form { display: flex; gap: .6rem; align-items: center;
      flex-wrap: wrap; margin: .5rem 0; }
    .clerk-go { border: 1px solid var(--weak); color: var(--weak); background: none;
      border-radius: 6px; padding: .3em .8em; cursor: pointer;
      font-size: .88rem; font-weight: 600; }
    .clerk-sub { font-size: .78rem; }
    /* North-star chips (2026-08-12): the four record categories and the
       two fact kinds. Colour language matches the approved mockups. */
    .rcat { display: inline-block; border-radius: 999px; padding: .05em .6em;
      font-size: .75rem; font-weight: 600; color: var(--card-bg); }
    .rcat-birth { background: var(--proven); } .rcat-marriage { background: var(--rcat-marriage); }
    .rcat-death { background: var(--undocumented); } .rcat-life { background: var(--weak); }
    .fk-chip { display: inline-block; border-radius: 4px; padding: .02em .45em;
      font-size: .66rem; font-weight: 700; letter-spacing: .06em;
      text-transform: uppercase; color: var(--card-bg); vertical-align: .08em; }
    .fk-relationship { background: var(--fk-relationship); } .fk-life { background: var(--fk-life); }
    /* Person page recomposition (1e-ii sub-slice 3a, round-5 mockup): a
       pinned head (same grammar as the tree's .detail-box, see below),
       two columns — "What we know" + "Records naming X" on the left,
       Family + Where to look next on the right. Desktop-first per the
       founder's own ruling: no re-flow breakpoint added here, the same
       call the mockup made for the whole workshop. */
    .person-head { position: sticky; top: 0; z-index: 5; margin: 0 0 .4rem;
      display: flex; align-items: baseline; gap: .8rem; flex-wrap: wrap;
      padding-top: .5rem; padding-bottom: .5rem; }
    .person-head .detail-head { flex: 1; min-width: 0; }
    /* The subhead is the NON-sticky half of what used to be one card (the
       founder: name and colour bar only) — designation, stats, quiet edit. */
    .person-subhead { margin: 0 0 .8rem; padding-top: .5rem; }
    .person-subhead .designation-line { margin: .1rem 0 0; }
    /* The relocated search-links + source-document widgets are real forms,
       not one-line menu items — give the menu the room they need instead
       of letting them run off the right edge of a sticky head (3a review). */
    .person-head .more-menu { width: 24rem; max-width: 80vw; max-height: 70vh; overflow-y: auto; }
    .person-head .stateline { flex-basis: 100%; margin: .15rem 0 0; font-size: .85rem; }
    .person-head .stateline strong { color: var(--ink); }
    /* The ✎ toggle is a plain disclosure widget — no JS, and no less quiet
       for it (the founder, round 4: "never a prominent button"). */
    .person-head .quick-edit { flex-basis: 100%; }
    .person-head .quick-edit summary { color: var(--muted); font-size: .85rem;
      cursor: pointer; list-style: none; width: max-content; }
    .person-head .quick-edit summary::-webkit-details-marker { display: none; }
    .person-head .quick-edit summary:hover { text-decoration: underline; }
    .person-cols { display: flex; gap: 1.2rem; align-items: flex-start; }
    .person-col-life { flex: 1.4; min-width: 0; }
    .person-col-side { flex: 1; min-width: 18rem; }
    .ledger { border-left: 3px solid var(--border-light); margin-left: .3rem; padding-left: 1rem; }
    .ledger .entry { margin: .7rem 0; }
    .ledger .when { font-variant-numeric: tabular-nums; font-weight: 700;
      margin-right: .4rem; display: inline-block; min-width: 2.6rem; }
    .ledger .rec { font-size: .82rem; color: var(--muted); margin: .2rem 0 0 3rem; }
    .ledger .rec a { color: inherit; }
    /* Records naming X: same recrow shape the mockup settled on — chip,
       title, fact count, one row per record. */
    .recrow { display: flex; gap: .5rem; align-items: baseline; padding: .32rem 0;
      border-bottom: 1px solid var(--tint-border-40); flex-wrap: wrap; }
    .recrow:last-child { border-bottom: 0; }
    .recrow .title { flex: 1; min-width: 12rem; }
    /* Record slivers (1e-ii 3d): a fixed 20x26px window, clipped — the
       <img> inside is oversized/offset by slivers.record_sliver's inline
       style, this box just crops it. The glyph reuses the same box so a
       row of mixed image/PDF/imageless records still lines up. */
    .rec-sliver { display: inline-block; position: relative; width: 20px; height: 26px;
      overflow: hidden; vertical-align: middle; border-radius: 2px; background: var(--tint-border-40);
      flex-shrink: 0; }
    .rec-sliver img { position: absolute; max-width: none; }
    .rec-sliver-glyph { color: var(--muted-icon); padding: 1px; box-sizing: border-box; }

    /* The founder's whole-image sliver: the scan shrunk to fit, shape
       intact — contain, never cover, so the document IS its own icon. */
    .rec-sliver img.whole { display: block; width: 100%; height: 100%;
      position: static; object-fit: contain; }
    .rec-sliver-glyph svg { width: 100%; height: 100%; }
    .ledger .rec .rec-sliver { margin-right: .35rem; }
    /* Family card: flat, per the founder's round-5 "otherwise looks good" —
       complex moves (unlink, relationship change, replace spouse) stay in
       the family-controls disclosure right underneath it, not gone —
       only the duplicate ancestry PICTURE went. */
    .fam { display: grid; grid-template-columns: 1fr 1fr; gap: .4rem .8rem; }
    .fam .who { border: 1px solid var(--border); border-left-width: 4px; border-radius: 6px;
      padding: .3rem .6rem; font-size: .9rem; background: var(--card-bg); }
    .fam .role { font-size: .7rem; color: var(--muted); text-transform: uppercase;
      letter-spacing: .04em; display: block; }
    .fam .ghost { border: 1px dashed var(--border); color: var(--muted); border-radius: 6px;
      padding: .3rem .6rem; font-size: .9rem; display: block; text-align: center; }
    /* Notepad (1e-ii 3b, round-4/5 mockup): a ruled, borderless textarea —
       one directly-typeable box, not a form field, so it should not look
       like one. The repeating-linear-gradient draws the ruling; line-height
       and the gradient's step must match or the lines drift out from under
       the text as you type. */
    .notepad-card { padding: .6rem .8rem; }
    .notepad { width: 100%; border: 0; border-radius: 4px; resize: vertical;
      background: repeating-linear-gradient(180deg, transparent 0 1.35em,
        var(--tint-border-40) 1.35em calc(1.35em + 1px));
      line-height: 1.35em; font: inherit; font-size: .88rem; color: var(--ink); }
    /* NOT `outline: 0` — this was the one control on the page with its
       focus ring removed and nothing put back, which fails WCAG 2.4.7 for
       anyone tabbing to it (1e-ii 3b review, should-fix 10). An INSET ring
       (box-shadow, not outline) keeps the borderless notepad look the
       mockup asked for while still being visibly focused. */
    .notepad:focus { outline: none;
      box-shadow: inset 0 0 0 2px var(--accent); }
    .notepad:disabled { color: var(--muted); background-color: var(--subtle-bg); }
    .notepad-foot { margin: .25rem 0 0; display: flex; align-items: center;
      justify-content: space-between; gap: .5rem; }
    .tree-details { margin-top: 1.2rem; }
    .messages li.error { color: var(--conflicted); }
    .messages li.warning { color: var(--probable); }
    .messages li.info { color: var(--undocumented); }
    /* A proposed person who would JOIN the direct tree — the founder
       almost missed these. Accent left edge + a loud badge; the person's
       own colour system is untouched (these people have no colour yet). */
    .pending-card.joins-the-tree { border-left: 4px solid var(--accent);
      background: color-mix(in srgb, var(--accent) 4%, var(--card-bg)); }
    .joins-badge { display: inline-block; font-weight: 700; font-size: .78rem;
      color: var(--accent); text-transform: uppercase;
      letter-spacing: .06em; margin-bottom: .2rem; }
    .undo-bar { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap;
      margin: .5rem 0 .9rem; padding: .5rem .75rem; border-radius: 6px;
      background: var(--tint-probable-6); border: 1px solid var(--tint-probable-35); font-size: .9rem; }
    .undo-bar span { flex: 1; min-width: 12rem; }
    .undo-bar button { font-size: .85rem; padding: .25rem .8rem; }
    .page-spread figure { margin: 0; }
    .page-spread.multi { display: flex; gap: .5rem; align-items: flex-start; overflow-x: auto; }
    .page-spread.multi figure { flex: 1 1 0; min-width: 0; }
    .page-spread figcaption { font-size: .8rem; margin-top: .2rem; }
    .stale-read { background: var(--tint-probable-12); border: 1px solid var(--tint-probable-55); border-radius: 6px;
      padding: .55rem .75rem; margin: .5rem 0; font-size: .9rem; }
    .stale-read button { margin-left: .4rem; font-size: .85rem; padding: .25rem .7rem; }
    /* Small, quiet, and always in the same corner so the eye can run down a
       generation without reading. A filled pill is a person somebody has
       worked on; the hollow one is the invitation. */
    /* Absolutely placed, NOT floated: .pbox is a flex column, where a float
       is ignored and the badge becomes a full-width bar shoving the name and
       the years apart — which is what it did on the first attempt. Out of
       flow it also costs the box no height, so a tree with the count reads
       at exactly the same density as one without. Same corner on every box
       so the eye can run down a generation without reading. */
    a.src, a.src:hover { text-decoration: none; }
    a.src:hover { background: var(--tint-accent-22); }
    a.src.none:hover { background: var(--subtle-bg); color: var(--muted); }
    .src { position: absolute; top: .2rem; right: .25rem;
      font-size: .65rem; font-variant-numeric: tabular-nums; line-height: 1.4;
      min-width: 1.15em; text-align: center; padding: 0 .25em;
      border-radius: 999px; background: var(--info-pill-bg); color: var(--info-pill-text); }
    .src.none { background: none; color: var(--tint-muted-35); border: 1px dashed var(--tint-border-70); }
    .identify { display: inline-flex; gap: .3rem; align-items: center; margin: .2rem .3rem; }
    .identify select { width: auto; max-width: 18rem; padding: .2rem; font-size: .85rem; }
    .identify button { padding: .2rem .6rem; font-size: .8rem; }
    /* details.mini-tree rules retired with the pedigree drawing (3a) —
       the person page was their only consumer. */
    .family-verb { display: flex; align-items: center; gap: .6rem; flex-wrap: wrap;
      margin: .35rem 0; font-size: .9rem; }
    /* A checklist you can tick where you read it. These were the last control
       INSIDE a collapsed row, so eight leads showed as eight closed rows with
       nothing to act on. */
    button.tick { color: var(--muted); font-size: .85rem; }
    .proof-row .verdict { margin: .3rem 0; }
    .proof-row .verdict.argument { color: var(--conflicted); }
    .proof-row .verdict.summary { color: var(--probable); }
    .proof-row .verdict.statement { color: var(--proven); }
    .because { font-size: .9rem; margin: .2rem 0; }
    .because .asks { display: block; color: var(--muted); font-style: italic; }
    /* A change waiting for a click, drawn on the tree it would change.
       Dashed for arriving because it is not there yet; struck and faded for
       going because it still is. Never colour alone — these boxes already
       carry a colour strip for the person themselves. */
    .pbox.proposed-arriving { border: 2px dashed var(--proven); background: var(--tint-proven-8); }
    .pbox.proposed-going { border: 2px dashed var(--conflicted); background: var(--tint-conflicted-4); opacity: .78; }
    .pbox.proposed-going > a { text-decoration: line-through; }
    /* The ⚭ strip carries its own mark: a marriage with no children is drawn
       there, not as a couple, so that is where a proposal about it belongs. */
    .spouse-strip .proposed-going { color: var(--conflicted); text-decoration: line-through; }
    .spouse-strip .proposed-arriving { color: var(--proven); font-weight: 600; }
    .proposal-bar { display: flex; align-items: center; gap: .75rem; flex-wrap: wrap;
      margin: .5rem 0 .9rem; padding: .55rem .8rem; border-radius: 6px;
      background: var(--tint-proven-8); border: 1px solid var(--proven); font-size: .9rem; }
    .proposal-bar .what { flex: 1; min-width: 14rem; }
    .proposal-bar .key { color: var(--muted); font-size: .82rem; }
    .pill-arriving, .pill-going { border-radius: 3px; padding: 0 .3em; font-size: .78rem; }
    .pill-arriving { border: 1px dashed var(--proven); color: var(--proven); background: var(--tint-proven-8); }
    .pill-going { border: 1px dashed var(--conflicted); color: var(--conflicted);
      background: var(--tint-conflicted-4); text-decoration: line-through; }
    button.tick:hover { color: var(--proven); text-decoration: none; }
    form p { margin: .5rem 0; } input[type=text], input[type=url], textarea, select { width: 100%; box-sizing: border-box; padding: .4rem; }
    ul.checkbox-list { list-style: none; padding-left: 0; } ul.checkbox-list li { margin: .15rem 0; }
    button { padding: .5rem 1rem; border-radius: 6px; border: 1px solid var(--border); background: var(--proven); color: var(--card-bg); cursor: pointer; }
    button.secondary { background: var(--card-bg); color: var(--ink); }
    button:disabled { opacity: .45; cursor: not-allowed; }
    .fail { border-left: 4px solid var(--conflicted); } .warn { border-left: 4px solid var(--probable); }
    .fix { font-size: .85rem; color: var(--proven); }
    .muted { color: var(--muted); font-size: .85rem; }
    /* Same shape as the nav's ⋯, on the other side of the bar. */
    details.tree-more { display: inline-block; position: relative; }
    details.tree-more > summary { list-style: none; cursor: pointer; padding: 0 .3rem; }
    details.tree-more > summary::-webkit-details-marker { display: none; }
    details.tree-more > span { position: absolute; right: 0; top: 1.4rem; z-index: 5;
        white-space: nowrap; background: var(--card-bg); border: 1px solid var(--border);
        border-radius: 6px; padding: .4rem .7rem; box-shadow: 0 2px 8px rgba(0,0,0,.08); }
    a.danger, button.danger, .link-btn.danger { color: var(--conflicted); }
    /* The library, as a contact sheet. Fixed-height thumbs so the eye can run
       down a column; the point is comparison, not presentation. */
    .source-grid { display: grid; gap: .8rem;
        grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); }
    .source-tile img { width: 100%; height: 130px; object-fit: cover;
        border-radius: 4px; border: 1px solid var(--border); display: block;
        margin-bottom: .4rem; background: var(--subtle-bg); }
    .source-tile .no-thumb { height: 130px; display: flex; align-items: center;
        justify-content: center; border: 1px dashed var(--border); border-radius: 4px;
        margin-bottom: .4rem; }
    .source-tile.is-dupe { border-color: var(--probable); }
    ul.doomed { margin: .4rem 0 1rem; line-height: 1.7; }
    ul.doomed strong { font-variant-numeric: tabular-nums; }
    /* Legible but not loud: it should be readable at a glance and never
       compete with the three things you actually navigate by. */
    a.spend { font-variant-numeric: tabular-nums; font-size: .82rem;
              color: var(--muted); border: 1px solid var(--border); border-radius: 999px;
              padding: .05rem .5rem; margin-left: .5rem; }
    a.spend:hover { color: var(--ink); text-decoration: none; border-color: var(--muted-icon); }
    /* Past the CHAINPROOF_SPEND_ALERT_USD line: same pill, now loud on
       purpose. The one nav element allowed to compete for attention,
       because it is the one that costs money while being ignored. */
    a.spend.over { color: var(--spend-over-text); border-color: var(--spend-over-border); font-weight: 600; }
    /* One record, two jobs, shown as tabs of one thing (_record_header.html).
       The dock is the third entry and is not a page, so its door sits on
       the same strip. */
    /* An axis dropdown with the question it answers in front of it. Inline
       so a row of four still reads as one row, wrapping on a narrow screen
       rather than overflowing. */
    label.axis { font-size: .8rem; color: var(--muted); display: inline-flex;
                 align-items: center; gap: .3rem; margin-right: .5rem; }
    label.axis span { display: inline-block; }
    .actions { margin: .4rem 0; font-size: .9rem; }
    .actions a { margin-right: .8rem; }
    form.inline { display: inline; }
    button.small { padding: .15rem .6rem; font-size: .8rem; }
    .link-btn { background: none; border: none; padding: 0; margin: 0; color: var(--accent); font: inherit; cursor: pointer; }
    .link-btn:hover { text-decoration: underline; }
    .hint { font-size: .85rem; color: var(--muted); background: var(--subtle-bg); border-radius: 6px; padding: .5rem .8rem; }
    .helptext { font-size: .8rem; color: var(--muted); display: block; }
    .slot-group { border: 1px solid var(--border); border-radius: 6px; padding: .4rem .8rem .6rem; margin: .5rem 0; }
    .slot-group legend { font-weight: 600; padding: 0 .3rem; }
    .slot-option { display: block; font-size: .9rem; margin: .3rem 0; font-weight: normal; }
    .slot-option input { width: auto; margin-right: .4rem; }
    .slot-option.slot-done { color: var(--proven); }
    .quick-add-row { display: flex; align-items: center; gap: .4rem; flex-wrap: wrap; }
    .quick-add-row input[type=text] { width: 220px; display: inline-block; }
    /* The "already in this tree?" duplicate hint under a hand-typed name —
       amber, the app's "probable/attention" colour, and full-width so it sits
       under the row rather than fighting it. */
    /* Approved proposal #3: evidence grouped by person — compact rows,
       axes behind a per-fact disclosure. */
    .evidence-group .ev-who { margin-bottom: .25rem; margin-right: .6rem; }
    .evidence-group .dot { display: inline-block; width: .65rem; height: .65rem;
                           border-radius: 50%; margin-right: .25rem; }
    .mark-state { font-size: .8rem; margin-right: .6rem; color: var(--proven); }
    a.mark-state { color: var(--muted); text-decoration: underline; }
    .also-shows { font-size: .8rem; }
    .ev-row { display: flex; align-items: baseline; gap: .5rem; flex-wrap: wrap;
              padding: .3rem 0; border-top: 1px solid var(--border-light); }
    .ev-row:first-of-type { border-top: 0; }
    .ev-what { color: var(--muted); }
    .ev-axes { flex-basis: 100%; margin-left: .8rem; }
    .ev-axes summary { cursor: pointer; }
    .dup-slot { flex-basis: 100%; }
    .dup-hint { display: block; color: var(--probable); font-size: .82rem; margin: .1rem 0 .2rem; }
    .dup-hint a { color: var(--probable); text-decoration: underline; }
    .quick-add-row select { width: auto; display: inline-block; }
    .quick-add-row .errorlist { flex-basis: 100%; margin: 0; color: var(--conflicted); font-size: .8rem; }
    /* Unscoped, for every other form's errorlist — simple_form.html turned
       off native HTML5 validation (bug report #12) so a required field left
       empty gets a real, visible error here instead of a browser tooltip
       that may never be seen; without this rule that error rendered as
       plain black text, easy to mistake for one more label. */
    .errorlist { color: var(--conflicted); padding-left: 1.1rem; }
    /* h3 had no rule, so it fell back to the browser's 1.17em and rendered
       LARGER than the h2 above it — "Parents" outranking "What we know" on
       the densest page in the app. */
    h3 { font-size: .92rem; margin: 1rem 0 .3rem; }
    h2 { font-size: 1.05rem; margin: 1.4rem 0 .4rem; }
    /* Outbound search links (_search_links.html): each platform's logo
       sits beside its own h3 text, sized as an icon next to the label
       rather than a banner — this is a compact disclosure widget. */
    .platform-logo { height: 1.05rem; width: auto; vertical-align: middle; margin-right: .3rem; }
    body.wide { max-width: 1280px; }
    /* Pedigree tree */
    .tree-layout { display: flex; gap: 1.2rem; align-items: flex-start; }
    /* min-width:0 lets this flex item shrink below its content width, which
       is what allows .tree-scroll inside it to actually scroll. */
    .tree-main { flex: 1; min-width: 0; }
    .tree-scroll { overflow-x: auto; padding: .5rem 0 1rem; }
    /* The root .pnode is a plain block child of .tree-scroll (not a flex
       item), so by default it stretches to fill the scroll container's
       width. When its content (ancestors above a name) is naturally wider
       than that, align-items:center then bleeds the overflow symmetrically
       left AND right of that stretched box — but only the right-side bleed
       is inside [0, scrollWidth] and reachable by scrolling; the left side
       renders permanently off-screen. width:fit-content makes the root
       shrink to its true content width instead, so any overflow only ever
       grows rightward from x=0, which is always reachable. */
    .tree-scroll > .pnode { width: fit-content; }
    .pnode { display: flex; flex-direction: column; align-items: center; }
    /* The couple row aligns on its bottom edge — the boxes sit under
       ancestries of different depths, so stretching would pull a box up to
       meet a taller subtree rather than its partner. Uniform height comes
       from .pbox's min-height instead. */
    .parents { display: flex; gap: .8rem; align-items: flex-end; padding-bottom: .8rem; position: relative; }
    /* The connector drops from the COUPLE, not from the middle of the block.
       Those are the same point only when both parent subtrees are equally
       wide; --seam is measured and set per node by alignGenerations(). */
    /* A pedigree join, drawn in three parts: a stub down from each parent, a
       horizontal bar linking them, and one drop from the couple to the child.
       Before this there was only the drop, so a box floated under a gap and
       you had to infer which pair it belonged to — which is most of what makes
       a wide tree hard to read.

       --seam, --bar-left and --bar-right are measured per node by
       alignGenerations(): a couple's join sits at the midpoint of the two
       BOXES, which is only the midpoint of the block when both parents' own
       ancestries happen to be equally wide. */
    .parents::before { content: ""; position: absolute; bottom: .4rem;
      left: var(--bar-left, 50%); width: var(--bar-width, 0px); height: 2px;
      background: var(--border); }
    .parents::after { content: ""; position: absolute; left: var(--seam, 50%);
      bottom: 0; width: 2px; height: .4rem; background: var(--border); }
    /* The stub each parent drops to the bar. On the box, not the column,
       because alignGenerations shifts the box within its column. */
    .parents > .pnode > .pbox::after { content: ""; position: absolute;
      left: 50%; bottom: -.4rem; width: 2px; height: .4rem; background: var(--border); }
    /* Hug couples toward each other: a parent's box sits on the spouse-side
       edge of their own ancestor subtree, so partners stay adjacent. */
    .parents > .pnode:first-child { align-items: flex-end; }
    .parents > .pnode:last-child { align-items: flex-start; }
    .parents > .pnode:only-child { align-items: center; }
    /* Ancestors and children share one centring column, so the child row
       hangs under the FOCUS BOX and not under the middle of the page. */
    .tree-body { width: fit-content; display: flex; flex-direction: column; align-items: center; }
    /* Children hang BELOW the focus, mirroring .parents above it — same
       connector, drawn from the top instead of the bottom. */
    .children-row { display: flex; flex-direction: column; align-items: center; }
    /* stretch, not flex-start: siblings match the tallest in the row, so a
       child with dates and one without are still the same card. */
    .children { display: flex; gap: .8rem; align-items: stretch; padding-top: .8rem; position: relative; flex-wrap: wrap; justify-content: center; max-width: 100%; }
    .children::before { content: ""; position: absolute; left: 50%; top: 0; width: 2px; height: .7rem; background: var(--border); }
    .children-label { font-size: .75rem; padding-top: .8rem; }
    /* One control between the generations rather than a button per box: a
       big family is the case where you most want the tree back. */
    .gen-toggle { background: var(--subtle-bg); color: var(--muted); border: 1px solid var(--border);
      border-radius: 999px; padding: .1rem .7rem; font-size: .78rem; margin-top: .5rem; }
    .gen-toggle:hover { background: var(--border-light); }
    /* Tree-page toggle only. The record drawing (.record-family) reuses
       .children-row; if this attribute ever gets set outside tree.html,
       scope this rule or "hide children" will blank "What this record
       says" too. */
    [data-children-hidden] .children-row:not(.record-family .children-row) { display: none; }
    [data-children-hidden] .gen-toggle .when-shown { display: none; }
    .gen-toggle .when-hidden { display: none; }
    [data-children-hidden] .gen-toggle .when-hidden { display: inline; }
    .children-label + .children { padding-top: .2rem; }
    .children-label + .children::before { display: none; }
    .spouse-strip { font-size: .78rem; margin-top: .2rem; border-top: 1px dashed var(--border); padding-top: .2rem; }
    /* Each strip entry is a SEPARATE PERSON who happens to share the card —
       the tester's "they are in one rectangle" report, verified 2026-08-08:
       a childless or alternate marriage really is drawn inside the partner's
       box. The layout is deliberate (a box per spouse would double the
       tree's width for marriages with nothing hanging off them); the pill
       is what says "attached person, not a caption". */
    .spouse-strip > span { display: inline-block; border: 1px solid var(--border);
                           border-radius: 999px; padding: 0 .5rem;
                           margin: .12rem 0; background: var(--subtle-bg); }
    /* Every box the same size, name-bearing or not. They used to size to
       their contents, so a row read as a ragged line of different-shaped
       cards and an empty slot was visibly the runt — which made a gap in the
       tree look like a lesser thing rather than the next thing to fill in.
       Fixed width with wrapping, and a min-height so a two-line name doesn't
       stand taller than its siblings. */
    .pbox { position: relative; box-sizing: border-box; width: 11rem; min-height: 3.4rem;
      display: flex; flex-direction: column; justify-content: center;
      border: 1px solid var(--border); border-radius: 6px; padding: .3rem .5rem;
      background: var(--card-bg); text-align: center; font-size: .85rem;
      overflow-wrap: anywhere; }
    .pbox.unknown { color: var(--muted); border-style: dashed; }
    a.pbox.addslot { border-style: dashed; }
    /* RECORDED BUT NOT DRAWN — pointedly NOT the dashed addslot style. Dashed
       means "a position in the tree that is empty", and this makes the
       opposite claim: the position is full, and you are being shown that it
       exists. Solid, quiet, and smaller than a person box, so it reads as an
       edge of the drawing rather than as somebody in the family. */
    a.pbox.more-above { border-style: solid; border-color: var(--border);
      background: var(--subtle-bg); color: var(--muted); font-size: .75rem;
      padding: .15rem .4rem; white-space: nowrap; }
    a.pbox.more-above:hover { border-color: var(--muted); color: var(--ink);
      text-decoration: none; }
    /* align-self, because .pbox is a flex COLUMN: without it this tag
       stretches the full width of the box and "M" renders as a wide bordered
       bar across the middle of every person in the tree, which is what it has
       been doing. */
    .gender-tag { align-self: center; font-size: .7rem; border: 1px solid var(--border);
      border-radius: 3px; padding: 0 .25rem; margin-left: .2rem; }
    /* The tree carries no tool row any more — a box is its name plus the
       facts about that person, and the name is the only control. */
    .pbox.is-focus { font-weight: 700; box-shadow: 0 0 0 2px var(--focus-ring); }
    .pbox-note { font-size: .72rem; }
    .designation-line { margin: -.5rem 0 .8rem; font-style: italic; }
    .panel-choice { margin: .5rem 0; display: flex; flex-direction: column; gap: .25rem; align-items: flex-start; }
    .panel-choice button[disabled] { opacity: 1; color: var(--proven); border-color: var(--proven); cursor: default; }
    /* The panel is always open now (it follows the focus), so it has to earn
       its width every second rather than only when summoned. */
    .panel { width: 240px; flex-shrink: 0; border: 1px solid var(--border); border-radius: 8px; padding: .8rem 1rem; position: sticky; top: 1rem; background: var(--card-bg); }
    .panel h3 { margin: .1rem 0 .4rem; }
    .quick-edit { margin: .5rem 0; font-size: .85rem; }
    .quick-edit summary { cursor: pointer; }
    .quick-edit form { margin-top: .4rem; }
    .quick-edit p { margin: .3rem 0; }
    .quick-edit input, .quick-edit select, .quick-edit textarea { width: 100%; box-sizing: border-box; font-size: .85rem; }
    .clerk-read { border-left: 3px solid var(--accent-purple-2); padding: .3rem .7rem; margin: .6rem 0;
      background: var(--tint-purple-3); border-radius: 0 4px 4px 0; }
    .clerk-read p { margin: .25rem 0; }
    .set-aside { border-left: 3px solid var(--muted-icon); background: var(--subtle-bg);
      padding: .4rem .7rem; margin: .5rem 0; font-size: .85rem; }
    /* One card per person or couple the record names. */
    .person-card { border: 1px solid var(--border); border-radius: 8px; padding: .55rem .8rem;
      margin: .5rem 0; }
    .person-card-new { border-style: dashed; background: var(--tint-purple-3); }
    .person-card-head { font-size: .95rem; margin-bottom: .2rem; }
    .person-card .assist-fact { margin: .35rem 0; }
    /* The tree as it stands, beside the tree this record would leave —
       engine/logic/recordtree.py. Two columns rather than one annotated
       picture: the change IS the difference between them, which is what an
       eye does anyway without being handed a legend. */
    .tree-diff { border: 1px solid var(--border); border-radius: 8px; padding: .6rem .9rem;
      margin: .8rem 0; background: var(--tint-border-5); }
    .tree-diff > summary { cursor: pointer; }
    .tree-diff-summary { display: block; font-size: .85rem; color: var(--muted);
      margin-top: .15rem; }
    .tree-diff-note { font-size: .85rem; margin: .5rem 0 .7rem; }
    .tree-diff-columns { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; }
    /* A pedigree is WIDE — it doubles every generation — and this one sits in
       a half-width column. It scrolls inside its own box so the page body
       never scrolls sideways, which is the rule everywhere else too. */
    .pedigree-scroll { overflow-x: auto; padding-bottom: .3rem; }
    .tree-diff-split { border: 0; border-top: 1px dashed var(--border); margin: 1rem 0; }
    /* One row per answer, the button beside the sentence that says what it
       does — a structural change to somebody's family is not something to
       choose from a verb alone. */
    .settle-choice { display: flex; gap: .7rem; align-items: baseline;
      margin: .6rem 0; flex-wrap: wrap; }
    .settle-choice > span { flex: 1 1 22rem; font-size: .85rem; }
    /* The legend names its colours AND shows them — "green" and "amber" are
       words a colour-blind reader cannot check against the drawing. */
    .legend-new { color: var(--proven); font-weight: 600; }
    .legend-clash { color: var(--probable); font-weight: 600; }
    /* The third mark, alongside proposed-arriving and proposed-going. Amber,
       not red: this is "these two cannot both be true", not "this is being
       deleted", and the app never chooses a side for you. */
    .pbox.proposed-disputed { border: 2px dashed var(--probable); background: var(--tint-probable-3); }
    .spouse-strip .proposed-disputed { color: var(--probable); font-weight: 600; }
    @media (max-width: 640px) { .tree-diff-columns { grid-template-columns: 1fr; } }
    .tree-diff-head { font-size: .8rem; text-transform: uppercase; letter-spacing: .04em;
      color: var(--muted); margin: 0 0 .4rem; font-weight: 600; }
    /* Both columns list the SAME people in the SAME order, so the rows line
       up and a person missing on the left is visibly missing rather than
       silently shifting everything below them. */
    .tree-diff-row { border: 1px solid var(--border-light); border-radius: 6px; padding: .4rem .6rem;
      margin: 0 0 .4rem; font-size: .88rem; min-height: 2.4rem; }
    .tree-diff-absent { border-style: dashed; background: var(--tint-border-8); }
    .tree-diff-nobody { color: var(--muted-icon); font-size: .82rem; font-style: italic; }
    .tree-diff-new { border-color: var(--proven); background: var(--tint-proven-7); }
    .tree-diff-edge { font-size: .8rem; color: var(--muted); margin-top: .2rem; }
    .tree-diff-add { color: var(--proven); }
    /* Conflicted, not "wrong" — the app never picks a side. Same red the
       rubric already uses for a conflicted claim, so the colour means the
       same thing here as everywhere else in the app. */
    .tree-diff-clash { color: var(--conflicted); font-weight: 600; }
    .tree-diff-tag { display: inline-block; font-size: .7rem; padding: 0 .35rem;
      border-radius: 999px; background: var(--proven); color: var(--card-bg); margin-left: .25rem;
      vertical-align: .05rem; }
    .tree-diff-tag-clash { background: var(--conflicted); }
    .tree-diff-clashes { margin-top: .9rem; border-top: 1px solid var(--border-light); padding-top: .6rem; }
    .tree-diff-clashes h3 { font-size: .9rem; margin: 0 0 .3rem; }
    .tree-diff-clashes ul { margin: .2rem 0 .5rem; padding-left: 1.1rem; font-size: .85rem; }
    .more-slots { margin: .35rem 0 .1rem; }
    .more-slots summary { cursor: pointer; font-size: .8rem; }
    .fact-image { margin: .2rem 0 .5rem 4rem; }
    .fact-image summary { cursor: pointer; font-size: .78rem; }
    .fact-image img { max-width: 100%; max-height: 28rem; border: 1px solid var(--border);
      border-radius: 4px; margin-top: .3rem; display: block; }
    .fact-image .transcript-preview { margin-top: .3rem; }
    .family-detail > summary { cursor: pointer; margin: .6rem 0; }
    .clerk-reading { border: 1px solid var(--tint-purple-40); background: var(--tint-purple-3); border-radius: 8px;
      padding: .7rem 1rem; margin: .6rem 0; }
    .clerk-reading strong::after { content: ""; display: inline-block; width: .6rem;
      animation: clerk-dots 1.2s steps(4, end) infinite; }
    @keyframes clerk-dots { 0% { content: ""; } 25% { content: "."; }
      50% { content: ".."; } 75% { content: "..."; } }
    /* Ticking is a commitment, so it should look like one. */
    .tick-state { font-size: .75rem; margin-left: .3rem; white-space: nowrap; }
    .tick-state .when-ticked { display: none; color: var(--proven); font-weight: 600; }
    .assist-fact:has(input:checked) { background: var(--tint-proven-8); border-left-color: var(--proven); }
    .assist-fact:has(input:checked) .when-ticked { display: inline; }
    .assist-fact:has(input:checked) .when-unticked { display: none; }
    .assist-fact:has(input:disabled) .tick-state { display: none; }
    #tick-summary.linked { color: var(--proven); font-weight: 600; }
    .save-bar { position: sticky; bottom: 0; background: var(--card-bg); border-top: 1px solid var(--border);
      padding: .6rem 0; margin-top: 1rem; }
    .read-part > summary { cursor: pointer; }
    .evidence-locator { font-weight: 600; }
    .pdf-page { white-space: pre-wrap; font-size: .88rem; line-height: 1.5;
      border: 1px solid var(--border); border-radius: 8px; padding: 1rem 1.2rem;
      background: var(--card-bg); max-height: 42rem; overflow-y: auto; }
    .pdf-page::selection, .pdf-page ::selection { background: var(--tint-probable-14); }
    .page-nav { display: flex; gap: .8rem; align-items: center; margin-bottom: .5rem; }
    .model-pick { width: auto; display: inline-block; font-size: .78rem; padding: .1rem .3rem; }
    /* Two ancestries side by side: the couple, not the individual. */
    /* Two ancestries fanning up and OUTWARD from a couple standing together.
       Each column centres its own subtree by default, which pushed the two
       married people to opposite ends of their pedigrees. The fix is the
       SPOUSE'S DEPTH (see SPOUSE_PEDIGREE_DEPTH), not alignment: a column is
       fit-content and its box is positioned by alignGenerations, so
       align-items here has nothing to move. */
    .couple-pedigrees { display: flex; gap: .8rem; align-items: flex-end;
      justify-content: center; width: fit-content; margin: 0 auto;
      position: relative; padding-bottom: .8rem; }
    /* The marriage bar — same visual language as .parents::before one
       generation up (a horizontal bar between two people, each with a stub
       dropping to it), but for the couple at the CENTRE of the tree rather
       than a child's two parents. Needed as its own rule because when one
       side's ancestry is much shallower than the other's (SPOUSE_PEDIGREE_DEPTH
       vs PEDIGREE_DEPTH), the two boxes can land hundreds of pixels apart —
       proximity alone stops reading as "these two are married" once they're
       that far apart, which is exactly the case that prompted this. Gated on
       a second .tree-body actually being present: an unmarried focus is
       still one .couple-pedigrees, and undrawing the bar there (rather than
       collapsing it to a zero-width stub in the middle of nowhere) is the
       point of :has() here. --couple-bar-left/-width are measured per box by
       alignCoupleBar() in tree.html, the same technique alignGenerations()
       uses one generation up. */
    .couple-pedigrees:has(> .tree-body ~ .tree-body)::before { content: "";
      position: absolute; bottom: 0; left: var(--couple-bar-left, 0);
      width: var(--couple-bar-width, 0); height: 2px; background: var(--border); }
    .couple-pedigrees:has(> .tree-body ~ .tree-body) > .tree-body > .pnode > .pbox::after {
      content: ""; position: absolute; left: 50%; bottom: -.8rem;
      width: 2px; height: .8rem; background: var(--border); }

    .couple-label { margin-top: .35rem; font-weight: 600; }
    /* The children belong to the COUPLE, so they hang between the two
       ancestries rather than under whichever one was drawn first. */
    .couple-pedigrees + .tree-body { margin: 0 auto; }
    .actions a.chosen { font-weight: 700; text-decoration: underline; }
    /* Louder than .muted on purpose: this is the one place the page can
       invite an irreversible mistake. */
    /* The rubric still rates the claim on its strongest evidence; this says
       plainly that the evidence is not unanimous, and leaves the judgement
       where it belongs. */
    .story-tag.contradicted { background: var(--conflicted); color: var(--card-bg);
      border-radius: 999px; padding: 0 .5rem; font-size: .72rem; }
    .dupe-warn { font-size: .85rem; color: var(--conflicted); }
    .dupe-warn a { color: var(--conflicted); text-decoration: underline; }
    .dupe-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 1.2rem;
      margin: .5rem 0; font-size: .9rem; }
    /* The only hard-coded multi-column layout in this file; everything else
       degrades on its own. Two columns of a duplicate pair on a 375px screen
       leave about 165px each before card padding. */
    @media (max-width: 480px) { .dupe-pair { grid-template-columns: 1fr; gap: .3rem; } }
    /* Three destinations and a menu, not seven equals. */
    .nav-more { display: inline-block; }
    .nav-more summary { cursor: pointer; display: inline; list-style: none; padding: 0 .35rem; }
    .nav-more summary::-webkit-details-marker { display: none; }
    .nav-more summary:hover { background: var(--border-light); border-radius: 4px; }
    .nav-more[open] > span { margin-left: .3rem; }
    /* Reads as a label, not as punctuation. Sits beside the tree picker so
       "which tree am I in" and "start another" are one thought. */
    .new-tree { white-space: nowrap; font-size: .85rem; margin-left: .2rem; }
    .count { background: var(--conflicted); color: var(--card-bg); border-radius: 999px;
      padding: 0 .4rem; font-size: .75rem; font-weight: 600; }
    /* Source annotation */
    /* The gated add-someone flow's step-two shortlist: the obvious
       relatives (and "+ Someone new") as one-click CHIPS, best guess
       first and emphasized (task B2, item 2 — the approved mockup's
       "likely people render as one-tap chips"). */
    .tag-chips { display: flex; flex-wrap: wrap; gap: .4rem; margin: .3rem 0; }
    .tag-chip { border: 1px solid var(--border); background: var(--card-bg); color: var(--ink);
      border-radius: 999px; padding: .28rem .75rem; font-size: .82rem; cursor: pointer; }
    .tag-chip.hot { border-color: var(--accent); color: var(--accent); font-weight: 600; }
    .tag-chip.is-picked, .likely-pick.is-picked { border-color: var(--accent); color: var(--accent);
      background: var(--subtle-bg); }
    .who-reach-label { margin: .5rem 0 .2rem; }
    #who-panel { border-left: 3px solid var(--border); padding-left: .8rem;
      margin: .5rem 0; }
    /* THE ANCHORED CARD (task B2, item 1 — the approved mockup's
       "the moment the box is drawn, a small card opens anchored to it").
       Progressive enhancement ONLY: the drawing script adds this class and
       the inline left/top/bottom that position it; without the script
       #who-panel keeps its plain in-flow spot above (the no-JS
       fallback, item 5). */
    #who-panel.ann-anchored { position: fixed; z-index: 60; border-left: none;
      width: min(320px, calc(100vw - 24px)); max-width: 320px;
      padding: .75rem .9rem; background: var(--card-bg); border: 1px solid var(--border);
      border-radius: 8px; box-shadow: 0 6px 22px rgba(0,0,0,.2); }
    #who-panel.ann-anchored::before { content: ""; position: absolute; left: 20px;
      width: 12px; height: 12px; background: var(--card-bg);
      border-left: 1px solid var(--border); border-top: 1px solid var(--border); }
    #who-panel.ann-anchored.arrow-top::before { top: -7px; transform: rotate(45deg); }
    #who-panel.ann-anchored.arrow-bottom::before { bottom: -7px; transform: rotate(225deg); }
    /* MOBILE dock-to-bottom rules for #who-panel.ann-anchored live in the
       ONE trailing "MOBILE" breakpoint block near the end of this file
       (tests_mobile.py's own invariant: exactly one such block, last in
       the file, so it wins cascade ties) — not a second one here. */
    /* The highlight pulse on the drawn box while the card is docked away
       from it on mobile (item 4), so the link between box and card stays
       visible even though the card no longer sits beside the box.
       color-mix against --accent, not a hardcoded sepia rgba(), so all
       four themes render the pulse in their own accent (same reasoning
       as the rest of this file's theme variables). Animated only under
       no-preference, same convention as .is-fresh/.is-flash above — a
       steady equivalent below keeps the box↔card link for reduced-motion
       users without any motion carrying it. */
    @media (prefers-reduced-motion: no-preference) {
      .draft-box-pulse { animation: ann-box-pulse 1.1s ease-in-out infinite; }
      @keyframes ann-box-pulse {
        0%, 100% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
        50% { box-shadow: 0 0 0 7px color-mix(in srgb, var(--accent) 0%, transparent); }
      }
    }
    @media (prefers-reduced-motion: reduce) {
      .draft-box-pulse { box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent); }
    }
    .ann-esc-hint { margin-left: .5rem; }
    .annotate-wrap { position: relative; display: inline-block; max-width: 100%; }
    .annotate-wrap img { max-width: 100%; display: block; }
    .annotate-wrap.armed { cursor: crosshair; }
    .ann-box { position: absolute; border: 2px solid; border-radius: 2px; box-sizing: border-box; }
    .ann-box .ann-del { position: absolute; top: -.7rem; right: -.7rem; display: none; }
    .ann-box:hover .ann-del { display: block; }
    .swatch { display: inline-block; width: .8rem; height: .8rem; border-radius: 3px; vertical-align: middle; margin-right: .3rem; }
    /* S8 crop windows (briefs/READER-GEOMETRY-SPIKE.md) — an overflow-
       hidden box whose background-image is the SAME full page image,
       sized/positioned (engine.lines.background_css) so only the tapped
       anchor or agreed-on line strip shows through. No crop-serving
       endpoint; the browser does the cropping. Width/height are set INLINE
       per crop (views_reader._apply_crop/lines.container_size) so the box's
       own aspect ratio matches the crop's TRUE pixel aspect — Opus review
       round 2: a fixed one-size-fits-all box here stretched a typical
       tapped anchor ~1.9x horizontally, unacceptable distortion for a tool
       whose whole job is showing a letterform's actual shape. This rule
       supplies everything that ISN'T size. */
    .reader-crop { margin: .3rem 0; border: 1px solid var(--border);
      border-radius: 3px; background-repeat: no-repeat; }
    .reader-crop-small { display: inline-block; vertical-align: middle; }
    /* THE NO-JS TAP-TO-ANCHOR CSS INVARIANT (Opus review round 2): the
       no-JS fallback (engine/templates/engine/reader_anchor.html's
       `<input type="image" id="anchor-tap">`) converts a click's PIXEL
       coordinates to percent by dividing by the element's DECLARED
       width/height attributes (views_reader._anchor_target_size) — a
       calculation that is only correct if the browser renders the element
       at EXACTLY that declared size. Nothing today scales it down, but the
       page body's own max-width (860px, well under the 900px this can
       render at) already overflows, and the ordinary house pattern
       elsewhere on this page (`.annotate-wrap img { max-width: 100% }`)
       would — if ever applied here too — silently desync every tap this
       fallback stores (measured: an 80% tap landing at 34.67% once the
       element was scaled down). `max-width: none` pins the element at its
       declared size regardless of any ancestor or future site-wide image
       rule; do not remove this without re-deriving the pixel math in
       reader_anchor.html's own click handler and views_reader._parse_tap
       to read the element's ACTUAL rendered box instead (getBoundingClientRect
       already does this for the JS-enhanced anchor_x/anchor_y path — only
       the no-JS tap.x/tap.y path depends on this rule). */
    #anchor-tap { max-width: none; }
    /* AI capture assist */
    .assist-panel summary { cursor: pointer; }
    .assist-result { background: var(--subtle-bg); }
    .transcript-preview { border: 1px solid var(--border); background: var(--card-bg); padding: .4rem .6rem; white-space: pre-wrap; font-size: .85rem; max-height: 14rem; overflow-y: auto; }
    .assist-fact { border-left: 3px solid var(--border); padding: .25rem .6rem; margin: .4rem 0; }
    .assist-fact.assist-flagged { border-left-color: var(--conflicted); }
    /* A ruling you already made — engine/logic/overrules.py. Quiet on
       purpose: it is settled, it is not asking for anything, and it must not
       compete for attention with the suggestions that still are. */
    .assist-overruled { border-left-style: dashed; opacity: .75; }
    .overrule-tool { margin-top: .25rem; display: flex; gap: .4rem;
      align-items: center; flex-wrap: wrap; }
    .overrule-note { font-size: .78rem; padding: .1rem .3rem; width: 15rem;
      max-width: 100%; }
    /* A button that reads as a link, for an action secondary to the text it
       sits beside. Overruling a suggestion is a judgement, not a primary
       control; a real button next to every fact would read as the thing the
       page wants you to press. */
    .linklike { background: none; border: 0; padding: 0; color: var(--accent);
      font: inherit; font-size: .78rem; cursor: pointer; text-decoration: underline; }
    .linklike:hover { text-decoration: none; }
    .agree-row { border-left: 3px solid var(--border); padding: .3rem .6rem; margin: .4rem 0;
      font-size: .88rem; }
    .agree-disagree { border-left-color: var(--conflicted); }
    /* Amber, deliberately not red. "Nearly agrees" is a different finding
       from a contradiction, not a weaker one, and colouring it the same
       would undo the whole reason for having three states. */
    .agree-close { border-left-color: var(--probable); }
    .agree-agree { border-left-color: var(--proven); }
    .agree-tag-close { background: var(--probable); }
    .assist-flag { color: var(--conflicted); font-size: .85rem; }
    .assist-slot { width: auto; margin-right: .4rem; }
    .ai-note { font-size: .8rem; color: var(--probable); }
    /* The open-question ledger's candidate table: status on the left
       border (the confidence-bar convention), ruled-out struck but
       READABLE — the reasoning on a dead candidate is what stops it being
       re-litigated, so it stays legible, never hidden. */
    .candidate-table td { vertical-align: top; }
    tr.candidate-leading td:first-child { border-left: 3px solid var(--proven); }
    tr.candidate-contending td:first-child { border-left: 3px solid var(--probable); }
    tr.candidate-ruled_out td:first-child { border-left: 3px solid var(--border-light); }
    .ruled-out-label { text-decoration: line-through; }
    /* Rare and destructive actions live behind a ⋯ disclosure so the common
       ones ("+ child", "+ Record") aren't competing with "delete". */
    .row-more { display: inline-block; margin-left: .3rem; }
    .row-more summary { cursor: pointer; list-style: none; padding: 0 .3rem; border-radius: 4px; }
    .row-more summary::-webkit-details-marker { display: none; }
    .row-more summary:hover { background: var(--border-light); }
    .row-more[open] { display: block; margin: .3rem 0 0; }
    /* One chronological account. The year gutter is what makes gaps legible
       — a decade with nothing in it is the thing you want to notice — and
       the left bar carries confidence so the ORDER can stay chronological
       instead of being spent on status. */
    .story { margin: .6rem 0 1rem; max-width: 62rem; }
    .story-line { display: flex; align-items: baseline; gap: .6rem; font-size: .9rem;
      padding: .28rem .2rem .28rem .55rem; border-bottom: 1px solid var(--border-light);
      border-left: 3px solid transparent; }
    .story-line:last-child { border-bottom: none; }
    .story-year { flex: 0 0 3.4rem; color: var(--muted); font-variant-numeric: tabular-nums; font-size: .82rem; }
    .story-what { flex: 1 1 auto; }
    /* An assumption is a caveat, not evidence — bordered rather than shaded
       like the facts it sits beside. Settled ones fade back. */
    /* Chat docks beside the tree rather than living on its own page —
       losing your place in the tree to ask a question was the whole problem.
       Fixed and full-height: as a card in the flex row it floated in the
       middle of the page with the divider stopping partway down, which read
       as a widget rather than as the other half of the screen. Out of flow,
       so the page reserves its width with padding instead. */
    .chat-dock { position: fixed; top: 0; right: 0; bottom: 0; z-index: 20;
      display: flex; align-items: stretch; background: var(--card-bg);
      width: var(--dock-width, 380px); }
    body.has-dock { max-width: none; margin: 0; padding: 2rem 1.5rem;
      padding-right: calc(var(--dock-width, 380px) + 1.5rem); }
    /* CLOSED, the dock is a 2.4rem rail, not a panel — so the page has no
       reason to give up its reading column for it. Without this rule
       body.has-dock's own max-width:none/margin:0 wins over BOTH the plain
       860px default and body.wide's 1280px regardless of dock state, so
       nine list/workspace pages that only ever show the closed rail
       (guide, standing_instructions, workspace_edit, collections,
       chain_detail, research_plan, tidy, noise_clusters, source_list)
       rendered full-bleed prose the moment they gained has-dock — a much
       bigger visual change than "an edge tab appeared". Restoring the
       ordinary column here makes CLOSED visually identical to a page
       with no dock at all; body.wide (1280px) still wins the cascade on a
       wide page by ordinary specificity, so tidy keeps its wider table
       layout and everything else keeps its 860px prose width. OPEN is
       unaffected — that state has always needed the full-bleed padding
       to make room for the panel, on every page that had a dock before
       this slice. */
    body.has-dock.dock-closed { --dock-width: 2.4rem; max-width: 860px; margin: 2rem 0; }
    body.has-dock.dock-closed.wide { max-width: 1280px; }
    /* The record page's three columns (2026-08-13): scan left, transcript
       + who-this-names middle, the dock rail is the right column. */
    .record-cols { display: flex; gap: 22px; align-items: flex-start; }
    .col-scan { flex: 0 1 36%; min-width: 280px; }
    .col-scan .annotate-wrap img { max-width: 100%; }
    .col-main { flex: 1 1 56%; min-width: 340px; }
    .col-main h2:first-child { margin-top: 0; }
    @media (max-width: 900px) { .record-cols { flex-direction: column; }
      .col-scan, .col-main { flex: 1 1 auto; min-width: 0; } }
    .pending-row { background: rgba(154,103,0,.06); border-radius: 6px;
      padding: .2rem .45rem; margin: .2rem -.45rem; }
    .att { font-size: .76rem; font-weight: 600; border-radius: 999px;
      padding: .05em .55em; }
    .att.pend { color: var(--att-pending); background: var(--att-pending-bg); }
    .att.ok { color: var(--att-ok); background: var(--att-ok-bg); }
    .att.hold { color: var(--att-hold); background: var(--att-hold-bg); }
    .file-btn { border: 1px solid var(--probable); color: var(--probable); background: none;
      border-radius: 6px; padding: .1em .6em; cursor: pointer; }
    .file-all { margin-left: auto; }
    .clerk-done { color: var(--proven); font-size: .88rem; margin: .5rem 0; }
    .clerk-done .link-btn { color: var(--muted); }
    .scan-full-btn { margin-bottom: .3rem; }
    .annotate-wrap:fullscreen { background: var(--ink); display: flex;
      align-items: center; justify-content: center; }
    .annotate-wrap:fullscreen img { max-height: 100vh; max-width: 100vw;
      width: auto; object-fit: contain; }
    #dock-optin { padding: .6rem .8rem; border-bottom: 1px solid var(--border); }
    #dock-optin .clerk-optin-form { margin: 0; }
    /* Timeline | Records tabs in the details panel (2026-08-13). */
    .d-tabs { display: flex; gap: 4px; margin: .5rem 0 .3rem; }
    .dt { font-size: .75rem; border: 1px solid var(--border); background: var(--subtle-bg);
      color: var(--ink); border-radius: 6px; padding: .15em .7em; cursor: pointer; }
    .dt.active { background: var(--accent); border-color: var(--accent); color: var(--card-bg);
      font-weight: 600; }
    /* The ⋯ actions menu on the details head (north-star round 2). */
    /* Household-shape hypothesis boxes (briefs/CENSUS-KINSHIP.md): dashed,
       visibly distinct from a clerk-read edge, wearing their own doubt. The
       inline person-colour border-top stays solid — identity and hypothesis
       compose rather than fight, same rule as is-fresh. */
    .pbox.kin-hypothesis { border-style: dashed; }
    .kin-note { color: var(--probable); font-size: .68rem; font-weight: 600; }
    /* A clerk-written kinship assumption: a dashed chip ON the subject's
       box — the dashes carry the doubt, the box stays the person's own.
       Distinct on purpose from the extra_parents "record also names" box,
       which states record testimony, not an assumption. */
    .assumed-chip { display: inline-block; border: 1px dashed var(--probable);
      color: var(--probable); border-radius: 6px; padding: .05em .45em;
      margin-top: .15rem; font-size: .68rem; font-weight: 600; }
    /* PORT-STATUS CHIP (PEDIGREE-PORT.md section (b)): "in your tree" vs
       "new" on a record-only box — same quiet chip size as .assumed-chip,
       a SOLID border (not dashed — this states a fact the panel already
       knows, not a doubt) so the two chip meanings never look alike. */
    .port-status-chip { display: inline-block; border: 1px solid var(--border);
      border-radius: 6px; padding: .05em .45em; margin-top: .15rem;
      font-size: .68rem; font-weight: 600; }
    .port-status-chip.port-status-in_tree { border-color: var(--proven); color: var(--proven); }
    .port-status-chip.port-status-new { border-color: var(--muted); color: var(--muted); }
    /* THE "NAMED BUT NOT PLACED" STRIP (PEDIGREE-PORT.md section (b)): a
       quiet line below the record's own drawing, visible to a viewer —
       .muted.small already carries the right weight, this only adds the
       breathing room a paragraph under a drawing needs. */
    .record-floaters-strip { margin-top: .5rem; }
    .detail-more { margin-left: auto; position: relative; }
    .detail-more summary { font-weight: 700; color: var(--muted); }
    .detail-more .more-menu { position: absolute; right: 0; top: 1.5rem;
      background: var(--card-bg); border: 1px solid var(--border); border-radius: 8px;
      padding: .35rem 0; min-width: 200px; z-index: 1000;
      box-shadow: 0 6px 18px rgba(0,0,0,.14); display: flex;
      flex-direction: column; }
    /* The open menu must beat every neighbouring stacking context — the
       founder's screenshot had it BEHIND the records list, because a menu's
       z-index only competes inside its own sticky head's context (z-index 2).
       While open, the head itself rises too. */
    .detail-head:has(.detail-more[open]), .person-head:has(.detail-more[open]) {
      z-index: 1000; }
    /* Slice-2 review nit: at a mid drag height #dock-details is a real
       overflow-y:auto scroller, and position:absolute is clipped by its
       nearest scrolling ancestor no matter how high its z-index goes — the
       fix above solves stacking, not clipping. .menu-portalled is set by
       the toggle handler below the instant the menu opens: it switches to
       position:fixed with coordinates computed from the disclosure
       element's own bounding rect (that of the row-more toggle; roughly the
       summary's, since .more-menu is absolutely positioned and out of
       flow), which escapes the scroll clip at every panel size (collapsed,
       mid-drag, or full). */
    .detail-more .more-menu.menu-portalled { position: fixed; }
    .detail-more .more-menu a { padding: .3rem .9rem; font-size: .85rem;
      color: var(--ink); }
    .detail-more .more-menu a:hover { background: var(--subtle-bg);
      text-decoration: none; }
    .detail-more .more-menu a.danger { color: var(--conflicted); }
    .detail-head { display: flex; align-items: baseline; gap: .45rem;
      flex-wrap: wrap; }
    /* The details / Assistant split (north-star slice 2). The rail's right
       side is a column: details on top, the shared edge, the chat below.
       --dock-details-h is set by the drag script and persisted; at the
       minimum the details collapse to the head line. */
    .dock-col { flex: 1; min-width: 0; display: flex; flex-direction: column; }
    #dock-details { flex: 0 0 auto; height: var(--dock-details-h, 38%);
      overflow-y: auto; border-bottom: 1px solid var(--border);
      /* No elastic bounce: macOS rubber-banding drives scrollTop negative
         and a sticky top:0 head travels with it — the founder saw the
         name and colour bar "going past" on overscroll (2026-08-14).
         Rigid means rigid. */
      overscroll-behavior: none; }
    #dock-details .detail-divider { display: none; }
    #dock-details .detail-box { border-top-width: 3px; margin: 0;
      padding: .6rem .8rem; }
    /* The head stays pinned while the panel scrolls (the founder,
       2026-08-13) — TRULY pinned: zero travel, no response to scroll
       motion. The box's own top padding moves onto the head (full-bleed
       over the side padding), so the head starts flush at the scroll
       container's top and sticky top:0 never lets it move. */
    /* The person-colour bar rides the PINNED head (border-top-color:
       inherit picks up the box's inline colour), and the box's own bar is
       suppressed — it was the 3px of travel left in the scroll. */
    #dock-details .detail-box { padding-top: 0;
      border-top-width: 0 !important; }
    #dock-details .detail-head { position: sticky; top: 0;
      background: var(--card-bg); z-index: 2; margin: 0 -.8rem;
      padding: .5rem .8rem .35rem; border-bottom: 1px solid var(--border-light);
      border-top: 3px solid; border-top-color: inherit; }
    #dock-splitter { flex: 0 0 5px; cursor: row-resize;
      background: transparent; margin-top: -3px; z-index: 2; }
    #dock-splitter:hover { background: var(--border); }
    .dock-col.details-min #dock-details { height: auto; }
    .dock-col.details-min #dock-details .detail-box > *:not(.detail-head) {
      display: none; }
    .d-recs { margin: .5rem 0 .2rem; }
    .d-recs-head { text-transform: uppercase; letter-spacing: .05em;
      font-size: .72rem; }
    .d-rec { margin: .25rem 0; font-size: .85rem; display: flex;
      gap: .4rem; align-items: baseline; }
    [data-dock-collapsed] .dock-body { display: none; }
    /* FRESHNESS. An outline and a marker, never a fill recolour: every person
       box already carries that person's own colour, and the whole point of
       that system is that the colour MEANS someone. Repainting a box to say
       "recently changed" would overwrite an identity with a timestamp, and
       the two would fight on exactly the boxes the researcher is looking at.
       An outline sits outside the colour and composes with it. */
    .is-fresh { position: relative; outline: 2px solid var(--proven);
      outline-offset: 2px; border-radius: 4px; }
    .is-fresh::after { content: "new"; position: absolute; top: -.55rem;
      right: -.35rem; background: var(--proven); color: var(--card-bg); font-size: .6rem;
      font-weight: 600; letter-spacing: .02em; padding: .05rem .3rem;
      border-radius: 999px; }
    @media (prefers-reduced-motion: no-preference) {
      .is-fresh { animation: freshin .5s ease-out; }
      @keyframes freshin { from { outline-color: transparent; } }
    }
    /* ARRIVED FROM A CHAT CHIP. "Show X in the tree" lands here with ?flash=,
       and the box says so by pulsing the SAME outline the freshness accent
       uses — the founder asked for the chip-to-box link to be shown "without
       using more colors", so this adds motion, not a hue. A pointer rather
       than a state: it says "this is the one you clicked" and leaves nothing
       behind. Reduced-motion users still get the steady outline, so the box
       is identifiable without the animation. */
    .is-flash { border-radius: 4px; }
    @media (prefers-reduced-motion: no-preference) {
      /* The outline lives ONLY in the keyframes, so the pulse genuinely
         leaves nothing behind. It used to be a static rule with the
         animation layered on top: the animation stopped after two
         iterations and the ring stayed — a steady green outline
         indistinguishable from the freshness accent, for the life of the
         page. A pointer that never stops pointing is just clutter. */
      .is-flash { animation: flashpulse 1s ease-out 2; }
      @keyframes flashpulse {
        0%, 100% { outline: 2px solid transparent;      outline-offset: 2px; }
        50%      { outline: 2px solid var(--proven);    outline-offset: 7px; }
      }
    }
    @media (prefers-reduced-motion: reduce) {
      /* No animation to carry it, so this is the one case that needs a
         steady ring — cleared on the next exchange by syncFreshMarks. */
      .is-flash { outline: 2px solid var(--proven); outline-offset: 2px; }
    }
    .chat-dock.is-rail { width: 2.4rem; }
    .dock-rail { display: flex; align-items: center; justify-content: center; flex: 1;
      border-left: 1px solid var(--border); background: var(--subtle-bg); color: var(--muted);
      text-decoration: none; font-size: .8rem; }
    .dock-rail:hover { background: var(--border-light); }
    .dock-rail span { writing-mode: vertical-rl; }
    .dock-grip { width: 10px; cursor: col-resize; flex-shrink: 0; background: var(--subtle-bg);
      border-left: 1px solid var(--border); border-right: 1px solid var(--border-light); }
    .dock-grip:hover { background: var(--border-light); }
    .dock-body { flex: 1; min-width: 0; display: flex; flex-direction: column;
      background: var(--card-bg); overflow: hidden; }
    .dock-head { display: flex; gap: .5rem; align-items: center; padding: .8rem .6rem .5rem;
      border-bottom: 1px solid var(--border-light); font-size: .85rem; }
    .dock-head select { flex: 1; min-width: 0; font-size: .8rem; padding: .15rem .3rem; }
    .dock-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis;
      white-space: nowrap; font-weight: 600; }
    .dock-tabs { flex: 1; min-width: 0; display: flex; gap: .15rem; }
    .dock-tab { padding: .2rem .5rem; border-radius: 5px 5px 0 0; color: var(--muted);
      text-decoration: none; white-space: nowrap; font-size: .8rem; }
    .dock-tab:hover { background: var(--border-light); }
    /* The active tab reads as the surface you're on, not as a pressed button —
       the panel below it is the same white. */
    .dock-tab.is-active { background: var(--card-bg); color: var(--ink); font-weight: 600;
      box-shadow: inset 0 -2px 0 var(--accent, var(--accent-purple-2)); }
    .dock-scope { font-size: .78rem; color: var(--muted); padding: .25rem .6rem; background: var(--subtle-bg); }
    /* The thread is the column that flexes; the head stays put and the
       composer stays on the bottom edge. */
    .dock-thread { flex: 1; min-height: 0; display: flex; flex-direction: column; }
    .dock-messages { flex: 1; overflow-y: auto; padding: .6rem; min-height: 6rem;
      overscroll-behavior: none; }
    .turn + .turn { margin-top: .7rem; }
    .turn-who { font-size: .72rem; text-transform: uppercase; letter-spacing: .03em; }
    /* A long unbroken URL must WRAP, not widen the dock (the founder,
       2026-08-13: a pasted link "expanded the chat window which now has to
       scroll"). anywhere over break-word because a URL has no break points. */
    .turn-text { font-size: .88rem; overflow-wrap: anywhere; }
    .turn-thinking { margin: .2rem 0 .35rem; }
    .turn-thinking summary { cursor: pointer; font-size: .75rem; }
    .thinking-text { font-size: .8rem; color: var(--muted); border-left: 2px solid var(--tint-purple-40);
      padding: .3rem .55rem; margin-top: .25rem; background: var(--tint-purple-3);
      max-height: 22rem; overflow-y: auto; white-space: pre-wrap; }
    .write { font-size: .8rem; background: var(--subtle-bg); border-left: 3px solid var(--border);
      padding: .25rem .5rem; margin: .3rem 0; border-radius: 0 4px 4px 0; }
    .write.pending { border-left-color: var(--probable); }
    .write.refused { border-left-color: var(--conflicted); }
    .write.applied { border-left-color: var(--proven); }
    /* G2 (briefs/SESSIONS.md 2026-09-01): the pending write's own drawn
       before/after, sized for the dock rather than the record page's
       tree-diff (.tree-diff-columns assumes a half-width desktop column
       and stacks under 640px). ONE horizontally-scrolling unit here, not
       two independently-scrolling panes and not a mobile stack — a
       fragment is 2-6 people, and the side-by-side compare is the point
       even at phone width, so it scrolls as a pair rather than losing the
       "beside each other" layout. */
    .chat-fragments { margin: .4rem 0; }
    .chat-fragments-scroll { display: flex; gap: .8rem; overflow-x: auto; padding-bottom: .3rem; }
    .chat-fragment-col { flex: 0 0 auto; min-width: 11rem; max-width: 15rem; }
    .chat-fragment-head { font-size: .7rem; text-transform: uppercase; letter-spacing: .04em;
      color: var(--muted); margin: 0 0 .3rem; font-weight: 600; }
    /* A chip that carries its text opens in place. The summary keeps the
       exact look of a plain chip, so nothing about the thread changes until
       you ask; the ▸ is the only tell that there is something to read. */
    details.write-text > summary { list-style: none; cursor: pointer; }
    details.write-text > summary::-webkit-details-marker { display: none; }
    details.write-text > summary::after { content: " ▸"; color: var(--muted-icon); }
    details.write-text[open] > summary::after { content: " ▾"; }
    .write-body { margin-top: .35rem; padding: .35rem .5rem; background: var(--card-bg);
      border: 1px solid var(--border); border-radius: 4px; color: var(--ink); }
    /* htmx toggles .htmx-request on the indicator for the life of the call. */
    .htmx-indicator { display: none; }
    .htmx-indicator.htmx-request { display: block; }
    .dock-composer { border-top: 1px solid var(--border-light); padding: .5rem; }
    /* The arrival notice for a pasted list. Coloured like a proven fact
       rather than a warning: nothing has gone wrong, something has landed. */
    .triage-landed { margin: 0 0 .35rem; padding: .3rem .45rem; font-size: .8rem;
      border-left: 3px solid var(--proven); background: var(--tint-proven-6); }
    .dock-composer textarea { font-size: .85rem; margin-bottom: .3rem; }
    /* The focus person's detail, under them in the tree. The side panel it
       replaces gave up its slot to the dock. */
    /* A boundary you can see. The tree is a diagram; this is prose about one
       person. Same background as the page behind the tree made them one thing. */
    .detail-divider { border-top: 1px solid var(--border); margin: 1.6rem 0 0; }
    .detail-box { border: 1px solid var(--border); border-radius: 8px; background: var(--card-bg);
      padding: .9rem 1.1rem; margin: -1px auto 0; max-width: 40rem;
      text-align: left; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
    .detail-kicker { display: block; font-size: .7rem; text-transform: uppercase;
      letter-spacing: .06em; margin-bottom: .1rem; }
    .detail-head { font-size: 1rem; margin-bottom: .2rem; }
    .detail-story { margin: .4rem 0; }
    .detail-story .story-line { padding: .18rem .2rem .18rem .5rem; font-size: .85rem; }
    .chat { margin: 1rem 0; }
    .turn { margin: 0 0 1rem; padding: .1rem 0 .1rem .7rem; border-left: 3px solid var(--border); }
    .turn.user { border-left-color: var(--accent); }
    .turn-who { font-size: .75rem; text-transform: uppercase; letter-spacing: .04em; }
    .turn-text { white-space: pre-wrap; }
    /* An edit made by a conversation has to LOOK like an edit. */
    .write { font-size: .85rem; margin: .35rem 0 0; padding: .2rem .5rem; border-radius: 4px; background: var(--subtle-bg); }
    .write.pending { background: var(--tint-probable-8); border: 1px solid var(--tint-probable-55); }
    .write.refused { background: var(--tint-conflicted-6); color: var(--tint-conflicted-strong); }
    .write.rejected { background: none; }
    .projection { background: var(--subtle-bg); border: 1px solid var(--border); border-radius: 6px;
      padding: .8rem; font-size: .78rem; line-height: 1.45; overflow-x: auto; white-space: pre; }
    .assumption-row { border-left: 3px solid var(--weak); }
    .assumption-row.settled { border-left-color: var(--proven); opacity: .7; }
    .assumption-row.withdrawn { border-left-color: var(--tint-muted-30); opacity: .55; }
    .story-tag { font-size: .72rem; background: var(--tint-weak-8); color: var(--weak); border-radius: 3px; padding: 0 .3rem; }
    .story-sources { color: var(--muted); font-size: .82rem; }
    .story-sources::before { content: " ("; }
    .story-sources::after { content: ")"; }
    .story-sources a { color: var(--muted); }
    /* WHO ASSERTED THIS. Deliberately quieter than the source it sits beside
       (which answers the more important question) — it appears only on a
       shared tree, where "who" is a real question, so it must inform without
       crowding a row that is already dense. */
    .by { font-size: .78rem; color: var(--muted-icon); margin-left: .3rem;
          white-space: nowrap; }
    .story-sources a.needs { color: var(--probable); border-bottom: 1px dashed var(--tint-probable-55); }
    .story-line.proven { border-left-color: var(--proven); }
    .story-line.probable { border-left-color: var(--probable); }
    .story-line.weak { border-left-color: var(--weak); }
    .story-line.undocumented { border-left-color: var(--undocumented); }
    .story-line.conflicted { border-left-color: var(--conflicted); background: var(--tint-conflicted-3); }
    /* Per-fact tools: present, but quiet until you're looking at the row. */
    .fact-row .row-tools { opacity: 0; transition: opacity .1s; }
    .fact-row:hover .row-tools, .fact-row:focus-within .row-tools { opacity: 1; }
    @media (hover: none) { .fact-row .row-tools { opacity: 1; } }
    /* Inbox drop target */
    .dropzone { border: 2px dashed var(--tint-muted-38); border-radius: 8px; padding: 1rem 1.2rem; background: var(--subtle-bg); }
    .dropzone.hot { border-color: var(--proven); background: var(--tint-proven-8); }
    .filepick { color: var(--accent); cursor: pointer; text-decoration: underline; }
    .filepick input { display: none; }
    #drop-overlay { position: fixed; inset: 0; z-index: 999; display: none; align-items: center; justify-content: center;
      background: color-mix(in srgb, var(--ink) 55%, transparent); color: var(--card-bg); font-size: 1.3rem; text-align: center; padding: 2rem; }
    #drop-overlay.show { display: flex; }
    #drop-toast { position: fixed; bottom: 1rem; right: 1rem; z-index: 1000; display: none;
      background: var(--ink); color: var(--card-bg); padding: .6rem 1rem; border-radius: 6px; font-size: .9rem; }
    #drop-toast.show { display: block; }
    /* Clear of the chat dock, which owns the right edge. --dock-width is set
       by the dock itself, so this follows it open or shut. */
    #paste-open { position: fixed; z-index: 1000; bottom: 1rem;
      right: calc(var(--dock-width, 0px) + 1rem);
      width: 2.6rem; height: 2.6rem; border-radius: 50%; padding: 0;
      font-size: 1.4rem; line-height: 1; box-shadow: 0 2px 8px rgba(0,0,0,.18); }
    #paste-source { position: fixed; z-index: 1001; bottom: 4.2rem;
      right: calc(var(--dock-width, 0px) + 1rem);
      width: min(30rem, calc(100vw - 2rem)); background: var(--card-bg);
      border: 1px solid var(--border); border-radius: 8px; padding: .7rem .8rem;
      box-shadow: 0 4px 16px rgba(0,0,0,.14); }
    #paste-source textarea { margin-top: .35rem; font-size: .9rem; }
    #paste-is-list { display: block; margin-top: .35rem; font-size: .85rem; }
    #paste-is-list input { margin-right: .3rem; }
    #paste-list-hint { margin-top: .3rem; font-size: .8rem; }
    /* Search-results triage. The verdict is the thing you scan, so it is a
       fixed-width tag on the left and the reason runs beside it — a list you
       read down the left edge of, which is how forty rows get triaged in the
       time it takes to read three. Colours are the rubric's existing three,
       reused rather than invented: this is the same "settled / open /
       excluded" distinction the confidence bars already draw. */
    /* THE FIELD FORM IS GONE — the frame comes out of prose now — so what is
       left is the toggle for a list typed straight into the box. */
    .triage-on { display: block; margin: .3rem 0 .25rem; font-size: .78rem; }
    .triage-on input { margin-right: .3rem; }
    /* THE ATTACHED LIST. A card ABOVE the composer that reads as carried
       rather than as typed — the founder's "an attachment that looks like it
       will send with your next chat". Outlined rather than filled so it does
       not compete with the thread behind it. */
    .triage-attachment { border: 1px solid var(--proven); border-radius: 6px;
      background: var(--tint-proven-6); padding: .35rem .45rem; margin: 0 0 .4rem; }
    .triage-attachment .triage-landed { border-left: none; background: none;
      padding: 0; margin: 0 0 .25rem; display: flex; gap: .3rem;
      align-items: baseline; flex-wrap: wrap; }
    /* The rows stay readable but never take the panel over: an attachment you
       cannot see is one you cannot check landed whole, and one that fills the
       dock has become the text-in-the-box this replaced. */
    .triage-attachment-rows { max-height: 7.5rem; overflow: auto; margin: 0;
      font-size: .72rem; line-height: 1.35; white-space: pre-wrap;
      word-break: break-word; background: var(--card-bg); border: 1px solid var(--tint-proven-18);
      border-radius: 4px; padding: .3rem .35rem; }
    .triage-landed-ask { margin: .3rem 0 0; font-size: .76rem; }
    .link-button { background: none; border: none; padding: 0; margin-left: auto;
      font-size: .72rem; color: var(--muted); text-decoration: underline;
      cursor: pointer; }
    .triage-verdicts { display: flex; flex-direction: column; gap: .18rem; }
    .verdict { display: flex; gap: .4rem; align-items: baseline;
      border-left: 3px solid var(--border); padding: .1rem .4rem; border-radius: 3px; }
    .verdict-tag { flex: 0 0 auto; font-size: .68rem; letter-spacing: .03em;
      font-weight: 600; text-transform: uppercase; white-space: nowrap; }
    .verdict-why { font-size: .85rem; }
    .verdict.pursue { border-left-color: var(--proven); background: var(--tint-proven-6); }
    .verdict.pursue .verdict-tag { color: var(--proven); }
    .verdict.unclear { border-left-color: var(--probable); background: var(--tint-probable-5); }
    .verdict.unclear .verdict-tag { color: var(--probable); }
    /* Ruled out is quiet, not alarming. A ruled-out row is a GOOD outcome —
       it is what makes a batch evidence of a reasonably exhaustive search —
       so it reads as settled and out of the way rather than as an error. */
    .verdict.ruled-out { border-left-color: var(--tint-muted-45); background: var(--subtle-bg); color: var(--muted); }
    .verdict.ruled-out .verdict-tag { color: var(--muted); }
    /* UNRESOLVED is not a weaker CANNOT TELL — it is the opposite condition
       (two real signals opposing, rather than too little signal) and a stable,
       legitimate end state. Its own colour so it does not read as a shade of
       "don't know". */
    .verdict.unresolved { border-left-color: var(--accent-purple); background: var(--tint-purple-4); }
    .verdict.unresolved .verdict-tag { color: var(--accent-purple-2); }
    .verdict-cat { flex: 0 0 auto; font-size: .66rem; padding: 0 .3rem;
      border: 1px solid var(--border); border-radius: 999px; color: var(--muted);
      white-space: nowrap; cursor: help; }
    .verdict-reopens { flex: 0 0 auto; font-size: .66rem; padding: 0 .3rem;
      border-radius: 999px; background: var(--tint-probable-16); color: var(--tint-probable-strong);
      white-space: nowrap; cursor: help; }
    .verdict-prose { font-size: .85rem; margin: .25rem 0; }
    .htmx-indicator { display: none; }
    .htmx-request .htmx-indicator, .htmx-request.htmx-indicator { display: inline; }
    .person-core textarea { min-height: 4rem; }
    /* Uncertain readings inside a transcription. The clerk has been told to
       mark these since the palaeography primer landed ([illegible], [word?])
       and nothing rendered the mark, so a word it would defend and a word it
       was guessing at looked identical. See engine/palaeography.py. */
    .doubtful { border-bottom: 1px dotted var(--probable); background: var(--tint-probable-6); cursor: help; }
    .illegible { color: var(--muted); font-style: italic; }
    /* [note: ...] — the third bracket form (FOUNDER RULING 2026-08-20,
       engine/palaeography.py): a physical/layout observation, not doubt.
       Same muted ink as .illegible (both are "not running prose"), but
       Opus review flagged the two declarations as otherwise byte-identical
       — on a printed proof the only way to tell a note from an illegible
       span was to read the bracket body. A left rule (not italic — a note
       is a stated fact about the page, unlike an illegible span's genuine
       absence of one) is the cheap differentiator: upright text reads as
       "here is what I observed", distinct from illegible's admission of
       defeat, without touching the shared muted color both need. */
    .layout-note { color: var(--muted); border-left: 2px solid var(--tint-muted-45);
                   padding-left: .3em; }
    .method-notes { list-style: none; padding: 0; }
    .method-notes li { border-left: 3px solid var(--proven); padding: .3rem .6rem;
      margin: .5rem 0; background: var(--subtle-bg); border-radius: 0 4px 4px 0; }
    /* Retired findings stay VISIBLE but read as past tense — "we used to think
       this" is itself method knowledge, and deleting it means somebody
       re-derives the old belief. */
    .method-notes li.retired { border-left-color: var(--tint-muted-45); color: var(--muted);
      background: var(--tint-border-6); }
    .method-notes .retire { font-size: .8rem; margin-top: .2rem; }
    .method-notes .retire input[type=text] { width: 24rem; max-width: 100%; font-size: .8rem; }
    /* One control, same on every page it appears. */
    .source-doc { border: 1px solid var(--proven); background: var(--tint-proven-6);
      border-radius: 6px; padding: .5rem .7rem; margin: .6rem 0; font-size: .9rem; }
    .source-doc > summary { cursor: pointer; }
    .source-doc form { margin-top: .5rem; }
    .source-doc label { display: block; font-size: .85rem; margin-bottom: .15rem; }
    .source-doc select { width: auto; max-width: 100%; }
    .source-doc-preview { margin: 0 0 .6rem; }
    .notes-block { white-space: pre-line; }
    .family-people { display: flex; flex-wrap: wrap; gap: .6rem; margin: .4rem 0; }
    .family-card { border: 1px solid var(--border); border-radius: 6px; padding: .3rem .6rem; font-size: .85rem; }

    .nav-chat-mobile { display: none; }
    .mobile-chat-pill { display: none; }
    /* The standalone thread page (/chat/<pk>/) as a chat window — the
       founder's phone-tested design: the page itself never scrolls, the
       header stays put, the THREAD is the one scrolling region and opens
       at its newest turn (scrollChatToLatest covers #page-thread), the
       composer stays on screen. In-flow, not fixed, so a phone keyboard
       gets the browser's own scroll-into-view behaviour instead of the
       sheet arithmetic. */
    body.conversation-page { height: 100vh; height: 100dvh; margin: 0;
      box-sizing: border-box; display: flex; flex-direction: column;
      overflow: hidden; }
    body.conversation-page #page-thread { flex: 1 1 auto;
      /* A hard floor: whatever else claims the shrunken page, the
         conversation may never collapse to invisible again. */
      min-height: 6rem;
      overflow-y: auto; border-top: 1px solid var(--border-light);
      border-bottom: 1px solid var(--border-light); padding: .4rem 0; }
    /* Keyboard up (fitConversationPage toggles the class): the header
       chrome steps aside so the squeeze lands on nothing the typist
       needs — the last messages and the composer keep every pixel. */
    body.conversation-page.keyboard-open nav,
    body.conversation-page.keyboard-open h1,
    body.conversation-page.keyboard-open .actions,
    body.conversation-page.keyboard-open .hint { display: none; }

    /* TRANSCRIPTION MODE (engine/transcribe.py, briefs/READER-SCOTTISH-
       PROGRAM.md (h)) — written mobile-first from the start (the
       founder's mother is the real test) rather than a desktop layout
       with a phone override bolted on: one column, generous tap targets,
       a 1rem input everywhere (not just under the mobile breakpoint) so
       iOS never zooms on focus regardless of viewport width. Every
       colour below is a token, per NoRawHexOutsideTokenDeclarationsTests. */
    .transcribe-page { max-width: 40rem; margin: 0 auto; }
    .transcribe-meta { display: flex; justify-content: space-between;
      flex-wrap: wrap; gap: .3rem .8rem; font-size: .85rem; color: var(--muted); }
    .transcribe-stage { background: var(--card-bg); border: 1px solid var(--border);
      border-radius: 8px; padding: 1rem; margin: .8rem 0; }
    .transcribe-strip { display: block; width: 100%; box-sizing: border-box;
      background: var(--subtle-bg); border: 1px solid var(--border-light);
      border-radius: 5px; padding: .4rem; margin: .4rem 0; }
    .transcribe-strip img { display: block; max-width: 100%; height: auto;
      image-rendering: -webkit-optimize-contrast; }
    /* Faint neighbours above/below the current line — the mockup's key
       detail: reading Secretary Hand without the line before/after it is
       how mistakes happen. */
    .transcribe-strip.is-faint { opacity: .45; padding: .25rem .4rem; }
    .transcribe-guess { background: var(--subtle-bg); border: 1px dashed var(--probable);
      border-radius: 5px; padding: .5rem .7rem; margin: .5rem 0; font-family: var(--font-prose); }
    .transcribe-guess .tag { display: block; font-family: var(--font-ui); font-size: .68rem;
      letter-spacing: .05em; text-transform: uppercase; color: var(--probable);
      margin-bottom: .15rem; }
    .transcribe-input input[type=text] { width: 100%; box-sizing: border-box;
      border: 1.5px solid var(--ink); border-radius: 5px; padding: .6rem .7rem;
      font-size: 1rem; font-family: var(--font-prose); background: var(--card-bg);
      color: var(--ink); }
    .transcribe-actions { display: flex; gap: .5rem; margin-top: .5rem; flex-wrap: wrap; }
    .transcribe-keys { font-size: .78rem; color: var(--muted); margin-top: .55rem; }
    .transcribe-keys kbd { border: 1px solid var(--border); border-radius: 3px;
      padding: .05rem .35rem; font-family: var(--font-ui); font-weight: 600; }
    .transcribe-progress { height: 6px; background: var(--subtle-bg); border-radius: 3px;
      margin: .7rem 0 0; overflow: hidden; }
    .transcribe-progress > span { display: block; height: 6px; background: var(--proven); }
    .transcribe-linelist { display: flex; flex-wrap: wrap; gap: .3rem; padding: 0;
      list-style: none; margin: .6rem 0 0; }
    .transcribe-linelist a { display: inline-block; min-width: 1.7rem; text-align: center;
      padding: .25rem .4rem; border-radius: 4px; border: 1px solid var(--border);
      font-size: .8rem; text-decoration: none; color: var(--ink); }
    .transcribe-linelist li.done a { background: var(--att-ok-bg); border-color: var(--proven);
      color: var(--proven); }
    .transcribe-linelist li.skipped a { background: var(--subtle-bg); color: var(--muted); }
    .transcribe-linelist li.current a { outline: 2px solid var(--accent); }
    .transcribe-consent { border: 1px solid var(--proven); border-radius: 6px;
      padding: .6rem .9rem; font-size: .85rem; margin: .8rem 0; }

    /* ============================ MOBILE ============================
       The responsive pass (2026-08-16, founder-approved). One block, at
       the end so it wins ties. The design decision that matters: on a
       phone THE DOCK BECOMES A BOTTOM SHEET — a fixed 380px right panel
       covers a 375px screen entirely, which is what made the app unusable
       on phones, and a bottom sheet is the phone-native shape for a chat
       that shares the screen with the thing it talks about. Closed, it is
       a small floating pill instead of a full-height rail, so the page
       gets every pixel back. The width-drag machinery is desktop-only
       (the grip hides); the session/localStorage state it manages is
       untouched, so a phone and a laptop sharing an account never fight
       over dock state. */
    /* MOBILE SHEET TABS (briefs/MOBILE-SHEET-TABS.md, founder-approved
       2026-08-31): desktop never sees the strip — the rail keeps its
       details-over-assistant split and edge drag. All tab behaviour lives
       inside the mobile block below. */
    .sheet-tabs { display: none; }
    .sheet-tab .tab-dot { display: none; }

    @media (max-width: 700px) {
      /* One reading column, same left edge for every page shape — the
         nav-stays-put invariant, restated for small screens. */
      body { margin: 1rem 0; padding: 1rem .9rem; }
      body.has-dock .nav-chat-mobile { display: inline; }
      /* The ONE mobile chat door (see base.html). The old is-rail pill
         hides here so a tree page never floats two. */
      body.has-dock .mobile-chat-pill { display: inline-block;
        position: fixed; right: .9rem;
        bottom: calc(3rem + env(safe-area-inset-bottom, 0px));
        z-index: 30; background: var(--card-bg);
        border: 1px solid var(--border); border-radius: 999px;
        padding: .55rem 1rem; box-shadow: 0 2px 8px rgba(0,0,0,.18);
        text-decoration: none; }
      .chat-dock.is-rail { display: none; }
      /* An OPEN sheet is its own door — the pill yields to it. :has() is
         everywhere mobile matters (iOS 15.4+). */
      /* Yield to a dock WITH A COMPOSER only. The record page renders a
         dock shell (opt-in header, thread tabs) even when no thread is
         loaded — the founder's screenshot: "the clerk is still hard to
         access" beside a sheet that offered nothing to type into, while
         the pill politely yielded to it. A dock you cannot talk into is
         not a chat door. */
      body:has(.chat-dock .dock-composer) .mobile-chat-pill { display: none; }
      body.has-dock { margin: 1rem 0; padding: 1rem .9rem; max-width: none; }
      body.has-dock.dock-closed { margin: 1rem 0; }
      /* OPEN: a bottom sheet. dvh where the browser has it, so the sheet
         does not hide behind phone browser chrome. */
      .chat-dock:not(.is-rail) { top: auto; left: 0; right: 0;
        width: 100vw !important; height: 56vh; height: 56dvh;
        border-top: 2px solid var(--border); border-left: none;
        box-shadow: 0 -6px 18px rgba(0,0,0,.14); }
      /* The page keeps scrolling above the sheet rather than dying under it. */
      body.has-dock:not(.dock-closed) {
        padding-bottom: calc(var(--sheet-height, 56vh) + 2.5rem);
        padding-bottom: calc(var(--sheet-height, 56dvh) + 2.5rem); }
      body.has-dock.dock-closed { padding-bottom: 4.5rem; }
      /* THE SHEET IS DRAGGABLE (the founder's first mobile feedback:
         "drag the person profile up and shrink down the tree part"). The
         desktop grip returns as a horizontal handle bar across the sheet's
         top edge — pull up for more person-and-chat, down for more tree.
         touch-action none, or the browser scrolls the page instead of
         resizing the sheet. The height rides --sheet-height, set by the
         drag JS in base.html and remembered per browser. */
      /* THE SHEET'S INTERIOR MUST BE THE SCROLLER (the founder, from his
         phone, on /sources/: "trying to scroll in chat scrolled the back
         page instead"). A flex child only overflows when its chain is
         height-constrained: .dock-body had no min-height:0, so a long
         thread GREW the body past the 56dvh sheet, .dock-messages never
         overflowed, there was no inner scroller to grab, and every touch
         fell through to the page behind. */
      .chat-dock:not(.is-rail) { overflow: hidden; }
      /* EVERY wrapper between the sheet and the scroller, not just the two
         the first fix guessed at (the founder, retesting: "Still having
         trouble talking with the clerk"). Measured on the real record
         page: dock@456px > .dock-col@1032 > .dock-body@1012 >
         #chat-thread@960 > .dock-messages@795 — the chain broke at
         .dock-col (min-height unset) and at .dock-thread (no flex rules
         at all, an ordinary div), so the interior still grew past the
         sheet and the composer sat 495px below the screen. A flex chain
         is only as constrained as its least constrained link. */
      .chat-dock:not(.is-rail) .dock-col { min-height: 0; flex: 1 1 auto; }
      .chat-dock:not(.is-rail) .dock-body { min-height: 0; }
      .chat-dock:not(.is-rail) .dock-thread { display: flex;
        flex-direction: column; min-height: 0; flex: 1 1 auto; }
      .chat-dock:not(.is-rail) .dock-messages { min-height: 0; flex: 1 1 auto;
        overflow-y: auto; -webkit-overflow-scrolling: touch; }
      .chat-dock:not(.is-rail) .dock-composer { flex-shrink: 0; }
      .chat-dock:not(.is-rail) { flex-direction: column;
        padding-bottom: env(safe-area-inset-bottom, 0px);
        height: var(--sheet-height, 56vh); height: var(--sheet-height, 56dvh); }
      .chat-dock:not(.is-rail) .dock-grip { display: block; width: 100%;
        height: 20px; flex-shrink: 0; cursor: row-resize; touch-action: none;
        border: none; border-bottom: 1px solid var(--border);
        position: relative; }
      .chat-dock:not(.is-rail) .dock-grip::after { content: "";
        position: absolute; left: 50%; top: 8px; transform: translateX(-50%);
        width: 3rem; height: 4px; border-radius: 999px;
        background: var(--border); }
      .chat-dock.is-rail .dock-grip { display: none; }
      /* A saved desktop drag below the collapse threshold hides .dock-body
         — on a phone that would be a mostly-empty sheet whose only exit
         control is inside the hidden body. A "collapsed width" means
         nothing when the width is forced to 100vw; show the body. */
      [data-dock-collapsed] .dock-body { display: flex; }
      /* CLOSED: a pill, bottom-right. Lifted clear of the browser's own
         bottom chrome: iOS browsers (Chrome iOS especially, per the
         founder's real phone) run a dynamic bottom toolbar that can sit
         OVER the last ~3rem of the layout viewport, and a pill parked
         .9rem off the bottom edge is exactly what it swallows — "I can't
         call the chat up" with nothing broken in the CSS a desktop
         browser can see. env(safe-area-inset-bottom) needs
         viewport-fit=cover in the meta (added alongside this rule) and
         is 0 where not applicable, so the +3rem is the real lift. */
      .chat-dock.is-rail { top: auto; left: auto; right: .9rem;
        bottom: calc(3rem + env(safe-area-inset-bottom, 0px));
        width: auto !important; height: auto; border-radius: 999px;
        border: 1px solid var(--border);
        box-shadow: 0 2px 8px rgba(0,0,0,.18); }
      .chat-dock.is-rail .dock-rail { border-left: none; padding: .55rem 1rem;
        border-radius: 999px; }
      .dock-rail span { writing-mode: horizontal-tb; }
      /* The floating capture button stops following --dock-width — the
         dock no longer owns the right edge. Above the chat pill when the
         dock is closed; above the whole SHEET when it is open, or the
         button lands on top of the composer. */
      #paste-open { right: .9rem !important; bottom: 4.4rem; }
      #paste-source { right: .9rem !important; bottom: 8rem; }
      body.has-dock:not(.dock-closed) #paste-open {
        bottom: calc(var(--sheet-height, 56vh) + 1rem);
        bottom: calc(var(--sheet-height, 56dvh) + 1rem); }
      body.has-dock:not(.dock-closed) #paste-source {
        bottom: calc(var(--sheet-height, 56vh) + 4.6rem);
        bottom: calc(var(--sheet-height, 56dvh) + 4.6rem); }
      #drop-toast { left: .9rem; right: .9rem; }
      /* The person page's two columns become one. */
      .person-cols { flex-direction: column; }
      .person-col-side { min-width: 0; }
      /* The sticky head computes shrink-to-fit width on a narrow screen
         (its auto width collapses to its longest word); state it plainly. */
      .person-head, .person-subhead { width: 100%; box-sizing: border-box; }
      /* The record page's scan column loses its desktop floor. */
      .col-scan { min-width: 0; }
      /* Touch targets: the ⋯ disclosures and small buttons sit well
         under the ~44px finger floor on phones. Padding, not font-size,
         so nothing reflows on desktop. */
      .row-more > summary, details.search-links > summary,
      .dock-head a, .dock-head select { padding: .45rem .5rem; }
      button.small, .small button, .link-btn { min-height: 2.2rem;
        min-width: 2.2rem; }
      /* iOS zooms any focused input under 16px — which on a form-heavy
         app means the whole page lurches on every tap. The element selector
         alone LOSES to every class-scoped size in this sheet, so the
         controls a phone user actually focuses — the composer inside the
         bottom sheet above all — are named explicitly. */
      input, select, textarea, button { font-size: 16px; }
      .dock-composer textarea, #paste-source textarea, .dock-head select,
      .quick-edit input, .quick-edit select, .quick-edit textarea {
        font-size: 16px; }
      /* THE ANCHORED TAG CARD, docked (task B2, item 4): on a phone the
         card gives up pointing at the box — there is no good side of a
         375px-wide box to anchor a 300px card to — and docks to the
         bottom of the viewport instead, thumb reach. Same markup, same
         #who-panel.ann-anchored class the desktop anchoring adds; only
         the position changes. */
      #who-panel.ann-anchored { left: 0 !important; right: 0; bottom: 0; top: auto !important;
        width: auto; max-width: none; border-radius: 12px 12px 0 0;
        box-shadow: 0 -6px 20px rgba(0,0,0,.28); max-height: 70vh; overflow-y: auto; }
      #who-panel.ann-anchored::before { display: none; }
    
      /* THE SHEET BECOMES TABS (briefs/MOBILE-SHEET-TABS.md, the founder
         from his phone: "they're both too thin to use either"). One pane
         at full sheet height; the OTHER pane follows the tree silently.
         The default (no data-sheet-tab on <html>) IS the Person state —
         his ruling — so the sheet is right even before the script runs.
         Gated on :has(#dock-details): pages whose dock carries no detail
         pane (record, list rails) keep today's chat-only sheet. */
      .chat-dock:not(.is-rail) .sheet-tabs { display: flex; gap: .45rem;
        padding: .45rem .7rem .3rem; }
      .sheet-tab { flex: 1; text-align: center; font-size: .82rem;
        padding: .38rem 0; border: 1px solid var(--border);
        border-radius: 999px; background: none; color: var(--muted);
        font-family: var(--font-ui); position: relative; }
      .sheet-tab.is-active { background: var(--accent); color: var(--card-bg);
        border-color: var(--accent); font-weight: 600; }
      /* Person state: the details pane takes the whole sheet; the thread
         waits behind the tab. */
      html:not([data-sheet-tab="assistant"]) .dock-col:has(#dock-details) .dock-body > :not(.dock-head) {
        display: none; }
      html:not([data-sheet-tab="assistant"]) .dock-col:has(#dock-details) #dock-details {
        flex: 1; min-height: 0; height: auto !important; overflow-y: auto; }
      /* Assistant state: the details collapse to the head line — the
         identity strip ("shows the person selected still but doesn't show
         their details"). Same reuse as the desktop details-min collapse. */
      html[data-sheet-tab="assistant"] .dock-col #dock-details .detail-box > *:not(.detail-head) {
        display: none; }
      html[data-sheet-tab="assistant"] .dock-col #dock-details {
        height: auto !important; flex: none; }
      /* ONE LINE, truly (the founder, first thumb on it: "the name and
         details of the person still appears and takes up too much space,
         I can't interact with the chat"). The head is a full header —
         kicker, birthplace, the ⋯ menu, its own padding — and on a real
         person it wraps to three lines and starves the thread. The
         proposal's own words were "shows the person selected still but
         doesn't show their details": the strip is the NAME, in the
         person's colour, and nothing else. Everything hidden here is one
         tap away on the Person tab. */
      html[data-sheet-tab="assistant"] .dock-col #dock-details .detail-divider {
        display: none; }
      html[data-sheet-tab="assistant"] .dock-col #dock-details .detail-box {
        padding: 0; margin: 0; }
      html[data-sheet-tab="assistant"] .dock-col #dock-details .detail-head {
        cursor: pointer; display: flex; align-items: center; gap: .4rem;
        padding: .3rem .8rem .35rem; margin: 0;
        white-space: nowrap; overflow: hidden; }
      html[data-sheet-tab="assistant"] .dock-col #dock-details .detail-head > .detail-kicker,
      html[data-sheet-tab="assistant"] .dock-col #dock-details .detail-head > .muted,
      html[data-sheet-tab="assistant"] .dock-col #dock-details .detail-head > .detail-more {
        display: none; }
      html[data-sheet-tab="assistant"] .dock-col #dock-details .detail-head > strong {
        min-width: 0; overflow: hidden; text-overflow: ellipsis; }
      .dock-col:has(.sheet-tabs) { position: relative; }
      .dock-col:has(.sheet-tabs) .dock-head { position: absolute;
        top: .5rem; right: .55rem; z-index: 3; padding: 0; margin: 0;
        border: none; background: none; }
      .dock-col:has(.sheet-tabs) .dock-head .dock-title,
      .dock-col:has(.sheet-tabs) .dock-head .dock-tabs { display: none; }
      .dock-col:has(.sheet-tabs) .sheet-tabs { padding-right: 2.6rem; }
      /* The scope line repeats the strip in assistant mode. */
      html[data-sheet-tab="assistant"] .dock-col:has(.sheet-tabs) .dock-scope {
        display: none; }
      /* No details/chat split on a phone — the tabs ARE the split. */
      .dock-col:has(.sheet-tabs) #dock-splitter { display: none; }
      /* A reply that lands behind the Person tab dots the Assistant tab —
         without it, choosing Person silently hides an answer you asked
         for. */
      .sheet-tab.has-dot .tab-dot { display: inline-block; position: absolute;
        top: 2px; right: .7rem; width: 7px; height: 7px; border-radius: 999px;
        background: var(--proven); }
    }
