
  * { box-sizing: border-box; }

  /* ==================================================================== *
   * TOKENS -- the vocabulary every rule below spends from.               *
   * Added 2026-08-06 as the first step of the UI pass.                   *
   *                                                                      *
   * Before this block this file had ZERO custom properties (bar          *
   * `--tenant-accent`, set on `.topbar`) and carried 22 font sizes, 28   *
   * spacings, 16 radii, 11 shadows and 106 distinct hex values -- each   *
   * one typed by eye at its own call site, which is exactly how a        *
   * stylesheet goes from 14KB to 106KB in a day.                         *
   *                                                                      *
   * THE CONVERSION IS NOW DONE -- 2026-08-07. This block used to end     *
   * "only a handful of rules below have been converted so far", and      *
   * that was the whole problem: a declared scale nobody spends from is   *
   * a second vocabulary, not a smaller one. 174 font sizes, 346          *
   * spacings, 73 radii and 9 shadows now read from the tokens above.     *
   * What that actually reduced, measured before and after:               *
   *                                                                      *
   *   font-size    22 distinct -> 10 tokens + 5 responsive literals      *
   *   border-radius 17 distinct -> 4 tokens, no literals at all          *
   *   spacing      25 scalars  -> 9 tokens + 1px, and three real         *
   *                              layout offsets (70/80/202px)            *
   *   elevation    11 shadows  -> 3 tokens + the rings and the glow,     *
   *                              which are not elevation and never were  *
   *                                                                      *
   * The four sizes doing ONE job are the clearest thing the audit        *
   * found: uppercase micro-labels were set at 9px, 9.5px, 10px and       *
   * 10.5px in different corners of the same product. They are all        *
   * `--fs-eyebrow` now, which is also the accessibility answer -- 9px    *
   * tracked uppercase is not legible type for this audience.             *
   *                                                                      *
   * THE FIVE REMAINING LITERAL FONT SIZES ARE ALL INSIDE `@media`, and   *
   * they stay literal on purpose. A responsive override is a platform    *
   * constraint, not a step on a type scale: the 16px on phone text       *
   * inputs is the iOS zoom threshold, not a size somebody chose. The     *
   * SIXTH was the phone `h1`, and it was never a platform constraint --  *
   * it is a step on the scale (M3 Headline Small), so it reads from      *
   * `--fs-xl` now.                                                       *
   *                                                                      *
   * If you are about to type a literal below, the value you want is      *
   * probably already here. If it genuinely is not, add it HERE.          *
   *                                                                      *
   * DELIBERATELY ABSENT: `--tenant-accent`. It is set on `.topbar` and   *
   * read with a fallback in three places, so defining it at `:root`      *
   * would silently change what those fallbacks resolve to. Moving it to  *
   * `<body>` is its own change (it is a visible one, for one tenant).    *
   * ==================================================================== */
  :root {
    /* ---- SCHEME. One line, and it is a bug fix rather than a step towards a
       dark mode.

       This app declared no `color-scheme` at all, so on a machine set to dark
       the BROWSER decided for the parts it owns: form controls, scrollbars, the
       native `<select>` popup and the autofill background. Those went dark while
       every surface in this file stayed white, which is how a `<select>` ends up
       rendering pale text on a dark ground inside a white card -- unreadable, and
       invisible to anybody developing on a light machine.

       `light` states what is true today. A real dark mode is a SECOND SET OF
       VALUES for the colour roles above under `prefers-color-scheme: dark`, and
       it is not startable yet: 408 hex literals and 22 `rgb()` calls still sit
       outside `:root` in this file, plus ~190 more in the modules that render
       HTML, so a token flip would repaint the roles and leave the literals light.
       Finishing the colour conversion the way the type, space and radius
       conversions were finished is the prerequisite, and it is its own piece of
       work. When it lands, change this to `light dark` in the same commit --
       declaring both before the values exist is worse than declaring neither. */
    color-scheme: light;

    /* ---- TYPE. Ten steps to replace twenty-two. No half-pixels: they
       snap to device pixels inconsistently, so the same label renders a
       pixel taller on one monitor than another.

       MAPPED ONTO MATERIAL DESIGN 3, 2026-08-07, per the instruction to
       default to Google style on a taste call. The SPECIFICATION was
       adopted, not Material Web's custom elements -- those would add a
       build step and a JS dependency to every page for a project Google
       has deprioritised, where the type scale is free and authoritative.

       The mapping is mostly an identity, which is why almost nothing
       below moved. M3's steps, and what each of ours is:

         --fs-eyebrow 11  =  Label Small        (exact)
         --fs-xs      12  =  Body Small / Label Medium (exact)
         --fs-sm      13  -  no M3 step; ours, for dense table cells
         --fs-base    14  =  Body Medium / Label Large / Title Small
         --fs-md      16  =  Body Large / Title Medium (exact)
         --fs-lg      18  -  no M3 step; sits where Title Large (22) would
         --fs-xl      24  =  Headline Small      (exact)
         --fs-display 30  -  between Headline Medium (28) and Large (32)
         --fs-headline 32 =  Headline Large      (exact)   <- NEW
         --fs-hero    44  ~  Display Medium (45)

       WHAT M3 ACTUALLY CHANGED HERE: the scale topped out at 24px and
       `h1` sat on it, so every page in the product opened at Headline
       SMALL -- the quietest step M3 offers for a page title -- and
       nothing established hierarchy at a glance. `h1` is Headline Large
       now, and the space above it grew with it (see the `h1` rule): a
       bigger heading in the same 4px of clearance is a cramped page
       shouting, not a hierarchy.

       `--fs-display`'s NAME PREDATES THIS MAPPING and does not mean M3's
       Display role. It is the big-numeral size (`.stat .value`,
       `.lp-title`, `.lg-stat`); M3 would call 30px a Headline. Renaming
       it would touch every call site to gain nothing, so it is documented
       instead. If you want M3's Display, that is `--fs-hero`.

       CORRECTED 2026-08-07 (earlier the same day): these comments named
       the wrong headings, and a comment on a scale is what the next
       person types a literal against. `h3` is `--fs-base` now that it has
       a rule at all -- it had none, so it fell through to ~16.4px browser
       default and rendered LARGER than the h2 above it. ---- */
    --fs-eyebrow: 11px;   /* uppercase micro-labels, badges, counts */
    --fs-xs:      12px;   /* filter labels, dense chrome            */
    --fs-sm:      13px;   /* secondary + meta text, table cells     */
    --fs-base:    14px;   /* body, nav, buttons, form controls      */
    --fs-md:      16px;   /* card titles, .ws-name                  */
    --fs-lg:      18px;   /* h2, topbar brand, .lp-name, phase nums */
    --fs-xl:      24px;   /* h1 on a phone, .fin-tile figures       */
    --fs-display: 30px;   /* .stat .value, .lp-*                    */
    --fs-headline: 32px;  /* h1, and nothing else                   */
    --fs-hero:    44px;   /* .fin-hero-value, and nothing else      */

    --lh-tight: 1.2;      /* display and headings                   */
    --lh-snug:  1.35;     /* dense UI: cells, pills, bubbles        */
    --lh-body:  1.5;      /* default                                */
    --lh-prose: 1.6;      /* long-form paragraphs                   */

    --ls-tight: -0.015em; /* display only                           */
    --ls-caps:  0.05em;   /* the one tracking value for small caps  */

    /* ---- MEASURE. `ch`, not `em`: `em` measures the FONT SIZE, `ch`
       measures the glyph, so only one of the two actually tracks
       characters. The existing em caps all land near double. ---- */
    --measure:       68ch;
    --measure-short: 46ch;

    /* ---- SPACE. A 4px grid with one half-step at 6px for inline and
       icon gaps. Nine tokens to replace twenty-eight values. ---- */
    --sp-05: 2px;  --sp-1:  4px;  --sp-15: 6px;  --sp-2: 8px;
    --sp-3:  12px; --sp-4:  16px; --sp-6:  24px; --sp-8: 32px;
    --sp-12: 48px;

    /* ---- NEUTRALS. 900/700/600/400/300/200/100 are the exact values
       already carrying most of the greys in this file; 800/500/050 are
       the three rungs that were missing, which is why so many one-off
       greys were mixed by hand between them. ---- */
    --n-900: #1f2933;  --n-800: #323f4b;  --n-700: #3e4c59;
    --n-600: #52606d;  --n-500: #616e7c;  --n-400: #7b8794;
    --n-300: #9aa5b1;  --n-200: #cbd2d9;  --n-100: #e4e7eb;
    --n-050: #f5f7fa;

    /* ---- SURFACES AND TEXT.
       THE RULE: `--n-400` and lighter are NEVER a text colour. They are
       borders, dividers, icons and dot fills. Every declaration that
       broke that rule failed WCAG AA, several of them badly. ---- */
    --bg-app:        #f7f8fa;
    --bg-surface:    #fff;
    --bg-sunken:     var(--n-050);
    --bg-hover:      #f2f6fc;      /* what tr.rowlink:hover already uses */
    --text:          var(--n-900);
    --text-2:        var(--n-700);
    --text-muted:    var(--n-500); /* 5.21:1 on white, 4.90:1 on --bg-app */
    --border:        var(--n-200);
    --border-subtle: var(--n-100);
    --border-strong: var(--n-400); /* 3.66:1 -- clears 1.4.11 for a control
                                      boundary, where #d5dbe3 was 1.39:1 */

    /* ---- BRAND. Tenant-neutral; the per-tenant colour stays
       `--tenant-accent`, which both tenants set for themselves. ---- */
    --brand:        #1f3864;   /* 11.62:1 on white */
    --brand-hover:  #2b4a80;
    --brand-active: #182c50;
    --brand-weak:   #e8edf6;
    --link:         #2f5b8c;   /* 7.01:1; already in the file, as .ub-more */

    /* ---- STATUS. Each is a TEXT tone at >= 4.5:1 plus a ground and a
       border. Colour is never the only signal: every use keeps its word
       and, where one exists, its border-style. ---- */
    --ok-text:     #2e7d32;  --ok-ground:     #d9ead3;  --ok-border:     #a8cfa8;
    --warn-text:   #8a6d00;  --warn-ground:   #fff2cc;  --warn-border:   #e8b931;
    --danger-text: #b42318;  --danger-ground: #f4cccc;  --danger-border: #e0a49c;
    --info-text:   var(--link); --info-ground: #d9e2f3; --info-border:   #b8c8e6;
    /* The intelligence rail's PROPOSAL tint is a spatial boundary, not a
       status. Folding it into `warn` would make a proposal look like a
       warning, so it keeps its own pair. */
    --proposal-ground: #fbf9f4;  --proposal-rule: #b7791f;

    /* ---- RADII. Four to replace sixteen. 999px rather than 50%, because
       50% makes an ellipse of anything that is not square. ---- */
    --r-sm: 4px;  --r-md: 6px;  --r-lg: 10px;  --r-full: 999px;

    /* ---- ELEVATION. Three to replace eleven, all on one hue. Four of
       the eleven were the same shadow retyped with the alpha nudged. ---- */
    --sh-1: 0 1px 2px rgba(31,56,100,.06), 0 1px 3px rgba(31,56,100,.08);
    --sh-2: 0 2px 4px rgba(31,56,100,.06), 0 4px 12px rgba(31,56,100,.10);
    --sh-3: 0 4px 8px rgba(31,41,51,.07), 0 14px 38px -8px rgba(31,41,51,.22);
    --ring: 0 0 0 3px rgba(31,56,100,.30);

    /* ---- MOTION. Interaction states ONLY. `app_feedback.py` owns its own
       timings and nothing here may reach them. ---- */
    --dur-1: 120ms;  --dur-2: 200ms;  --ease: cubic-bezier(.2,0,.2,1);

    /* ---- LAYOUT ---- */
    --page-max:  1240px;
    --page-wide: 1680px;
    --gutter:    24px;
  }

  /* ---- Links and focus, the two global primitives this file never had.

     THERE WAS NO `a { }` RULE IN THIS STYLESHEET. Every bare content link
     rendered at the browser default -- #0000EE, and #551A8B once visited.
     Purple-means-read is useful on the open web and actively misleading in
     a tool where you open the same client forty times a day, so `:visited`
     is pinned to the same colour rather than left to the browser.

     Every scoped nav/tab/tile rule below sets its own colour and
     text-decoration at a higher specificity, so this sits underneath them.

     THE FOCUS RING is written with `:where()`, which contributes ZERO
     specificity -- so all seven bespoke rings already in this file still
     win, wherever they are in source order. Nothing here suppresses an
     outline; there was simply never a designed one, and a keyboard user
     was following the browser default across 147 interactive selectors. */
  a { color: var(--link); text-underline-offset: 2px;
      text-decoration-thickness: 1px; }
  a:visited { color: var(--link); }
  a:hover { text-decoration-thickness: 2px; }
  :where(a, button, .btn, summary, input, select, textarea,
         [tabindex]:not([tabindex="-1"])):focus-visible {
    outline: 2px solid var(--brand); outline-offset: 2px;
    border-radius: var(--r-sm); }
  /* On the navy top bar a navy ring is invisible. */
  .topbar :where(a, button, summary):focus-visible {
    outline-color: #fff; }

  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
         margin: 0; background: #f7f8fa; color: #1f2933; font-size: var(--fs-base); line-height: 1.5;
         /* Mobile browsers inflate text in some orientations once a viewport is
            declared, which would silently change every measured size below. */
         -webkit-text-size-adjust: 100%; text-size-adjust: 100%; }
  /* ---- THE MAIN NAV IS PINNED, ON EVERY VIEW ------------------------------
     Direct instruction 2026-08-08, from the browser: *"the main nav scrolls, it
     should always be pinned on every view including hatch."* So this is a change to
     the SHELL and not to Hatch -- it is the one rule every page in the product goes
     through, which is the only placement that makes "every view" true without each
     new route remembering it.

     `sticky` and not `fixed`, and the difference is the whole reason this is safe to
     apply globally. A fixed bar leaves the page's first ~64px underneath it and every
     surface in the app would need a compensating `padding-top`; a sticky bar occupies
     its own space in flow, so nothing below it moves and no other rule has to know
     the bar exists.

     AN OPEN NAV PANEL MUST NOT BE CLIPPED, and it is not. `NAV_MODE` defaults to
     `grouped` now, so the bar holds `<details>` panels that open downward: they are
     `.tenant-menu`, `position: absolute`, hanging out of the bar's box. Nothing here
     sets `overflow`, so there is nothing to clip them -- the trap would have been
     reaching for `overflow: hidden` to tidy the bar's corners, and this comment is
     the reason not to. The panels still close each other by themselves: they share
     `name="navgroup"` (`nav._panel`), which is a browser behaviour and needs no CSS.

     THE `z-index` IS LOAD-BEARING AND NOT DECORATION. Sticky is a positioned element,
     and Hatch's work column is sticky too -- with both at `z-index: auto` the column
     paints over the bar, because they tie and the later one in the document wins. 50
     puts the bar above every surface and below `.cp-bar` (9999) and `.cp-note`
     (9998), which are meant to sit above everything including this.

     It makes the bar a stacking context, which is why `.tenant-menu`'s `z-index: 60`
     is now scoped inside it rather than competing with the page -- that is fine and
     in fact tidier, since a dropdown belongs to its bar.

     NOT PINNED ON PAPER (see `@media print`) and NOT PINNED ON A PHONE (see the
     700px block): below 700px the nav is a scrolling row and the bar is one 44px
     line, but the tenant chip and the signed-in name still wrap it onto two or three
     rows on a narrow screen, and a permanently parked third of a phone screen is not
     what "pinned" was asked for. */
  .topbar { display: flex; align-items: center; gap: var(--sp-4); background: #1f3864; color: #fff; padding: var(--sp-3) var(--sp-6);
            position: sticky; top: 0; z-index: 50; }
  .topbar img { width: 40px; height: 40px; border-radius: var(--r-md); background: #fff; padding: var(--sp-1); }
  .topbar .brand { font-size: var(--fs-lg); font-weight: 700; letter-spacing: -0.01em; margin-right: var(--sp-4); }
  /* The mark and the product name became ONE link to /home, 2026-08-06, when the
     landing page arrived: clicking the logo to get back to the front is the
     convention people already try, and it means the page needs no nav item of its
     own -- one that appeared for every tenant would be a permanent invitation to a
     page you only need at the start of a session. Wrapping them re-creates the
     14px the topbar's own `gap` used to put between two separate flex items. */
  .topbar .topbrand { display: flex; align-items: center; gap: var(--sp-4);
                      color: inherit; text-decoration: none; }
  .topbar .topbrand:hover .brand, .topbar .topbrand:focus-visible .brand {
    text-decoration: underline; }
  .topbar nav { display: flex; gap: var(--sp-1); flex: 1; }
  /* THE BAR WRAPS AT EVERY WIDTH, NOT ONLY BELOW 900px, AND THAT BREAKPOINT IS
     WHY THIS DEFECT SURVIVED NINE ZOOM STEPS. The 900px block far below already
     solved this exact problem and explains it exactly right: flex items cannot
     shrink below min-content, so a nowrap `.topbar` has a hard minimum of about
     1,450px and everything past it is pushed out of the bar and off the screen.
     That block is gated on the VIEWPORT, and a reader who scales TEXT does not
     change the viewport -- `html { font-size: 32px }` multiplies the bar's
     min-content to ~2,900px while the window still reports 1,440px, so the rule
     that would have saved it never fires.

     BROWSER ZOOM HIDES IT AND TEXT SCALING FINDS IT, and the distinction is the
     whole reason nobody caught this: zoom multiplies every CSS pixel AND divides
     the viewport, so at 200% zoom the window reports 720px, the 900px block fires,
     the bar wraps, and a px layout survives by accident. Measured on the text
     ladder 100/125/150/175/200/250 at 1440x900, 1152x720 and the shell's own
     declared minimum 1001x620, with `NAV_MODE=grouped` (the default, and what
     production runs): before this rule `Sign out` left the bar and the viewport
     at 200%/1001x620 for a 22-character name and the document gained 106px of
     sideways scroll; at 250% it failed at 1152x720 as well. It is the top bar, so
     it is EVERY page, and the control it loses is the one that ends a session.

     THREE RULES AND EACH IS LOAD-BEARING. `flex-wrap` on the bar lets the
     signed-in name drop to its own row instead of being pushed off the end;
     `min-width: 0` on the nav is what lets a flex item shrink past min-content at
     all, and without it the wrap happens but the nav still overflows its own row;
     `margin-left: auto` keeps the name hard right on the row it lands on, which is
     where the previous layout put it by consuming the leftover space.

     COSTS NOTHING AT 100%. Content that fits does not wrap, so every viewport at
     every ordinary text size renders byte-identically to before. Verified across
     the same eighteen measurements. */
  .topbar { flex-wrap: wrap; row-gap: var(--sp-2); }
  .topbar nav { flex-wrap: wrap; min-width: 0; }
  .topbar .who { min-width: 0; margin-left: auto; }
  .topbar nav a { color: #c9d4e6; text-decoration: none; padding: var(--sp-2) var(--sp-3); border-radius: var(--r-md); font-weight: 600; font-size: var(--fs-sm); }
  .topbar nav a.active, .topbar nav a:hover { background: rgba(255,255,255,0.14); color: #fff; }
  .topbar nav a .soon { font-size: var(--fs-eyebrow); background: #d9a441; color: #1f2933; border-radius: var(--r-full); padding: 1px var(--sp-15); margin-left: var(--sp-15); vertical-align: middle; }
  /* Nav tags marking the two client surfaces while both exist. Quieter than
     .soon: these are orientation, not an announcement, and they disappear when
     the legacy page is retired. */
  .topbar nav a .navtag { font-size: var(--fs-eyebrow); text-transform: uppercase; letter-spacing: .04em; border: 1px solid rgba(255,255,255,.35); border-radius: var(--r-full); padding: 0 var(--sp-15); margin-left: var(--sp-15); vertical-align: middle; opacity: .8; }
  /* HOW MANY FLAGS ARE OPEN, next to the Flags item. Per direct instruction
     2026-08-06, and rendered by `flag_tally.nav_badge`.

     NOT `.pill red`, which is what the Admin dual-write badge uses. That badge
     means "something is wrong"; this one means "there is work", and work is the
     normal state of a review queue -- a permanently red nav bar is a nav bar
     people stop reading, which is the same failure as having no badge at all.
     So it borrows `.navtag`'s quiet treatment and adds only a filled ground and
     tabular figures, so 9 and 41 do not shift the label beside them.

     `.unknown` is the "?" state: the count is unavailable. Deliberately the
     SAME shape and a lighter weight, because it is not an alarm either -- it is
     an admission, and the title attribute carries the reason. */
  /* THE SAME BADGE IN THE TWO PLACES IT CAN APPEAR, grouped rather than copied.
     With `NAV_MODE=grouped` a member's count is rendered on the GROUP'S `<summary>`
     so it is readable with the panel shut -- and a summary is not an `<a>`, so the
     rule below styled the flat-nav copy and left the grouped one unstyled. An
     unstyled `.navcount` is not broken, it is INVISIBLE: white-on-navy text with no
     pill behind it, which is the precise failure the `.unknown` sibling exists to
     stop somebody shipping.
     `> summary` and not a descendant combinator: the copy inside the OPEN menu is a
     dark-on-white pill with its own rule further down this file, and a descendant
     selector here would reach it and repaint it for a navy bar it is not on. */
  .topbar nav a .navcount,
  .topbar nav .nav-group > summary .navcount { font-size: var(--fs-eyebrow); font-weight: 700; font-variant-numeric: tabular-nums; background: rgba(255,255,255,.18); border: 1px solid rgba(255,255,255,.32); color: #fff; border-radius: var(--r-full); padding: 0 var(--sp-15); margin-left: var(--sp-15); vertical-align: middle; }
  .topbar nav a .navcount.unknown,
  .topbar nav .nav-group > summary .navcount.unknown { font-weight: 600; opacity: .75; }

  /* ---- Client Workspace: the three-zone shell ------------------------------
     Grid, not flex: the two rails hold their width while only the centre
     reflows, which is the whole point of a persistent context rail -- it must
     not move as you change panes. */
  .ws { display: grid; grid-template-columns: 260px minmax(0,1fr) 320px; gap: var(--sp-4); align-items: start; }
  /* JUGGERNAUT COMMUNICATIONS widens the left column, because the intelligence
     panel is the headline surface there and 260px does not hold a category, four
     facts, a move and the reason for it. Hatch's own proportions are unchanged --
     this is scoped to the class only the comms shell adds.

     THE `min-width` BOUND IS LOAD-BEARING AND IT IS A CORRECTION. The obvious form
     is `body:has(.ws-hatch) .ws.ws-coach { ... }` placed after the rule above, on the
     reasoning that avoiding `!important` leaves the ≤1200px and ≤900px breakpoints
     free to win. THEY WOULD NOT. Those breakpoints target `.ws`, specificity (0,1,0);
     that selector is roughly (0,3,1), and SPECIFICITY BEATS SOURCE ORDER -- so it
     would win INSIDE both media queries and the page would stop stacking on a phone,
     which is the exact failure the no-`!important` rule was meant to prevent.
     Bounding it above the first breakpoint means it does not exist where they apply,
     so there is nothing to out-rank. */
  @media (min-width: 1201px) {
    .ws.ws-coach { grid-template-columns: minmax(0,28%) minmax(0,1fr) minmax(0,19%); }
  }
  .ws-rail, .ws-main, .ws-intel { min-width: 0; }
  .ws-rail .card, .ws-intel .card { padding: var(--sp-3) var(--sp-4); }
  .ws-rail h2 { font-size: var(--fs-eyebrow); text-transform: uppercase; letter-spacing: .06em; color: var(--text-muted); margin: var(--sp-4) 0 var(--sp-1); }
  .ws-rail h2:first-child { margin-top: 0; }
  /* The client's name is the page's `h1` as of this pass -- it was a `<p>`, so
     every one of the six client panes rendered with NO h1 at all and its first
     heading was "Stage". Sized here rather than inherited, because 24px navy in
     a 260px rail would out-shout the pane the person came to read. */
  .ws-name { font-size: var(--fs-md); font-weight: 700; margin: 0 0 var(--sp-05); }
  /* "(derived)" beside a rail heading: the heading is small caps, the note is not. */
  .ws-h-note { text-transform: none; letter-spacing: 0; }
  .ws-more { margin-top: var(--sp-3); }
  /* CONTACT & DETAILS GETS THE SAME HOVER AFFORDANCE EVERY OTHER DISCLOSURE HAS.
     Added 2026-08-08 (U1). This was reported as "Contact & Details does not expand",
     and it always did -- it is a native <details> that nothing clips, no rule sets
     `pointer-events` on, and `app_feedback`'s capture-phase click handler does not
     match (it looks for a[href], button and input[type=submit]; a <summary> is none of
     those). What it did not do was LOOK clickable: `.conv-fold`, `.set-fold`,
     `.ff-detail`, `.ff-canned-box`, `.itfw-nofile` and `.tenant-chip` all set
     `cursor: pointer` on their summary, and this was the only one that did not. Muted
     grey text with an arrow cursor reads as a label. "Does not expand" and "gives no
     sign it can be clicked" are the same report from somebody using the page. */
  .ws-more > summary { cursor: pointer; }
  .ws-more-body { margin-top: var(--sp-15); }
  .ws-legacy { margin: var(--sp-3) 0 0; }
  .ws-notyet-stacked { margin-bottom: var(--sp-3); }
  .ws-stage { display: flex; gap: var(--sp-15); align-items: center; margin: var(--sp-1) 0 var(--sp-05); }
  /* SHAPE, NOT ONLY COLOUR. A filled green dot and an empty grey one differed by
     hue alone, which is the one thing this audience cannot rely on -- and five
     dots in a row is exactly the pattern somebody reads at a glance and never
     hovers. The reached steps are filled and ringed, the unreached ones are a
     hollow outline, so the count survives greyscale and a printout. The rail
     also names the stage in words underneath, and `.ws-stage` carries an
     `aria-label` saying which of five it is. */
  .ws-dot { width: 9px; height: 9px; border-radius: var(--r-full);
            background: #fff; border: 2px solid #cbd2d9; box-sizing: border-box; }
  .ws-dot.on { background: #2e7d32; border-color: #1e5b21; }
  /* The sentence beside a lane count taken from legacy `claim_type`, which
     `EXT-229` §43 requires be labelled `legacy_not_authoritative`. It arrived in
     `55d44e9` with NO CSS RULE, which `test_module_split` correctly failed --
     a class rendered with no rule renders unstyled, and an unstyled caution
     reads as body copy. Small, muted and set off, because it is the reason a
     number above it is not an answer. */
  .ws-legacy-note { font-size: var(--fs-eyebrow); line-height: 1.4;
                    margin-top: var(--sp-15); padding-left: var(--sp-2);
                    border-left: 2px solid #e4e7eb; }
  .ws-tabs { display: flex; gap: var(--sp-05); border-bottom: 2px solid #e4e7eb; margin-bottom: var(--sp-3); flex-wrap: wrap; }
  .ws-tabs a { padding: var(--sp-2) var(--sp-4); font-size: var(--fs-sm); font-weight: 600; color: #52606d; text-decoration: none; border-bottom: 2px solid transparent; margin-bottom: -2px; }
  .ws-tabs a:hover { color: #1f3864; }
  .ws-tabs a.on { color: #1f3864; border-bottom-color: #1f3864; }
  .ws-tabs a .cnt { font-size: var(--fs-eyebrow); background: #e4e7eb; color: #52606d; border-radius: var(--r-full); padding: 1px var(--sp-15); margin-left: var(--sp-15); }
  /* Everything in the intelligence rail is a PROPOSAL. The tint is the boundary
     -- see DESIGN_CLIENT_WORKSPACE.md: a spatial rule a developer cannot
     accidentally violate beats a pill somebody has to notice. */
  .ws-intel .card { background: #fbf9f4; border-left: 3px solid #b7791f; }
  /* The rail's own heading, which was five declarations in a `style=` attribute
     repeated on every card in `workspace_routes` -- twelve copies across the six
     panes, carrying a raw 11px and a raw #b7791f that the token pass could not
     see because they were in HTML rather than in this file. Same shape as
     `.ws-rail h2` above, in the rail's amber rather than the muted grey, because
     these headings label PROPOSALS and the left rail labels facts. */
  .ws-intel h2 { font-size: var(--fs-eyebrow); text-transform: uppercase;
                 letter-spacing: .06em; color: #b7791f;
                 margin: 0 0 var(--sp-1); }

  /* ---- The utility rail (U10) --------------------------------------------
     The rail was two fixed cards stacked in one column and both were always
     rendered, so it spent most of its height on whichever one you were not
     reading. It now carries modes: Decisions, Conditions, Intelligence, Client
     App, one visible at a time.

     THE STRIP IS BUTTONS AND NOT LINKS, and that is a behaviour decision rather
     than a styling one. Every panel is in the same server response, so switching
     changes nothing but which one is `hidden` -- there is no request, and a
     progress bar started for it would run until something unrelated cleared it.
     `workspace_rail` puts `data-no-busy` on the whole rail for that reason, and
     nothing is ever `disabled` to show which mode is on: `.on` and
     `aria-selected` carry it.

     Shaped after the flag pane's `cf-subtabs` strip rather than after `.ws-tabs`,
     for the same reason `client_flags` chose it: this is a level BELOW the pane
     tabs and two identical-looking tab strips is a reader guessing which one they
     are on. The active colour is the rail's amber and not the app's navy, because
     the rail already reads as its own territory. (Written without the leading dot
     deliberately -- `test_client_flags.test_no_new_media_query_fires_when_printing`
     slices this stylesheet on the FIRST occurrence of the dotted selector, so
     naming it here would move that slice up by 1,300 lines and fail on the
     workspace breakpoints.) */
  .ws-util { display: block; }
  .ws-modes { display: flex; gap: var(--sp-1); flex-wrap: wrap; margin: 0 0 var(--sp-3); }
  .ws-mode { padding: var(--sp-15) var(--sp-2); font-size: var(--fs-eyebrow); font-weight: 700;
             text-transform: uppercase; letter-spacing: .04em; color: #7f6000;
             background: #fff; border: 1px solid #e6dcc4; border-radius: var(--r-full);
             cursor: pointer; font-family: inherit; }
  .ws-mode:hover { border-color: #b7791f; }
  .ws-mode.on { background: #b7791f; border-color: #b7791f; color: #fff; }
  .ws-mode .cnt { font-size: var(--fs-eyebrow); background: #f4ecd8; color: #7f6000;
                  border-radius: var(--r-full); padding: 0 var(--sp-15); margin-left: var(--sp-1); }
  .ws-mode.on .cnt { background: rgba(255,255,255,.24); color: #fff; }
  .ws-panel[hidden] { display: none; }

  /* THE CONTEXT RAIL'S CONDITION COUNTS OPEN NOW. `PLAN_UNBUTTONED.md` lists
     "the condition counts do not open" as one of the four things wrong with
     Hatch. The heading and the lane breakdown are unchanged; they are wrapped in
     a button that switches the utility rail to the Quick View. Reset to look
     exactly like the text it replaced, because it is the same information -- the
     only added mark is the cue line, so it does not read as a dead label the way
     Contact & Details did before U3. */
  .ws-count-open { display: block; width: 100%; text-align: left; padding: 0;
                   background: none; border: 0; font: inherit; color: inherit; cursor: pointer; }
  .ws-count-cue { display: block; margin-top: var(--sp-1); font-size: var(--fs-eyebrow);
                  font-weight: 700; text-transform: uppercase; letter-spacing: .06em;
                  color: #b7791f; }
  .ws-count-open:hover .ws-count-cue { text-decoration: underline; }

  /* ---- Condition Quick View (U11) ----------------------------------------
     Rapid inspection, between the context rail's summary and the Case tab's full
     management. One scannable line per condition and everything else behind a
     fold.

     Every judgement on it comes from `road_map.compute_evidence_lights` and
     `summarize_condition_status`, the same two functions the Case tab renders
     from, and the pills are `app_shared._light_pill`. There is no second
     calculator here and there must never be one.

     `.cqv-list` HAS NO SCROLLER OF ITS OWN, AND THAT IS A DECISION, 2026-08-08.
     It carried `max-height: clamp(240px, 100vh - 420px, 560px); overflow-y: auto`
     -- written when the page was the only scroller and a long rail simply made a
     long page. U7 changed the arrangement underneath it: on Hatch the WORK COLUMN
     is pinned and the rails are what move past it, so the rail's height IS the
     scroll. A 560px scroller inside it does three bad things and buys nothing:

       * IT EATS THE GESTURE. The wheel over a nested scroller is consumed by that
         scroller until it bottoms out. The rail is the one place a reader points
         while reading the rail, and on Hatch the clamped rail is shorter than the
         100vh pinned column, so the page underneath has nothing to scroll -- "the
         rails scroll" was being delivered as "a box inside the rail scrolls".
       * THE ROWS ARE `<details>`. Opening one inside a short scroller reveals a
         body that is frequently below that box's own fold: a scroll, inside a
         scroll, inside a page.
       * IT CLIPPED THE PRINTED PAGE. `@media print` deliberately un-clamps `.conv`
         and the composer's textarea, on the stated rule that a case file must not
         silently lose everything below one screen. This scroller was never added
         to that block, so a printed workspace dropped every condition past the
         first ~560px with no indication that it had.

     Compactness was never coming from the clamp -- it comes from the row being one
     line with everything else folded away, which is unchanged. The same call was
     already made one breakpoint down for `.panel`, whose sticky max-height scroller
     goes static below 760px for exactly this reason. */
  .cqv-head { font-size: var(--fs-eyebrow); margin: 0 0 var(--sp-2); }
  .cqv-row { border-top: 1px solid #eee6d5; }
  /* ---- THE ROW READS AS A COLUMN, because the rail is one ------------------
     Reported 2026-08-07: *"on hatch on the right side rail, when conditions is
     selected the surfacing of the details is not easily readable looks like some
     column vs row issues."* It was two, and both are exactly that.

     ONE: this summary was `display: flex` with NO `flex-wrap`, holding `.cqv-name`
     (`overflow-wrap: anywhere`) beside `.cqv-state`, which is a `.pill` and therefore
     `white-space: nowrap`. The state text is a whole sentence from
     `road_map.summarize_condition_status` -- "Needs diagnosis + nexus + functional
     impact" is about 275px with its padding, against roughly 208px of usable width in
     a 240px rail and 288px in a 320px one. So the unbreakable pill took the entire
     line, the name was squeezed into whatever remained, and `overflow-wrap: anywhere`
     then broke it a character or two per line. The worst case is also the COMMON case:
     a condition with all three gaps open is the longest label there is.

     The fix is layout only. Nothing about the status is computed anywhere near here --
     the words come from `summarize_condition_status`, the pill from
     `app_shared._light_pill`, and `workspace_rail` decides nothing but the order.

     The name takes its own row (`flex: 1 1 100%`) rather than being made to compete,
     and the state sits under it and may wrap. That is deterministic at 240px and at
     320px, which is the width the rail actually is at each of the two breakpoints --
     `.ws` is `240px minmax(0,1fr) 320px`, and below 1200px the intelligence rail moves
     to column 1, which is the 240px one.

     And the disclosure looks like one again. `display: flex` on a `<summary>` removes
     the browser's own triangle, so these rows opened with nothing saying they could --
     the same defect U1 found on Contact & Details, where "does not expand" and "gives
     no sign it can be clicked" turned out to be the same report. Same caret the rest
     of the app already uses. */
  .cqv-row > summary { cursor: pointer; display: flex; flex-wrap: wrap;
                       gap: var(--sp-05) var(--sp-2); align-items: baseline;
                       padding: var(--sp-15) 0; font-size: var(--fs-sm);
                       list-style: none; }
  .cqv-row > summary::-webkit-details-marker { display: none; }
  .cqv-name { flex: 1 1 100%; min-width: 0; font-weight: 600; color: #1f2933;
              overflow-wrap: anywhere; }
  /* DOUBLE BACKSLASHES, AND THEY ARE NOT A TYPO. `APP_CSS` is an ordinary
     triple-quoted Python string, not a raw one, so a CSS escape written `\25b8`
     is read by PYTHON first -- `\25` is a three-digit octal escape and the browser
     receives chr(0x15) followed by the literal text "b8". See the note on
     `.conv-more::before`, where that had been happening unnoticed. */
  .cqv-name::before { content: "\25b8\00a0"; color: #b7791f; font-weight: 700; }
  .cqv-row[open] > summary .cqv-name::before { content: "\25be\00a0"; }
  /* The one place a `.pill` is allowed off its single line, and only because the
     alternative is a pill wider than the column it lives in. */
  .cqv-state { flex: 0 1 auto; min-width: 0; white-space: normal; text-align: left;
               line-height: 1.35; }
  .cqv-body { padding: 0 0 var(--sp-3) var(--sp-2); font-size: var(--fs-xs); }
  /* TWO: the traffic lights. `.lights-cell` is `white-space: nowrap` for a wide
     table; this switched it to `normal` so three label+pill pairs could wrap in a
     rail, but they are SIX independent inline boxes, so a wrap was free to land
     between a label and the pill it labels -- "Dx" ending one line and its own
     status starting the next. Three pills reading "On Track / In Progress / Needs
     Attention" against the wrong three letters is worse than not showing them.

     A two-column grid makes each pair a row, so the label and its pill cannot be
     separated by anything. Same labels, same pills, same order; only the flow
     changes. `max-content` on the first column keeps Dx/Nx/Fx aligned with each
     other, which is what makes the three scannable as a set. */
  .cqv-body .lights-cell { display: grid; grid-template-columns: max-content minmax(0,1fr);
                           gap: var(--sp-1) var(--sp-2); align-items: center;
                           white-space: normal; line-height: 1.35;
                           margin: var(--sp-1) 0 var(--sp-2); }
  .cqv-body .lights-cell .light-label { margin: 0; }
  .cqv-body .lights-cell .pill { justify-self: start; white-space: normal; }
  .cqv-canonical { font-size: var(--fs-eyebrow); color: var(--text-muted);
                   text-transform: uppercase; letter-spacing: .04em; font-weight: 700;
                   margin-bottom: var(--sp-1); }
  /* The mental-health callout. Every mental-health diagnosis, PTSD included,
     shares one canonical and the specific diagnoses are a callout on the one
     record. Tinted so it reads as an explanation of the row rather than as more
     of the row. */
  .cqv-mh { background: #f2f6fa; border-radius: var(--r-sm); padding: var(--sp-2);
            margin: var(--sp-2) 0 var(--sp-1); line-height: 1.5; }
  /* The name-callout chips. Inline-block pills separated by a right margin collide
     vertically the moment they wrap, which in a 240px rail is immediately and on the
     densest row in the list -- the mental-health record carrying PTSD, Depression and
     Anxiety at once. A wrapping flex row gives them a gap in both directions, so the
     three read as three chips rather than as a block of overlapping text. */
  .cqv-subs { display: flex; flex-wrap: wrap; gap: var(--sp-1); margin: 0 0 var(--sp-2); }
  .cqv-sub { margin-right: 0; white-space: normal; }
  /* The curated secondary-connection reference. The fold and its count render with
     the page; the text is fetched the first time it is opened, because in full it is
     6.4KB per condition and 26KB for the mental-health canonical. */
  .cqv-secondary { margin-top: var(--sp-2); }
  .cqv-secondary > summary { cursor: pointer; font-size: var(--fs-eyebrow); font-weight: 700;
                             text-transform: uppercase; letter-spacing: .04em; color: #b7791f; }
  .cqv-sec-body { margin-top: var(--sp-1); }
  .cqv-sec { font-size: var(--fs-eyebrow); text-transform: uppercase; letter-spacing: .04em;
             color: #52606d; margin: var(--sp-2) 0 var(--sp-1); }
  .cqv-link > summary { cursor: pointer; font-size: var(--fs-xs); padding: var(--sp-05) 0; }
  .cqv-strength { margin-left: var(--sp-1); }
  .cqv-link-body { background: #fafbfc; border-radius: var(--r-md); padding: var(--sp-2);
                   margin: var(--sp-05) 0 var(--sp-15); color: #3e4c59; line-height: 1.6; }
  .cqv-note { font-size: var(--fs-eyebrow); margin: var(--sp-2) 0 0; }
  .cqv-open { margin: var(--sp-2) 0 0; }
  .cqv-all { margin: var(--sp-3) 0 0; font-size: var(--fs-xs); }
  /* ---- Tenant chip and switcher ------------------------------------------
     Claim Pilot serves two brands and both are navy -- sampled from the real logos,
     OVE #012c56 and SHW #1f2a44. So colour CANNOT be the thing that tells you which
     side you are on. The mark and the written name do that; --tenant-accent is a
     secondary cue set per tenant on .topbar and used only for a thin rule.

     Design notes taken from sternhealthworks.com: sharp corners, generous whitespace,
     structure over decoration. The chip follows that -- no gradient, no shadow, a
     1px rule and real spacing. */
  .tenant-chip { margin-left: var(--sp-3); font-size: var(--fs-xs); position: relative; }
  .tenant-chip .tenant-name { color: rgba(255,255,255,.92); font-weight: 600; letter-spacing: .01em; }
  .tenant-chip.solo { padding-left: var(--sp-3); border-left: 2px solid var(--tenant-accent, #8a97a3); }
  .tenant-chip > summary { list-style: none; cursor: pointer; padding: var(--sp-1) var(--sp-2) var(--sp-1) var(--sp-3);
    border-left: 2px solid var(--tenant-accent, #8a97a3); border-radius: 0 var(--r-sm) var(--r-sm) 0;
    display: inline-flex; align-items: center; gap: var(--sp-2); }
  .tenant-chip > summary::-webkit-details-marker { display: none; }
  .tenant-chip > summary:hover { background: rgba(255,255,255,.08); }
  .tenant-caret { color: rgba(255,255,255,.55); font-size: var(--fs-eyebrow); }
  .tenant-menu { position: absolute; top: calc(100% + 6px); left: 0; z-index: 60;
    min-width: 288px; background: #fff; border: 1px solid #cbd2d9; border-radius: var(--r-sm);
    box-shadow: var(--sh-2); overflow: hidden; }
  .tenant-menu-head { font-size: var(--fs-eyebrow); text-transform: uppercase; letter-spacing: .07em;
    color: var(--text-muted); padding: var(--sp-2) var(--sp-3) var(--sp-15); font-weight: 700; }
  .tenant-menu-foot { font-size: var(--fs-eyebrow); color: var(--text-muted); padding: var(--sp-2) var(--sp-3) var(--sp-3);
    border-top: 1px solid #eceff1; }
  .tenant-option { margin: 0; }
  .tenant-option button, .tenant-option.current { display: flex; align-items: center;
    gap: var(--sp-3); width: 100%; text-align: left; padding: var(--sp-2) var(--sp-3); background: none;
    border: 0; font: inherit; cursor: pointer; color: #1f2933; }
  .tenant-option button:hover { background: #f2f6fa; }
  .tenant-option.current { background: #f7f9fb; cursor: default; }
  .tenant-option img { width: 26px; height: 26px; object-fit: contain; flex: 0 0 26px; }
  .tenant-option span { line-height: 1.35; font-size: var(--fs-xs); }

  /* ---- Nav group panels, NAV_MODE=grouped -------------------------------
     Four rules and no component. The disclosure IS the tenant chip -- same
     details/summary, same dropdown, same caret -- so the top bar keeps one
     opening behaviour rather than growing a second. What these rules do is
     put the summary back into the nav's own type and colour (the chip's
     accent rule belongs to the tenant indicator and would read as a second
     brand cue here), and make an anchor inside the white dropdown legible,
     which `.topbar nav a` alone does not: it paints links #c9d4e6 for a navy
     bar, which on white is very nearly invisible. */
  .topbar nav .nav-group { margin-left: 0; font-size: var(--fs-sm); }
  .topbar nav .nav-group > summary { border-left: 0; border-radius: var(--r-md);
    padding: var(--sp-2) var(--sp-3); color: #c9d4e6; font-weight: 600; }
  .topbar nav .nav-group[open] > summary, .topbar nav .nav-group.active > summary,
  .topbar nav .nav-group > summary:hover { background: rgba(255,255,255,0.14); color: #fff; }
  .topbar nav .nav-group .tenant-menu a { display: block; border-radius: 0;
    color: #1f2933; font-size: var(--fs-xs); padding: var(--sp-2) var(--sp-3); }
  .topbar nav .nav-group .tenant-menu a:hover,
  .topbar nav .nav-group .tenant-menu a.active { background: #f2f6fa; color: #1f2933; }
  .topbar nav .nav-group .tenant-menu .navtag,
  .topbar nav .nav-group .tenant-menu .navcount { border-color: #cbd2d9; color: #52606d;
    background: #f7f9fb; }

  /* ---- Conversation pane -------------------------------------------------
     ADDED 2026-08-06. The pane shipped the night before using .conv, .conv-item,
     .conv-meta and .conv-composer and NONE of them had a rule -- the bubbles
     rendered as undifferentiated divs, so inbound and outbound were
     indistinguishable. Caught by a test asserting that every class the app renders
     is styled, not by looking at the page.

     Inbound sits left and outbound right, which is the convention every messaging
     client uses and therefore the one a case worker already knows. Outbound also
     gets a tint rather than relying on position alone, because at a glance in a
     narrow column position is easy to misread and the cost of misreading is
     replying to your own message. */
  /* SCROLLABLE AND RESPONSIVE, per direct instruction 2026-08-06.

     ONE SCROLLER PER JOB, settled by direct instruction 2026-08-06: *"the composer
     should scroll with the page, the messages should only scroll when inside the
     messages window"*. THE THREAD'S HALF OF THAT IS UNCHANGED and is the half that
     matters here; the composer's half was superseded on 2026-08-08 and the reasoning
     is recorded on `.conv-composer` below rather than repeated.

     The thread still scrolls ITSELF rather than the page, and that part is unchanged
     for the original reason: without it a client with two years of email pushed the
     composer below the fold, so replying meant scrolling past the entire history to
     reach the box -- on the pane whose whole purpose is replying. The height is a
     clamp, not a fixed pixel count: it grows with the viewport and never collapses
     below something usable on a laptop, and the subtrahend leaves room for the
     composer underneath it. The clamp is now belt to the composer's braces rather
     than the only thing keeping the box reachable.

     `overflow-anchor` keeps the scroll position stable when the pane re-renders after
     a send, so the message you just wrote does not jump away from you.

     THE CLAMP IS IN `rem` AND NOT `px`, CHANGED 2026-08-11, and the numbers are
     deliberately the same ones: at the 16px root every browser ships, `20rem` IS
     320px and `25rem` IS 400px, so nothing moves for a reader who has changed
     nothing. What changes is the reader who HAS. Direct instruction: *"user should
     be able to scale up the page without breaks from containers."* A `px` clamp is a
     container that ignores the text inside it -- raise the browser's font size and
     the bubbles grow while the box does not, so the thread holds fewer and fewer
     messages until it holds one. Expressed in `rem` the box is sized in the reader's
     own unit and grows with what it has to hold, which is the whole of "driven by
     content". Every `.conv` clamp in this file moved together; a mixed set would be
     worse than either, because the breakpoint a reader crosses would change the
     rules on them mid-zoom. */
  .conv { display: flex; flex-direction: column; gap: var(--sp-3); margin-bottom: var(--sp-4);
    max-height: clamp(20rem, calc(100vh - 25rem), 45rem); overflow-y: auto;
    overflow-anchor: auto; padding-right: var(--sp-15); scrollbar-width: thin; }
  .conv::-webkit-scrollbar { width: 9px; }
  .conv::-webkit-scrollbar-thumb { background: #cbd2d9; border-radius: var(--r-sm); }
  .conv::-webkit-scrollbar-track { background: transparent; }
  /* `--lh-snug` AND NOT THE LITERAL `1.5` IT WAS, changed 2026-08-11 under the
     instruction *"font size needs to be reduced on the contact page to make it all
     fit"*. The token's own comment names this exact case -- "dense UI: cells, pills,
     BUBBLES" -- so the bubble was the one place in the file using a prose ratio while
     a bubble ratio sat unused two hundred lines up. 1.35 against 1.5 is ~10% of the
     height of every message in the thread, taken from the leading rather than from
     the letterforms: the type stays at `--fs-sm`, which is what a case worker reads
     all day and is not the thing to shave. A UNITLESS ratio, which is the other half
     of scaling cleanly -- a `px` line-height would stop tracking the text the moment
     anybody raised it. */
  .conv-item { max-width: 78%; padding: var(--sp-2) var(--sp-3); border-radius: var(--r-lg); border: 1px solid #e4e7eb; background: #fff; font-size: var(--fs-sm); line-height: var(--lh-snug);
    /* A long unbroken URL or a pasted identifier must not widen the pane and force
       the whole page sideways. */
    overflow-wrap: anywhere; }
  /* COLOUR CARRIES THE DIRECTION, changed 2026-08-06 on direct instruction: "client
     comms to us should be a different color than our comms to them". Position alone
     is easy to misread in a narrow column and the cost of misreading is replying to
     your own message.

     THE CLIENT'S side is the tinted one, and that is the way round it should be --
     their words are what you opened the pane to find. This used to tint OURS, which
     drew the eye to the automated mail the CRM sent. */
  .conv-item.in  { align-self: flex-start; border-bottom-left-radius: 3px;
                   background: #eef4fb; border-color: #d5e3f3;
                   border-left: 3px solid #3b6ea5; }
  .conv-item.out { align-self: flex-end; border-bottom-right-radius: 3px;
                   background: #fff; border-color: #e6eaee;
                   border-right: 3px solid #cbd2d9; }
  .conv-meta { font-size: var(--fs-eyebrow); color: var(--text-muted); text-transform: uppercase; letter-spacing: .03em; margin-bottom: var(--sp-1); }
  /* ---- THE COMPOSER IS PINNED TO THE BOTTOM ------------------------------
     AND THAT REVERSES A STANDING INSTRUCTION, SO READ THIS BEFORE CHANGING IT BACK.

     This rule used to read "NO `position: sticky`, AND THAT ABSENCE IS THE FEATURE".
     It was `sticky; bottom: 0` until 2026-08-06, when the instruction was explicit
     that the composer "should scroll with the page" -- a panel hovering over a page
     that also scrolled was two things moving under one gesture, and it felt clunky.

     SUPERSEDED 2026-08-08, by the founder, from the browser: *"bring the email text
     editor/composer down below the context thread ... the context window should be
     between the editor below it pinned and the nav bar on the top which should always
     be visible."* Pinned below is now the requirement, and the reason the old
     complaint does not come back with it is that the thing it was about is gone: in
     2026-08-06's arrangement the composer hovered over a PAGE that scrolled, and the
     page was the only scroller. Now the thread has its own bounded scroller directly
     above the composer, so what moves under the gesture is the thread's own contents
     inside their own box -- one scroller, one moving thing, and a fixed frame around
     it. That is the arrangement every mail and message client has.

     `sticky` AND NOT `fixed`, for the same reason the nav is sticky: sticky is
     scoped by its containing block, so the panel stays inside the work column, keeps
     the column's width, and stops being pinned the moment the column ends. A fixed
     composer would hang over the intelligence rail on a stacked layout.

     IT EARNS ITS KEEP MOST WHERE THE COLUMN IS *NOT* PINNED. Above 1000px the column
     is a fixed-height flex box and the composer is already its last child, so this
     changes nothing there except during the first paint before the column locks.
     Below 1000px there is no pinned column at all, and this rule is the whole of
     "pinned below": the reply box rides the bottom of the viewport for as long as
     Hatch's work column is on screen.

     The shadow is not decoration -- it is the only cue that the bubbles are passing
     UNDER the panel rather than ending at it. `z-index` for the same reason: the
     thread is a scroll container above it in the source, and without a layer the
     last bubble paints over the panel's top edge. */
  .conv-composer { background: #fff; position: sticky; bottom: 0; z-index: 3;
                   box-shadow: 0 -6px 14px -10px rgba(31, 41, 51, .45),
                               var(--sh-1); }
  .ws-notyet { border: 1px dashed #cbd2d9; border-radius: var(--r-md); padding: var(--sp-4); color: var(--text-muted); font-size: var(--fs-sm); background: #fafbfc; }
  .ws-notyet strong { color: #52606d; display: block; margin-bottom: var(--sp-1); }

  /* ---- U13 / 7a: THE CLIENT APP VIEW -------------------------------------
     THE BEZEL IS THE FEATURE, NOT THE DECORATION. Direct instruction
     2026-08-08: the right rail is "the intel panel, the app view where we can
     see what the client sees on their app". The panel underneath renders
     correctly with no rule at all -- it is an ordinary stacked list, which is
     why it was safe to ship a day before this block existed -- but a stacked
     list inside a staff card reads as more staff card, and nothing then tells a
     reader which part of that column is OUR view of a veteran and which part is
     THEIRS. That distinction is the entire surface. One frame carries it.

     THE HOOKS ARE `data-ca` ATTRIBUTES AND NOT CLASSES, and that was a correct
     call rather than a workaround to undo now that this file is writable.
     `test_module_split.test_every_class_the_app_renders_has_a_rule` failed the
     panel's first run on thirteen invented `.ca-*` names, and it was right: an
     unstyled class is invisible rather than a placeholder. An attribute claims
     to paint nothing, so the markup never carried a promise the stylesheet had
     not kept. It is also the convention already in use next door --
     `data-ws-panel`, `data-ws-mode`, `data-cqv-src`.

     `.log-body[data-ca="screen"]` AND THE CLASS HALF IS LOAD-BEARING.
     `.log-body` -- the existing inset-panel treatment the screen reuses for its
     colour, line height and scroller -- is declared ~570 lines BELOW this block.
     A bare `[data-ca="screen"]` has identical specificity, so it would lose the
     tie on `max-height`, `padding` and `background` purely on source order, and
     the frame would silently keep the log's 320px box. Qualifying with the class
     the markup already carries wins on specificity instead, which is what lets
     these rules live beside the workspace rules they belong to rather than being
     exiled under the condition-log block for a reason nobody would guess later.

     240px, AND IT IS SIZED FOR THE NARROWER RAIL. The rail is 320px above
     1200px and 280px below it, and `.ws-intel .card` takes 16px of side padding,
     so the tighter case leaves 248px. 240 fits there and centres in the wider
     one; the content box inside bezel and padding is 208px, which is why the
     bubbles below drop to `--fs-xs`. */
  .log-body[data-ca="screen"] {
    max-width: 240px; margin: var(--sp-3) auto; background: #fff;
    border: 8px solid #1f2933; border-radius: 22px; box-shadow: var(--sh-2);
    /* NO TOP PADDING HERE -- the app bar below carries it. See that rule. */
    padding: 0 var(--sp-2) var(--sp-3);
    max-height: 420px; overflow-y: auto;
    /* NO `overscroll-behavior: contain` HERE, AND IT WAS MEASURED. Containment
       made this a dead end: eight wheel events of 2,400px intent, the frame
       absorbed 145px and THE RIGHT RAIL NEVER MOVED AGAIN -- 779px of the rail
       unreachable, because the frame occupies 420 of its 739 visible pixels and
       a pointer resting anywhere over it cannot reach the column behind. Chaining
       is correct here: the wheel scrolls the phone until it bottoms out, then the
       rail. `.conv` has no containment either, for the same reason.
       The bezel is still the feature; only the trap is gone. */
    scrollbar-width: thin; }
  [data-ca="screen"]::-webkit-scrollbar { width: 6px; }
  [data-ca="screen"]::-webkit-scrollbar-thumb { background: #cbd2d9; border-radius: var(--r-sm); }
  [data-ca="screen"]::-webkit-scrollbar-track { background: transparent; }
  /* The app bar stays put while the screen scrolls, which is what an app does,
     and it is why the frame's top padding was moved onto the bar: a sticky
     element pins to the SCROLLPORT edge, so padding above it is space the
     bubbles would slide up into and be read over the app's own name. */
  [data-ca="app-bar"] { position: sticky; top: 0; z-index: 1; background: #fff;
    margin: 0 0 var(--sp-2); padding: var(--sp-3) 0 var(--sp-15);
    border-bottom: 1px solid #e4e7eb; }
  /* The thread, at 208px. `.conv-item`'s 78% (92% under 900px) is a proportion
     tuned for the full pane and leaves a third of a phone screen empty, and its
     `align-self` does nothing here anyway -- this is not a flex container, and
     it deliberately is not one: direction is carried by COLOUR, which is the
     2026-08-06 instruction and survives a column this narrow where position
     does not. */
  [data-ca="screen"] .stack { gap: var(--sp-2); }
  [data-ca="screen"] .conv-item { max-width: 100%; font-size: var(--fs-xs);
    padding: var(--sp-15) var(--sp-2); line-height: 1.45; }
  [data-ca="screen"] ul { margin: var(--sp-1) 0; padding-left: var(--sp-4); }
  [data-ca="screen"] li { margin-bottom: var(--sp-1); }

  /* ---- LABEL AND PILL AS A TABLE ROW, NOT A PARAGRAPH WITH PILLS IN IT ----
     `[data-ca="state"]` (is the app live) and `[data-ca="reach"]` (can we reach
     them today) render the same shape four times over and three times over: a
     label, a pill, and an optional muted reason beneath. Flowing as inline text
     the pill lands wherever its label happens to end, so no two rows agree on a
     pill position and there is nothing to run an eye down -- which on a panel
     whose whole job is "what is on and what is off" is the answer being present
     and unreadable.

     A GRID AND NOT A FLEX ROW, BECAUSE OF THE `<br>`. In a flex container a
     `<br>` becomes a flex ITEM with no content and stops breaking anything, so
     the reason snaps back onto the pill's line. Grid lets the same element be
     removed outright and gives the reason the full width by placement, so the
     two lines are structural rather than resting on an element that only
     behaves in normal flow. The row markup is untouched either way. */
  [data-ca="state"] > div > div,
  [data-ca="reach"] > div {
    display: grid; grid-template-columns: minmax(0, 1fr) auto;
    align-items: baseline; gap: var(--sp-05) var(--sp-2);
    padding: var(--sp-15) 0; border-top: 1px solid #e8ecf0; }
  [data-ca="state"] > div > div:first-child,
  [data-ca="reach"] > div:first-child { border-top: 0; }
  [data-ca="state"] > div > div > br,
  [data-ca="reach"] > div > br { display: none; }
  [data-ca="state"] > div > div > .muted,
  [data-ca="reach"] > div > .muted { grid-column: 1 / -1; line-height: 1.45; }
  /* `.ws-notyet strong` makes every strong inside a dashed box a block with a
     margin under it. Right for that box's own headline, wrong for a row label
     that now has a grid cell of its own. */
  [data-ca="state"] > div > div > strong,
  [data-ca="reach"] > div > strong { margin-bottom: 0; font-weight: 600; }

  /* ---- "NOT BUILT YET" AND "NOT SHOWN" ARE OPPOSITE FACTS ----------------
     They were the same dashed box, and that was the weakest thing on the panel.

     `.ws-notyet` is what this app uses for something that does not exist yet.
     Dashed, muted, provisional: it will be filled in, and reading it that way
     is correct -- the client portal really is unbuilt, which is what
     `[data-ca="state"]` above says and why it keeps the dashed treatment.

     `[data-ca="refused"]` is the OPPOSITE claim. Conditions, ratings, flags,
     priority and case strategy are absent ON PURPOSE, pending a human answer to
     `DISCLOSURE_TIERS.md` open question 5, and the panel is COMPLETE without
     them. Painting a held decision as an unfinished patch invites the one
     reading that gets it helpfully "finished" by somebody who never saw the
     question -- on the surface where the mistake would be disclosing a
     veteran's strategy to them as fact.

     So: a SOLID edge, because the boundary is real and is where it is meant to
     be, and a slate rule down the side for a decision being held rather than a
     feature being awaited. Same box, same padding, opposite signal. The
     specificity is doubled rather than left to source order, because a rule
     that only wins because of where it sits is one an insertion above it
     breaks. */
  .ws-notyet[data-ca="refused"] { border: 1px solid #d5dbe3;
    border-left: 3px solid #52606d; border-radius: 0 var(--r-md) var(--r-md) 0;
    background: #f4f6f8; }
  .ws-notyet[data-ca="refused"] > strong { color: #1f2933; }

  /* ---- The onboarding card, H5 -------------------------------------------
     NINE CLASSES WITH NO RULE ANYWHERE, measured 2026-08-08. `render()` has
     never had a caller, so the whole card was rendering unstyled and nobody had
     seen it -- harmless right up to the moment something calls it, which is
     exactly the shape `test_every_class_the_app_renders_has_a_rule` exists to
     catch and exactly the module that test does not scan. It scans now, and
     these are the rules that let it pass.

     RULES RATHER THAN A CONVERSION TO `data-ob` ATTRIBUTES, and the reason is
     that the two conventions answer different questions. An attribute is the
     honest hook for a STRUCTURAL SEAM the stylesheet has not been asked to
     paint yet -- which is what the Client App panel's `data-ca` hooks were on
     the day they shipped. These are not seams. Every one of them is a piece of
     a card with a stated visual job (a count that must not read as the
     headline, a group heading, a blocking note that must sit under its own
     item), and converting them would move the same nine names into a namespace
     the build-wide guard does not look at. That is not a fix, it is the defect
     with better camouflage.

     THE COUNT IS NOT THE HEADLINE, and that is the module's own rule rather
     than a preference: `onboarding_surface` states plainly that "9 of 15" is
     never a percentage, because a percentage invites a threshold and a
     threshold is the locked door `PLAN_HANDOVER.md` 6a refuses. It already
     carries `muted`, which sets the colour, so all this adds is the spacing and
     digits that line up between two of these cards side by side. DELIBERATELY
     NOT uppercase or bold: the same class is also used on a sentence, and a
     rule that assumes its element is a two-word count would set that sentence
     in shouting capitals.

     `.ob-clear` IS NOT PAINTED GREEN, and that is the same restraint. It reads
     "Nothing outstanding" in one place and "nothing computes a capture picture
     yet" in another -- good news and a stated limitation, one class. Colour
     would be asserting which, and it would be wrong half the time. The words
     say it; this only makes them full strength instead of muted. */
  .ob-count { margin: 0 0 var(--sp-2); font-variant-numeric: tabular-nums; }
  .ob-lead { margin: 0 0 var(--sp-3); color: #3e4c59; }
  .ob-clear { margin: 0; color: #3e4c59; }
  .ob-clear strong { color: #1f2933; }
  /* The group heading IS the answer to "who is this waiting on", so it is the
     loudest thing on the card and carries its own count. */
  .ob-who { font-size: var(--fs-eyebrow); text-transform: uppercase;
    letter-spacing: .04em; color: #52606d; margin: var(--sp-3) 0 var(--sp-1);
    padding-bottom: var(--sp-05); border-bottom: 1px solid #e8ecf0; }
  .ob-list { list-style: none; margin: 0; padding: 0; }
  .ob-list > li { padding: var(--sp-15) 0; border-top: 1px solid #f0f3f6;
    line-height: 1.5; }
  .ob-list > li:first-child { border-top: 0; }
  .ob-list > li > strong { margin-right: var(--sp-1); }
  /* What the gap BLOCKS, which is the sentence that turns a checklist into a
     reason to act. Muted, because the item is the thing being scanned. */
  .ob-blocks { font-size: var(--fs-xs); line-height: 1.45; }
  .ob-ask { margin: var(--sp-4) 0 0; padding-top: var(--sp-2);
    border-top: 1px solid #e8ecf0; font-size: var(--fs-xs); line-height: 1.5; }
  /* A MEASURE ON THE PROSE AND NOT A WIDTH ON THE CARD. This card is mostly
     sentences -- what a gap blocks, and why asking for the document beats
     asking for the value -- and a paragraph running the full width of a page
     card is the thing that makes a text-heavy surface unreadable. Constraining
     the CARD instead would also squeeze anything tabular somebody puts in it,
     which is the wrong trade on a surface whose whole point is one row per
     criterion. */
  .ob-card p { max-width: 68ch; }
  /* RESPONSIVE: THREE COLUMNS FOR AS LONG AS THERE ARE THREE, THEN ONE.
     ==========================================================================
     THE LEFT RAIL IS THE CLIENT'S STATIC DATA AND THE RIGHT RAIL IS THE
     INTELLIGENCE PANEL AND THE APP VIEW. Direct instruction 2026-08-08, from the
     browser: *"you moved a rail from the right to the left, push it back right ...
     the context window and editor should sit between a left and right rail, the left
     rail is the clients static data, the right is things like the intel panel, the
     app view where we can see what the client sees on their app."*

     THIS REVERSES A CHANGE MADE EARLIER THE SAME DAY (c2f1ea3, "U1"), and the
     reversal is deliberate rather than a regression. That pass found a real defect --
     `.ws-intel { grid-column: 2 }` put the utility rail in ROW TWO, underneath the
     entire activity feed -- and fixed it by moving the rail to `grid-column: 1`,
     under the CONTEXT rail. That got it off the page bottom and it also put a
     right-hand rail on the left, at every width below 1200px, which is exactly what
     was then reported from the browser. Both of those layouts were wrong; the
     original diagnosis was right and the remedy was not.

     THE REMEDY IS TO STOP DROPPING A COLUMN AT 1200px AT ALL. 260 + 320 of rails
     plus two 16px gaps is 612px, so at 1200px there were still ~540px left for the
     work column -- the two-column fallback was never needed there. The rails simply
     get narrower and the arrangement is preserved, which is the thing that was
     actually being asked for: the same three zones in the same three places, all the
     way down until there is genuinely not room for them.

     AND WHEN THERE IS NOT ROOM, IT GOES STRAIGHT TO ONE COLUMN. No intermediate
     two-column state, because every two-column arrangement of three zones puts
     something where it does not belong -- that is the whole history of this block.
     1000px is where the middle column would fall under ~420px, which is narrower
     than a message thread can usefully be. Below it the page is a single stack in
     reading order: who this is, the conversation, the utilities.

     The stack breakpoint is 1000px and NOT the 900px that the rest of the tablet
     rules use, on purpose: 900px is a touch-target and type-scale threshold and this
     is a "do three columns fit" question. They are different questions and they had
     been sharing a number. */
  @media (max-width: 1200px) { .ws { grid-template-columns: 220px minmax(0,1fr) 280px; } }
  @media (max-width: 1000px) {
    .ws { grid-template-columns: minmax(0,1fr); }
    .ws-rail, .ws-intel { grid-column: 1; }
  }
  @media (max-width: 900px) {
    .conv { max-height: clamp(17.5rem, calc(100vh - 20rem), 38.75rem); }
    .conv-item { max-width: 92%; }
    /* THE TABS WRAP. They used to be `flex-wrap: nowrap` with `overflow-x: auto` and
       the scrollbar explicitly hidden, which meant Flags, Case, Documents, Road Map and
       Timeline scrolled off the right edge WITH NO AFFORDANCE OF ANY KIND saying they
       existed. Reported as "the Hatch nav is dead". The routing was always fine -- all
       six panes return 200 -- and MANIFEST's note on U4 said not to replace working
       routing to fix a UI symptom, which is exactly what this was.

       Corrected 2026-08-08 (U1). Six short items wrapping onto a second row costs one
       row of height; hiding five of them costs the feature. Note this is the OPPOSITE
       call to the one made for `.topbar nav` below, and deliberately: that one carries
       nine links measuring ~990px against 332px of usable width, so wrapping gives
       three rows and a 206px top bar before any content, and it keeps a thin scrollbar
       as its overflow hint. Six tabs are not nine links. The real fix for both is a
       collapsed menu, which needs HTML and belongs to R1. */
    .ws-tabs { flex-wrap: wrap; }
  }
  /* The `z-index: 5` that used to sit here went with the sticky positioning: it existed
     so the pinned composer painted above the bubbles sliding under it, and an ordinary
     in-flow block below a scroll container overlaps nothing. */

  /* ---- HATCH IS AN APP SHELL, NOT A DOCUMENT ------------------------------
     Direct instruction 2026-08-08, from the browser, and it is ONE requirement
     rather than the five it looks like: *"the left and right rails should scroll
     individually not together, the left should only scroll if there is additional
     information below the fold, currently there is not, the nav header just above
     the context communications window should be pinned in place and not scroll up
     or down, the page should look static with a right side rail scroll, the main
     nav should not move at all."*

     THE PAGE DOES NOT SCROLL. REGIONS INSIDE IT DO. Everything he listed falls out
     of that one sentence, so it is built as one height chain rather than five
     patches:

       TOP BAR      cannot move, because the thing it would move against is gone
       USER BAR     same
       -----------------------------------------------------------------------
       TAB STRIP    | pinned: first child of a column that is not a scroller
       LEFT RAIL    | THREAD, its own scroller  | RIGHT RAIL, its own scroller
       own scroller | ------------------------- |
       only if      | COMPOSER, pinned bottom   |
       needed       |                           |

     WHAT THIS REPLACES, AND WHY THE OLD ANSWER COULD NOT GET THERE. Until this
     pass `.ws-main` was `position: sticky` inside a page that was still the only
     scroller. That pins ONE of the three columns and leaves the other two riding
     the page -- which is exactly the report: "the left and right rails should
     scroll individually not together". A sticky item cannot give its siblings
     scrollers of their own, because they have no bounded height to scroll inside.
     Nothing short of a height chain from the viewport down does that.

     AND IT MEASURES NOTHING, WHICH IS THE PART THAT USED TO BE IMPOSSIBLE. The
     rule this replaces carried the honest objection that a viewport-height shell
     "would have to subtract the top bar, the user bar, `main`'s padding, the page
     title and the back link -- five numbers this file does not know". That is true
     of `calc()` and false of flexbox: `body` is a column, everything above `main`
     keeps its own height, `main` takes what is left. The bar can wrap onto three
     rows and the arithmetic is still right, with no script and no literal. So
     `--nav-h` is no longer consumed here -- `.cp-note` is `position: fixed` and
     still genuinely needs it; this does not, and a measurement that is not needed
     is a measurement that can be wrong.

     `:has()` AND NOT A BODY CLASS, WHICH IS A DELIBERATE TRADE. `road_map_app._page`
     renders one `<body>` for every route of both tenants and takes no class
     argument; adding one would be a change to the module under a size ratchet, to
     tell the stylesheet something the DOM already says. `body:has(.ws-hatch)` reads
     the fact off the markup instead. THE DEGRADATION IS THE POINT OF WRITING EVERY
     RULE THIS WAY: a browser without `:has()` drops the whole block as one unit and
     gets an ordinary scrolling document with a bottom-pinned composer -- which is
     precisely the sub-1001px fallback below, a shape that is known to work. The
     precedent and the same argument are at `body:has(.topbar details[open])`.

     WHERE EACH `min-height: 0` IS AND WHY. Every one of them is load-bearing; a
     flex or grid child refuses to shrink below its content without it, and the
     scroller then silently moves back to the page while looking almost right.
       * `> main`      -- flex child of body, holds the grid
       * `.ws-hatch`   -- flex child of main, and `grid-template-rows: minmax(0,1fr)`
                          is the grid's own form of the same fix for its ROW
       * the two rails -- grid children
       * `.ws-main`    -- grid child, and a flex parent in turn
       * `.conv`       -- flex child of `.ws-main`

     `overflow-y: auto` ON THE RAILS AND NEVER `scroll`. "The left should only
     scroll if there is additional information below the fold, currently there is
     not" -- a permanent gutter on a rail that fits is the defect being named. No
     `scrollbar-gutter` either, for the same reason.

     GATED ON HEIGHT AS WELL AS WIDTH, and the gate is unchanged. Below 620px of
     viewport there is not enough room to divide between a composer and a thread.
     Below 1001px the rails are stacked above and below the work column, so
     "the rails scroll individually" is not a thing that can be expressed -- and a
     shell on a phone pins the middle of a document and traps the reader. There the
     page scrolls as a page and the composer's own pinning keeps the reply box
     reachable. The width gate tracks the grid's stack breakpoint above and must
     keep tracking it.

     ON PAPER NONE OF IT APPLIES: this is `@media screen`, so a printer never sees
     the block at all. `@media print` un-does it a second time anyway, belt to this
     brace, because the cost of being wrong is a case file carrying one screen. */
  @media screen and (min-width: 1001px) and (min-height: 620px) {
    /* 1. THE FRAME. `overflow` on `<html>` propagates to the viewport, so this is
       the hard guarantee that the page itself cannot scroll -- and therefore that
       the top bar cannot be scrolled past, vertically OR horizontally. The bar is
       still `position: sticky` for every other view in the product; here it simply
       has nothing left to stick to. */
    html:has(.ws-hatch), body:has(.ws-hatch) { height: 100%; overflow: hidden; }
    /* `dvh` as a SECOND declaration rather than in place of the `%` one, the
       convention this file already uses: a browser that does not know `dvh` keeps
       the working line above instead of being left with no height at all. */
    body:has(.ws-hatch) { display: flex; flex-direction: column; height: 100dvh; }
    /* 2. The top bar and the user bar keep their own height and cannot be squeezed
       -- a flex item's default `flex-shrink: 1` would compress the nav before it
       compressed the thread, which is the opposite of the order of importance. */
    body:has(.ws-hatch) > * { flex: 0 0 auto; }
    /* 3. `main` is the elastic band, and a column in its own right so the back link
       above the grid keeps its natural height.

       `width: 100%` IS NOT REDUNDANT AND IS THE ONE NON-OBVIOUS LINE IN THIS BLOCK.
       `main` is `max-width: 1240px; margin: 0 auto`, and a flex item with AUTO
       cross-axis margins is explicitly not stretched -- so as a flex child it would
       shrink-to-fit its own max-content width instead of filling the window, and the
       three columns would visibly narrow the moment this shell engaged. The auto
       margins still do the centring; this only restores the width they centre. */
    body:has(.ws-hatch) > main { flex: 1 1 auto; min-height: 0; overflow: hidden;
                                 width: 100%;
                                 display: flex; flex-direction: column; }
    body:has(.ws-hatch) > main > * { flex: 0 0 auto; }
    /* 4. The grid takes whatever is left. `align-items: stretch` overrides the
       `start` the general rule sets -- without it the three columns are as tall as
       their own content and there is nothing for a rail to scroll inside. */
    body:has(.ws-hatch) .ws.ws-hatch { flex: 1 1 auto; min-height: 0;
                                       grid-template-rows: minmax(0, 1fr);
                                       align-items: stretch; }
    /* 5. THREE INDEPENDENT SCROLLERS. This is the whole report answered. */
    body:has(.ws-hatch) .ws-rail,
    body:has(.ws-hatch) .ws-intel { min-height: 0; overflow-y: auto;
                                    overscroll-behavior: contain;
                                    scrollbar-width: thin; }
    body:has(.ws-hatch) .ws-rail::-webkit-scrollbar,
    body:has(.ws-hatch) .ws-intel::-webkit-scrollbar { width: 9px; }
    body:has(.ws-hatch) .ws-rail::-webkit-scrollbar-thumb,
    body:has(.ws-hatch) .ws-intel::-webkit-scrollbar-thumb {
      background: #cbd2d9; border-radius: var(--r-sm); }
    body:has(.ws-hatch) .ws-rail::-webkit-scrollbar-track,
    body:has(.ws-hatch) .ws-intel::-webkit-scrollbar-track {
      background: transparent; }
    /* 6. THE WORK COLUMN IS NOT A SCROLLER, and that is what pins the tab strip.
       `overflow: hidden` here means the strip -- the first child, fixed height --
       has nothing to scroll out of. No second `position: sticky`, no `top` to keep
       in step with anything, and the tabs cannot wrap themselves out of view. */
    body:has(.ws-hatch) .ws-hatch .ws-main { min-height: 0; overflow: hidden;
                                             display: flex; flex-direction: column; }
    /* Everything except the thread keeps its natural height. The tabs and the
       composer are genuinely fixed-size; only the thread is elastic, so growing the
       composer takes height FROM THE THREAD rather than from the bottom of the
       screen. The channel-state note is the one exception and it is declared below:
       it keeps its natural height too, right up until the thread has reached its
       floor, and then it is what gives. */
    body:has(.ws-hatch) .ws-hatch .ws-main > * { flex: 0 0 auto; }
    /* 6b. A WORK COLUMN WITH NO THREAD IN IT MUST SCROLL ITSELF. Reported by the
       founder 2026-08-09: "juggernaut main page doesn't scroll".
       Rule 6 above is right for Hatch and wrong for everybody else, and the
       reason is worth stating because the next surface to reuse this shell will
       hit it too. Hatch's work column is a fixed tab strip, an ELASTIC `.conv`
       thread that scrolls inside itself, and a composer -- so `overflow: hidden`
       there is what pins the tab strip and stops the tabs scrolling out of view.
       Juggernaut borrowed the same shell and has NO `.conv`. Every one of its
       children is `flex: 0 0 auto` inside a hidden overflow, so the scoreboard,
       the indicators card and the roster simply ran off the bottom of the
       viewport with no way to reach them. The page was not short -- it was
       clipped.
       `:not(:has(.conv))` asks the DOM the question directly rather than adding a
       class every future page must remember to set, which is the same reasoning
       `body:has(.ws-hatch)` uses twenty lines above: a rule each new route has to
       remember is not a rule. A pane that grows its own thread later becomes a
       non-scroller again automatically, with nothing to update. */
    body:has(.ws-hatch) .ws-hatch .ws-main:not(:has(.conv)) {
      overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
    body:has(.ws-hatch) .ws-hatch .ws-main:not(:has(.conv))::-webkit-scrollbar {
      width: 9px; }
    body:has(.ws-hatch) .ws-hatch .ws-main:not(:has(.conv))::-webkit-scrollbar-thumb {
      background: #cbd2d9; border-radius: var(--r-sm); }
    body:has(.ws-hatch) .ws-hatch .ws-main:not(:has(.conv))::-webkit-scrollbar-track {
      background: transparent; }
    /* Specificity is deliberate and is now doubled: `body.cmp-collapsed .conv`
       (0,2,1) outranks a plain `.conv` rule in any media query, and it also ties
       with `body:has(.ws-hatch) .conv` and wins on source order. `.ws.ws-hatch`
       inside the guard is what takes it clear, without touching the three fold
       rules, which the fold still needs everywhere this block does not apply. */
    body:has(.ws-hatch) .ws.ws-hatch .conv { flex: 1 200 auto;
                                             min-height: min(7.5rem, 20vh);
                                             max-height: none; margin-bottom: 0; }
    /* THE THREAD HAD NO FLOOR AND IT REACHED ZERO INSIDE THE SHELL'S OWN GATE.
       Measured 2026-08-08 in a real browser: 0px tall at 1440x620 and 1440x660,
       21px at 1440x700, 0px at 1024x768 -- the pane's primary content absent at
       viewports `min-height: 620px` declares supported. Every sibling was
       `flex: 0 0 auto`, so the thread was whatever the fixed children left, and
       what they left was nothing: 38px of tabs, 252px of composer, 24px of
       margins and a 193px "Not every channel is connected yet" notice inside a
       459px column. That notice renders whenever email and SMS are unwired,
       which is production's state per SETUP_COMMS.md, so this was the live
       shape and not an edge case.

       `min-height: 7.5rem` IS THE FLOOR AND THE NOTICE IS WHAT YIELDS TO GIVE
       IT ONE. A floor alone would have moved the defect rather than fixed it:
       `.ws-main` is `overflow: hidden` by design (it is what pins the tab
       strip), so anything past the column's height is not clipped-but-
       scrollable, it is gone -- 59px of it at 1440x620 and 8px at 1024x768,
       which is the second measured defect and has the same root. So the notice
       becomes shrinkable WITH ITS OWN SCROLLER below, and nothing in it stops
       being reachable.

       WHY `200` AND NOT `1`, WHICH IS THE ONE NON-OBVIOUS NUMBER HERE. Shrink is
       distributed in proportion to flex factor TIMES flex base size, and the
       thread's base is its own content -- about 7,000px on a real thread against
       the notice's 193. An equal factor therefore hands the notice 2.7% of a
       6,671px deficit and collapses a 193px notice to 12px at 1440x900, a
       viewport where nothing needed to yield at all (measured). A dominant
       factor keeps the notice at its natural height until the thread has
       actually reached its floor, and only then does the notice give. Any factor
       past ~100 produces the same geometry; 200 is round and has margin.

       AND THE PADDING COMES DOWN WITH IT, WHICH IS NOT COSMETIC. `min-height: 0`
       lets the CONTENT box reach zero and does nothing to padding or border, so
       `.ws-notyet`'s 16px of vertical padding put an irreducible 34px floor under
       a box that had 25px to live in -- measured: 20px still hidden at 1440x620
       with everything else already correct. 8px of padding and an 8px margin take
       that floor to 18px, which fits with room to spare at the shortest supported
       viewport and reads no differently at any taller one.

       AND THE TYPE COMES DOWN WITH IT, ADDED 2026-08-11, because the founder's
       *"font size needs to be reduced on the contact page to make it all fit"* is
       first of all about THIS BOX. Measured at 1440x900 it was 176px -- the tallest
       thing on the surface after the composer, and the only one nobody needs to read
       twice: it says which channels are unwired, which is production's standing state
       per `SETUP_COMMS.md` and therefore a sentence a case worker has already read
       every day this month. At 1152x720 -- an ordinary laptop at 125% browser zoom --
       it was being crushed to 117px of a 176px box, so a third of it was behind a
       scrollbar Chromium only draws while you are scrolling; the page LOOKED cut off
       mid-word. `--fs-xs` with `--lh-snug` takes it to about 130px at rest, which is
       ~46px handed straight back to the thread, and at 125% it now fits without
       being crushed at all. Both are existing tokens and the reduction is scoped to
       this one box on this one shell: `.ws-notyet` is the product's general
       does-not-exist-yet treatment and appears on surfaces nobody complained about. */
    body:has(.ws-hatch) .ws-hatch .ws-main > .ws-notyet {
      flex: 0 1 auto; min-height: 0; overflow-y: auto; scrollbar-width: thin;
      font-size: var(--fs-xs); line-height: var(--lh-snug);
      padding: var(--sp-2) var(--sp-3); margin-bottom: var(--sp-2); }
    /* A CRUSHED BOX HAS TO LOOK LIKE A SCROLLER OR IT LOOKS LIKE A BUG, and that is
       the actual complaint behind *"breaks from containers"*. This notice is the one
       thing on the surface designed to yield, and it yields by scrolling -- but
       Chromium draws an OVERLAY scrollbar, which is invisible until you are already
       scrolling. So at 125% browser zoom the box read as a sentence chopped off after
       the word "sms": a reader has no way to tell "there is more below" from "the
       layout broke". Measured before this rule: 60px of 176 hidden with no mark of
       any kind.

       THE CLASSIC PURE-CSS SCROLL SHADOW, and the two `background-attachment` values
       are the entire mechanism rather than decoration. The COVER layers are `local`,
       so they travel with the content and sit exactly over the top and bottom edges
       of it; the SHADOW layers are `scroll`, so they stay pinned to the box. At the
       very top the cover hides the top shadow and only the bottom one shows; scroll
       down and the cover moves away, revealing it. The result is a cue that is
       present exactly when there is something out of view and absent when there is
       not -- which is the same standard `overflow-y: auto` and not `scroll` is held
       to five rules above: never a gutter on a box that fits.

       NO JAVASCRIPT, NO MEASUREMENT, NO MARKUP. It is four background layers on a
       box that already exists, it degrades to the flat `#fafbfc` this box always
       had in any engine that drops a layer, and it costs nothing on the surfaces
       that never crush the notice because there the covers sit on the shadows. */
    body:has(.ws-hatch) .ws-hatch .ws-main > .ws-notyet {
      background:
        linear-gradient(#fafbfc 40%, rgba(250, 251, 252, 0)) top / 100% 14px
          no-repeat local,
        linear-gradient(rgba(250, 251, 252, 0), #fafbfc 60%) bottom / 100% 14px
          no-repeat local,
        radial-gradient(farthest-side at 50% 0,
                        rgba(31, 41, 51, .17), rgba(31, 41, 51, 0)) top / 100% 7px
          no-repeat scroll,
        radial-gradient(farthest-side at 50% 100%,
                        rgba(31, 41, 51, .17), rgba(31, 41, 51, 0)) bottom / 100% 7px
          no-repeat scroll,
        #fafbfc; }
    /* THE BACK LINK'S OWN WHITESPACE, WHICH IS 28px OF NOTHING IN A COLUMN THAT IS
       SHORT. `main`'s only other child above the grid is the `← Workspace` paragraph,
       and `p`'s default `margin: 1em 0` puts 14px above and 14px below a 22px line.
       Inside this shell that margin is not separating a paragraph from prose -- it is
       taken out of the thread, because the grid gets what `main` has left. `--sp-2`
       above and below keeps the link clear of the top bar and returns 12px. Scoped
       inside the gate, so every other page's paragraphs are untouched and the shell's
       own fallback below 1001px is untouched with them. */
    body:has(.ws-hatch) > main > p { margin-top: var(--sp-2);
                                     margin-bottom: var(--sp-2); }
    /* The gap is ABOVE the composer, because the composer is underneath the thread.
       It was `margin-bottom` when the panel sat at the top of the column; leaving it
       there would have put a white strip between the composer and the bottom edge it
       is pinned to. The composer's own `sticky; bottom: 0` is untouched -- it is what
       keeps the reply box reachable below this block's gate, and inside the gate it
       is simply the last child of a fixed-height column. */
    body:has(.ws-hatch) .ws-hatch .conv-composer { margin-top: var(--sp-3);
                                                   margin-bottom: 0; }

    /* ---- THE COMPOSER IS THE LAST THING TO YIELD, AND IT YIELDS BY SCROLLING ----
       ADDED 2026-08-11, and it is the fix for the defect the founder reported as
       *"user should be able to scale up the page without breaks from containers."*

       WHAT WAS ACTUALLY BROKEN, MEASURED IN CHROMIUM BEFORE THIS RULE. Scale the
       type up -- Chromium's own "minimum font size" setting, which is the
       accessibility affordance a case worker reading claims all day reaches for --
       and the work column swallowed content whole:

         type at 100%   nothing hidden
         type at 150%   the work column hides 153px, and there is no way to reach it
         type at 200%   the work column hides 228px at 1440x900, 515px at 1152x720;
                        the composer OVERFLOWS ITS PARENT by 123px; and the channel
                        tabs paint straight over the Hatch / Flags / Case / Documents
                        / Road Map / Timeline strip -- seven controls unclickable,
                        one panel's text sitting on top of another's

       THE CAUSE IS ONE DECLARATION AND IT IS NOT THE FONT SIZE. `.ws-main` is
       `overflow: hidden` by design (rule 6: it is what pins the tab strip), so
       anything past the column's height is not clipped-but-scrollable, it is GONE --
       the same root the 2026-08-08 pass found and fixed for the notice. Every child
       was `flex: 0 0 auto` except the thread and the notice, so those two were the
       whole shrink pool; once the thread hit its floor and the notice hit zero, the
       surplus had nowhere left to go and the composer simply grew off the bottom of
       a box that clips. A container sized by a viewport minus a stack of rigid
       children is a container that cannot survive its children getting bigger, and
       type scale is exactly how they get bigger.

       SO THE COMPOSER BECOMES SHRINKABLE AND A SCROLL REGION. Nothing is lost at any
       scale: what does not fit is reachable by scrolling the panel, which is what
       `overflow: hidden` refused to offer. `min-height: 0` is what lets a flex child
       go below its content at all -- the same link this whole chain is made of.

       `flex-shrink: 0.06` AND NOT `1`, WHICH IS THE ORDER OF IMPORTANCE WRITTEN AS A
       NUMBER. Shrink is distributed as factor x base size, so a bare `1` against the
       notice's `1` would take three quarters of every deficit out of the reply box
       (539px of composer against 176px of notice) and start scrolling Send away on a
       screen where the standing "not every channel is connected" notice still had
       plenty to give. 0.06 x 539 is ~32 against the notice's 176, so the notice
       yields ~85% of the first deficit and the composer only starts giving once the
       notice is spent. Same technique and same reasoning as the thread's `200`
       twenty lines up, pointed the other way.

       AND THE HEAD STAYS PINNED INSIDE IT, WHICH IS THE PART THAT IS NOT NEGOTIABLE.
       `.cmp-head` holds the four channel tabs and the privacy divider -- left of the
       rule reaches the veteran, right of it never does -- and it is the FIRST child,
       so a panel that scrolls is a panel whose privacy boundary scrolls out of the
       top. That is a safety regression, not a cosmetic one, and it is the exact
       trade `composer.py` says is not close: if a compact panel ever made the
       divider harder to read, the divider wins. So the head sticks to the top of the
       panel's own scrollport and is opaque, and the tabs are visible at every scale
       at which the panel is visible at all. THE CARD'S TOP PADDING MOVES ONTO THE
       HEAD to make that work, rather than the head being pulled up by a negative
       offset: a sticky element pins to the SCROLLPORT edge, so 12px of padding above
       it is 12px the tabs would slide up into and be read through. It is the same
       move, for the same reason, that `[data-ca="app-bar"]` records six hundred lines
       up -- and doing it with padding instead of `calc()` keeps this block free of
       the arithmetic `test_conversation_order.test_the_shell_measures_nothing` bans.

       IT DELIBERATELY HAS NO FLOOR, AND A FLOOR WAS TRIED AND MEASURED AND REVERTED
       ON THE SAME DAY -- record it here so the next reader does not re-derive it.
       The thread has one (`min-height: 7.5rem`) and the obvious symmetry is to give
       the composer one too, because with the type at 200% in a 1152x720 window the
       panel shrinks to 78px, which is the tabs and nothing else. `min(7.5rem, 26vh)`
       was added for exactly that reason and it PUT THE CLIPPING BACK, worse than
       before it: the work column went from hiding nothing to hiding 153px at
       1152x720 with the type at 150%, 218px at 1001x620 with it at 125%, and 66px at
       1001x620 with the type at its ordinary size -- plus the channel tabs painting
       over the Hatch/Flags/Case tab strip again at 150%. The reason is the whole
       lesson of this block: the LAST resort in a chain cannot have a minimum. Every
       floor above it is a promise the column must keep, and a floor on the one child
       that was supposed to absorb whatever is left over means there is nothing left
       to absorb it, so `.ws-main`'s `overflow: hidden` eats the difference again.
       A 78px panel is a panel you scroll; a clipped one is content that is gone. */
    body:has(.ws-hatch) .ws-hatch .ws-main > .conv-composer {
      flex: 0 0.06 auto; min-height: 0; padding-top: 0;
      overflow-y: auto; overscroll-behavior: contain; scrollbar-width: thin; }
    body:has(.ws-hatch) .ws-hatch .ws-main > .conv-composer::-webkit-scrollbar {
      width: 9px; }
    body:has(.ws-hatch) .ws-hatch .ws-main
      > .conv-composer::-webkit-scrollbar-thumb {
      background: #cbd2d9; border-radius: var(--r-sm); }
    body:has(.ws-hatch) .ws-hatch .ws-main
      > .conv-composer::-webkit-scrollbar-track { background: transparent; }
    body:has(.ws-hatch) .ws-hatch .ws-main > .conv-composer > .cmp-head {
      position: sticky; top: 0; z-index: 4; background: #fff;
      padding-top: var(--sp-3); padding-bottom: var(--sp-15); margin-bottom: 0; }
  }

  /* ---- THE THREAD TAKES BACK WHAT THE FOLDED EDITOR GIVES UP --------------
     Direct instruction 2026-08-06: "the panel is too tall eating too much into the
     chat window itself, make it collapsable upwards so more text can be read then
     the editor can be accessed when needed."

     THE CLASS IS ON <body>, and that is the only reason this needs no JavaScript
     measuring anything. `#convThread` and `#composer` are SIBLINGS rendered by two
     different modules, so there is no wrapper to hang a state class on -- body is
     the nearest ancestor they share. `composer` toggles it on a click; every rule
     that answers it is here, so the fold is one class in one place rather than a
     height calculation that has to re-run on every resize, every wrap of the tabs
     and every refresh of the thread.

     About 145px comes back -- the destination line, the reply/subject row, the
     two-row textarea and the send row that now carries the Wingman bar. It was 210px
     before the 2026-08-08 compaction, which is a smaller prize for the same click and
     the right direction to have moved: the fold matters less the less there is to
     fold away. The numbers below are unchanged and are deliberately generous. So the clamp's
     subtrahend drops by roughly that much AND its ceiling rises: without raising
     the ceiling a tall screen would stop the thread at 720px and put the reclaimed
     height into white space, which is the opposite of what was asked for.

     WHY THERE ARE THREE OF THESE. `body.cmp-collapsed .conv` outranks every plain
     `.conv` rule in this file on specificity, no matter which media query that rule
     sits in -- including the phones section at the end, whose whole arrangement is
     about winning on source order. So each breakpoint that clamps the thread needs
     its own collapsed pair here, or the narrow ones would silently inherit the
     desktop number. */
  body.cmp-collapsed .conv { max-height: clamp(20rem, calc(100vh - 11.875rem), 58.75rem); }
  @media screen and (max-width: 900px) {
    body.cmp-collapsed .conv { max-height: clamp(17.5rem, calc(100vh - 10rem), 53.75rem); }
  }
  @media screen and (max-width: 700px) {
    /* `dvh` for the same reason the phones block gives: `vh` on iOS measures the
       large viewport, so a `vh` clamp overshoots by the height of the URL bar. */
    body.cmp-collapsed .conv { max-height: clamp(15rem, calc(100dvh - 11.25rem), 47.5rem); }
  }
  .topbar .who { font-size: var(--fs-sm); color: #c9d4e6; }
  .topbar .who a { color: #fff; margin-left: var(--sp-2); }
  main { max-width: 1240px; margin: 0 auto; padding: var(--sp-6); }
  /* THE PAGE TITLE, and it was the quietest thing M3 has a name for.
     `h1` was 24px -- Headline SMALL -- so every page in the product opened
     at the bottom of the headline range and nothing said, at a glance,
     which of the things on screen was the page. Headline Large now.

     THE SPACE ABOVE IT GREW WITH IT, and that is the whole reason this is
     two changes rather than one: `margin-top` was `--sp-1`, four pixels,
     which under a 32px heading is a bigger heading in the same cramped
     hole. A page that is louder and no clearer is worse than a quiet one.
     24px above (48px from the viewport, with `main`'s own padding, which
     does not collapse into a margin) and 16px below, so the title is
     nearer the content it names than the chrome above it.

     `line-height` and `letter-spacing` are on it for the first time.
     `--lh-body`'s 1.5 was inherited from `body` and is a PROSE ratio: at
     32px it opens a two-line title to 96px. `--lh-tight` is what the
     token block already reserved for display and headings.

     `h2` moves 16px -> 18px in the same pass. M3's ladder from Headline
     Large is Title Large (22) and then Title Medium (16); 32 straight to
     16 with nothing between is not a ladder, it is two unrelated sizes.
     18px is the nearest step this scale already has, and it keeps a real
     gap above `h3`'s 14px. Every scoped `.foo h2` sets its own size and
     still wins, so this reaches the bare ones only. */
  h1 { color: #1f3864; font-size: var(--fs-headline); line-height: var(--lh-tight);
       letter-spacing: var(--ls-tight); margin: var(--sp-6) 0 var(--sp-4); }
  h2 { color: #1f3864; font-size: var(--fs-lg); line-height: var(--lh-tight);
       margin: var(--sp-6) 0 var(--sp-3); }
  /* THERE WAS NO `h3` RULE, and the hierarchy was inverted because of it.
     A bare <h3> fell through to the browser default -- 1.17em of 14px, so
     ~16.4px in near-black -- while the <h2> above it is 16px navy. The
     subordinate heading rendered LARGER than its parent and in a different
     colour family, on /questions and anywhere else outside a scoped block.
     Every scoped `.tile h3` / `.cond-card h3` / `.hs-beat h3` sets its own
     size and still wins on specificity, so this only reaches the bare ones.
     `h4` is folded in at the same size and a muted colour: below h3 the
     distinction people actually read is weight and colour, not another step
     down the scale -- a fifth size here would be a fifth thing to keep true. */
  h3 { color: #1f3864; font-size: var(--fs-base); font-weight: 700;
       line-height: var(--lh-snug); margin: var(--sp-4) 0 var(--sp-2); }
  h4 { color: var(--n-700); font-size: var(--fs-base); font-weight: 700;
       line-height: var(--lh-snug); margin: var(--sp-4) 0 var(--sp-2); }
  .card { background: #fff; border-radius: var(--r-lg); padding: var(--sp-4) var(--sp-6); box-shadow: var(--sh-1); }
  /* VERTICAL RHYTHM BETWEEN CARDS, which this file never had. `.card` set no
     margin at all, so two adjacent cards touched -- 22 flush pairs across 13
     rendered pages, and two white rounded boxes with no gap read as one box
     that has broken rather than as two things. Every surface that noticed
     patched itself instead: `.fl-alarm`, `.fl-empty`, `.itfw` and `.asw` each
     carry their own `margin-bottom`, which is the same primitive written four
     times and the reason the fifth surface did not get one.

     ADJACENT SIBLING MARGINS COLLAPSE, so this does not double up on any of
     those four -- 16 against 16 stays 16. It is the ADJACENCY selector and not
     a margin on `.card` itself, deliberately: cards are grid and flex children
     in the workspace rails and in `.tiles`, where a trailing margin would fight
     the container's own `gap`. */
  .card + .card { margin-top: var(--sp-4); }
  /* A card's first child must not add its own top margin on top of the
     card's padding. `h2` carries `margin-top: 22px`, so almost every card
     in the app opened with 40px of dead space while a handful had 18px --
     the difference being whether somebody had noticed and patched that one
     card with an inline `style="margin-top:0"`. Those inline patches are
     now redundant rather than wrong; removing them is a later, separate
     sweep. */
  .card > :first-child { margin-top: 0; }
  .card > :last-child { margin-bottom: 0; }
  .muted { color: var(--text-muted); font-size: var(--fs-sm); }
  .error { color: #c0392b; font-weight: 600; }
  table.grid { border-collapse: collapse; width: 100%; }
  table.grid th { text-align: left; color: #52606d; font-size: var(--fs-eyebrow); text-transform: uppercase; letter-spacing: 0.03em;
                  padding: var(--sp-2) var(--sp-3); border-bottom: 2px solid #e3e8ee; }
  table.grid td { padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid #eef1f4; vertical-align: top; }
  /* A TOTALS ROW, which the app had no treatment for. `/admin/activity` made every
     footer cell a `<th>` to get some emphasis, and inherited the header treatment
     instead -- so the only row on the page that is a figure rendered as 11px grey
     uppercase, `$0.00` included. Weight and a rule above it, which is what a reader
     is actually looking for, and the numbers stay at the size of the numbers above. */
  table.grid tfoot td, table.grid tfoot th { border-top: 2px solid #e3e8ee;
    border-bottom: none; padding-top: var(--sp-3); }
  table.grid tfoot td { font-weight: 700; color: var(--n-900); }
  .pill { display: inline-block; padding: var(--sp-1) var(--sp-3); border-radius: var(--r-full); font-size: var(--fs-eyebrow); font-weight: 700; white-space: nowrap; }
  .pill.red { background: #f4cccc; color: #85200c; }
  .pill.amber { background: #fff2cc; color: #7f6000; }
  .pill.green { background: #d9ead3; color: #274e13; }
  .pill.blue { background: #d9e2f3; color: #1f3864; }
  .pill.gray { background: #eeeeee; color: #52606d; }
  .btn { display: inline-block; background: #1f3864; color: #fff; border: none; border-radius: var(--r-md); padding: var(--sp-2) var(--sp-4);
         font-weight: 700; font-size: var(--fs-sm); cursor: pointer; text-decoration: none;
         transition: background-color var(--dur-1) var(--ease); }
  .btn.secondary { background: #e3e8ee; color: #1f3864; }
  .btn.danger { background: #c0392b; }
  .btn.success { background: #2e7d32; cursor: default; }
  .btn:disabled { opacity: 0.5; cursor: default; }
  /* PRESSED AND HOVER STATES. The primary action on every page was inert
     under the cursor, which matters more here than in most apps: this runs
     on a starter instance that can take two seconds to answer, so the
     acknowledgement layer correctly paints a busy state AFTER the click and
     there was nothing at all before it. A hover state is the cheapest
     possible signal that a click is going to land.

     `:not(:disabled)` rather than a pile of resets, so a control that
     cannot be pressed does not brighten under the cursor -- and it raises
     the specificity to 0,3,0, which is why each variant needs its own
     hover rather than inheriting the navy one. `.btn.ghost:hover` is
     declared further down this file and still wins on source order.

     ONLY `background-color` transitions, and only via `--dur-1`. Nothing
     here touches what `app_feedback.py` animates: `.cp-busy` adds a
     pseudo-element and changes no background. */
  .btn:hover:not(:disabled) { background: var(--brand-hover); }
  .btn:active:not(:disabled) { background: var(--brand-active); }
  .btn.secondary:hover:not(:disabled) { background: var(--n-200); }
  .btn.danger:hover:not(:disabled) { background: #a13124; }
  /* `.success` is a state, not an action -- it already says `cursor:
     default`, so it must not react either. */
  .btn.success:hover { background: #2e7d32; }
  /* ---- The hub LIST. Three surfaces, one rule, and it used to be squares.
     Changed 2026-08-07, per the instruction to default to Google style.

     WHAT WAS WRONG: `.tile` was `aspect-ratio: 1 / 1` with a heading, a
     paragraph and a location stacked inside it. A square with a paragraph
     in it is not a pattern Google ships anywhere -- Drive, Gmail and the
     Workspace admin console all list this kind of thing as ROWS with a
     short label and secondary text beside it. The square also cost real
     legibility: at 210px wide it forced the blurb down to `--fs-xs`, and
     `/features` is 40-odd of them, so the page was six screens of boxes
     with no scannable left edge.

     ONE CHANGE REACHES ALL THREE SURFACES, and that is why it is done in
     CSS and not in markup. `/needs-review`, `/features` and `/admin` emit
     the SAME shape -- a pill, an `h3`, a `p`, and a `.where` -- from three
     different modules, one of which (`road_map_app`) is under a line
     ratchet and could not be touched. A markup fix would have converted
     two of the three and left the product disagreeing with itself, which
     is worse than leaving all three square.

     SOURCE ORDER IS PILL-FIRST AND READING ORDER IS NAME-FIRST, so the
     name is ordered into place rather than moved in the markup: the pill
     is a status and the name is the thing you are looking for, but a
     screen reader and the tab order should still meet the status label
     attached to the row it belongs to.

     THE NAME COLUMN IS A FIXED BASIS, not `auto`, so names line up down
     the page -- that alignment is the entire reason a list scans faster
     than a grid. A longer name wraps inside its column instead of pushing
     everything after it out of line.

     `--fs-xs` -> `--fs-sm` on the blurb. There is room now, and 12px was
     a symptom of the square rather than a decision. */
  .tiles { display: flex; flex-direction: column; gap: var(--sp-1); }
  /* `--tile-name` is LOCAL to this component and is the name column's width. It is
     here rather than typed twice because two rules need the same number -- the
     heading's flex-basis and the indent that lines `.nr-note` up with the blurb
     rather than with the name -- and the phone override below changes it in one
     place. Not a `:root` token: it is one component's measurement, and a design
     token is a value the whole product spends from. */
  .tile { --tile-name: 24ch;
          background: var(--bg-surface); border-radius: var(--r-md);
          padding: var(--sp-3) var(--sp-4); box-shadow: var(--sh-1);
          display: flex; flex-flow: row wrap; align-items: baseline;
          column-gap: var(--sp-3); row-gap: var(--sp-1);
          text-decoration: none; color: inherit;
          border: 2px solid transparent; }
  /* Hover keeps the border it always had -- it is already reserved at
     `transparent`, so nothing reflows -- and gains the row tint Drive uses,
     which is what actually reads as "this row" at full width. */
  .tile:hover { border-color: #1f3864; background: var(--bg-hover); }
  .tile.dead { cursor: default; }
  .tile.dead:hover { border-color: #e3e8ee; background: var(--bg-surface); }
  .tile h3 { order: 1; margin: 0; font-size: var(--fs-base);
             line-height: var(--lh-snug); flex: 0 0 var(--tile-name); min-width: 0; }
  .tile > .pill, .tile > .nr-ind { order: 2; flex: 0 0 auto; }
  .tile p { order: 3; margin: 0; font-size: var(--fs-sm); color: #52606d;
            line-height: var(--lh-snug); flex: 1 1 26ch; min-width: 0; }
  /* A SHRINKABLE COLUMN, and an auto margin, and both are load-bearing.
     With `flex: 0 0 auto` this took its content's full width, so the two or three
     rows whose location is a sentence ("the transaction register, the event stream,
     git, ...") could not fit beside the blurb and dropped onto a line of their own --
     where an uppercase caption under a paragraph reads as a footnote to it rather
     than as the same row's last column. A shrinkable basis wraps the long ones
     inside their column instead.
     `margin-left: auto` does nothing on a row that fits, because the blurb's
     `flex-grow` has already eaten the free space; it acts only when this DOES land
     on a wrapped line (a `/needs-review` row carrying a note), where without it the
     location would sit stranded at the left edge under the name. */
  .tile .where { order: 5; flex: 0 1 20ch; margin-left: auto; text-align: right;
                 font-size: var(--fs-eyebrow); color: var(--text-muted); font-weight: 700;
                 text-transform: uppercase; letter-spacing: var(--ls-caps); }
  /* ---- The Juggernaut LAUNCHER, 2026-08-09 (ROUTE AC) --------------------
     Four rules, and this section exists to say why they are not the block
     directly above.

     WHY NOT `.tile`. Because `.tile` USED to be exactly this -- a square with
     a heading in it -- and it was deliberately un-squared on 2026-08-07 on the
     instruction to default to Google style. That change is right and stands:
     `/features` is 40-odd rows, `/needs-review` and `/admin` emit the same
     shape from three modules, and a square with a paragraph in it scans worse
     than a row with a left edge. THIS IS A DIFFERENT INSTRUCTION ABOUT A
     DIFFERENT SURFACE -- *"a juggernaut homepage with square tiles that go
     into the juggernaut apps"* -- and the two do not conflict, because the
     thing that made the square wrong there is absent here: a launcher tile
     carries a LABEL and at most ONE fact, never a blurb. Reusing `.tile` would
     have meant overriding almost every declaration in it, which is
     `.lp-tile`'s stated reason for existing and is how one component quietly
     becomes two that share a name. Nothing above is touched, so `/features`,
     `/needs-review` and `/admin` render byte-identically.

     WHY NOT `.admin-grid` OR `.lp-tiles`. `.admin-grid` is `1fr` columns with
     no aspect, so a square in it would be 400px tall on a wide screen;
     `.lp-tiles` is centred and capped for the one-or-two brand tiles it was
     measured for. Both would have needed overriding into a third thing.

     NO MEDIA QUERY, AND THAT IS THE POINT OF THE FLOOR. `min(100%, 210px)` is
     `.lp-tiles`' own trick: a 320px phone gets one tile that FITS rather than
     one that overflows, and the grid reflows from four columns to one on its
     own at every width in between. A breakpoint fewer is a breakpoint that
     cannot silently apply to a printout.

     THE CAP IS 260px SO A SQUARE STAYS A SQUARE YOU CAN READ. Uncapped `1fr`
     columns make a 500px square on a wide monitor, which is a poster.

     `aspect-ratio` AND NOT A FIXED HEIGHT, so the box grows if a label wraps
     to three lines rather than clipping it. `min-height: 0` is not needed:
     nothing here scrolls.

     AN ANCHOR, SO IT IS KEYBOARD REACHABLE FOR FREE. `:focus-visible` gets the
     same treatment as `:hover` because a keyboard user has to be able to see
     where they are; the border is reserved at `transparent` so nothing
     reflows when it appears. */
  .jt-grid { display: grid; gap: var(--sp-4); margin: var(--sp-4) 0;
             grid-template-columns: repeat(auto-fill, minmax(min(100%, 210px), 260px)); }
  .jt-tile { aspect-ratio: 1 / 1; display: flex; flex-direction: column;
             gap: var(--sp-2); background: var(--bg-surface);
             border-radius: var(--r-lg); padding: var(--sp-4);
             box-shadow: var(--sh-1); border: 2px solid transparent;
             text-decoration: none; color: inherit; }
  .jt-tile:hover, .jt-tile:focus-visible { border-color: #1f3864;
                                           background: var(--bg-hover); }
  .jt-tile h3 { margin: 0; font-size: var(--fs-base); line-height: var(--lh-snug); }
  /* The fact sits at the BOTTOM of the square, pushed there by the auto
     margin rather than by a spacer: the label is what you are looking for and
     the number is what you read once you have found it. `margin-top: auto` on
     a flex column is the whole mechanism. */
  .jt-fact { margin: auto 0 0; font-size: var(--fs-sm); color: #52606d;
             line-height: var(--lh-snug); }
  /* `flex-wrap: wrap` added 2026-08-06: without it a filter row that does not fit
     overflows the page rather than reflowing, which is never the better of the two.
     It changes nothing at any width where the row already fits. */
  form.filters { display: flex; gap: var(--sp-3); align-items: end; margin: 0 0 var(--sp-4); flex-wrap: wrap; }
  form.filters label { display: flex; flex-direction: column; gap: var(--sp-1); font-size: var(--fs-xs); color: #52606d; font-weight: 700; }
  select, input, textarea { font: inherit; padding: var(--sp-2) var(--sp-3); border: 1px solid #d5dbe3; border-radius: var(--r-md); background: #fff; }
  /* ---- The stacked form, which the app wrote out longhand three times ------
     `form.filters label` and `.login-box label` above are the same six
     declarations twice already, and `/tasks` and `/questions` wrote a THIRD
     copy in `style=` attributes -- `width:100%` on every control and a
     `margin-left:1.5rem` to fake a second column. Those pixel values sit off
     the 4px scale and were invisible to the token pass because they live in
     HTML, not here.

     `box-sizing: border-box` is the part that was actually broken: a control
     at `width:100%` WITH padding overflows its label by the padding, so every
     one of those inputs ran a few pixels past the card it sat in. */
  .stack { display: flex; flex-direction: column; gap: var(--sp-4); }
  .field { display: flex; flex-direction: column; gap: var(--sp-1);
           font-size: var(--fs-xs); font-weight: 700; color: #52606d; }
  .field > input, .field > select, .field > textarea {
    width: 100%; box-sizing: border-box; font-size: var(--fs-base);
    font-weight: 400; color: var(--text); }
  .field-hint { font-weight: 400; color: var(--text-muted); }
  .field-row { display: flex; gap: var(--sp-4); flex-wrap: wrap; }
  .field-row > .field { flex: 1 1 200px; }
  .login-box { max-width: 360px; margin: 80px auto; text-align: center; }
  .login-logo { width: 64px; height: 64px; }
  .login-box form { display: flex; flex-direction: column; gap: var(--sp-3); text-align: left; margin-top: var(--sp-4); }
  .login-box label { display: flex; flex-direction: column; gap: var(--sp-1); font-size: var(--fs-xs); font-weight: 700; color: #52606d; }
  /* ---- Admin dashboard ------------------------------------------
     Status colours are the reserved status palette (good/warning/
     critical), validated for CVD separation and the normal-vision
     floor in both light and dark. They are NEVER the only signal --
     every use is paired with an icon and a text label, because
     warning sits below 3:1 contrast on a light surface by design and
     because colour alone excludes colourblind readers.
     Backup GENERATIONS (daily/weekly/monthly) are distinguished by
     row and label, not by hue, so colour is free to mean one thing
     only: health. */
  .admin-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); gap: var(--sp-4); }
  /* Was `style="margin-top:14px"`. 14px is not on the 4px scale and never was. */
  .admin-grid-spaced { margin-top: var(--sp-4); }
  .stat { background: #fff; border-radius: var(--r-lg); padding: var(--sp-4); box-shadow: var(--sh-1); }
  .stat .label { font-size: var(--fs-eyebrow); text-transform: uppercase; letter-spacing: 0.03em; color: #52606d; font-weight: 700; }
  .stat .value { font-size: var(--fs-display); font-weight: 700; color: #1f2933; letter-spacing: -0.02em; margin-top: var(--sp-1); }
  .stat .sub { font-size: var(--fs-xs); color: var(--text-muted); margin-top: var(--sp-05); }
  .status-line { display: flex; align-items: center; gap: var(--sp-2); font-weight: 700; font-size: var(--fs-sm); }
  .status-dot { width: 11px; height: 11px; border-radius: var(--r-full); flex: none; }
  .s-good .status-dot { background: #0ca30c; } .s-good { color: #0a7a0a; }
  .s-warn .status-dot { background: #fab219; } .s-warn { color: #7a5600; }
  .s-crit .status-dot { background: #d03b3b; } .s-crit { color: #a52a2a; }
  /* Unknown/unconfigured: deliberately NEUTRAL, not red. It is separated from
     the health states by lightness and by its own glyph, so it never reads as
     a failure and stays distinct in greyscale. */
  .s-unknown .status-dot { background: #8a97a3; } .s-unknown { color: #52606d; }
  /* Coverage strip: one cell per day. Presence/absence, not magnitude --
     so cells are uniform and only filled-vs-empty carries meaning. */
  .cov { display: flex; gap: var(--sp-05); align-items: flex-end; margin-top: var(--sp-3); }
  .cov .day { width: 100%; height: 34px; border-radius: var(--r-sm); background: #eef1f4; position: relative; }
  .cov .day.has { background: #0ca30c; }
  /* A GAP is encoded by FORM, not colour: diagonal hatch plus a solid
     border. Measured with the palette validator, #0ca30c vs #d03b3b is
     CVD deltaE 4.1 under deuteranopia -- i.e. indistinguishable to a
     colourblind reader even though full-colour vision sees 33.9. The
     hatch and border survive colourblindness, greyscale printing and
     forced-colors mode; the red is redundant reinforcement only. */
  .cov .day.gap { background: repeating-linear-gradient(45deg,
      #d03b3b 0 3px, #ffffff 3px 6px); border: 2px solid #d03b3b; }
  .cov .day.today-pending { background: #eef1f4; border: 2px dashed #b6c0cb; }
  /* The legend beside the strip, which was three `style=` attributes carrying the
     same three encodings again -- two raw hexes, a second hatch at a different
     pitch, and a 3px radius the radius pass had already retired everywhere else.
     Same three names as the cells so a change to one is visibly a change to both. */
  .cov-key { display: inline-block; width: 11px; height: 11px; vertical-align: -1px;
             border-radius: var(--r-sm); box-sizing: border-box; background: #eef1f4; }
  .cov-key.has { background: #0ca30c; }
  .cov-key.gap { background: repeating-linear-gradient(45deg,
      #d03b3b 0 3px, #ffffff 3px 6px); border: 2px solid #d03b3b; }
  .cov-key.today-pending { border: 2px dashed #b6c0cb; }
  .cov-axis { position: relative; height: 16px; font-size: var(--fs-eyebrow); color: var(--text-muted); margin-top: var(--sp-15); }
  .cov-axis span { position: absolute; white-space: nowrap; }
  .depth-scale-pos { position: relative; height: 16px; font-size: var(--fs-eyebrow); color: var(--text-muted);
                     margin-left: 202px; margin-top: var(--sp-1); }
  .depth-scale-pos span { position: absolute; white-space: nowrap; }
  .depth-tick { position: absolute; top: 0; bottom: 0; width: 1px; background: rgba(31,56,100,0.13); }
  /* Recovery depth: two coverage windows on ONE shared time axis.
     Answers "how far back can we actually go?" at a glance. */
  .depth-row { display: grid; grid-template-columns: 190px 1fr; gap: var(--sp-3); align-items: center; margin: var(--sp-2) 0; }
  .depth-row .name { font-size: var(--fs-sm); font-weight: 700; color: #1f2933; }
  .depth-row .name small { display: block; font-weight: 400; color: var(--text-muted); font-size: var(--fs-eyebrow); }
  .depth-track { position: relative; height: 22px; background: #eef1f4; border-radius: var(--r-sm); overflow: hidden; }
  .depth-fill { position: absolute; left: 0; top: 0; bottom: 0; border-radius: var(--r-sm); }
  .depth-fill.pitr { background: #1f3864; }
  .depth-fill.offsite { background: #0ca30c; }
  .depth-fill.none { background: #f4d3d3; }
  .depth-scale { display: flex; justify-content: space-between; font-size: var(--fs-eyebrow); color: var(--text-muted);
                 margin-left: 202px; margin-top: var(--sp-1); }
  .depth-note { font-size: var(--fs-xs); color: #52606d; margin-top: var(--sp-3); }
  .kv { display: grid; grid-template-columns: max-content 1fr; gap: var(--sp-1) var(--sp-4); font-size: var(--fs-sm); }
  .kv dt { color: #52606d; font-weight: 700; }
  .kv dd { margin: 0; }
  /* ---- /admin/dev control centre ---------------------------------
     Six statuses, none optimistic by default. Colour NEVER carries the
     meaning alone: every badge has a glyph and a text label, because
     green vs red measures CVD deltaE 4.1 under deuteranopia. The three
     grey states are separated from each other by GLYPH and wording, not
     hue, so they stay distinguishable in greyscale and for any CVD. */
  .st { display: inline-flex; align-items: center; gap: var(--sp-15); font-size: var(--fs-eyebrow); font-weight: 700;
        padding: var(--sp-1) var(--sp-2); border-radius: var(--r-full); white-space: nowrap; }
  .st-good   { background: #e6f4e6; color: #0a6b0a; border: 1px solid #0ca30c; }
  .st-warn   { background: #fdf3dd; color: #6f4e00; border: 1px solid #fab219; }
  .st-crit   { background: #fae3e3; color: #8f2020; border: 1px solid #d03b3b; }
  .st-unconf { background: #eef1f4; color: #52606d; border: 1px dashed #8a97a3; }
  .st-uninst { background: #f7f8fa; color: #6b7885; border: 1px solid #d5dbe3; }
  .st-planned{ background: #f0f3f9; color: #3d5280; border: 1px solid #b7c4de; }
  .env-row { display: flex; gap: var(--sp-3); flex-wrap: wrap; margin: 0 0 var(--sp-15); }
  .env-card { flex: 1 1 220px; background: #fff; border-radius: var(--r-lg); padding: var(--sp-3) var(--sp-4);
              box-shadow: var(--sh-1); border-left: 4px solid #d5dbe3; }
  .env-card.is-active { border-left-color: #0ca30c; }
  .env-card .env-name { font-weight: 700; font-size: var(--fs-base); color: #1f2933; }
  .env-card .env-backend { font-size: var(--fs-xs); color: #52606d; margin-top: var(--sp-05); }
  .env-card .env-note { font-size: var(--fs-xs); color: var(--text-muted); margin-top: var(--sp-15); }
  /* Rendered on /admin/dev's environment cards and never styled -- a pre-existing
     gap, found 2026-08-06 by the test that asserts every rendered class has a rule.
     Matched to .env-note's weight so a probe result reads as detail rather than as
     the card's headline. */
  .env-card .env-probe { font-size: var(--fs-xs); color: #52606d; margin-top: var(--sp-15); }
  /* Warning state carries its meaning in the words and the bold lead-in, not
     the amber alone -- the same rule the status badges follow. */
  .env-warn { flex: 1 1 100%; background: #fff8e6; border: 1px solid #e8b931;
              border-left: 4px solid #e8b931; border-radius: var(--r-lg);
              padding: var(--sp-3) var(--sp-4); font-size: var(--fs-sm); color: #6b5000; }
  .switcher { display: flex; gap: var(--sp-15); align-items: center; margin: var(--sp-3) 0 var(--sp-4); font-size: var(--fs-xs); color: var(--text-muted); }
  .switcher button { font: inherit; font-weight: 700; padding: var(--sp-15) var(--sp-3); border-radius: var(--r-md);
                     border: 1px solid #d5dbe3; background: #eef1f4; color: var(--text-muted); cursor: not-allowed; }
  .phase { display: grid; grid-template-columns: 42px 1fr auto; gap: var(--sp-3); align-items: start;
           padding: var(--sp-3) 0; border-bottom: 1px solid #eef1f4; }
  .phase:last-child { border-bottom: none; }
  .phase .num { font-weight: 700; color: var(--text-muted); font-size: var(--fs-lg); text-align: right; }
  .phase .title { font-weight: 700; color: #1f2933; }
  .phase .sum { font-size: var(--fs-sm); color: #52606d; margin-top: var(--sp-05); }
  .phase .gate { font-size: var(--fs-xs); color: var(--text-muted); margin-top: var(--sp-1); }
  .phase .ev { font-size: var(--fs-xs); color: #52606d; margin-top: var(--sp-1); }
  .blocker { border-left: 3px solid #d03b3b; padding: var(--sp-2) 0 var(--sp-2) var(--sp-3); margin: var(--sp-3) 0; }
  .blocker .bt { font-weight: 700; color: #1f2933; font-size: var(--fs-sm); }
  .blocker .bd { font-size: var(--fs-xs); color: #52606d; margin-top: var(--sp-1); }
  .kanban { display: grid; grid-template-columns: repeat(7, minmax(150px, 1fr)); gap: var(--sp-3); overflow-x: auto; }
  .kcol { background: #eef1f4; border-radius: var(--r-lg); padding: var(--sp-2); min-height: 90px; }
  /* `h3`, not `h4`: the board sits under an `h2` and the columns were jumping a
     level, which is the one heading error a screen reader user cannot work around
     -- the outline simply loses a rung. Nothing about the rendering changed. */
  .kcol h3 { margin: 0 0 var(--sp-2); font-size: var(--fs-eyebrow); text-transform: uppercase; letter-spacing: 0.03em;
             color: #52606d; display: flex; justify-content: space-between; }
  .kempty { font-size: var(--fs-xs); }
  .kcard { background: #fff; border-radius: var(--r-md); padding: var(--sp-2); margin-bottom: var(--sp-2);
           box-shadow: var(--sh-1); font-size: var(--fs-xs); }
  .kcard .kt { font-weight: 700; color: #1f2933; }
  .kcard .kn { color: var(--text-muted); font-size: var(--fs-eyebrow); margin-top: var(--sp-1); }
  .kcard .kref { font-size: var(--fs-eyebrow); color: var(--text-muted); margin-top: var(--sp-1); font-family: ui-monospace, monospace; }
  .readonly-note { background: #f0f3f9; border: 1px solid #b7c4de; border-radius: var(--r-lg);
                   padding: var(--sp-3) var(--sp-4); font-size: var(--fs-xs); color: #3d5280; margin: 0 0 var(--sp-4); }
  .review-layout { display: grid; grid-template-columns: minmax(0, 1fr) 430px; gap: var(--sp-4); align-items: start; }
  .doc-frame { width: 100%; height: calc(100vh - 170px); border: 1px solid #d5dbe3; border-radius: var(--r-lg); background: #fff; }
  .panel { position: sticky; top: 16px; max-height: calc(100vh - 150px); overflow-y: auto; }
  .section-list { display: flex; flex-direction: column; gap: var(--sp-1); margin: var(--sp-2) 0 var(--sp-4); }
  .section-list button { text-align: left; background: #f2f4f7; border: 1px solid transparent; border-radius: var(--r-md);
                         padding: var(--sp-2) var(--sp-3); font: inherit; font-size: var(--fs-sm); cursor: pointer; }
  .section-list button.selected { border-color: #1f3864; background: #e8edf6; font-weight: 700; }
  .section-list .edited { float: right; font-size: var(--fs-eyebrow); background: #d9a441; color: #1f2933; border-radius: var(--r-full); padding: 1px var(--sp-2); }
  #editor { min-height: 140px; max-height: 320px; overflow-y: auto; border: 1px solid #d5dbe3; border-radius: var(--r-md);
            padding: var(--sp-3); background: #fff; font-size: var(--fs-sm); }
  #editor:focus { outline: 2px solid #1f3864; }
  .panel textarea { width: 100%; min-height: 64px; margin-top: var(--sp-2); }
  .panel .actions { display: flex; gap: var(--sp-2); margin-top: var(--sp-3); flex-wrap: wrap; }
  .flash { background: #d9ead3; color: #274e13; border-radius: var(--r-md); padding: var(--sp-3) var(--sp-4); margin-bottom: var(--sp-4); font-weight: 600; }
  .flash.bad { background: #f4cccc; color: #85200c; }
  .itf-chip { font-size: var(--fs-xs); }
  .coming-soon { text-align: center; padding: 70px 0; color: var(--text-muted); }
  tr.rowlink { cursor: pointer; }
  tr.rowlink:hover td { background: #f2f6fc; }
  .client-header { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--sp-4); }
  .client-header + .client-header { margin-top: var(--sp-3); }
  .section-sub { color: #52606d; font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: 0.04em; margin: var(--sp-4) 0 var(--sp-15); }
  .card .section-sub:first-child { margin-top: 0; }
  .hdr-label { font-size: var(--fs-eyebrow); color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; }
  .evidence-list { padding-left: var(--sp-4); list-style: disc; margin: 0; font-size: var(--fs-sm); }
  .evidence-list li { margin-bottom: var(--sp-1); }
  .lights-cell { white-space: nowrap; }
  .lights-cell .light-label { font-size: var(--fs-eyebrow); color: var(--text-muted); margin: 0 var(--sp-1) 0 var(--sp-2); font-weight: 700; }
  .lights-cell .light-label:first-child { margin-left: 0; }
  select.cond-edit { font-size: var(--fs-xs); padding: var(--sp-1) var(--sp-15); margin-bottom: var(--sp-1); max-width: 175px; }
  .table-wrap { overflow-x: auto; }
  .cond-lane { border-radius: var(--r-lg); padding: var(--sp-3) var(--sp-4) var(--sp-15); margin-top: var(--sp-4); }
  .lane-title { font-size: var(--fs-sm); text-transform: uppercase; letter-spacing: 0.05em; margin: 0 0 var(--sp-3); }
  .lane-count { display: inline-block; background: rgba(255,255,255,0.85); border-radius: var(--r-full); padding: 1px var(--sp-2);
                font-size: var(--fs-eyebrow); margin-left: var(--sp-15); color: #52606d; }
  .cond-card { background: #fff; border-radius: var(--r-md); border-left: 4px solid #1f3864; padding: var(--sp-3) var(--sp-4);
               margin-bottom: var(--sp-3); box-shadow: var(--sh-1); }
  .cond-card h3 { color: #1f3864; font-size: var(--fs-base); margin: 0 0 var(--sp-2); }
  .cond-canonical { font-weight: 400; font-size: var(--fs-xs); margin-left: var(--sp-2); }
  /* THE THREE REGISTERS, ABOVE THE CONTROLS AND ABOVE THE LIGHTS. EXT-184
     §130-138. Documented / Veteran reported / Filing selection, always all
     three, so the absence of a selection is a visible line rather than a gap
     somebody reads as "not applicable".

     A LEFT RULE AND NOT A BADGE, deliberately. A badge appears only when there
     is something to say, and "nobody has asked the veteran yet" is exactly the
     case that has to be seen. `.register-open` warms the rule for an unrecorded
     selection -- the same amber the four-state pill uses for
     `not_examined`, and for the same reason: amber is GO AND LOOK. It is never
     red, because red reads as stop-working-it and nothing here says stop. */
  .cond-registers { border-left: 3px solid var(--line); padding-left: var(--sp-3); margin: 0 0 var(--sp-3);
                    display: flex; flex-direction: column; gap: var(--sp-1); }
  .cond-registers.register-open, .register-line.register-open { border-left-color: #b7791f; }
  .register-headline { font-size: var(--fs-sm); font-weight: 700; color: #1f3864; }
  .register-line { font-size: var(--fs-xs); color: var(--text-muted); }
  .register-line.register-open { color: #8a5b00; font-weight: 700; padding-left: var(--sp-2); border-left: 3px solid #b7791f; }
  /* THE UNAVAILABLE LINE AND THE CAPTURE CONTROL. EXT-198 §98, §110.
     `.register-unavailable` is NOT styled as an error, deliberately: it is a
     statement that this surface could not determine something, and the next
     action belongs to an engineer rather than to the coach reading the card.
     Grey and quiet, with the typed reason's own sentence in it. Red would send
     somebody to the veteran, which is precisely the confusion this line exists
     to remove. */
  .register-unavailable { font-size: var(--fs-xs); color: var(--text-muted); background: #f0f2f4;
                          border-radius: var(--r-sm); padding: var(--sp-1) var(--sp-2); margin-top: var(--sp-1); }
  .selection-capture { display: flex; gap: var(--sp-3); align-items: flex-end; flex-wrap: wrap; margin-top: var(--sp-2); }
  .selection-capture label { display: flex; flex-direction: column; gap: var(--sp-1); font-size: var(--fs-eyebrow);
                             color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; }
  .selection-words { min-width: 260px; padding: var(--sp-1) var(--sp-2); border: 1px solid var(--line);
                     border-radius: var(--r-sm); font-size: var(--fs-sm); }
  .selection-stance, .selection-source { padding: var(--sp-1) var(--sp-2); border: 1px solid var(--line);
                                         border-radius: var(--r-sm); font-size: var(--fs-sm); }
  /* `.btn` already carries the look; this only stops the button stretching to
     the flex row's full height beside the labelled inputs. A rule is required
     because `test_module_split` refuses a class the app renders and the
     stylesheet does not know -- an unstyled control is an unfinished one. */
  .selection-save { align-self: flex-end; }
  .selection-receipt { font-size: var(--fs-xs); font-weight: 700; }
  /* FIVE OUTCOMES, THREE TONES, AND `conflicted` IS NOT ONE OF THE QUIET ONES.
     written/updated are done. `kept` is grey: nothing changed and nothing is
     wrong. `conflicted` and `refused` are red because both need a human, and a
     conflict that reads as "saved" is the one outcome that could lose a
     veteran's answer. */
  .outcome-written, .outcome-updated { color: #2e7d32; }
  .outcome-kept { color: var(--text-muted); }
  .outcome-conflicted, .outcome-refused { color: #c0392b; }
  .cond-controls { display: flex; gap: var(--sp-4); align-items: flex-end; flex-wrap: wrap; margin-bottom: var(--sp-3); }
  .cond-controls label { display: flex; flex-direction: column; gap: var(--sp-1); font-size: var(--fs-eyebrow); color: var(--text-muted);
                         text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; }
  .cond-status-grid { display: flex; gap: var(--sp-4); flex-wrap: wrap; }
  .status-col { display: flex; flex-direction: column; gap: var(--sp-15); }
  .status-col label { display: flex; flex-direction: column; gap: var(--sp-1); font-size: var(--fs-eyebrow); color: var(--text-muted);
                      text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; }
  .light-slot { min-height: 22px; }
  .log-expander { margin-top: var(--sp-2); font-size: var(--fs-sm); }
  .log-expander summary, .log-title { font-size: var(--fs-eyebrow); color: #52606d; text-transform: uppercase;
                                      letter-spacing: 0.04em; font-weight: 700; cursor: pointer; }
  .log-none .log-title { cursor: default; }
  .log-body { background: #fafbfc; border-radius: var(--r-md); padding: var(--sp-2) var(--sp-3); margin-top: var(--sp-15);
              color: #3e4c59; font-size: var(--fs-xs); line-height: 1.6; max-height: 320px; overflow-y: auto; }
  /* THE TALLER LOG, AND IT IS AN ATTRIBUTE HERE BECAUSE IT WAS AN INLINE STYLE.
     The client record's flag log wants 480px rather than 320, and it asked for it
     with `style="max-height:480px"` -- which an author stylesheet cannot beat, so
     `@media print`'s un-clamp below could not reach it and that log clipped on
     paper on the one page whose whole purpose is going into a case file. Same
     defect as the two `.log-body` clampers already recorded in the print block,
     arriving by a different route: not a rule somebody forgot to un-clamp, a rule
     nothing was allowed to un-clamp. A `data-` hook rather than a class, following
     `data-ca` next door. */
  .log-body[data-log="tall"] { max-height: 480px; }
  .log-entry { padding: var(--sp-2) 0; border-top: 1px solid #e8ecf0; }
  .log-entry:first-child { border-top: none; padding-top: var(--sp-05); }
  .log-entry strong { color: #1f3864; }
  .extract-list li { margin-bottom: var(--sp-3); }
  .extract-detail { background: #fff; border: 1px solid #eef1f4; border-radius: var(--r-md); padding: var(--sp-15) var(--sp-3); margin-top: var(--sp-15); font-size: var(--fs-xs); }
  .extract-sub { margin-top: var(--sp-1); }
  .extract-sub summary { font-size: var(--fs-eyebrow); color: #52606d; text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; cursor: pointer; }
  mark { background: #fff59d; padding: 0 var(--sp-05); border-radius: var(--r-sm); }
  .evidence-block { background: #fafbfc; border-radius: var(--r-md); padding: var(--sp-2) var(--sp-3); margin-top: var(--sp-15); }
  .evidence-heading { font-size: var(--fs-eyebrow); color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.04em; font-weight: 700; }

  /* ---- Conversation bubbles, reworked 2026-08-06 on direct instruction -------
     "a clear indicator that something is email... email needs to be collapsed and
     expandable... client comms to us should be a different color than our comms to
     them."

     COLOUR CARRIES THE DIRECTION, not position alone. In a narrow column left-vs-right
     is easy to misread, and the cost of misreading is replying to your own message. */
  /* The channel BADGE. Scanning a thread for "which of these was a text?" should not
     require reading -- so channel is a shape and a colour, not a word in grey. */
  .conv-badge { display: inline-block; font-size: var(--fs-eyebrow); font-weight: 700;
                text-transform: uppercase; letter-spacing: .05em;
                padding: 1px var(--sp-2); border-radius: var(--r-full); margin-right: var(--sp-2);
                border: 1px solid transparent; }
  .conv-badge.ch-email  { background: #e7edf7; color: #2f5b8c; border-color: #cfdcee; }
  .conv-badge.ch-sms    { background: #e6f5ec; color: #1f6b40; border-color: #c9e7d6; }
  .conv-badge.ch-portal { background: #f1eaf8; color: #5c3d8a; border-color: #e0d2f0; }
  .conv-badge.ch-phone  { background: #fdf0e3; color: #8a5a1f; border-color: #f4dcc0; }
  .conv-badge.ch-other  { background: #eef1f4; color: #52606d; border-color: #e0e5ea; }
  /* The only badge in the thread that is a warning rather than a label. Solid amber
     against five pale tints, because this one answers "can the veteran see this?" and
     every other one answers "how did it get here?" */
  .conv-badge.ch-internal { background: #a8620d; color: #fff; border-color: #a8620d; }

  /* ---- Internal notes -----------------------------------------------------
     A note the client never sees, sitting in the same column as messages they did.
     Marked THREE ways -- the amber badge above, this left edge, and the tinted
     ground -- because one marking is one refactor away from none, and the cost of
     mistaking a private note for a sent message runs in both directions: quoting it
     to the veteran, or assuming they were told something they never were. */
  .conv-item.internal { background: #fdf6ec; border-left: 3px solid #a8620d;
                        padding-left: var(--sp-2); border-radius: 0 var(--r-md) var(--r-md) 0; }

  /* ---- The message panel --------------------------------------------------
     Four channels, and a rule down the middle. Left of it reaches the veteran, right
     of it never does. Position is what somebody learns after a week; the labels and
     the colour are what they read before that. */
  /* ---- COMPACT AT REST -----------------------------------------------------
     Direct instruction 2026-08-08, from the browser: *"the text editor/composer is
     clunky and big, needs to be overhauled for best ui ux and pinned below."*

     THE PANEL NOW COSTS THE THREAD ITS OWN HEIGHT, which is why this is a
     requirement and not a preference. Pinned to the bottom of a fixed-height column,
     every pixel the composer takes at rest is a pixel of conversation nobody can
     read -- the two share one viewport and there is no third place for the height to
     come from. At rest it was around 340px; the same panel with the same controls is
     around 235px now.

     WHERE THE HEIGHT WENT, in order of size, so none of it is mysterious:
       * The textarea opens at TWO lines instead of four and grows from there
         (`#composer textarea` below, and the `rows` attribute in `composer.py` so it
         is right before any CSS or JS loads). ~41px.
       * "Replying to ..." and the subject row SHARE A LINE (`.cmp-meta`). ~26px.
       * The Wingman bar is one line rather than two (`.cmp-est`). ~18px.
       * The card's own padding drops from 16/24 to 12/16. ~8px, and it also stops
         the panel reading as a page-level card when it is a dock.
       * The channel note drops to the eyebrow size and the block margins go from
         8px to 6px. ~25px between them.

     NOTHING ON THE PRIVACY BOUNDARY WAS TOUCHED TO GET IT. Not the four tabs, not
     the rule between them, not the amber, not the confirm. The rule about which of
     those wins is stated in `composer.py` and it is not close: if a compact panel
     ever made the divider harder to read, the divider wins and the panel gets taller.
     The tab padding here is unchanged from before this pass for exactly that reason.

     THE `hidden` ATTRIBUTE HAS TO ACTUALLY HIDE, and until this pass it did not.
     `[hidden] { display: none }` lives in the USER-AGENT stylesheet, and any author
     `display` beats a UA declaration outright -- so `.cmp-subject { display: flex }`
     had been defeating `hidden` since the subject box shipped, and the Subject field
     and its "quote their message" checkbox rendered on Text and on Internal note,
     where neither has anywhere to go. `.cmp-who` carries a comment warning about
     precisely this trap; the rule three lines below it walked into it.

     Fixed once, at the panel, rather than per class: `#composer [hidden]` is (1,1,0)
     and outranks every `.cmp-*` rule in this file, so a control added later with a
     `display` of its own cannot re-open the hole. Scoped to `#composer` rather than
     written globally because a global `[hidden]` override is the kind of rule that
     changes something on a page nobody was looking at. */
  .conv-composer { padding: var(--sp-3) var(--sp-4); }
  #composer [hidden] { display: none; }
  /* TWO SIDES AND A RULE BETWEEN THEM, and the grouping is in the markup.
     `composer.render` wraps each side in `.cmp-side` -- derived from
     `reaches_client`, never from a count -- so `.cmp-tabs` holds three children and
     not five. That is what lets the rule stay VERTICAL at every width: the reaching
     side wraps onto as many rows as it needs, inside its own box, and the rule stands
     to the right of all of them. It used to be a flat run of four buttons with a 1px
     span spliced in, which wrapped to wherever there was room. */
  .cmp-tabs { display: flex; align-items: stretch; gap: var(--sp-1); margin-bottom: var(--sp-2);
              flex-wrap: nowrap; }
  /* The reaching side takes the slack and is the one allowed to wrap. `min-width: 0`
     is load-bearing: without it a flex item will not shrink below its content and the
     internal tab would be pushed off the right edge -- taking the rule with it. */
  .cmp-side { display: flex; align-items: stretch; flex-wrap: wrap; gap: var(--sp-1);
              flex: 0 1 auto; min-width: 0; }
  .cmp-side.internal { flex: 0 0 auto; }
  .cmp-tab { font: inherit; font-size: var(--fs-xs); padding: var(--sp-15) var(--sp-3); cursor: pointer;
             background: #f4f6f8; border: 1px solid #d7dde3; border-radius: var(--r-md);
             color: #52606d; }
  .cmp-tab:hover { background: #eaeef2; }
  .cmp-tab.on { background: #1f3864; border-color: #1f3864; color: #fff;
                font-weight: 600; }
  /* An unavailable channel is SHOWN, dimmed, never hidden. A tab that vanishes leaves
     somebody wondering whether the feature exists; a dim one with a reason under it
     answers the question, and the panel does not change shape the day it turns on. */
  .cmp-tab.off { opacity: .5; }
  .cmp-tab.off.on { background: #7b8794; border-color: #7b8794; opacity: .75; }
  /* The divider. Its own element rather than a border on a tab, so it stays put when
     the tab beside it is selected and grows a border of its own.

     `flex: 0 0 1px` and not a bare `width`, because a 1px flex item with `flex-shrink`
     at its default of 1 is a 1px item a crowded row is allowed to shrink to nothing --
     and the one thing on this panel that must never disappear is the rule that says
     which side reaches the veteran. It stretches to the full height of the tallest
     side, so when the reaching side wraps to two or three rows the rule grows with it
     instead of marking only the first row. */
  .cmp-divide { flex: 0 0 1px; width: 1px; background: #cbd2d9;
                margin: var(--sp-05) var(--sp-15); align-self: stretch; }

  /* ---- The head, which never folds ---------------------------------------
     The panel is a head and a body, and only the body folds. What is in the head is
     a safety decision rather than a layout one: THE FOUR TABS STAY, because they are
     the privacy control and the rule down their middle is one of the four places
     "this reaches a veteran" is drawn. A folded panel whose only visible element was
     a box you can type into would have dropped that signal entirely -- and the
     channel is remembered across a page load, so the one somebody last used is not
     the one they would assume.

     `.cmp-who` stands in for the fuller note under the tabs, which is inside the
     folded part. Amber when it is the internal note, the same as everywhere else. */
  .cmp-head { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap;
              margin-bottom: var(--sp-15); }
  .cmp-head .cmp-tabs { margin-bottom: 0; }
  /* NO `display` declaration, deliberately: these carry the `hidden` attribute for
     the three channels that are not selected, and an author `display` on the class
     would beat it and show all four at once. */
  .cmp-who { font-size: var(--fs-xs); color: #52606d; }
  .cmp-who.internal { color: #8a5a1f; font-weight: 700; }
  #composer:not(.collapsed) .cmp-who { display: none; }
  .cmp-fold { margin-left: auto; font: inherit; font-size: var(--fs-xs); font-weight: 600;
              padding: var(--sp-15) var(--sp-3); cursor: pointer; white-space: nowrap;
              color: #2f5b8c; background: #fff; border: 1px solid #cbd2d9;
              border-radius: var(--r-md); }
  .cmp-fold:hover { background: #f4f6f8; border-color: #9aa5b1; }
  #composer.collapsed .cmp-body { display: none; }
  /* Folded, the whole strip is the way back in: a one-line panel whose only target
     is a small button at the far right is a panel people miss. */
  #composer.collapsed .cmp-head { margin-bottom: 0; cursor: pointer; }
  @media screen and (max-width: 900px) {
    /* Same tap-target treatment the tabs beside it get. */
    /* `--fs-sm` is exactly the 13px this said, written as the token. Same reason as
       `.cmp-tab` in the phones block: a literal that happens to equal a token is a
       literal somebody will change without changing the scale. */
    .cmp-fold { padding: var(--sp-3); min-height: 42px; font-size: var(--fs-sm); }
  }

  .cmp-note { font-size: var(--fs-eyebrow); color: #52606d; margin-bottom: var(--sp-15);
              line-height: var(--lh-snug); }
  /* WHAT IS BEING REPLIED TO AND WHAT IT IS CALLED, ON ONE LINE.
     Two email-only strips that were two stacked rows and are one row wherever there
     is room for one. They wrap independently at their own flex bases, so a 420px
     work column stacks them exactly as before rather than crushing both -- the
     compaction is taken where it is free and given back where it is not.

     `display: flex` here is safe because `#composer [hidden]` above outranks it;
     that was not true of the rule it replaces, and the note up there says why. */
  .cmp-meta { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2);
              margin-bottom: var(--sp-15); }
  .cmp-meta > * { margin-bottom: 0; }
  .cmp-meta .cmp-reply-to { flex: 1 1 210px; min-width: 0; }
  .cmp-meta .cmp-subject { flex: 3 1 300px; min-width: 0; }
  /* ---- COMPACT, THEN IT GROWS, THEN IT STOPS -----------------------------
     TWO lines to start (the `rows` attribute, so it is right before any CSS or JS
     loads), growing with what is typed to about nine, and after that THE TEXTAREA
     SCROLLS -- never the page.

     IT WAS FOUR LINES UNTIL 2026-08-08 and the founder called the panel "clunky and
     big" in the browser. Two is the right floor rather than a smaller number that
     looks tidier in a screenshot: one line reads as a search box and hides the fact
     that this is where a letter to a veteran gets written, and it makes the box
     resize on the second word of every message. Two lines is the smallest thing that
     still looks like a message editor and holds a normal reply without moving.

     THE CEILING LIVES HERE AND NOT IN THE SCRIPT. `PANEL_SCRIPT` sets `height` from
     the box's own `scrollHeight` and knows nothing about a maximum; `max-height`
     clamps it and `overflow-y` gives it somewhere to go. So the limit is a style
     decision in the stylesheet rather than a number in JavaScript, and a browser with
     JavaScript off still gets a two-line box that scrolls rather than one that grows.

     `line-height` is declared because a textarea does NOT inherit it usefully -- the
     UA `font` shorthand resets it to `normal` -- and every figure below is counted in
     lines, so an unknown line height would make them arbitrary. 1.5 x 14px is 21px a
     line: `min-height` is two of them plus the 16px of padding, and `max-height`'s
     14em is a little over nine.

     THE `32vh` IS A SHORT-SCREEN GUARD, not a second opinion about line count. In the
     pinned Hatch column the composer and the thread divide one viewport between them,
     so on a 640px window nine lines of editor would leave the thread a strip. At that
     height 32vh is about six lines; on any ordinary screen the `14em` is what binds.

     `resize: vertical` stays available: auto-grow is a default, not a policy, and
     somebody drafting a long letter should be able to overrule it -- which matters
     more now that the floor is lower, not less. */
  #composer textarea { width: 100%; padding: var(--sp-2) var(--sp-3); font-size: var(--fs-base);
    border: 1px solid #cbd2d9; border-radius: var(--r-md); font-family: inherit;
    line-height: 1.5; min-height: 4.2em; max-height: min(14em, 32vh);
    overflow-y: auto; resize: vertical; box-sizing: border-box; }
  /* SEND AND THE STATE LINE ON ONE ROW WITH THE WINGMAN BAR, which is why the bar is
     rendered INSIDE `.cmp-actions` rather than under it. Two rows of controls under a
     two-line box was most of what read as clunky.

     `margin-left: auto` on the bar is what keeps the two kinds of button apart: Send
     is the primary action and the priced buttons are a detour, so they sit at
     opposite ends of the row and a mis-click has the width of the panel to cross. */
  .cmp-actions { margin-top: var(--sp-15); display: flex; align-items: center;
                 flex-wrap: wrap; gap: var(--sp-2) var(--sp-3); }
  .cmp-actions .cmp-wingman-bar { margin-top: 0; margin-left: auto; }
  .cmp-actions .muted { margin-left: 0; }

  /* ---- Replying ------------------------------------------------------------
     WHICH MESSAGE IS BEING ANSWERED, stated rather than inferred from the thread
     above. The left rule ties it visually to the quote it will produce, and it is
     grey rather than amber: amber in this panel means "internal, the client never
     sees this", and one meaning per colour is the rule that keeps that legible. */
  .cmp-reply-to { font-size: var(--fs-xs); color: #3e4c59; margin-bottom: var(--sp-2);
                  padding-left: var(--sp-2); border-left: 2px solid #cbd2d9; }
  .cmp-subject { display: flex; align-items: center; gap: var(--sp-2); margin-bottom: var(--sp-2);
                 flex-wrap: wrap; }
  .cmp-subject label { font-size: var(--fs-eyebrow); color: #52606d; }
  .cmp-subject input[type=text] { flex: 1 1 260px; padding: var(--sp-2) var(--sp-3); font-size: var(--fs-sm);
    border: 1px solid #cbd2d9; border-radius: var(--r-md); font-family: inherit; }
  .cmp-quote { display: inline-flex; align-items: center; gap: var(--sp-15); white-space: nowrap; }

  /* The priced buttons, and the free path named to their left. Deliberately quiet:
     the sentence saying it costs nothing to write it yourself should not be losing a
     visual contest with two buttons carrying dollar figures. */
  .cmp-wingman-bar { margin-top: var(--sp-3); display: flex; align-items: center; gap: var(--sp-2);
                     flex-wrap: wrap; font-size: var(--fs-xs); }
  .cmp-wingman-bar .pill { margin-left: var(--sp-15); }
  /* The estimate caveat takes its own line INSIDE the bar rather than pushing the
     buttons off theirs -- `flex: 1 1 100%` is a line break that costs no element.
     Right-aligned because the bar now sits at the right end of the action row and a
     left-aligned caption under right-aligned buttons reads as belonging to Send. */
  .cmp-est { flex: 1 1 100%; font-size: var(--fs-eyebrow); text-align: right; }
  /* Composing a note looks like the note will look. The box a person types into is
     the last chance to notice they picked the wrong channel. */
  #composer.internal textarea { background: #fdf6ec; border-color: #dbb277; }
  #composer.internal { border-left: 3px solid #a8620d; }

  /* ---- Wingman ------------------------------------------------------------
     A machine-written draft, and it has to look like one. Tinted ground, dashed
     edge, its own label: nothing else in the composer is dashed, so a glance
     tells you these words came from a model and have not been read by a person
     yet. It sits BELOW the textarea rather than replacing it, because the draft
     is a suggestion beside your own writing, not a replacement for it. */
  .cmp-wingman { margin-top: var(--sp-3); padding: var(--sp-3); border-radius: var(--r-md);
                 background: #f3f6fb; border: 1px dashed #9fb3d1; }
  .cmp-wingman-head { font-size: var(--fs-eyebrow); font-weight: 700; text-transform: uppercase;
                      letter-spacing: .05em; color: #2f5b8c; margin-bottom: var(--sp-15); }
  .cmp-wingman-text { white-space: pre-wrap; font-size: var(--fs-base); color: #1f2933;
                      overflow-wrap: anywhere; }
  .cmp-wingman-actions { margin-top: var(--sp-2); display: flex; gap: var(--sp-2); align-items: center;
                         flex-wrap: wrap; }
  .cmp-wingman-actions .muted { font-size: var(--fs-eyebrow); }
  .btn.ghost { background: #fff; color: #2f5b8c; border: 1px solid #cbd2d9; }
  .btn.ghost:hover { background: #f4f6f8; }
  .btn.ghost:disabled { opacity: .5; cursor: not-allowed; }

  .conv-subject { font-weight: 700; color: #1f3864; margin: var(--sp-1) 0; }
  .conv-body { overflow-wrap: anywhere; }

  /* Collapsed email. <details> rather than JavaScript so it survives the thread being
     replaced by the live refresh, and works with a keyboard for free. */
  .conv-fold > summary { cursor: pointer; list-style: none; display: block; }
  .conv-fold > summary::-webkit-details-marker { display: none; }
  .conv-preview { color: #52606d; }
  .conv-more { display: inline-block; margin-left: var(--sp-15); font-size: var(--fs-eyebrow);
               font-weight: 700; color: #2f5b8c; white-space: nowrap; }
  /* FOUND 2026-08-08 WHILE ADDING THE SAME CARET TO THE QUICK VIEW, and it is a live
     defect rather than a style note. `APP_CSS` is a normal Python string, so `\25b8`
     was consumed by PYTHON as the octal escape `\25` -- the browser was handed
     chr(0x15) followed by the literal characters "b8", and this caret has been
     rendering as an invisible control character and the text "b8" in front of "show
     full email" since it was written. Escaped so the browser receives the CSS escape
     it was always meant to get. The same mistake is one line down and in the Quick
     View above; all four are corrected together. */
  .conv-more::before { content: "\25b8 "; }
  .conv-fold[open] > summary .conv-preview { display: none; }
  .conv-fold[open] > summary .conv-more { margin-left: 0; }
  .conv-fold[open] > summary .conv-more::before { content: "\25be "; }
  .conv-fold[open] > summary .conv-more::after { content: " (collapse)"; font-weight: 400; }
  .conv-fold > summary:hover .conv-more { text-decoration: underline; }

  /* ---- The Workspace client list ------------------------------------------
     Search and two filters in a row above a dense table. The condition counts are
     the point of the row, so they are chips rather than columns: seven numeric
     columns would make the table unreadable at a glance, and a client with none of
     a category should read as absence rather than as a nought competing for
     attention. */
  .wl-controls { display: flex; gap: var(--sp-3); align-items: center; flex-wrap: wrap;
                 margin-bottom: var(--sp-3); }
  .wl-controls input, .wl-controls select { padding: var(--sp-2) var(--sp-3); font-size: var(--fs-sm);
    border: 1px solid #cbd2d9; border-radius: var(--r-md); background: #fff; }
  .wl-controls input { flex: 1; min-width: 200px; }
  .wl-counts { white-space: nowrap; }
  .wl-count { display: inline-block; font-size: var(--fs-eyebrow); color: #52606d;
              background: #eef1f4; border-radius: var(--r-full); padding: 1px var(--sp-2);
              margin-right: var(--sp-1); }
  .wl-count b { color: #1f3864; }
  /* A zero is shown but recedes: the row must still reconcile, without every empty
     category shouting as loudly as a real one. */
  .wl-count.zero { color: var(--n-400); background: #f7f9fa; }
  .wl-count.zero b { color: var(--n-400); font-weight: 400; }
  table.wl td { vertical-align: top; }

  /* ---- The user bar, under the nav ----------------------------------------
     What the signed-in person has actually done. Quiet by design: it sits between
     the nav and the page content, so anything loud here competes with the work.
     A thin rule, small type, numbers bolded so the row scans as figures rather
     than as a sentence. */
  .userbar { display: flex; align-items: center; gap: var(--sp-4); flex-wrap: wrap;
             background: #fbfcfd; border-bottom: 1px solid #e4e9ee;
             padding: var(--sp-15) var(--sp-6); font-size: var(--fs-xs); color: var(--text-muted); }
  .userbar .ub-who { font-weight: 700; text-transform: uppercase;
                     letter-spacing: .05em; font-size: var(--fs-eyebrow); color: var(--n-600); }
  .userbar .ub-item b { color: #1f3864; font-weight: 700; }
  /* An estimated figure is labelled as one, everywhere it appears. A cost that
     looks authoritative and is a guess is worse than one that admits it. */
  .userbar .ub-est { font-size: var(--fs-eyebrow); text-transform: uppercase; letter-spacing: .05em;
                     color: var(--text-muted); border: 1px solid #e0e5ea; border-radius: var(--r-full);
                     padding: 0 var(--sp-1); margin-left: var(--sp-1); }
  .userbar .ub-more { margin-left: auto; color: #2f5b8c; text-decoration: none;
                      font-weight: 600; }
  .userbar .ub-more:hover { text-decoration: underline; }

  /* ------------------------------------------------------------------ *
   * THE ACKNOWLEDGEMENT LAYER. Required on every page -- see            *
   * app_feedback.py, which owns the behaviour these rules paint.        *
   *                                                                     *
   * The rule it serves: a click produces a visible change within one    *
   * frame, whether or not the work behind it has finished. Two seconds  *
   * of an unchanged page is indistinguishable from a click that did not *
   * land, and the cost of that is a double-submitted save.              *
   * ------------------------------------------------------------------ */

  /* The swirl. An ::after ring rather than a replaced label: swapping the label for
     "Saving…" loses the word somebody was reading, and on a control whose label is an
     icon there is nothing to swap. currentColor so it works on every button variant
     in this stylesheet without one rule per variant. */
  .cp-busy { position: relative; }
  .cp-busy::after {
    content: ''; position: absolute; top: 50%; right: 7px; width: 11px; height: 11px;
    margin-top: -6px; border-radius: var(--r-full);
    border: 2px solid currentColor; border-right-color: transparent;
    opacity: 0.65; animation: cp-spin 0.62s linear infinite; pointer-events: none;
  }
  /* A BUTTON widens by ~19px to make room, so the ring never sits over its label.
     That does move whatever is beside it, and the alternative -- overlapping a tight
     button's text with a spinner -- is worse: the label becomes unreadable at the
     exact moment somebody is checking what they just pressed. It happens once, on the
     control being pressed, and it stops when the next page paints.

     A LINK does not widen. In running text or a nav row a reflow would shove the
     whole line, so the ring hangs outside the link's own box instead. */
  button.cp-busy, input.cp-busy { padding-right: var(--sp-6); }
  a.cp-busy::after { right: -15px; }

  @keyframes cp-spin { to { transform: rotate(360deg); } }

  /* The top bar. Runs to 88% on its own and stops there: a bar that reaches 100%
     while the page is still loading has told a lie, and the next page's paint is
     what actually finishes it. */
  .cp-bar {
    position: fixed; top: 0; left: 0; height: 2.5px; width: 0;
    background: var(--tenant-accent, #c8a24a);
    box-shadow: 0 0 8px var(--tenant-accent, #c8a24a);
    z-index: 9999; opacity: 0; transition: opacity var(--dur-1) var(--ease);
    pointer-events: none;
  }
  .cp-bar.cp-bar-on { opacity: 1; animation: cp-crawl 9s cubic-bezier(0.15, 0.85, 0.25, 1) forwards; }
  @keyframes cp-crawl { from { width: 0; } to { width: 88%; } }

  /* The companion note. TOP RIGHT under the topbar, and deliberately its OWN
     object floating above the page rather than a strip pinned into the layout --
     per direct instruction: "super professional but also relaxing and fun".

     MOVED OFF CENTRE 2026-08-08, on direct instruction: *"the fun facts bubble that
     comes up on every change screen needs to come up on the right side of the screen
     and grow in height and shrink in width so that the nav menu dropdown doesn't get
     covered up."* It was `left: 50%; transform: translateX(-50%)` with a 600px cap --
     top centre, directly under the bar, which is precisely where the grouped nav's
     panels open now that grouped is the default and Platform carries twelve items.

     SO IT IS A COLUMN NOW, not a banner: a fixed ~300px width instead of a
     `max-content` box up to 600px wide, which is the "grow in height and shrink in
     width" half of the instruction. The same sentence takes three or four lines
     instead of one and occupies a third of the horizontal space it used to.

     IT ALSO GETS OUT OF THE WAY OF AN OPEN PANEL ENTIRELY -- see the `:has` rule
     below -- because narrowing alone only guarantees a miss on a wide screen: a
     288px menu anchored under the last group on a 1024px window still reaches the
     right edge, and "doesn't get covered up" was the instruction, not "usually".

     Depth is what makes it read as a separate friendly thing rather than a browser
     notification bar: a soft double shadow (a tight contact shadow plus a wide
     ambient one), a hairline border, and a generous radius. The tenant accent is a
     left rule only, so it is recognisably part of the app without shouting. Those
     stay exactly as they were, and they are the reason a card in the top-right
     corner still does not read as a toast: a toast is a status, this is a diversion,
     and it carries a "fun fact" label instead of an outcome.

     STILL AN OUTER POSITIONER AND AN INNER CARD. The original reason was that one
     element cannot hold both the centring transform and the transform that slides
     it out; with `right` doing the placement that constraint is gone, and the split
     is kept anyway because it is what keeps `app_feedback`'s animation ownership
     clean -- the positioner owns WHERE, the card owns HOW IT MOVES. */
  /* `top` WAS THE LITERAL 74px and is now derived, because the bar it tucks under is
     pinned as of 2026-08-08 and is therefore always the thing directly above this. On
     a narrow window the bar wraps to two or three rows and 74px put the note across
     the middle of it -- over the nav, which is the one thing the instruction that
     moved this note said it must not cover. `--nav-h` is written by `app_feedback`
     from the bar's measured height; the fallback is the desktop bar's real 64px, so
     the note is placed correctly before any script runs. */
  .cp-note { position: fixed; top: calc(var(--nav-h, 64px) + var(--sp-15)); right: var(--sp-4);
             z-index: 9998; pointer-events: none;
             width: min(300px, calc(100vw - 40px));
             transition: opacity var(--dur-1) var(--ease); }
  /* WHILE A NAV PANEL IS OPEN, THE NOTE STANDS DOWN. The instruction names the
     dropdown as the thing that must not be covered, and the note is the one of the
     two that nobody opened on purpose -- a diversion yields to a control, never the
     other way round. Both disclosures in the bar are `<details>` (the tenant switcher
     and every nav group), so one selector covers them and a group added next month
     is covered without anybody remembering this rule.

     `opacity` on the POSITIONER, not on the card: the card's own opacity is mid
     transition for most of a note's life and fighting it would make the note flicker
     as it arrived. A browser without `:has()` support simply keeps the note visible,
     which is the behaviour before this rule and still better than the centred one. */
  body:has(.topbar details[open]) .cp-note { opacity: 0; pointer-events: none; }
  /* AND IT STANDS DOWN ON THE SHELL TOO, for a reason that did not exist until
     today. The note is `position: fixed` and used to be scrolled out from under
     by the page; Hatch's app shell removed the page scroller, so it sits over
     the utility rail PERMANENTLY. Measured: it covers the CONDITIONS mode button
     at 1440, 1280 and 1100, hit-tested unclickable for 10 of 30 one-second
     samples. A diversion that eats a control is worse than no diversion --
     the same argument as the nav panel above, arriving from a new direction. */
  body:has(.ws-hatch) .cp-note { display: none; }
  .cp-note-card {
    display: flex; align-items: flex-start; gap: var(--sp-3);
    background: linear-gradient(180deg, #ffffff 0%, #fcfdfe 100%);
    border: 1px solid #dfe6ee;
    border-left: 3px solid var(--tenant-accent, #c8a24a);
    border-radius: var(--r-lg); padding: var(--sp-3) var(--sp-4);
    font-size: var(--fs-sm); line-height: 1.55; color: #3e4c59;
    box-shadow: 0 1px 2px rgba(31, 41, 51, 0.07),
                0 14px 38px -8px rgba(31, 41, 51, 0.22);
    opacity: 0; pointer-events: auto;
    /* ENTERS from slightly above, LEAVES to the right. Both eased rather than
       linear, and the exit is slower than the entry -- a fast exit reads as a
       dismissal, a slow one reads as it finishing its sentence. */
    transform: translate3d(0, -10px, 0);
    transition: opacity 0.42s cubic-bezier(0.22, 1, 0.36, 1),
                transform 0.52s cubic-bezier(0.22, 1, 0.36, 1);
    will-change: transform, opacity;
  }
  .cp-note.cp-note-on .cp-note-card { opacity: 1; transform: translate3d(0, 0, 0); }
  .cp-note.cp-note-out .cp-note-card {
    opacity: 0; transform: translate3d(120px, 0, 0);
    transition: opacity 0.5s ease, transform 0.62s cubic-bezier(0.4, 0, 0.2, 1);
  }
  .cp-note-tag { display: block; font-size: var(--fs-eyebrow); text-transform: uppercase;
                 letter-spacing: 0.08em; font-weight: 700;
                 color: var(--tenant-accent, #b08a2e); margin-bottom: var(--sp-1); }
  .cp-note-text { display: block; }
  /* The × is a real button so it is keyboard-reachable. Quiet until you go near it:
     an escape hatch should be findable, not competing with the thing it closes. */
  .cp-note-x { flex: none; margin-left: var(--sp-1); align-self: flex-start;
               background: none; border: none; cursor: pointer;
               font-size: var(--fs-lg); line-height: 1; padding: 0 var(--sp-1) var(--sp-05);
               color: var(--n-400); border-radius: var(--r-md); transition: background-color var(--dur-1) var(--ease), color var(--dur-1) var(--ease); }
  .cp-note-x:hover { color: #3e4c59; background: #eef2f6; }
  .cp-note-x:focus-visible { outline: 2px solid var(--tenant-accent, #c8a24a);
                             outline-offset: 1px; }

  /* Reduced motion keeps the ACKNOWLEDGEMENT and drops the animation. The
     acknowledgement is the accessibility feature here; the spinning is the
     decoration, and this is which of the two survives. */
  @media (prefers-reduced-motion: reduce) {
    .cp-busy::after { animation: none; border-right-color: currentColor; opacity: 0.4; }
    .cp-bar.cp-bar-on { animation: none; width: 100%; opacity: 0.55; }
    .cp-note { transition: none; }
  }

  @media (max-width: 900px) {
    /* THE BAR WRAPS HERE, so `top: 74px` stops being a number that clears it: `nav`
       takes `flex-basis: 100%` and a row of its own, and the whole bar goes to
       roughly 116-160px depending on how many rows the links need. A note pinned
       74px down would sit ON the nav it was just moved away from -- and any panel
       opening out of that nav opens downward, into the same space again.

       Anchored to the BOTTOM instead of guessing a height. It cannot collide with a
       top bar of any height, it needs no number that goes stale when a nav item is
       added, and on a tablet the bottom-right corner is the one place nothing else
       lives. The exit animation is unchanged -- it still leaves to the right. */
    .cp-note { top: auto; bottom: var(--sp-4); }
  }

  @media (max-width: 760px) {
    /* On a phone the note would sit over the content it is distracting from. */
    .cp-note { display: none; }
  }

  /* ---- The document viewer -------------------------------------------------
     document_viewer.py renders it; viewer_routes.py serves it.

     THE ACCESSIBILITY RULE THIS SECTION EXISTS FOR: the two stances a mark can
     carry are `supports` and `contradicts`, and reading one as the other is a
     case-strategy error rather than a cosmetic one. So colour NEVER carries the
     distinction on its own. Four redundant signals, in order of how reliably
     they survive:

       1. A VISIBLE WORD in the badge ("supports" / "contradicts"). Survives
          greyscale, a photocopy, a fax, copy-paste into plain text, a screen
          reader, and every colour-vision deficiency. It is first because it is
          the one that always works and needs no legend.
       2. TEXTURE. Flat yellow for supports, DIAGONAL STRIPES for contradicts.
          A stripe is still a stripe in black and white, which is what makes it
          the signal that survives printing.
       3. UNDERLINE STYLE. Single for supports, DOUBLE for contradicts. It is
          intrinsic to the text run, so it survives a reader-mode or a
          high-contrast theme that strips backgrounds.
       4. THE LEFT RULE. Solid vs dashed -- the fastest cue when scanning a
          column of quotes rather than reading one.

     Yellow is present because it was asked for, and it is the FIFTH signal, not
     the first. Both variants keep #1f2933 text on a light yellow ground, which
     stays well above 4.5:1 -- a mark you cannot read is not a highlight.

     THE YELLOW IS INHERITED, NOT REDECLARED. `.dv-mark` sets no background at
     all: it is a `<mark>`, so it takes #fff59d from the bare `mark` rule above,
     which exists because of the direct instruction behind the Road Map's Match
     Evidence highlighting. One yellow in the product, changed in one place --
     a second, nearly-identical yellow here would be the kind of drift nobody
     notices until the two surfaces are seen side by side. */
  .dv { display: grid; grid-template-columns: minmax(0,1fr) minmax(0,1fr);
        gap: var(--sp-4); align-items: start; }
  .dv-left, .dv-right { min-width: 0; }
  .dv-back { margin: 0 0 var(--sp-3); }
  .dv-doc h2, .dv-quotes h2, .dv-assess h2 { margin: 0 0 var(--sp-1); font-size: var(--fs-md); }
  /* A tall frame, because the thing being read is a document. 70vh rather than a
     pixel height so it uses whatever screen it is on. */
  .dv-doc-frame { width: 100%; height: 70vh; border: 1px solid #cbd2d9;
                  border-radius: var(--r-md); background: #fff; margin-top: var(--sp-2); }
  .dv-doc-link { margin: var(--sp-2) 0 0; }
  /* THE IN-APP READER. An <object> served by this app, not an iframe pointed at
     drive.google.com -- no third-party viewer, no CDN, no second round trip on a
     starter instance, and no library nobody here has read running on a page showing
     medical records. It shares .dv-doc-frame's box so there is ONE document frame in
     the product, and so the @media print rule that hides a frame keeps working. */
  .dv-native { display: block; }
  /* The canonical reader renders one document with no assessment beside it, so it
     gets the full width rather than half of a two-column grid it is not in. */
  .dv-single { max-width: 1100px; }
  /* A COLD DOCUMENT SHOWS NO FRAME. The rule is that a cold cache shows no content
     rather than a zero, and a viewer's zero is an empty grey rectangle that reads as
     a page that failed. So: a sentence and a real control, bordered so it is
     obviously a state and not the document. */
  .dv-cold { border: 1px dashed #cbd2d9; border-radius: var(--r-md); padding: var(--sp-4);
             margin-top: var(--sp-2); background: #fbfcfd; }
  .dv-cold-act { margin: var(--sp-3) 0 0; }
  /* "We cannot show this" is a DIFFERENT state from "nobody has fetched it yet", and
     the red rule is the same one .dv-broken uses for the same reason -- a reader who
     cannot tell them apart clicks the same button forever. */
  .dv-unavailable { border-style: solid; border-left: 4px solid #c0392b; }
  .dv-legend { font-size: var(--fs-xs); color: #52606d; margin: var(--sp-1) 0 var(--sp-3); }
  .dv-quote-list { list-style: none; margin: 0; padding: 0; }
  .dv-quote { margin-bottom: var(--sp-2); }

  /* THE MARK. A real <mark>, not a styled span, so assistive technology has a
     concept of it -- and the stance badge inside it is real visible text rather
     than an aria-label, so sighted and non-sighted readers check the SAME fact. */
  .dv-mark { display: block; padding: var(--sp-15) var(--sp-2) var(--sp-15) var(--sp-3); border-radius: var(--r-sm);
             color: #1f2933; border-left: 4px solid #8a6d00;
             font-size: var(--fs-sm); }
  .dv-mark:focus-visible, .dv-mark:focus { outline: 3px solid #1f3864;
                                           outline-offset: 2px; }
  .dv-quote-text { display: block; margin-top: var(--sp-1); }
  .dv-supports .dv-quote-text, mark.dv-supports .dv-quote-text {
    text-decoration: underline; text-decoration-thickness: 1px;
    text-underline-offset: 2px; }
  /* Signals 2, 3 and 4 for contradicting text, all at once. */
  mark.dv-contradicts { border-left-style: dashed;
    background-image: repeating-linear-gradient(135deg,
      rgba(0,0,0,0.10) 0 3px, rgba(0,0,0,0) 3px 8px); }
  .dv-contradicts .dv-quote-text, mark.dv-contradicts .dv-quote-text {
    text-decoration: underline double; text-decoration-thickness: 1px;
    text-underline-offset: 2px; }
  mark.dv-neutral { background: #f1f3f6; border-left-color: #98a4b0; }
  /* Signal 1: the word. Bordered and uppercase so it reads as a label rather
     than as the first two words of the quote. */
  .dv-tag { display: inline-block; font-size: var(--fs-eyebrow); font-weight: 700;
            text-transform: uppercase; letter-spacing: 0.07em;
            border: 1px solid #6b5900; border-radius: var(--r-sm); padding: 0 var(--sp-1);
            margin-right: var(--sp-15); background: #fff; color: #4a3d00;
            vertical-align: 1px; }
  .dv-tag.dv-contradicts { border-style: double; border-width: 3px;
                           padding: 0 var(--sp-1); }
  .dv-tag-other { border-color: #98a4b0; color: #3e4c59; }
  /* PROVENANCE: a second badge, and never a colour. A quote that was already the
     filed justification for this document carries different weight from one a
     model noticed this pass, and two facts encoded in one hue are two facts a
     greyscale reader loses at once. Quieter than the stance badge because the
     stance is the one that must be read first. */
  .dv-prov { display: inline-block; font-size: var(--fs-eyebrow); text-transform: uppercase;
             letter-spacing: 0.05em; color: #52606d; border-radius: var(--r-sm);
             padding: 0 var(--sp-1); margin-right: var(--sp-15); background: rgba(255,255,255,0.75);
             border: 1px solid #cbd2d9; vertical-align: 1px; }
  .dv-prov-record { border-style: solid; font-weight: 700; }
  .dv-prov-model { border-style: dotted; }
  .dv-prov-unknown { border-style: dotted; font-style: italic; }
  .dv-provsum { font-size: var(--fs-xs); color: #52606d; margin: 0 0 var(--sp-2);
                border-left: 3px solid #cbd2d9; padding-left: var(--sp-2); }
  .dv-basis h2 { margin: 0 0 var(--sp-1); font-size: var(--fs-md); }
  /* The record's own filing justification. Monospaced-ish measure and preserved
     line breaks, because it is a transcription rather than prose. */
  .dv-basis-body { font-size: var(--fs-sm); white-space: pre-wrap;
                   background: #fbfcfd; border: 1px solid #e4e7eb;
                   border-radius: var(--r-md); padding: var(--sp-2) var(--sp-3); }
  .dv-page { font-size: var(--fs-eyebrow); font-weight: 700; color: #52606d;
             letter-spacing: 0.03em; }
  .dv-page-none { font-weight: 400; font-style: italic; }
  /* The flash after a citation jump. Focus moves too (see document_viewer's
     SCRIPT) so this is the sighted half of a two-channel confirmation. */
  .dv-hit { animation: dvhit 1.5s ease-out 1; }
  @keyframes dvhit {
    0% { box-shadow: 0 0 0 4px rgba(31,56,100,0.55); }
    100% { box-shadow: 0 0 0 4px rgba(31,56,100,0); }
  }

  /* The citation. Small, inline, and carries its page number so the link says
     where it goes rather than "here". */
  .dv-cites { white-space: nowrap; }
  .dv-cite { display: inline-block; font-size: var(--fs-eyebrow); font-weight: 700;
             text-decoration: none; border: 1px solid #8a6d00; border-radius: var(--r-sm);
             padding: 0 var(--sp-15); margin-left: var(--sp-15); background: #fff59d;
             color: #4a3d00; }
  .dv-cite:hover, .dv-cite:focus-visible { background: #ffe680; }
  a.dv-cite.dv-contradicts { border-style: dashed; text-decoration: underline double; }
  a.dv-cite.dv-neutral { background: #f1f3f6; border-color: #98a4b0; color: #3e4c59; }
  /* An ADMITTED missing citation. Never a link: a dead link reads as verified. */
  .dv-nocite { font-size: var(--fs-eyebrow); font-style: italic; color: var(--text-muted);
               margin-left: var(--sp-15); }

  .dv-block { border-top: 1px solid #e4e7eb; padding-top: var(--sp-2); margin-top: var(--sp-3); }
  .dv-block h3 { font-size: var(--fs-eyebrow); text-transform: uppercase; letter-spacing: 0.06em;
                 color: var(--text-muted); margin: 0 0 var(--sp-15); }
  .dv-rows { list-style: none; margin: 0; padding: 0; }
  .dv-row { padding: var(--sp-1) 0; border-bottom: 1px dotted #e4e7eb; }
  .dv-row:last-child { border-bottom: none; }
  .dv-row-main { font-size: var(--fs-sm); }
  .dv-row-sub { display: block; font-size: var(--fs-xs); color: #52606d; }
  .dv-lead { font-size: var(--fs-base); margin: var(--sp-1) 0 var(--sp-15); }
  .dv-note { font-size: var(--fs-xs); color: var(--text-muted); margin: 0 0 var(--sp-15); }
  .dv-criteria { margin: var(--sp-1) 0 0 var(--sp-4); padding: 0; font-size: var(--fs-xs);
                 color: #3e4c59; }
  .dv-target { font-weight: 700; }
  /* Honest emptiness, styled to be READ rather than skipped. */
  .dv-none { font-size: var(--fs-xs); color: var(--text-muted); font-style: italic; margin: var(--sp-05) 0; }
  .dv-empty { padding: var(--sp-1) 0; }
  .dv-empty h3 { margin: 0 0 var(--sp-15); font-size: var(--fs-md); }
  .dv-broken { border-left: 4px solid #c0392b; padding-left: var(--sp-3); }
  .dv-provenance { font-size: var(--fs-xs); color: var(--text-muted); margin: 0 0 var(--sp-2);
                   border-left: 3px solid #cbd2d9; padding-left: var(--sp-2); }
  /* "Never assessed" is a DIFFERENT state from "not yet" and from "broken", and
     three states that look alike are one state with extra words. Grey rather than
     red: nothing is wrong here, this document is simply out of scope. */
  .dv-blocked { border-left: 4px solid #98a4b0; padding-left: var(--sp-3); }

  @media (max-width: 1100px) {
    /* One column below the width where two document panes are legible. The
       document comes FIRST, because the assessment is a claim about it. */
    .dv { grid-template-columns: minmax(0,1fr); }
    .dv-doc-frame { height: 55vh; }
  }

  /* ---- Printing this page -------------------------------------------------
     A printed assessment is a real artefact here: it goes in a case file and it
     gets photocopied. Three things have to survive that, and none of them do by
     default. */
  @media print {
    /* A contradiction row must never split from its own quote. The quote IS the
       evidence, and this page goes into case files. Folded in here rather than in a
       second @media print block -- two print blocks is two places somebody has to
       check before trusting a printout. */
    .cq-table tr { break-inside: avoid; page-break-inside: avoid; }
    /* Browsers drop backgrounds when printing, which would take the yellow AND
       the stripes with it -- leaving supports and contradicts identical on
       paper. Forced, deliberately, on the marks only. */
    .dv-mark, .dv-tag, .dv-prov, .dv-cite {
      -webkit-print-color-adjust: exact; print-color-adjust: exact; }
    /* The bare `mark` rule is where the yellow comes from, so it is the one that
       has to survive printing -- forcing only `.dv-mark` would print the borders
       and the stripes onto white. */
    mark { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
    /* An iframe prints as an empty rectangle, so it is replaced by its link. An
       <object> holding a PDF prints as one too -- worse, some browsers print the
       first page and silently drop the rest -- so the in-app reader is hidden by
       the same rule, which it inherits by also carrying .dv-doc-frame. */
    .dv-doc-frame { display: none; }
    /* The cold state is a screen affordance: on paper "Read it here" is a button
       nobody can press, and it would sit where the document should be. */
    .dv-cold-act { display: none; }
    .dv-doc-link a::after { content: " (" attr(href) ")"; font-size: 10px;
                            word-break: break-all; }
    /* Both columns full width, in reading order: document, quotes, assessment. */
    .dv { display: block; }
    .dv-left, .dv-right { width: 100%; }
    /* A quote must never be split across a page break from its own badge. */
    .dv-quote, .dv-mark, .dv-block { break-inside: avoid; page-break-inside: avoid; }
    /* On paper a citation cannot be clicked, so it names its page instead of
       pretending to be a link. */
    .dv-cite { text-decoration: none; border-style: solid; }
    a.dv-cite.dv-contradicts { border-style: dashed; }
    .dv-hit { animation: none; }
    /* A FOLD MUST NOT DECIDE WHAT LANDS ON PAPER. The message panel folds its body
       away, and `<details>` was rejected for that job precisely because a closed one
       cannot be reliably forced open for print -- a button and a class can be, and
       this is where that promise is kept. Forced open so a printed page never depends
       on what somebody last clicked, and no `!important` needed: it matches the
       specificity of the rule that folded it and sits later in the file.

       The toggle itself goes, because a control that cannot be pressed on paper is
       noise, and `.cmp-who` with it -- it is the folded stand-in for the fuller note
       above the textarea, which is now visible again. */
    #composer.collapsed .cmp-body { display: block; }
    .cmp-fold { display: none; }
    #composer .cmp-who { display: none; }
    /* AND NEITHER MUST A SCROLLER. Same promise, one layer out: Hatch's work column
       is pinned to the viewport on screen and its thread is a flex child with its own
       overflow, so printed as-is a case file would carry one screen of messages and
       silently lose the rest. Everything goes back to ordinary flow on paper. The
       composer prints BELOW the thread, which is its DOM order again as of
       2026-08-08 -- the record first, then the empty box -- and it is unpinned here
       because a `sticky` element with nothing to scroll inside can land on a page
       break rather than after the messages it belongs to.

       THE PINNED NAV IS UNPINNED TOO, and that is not cosmetic: a `sticky` bar with a
       stacking context can repeat on, or overlap, a printed page, and a navy 64px
       banner on every sheet of a case file is a records problem rather than an ugly
       one.

       `min-height` on the textarea is dropped for the same reason as everything else
       here -- an empty box in a case file is wasted paper. */
    .topbar { position: static; }
    .conv-composer { position: static; box-shadow: none; }
    .ws-hatch .ws-main { position: static; max-height: none; display: block;
                         overflow: visible; }
    /* ---- AND THE APP SHELL IS FLATTENED, WHICH IS THE SAME PROMISE ONE LAYER
       FURTHER OUT AGAIN. Above 1000px Hatch is a fixed-height frame with three
       scrollers in it (see "HATCH IS AN APP SHELL"), and a fixed shell prints ONE
       SCREEN and silently loses everything else -- the rest of the client's record,
       the rest of the intelligence rail, the rest of the thread.

       STRICTLY SPEAKING THIS IS BELT AND BRACES: the shell is declared inside
       `@media screen and (...)`, so a printer never matches it in the first place.
       It is written out anyway because that protection is one word long, it is
       invisible at the point of use, and the failure it prevents is not a layout
       bug -- it is a case file that is quietly incomplete. The same reasoning is
       why `.topbar` and `.ws-main` above are un-pinned here rather than left to the
       media type. */
    html, body { height: auto; overflow: visible; display: block; }
    main { display: block; overflow: visible; }
    .ws-hatch .ws-rail, .ws-hatch .ws-intel { overflow: visible; min-height: 0; }
    /* A SCROLLER IS A CLIPPER ON PAPER, AND THESE TWO WERE CLIPPING. `.log-body` is
       the inset condition log (320px) and `.log-body[data-ca="screen"]` is the
       Client App phone frame (420px); both sit in the intelligence rail and neither
       is inside a `screen` block, so they have been dropping everything past their
       own fold from every printout. Exactly the defect found in `.cqv-list` earlier
       today -- a clamp written when the page was the only scroller, silently losing
       every condition past ~560px -- and it is recorded here rather than fixed by
       deleting the scroller, because on screen the frame IS the feature.

       `[data-log]` IS THE THIRD ONE AND IT USED TO BE UNREACHABLE FROM HERE. The
       client record's flag log carried `style="max-height:480px"`, and an inline
       declaration outranks every author rule including this block, so that log
       clipped on paper no matter what was written here. It is an attribute now
       (`.log-body[data-log="tall"]`), which this selector can and does beat -- and
       it has to be NAMED, because `.log-body` alone is (0,1,0) and loses to it. */
    .log-body, .log-body[data-ca="screen"], .log-body[data-log] {
      max-height: none; overflow: visible; }
    /* Two classes on the ancestor, deliberately: `body.cmp-collapsed .conv` is (0,2,1)
       and outranks a plain `.conv` wherever it sits, so un-clamping the thread for
       paper takes (0,3,0) to beat it. Written as `.ws.ws-hatch` rather than by adding
       a fourth `body.cmp-collapsed .conv` rule, which is counted by a test that exists
       to keep the fold's three breakpoint clamps in step. */
    .ws.ws-hatch .conv, .conv { max-height: none; overflow: visible; }
    #composer textarea { max-height: none; min-height: 0; overflow: visible; }
    /* ---- The finance page ------------------------------------------------
       Folded into THIS block rather than a second `@media print`, because
       test_mobile_viewport asserts there is exactly one -- two places to check
       before trusting a printout is one too many.

       A printed revenue figure goes into a board pack, so three things have to
       survive: the legend swatches (CSS backgrounds, which browsers drop when
       printing), the 45-degree hatch that separates "ours" from "unattributed"
       without relying on colour, and a table not split from the graph it is
       there to reconcile. The SVG's own fills print by default; the swatches
       do not. */
    .fin-sw-mine, .fin-sw-un, .fin-chart svg {
      -webkit-print-color-adjust: exact; print-color-adjust: exact; }
    .fin-chart, .fin-hero, .fin-band { break-inside: avoid; page-break-inside: avoid; }
    .fin-table tr { break-inside: avoid; page-break-inside: avoid; }
    /* A pill that looks like a button and cannot be pressed is noise on paper. */
    .fin-cross { display: none; }
    /* ---- The Intent to File watch ----------------------------------------
       Folded into THIS block for the reason stated twice above: exactly one
       @media print, asserted by test_mobile_viewport.

       A printed caseload deadline list goes in front of a meeting, and the whole
       point of the hatch is that it survives being printed -- which it does not
       by default, because browsers drop backgrounds. Forced on the row textures
       and the tags only. The WORD in each tag needs no help; that is why it is
       signal one. */
    .itfw-expired > td, .itfw-urgent > td, .itfw-tag {
      -webkit-print-color-adjust: exact; print-color-adjust: exact; }
    /* A client must never be separated from their own expiry date by a page
       break, and the section must not split from its own provenance line. */
    .itfw-table tr { break-inside: avoid; page-break-inside: avoid; }
    .itfw { break-inside: avoid; page-break-inside: avoid; }
    /* NO ATTEMPT IS MADE TO FORCE THE DISCLOSURE OPEN, and that is honest rather
       than lazy: this file already records (see the composer fold above) that a
       closed `<details>` cannot be reliably forced open for print, which is why
       the composer uses a button and a class instead. So the COUNT lives in the
       `<summary>`, which always renders and therefore always prints -- the number
       is the finding, and the names are a screen affordance for chasing it. A
       printout that quietly dropped the count would be the real loss. */
    .itfw-nofile > summary { font-weight: 700; }
    /* ---- Unassigned clients. Same treatment, same reasons. */
    .asw-both > td, .asw-tag {
      -webkit-print-color-adjust: exact; print-color-adjust: exact; }
    .asw-table tr { break-inside: avoid; page-break-inside: avoid; }
    .asw { break-inside: avoid; page-break-inside: avoid; }
    /* A tooltip cannot be hovered on paper, so the hint prints its own reason. */
    .asw-why::after { content: " " attr(title); font-size: 10px; }
    /* ---- The landing page ------------------------------------------------
       Folded into THIS block for the reason stated three times above: exactly
       one @media print, asserted by test_mobile_viewport.

       A tile must not be split from its own mark, and the grid becomes one
       column because two 280px columns on a 624px page leave neither of them
       room for the name. The switch tile prints as a plain block: `.fin-cross`
       is hidden on paper because a pill that cannot be pressed is noise, but
       here the button IS the content, so it keeps its words and loses only the
       affordance -- the border, which on paper reads as a control. */
    .lp-tiles { display: block; }
    .lp-tile { break-inside: avoid; page-break-inside: avoid; border: none;
               box-shadow: none; margin-bottom: var(--sp-4); }
    /* On paper "Switch to ..." is an instruction nobody can follow, so the line
       names the destination instead of pretending to be pressable. */
    .lp-go { text-decoration: none; }
  }


  /* =====================================================================
   * MERGED FROM THE MODULES THAT STAGED THEM, 2026-08-06.
   *
   * `flag_routes.FLAGS_CSS`, `nexus_attribution.CSS` and the contradiction
   * queue's block were each written by a different agent and staged in their
   * own module, because several were editing at once and two hands in this
   * file is how you get one class defined twice. They are merged here so
   * there is ONE definition of each class; the staged copies are emptied in
   * the same commit, and test_flag_routes asserts exactly that.
   * ===================================================================== */

  .fl-filters { display: flex; flex-wrap: wrap; gap: var(--sp-15); align-items: center; margin: 0 0 var(--sp-4); }
  .fl-filters .fl-label { font-size: var(--fs-eyebrow); font-weight: 700; letter-spacing: 0.04em;
                          text-transform: uppercase; color: var(--text-muted); margin-right: var(--sp-05); }
  .fl-filters .fl-total { margin-left: auto; }
  .fl-chip { text-decoration: none; border: 1px solid transparent; cursor: pointer; }
  .fl-chip:hover { border-color: #9aa5b1; }
  .fl-chip b { margin-left: var(--sp-1); font-variant-numeric: tabular-nums; }
  .fl-chip[hidden] { display: none; }
  .fl-sum { display: block; font-size: var(--fs-sm); color: #3e4c59; max-width: 46em; }
  .fl-none { color: var(--text-muted); font-style: italic; }
  .fl-note { display: block; font-style: italic; }
  .fl-empty { margin-bottom: var(--sp-4); }
  .fl-empty p { margin: 0; }
  /* ---- The "this is not a finding" card, and why it is shared ------------
     Four surfaces had four ways of saying the same sentence. `/flags` had
     `.fl-alarm` (a red left rule and tight paragraphs), the workspace had
     `.ws-notyet` (a dashed box), `/needs-review` had a pill plus `.nr-note`,
     and `/tasks` had nothing at all -- a bare card with the raw exception
     printed at body weight, which reads as content rather than as a failure.

     `.notice` is that card, once. Bare, it means A READ FAILED, so what you
     are looking at is not the answer. `.notice.wait` means nothing is wrong
     and the figure has not been computed yet -- the distinction the whole
     `/needs-review` design rests on. `.fl-alarm` is kept as an alias rather
     than churned, because `/flags` is another agent's surface today.

     The colour is never the signal. Every use carries the sentence in bold
     as its first line; the rule is the third cue after the words and the
     pill, and the failure is 4px against the wait's 3px so the two separate
     in greyscale as well. */
  .notice, .fl-alarm { border-left: 4px solid #c0392b; margin-bottom: var(--sp-4); }
  .notice > p, .fl-alarm p { margin: 0 0 var(--sp-15); }
  .notice > p:last-child, .fl-alarm p:last-child { margin-bottom: 0; }
  .notice.wait { border-left: 3px solid #b7791f; }

  .na-quote { border-left: 3px solid #b7791f; background: #fbf9f4; margin: var(--sp-15) 0 0;
              padding: var(--sp-15) var(--sp-3); font-size: var(--fs-xs); color: #3c4858; }
  .na-quote .na-page { color: var(--text-muted); font-size: var(--fs-eyebrow); font-weight: 700;
                       margin-right: var(--sp-15); }
  .na-attr { margin: 0 0 var(--sp-3); }
  .na-attr:last-child { margin-bottom: 0; }

  /* ---- The contradicting-evidence queue ---------------------------------
     Its quote reuses the document viewer's .dv-mark treatment, so the same
     sentence looks and prints identically on both surfaces and inherits all
     four redundant stance signals.

     ITS OWN STACKING BLOCK WAS DELIBERATELY NOT MERGED. It hid the thead and
     stacked every cell at 900px, which contradicts the app-wide treatment
     chosen in the mobile audit: wide tables become a horizontal scroll
     container instead, because a stacked row of BARE UNLABELLED values is
     harder to read than a table you can swipe, and stacking needs a
     data-label on every cell to be legible. One strategy, not two. */
  table.grid.cq-table { table-layout: fixed; }
  .cq-table td { font-size: var(--fs-sm); }
  .cq-rank { font-size: var(--fs-xs); }
  /* OCR produces long unbroken runs -- filenames, lab strings, redaction
     artefacts. Breaking mid-word is ugly; a scrollbar hiding the end of the
     evidence is worse. */
  .cq-quote { overflow-wrap: anywhere; }

  /* ---- The flag-fix form ------------------------------------------------
     `flag_push.py`. MERGED FROM `flag_push.PUSH_CSS` 2026-08-06, which was
     defined, tested, and inlined by NOTHING -- so every one of these classes
     was reaching the browser with no rule behind it and the form rendered as
     raw HTML. The staged constant is now empty, and `test_flag_push.py`'s
     `test_the_staged_css_is_not_silently_duplicated_in_app_css` is what keeps
     it that way. Same for `.nr-*` below.

     Placed ABOVE the client-flags section deliberately, and its subtab class
     is named nowhere in this comment on purpose: `test_client_flags` pins that
     component's region by substring, from the first mention of its class to
     the phones-and-tablets header, and asserts no media query inside. The
     720px query at the end of this block would land in that window -- and a
     comment that merely names the class opens the window early, which is the
     same trap a CSS comment sprang on a test once already.

     THE PAGE IS USABLE WITHOUT ANY OF IT. The controls are `.btn`, `.card`,
     `.pill` and `.muted`, all shared; `select, input, textarea` are already
     styled globally; and the write-in box is preferred because of ITS PLACE
     AND ITS LABEL -- first, above the disclosure, with the word on it -- not
     because of a colour. Everything here is refinement. */
  .ff-head { margin: var(--sp-05) 0 var(--sp-3); }
  .ff-said p { margin: 0; }
  .ff-detail pre { white-space: pre-wrap; word-break: break-word; max-height: 420px;
    overflow: auto; background: #f7f9fc; border: 1px solid #e3e8ef; border-radius: var(--r-md);
    padding: var(--sp-3); font-size: var(--fs-xs); }
  .ff-detail > summary { cursor: pointer; }
  /* THE DOCUMENT'S OWN WORDS, on the page where a flag is ruled on. A rule down
     the left and a quiet indent, which is what centuries of print already use to
     mean "this is quoted, not written here" -- the distinction is the whole point,
     because a flag's summary is what this code SAID and the span is what the
     RECORD said. Deliberately not a callout, a banner or a colour: it is evidence
     to read, not a status to notice, and it sits in the reading order rather than
     competing with it. Its label carries the word "verbatim" for the same reason
     `.ff-said` carries its own -- the meaning has to survive somebody skimming. */
  .ff-span-label { margin: var(--sp-2) 0 var(--sp-05); font-size: var(--fs-xs); }
  .ff-span { margin: 0 0 var(--sp-2); padding: var(--sp-1) 0 var(--sp-1) var(--sp-3);
    border-left: 3px solid #c9d3e0; color: #1f2733; white-space: pre-wrap;
    word-break: break-word; }
  .ff-fix { border-top: 1px solid #eef1f6; padding: var(--sp-3) 0 var(--sp-1); }
  .ff-fix:first-of-type { border-top: 0; }
  .ff-fix label { display: block; cursor: pointer; }
  .ff-fix p { margin: var(--sp-1) 0 var(--sp-15) var(--sp-6); }
  .ff-fix select { margin: 0 0 var(--sp-2) var(--sp-6); max-width: 520px; width: 100%; }
  .ff-noauto { border-left: 3px solid #d5dbe3; padding-left: var(--sp-2); }

  /* ------------------------------------------------ /vocab-review's cards
     THE BASIS IS TYPOGRAPHICALLY LOUDER THAN THE CARD'S OWN PROSE and the
     evidence quote is quieter than both. That ordering is the feature: the
     reviewer is confirming a DEFAULT, so the sentence explaining the default
     has to outrank the boilerplate, and the document's own words sit under it
     as support rather than competing with it.

     A reviewer working several hundred of these reads the basis line and
     nothing else on most cards. If it renders at the same weight as the
     grouping counts it will be skipped, and a pre-selection nobody reads is
     the rubber stamp this page exists to avoid. */
  .vr-basis { margin: var(--sp-2) 0 var(--sp-1); font-weight: 500; }
  .vr-quote { margin: 0 0 var(--sp-1); padding-left: var(--sp-2);
    border-left: 3px solid #d5dbe3; font-style: italic; }
  /* THE PAID ANSWER. Louder than the quote and quieter than the basis: it is a
     real recommendation, and it is one the pipeline REFUSED to act on. Styling
     it like a confident suggestion would undo in CSS the refusal the code makes
     in Python. */
  .vr-proposal { margin: var(--sp-2) 0 var(--sp-05); }
  .vr-proposal-why { margin: 0 0 var(--sp-05); }
  /* THE SET. Listed as a fact rather than styled as a control, because a
     broad term is not a question -- see vocab_breadth. */
  .vr-breadth { margin: var(--sp-2) 0 var(--sp-05); }
  .vr-rationale { margin: var(--sp-2) 0; }
  .vr-rationale textarea { width: 100%; min-height: 56px; }
  .vr-canned-row { display: flex; flex-wrap: wrap; gap: var(--sp-1);
    margin: var(--sp-1) 0 var(--sp-2); }
  .vr-canned { font-size: 0.86em; padding: var(--sp-1) var(--sp-2); }
  /* THE BULK BAR IS FRAMED, because it is the one control on the page whose
     effect is larger than the card it sits on. */
  .vr-bulk { border: 2px solid #d5dbe3; }
  .vr-bulk textarea { width: 100%; min-height: 56px; }
  .ff-rec { margin: var(--sp-1) 0 var(--sp-2) var(--sp-6); }
  /* THE PREFERRED CONTROL, and the only thing on the page given its own frame. */
  .ff-why { margin: var(--sp-4) 0 var(--sp-1); border: 1px solid #d5dbe3; border-radius: var(--r-lg);
    padding: var(--sp-3); }
  .ff-why legend { padding: 0 var(--sp-15); font-weight: 600; }
  .ff-why label { display: block; margin-bottom: var(--sp-15); }
  .ff-why textarea { width: 100%; min-height: 92px; }
  .ff-canned-box { margin-top: var(--sp-2); }
  .ff-canned-box > summary { cursor: pointer; color: var(--n-600); font-size: var(--fs-sm); }
  /* Full-width and left-aligned: these are sentences, not buttons in a row, and a
     five-across grid of them would out-shout the box above. */
  .ff-canned { display: block; width: 100%; text-align: left; margin: var(--sp-15) 0;
    font-weight: 400; }
  .ff-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2); align-items: center;
    margin-top: var(--sp-3); }
  .ff-inline { flex: 1 1 220px; }
  .ff-rationale { white-space: pre-wrap; }
  .ff-done { border-left: 3px solid #2f855a; }
  .ff-proposal { border-left: 3px solid #2b6cb0; }
  /* The approval half, visibly its own act rather than more of the same form. */
  .ff-decide { border-left: 3px solid #b7791f; }
  .ff-decide hr { border: 0; border-top: 1px solid #eef1f6; margin: var(--sp-4) 0 var(--sp-3); }
  .ff-decide textarea { width: 100%; }
  @media screen and (max-width: 720px) {
    .ff-fix p, .ff-fix select, .ff-rec { margin-left: 0; }
  }

  /* ---- The Needs Review hub ---------------------------------------------
     `needs_review_routes.py`. MERGED FROM `NEEDS_REVIEW_CSS`, same story.

     THE PAGE IS USABLE WITHOUT IT. `.tiles` and `.tile` are the admin hub's own
     classes and already exist, and every indicator is a `.pill` in an existing
     colour -- so the tiles lay out and the tags are visible from the shared
     palette alone. Everything here is refinement: the indicator sits above the
     heading, and the honesty sentence under the blurb is smaller than it. */
  .nr-tile { position: relative; }
  /* WAS `display: block` with a bottom margin, which put the indicator on
     its own line ABOVE the heading -- right in a square, wrong now that
     `.tile` is a row. The row's own `column-gap` supplies the spacing, and
     `.tile > .nr-ind` orders it beside the name. */
  .nr-ind .pill { font-variant-numeric: tabular-nums; }
  /* The honesty sentence ("part of this could not be read"), which is a DIFFERENT
     fact from the blurb and must not read as more of it. Given the whole width
     after the blurb rather than a column of its own: it is the one thing on the row
     somebody has to act on, and a 20ch column would break it across four lines.
     Indented to `--tile-name` so it lines up with the BLURB it qualifies rather than
     with the name it does not. `--fs-eyebrow` before this pass, which is 11px and
     the size reserved for uppercase micro-labels -- not a size to set a sentence at
     for this audience. */
  .tile .nr-note { order: 4; flex: 1 1 100%;
                   padding-left: calc(var(--tile-name) + var(--sp-3));
                   font-size: var(--fs-xs); line-height: var(--lh-snug);
                   color: var(--n-600); }

  /* ---- The client page's Flags pane -------------------------------------
     `client_flags.py`. Deliberately ABOVE the phones-and-tablets section at
     the end of this file, whose position after @media print is load-bearing.

     THE HATCH STRIP'S RULES ARE GONE, removed 2026-08-06 with the strip
     itself (`.cf-strip`, `.cf-strip-label`, `.cf-strip-clear`,
     `.cf-strip-alarm`, `.cf-item`, `.cf-item-type`, `.cf-more`). Direct
     instruction: *"do not have a preview of the flags covering the scroll"*.
     Deleted rather than left orphaned -- an unused rule is indistinguishable
     from a rule whose element stopped rendering by accident, and the next
     person to grep `.cf-item` should find nothing rather than find dead CSS
     and go looking for the markup.

     `.cf-subtabs` and NOT `.ws-tabs`, even though the three tabs are the same
     three: this row sits directly under the pane tabs, and two identical
     underlined tab strips stacked is a reader guessing which level they are
     on. Segmented pills read as a level down. */
  .cf-subtabs { display: flex; gap: var(--sp-1); flex-wrap: wrap; margin: 0 0 var(--sp-3); }
  .cf-subtabs a { padding: var(--sp-15) var(--sp-3); font-size: var(--fs-xs); font-weight: 600;
                  color: #52606d; text-decoration: none; border: 1px solid #cbd2d9;
                  border-radius: var(--r-lg); background: #fff; }
  .cf-subtabs a:hover { border-color: #9aa5b1; color: #1f3864; }
  .cf-subtabs a.on { background: #1f3864; border-color: #1f3864; color: #fff; }
  .cf-subtabs a .cnt { font-size: var(--fs-eyebrow); background: #e4e7eb; color: #52606d;
                       border-radius: var(--r-full); padding: 1px var(--sp-15); margin-left: var(--sp-15); }
  .cf-subtabs a.on .cnt { background: rgba(255,255,255,0.22); color: #fff; }
  .cf-table td { font-size: var(--fs-sm); }
  .cf-scope { margin: var(--sp-3) 0 0; font-size: var(--fs-xs); }

  /* ---- The finance page -------------------------------------------------
     `finance_page.py`. ABOVE the phones-and-tablets section, whose position
     after the print block is load-bearing. This section adds NO media query of
     its own: the chart is a viewBox that scales, the tiles and the legend wrap,
     and the wide table inherits the app-wide `screen and` overflow rule -- so
     there is nothing here that could fire on paper and clip a column off a
     figure somebody is reconciling. (Written without the at-rule's literal name
     because `test_client_flags` pins this region by substring, and a comment
     that merely mentions a query reads to it as one.)

     TWO COLOURS AND ONE TEXTURE, and they were validated rather than chosen:
     `#2a78d6` for this business's own revenue against `#52606d` for the shared
     unattributed bucket, on the white `.card` surface. Worst-pair separation
     16.5 (normal vision) and 16.4 (simulated protanopia/deuteranopia), both
     marks above 3:1 on white. The first grey tried was `#7b8794`, an existing
     token in this file, and it FAILED the normal-vision floor at 14.5 -- which
     is precisely the check that does not survive being eyeballed.

     COLOUR IS NEVER THE ONLY SIGNAL, the same way `.dv-mark` carries four
     redundant stance cues. Attributed versus unattributed is carried by the
     fill, a 45-degree hatch, a legend, its own table column and its own
     sentence.

     IN GREYSCALE THE HATCH IS THE WHOLE MECHANISM, not a backup -- measured
     rather than assumed. The two fills have Rec.709 luma 110.2 and 94.0, which
     is 1.46:1 against each other once the colour is gone: far apart in HUE and
     deliberately close in LIGHTNESS, because a de-emphasis grey that also went
     pale would stop reading as a real quantity. That is why the print block
     forces the pattern to render, and why a test fails the build if the hatch
     is dropped. The hatch is 45deg and not 135deg on purpose: 135deg already
     means "contradicts" on the document viewer, and one texture meaning two
     things is worse than two textures. */
  .fin-asat { font-size: var(--fs-xs); color: #3e4c59; margin: 0 0 var(--sp-3); }
  .fin-asat b { font-variant-numeric: tabular-nums; }
  .fin-cross { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-2);
               margin: 0 0 var(--sp-4); }
  .fin-cross a.pill { text-decoration: none; }
  .fin-cross .muted { font-size: var(--fs-xs); }

  /* The state bands. NOT four shades of one thing: "not wired", "could not be
     read", "partly read" and "no revenue" are four different facts and the one
     somebody must not misread is the difference between the last two. */
  .fin-band { border-radius: var(--r-md); padding: var(--sp-3) var(--sp-4); margin: 0 0 var(--sp-4);
              font-size: var(--fs-sm); border: 1px solid #e4e7eb;
              border-left: 4px solid #9aa5b1; background: #fff; }
  .fin-band strong { display: block; margin-bottom: var(--sp-1); color: #1f2933; }
  .fin-band p { margin: 0 0 var(--sp-15); color: #3e4c59; }
  .fin-band p:last-child { margin-bottom: 0; }
  .fin-band ul { margin: var(--sp-15) 0 0; padding-left: var(--sp-6); color: #52606d;
                 font-size: var(--fs-xs); }
  .fin-band-off { border-left-color: #9aa5b1; background: #fafbfc; }
  .fin-band-bad { border-left-color: #c0392b; background: #fdf6f5; }
  .fin-band-part { border-left-color: #b7791f; background: #fffdf7; }
  .fin-band-none { border-left-color: #52606d; background: #fafbfc; }

  /* The hero figure and the tiles beside it. Proportional figures on the big
     number -- `tabular-nums` gives every digit the width of a zero, which makes
     a large standalone value look loose. Tabular is for the columns below. */
  .fin-hero { display: flex; flex-wrap: wrap; gap: var(--sp-6); align-items: flex-start;
              margin-bottom: var(--sp-4); }
  .fin-hero-main { display: flex; flex-direction: column; gap: var(--sp-05); min-width: 220px; }
  .fin-hero-label { font-size: var(--fs-eyebrow); font-weight: 700; letter-spacing: 0.04em;
                    text-transform: uppercase; color: var(--text-muted); }
  .fin-hero-value { font-size: var(--fs-hero); line-height: 1.05; font-weight: 700;
                    color: #1f3864; letter-spacing: -0.02em; }
  .fin-hero .muted { font-size: var(--fs-xs); }
  .fin-tiles { display: flex; flex-wrap: wrap; gap: var(--sp-4); margin-left: auto; }
  .fin-tile { display: flex; flex-direction: column; gap: 1px; min-width: 130px; }
  .fin-tile > span:first-child { font-size: var(--fs-eyebrow); font-weight: 700;
                                 letter-spacing: 0.04em; text-transform: uppercase;
                                 color: var(--text-muted); }
  .fin-tile b { font-size: var(--fs-xl); color: #1f2933;
                font-variant-numeric: tabular-nums; }
  .fin-tile .muted { font-size: var(--fs-eyebrow); }
  .fin-multi { margin-bottom: var(--sp-4); }

  /* The legend. Always present, because there are two series -- never make a
     reader match colours from memory. */
  .fin-key { display: flex; flex-wrap: wrap; gap: var(--sp-4); margin: 0 0 var(--sp-3);
             font-size: var(--fs-xs); color: #3e4c59; }
  .fin-key span { display: inline-flex; align-items: center; gap: var(--sp-2); }
  .fin-sw-mine, .fin-sw-un { width: 14px; height: 14px; border-radius: var(--r-sm);
                             display: inline-block; flex: 0 0 auto; }
  .fin-sw-mine { background: #2a78d6; }
  .fin-sw-un { background: repeating-linear-gradient(45deg, #52606d 0 2px,
               rgba(82,96,109,0.16) 2px 6px); }

  /* The chart itself. A viewBox plus width:100% scales without JavaScript; the
     height is left to the aspect ratio so the x-axis band is never cropped into
     its own nested scrollbar. */
  .fin-chart { margin: 0 0 var(--sp-4); }
  .fin-chart svg { width: 100%; height: auto; display: block; overflow: visible; }
  .fin-noshape { font-size: var(--fs-sm); color: #52606d; background: #fafbfc;
                 border: 1px dashed #cbd2d9; border-radius: var(--r-md);
                 padding: var(--sp-4); margin: 0 0 var(--sp-4); }
  .fin-dropped { font-size: var(--fs-xs); color: #85200c; margin: 0 0 var(--sp-3); }

  /* The numbers. Right-aligned and tabular so a column can be scanned against
     a bank statement, which is the job the graph cannot do. */
  .fin-table .fin-num { text-align: right; font-variant-numeric: tabular-nums;
                        white-space: nowrap; }
  .fin-table tfoot th { text-align: right; border-top: 2px solid #cbd2d9;
                        padding: var(--sp-2) var(--sp-3); font-size: var(--fs-xs); color: #1f2933;
                        text-transform: none; letter-spacing: 0;
                        font-variant-numeric: tabular-nums; }
  .fin-table tfoot th:first-child { text-align: left; }
  .fin-table tr.fin-zero td { color: var(--n-400); }
  .fin-table tr.fin-un td:first-child { color: #52606d; font-style: italic; }
  .fin-foot { margin-top: var(--sp-4); max-width: 62em; }

  /* ---- Intent to File watch, at the top of the Workspace -------------------
     `itf_watch.py` renders it. ABOVE the phones-and-tablets section on purpose:
     that block's position after the print block is load-bearing, so nothing goes
     after it. Its own print rules are folded into the single print block above,
     for the same reason every other section's are.

     THE ACCESSIBILITY RULE THIS SECTION EXISTS FOR: a lapsed Intent to File costs
     a real veteran real back pay and cannot be recovered, so which client is
     closest to lapsing must never depend on hue. Four redundant signals, in the
     order they survive a greyscale print, a photocopy and a screen reader:

       1. A VISIBLE WORD in the tag -- EXPIRED / URGENT / APPROACHING. Real text,
          not an aria-label, so a sighted and a non-sighted reader check the same
          fact.
       2. THE DAY COUNT, as text, from the shared `_deadline_pill`.
       3. TEXTURE AND THE LEFT RULE -- a 45-degree hatch behind an expired row and
          a double rule on it, a heavy solid rule for urgent, a light dashed one
          for approaching. A stripe is still a stripe in black and white.
       4. POSITION -- worst first, stated in the provenance line so the order is a
          claim rather than an accident.

     The hatch is 45deg to match `finance_page`'s "unattributed" fill; 135deg is
     already spoken for by the document viewer's `contradicts` mark, and one angle
     meaning two different things across two pages is worse than either. */
  .itfw { margin-bottom: var(--sp-4); }
  .itfw-h { margin: 0 0 var(--sp-1); font-size: var(--fs-md); }
  .itfw-n { font-variant-numeric: tabular-nums; color: #52606d; font-weight: 700; }
  .itfw-prov { font-size: var(--fs-xs); max-width: 66em; margin: var(--sp-2) 0 0; }
  .itfw-good { margin: 0; color: #1f2933; }
  .itfw-more { font-size: var(--fs-xs); color: #85200c; margin: var(--sp-2) 0 0; }
  .itfw-blocked { font-size: var(--fs-xs); color: #85200c; margin: var(--sp-2) 0 0; }
  .itfw-table { margin-top: var(--sp-2); }
  .itfw-table td { font-size: var(--fs-sm); }

  /* Signal 3, the row itself. `border-left` on the first cell rather than the row,
     because a border on a <tr> is not painted with the default border model. */
  .itfw-row > td:first-child { border-left: 4px solid transparent; }
  .itfw-approaching > td:first-child { border-left: 3px dashed #b7791f; }
  .itfw-urgent > td:first-child { border-left: 5px solid #c0392b; }
  .itfw-expired > td:first-child { border-left: 6px double #85200c; }
  /* And the texture, on the whole row, so it reads while scanning the last column
     as well as the first. Low-contrast on purpose: it must not fight the text
     above it, and 4.5:1 on the text is the floor. */
  .itfw-expired > td {
    background-image: repeating-linear-gradient(45deg,
      rgba(133,32,12,0.13) 0 3px, rgba(0,0,0,0) 3px 8px); }
  .itfw-urgent > td { background: #fdf3f2; }

  /* Signal 1, the word. Bordered and uppercase so it reads as a label rather than
     as the first word of the client's name. Border STYLE varies with the tier as
     well as its colour -- the same trick as `.dv-tag`. */
  .itfw-tag { display: inline-block; font-size: var(--fs-eyebrow); font-weight: 700;
              text-transform: uppercase; letter-spacing: 0.07em;
              border: 1px solid #6b5900; border-radius: var(--r-sm); padding: 1px var(--sp-15);
              background: #fff; color: #4a3d00; white-space: nowrap; }
  .itfw-tag-approaching { border-style: dashed; border-color: #8a6d00;
                          color: #6b5900; }
  .itfw-tag-urgent { border-color: #c0392b; color: #85200c; border-width: 2px;
                     padding: 0 var(--sp-1); }
  .itfw-tag-expired { border-style: double; border-width: 3px;
                      border-color: #85200c; color: #85200c; padding: 0 var(--sp-1); }

  /* "No ITF on file" is a DIFFERENT state from a comfortable deadline, so it gets
     its own disclosure rather than a footnote nobody opens. `data-no-busy` is on
     the element in the HTML: it never navigates, and a progress bar for something
     that never finishes is a bar that lies. */
  .itfw-nofile { margin: var(--sp-3) 0 0; font-size: var(--fs-sm); }
  .itfw-nofile > summary { cursor: pointer; color: #3e4c59; }
  .itfw-nofile > summary:focus-visible { outline: 2px solid #1f3864;
                                        outline-offset: 2px; }
  .itfw-list { columns: 3 190px; margin: var(--sp-15) 0 0; padding-left: var(--sp-6);
               font-size: var(--fs-xs); }
  .itfw-list li { break-inside: avoid; }
  .itfw-broken { border-left: 4px solid #c0392b; }
  /* THE THREE OUTCOMES ARE THREE LEFT RULES, and until now only one of them had
     one. `-broken` (could not read) was red, `-has` (there is work) and `-clear`
     (nothing lapsing) had NO rule at all, so the two states a person sees almost
     every day rendered as a plain card indistinguishable from the prose block
     beside it -- and "we could not look" was the only state the eye could find.
     A card whose result you cannot tell at a glance is a card people stop
     scanning.

     Colour is not doing this on its own. Each rule is a different WIDTH as well
     as a different colour -- 4px solid red, 4px solid amber, 2px solid green --
     so the three are separable in greyscale, on a printout, and with any colour
     vision. The heading already states the outcome in words; this is the third
     signal, not the first. */
  .itfw-has { border-left: 4px solid #b7791f; }
  .itfw-clear { border-left: 2px solid #2e7d32; }

  /* ---- Unassigned clients, the second section above the client list -------
     `assignment_watch.py` renders it. A SEPARATE card from the Intent to File one
     on purpose: a lapsing ITF has a date and costs back pay, an unassigned client
     has no clock and is simply unwatched, and one merged list would have to rank
     the two against each other in a unit that does not exist.

     Deliberately QUIETER than the Intent to File card, and that is the whole
     hierarchy: amber-ish rules rather than red ones, because ranking this as
     equally alarming as a deadline that costs a veteran money would make both
     read as noise. The four non-colour signals are still all present -- the word,
     the absence spelled out in the cell, the texture, and the order. */
  .asw { margin-bottom: var(--sp-4); }
  .asw-h { margin: 0 0 var(--sp-1); font-size: var(--fs-md); }
  .asw-n { font-variant-numeric: tabular-nums; color: #52606d; font-weight: 700; }
  .asw-prov { font-size: var(--fs-xs); max-width: 66em; margin: var(--sp-2) 0 0; }
  .asw-good { margin: 0; color: #1f2933; }
  .asw-more, .asw-note { font-size: var(--fs-xs); color: #6b5900; margin: var(--sp-2) 0 0; }
  .asw-table { margin-top: var(--sp-2); }
  .asw-table td { font-size: var(--fs-sm); }

  /* Signal 3. On the first cell rather than the row, because a border on a <tr> is
     not painted under the default border model. */
  .asw-row > td:first-child { border-left: 4px solid transparent; }
  .asw-case_worker > td:first-child { border-left: 4px solid #b7791f; }
  .asw-coach > td:first-child { border-left: 5px solid #8a6d00; }
  .asw-both > td:first-child { border-left: 6px double #6b5900; }
  .asw-both > td {
    background-image: repeating-linear-gradient(45deg,
      rgba(107,89,0,0.13) 0 3px, rgba(0,0,0,0) 3px 8px); }

  /* Signal 1, the word. Border STYLE carries the tier as well as its colour. */
  .asw-tag { display: inline-block; font-size: var(--fs-eyebrow); font-weight: 700;
             text-transform: uppercase; letter-spacing: 0.07em;
             border: 1px solid #8a6d00; border-radius: var(--r-sm); padding: 1px var(--sp-15);
             background: #fff; color: #4a3d00; white-space: nowrap; }
  .asw-tag-case_worker { border-style: dashed; }
  .asw-tag-coach { border-width: 2px; padding: 0 var(--sp-1); }
  .asw-tag-both { border-style: double; border-width: 3px; padding: 0 var(--sp-1); }

  /* Signal 2: the absence, IN WORDS. Never an em-dash -- a dash in a column of
     names reads as "nothing to see here", and here the nothing IS the finding. So
     it is real italic text that survives the tag column being cropped off a
     narrow printout. */
  .asw-blank em { color: #85200c; font-style: italic; }
  .asw-why { font-size: var(--fs-eyebrow); color: #52606d; cursor: help;
             border-bottom: 1px dotted #98a4b0; }
  .asw-broken { border-left: 4px solid #c0392b; }
  /* Same three outcomes, same three rules, one step quieter throughout --
     see the hierarchy note at the top of this section. `-has` and `-clear`
     had no rule here either. */
  .asw-has { border-left: 4px solid #d9a441; }
  .asw-clear { border-left: 2px solid #2e7d32; }

  /* ---- The landing page (`landing_page.py`) -------------------------------
     Deliberately ABOVE the phones-and-tablets section at the end of this file,
     whose position after the print block is load-bearing.

     NO MEDIA QUERY OF ITS OWN, and none of the words that would look like one:
     two tests in this suite scan the text of this region for a query written
     unqualified, and a comment mentioning one reads to them exactly like a rule
     that is one. `auto-fit` with a `minmax` floor collapses the two tiles to a
     single column on its own at any width, which is the whole behaviour a
     breakpoint would have bought -- and a query fewer is a query that cannot
     silently apply to a printout, which on a letter page is about 624px wide.

     NOT `.tile`. The feature/hub tiles are a square 210px grid holding a title
     and a blurb; these are the two things the app is FOR, they were asked for as
     "two big tiles ... center screen", and they lead with a logo. Reusing the
     class would have meant overriding almost every declaration in it, which is
     how one component quietly becomes two that share a name.

     NO TENANT ACCENT ANYWHERE IN HERE, and that is a rule rather than an
     omission: both brands are navy (OVE #012c56, SHW #1f2a44), so a colour-coded
     tile would be a distinction people cannot see. The mark and the written name
     carry it, exactly as the topbar's own comment says. */
  .lp { max-width: 900px; margin: 4vh auto 0; text-align: center; }
  .lp-title { font-size: var(--fs-display); margin: 0 0 var(--sp-15); letter-spacing: -0.01em; }
  .lp-sub { color: #52606d; font-size: var(--fs-base); margin: 0 0 var(--sp-6); }
  /* A problem is never rendered in the muted grey the footnote uses -- one of
     these says "here is a caveat" and the other says "your access is not what
     you expect", and they must not look the same. */
  .lp-problem { color: #85200c; background: #fdf3f2; border: 1px solid #f0cfc9;
                border-radius: var(--r-md); padding: var(--sp-3) var(--sp-4); font-size: var(--fs-sm);
                margin: 0 auto var(--sp-6); max-width: 640px; text-align: left; }
  /* `400px` as the ceiling and `justify-content: center` rather than `1fr`, because
     nine of the ten staff belong to ONE business and will see ONE tile: with `1fr`
     that tile stretches to the full 900px and reads as a banner rather than as one
     of a pair. Capped and centred, one tile and two tiles are the same object.
     `min(100%, 280px)` as the floor so a 320px phone gets a tile that fits its
     screen instead of one that overflows it -- which is what a bare `280px` floor
     does, and it needs no breakpoint to say so. */
  .lp-tiles { display: grid; gap: var(--sp-6); text-align: left; justify-content: center;
              grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 400px)); }
  /* The form is only a carrier for the POST. It must not become a second box
     around the tile, or the two sides of the page would be visibly different
     shapes for a reason that is pure plumbing. */
  .lp-switch { margin: 0; display: flex; }
  .lp-tile { display: flex; flex-direction: column; align-items: flex-start;
             gap: var(--sp-3); width: 100%; background: #fff; border-radius: var(--r-lg);
             padding: var(--sp-6); box-shadow: var(--sh-1);
             border: 2px solid transparent; text-decoration: none; color: inherit;
             font: inherit; text-align: left; cursor: pointer; }
  .lp-tile:hover, .lp-tile:focus-visible { border-color: #1f3864;
                                           box-shadow: var(--sh-2); }
  .lp-dead { cursor: default; }
  .lp-dead:hover { border-color: transparent; }
  .lp-mark { width: 84px; height: 84px; object-fit: contain; }
  .lp-name { font-size: var(--fs-lg); font-weight: 700; color: #1f3864; line-height: 1.2; }
  /* "you are here" is the switcher's own words, reused rather than reinvented:
     the two controls answer the same question and should say it the same way. */
  .lp-here { display: inline-block; margin-left: var(--sp-2); font-size: var(--fs-eyebrow);
             font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em;
             color: #52606d; background: #eef1f4; border-radius: var(--r-lg);
             padding: var(--sp-05) var(--sp-2); vertical-align: middle; }
  .lp-blurb { font-size: var(--fs-sm); color: #3c4858; line-height: 1.45; }
  .lp-facts { display: flex; flex-direction: column; gap: var(--sp-15); width: 100%;
              border-top: 1px solid #eef1f4; padding-top: var(--sp-3); }
  .lp-fact { display: flex; gap: var(--sp-3); font-size: var(--fs-xs); line-height: 1.4; }
  .lp-fact-k { flex: 0 0 84px; color: var(--text-muted); font-weight: 700;
               text-transform: uppercase; font-size: var(--fs-eyebrow); letter-spacing: 0.04em;
               padding-top: var(--sp-05); }
  .lp-fact-v { color: #3c4858; }
  /* Styled as the destination it is, not as a button: the whole tile is the
     control, and a button inside a button is a target people miss. */
  .lp-go { margin-top: auto; font-size: var(--fs-sm); font-weight: 700; color: #1f3864; }
  .lp-tile:hover .lp-go, .lp-tile:focus-visible .lp-go { text-decoration: underline; }
  .lp-foot { color: var(--text-muted); font-size: var(--fs-xs); margin: var(--sp-6) auto 0; max-width: 640px; }

  /* ---- 404 / 403 / 405 / 500 (`landing_page.error_body`) -------------------
     The app had no error pages, so a mistyped address and every `abort(403)`
     rendered Werkzeug's own: white, Times New Roman, no way back. Reachable
     from inside the product -- `/history` refuses a non-admin that way.

     Written narrow and quiet on purpose. Every instinct on an error page is to
     make it loud; a red banner tells somebody who mistyped a URL that they have
     broken something. The code is stated as text ("Error 404"), never as a
     colour, because it is the one part worth quoting to a colleague. */
  .err { max-width: var(--measure); margin: var(--sp-12) auto; }
  .err-code { font-size: var(--fs-eyebrow); font-weight: 700; text-transform: uppercase;
              letter-spacing: var(--ls-caps); color: var(--text-muted); margin: 0; }
  .err h1 { margin: var(--sp-1) 0 var(--sp-3); }
  .err p { max-width: var(--measure); }
  .err-ways { display: flex; gap: var(--sp-4); align-items: center;
              margin-top: var(--sp-6); flex-wrap: wrap; }

  /* ---- The settings page (`settings_routes`) -------------------------------
     WRITTEN ENTIRELY FROM TOKENS. Not a house style point: this is the first
     surface built after the conversion pass, so if a value it needed were
     missing from `:root` that is the signal the scale is wrong -- and none
     was. Nothing below types a pixel that is not a border or a rule.

     THE SOURCE CHIP CARRIES A WORD, AND THE BORDER IS THE SECOND SIGNAL.
     `SOURCE_WORDS` renders "set here", "environment", "built-in default" or
     "not set" inside these, so the four states are distinguishable with no
     colour perception at all -- and each also differs in border STYLE, which
     is the same rule `ledger_page`'s urgency chips already hold. On this page
     the difference between two of these states is the difference between an
     edit taking effect and silently not, which is the last thing to encode in
     hue alone. */
  .set-band { font-size: var(--fs-sm); color: var(--text-2);
              background: var(--bg-sunken); border: 1px solid var(--border-subtle);
              border-radius: var(--r-md); padding: var(--sp-2) var(--sp-3);
              margin: 0 0 var(--sp-4); }
  .set-reread { display: flex; align-items: center; gap: var(--sp-3);
                flex-wrap: wrap; margin: 0 0 var(--sp-4); }
  .set-group { margin-bottom: var(--sp-4); }
  .set-group h2 { margin-top: 0; }
  /* One rule between settings and nothing else. A card per setting would put a
     shadow and a border around a single number, which is how a settings page
     starts looking like a dashboard. */
  .set-item { border-top: 1px solid var(--border-subtle);
              padding: var(--sp-4) 0 var(--sp-3); }
  .set-item:first-of-type { border-top: none; padding-top: 0; }
  .set-head { display: flex; align-items: baseline; gap: var(--sp-2);
              flex-wrap: wrap; }
  .set-head h3 { margin: 0; font-size: var(--fs-md); color: var(--text); }
  .set-src { font-size: var(--fs-eyebrow); font-weight: 700;
             text-transform: uppercase; letter-spacing: var(--ls-caps);
             padding: var(--sp-05) var(--sp-2); border-radius: var(--r-full);
             white-space: nowrap; }
  .set-src-override { color: var(--brand); background: var(--brand-weak);
                      border: 2px solid var(--brand); }
  .set-src-env { color: var(--info-text); background: var(--info-ground);
                 border: 1px solid var(--info-border); }
  .set-src-default { color: var(--text-2); background: var(--bg-sunken);
                     border: 1px dashed var(--border-strong); }
  .set-src-unset { color: var(--text-muted); background: transparent;
                   border: 1px dotted var(--border-strong); }
  .set-help { color: var(--text-2); font-size: var(--fs-sm);
              line-height: var(--lh-body); max-width: var(--measure);
              margin: var(--sp-1) 0 var(--sp-2); }
  .set-now { font-size: var(--fs-sm); color: var(--text-2); margin: 0 0 var(--sp-2);
             max-width: var(--measure); }
  .set-else { font-size: var(--fs-xs); color: var(--text-muted);
              margin: 0 0 var(--sp-2); }
  /* The shadowing warning. AMBER AND A HEADING, because it is the one thing on
     this page that explains an edit somewhere else appearing to do nothing --
     the failure the whole three-source display exists to prevent. */
  .set-shadow { font-size: var(--fs-sm); color: var(--warn-text);
                background: var(--warn-ground);
                border-left: 3px solid var(--warn-border);
                border-radius: 0 var(--r-sm) var(--r-sm) 0;
                padding: var(--sp-2) var(--sp-3); margin: 0 0 var(--sp-2);
                max-width: var(--measure); }
  .set-form, .set-revert { display: flex; align-items: flex-end; gap: var(--sp-3);
                           flex-wrap: wrap; margin: 0 0 var(--sp-2); }
  .set-form label, .set-revert label { display: block; font-size: var(--fs-xs);
    font-weight: 700; color: var(--text-2); }
  .set-value { flex: 0 1 260px; }
  .set-why { flex: 1 1 280px; }
  .set-why input { width: 100%; }
  .set-list { width: 100%; font-family: inherit; font-size: var(--fs-sm);
              line-height: var(--lh-snug); }
  .set-revert { border-top: 1px dashed var(--border-subtle);
                padding-top: var(--sp-2); }
  .set-name { font-size: var(--fs-eyebrow); color: var(--text-muted); margin: 0; }
  .set-fold > summary { cursor: pointer; font-weight: 700; color: var(--brand);
                        font-size: var(--fs-sm); }
  .set-fold > summary + * { margin-top: var(--sp-3); }
  .set-locked-names { font-size: var(--fs-xs); line-height: var(--lh-snug);
                      color: var(--text-2); }

  /* ---- The design inventory (`design_inventory`) ---------------------------
     THREE RULES, and the page is deliberately built from everything else in
     this file. That is the point of it: a component gallery written in its own
     private styles would look correct on a day the product did not, which is
     the one failure a gallery must not have.

     `.di-swatch` carries a BORDER as well as a fill, because several of the
     tokens it shows are near-white and a borderless white square on a white
     card is not a swatch, it is a gap. `.di-bar` draws a length token at its
     own size -- its `width` comes from the token being described, so a spacing
     step that changed would move the bar without anybody editing this page.

     `.di-example` is a plain well and not a card: the examples inside it are
     often cards themselves, and a card in a card reads as nesting that means
     something. */
  .di-swatch { display: inline-block; width: 20px; height: 20px;
               border: 1px solid var(--border); border-radius: var(--r-sm);
               vertical-align: middle; margin-right: var(--sp-15); }
  .di-bar { display: inline-block; height: 10px; min-width: 1px;
            background: var(--brand); border-radius: var(--r-sm);
            vertical-align: middle; margin-right: var(--sp-15); }
  .di-example { background: var(--bg-sunken); border: 1px solid var(--border-subtle);
                border-radius: var(--r-md); padding: var(--sp-4);
                margin-top: var(--sp-3); }
  /* A token name broken across two lines (`--fs-` / `eyebrow`) reads as two names.
     The column is the narrowest on the page and the value it holds is the one thing
     a reader is scanning for. */
  .di-token { white-space: nowrap; }

  /* ==================================================================== *
   * PHONES AND TABLETS -- Phase 0, added 2026-08-06                      *
   * ==================================================================== *
   *
   * WHY THIS IS A NEW SECTION AT THE END RATHER THAN EDITS ABOVE. Until this
   * date `road_map_app._page` emitted no `<meta name="viewport">`, so a phone
   * reported a ~980px layout viewport and scaled the page down: every
   * `@media (max-width: ...)` rule in this file had never once fired on a real
   * device. The 1200px / 1100px / 900px / 760px blocks above are therefore not
   * "proven rules being overridden" -- they are rules that switched on for the
   * first time in the same commit as this section. They are left exactly as they
   * were and EXTENDED here; appending means these declarations win on equal
   * specificity without any of them having to be rewritten.
   *
   * EVERY BLOCK BELOW IS `screen and`, and that is load-bearing rather than
   * tidy. A letter page prints at roughly 624 CSS px, so an unqualified
   * `max-width: 900px` query APPLIES WHEN PRINTING -- and the table rule below
   * turns a table into a horizontal scroll container, which on paper means the
   * columns past the fold are simply CLIPPED AND GONE. These pages go into case
   * files, so a print that quietly drops four columns is a records problem, not
   * a cosmetic one. The existing unqualified blocks above are safe because
   * stacking a grid and hiding a floating note both help a printout; adding
   * overflow does not.
   *
   * WHAT IS DELIBERATELY NOT HERE: no table is stacked into label/value pairs.
   * Doing that legibly needs a `data-label` on every `<td>` -- an HTML change
   * across six modules -- and a stacked row of bare unlabelled values is less
   * readable than a table you can swipe. Reported as a recommendation instead. */

  /* ---- Tablet and below: the chrome, the nav, and wide tables ------------- */
  @media screen and (max-width: 900px) {

    /* THE NAV WAS THE BLOCKER FOR THE WHOLE PHASE. `.topbar` is a flex row that
       does not wrap, holding a mark, the platform name, the tenant chip, NINE
       nav links (several carrying a "new"/"legacy"/"soon" tag) and the signed-in
       name. Flex items cannot shrink below min-content, so its minimum is around
       1,450px: at 360px the TOP BAR ALONE forced the document to ~1,450px wide
       and every page below it scrolled sideways as a side effect. Turning the
       viewport on without this would have made every page worse than the
       scaled-down 980px render it replaced.

       Wrapping on a TABLET, and only there. Wrapping is the better answer for
       discoverability -- Flags and File Review are destinations, not tabs, and a
       nav that hides half its links off-screen is a nav people stop using -- but
       it is only affordable while it costs one extra row. At 768px the nine links
       measure about 990px, so they wrap to two rows and the whole bar is ~160px of
       a 1024px screen. The phone case is handled in the 700px block below, because
       there the same links wrap to THREE rows and the bar becomes ~206px, a
       quarter of the screen, on every page. */
    .topbar { flex-wrap: wrap; padding: var(--sp-2) var(--sp-4); gap: var(--sp-2) var(--sp-3); }
    .topbar nav { flex-wrap: wrap; flex-basis: 100%; gap: var(--sp-05); }
    .topbar .brand { margin-right: 0; font-size: 17px; }
    .topbar .who { flex: 1 1 auto; text-align: right; }
    /* The switcher menu is absolutely positioned and 288px wide; from a chip
       near the right edge that hangs off the screen. */
    .tenant-menu { max-width: calc(100vw - 24px); }

    /* Padding was 44px of a 360px screen before any content. */
    main { padding: var(--sp-4); }
    .card { padding: var(--sp-4); }
    .userbar { padding: var(--sp-15) var(--sp-4); gap: var(--sp-3) var(--sp-4); }
    /* One step down the same scale, not a size typed by eye: M3 Headline
       Small, which is what `h1` was everywhere before this pass. 32px of
       navy across 360px is two or three lines of title before any content,
       and the top margin comes back down with it. */
    h1 { font-size: var(--fs-xl); margin-top: var(--sp-3); }

    /* ---- WIDE TABLES: the table scrolls, not the page ------------------
       /admin/activity is 11 columns, /admin/spend has an 8, /flags and the
       workspace client list are 9 each. At 360px these cannot be made to fit,
       and the current failure mode is the worst one available: the table
       overflows `main`, so the WHOLE PAGE pans sideways and the nav goes with
       it.

       `display: block` makes the table its own scroll container. The browser
       wraps its thead/tbody/tfoot/colgroup in one anonymous table box, so
       column alignment, `border-collapse` (an inherited property, so it still
       reaches that box) and contradiction_queue's percentage `<colgroup>` all
       keep working. Chosen over putting the overflow on `.card` because
       `overflow-x: auto` forces `overflow-y` to `auto` too, which would create
       a second scroll container on every card and break `position: sticky` for
       `.panel`. (The message composer was the other sticky element this rule
       protected; it stopped being sticky on 2026-08-06, but `.panel` still is,
       and one reason is enough. Named in words rather than as a selector so a
       test that pins the composer's rules by substring does not read this
       sentence as one of them.)

       `th { white-space: nowrap }` is what makes the scroll USEFUL rather than
       decorative. Without it the columns compress to one word per line and the
       table still "fits", unreadably; with it each column is at least as wide as
       its own header, so 11 columns genuinely exceed the screen and the swipe
       has something to do. It also means the floor is derived from the content
       rather than from a magic pixel number.

       Honest cost: a table whose content is all short (a two-column key/value
       pair) now shrink-wraps instead of stretching to full width, because the
       anonymous table box sizes to fit. Cosmetic, and only on a phone. */
    table.grid { display: block; overflow-x: auto; overscroll-behavior-x: contain;
                 -webkit-overflow-scrolling: touch; scrollbar-width: thin; }
    table.grid th { white-space: nowrap; }
    table.grid th, table.grid td { padding: var(--sp-2); }

    /* ---- FORM CONTROLS: 16px, and it is not a preference ---------------
       iOS zooms the entire page in when a text field with a font smaller than
       16px receives focus, and it does not zoom back out. On a page like the
       message panel that means every tap into the box leaves the layout
       magnified and the user pinching to recover. The base rule is `font:
       inherit` off a 14.5px body, and several call sites go smaller still, so
       each of those specificities has to be met rather than relying on one bare
       `input` selector. */
    select, input, textarea,
    form.filters select, form.filters input,
    .wl-controls input, .wl-controls select,
    .cmp-subject input[type=text], #composer textarea,
    .panel textarea, select.cond-edit { font-size: 16px; }
    /* 16px plus this padding is a 44px control. Checkboxes and radios are
       excluded -- a 44px-tall checkbox is a rendering bug, not a tap target. */
    select, textarea, input:not([type=checkbox]):not([type=radio]) {
      padding: var(--sp-3); }
    select, input:not([type=checkbox]):not([type=radio]) { min-height: 44px; }
    input[type=checkbox], input[type=radio] { width: 19px; height: 19px; }
    select.cond-edit { max-width: 100%; }

    /* ---- TAP TARGETS ---------------------------------------------------
       Everything here was between 24px and 36px tall. Sizes come from padding
       plus `min-height` rather than from changing `display`, so no element's
       participation in its parent's layout changes.

       NOT touched: `.dv-cite`, `.conv-more` and the other citation-sized links
       that sit INSIDE running prose. Inflating those would break the line box
       they live in, and WCAG's own inline exception is exactly this case. */
    .topbar nav a { padding: var(--sp-3); }
    .btn { padding: var(--sp-3) var(--sp-4); min-height: 44px; }
    .ws-tabs a { padding: var(--sp-3) var(--sp-4); }
    /* `--fs-sm` AND NOT THE `13.5px` IT WAS, 2026-08-11. A half-pixel literal is a
       one-off, and a one-off is how a type scale stops being one: nothing else in
       this file is 13.5, no token is, and it could only have been arrived at by
       nudging. 13 is the token, it is the size the channel labels already take at
       every other width, and it is half a pixel SMALLER on the one surface the
       founder said was too big. `min-height` stays in `px` on purpose -- a tap
       target is a physical thumb, not a multiple of the text. */
    .cmp-tab { padding: var(--sp-3); min-height: 42px; font-size: var(--fs-sm); }
    .section-list button { padding: var(--sp-3); }
    .tenant-chip > summary { padding: var(--sp-3); }
    .tenant-option button, .tenant-option.current { padding: var(--sp-3); }
    /* A pill that is a LINK is a control (the flag-queue filter chips); a pill
       that is a span is a status label inside a table cell and must stay small. */
    a.pill { padding: var(--sp-2) var(--sp-3); }

    /* The file-review split had NO breakpoint of its own: a fixed 430px rail
       beside the document guaranteed overflow at any phone width. Stacked, the
       sticky panel with its own `max-height` scroller becomes a third scroll
       container on the page, so it goes static as well. */
    .review-layout { grid-template-columns: minmax(0,1fr); }
    .panel { position: static; max-height: none; overflow-y: visible; }
    .doc-frame { height: 60vh; }
  }

  /* ---- Phones ------------------------------------------------------------ */
  @media screen and (max-width: 700px) {

    /* THE NAV ON A PHONE: one row that scrolls, not three that wrap.
       Measured at 360px: the nine links come to about 990px against 332px of
       usable width, so wrapping gives three rows and a 206px top bar -- a quarter
       of the screen, on every page, before any content. Scrolling holds it to one
       44px row.

       This is a REAL COST and it is the honest place to record it: two or three
       links sit off-screen and there is no affordance in the markup to say so.
       `.ws-tabs` sets the precedent for a scrolling row in this app and HIDES its
       scrollbar; this one deliberately keeps a thin one, because that bar is the
       only overflow hint available without adding an element. The proper fix is a
       collapsed menu, which needs HTML and is reported rather than forced in. */
    .topbar nav { flex-wrap: nowrap; overflow-x: auto; overscroll-behavior-x: contain;
                  -webkit-overflow-scrolling: touch; scrollbar-width: thin;
                  padding-bottom: var(--sp-05); }
    /* AND THE PINNED BAR STANDS DOWN HERE. The instruction that pinned it
       ("the main nav ... should always be pinned on every view") was given at a
       desktop browser about a 64px bar. On a phone this bar is the mark, the
       product name, the tenant chip, the signed-in name AND a scrolling nav row --
       two or three wrapped lines, ~110px of a 640px screen, parked there
       permanently on every page. That is a different thing from what was asked
       for, and the cost lands on the surface with the least room to pay it.

       `overflow-x: auto` on the nav row is the second, harder reason: a scroll
       container inside a sticky ancestor is where iOS Safari's sticky
       implementation is least reliable, and a bar that half-detaches while you
       swipe its links is worse than one that scrolls away cleanly. */
    .topbar { position: static; }
    .topbar nav a { white-space: nowrap; flex: none; }
    /* The busy ring on a nav link hangs 15px OUTSIDE its own box so it does not
       reflow running text. In a scrolling row that lands on top of the next link,
       so here it goes inside. */
    .topbar nav a.cp-busy::after { right: 3px; }

    /* THE PRIVACY DIVIDER STAYS VERTICAL ON A PHONE. Corrected 2026-08-08 (U7).
       This block used to say `flex: 0 0 100%; height: 1px`, which turned the rule
       into a full-width HORIZONTAL bar below 700px -- so the cue silently changed
       from left/right to above/below. Nobody decided that; it was a workaround for
       a flex reflow, and the note it carried argued the boundary was still drawn.
       It was, but not the boundary anybody had been taught: `composer.py`, `CLAUDE.md`
       and `PLAN_UNBUTTONED.md` all state the mechanism as LEFT of the rule reaches
       the veteran, right of it never does. A control that means one thing on a laptop
       and a rotated thing on a phone is a control nobody can rely on under pressure,
       and this one exists for the moment somebody is about to say something about a
       veteran that the veteran must not read.

       Nothing is needed here now, because the cause is gone: `composer.render`
       groups the two sides into `.cmp-side` boxes, so the reaching side wraps inside
       its own box and the rule stands to the right of every row of it. What this
       block does instead is buy that arrangement the width it needs at 360px --
       shorter labels are not an option (they are the channel names) so the tabs give
       up horizontal padding, and the rule gives up its side margins. The two sides
       stay side by side down to well under 320px. */
    .cmp-tab { padding: var(--sp-3) var(--sp-15); }
    .cmp-divide { margin: var(--sp-05) var(--sp-1); }

    /* `vh` on iOS measures the LARGE viewport, so `100vh - 400px` overshoots by
       the height of the URL bar and the clamp lets the thread grow past the
       screen. `dvh` measures what is actually visible. Declared as an extra rule
       so a browser that does not know `dvh` simply keeps the `vh` clamp above.

       The clamp itself stays, and since 2026-08-06 it is the ONLY thing keeping
       the reply box within reach: the composer no longer sticks to anything, so
       an unclamped thread would put it below two years of email exactly as the
       comment on `.conv` describes. One scroller inside the thread and the page
       scrolling underneath it is now the whole arrangement, which is what was
       asked for -- the clamp is what makes it work. */
    .conv { max-height: clamp(15rem, calc(100dvh - 20.625rem), 35rem); }
    .dv-doc-frame { height: 55dvh; }
    .doc-frame { height: 60dvh; }

    /* THE SQUARE IS GONE, so the override that undid it is too -- it said
       `aspect-ratio: auto; min-height: 116px`, and a min-height on a row is
       just padding nobody asked for.
       What a phone still needs is the row STACKED: a 24ch name column plus a
       26ch blurb cannot both fit 360px, and forcing them produces one word
       per line. `flex-basis: 100%` on the two text items is the whole fix --
       the row wraps to name / status / blurb / location, in that reading
       order, with no new markup and no second set of classes.
       `--tile-name` goes with them, which is what takes the indent off
       `.nr-note`: an alignment to a column that is now the full width would
       push the note off the screen. */
    .tile { --tile-name: 100%; }
    .tile h3, .tile p { flex-basis: 100%; }
    .tile .nr-note { padding-left: 0; }

    /* A 190px label column plus a 202px axis indent leaves ~110px of timeline
       on a 360px screen. Stacked, the track gets the full width. */
    .depth-row { grid-template-columns: minmax(0,1fr); gap: var(--sp-1); }
    .depth-scale, .depth-scale-pos { margin-left: 0; }

    /* Wingman's price pills and the free-path sentence each take their own line
       here, which is the right order: the sentence saying it costs nothing to
       write it yourself should not end up beneath two dollar figures. */
    .cmp-wingman-bar { gap: var(--sp-15); }
    .cmp-wingman-bar .btn.ghost { flex: 1 1 100%; text-align: center; }

    /* THE TWO CELLS THAT SET THE WIDTH OF THE TWO BUSIEST TABLES. Seven condition
       chips on one unbreakable line, and the conditions table's traffic lights with
       their labels, are each wider than a phone on their own -- so they decided how
       far every other column had to be swiped past. Allowed to wrap into two or
       three lines instead, which costs a little row height and buys back most of the
       horizontal scroll. The chips and the lights are unchanged; only the line
       breaking is. */
    .wl-counts, .lights-cell { white-space: normal; }
    .wl-count { display: inline-block; margin-bottom: var(--sp-05); }

    /* 160px of dead space above a sign-in box on a 780px screen. */
    .login-box { margin: var(--sp-8) auto; }
  }

  .lg-lede { color: var(--text-2); font-size: var(--fs-base); line-height: var(--lh-prose);
             max-width: var(--measure); margin: var(--sp-2) 0 var(--sp-3); }
  .lg-asat { color: var(--text-muted); font-size: var(--fs-sm); margin: 0 0 var(--sp-4); }

  /* The toggle. Google Workspace's tab bar: type and a 2px rule, no boxes, no
     pills, no chrome. Only held tiers are ever rendered here. */
  .lg-tiers { display: flex; gap: var(--sp-8); border-bottom: 1px solid var(--border-subtle);
              margin: 0 0 var(--sp-6); }
  .lg-tier { display: block; padding: var(--sp-3) 0 var(--sp-3); text-decoration: none;
             color: var(--text-muted); font-size: var(--fs-base); font-weight: 600;
             border-bottom: 2px solid transparent; margin-bottom: -1px; }
  .lg-tier:hover { color: var(--text); text-decoration: none; }
  .lg-tier.on { color: var(--brand); border-bottom-color: var(--brand); }
  .lg-tier-sub { display: block; font-size: var(--fs-eyebrow); font-weight: 500;
                 text-transform: uppercase; letter-spacing: var(--ls-caps);
                 color: var(--text-muted); margin-top: var(--sp-05); }

  /* Sections are separated by SPACE and a heading, not by borders. */
  .lg-card { margin: 0 0 var(--sp-12); }
  .lg-eyebrow { display: flex; align-items: baseline; gap: var(--sp-3);
                font-size: var(--fs-eyebrow); text-transform: uppercase;
                letter-spacing: var(--ls-caps); color: var(--text-muted);
                font-weight: 700; margin: 0 0 var(--sp-3);
                padding-bottom: var(--sp-2); border-bottom: 1px solid var(--border-subtle); }
  .lg-stamp { font-weight: 500; letter-spacing: 0; text-transform: none;
              font-size: var(--fs-xs); color: var(--text-muted); }
  .lg-currency { font-size: var(--fs-lg); color: var(--text); margin: var(--sp-8) 0 var(--sp-3); }

  /* Tables: hairlines across, nothing down. Generous rows -- 12px against 13px
     type is roughly a 44px row, which is Drive's list and is the density the
     founder asked for. */
  .lg-tbl { width: 100%; border-collapse: collapse; font-size: var(--fs-sm);
            color: var(--text); }
  .lg-tbl th { text-align: left; font-size: var(--fs-eyebrow); font-weight: 600;
               text-transform: uppercase; letter-spacing: var(--ls-caps);
               color: var(--text-muted); padding: var(--sp-2) var(--sp-3);
               border-bottom: 1px solid var(--border); white-space: nowrap; }
  .lg-tbl td { padding: var(--sp-3); border-bottom: 1px solid var(--border-subtle);
               line-height: var(--lh-snug); vertical-align: top; }
  .lg-tbl tbody tr:last-child td { border-bottom: 0; }
  .lg-tbl th.lg-num, .lg-tbl td.lg-num { text-align: right;
                                         font-variant-numeric: tabular-nums;
                                         white-space: nowrap; }
  .lg-fig { font-variant-numeric: tabular-nums; }

  /* The floor mark. The ONE place a status colour appears in a figure, and it
     carries its word as well as its colour. */
  .lg-floor { font-size: var(--fs-eyebrow); text-transform: uppercase;
              letter-spacing: var(--ls-caps); font-weight: 700;
              color: var(--warn-text); white-space: nowrap; cursor: help; }

  .lg-two { display: flex; flex-wrap: wrap; gap: var(--sp-12); margin-bottom: var(--sp-6); }
  .lg-stat-label { font-size: var(--fs-eyebrow); text-transform: uppercase;
                   letter-spacing: var(--ls-caps); color: var(--text-muted);
                   font-weight: 600; }
  .lg-stat { font-size: var(--fs-display); line-height: var(--lh-tight);
             letter-spacing: var(--ls-tight); color: var(--text);
             font-variant-numeric: tabular-nums; margin-top: var(--sp-1); }
  .lg-stat-quiet { color: var(--text-2); }
  .lg-sub { font-size: var(--fs-xs); color: var(--text-muted); line-height: var(--lh-snug);
            margin-top: var(--sp-05); }
  .lg-attention { color: var(--warn-text); }
  .lg-note { font-size: var(--fs-sm); color: var(--text-muted);
             line-height: var(--lh-prose); max-width: var(--measure);
             margin: var(--sp-3) 0 0; }
  .lg-blurb { margin-bottom: var(--sp-6); }
  .lg-warn { font-size: var(--fs-sm); color: var(--text-2); line-height: var(--lh-prose);
             max-width: var(--measure); margin: var(--sp-3) 0 0;
             border-left: 2px solid var(--warn-border); padding-left: var(--sp-3); }
  .lg-none { font-size: var(--fs-sm); color: var(--text-muted);
             line-height: var(--lh-prose); max-width: var(--measure); }
  .lg-unknowable { border-left: 2px solid var(--border-strong); padding-left: var(--sp-3);
                   margin-bottom: var(--sp-6); }
  .lg-unknowable p { font-size: var(--fs-sm); color: var(--text-2);
                     line-height: var(--lh-prose); max-width: var(--measure);
                     margin: var(--sp-1) 0 0; }

  /* One series, one hue: the accent. Bar length already carries the magnitude. */
  .lg-chart { margin: var(--sp-4) 0 var(--sp-6); }
  .lg-chart svg { width: 100%; height: auto; }
  .lg-bar { fill: var(--brand); }
  .lg-bar-label { font-size: var(--fs-xs); fill: var(--text-2); }
  .lg-bar-value { font-size: var(--fs-xs); fill: var(--text-muted);
                  font-variant-numeric: tabular-nums; }

  /* The bands. Colour is never the only signal -- each one leads with a sentence. */
  .lg-band { border-radius: var(--r-md); padding: var(--sp-3) var(--sp-4);
             margin: 0 0 var(--sp-6); font-size: var(--fs-sm);
             line-height: var(--lh-body); }
  .lg-band p, .lg-band ul { margin: var(--sp-1) 0 0; }
  .lg-band-off { background: var(--bg-sunken); color: var(--text-2);
                 border: 1px solid var(--border-subtle); }
  .lg-band-none { background: var(--bg-sunken); color: var(--text-2);
                  border: 1px solid var(--border-subtle); }
  .lg-band-bad { background: var(--danger-ground); color: var(--danger-text);
                 border: 1px solid var(--danger-border); }
  .lg-band-part { background: var(--warn-ground); color: var(--warn-text);
                  border: 1px solid var(--warn-border); }

  /* ---- Decisions and actions. -----------------------------------------------
     These rules used to live in an inlined `ledger_page.STYLE` block, where a
     comment was a word ON the page -- naming the tier these panels belong to
     right here is what broke five tests the first time. They are in the served
     asset now, so a comment here reaches a stylesheet and not a reader; the
     habit of saying "these panels" is kept anyway, because `agenda()` names the
     tier and one place naming it is enough.

     Items are separated by a hairline and space, exactly like the tables above:
     no cards inside a card, no second accent, no new palette. The urgency chips
     reuse the danger and warn tokens the state bands already spend, because
     "overdue" and "the read failed" are the same order of thing to a reader and
     inventing a third treatment for it would be inventing a colour scheme. */
  .lg-item { border-top: 1px solid var(--border-subtle); padding-top: var(--sp-6);
             margin-top: var(--sp-6); }
  .lg-item:first-of-type { border-top: 0; padding-top: 0; margin-top: var(--sp-4); }
  .lg-item-title { font-size: var(--fs-md); font-weight: 600; color: var(--text);
                   line-height: var(--lh-snug); max-width: var(--measure);
                   margin: 0 0 var(--sp-2); }
  .lg-marks { display: flex; flex-wrap: wrap; gap: var(--sp-2);
              margin: 0 0 var(--sp-4); }
  .lg-mark { font-size: var(--fs-eyebrow); font-weight: 700;
             text-transform: uppercase; letter-spacing: var(--ls-caps);
             padding: var(--sp-05) var(--sp-2); border-radius: var(--r-sm);
             border: 1px solid var(--border-subtle); background: var(--bg-sunken);
             color: var(--text-2); font-variant-numeric: tabular-nums;
             white-space: nowrap; }
  /* Colour ONLY where it carries meaning, and the chip prints the same fact in
     words -- these get printed, and the reader is being asked to act. */
  .lg-mark-passed { background: var(--danger-ground); color: var(--danger-text);
                    border-color: var(--danger-border); }
  .lg-mark-soon { background: var(--warn-ground); color: var(--warn-text);
                  border-color: var(--warn-border); }
  .lg-mark-ahead { color: var(--text-2); }
  .lg-mark-undated { color: var(--text-muted); }
  .lg-mark-unknown { color: var(--text-muted); white-space: normal; }
  .lg-mark-quiet { background: transparent; border-color: transparent;
                   color: var(--text-muted); font-weight: 600; padding-left: 0; }

  .lg-item-line { font-size: var(--fs-sm); color: var(--text-2);
                  line-height: var(--lh-prose); max-width: var(--measure);
                  margin: 0 0 var(--sp-4); }
  .lg-item-lead { display: block; font-size: var(--fs-eyebrow); font-weight: 700;
                  text-transform: uppercase; letter-spacing: var(--ls-caps);
                  color: var(--text-muted); margin-bottom: var(--sp-05); }
  /* The recommendation is the only thing on the page besides the toggle that
     spends the accent, because it is the only thing here that is a position. */
  .lg-rec { font-size: var(--fs-sm); color: var(--text); line-height: var(--lh-prose);
            max-width: var(--measure); margin: var(--sp-4) 0;
            border-left: 2px solid var(--brand); padding-left: var(--sp-3); }
  .lg-first { font-size: var(--fs-sm); color: var(--text-2);
              line-height: var(--lh-prose); max-width: var(--measure);
              margin: 0 0 var(--sp-4); border-left: 2px solid var(--warn-border);
              padding-left: var(--sp-3); }
  .lg-src { font-size: var(--fs-xs); color: var(--text-muted);
            line-height: var(--lh-snug); max-width: var(--measure);
            margin: var(--sp-3) 0 0; }

  .lg-foot { font-size: var(--fs-xs); color: var(--text-muted);
             line-height: var(--lh-prose); max-width: var(--measure);
             margin-top: var(--sp-12); }

  /* ---- Waiting on you. The Tier I list, rebuilt 2026-08-07. -----------------
     A ROW, NOT A CARD. The thing this replaced rendered every task as an
     `.lg-item` article with its whole description in it, which on three tasks
     was most of a screen and on thirty would be unreadable -- the founder's own
     word for it was "a mess ... that will never get actioned". Rows and short
     labels, Drive's list, and the detail one click away.

     The next action is distinguished by TYPE AND POSITION and spends no colour.
     The accent on this page belongs to the toggle and to a recommendation, and
     a third thing wearing it would make it decoration rather than a signal. */
  .lg-wait { margin: var(--sp-4) 0 0; }
  .lg-wait-row { display: flex; align-items: baseline; gap: var(--sp-4);
                 padding: var(--sp-3) 0;
                 border-bottom: 1px solid var(--border-subtle); }
  .lg-wait-row:last-child { border-bottom: 0; }
  .lg-wait-main { flex: 1 1 auto; min-width: 0; }
  .lg-wait-act { flex: 0 0 auto; }
  .lg-wait-title { font-size: var(--fs-sm); color: var(--text);
                   line-height: var(--lh-snug); margin: 0 0 var(--sp-1); }
  .lg-next .lg-wait-title { font-size: var(--fs-md); font-weight: 600;
                            max-width: var(--measure); }
  .lg-next { padding-top: var(--sp-2); }
  /* The one-line pointer at everything not on this page. Deliberately quiet:
     it is a door, not a backlog, and a count that shouted would be a second
     list. */
  .lg-more { font-size: var(--fs-sm); color: var(--text-muted);
             line-height: var(--lh-prose); max-width: var(--measure);
             margin: var(--sp-3) 0 0; }
  .lg-group { font-size: var(--fs-eyebrow); font-weight: 700;
              text-transform: uppercase; letter-spacing: var(--ls-caps);
              color: var(--text-muted); margin: var(--sp-6) 0 0; }

  .lq-lede { color: var(--text-2); font-size: var(--fs-base);
             line-height: var(--lh-prose); max-width: var(--measure);
             margin: 0 0 var(--sp-3); }
  .lq-problems { list-style: none; padding: var(--sp-3) var(--sp-4); margin: 0 0 var(--sp-4);
                 background: var(--warn-ground); border: 1px solid var(--warn-border);
                 border-radius: var(--r-md); color: var(--warn-text);
                 font-size: var(--fs-sm); line-height: var(--lh-prose); }
  .lq-problems li + li { margin-top: var(--sp-2); }

  /* What is left, and what it costs both ways. The comparison is the point, so the
     sentence under the figures is not a caption -- it carries the second number. */
  .lq-progress { display: flex; flex-wrap: wrap; gap: var(--sp-12);
                 margin: 0 0 var(--sp-3); }
  .lq-stat { display: flex; flex-direction: column; }
  .lq-stat-label { font-size: var(--fs-eyebrow); text-transform: uppercase;
                   letter-spacing: var(--ls-caps); color: var(--text-muted);
                   font-weight: 600; }
  .lq-stat-fig { font-size: var(--fs-display); line-height: var(--lh-tight);
                 letter-spacing: var(--ls-tight); color: var(--text);
                 font-variant-numeric: tabular-nums; margin-top: var(--sp-1); }
  .lq-rate { font-size: var(--fs-sm); color: var(--text-muted);
             line-height: var(--lh-prose); max-width: var(--measure);
             margin: 0 0 var(--sp-8); }
  .lq-done { font-size: var(--fs-md); color: var(--text-2);
             line-height: var(--lh-prose); max-width: var(--measure);
             margin: var(--sp-6) 0; }

  .lq-shell { display: flex; align-items: flex-start; gap: var(--sp-12); }
  .lq-stage { flex: 1 1 auto; min-width: 0; }

  /* The keys. Sticky, because the hands never leave the number row and the eyes
     should not have to leave the screen either. */
  .lq-keys { flex: 0 0 17rem; position: sticky; top: var(--sp-4);
             font-size: var(--fs-xs); color: var(--text-muted); }
  .lq-keyset { margin: 0 0 var(--sp-6); }
  .lq-keyset-head { font-size: var(--fs-eyebrow); font-weight: 700;
                    text-transform: uppercase; letter-spacing: var(--ls-caps);
                    color: var(--text-muted); margin: 0 0 var(--sp-2);
                    padding-bottom: var(--sp-1);
                    border-bottom: 1px solid var(--border-subtle); }
  .lq-keylist { list-style: none; padding: 0; margin: 0; }
  .lq-keylist li { display: grid; grid-template-columns: 3.2rem 1fr;
                   gap: var(--sp-1) var(--sp-2); padding: var(--sp-1) 0;
                   align-items: baseline; }
  .lq-kbd { grid-row: span 2; font-family: ui-monospace, monospace;
            font-size: var(--fs-xs); text-align: center; padding: var(--sp-05) 0;
            border: 1px solid var(--border); border-radius: var(--r-sm);
            background: var(--bg-sunken); color: var(--text); }
  .lq-key-label { color: var(--text); font-weight: 600; }
  /* The one-line meaning under each key. Hidden until `?` for the same reason the
     keymap is visible at all: ten labels are a keymap, ten paragraphs are a manual. */
  .lq-key-why { display: none; color: var(--text-muted);
                line-height: var(--lh-snug); }
  .lq-long .lq-key-why { display: block; }
  .lq-keys-note { line-height: var(--lh-prose); margin: 0; }
  /* Live vs quiet, by weight and not by colour. */
  .lq-quiet { opacity: 0.45; }
  .lq-live .lq-key-label { color: var(--text); }

  .lq-boot { color: var(--text-muted); font-size: var(--fs-sm); margin: 0; }
  .lq-first { margin: var(--sp-2) 0 0; }
  .lq-first-label { font-size: var(--fs-sm); color: var(--text-2);
                    font-variant-numeric: tabular-nums; }

  /* The descriptor is the thing being recognised, so it is the largest type on the
     page and it is monospace: a bank descriptor is a machine string and reading it
     as prose is how two vendors look like one. */
  .lq-group { margin: 0 0 var(--sp-4); }
  .lq-descriptor { font-family: ui-monospace, monospace; font-size: var(--fs-lg);
                   line-height: var(--lh-tight); color: var(--text);
                   margin: 0 0 var(--sp-2); word-break: break-word; }
  .lq-meta { display: flex; flex-wrap: wrap; gap: var(--sp-4);
             font-size: var(--fs-sm); color: var(--text-muted); margin: 0; }
  .lq-way { font-weight: 700; text-transform: uppercase;
            letter-spacing: var(--ls-caps); font-size: var(--fs-eyebrow);
            color: var(--text-2); }
  .lq-count { font-variant-numeric: tabular-nums; color: var(--text); }
  .lq-span { font-variant-numeric: tabular-nums; }
  .lq-acct { color: var(--text-muted); }
  .lq-bankcat { font-style: italic; }

  /* The suggestion, with its evidence beside it and never instead of it. */
  .lq-suggest { display: grid; grid-template-columns: 4rem 1fr;
                gap: var(--sp-1) var(--sp-3); padding: var(--sp-3);
                border-left: 3px solid var(--brand); background: var(--brand-weak);
                border-radius: 0 var(--r-sm) var(--r-sm) 0;
                margin: 0 0 var(--sp-4); }
  .lq-suggest-key { grid-row: span 2; font-family: ui-monospace, monospace;
                    font-size: var(--fs-xs); text-align: center;
                    align-self: start; padding: var(--sp-05) 0;
                    border: 1px solid var(--border); border-radius: var(--r-sm);
                    background: var(--bg-surface); color: var(--text); }
  .lq-suggest-flow { font-size: var(--fs-md); font-weight: 600; color: var(--text); }
  .lq-suggest-why { font-size: var(--fs-sm); color: var(--text-2);
                    line-height: var(--lh-prose); max-width: var(--measure); }
  .lq-nosuggest { font-size: var(--fs-sm); color: var(--text-muted);
                  margin: 0 0 var(--sp-4); }

  .lq-selection { display: flex; align-items: baseline; gap: var(--sp-4);
                  padding: var(--sp-2) 0; border-top: 1px solid var(--border);
                  border-bottom: 1px solid var(--border);
                  font-size: var(--fs-sm); color: var(--text); }
  .lq-selection-sum { font-variant-numeric: tabular-nums; color: var(--text-2); }
  .lq-use { font-size: var(--fs-eyebrow); text-transform: uppercase;
            letter-spacing: var(--ls-caps); font-weight: 700; color: var(--brand); }

  /* The match list. Hairlines across, nothing down -- `.lg-tbl`'s density, restated
     rather than shared because this one carries a mark column and tighter rows: it
     is scanned, not read. */
  .lq-tbl { width: 100%; border-collapse: collapse; font-size: var(--fs-sm);
            color: var(--text); margin: 0 0 var(--sp-2); }
  .lq-tbl th { text-align: left; font-size: var(--fs-eyebrow); font-weight: 600;
               text-transform: uppercase; letter-spacing: var(--ls-caps);
               color: var(--text-muted); padding: var(--sp-1) var(--sp-2);
               border-bottom: 1px solid var(--border); white-space: nowrap; }
  .lq-tbl td { padding: var(--sp-1) var(--sp-2);
               border-bottom: 1px solid var(--border-subtle);
               line-height: var(--lh-snug); }
  .lq-tbl td.lq-num, .lq-tbl th.lq-num { text-align: right;
                                         font-variant-numeric: tabular-nums;
                                         white-space: nowrap; }
  .lq-mark { width: 1.5rem; color: var(--text-muted); text-align: center; }
  .lq-focus td { background: var(--bg-hover); }
  /* In and out of what the next keystroke will touch. A row taken out is dimmed
     and struck rather than removed: a list that reorders under somebody's hands is
     a list they stop trusting. */
  .lq-in { color: var(--text); }
  .lq-out { opacity: 0.4; text-decoration: line-through; }
  .lq-shown { font-size: var(--fs-xs); color: var(--text-muted);
              line-height: var(--lh-prose); max-width: var(--measure);
              margin: 0 0 var(--sp-4); }

  /* The second keystroke. Loud, because it is asked for exactly twice: a deposit
     leaving presumed income, and an apply too large to see at once. */
  .lq-confirm { padding: var(--sp-3) var(--sp-4); margin: var(--sp-3) 0;
                background: var(--warn-ground); border: 1px solid var(--warn-border);
                border-radius: var(--r-md); color: var(--warn-text);
                font-size: var(--fs-sm); line-height: var(--lh-prose);
                max-width: var(--measure); }
  .lq-confirm p { margin: var(--sp-2) 0 0; }
  .lq-confirm-how { font-weight: 600; }

  .lq-undo { font-size: var(--fs-xs); color: var(--text-muted); margin: var(--sp-4) 0 0; }
  .lq-said { font-size: var(--fs-sm); color: var(--text-2); margin: var(--sp-3) 0 0; }
  .lq-bad { color: var(--danger-text); }

  /* ---------------------------------------------------------------------
     EVERY KEY IS ALSO A BUTTON. Added 2026-08-10.

     The founder: "this whole app is getting or has been horrible at providing a
     way to update or interact with the data." This page printed a keymap and
     offered nothing to click. The keys stayed; these are the controls beside them.

     NOTHING HERE HAS A BUSY STATE OF ITS OWN and nothing is ever taken out of the
     form to show one -- that drops a control's name and value from the POST, and
     on this surface a lost POST is a classification somebody believes they made.
     `CP.working` owns waiting, at the stage, and there is one of it.
     --------------------------------------------------------------------- */

  /* The control in the keymap list, sitting where the label used to. Quiet by
     default because there are thirty of them on screen at once and thirty loud
     buttons is a wall; it reads as a control on hover and on focus. */
  .lq-do { appearance: none; -webkit-appearance: none; background: transparent;
           border: 1px solid transparent; border-radius: var(--r-sm);
           padding: var(--sp-05) var(--sp-1); margin: calc(-1 * var(--sp-05)) 0;
           font: inherit; font-weight: 600; color: var(--text); text-align: left;
           cursor: pointer; }
  .lq-do:hover { background: var(--bg-hover); border-color: var(--border); }
  .lq-do:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }

  /* A row of real actions on the stage. `.btn` is the app's own button and is
     reused rather than restyled; these two rules are layout only. */
  .lq-acts { display: flex; flex-wrap: wrap; gap: var(--sp-2);
             margin: var(--sp-2) 0 0; }
  .lq-act { appearance: none; -webkit-appearance: none;
            background: var(--bg-surface); color: var(--text);
            border: 1px solid var(--border); border-radius: var(--r-md);
            padding: var(--sp-1) var(--sp-3); font: inherit;
            font-size: var(--fs-sm); font-weight: 600; cursor: pointer; }
  .lq-act:hover { background: var(--bg-hover); }
  .lq-act:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }

  /* BUSINESS OR NOT BUSINESS, ASKED ONLY WHERE THE FLOW DOES NOT DECIDE IT. Three
     choices, because "not business" and "nobody has said" are different facts. */
  .lq-ask { padding: var(--sp-3) var(--sp-4); margin: var(--sp-3) 0;
            background: var(--brand-weak); border: 1px solid var(--border);
            border-left: 3px solid var(--brand); border-radius: var(--r-md);
            font-size: var(--fs-sm); line-height: var(--lh-prose);
            max-width: var(--measure); }
  .lq-ask-why { color: var(--text-2); margin: var(--sp-2) 0; }
  .lq-choices { display: flex; flex-wrap: wrap; gap: var(--sp-2);
                margin: var(--sp-2) 0; }
  .lq-choice { appearance: none; -webkit-appearance: none;
               background: var(--bg-surface); color: var(--text);
               border: 1px solid var(--border); border-radius: var(--r-md);
               padding: var(--sp-1) var(--sp-3); font: inherit;
               font-size: var(--fs-sm); cursor: pointer; }
  .lq-choice:hover { background: var(--bg-hover); }
  .lq-choice:focus-visible { outline: 2px solid var(--brand); outline-offset: 1px; }
  /* The one that is chosen, marked by weight and a border rather than by colour
     alone -- the same discipline `.lq-live` follows two hundred lines up. */
  .lq-on { border-color: var(--brand); border-width: 2px; font-weight: 700;
           background: var(--brand-weak); }

  /* THE NOTE. Free text beside the typed fact, never instead of one, and nothing
     ever parses it. Drawn only where ledger.classification.comment exists. */
  .lq-note { margin: var(--sp-3) 0; max-width: var(--measure); }
  .lq-note-label { display: block; font-size: var(--fs-eyebrow);
                   text-transform: uppercase; letter-spacing: var(--ls-caps);
                   font-weight: 600; color: var(--text-muted);
                   margin: 0 0 var(--sp-1); }
  .lq-note-box { width: 100%; box-sizing: border-box; font: inherit;
                 font-size: var(--fs-sm); line-height: var(--lh-prose);
                 color: var(--text); background: var(--bg-surface);
                 border: 1px solid var(--border); border-radius: var(--r-sm);
                 padding: var(--sp-2); resize: vertical; }
  .lq-note-hint { font-size: var(--fs-xs); color: var(--text-muted);
                  line-height: var(--lh-prose); margin: var(--sp-1) 0 0; }

  /* The tick box on a match row. It is `x` wearing the control people look for. */
  .lq-tick { accent-color: var(--brand); cursor: pointer; margin: 0; }
  .lq-tbl tbody tr[data-act] { cursor: pointer; }

  /* SEVERAL DESCRIPTORS IN ONE ACT, and the replay band, which are the same shape:
     tick, confirm, one batch, one undo. Two shapes for one act is how a person
     learns that a page has two minds. */
  .lq-many { margin: 0 0 var(--sp-6); padding: var(--sp-3) var(--sp-4);
             background: var(--bg-sunken); border: 1px solid var(--border);
             border-radius: var(--r-md); }
  .lq-many-head { font-size: var(--fs-eyebrow); font-weight: 700;
                  text-transform: uppercase; letter-spacing: var(--ls-caps);
                  color: var(--text-muted); margin: 0 0 var(--sp-2); }
  /* Bounded and scrollable: the working set is up to forty-odd descriptors and a
     list that pushes the confirm off the bottom of the screen is a list nobody
     reaches the end of. */
  .lq-many-list { max-height: 22rem; overflow-y: auto; margin: 0 0 var(--sp-2);
                  border-top: 1px solid var(--border-subtle); }
  .lq-many-row { display: grid; grid-template-columns: 1.5rem 1fr auto;
                 gap: var(--sp-2); align-items: baseline;
                 padding: var(--sp-1) var(--sp-1);
                 border-bottom: 1px solid var(--border-subtle);
                 font-size: var(--fs-sm); cursor: pointer; }
  .lq-many-row:hover { background: var(--bg-hover); }
  .lq-many-what { font-family: ui-monospace, monospace; word-break: break-word;
                  color: var(--text); }
  .lq-many-sum { font-variant-numeric: tabular-nums; color: var(--text-muted);
                 white-space: nowrap; }
  .lq-many-bar { font-size: var(--fs-sm); color: var(--text);
                 font-variant-numeric: tabular-nums; margin: var(--sp-2) 0; }

  @media (max-width: 60rem) {
    .lq-shell { flex-direction: column; }
    .lq-keys { position: static; flex: 1 1 auto; }
    .lq-act, .lq-choice { min-height: 44px; }
  }

  /* -------- TWO BUTTON TONES THE APP ALREADY RENDERS AND NEVER STYLED ---
     `.btn.secondary`, `.btn.danger` and `.btn.success` have had rules since the
     shell was written; `primary` and `quiet` never did, and both are rendered --
     `quartermaster_panel` uses all four. They are SINGLE WORDS, so
     `test_module_split`'s class guard cannot see them (it only reads hyphenated
     names), which is exactly how they stayed unstyled: `quiet` painted as the
     full-strength navy primary button, so "Pause with a stated reason" and
     "View history" were as loud as the destructive control beside them.

     `primary` is the base `.btn` restated rather than a new colour, so nothing
     that renders it changes appearance -- it exists so a reader grepping for the
     rule finds one. */
  .btn.primary { background: #1f3864; color: #fff; }
  .btn.quiet { background: transparent; color: #1f3864;
               border: 1px solid var(--border-subtle); font-weight: 600; }
  .btn.quiet:hover { background: #eef1f6; }

  /* -------- the panel shell ------------------------------------------- */
  .qmp { border: 1px solid var(--border-subtle); border-radius: var(--r-md);
         padding: var(--sp-3); margin-bottom: var(--sp-3);
         background: var(--surface, #fff); }
  /* A COLD PANEL LOOKS DIFFERENT FROM A READ ONE, and that is the point of the
     class. `panel(state=None)` says in words that this page load did not read the
     configuration; a cold panel that looked identical to a live one would leave
     the sentence as the only signal, and nobody reads the sentence. Amber and a
     dashed rule, because "we did not look" is NOT EXAMINED and not an error. */
  .qmp-cold { border-style: dashed; border-color: #b7791f;
              background: #fffdf6; }
  .qmp-h { margin: 0 0 var(--sp-1); font-size: var(--fs-md); }
  .qmp-lede { margin: 0 0 var(--sp-2); font-size: var(--fs-xs);
              color: var(--text-muted); max-width: 60em; }
  .qmp-state { margin: 0; }
  /* -------- the fields -------------------------------------------------
     A two-column grid rather than a table: the panel is a definition list of
     name/value pairs and a <dl> that renders as undifferentiated blocks is the
     defect this whole block exists to fix. `minmax` on the label column so a long
     field name wraps instead of squeezing the value to nothing. */
  .qmp-fields { display: grid; grid-template-columns: minmax(8rem, 14rem) 1fr;
                gap: var(--sp-15) var(--sp-2); margin: 0 0 var(--sp-2); }
  .qmp-f { display: contents; }
  .qmp-f > dt { font-size: var(--fs-xs); color: var(--text-muted);
                font-weight: 600; }
  .qmp-f > dd { margin: 0; font-size: var(--fs-sm); color: var(--text); }
  .qmp-master { padding-bottom: var(--sp-2);
                border-bottom: 1px solid var(--border-subtle); }
  .qmp-v { font-variant-numeric: tabular-nums; }
  /* NOT EXAMINED IS AN EM DASH AND AMBER, NEVER A ZERO -- and it is BOLD, so it
     survives greyscale and a photocopy. `absent` means stop working it; `not
     examined` means go and look, and they are opposite instructions. */
  .qmp-unexamined { color: #85200c; font-weight: 700; }
  .qmp-v[data-qmp-tone="amber"] { color: #85200c; font-weight: 600; }
  .qmp-v[data-qmp-tone="red"] { color: #85200c; font-weight: 700; }
  .qmp-v[data-qmp-tone="green"] { color: #1e6b32; }
  .qmp-v[data-qmp-tone="gray"] { color: var(--text); }
  /* -------- one module ------------------------------------------------- */
  .qmp-mod-wrap { margin-top: var(--sp-2); }
  .qmp-mod { border-top: 1px solid var(--border-subtle);
             padding-top: var(--sp-2); }
  .qmp-mod-h { margin: 0 0 var(--sp-15); font-size: var(--fs-sm);
               text-transform: uppercase; letter-spacing: var(--ls-caps);
               color: var(--text-muted); }
  .qmp-none { font-size: var(--fs-sm); color: var(--text-muted);
              margin: var(--sp-2) 0 0; max-width: 60em; }
  /* -------- the controls ----------------------------------------------
     NOTHING IS EVER `disabled` HERE. A disabled submit drops its name and value
     from the POST and in some browsers cancels the submission, so a helpful
     disable turns a slow save into a lost one -- `CP.working(el)` paints the busy
     treatment instead, and `road_map_app._page` injects it into every page.
     There is no `[disabled]` rule in this block on purpose: a style for a state
     the markup must never enter is an invitation to enter it. */
  .qmp-controls { display: flex; flex-wrap: wrap; gap: var(--sp-2);
                  align-items: center; margin-top: var(--sp-2); }
  .qmp-c { display: inline-flex; flex-wrap: wrap; gap: var(--sp-1);
           align-items: center; margin: 0; }
  .qmp-note { font-size: var(--fs-xs); color: var(--text-muted);
              max-width: 34em; }
  /* Optional canned reasons, folded. NEVER required -- the one-click pause above
     already carries a canned `why` and a `why_source`, which is what satisfies
     `qm_client_off_states_why` without a coach typing anything. */
  .qmp-canned { flex: 1 1 100%; font-size: var(--fs-sm); }
  .qmp-canned > summary { cursor: pointer; color: var(--text-muted); }
  .qmp-canned > summary:focus-visible { outline: 2px solid #1f3864;
                                       outline-offset: 2px; }
  .qmp-canned .qmp-c { margin-top: var(--sp-1); }
  .qmp-asof { margin: var(--sp-2) 0 0; font-size: var(--fs-xs);
              color: var(--text-muted); }

  /* -------- the Intent to File expiry selector -------------------------
     ONE EXTRA COLUMN ON THE EXPIRING LISTING, and it renders for EXPIRED rows
     only. Founder: *"this should be for after an intent to file expires, how to
     handle it."* A row still inside its window has nothing to decide, and a
     selector on it would invite somebody to close a live deadline. */
  .itfw-act { white-space: normal; }
  .itfw-act-form { display: flex; flex-wrap: wrap; gap: var(--sp-1);
                   align-items: center; margin: 0; }
  .itfw-act-sel { font-size: var(--fs-xs); max-width: 22rem; }
  .itfw-act-note { font-size: var(--fs-eyebrow); color: var(--text-muted);
                   flex: 1 1 100%; }
  /* Folded, because eight of the thirteen options need one of these and five need
     none -- three always-visible inputs on every expired row would be three empty
     boxes most of the time, and an empty box reads as something somebody forgot. */
  .itfw-act-more { flex: 1 1 100%; font-size: var(--fs-xs); }
  .itfw-act-more > summary { cursor: pointer; color: var(--text-muted); }
  .itfw-act-more > summary:focus-visible { outline: 2px solid #1f3864;
                                          outline-offset: 2px; }
  .itfw-act-in { display: block; width: 100%; margin-top: var(--sp-1);
                 font-size: var(--fs-xs); }

  @media (max-width: 60rem) {
    /* The label column collapses; the VALUES stay, and nothing here hides a
       state. `test_itf_watch.test_the_responsive_rules_do_not_weaken_a_signal`
       walks every max-width block for exactly that. */
    .qmp-fields { grid-template-columns: 1fr; gap: 0 0; }
    .qmp-f > dt { margin-top: var(--sp-1); }
    .qmp-c > .btn, .itfw-act-sel { min-height: 44px; }
  }
