    :root {
      --primary: #161616;     /* near-black, neutral (no blue tint) — reads as the school's black */
      /* Brand orange = PANTONE Orange 012 (rgb 255,102,0). These are DEFAULTS/
         fallbacks — the live values are re-derived from the org's saved brand seeds
         at boot (applyBrandColors in app.js), which also sets --accent-rgb. Keep the
         two in sync (the seed default in app.js BRAND_COLOR_DEFAULTS must match). */
      --accent: #ff6600;
      --accent-hover: #e65c00;
      --accent-light: #fff0e6;
      --accent-dark: #b34700;
      --accent-rgb: 255, 102, 0;   /* for rgba() glows/rings that tint with the accent */
      /* COMPUTED text color for content sitting ON a brand surface — black or white by
         luminance (readableOn() in app.js), re-derived per tenant at boot. Defaults to
         white here because DCEA's orange/black are dark; a LIGHT tenant accent flips
         these to dark automatically, so button/header labels never go unreadable. */
      --on-accent: #ffffff;   /* text on an --accent background (primary buttons, chips) */
      --on-primary: #ffffff;  /* text on the --primary header bar */
      --bg: #dad8d4;          /* page background — light warm neutral (the go-forward default site grey). The darker PANTONE 422 #9ba5a4 read too heavy as a full-page bg, so this lighter neutral is the default. */
      --card: #ffffff;
      /* A NESTED tray — the list of children inside an open config row (Home's buttons,
         the Resources links). Each is one step further inside --border, because a list
         sitting inside a card must not out-shout the card. Named rather than inlined:
         they appear in two trays and would otherwise be four magic hexes copied twice. */
      --tray-border: #e5e2de;     /* the tray container's own outline */
      --tray-rule: #f1efec;       /* the hairline between its rows */
      --tray-row-hover: #fbfaf9;  /* pointing at a row */
      --tray-row-open: #fffaf6;   /* the row being edited (the app's existing open tint) */
      --text: #171717;        /* neutral near-black body text (no blue tint) */
      --muted: #6b7280;
      --border: #d6d4d0;
      --radius: 12px;
      --shadow: 0 1px 2px rgba(16,24,40,.04), 0 2px 6px rgba(16,24,40,.06);
      /* Shared height for every bottom bar (mobile tab bar, sign-in footer,
         create-account action bar) so the chrome is uniform. The safe-area inset
         is added on top per-bar. */
      --footer-bar-h: 53px;
      /* ── Semantic status tokens ─────────────────────────────────────────
         One canonical value per intent (replaces ~20 one-off green/red/amber/
         blue hexes that had drifted across features). "Neutral" is deliberately
         GREY, not blue — the brand is no-blue-tint; the only sanctioned blue is
         the OEA/state skin. bg = soft tint for chips/callouts; fg = text/icon. */
      --ok: #15803d;       --ok-bg: #e6f4ea;
      --warn: #8a5a12;     --warn-bg: #faeeda;     --warn-rgb: 138, 90, 18;
      --danger: #dc2626;   --danger-bg: #fef2f2;
      --neutral: #5b6763;  --neutral-bg: #ececea;

      /* ── THE type standard (single source of truth) ──────────────────────
         One ladder for the whole app. Before this existed, font-size had
         drifted to ~30 distinct values between 9px and 56px across ~1,050
         declarations — including eight HALF-PIXEL steps (12.5px alone appeared
         132 times, 13.5px 75 times). Half-pixels are the drift signature:
         they're what nudging-until-it-looks-right leaves behind, never a
         decision. Don't add one back.

         Rules:
         • Use a token. Never a bespoke px size — same discipline as --page-col
           ("THE page-width standard", further down this file).
         • --fs-md (13px) is the app's REAL base body size, not 16px.
         • If a value feels between two rungs, pick a rung. If a screen
           genuinely needs an off-ladder size (a display/logo mark), give it a
           named local var and say why — don't inline a bare number.
         • font-family runs on INHERITANCE, not on tokens scattered per rule:
           `body` names the family once and everything else says
           `font-family: inherit`. That discipline is why a local can now CHOOSE
           its typeface — `body` reads --font-app, so one value repoints ~121
           declarations at once (see THE BRAND TYPEFACE below). Never write a
           bespoke family on a component: say `inherit` and it follows the
           local's choice. The few faces that must NOT follow are listed there.

         Retrofit status: A, B and C are DONE — the ladder is the whole app.
         A put it in :root; B swapped 1,068 declarations exact-match-only; C
         snapped the last 479 by ROLE (a half-pixel is an exact tie between two
         rungs, so the tiebreaker is what the text does, never rounding) and
         gave the handful that are genuinely off-ladder a name below.

         The half-pixels are gone — all of them. 12.5px (x122), 13.5px (x69),
         11.5px (x40) and the rest were the drift signature, and
         scripts/_typetest.mjs now FAILS the build if one comes back, along with
         a bare font-size, a bare weight, or a var() naming a token that does
         not exist. So a literal below is no longer "expected" — it is either on
         the exemption list in that suite, with a reason, or it is a bug.

         What made C safe to do at all: scripts/type-diff.mjs, which boots the
         app, walks every screen at 1500 and 375 and diffs computed type against
         real geometry, so an invisible size change that silently reflows a
         tuned table is caught as a box that moved. That is what "needs eyes,
         not a script" turned into — a script that HAS eyes. */
      /* --fs-scale — THE text-size knob (Profile → Display). Multiplies every rung
         at once; 1 is the designed size. It exists because browser zoom welds two
         different problems together: a 15" laptop needs more CSS width (zoom out)
         but the same readable text (zoom in), and one control can't do both. Zoom
         buys the width, this buys the text back.

         Range is deliberately 1 → 1.3, never below 1. Scaling DOWN would push
         text controls under the 16px iOS auto-zoom floor, which is the hard
         reason and has not changed. The old second reason — that hundreds of
         un-snapped px literals would drift against tuned row heights — is now
         SPENT: Stage C put them on the ladder, so the type scales as one. What
         still argues against a wider range is spacing, which is only ~5%
         tokenized (see --sp-scale below), so large multipliers grow the text
         inside boxes whose padding stays put. Clamped in setTextScale(); widen
         it only with type-diff.mjs open. */
      --fs-scale: 1;
      --fs-2xs:  calc(10px * var(--fs-scale, 1));   /* micro: badge counts, timestamps, table sub-labels */
      --fs-xs:   calc(11px * var(--fs-scale, 1));   /* chips, pills, dense meta rows */
      --fs-sm:   calc(12px * var(--fs-scale, 1));   /* secondary / muted text */
      --fs-md:   calc(13px * var(--fs-scale, 1));   /* DEFAULT body — the app's true base */
      --fs-lg:   calc(14px * var(--fs-scale, 1));   /* emphasized body, form labels */
      --fs-xl:   calc(16px * var(--fs-scale, 1));   /* card titles. Also the iOS auto-zoom floor — but the
                            `>= 16px` input rule just below is a SEPARATE
                            load-bearing rule; don't fold one into the other. */
      --fs-2xl:  calc(18px * var(--fs-scale, 1));   /* section headings */
      --fs-3xl:  calc(22px * var(--fs-scale, 1));   /* page headings */
      --fs-4xl:  calc(28px * var(--fs-scale, 1));   /* display */

      /* Weights — five rungs. `650`, `900` and `normal` each appeared once or
         twice across the whole app; they read as typos, not intent. */
      --fw-body:    400;
      --fw-medium:  500;   /* chrome: nav items, menus, the brand line. Real Medium on Apple/Android;
                              Segoe UI has no 500 so Windows shows 400 — why collapsing it into a
                              neighbour was rejected: either direction changes SOME platform. */
      --fw-semi:    600;   /* labels, table headers, emphasis */
      --fw-bold:    700;   /* headings, buttons — the workhorse */
      --fw-display: 800;   /* hero/display only */

      /* ── Named off-ladder marks ─────────────────────────────────────────
         The ladder's escape hatch, and the ONLY sanctioned one: a size that is
         deliberately not a rung gets a name and a reason here, never a bare
         number at the call site. If you are tempted to add one, the bar is
         "this is an identity mark or a numeral that IS the content" — not
         "this screen looked better a pixel bigger". */
      --fs-count-hero: calc(44px * var(--fs-scale, 1));   /* the CMS countdown numeral — same reason as --fs-offer-hero */
      --fs-quote-mark: calc(56px * var(--fs-scale, 1));   /* the pull-quote's decorative " — a Georgia flourish behind the text, sized to the block it dresses, not to the words */
      --fs-offer-hero: calc(42px * var(--fs-scale, 1));   /* the business-offer magnitude numeral — the number IS the content */
      --fs-signature: calc(26px * var(--fs-scale, 1));   /* the cursive signature script on an e-signed document — a mark, not body type */
      --fs-wordmark: calc(24px * var(--fs-scale, 1));   /* the wordmark glyph — an identity mark, sized to the 36px header slot, not a rung */
      --fw-wordmark: 900;   /* the wordmark is Black, past --fw-display; type, not emphasis */

      /* Line-heights — five rungs, replacing 22 distinct values (1.1 … 1.75,
         including 1.62 / 1.65 / 1.72). Unitless on purpose so they scale with
         the element's own font-size. */
      --lh-none:  1;      /* glyph/badge/icon chrome — the box is the height; the text adds none */
      --lh-tight: 1.2;    /* headings, single-line chrome */
      --lh-snug:  1.35;   /* card titles, tight multi-line labels */
      --lh-body:  1.5;    /* running text — the default */
      --lh-loose: 1.6;    /* long-form prose (CMS pages, help copy) */

      /* ── THE BRAND TYPEFACE ──────────────────────────────────────────────
         A local picks its typeface in Site Config → Branding → Wording, exactly
         as it picks its four brand colours. This is the ONE place the app's
         font is decided: `body` reads it, ~121 other declarations say
         `font-family: inherit`, so one value repoints the whole app.

         The value is always a STACK CHOSEN FROM A LIST (FONT_STACKS in
         public/js/app.js, whitelisted server-side in routes/branding.js) —
         never free text an admin typed. Two reasons, and both are the same
         reason brand colours compute their own contrast instead of trusting
         input: a font you have not measured can reflow a dense table, and a
         font nobody vetted can be illegible. Every stack on that list has been
         run through `node scripts/type-diff.mjs`, which walks 22 screens at
         1500 and 375 and reports any box that moved.

         Only system stacks are offered — fonts already on the device. No
         webfont is downloaded: that keeps cold load honest, works offline, and
         avoids a third-party font host, which the CSP would have to allow and
         which would leak every member's page loads to it.

         THREE THINGS DELIBERATELY DO NOT FOLLOW IT, and each is explicit rather
         than `inherit` for that reason:
           • monospace — keys, tokens, code. Mono must stay mono.
           • 'Segoe Script' — the e-signature. If the signature followed the
             body font it would stop reading as a signature, on a record the
             ESIGN/UETA path treats as evidence.
           • 'Fredoka Wordmark' and the Georgia pull-quote flourish — identity
             marks, sized and chosen on purpose (see Named off-ladder marks).
         Automated email does NOT follow it either: lib/email-theme.js owns its
         own stack, because Gmail strips @font-face and the foreignObject
         snapshot path serializes only the email document's own <style>. */
      --font-app: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;

      /* ── THE space standard (single source of truth) ─────────────────────
         Type is 99% tokenized and scales with the reader's setting. Spacing
         was 0.3% tokenized and scaled with nothing: 798 `padding`
         declarations, two of which read a var(); 748 `gap`, one. Zero `rem`
         in the file.

         These are ROLE tokens, deliberately NOT a numeric --sp-1..6 ladder.
         The problem was never "the app uses twenty different px values" —
         it is that ONE role, "the card's gutter", was spelled out in
         sixteen unrelated selectors (.app-container, .card.padded,
         .page-body, .page-foot, .page-scroll, .td-body, .comments-hdr,
         .td-card-hdr, …), so it could not be changed once. A numeric ladder
         would fix the cosmetic complaint and buy us a second Stage C
         (docs/DCEA-Build-Todo.md #30) for the real one.

         THE RULE THESE EXIST TO ENFORCE:
           The page card is the only card. Anything inside it is a SECTION,
           not a card — it separates with a hairline and a heading, not with
           a border, a radius, a fill and a second gutter. Between the
           card's wall and any line of body text there is exactly ONE gutter
           and ZERO borders.

         See docs/Space-Standard.md, the .card-sec block below, and the
         one-gutter assertions in scripts/_frametest.mjs. */
      /* --sp-scale — the DENSITY knob, sibling of --fs-scale. Set from
         applyDisplay() in app.js so spacing follows the reader's text size
         at half rate: at Largest, text is +30% and space +15%. Before this,
         460 declarations grew with the text (including every row height)
         and not one padding did, so the biggest text setting was also the
         most cramped. */
      --sp-scale: 1;
      --pad-card: calc(22px * var(--sp-scale, 1));   /* THE gutter: card wall → content. 14px on phones. */
      --pad-band: calc(14px * var(--sp-scale, 1));   /* a band/header row's vertical padding */
      --pad-row:  calc(12px * var(--sp-scale, 1));   /* a row's own inset, inside the gutter */
      --gap-sec:  calc(16px * var(--sp-scale, 1));   /* between sibling sections */
      --rail: 1px solid var(--border);               /* the divider that REPLACES an inner border */
      /* --pad-exhibit — the EXTRA reading indent an exhibit composition adds past the
         gutter. An exhibit is a panel whose content will rarely fill it (the request
         detail's editorial block is the reference): its text steps in one deliberate
         interval so the composition reads placed, not parked against the wall. This is
         NOT license for a second gutter on ordinary panels — it exists so the frame
         test can tell a DECLARED indent from double-gutter drift (_frametest asserts
         an exhibit's text at exactly --pad-card + --pad-exhibit). */
      --pad-exhibit: calc(34px * var(--sp-scale, 1));
      /* A drag handle is ONE object wherever it appears. Sized to --list-mark's 28px
         because that is the constrained context: in a list's lead slot the grip shares
         a column with avatars, locks and icon pickers, and it has to line up with them.
         Everywhere else (the shortcut tiles) nothing constrains it, so the list wins. */
      --grip-size: calc(28px * var(--fs-scale, 1));

      /* ── The section head's band ────────────────────────────────────────
         A head's fill, its rule, its label and its icon — all one hue, all
         derived from --primary, the tenant's own seed.

         It started as var(--bg) on the reasoning that a head does the same job
         as .list-head. The job is the same; the value could not be. .list-head
         sits over one column of rows, once per screen. A .card-sec-hd can land
         three-across plus a fourth below, and at that density the page grey
         reads as slabs — and as the page background bleeding up through the
         card, which is the one thing the card exists to stop. Deriving from
         --primary makes the head look chosen rather than left over.

         DERIVED PER TENANT in brandTokensFrom() (app.js), next to the accent
         family. These four are the DEFAULTS for the default seed (#161616,
         near-black) and must stay in sync with it — a maroon or green local
         gets a maroon or green band, and DCEA's near-black resolves to the warm
         near-grey below, which is what it should be. Never hardcode a band
         colour at a call site; that is how one tenant's blue ends up on
         everyone's screen. */
      --band:      #eaeaea;   /* --primary at 9% over the card  */
      --band-rail: #d5d5d5;   /* --primary at 18% — the head's own bottom rule */
      --band-ink:  #373737;   /* the label. Darkens instead of lightening when the seed is already light. */
      --band-ico:  #868686;   /* icons in a head — the same hue, stepped back */
      /* CALENDAR COLOUR. --cal-primary is the local's primary SOFTENED: the banner colour
         lifted in lightness so it can sit under a header painted in the raw one without
         competing with it — the raw banner colour never appears in calendar UI. The three
         slots after it are where a second, third and fourth calendar land, assigned by
         config ORDER (calColor in app.js), never by hashing a name. Fallbacks are DCEA's;
         the @supports block after :root derives them from whatever seed a local has. */
      --cal-primary: #43507f;
      --cal-slot-2: #0e7490;
      --cal-slot-3: var(--accent-dark, #b34700);
      --cal-slot-4: #15803d;
    }
    /* ONLY SLOT 1 IS DERIVED, and that is the lesson from trying to derive all four: hue-rotating
       a seed with no chroma rotates nothing. DCEA's primary is #161616 — near-black, chroma ~0 —
       so "the seed + 140deg" and "the seed + 260deg" both came out the same grey, and a month with
       three calendars in it drew three identical marks. The chroma floor keeps slot 1 a colour
       rather than a smudge; slots 2-4 stay fixed, far-apart hues that read against any brand,
       which is what the handoff's own palette listed. A local's identity is slot 1 — the calendar
       most of its events live on — and the others only have to be TELLABLE APART from it.
       AFTER :root, as its own block: folded into :root by closing the brace early, this dropped
       every token declared below it (--band* among them) and section-head bands went flat. */
    @supports (color: oklch(from red l c h)) {
      :root {
        --cal-primary: oklch(from var(--primary) clamp(.42, calc(l + .15), .62) max(c, .06) h);
      }
    }
    * { box-sizing: border-box; margin: 0; padding: 0; }
    /* No rubber-band / pull-to-refresh: keep overscroll from bouncing the page
       (the document level) — inner scrollers add this too. iOS honors it 16.4+. */
    html, body { height: 100%; overscroll-behavior: none; }
    /* Form controls don't inherit font-size — they take a User-Agent default (13.3333px
       in Chrome), so the body rule above can't reach them and text you actually TYPE
       would stay put while its label grew. Element-level selectors on purpose: every
       class rule that sets a size has higher specificity and still wins (and now
       scales too), so this only catches the controls that were riding the UA default.
       The literal reproduces that default exactly, so scale 1 renders unchanged.
       Deliberately NOT checkbox/radio/range — they render no text, and sizing them
       would resize the box.

       :where() is load-bearing — it contributes ZERO specificity, so this still beats
       the UA default (author styles always do) but loses to every author rule in this
       file. Written as a normal selector it silently outranked them: `:not([type=…])`
       carries attribute-selector weight, which beat plain class rules and resized 141
       elements that were already styled. Don't unwrap the :where(). */
    :where(input:not([type=checkbox]):not([type=radio]):not([type=range]),
           select, textarea, option, button) { font-size: calc(13.3333px * var(--fs-scale, 1)); }
    /* iOS Safari auto-zooms when you focus a form control whose text is < 16px, and
       never zooms back out after the keyboard closes. Keep text-entry controls at
       >= 16px on phones to prevent it (overrides the inline 13-14px sizes; this is
       font-size only, so layout is barely affected and pinch-zoom stays available). */
    @media (max-width: 760px) {
      input:not([type=checkbox]):not([type=radio]):not([type=range]),
      select, textarea { font-size: 16px !important; }
    }
    /* App shell: the page itself never scrolls — only panels inside it do. */
    /* height — two different correct answers, so we split by context:
       • Browser tab: --app-height (set in JS from window.innerHeight) = the area
         left after the address/tool bar, which 100vh overshoots on mobile Chrome.
       • Installed PWA: plain 100vh = the true full screen. In standalone iOS,
         innerHeight/dvh/svh UNDER-report (they drop the home-indicator strip),
         which is what left a grey gap below the footer — so we force 100vh there. */
    /* font-size on body is INHERITANCE plumbing for --fs-scale, not a new base size.
       The app sizes text per-element off the --fs-* ladder and never set a base here,
       so ~1,800 elements with no font-size rule of their own were inheriting the
       BROWSER default (16px) — outside the ladder, and therefore deaf to the text-size
       setting. The literal 16px keeps that inherited size byte-identical at scale 1
       (this is deliberately NOT var(--fs-md): switching inheritance from 16px to 13px
       would reflow every one of those elements, which is a separate decision from
       "let text scale"). Don't "clean this up" into a token without measuring that. */
    /* max-height is the CEILING under the line above, and it is load-bearing.
       --app-height is a JS-written number, so it can be STALE — the window changed
       size and no `resize` reached us (a bfcache back-navigation, a tab restored
       into a shorter window, a session reopened). Stale-large is the dangerous
       direction: body then measures TALLER than the viewport, and because <html>
       has no overflow of its own, body's own `overflow: hidden` doesn't save us —
       the DOCUMENT scrolls instead. The whole shell slides: the header leaves the
       top of the screen and every page's card runs off the bottom, so the card that
       is supposed to end 22px short of the edge is the thing you scroll to reach.
       That is not a page's bug, it looks like one on whichever page you're on.
       The ceiling makes it impossible. 100vh is the honest viewport in the two
       places it matters (desktop; standalone, where the rule below already uses it)
       and on a mobile browser it is the LARGE viewport — i.e. never smaller than
       innerHeight — so the clamp cannot bite the phone shell. */
    body { font-family: var(--font-app); font-size: calc(16px * var(--fs-scale, 1)); background: var(--bg); color: var(--text); height: 100vh; height: calc(var(--app-height, 100vh) / var(--ui-zoom, 1)); max-height: calc(100vh / var(--ui-zoom, 1)); overflow: hidden; display: flex; flex-direction: column; }
    @media (display-mode: standalone) { body { height: calc(100vh / var(--ui-zoom, 1)); } }
    /* --ui-zoom — THE layout knob (Profile → Display → Screen Fit), the in-app
       replacement for telling members to go set their browser zoom.
       Below 1 it hands the page MORE CSS pixels to lay out in, so a 13" laptop
       stops falling under the 1500px --page-col cap and grids keep their columns.
       Text does NOT shrink with it: setDisplayFit() divides the same factor back
       out of --fs-scale, so the two knobs the old copy asked members to operate by
       hand are now one control that moves them in opposite directions for you.
       Desktop only — the mobile shell is sized for its screen already, and the JS
       gate on the same 761px breakpoint keeps the --fs-scale compensation from
       leaking onto phones as oversized text.
       The height rules above divide by it because zoom scales the element's own
       box: body must be viewport/zoom tall to render exactly one viewport. */
    @media (min-width: 761px) { body { zoom: var(--ui-zoom, 1); } }
    /* Secondary text — the app marks ~60 elements `class="muted"` (hints, counts, empty
       states, captions). The rule itself never existed here: only compound selectors like
       `.state-sub-head .muted` did, so a bare `.muted` inherited full-strength --text and
       rendered near-black. Colour only, deliberately: those elements are sized by their own
       rules (12–16px observed), so admin.css's `font-size: calc(13px * var(--fs-scale, 1))` would resize all of them. */
    .muted { color: var(--muted); }
    /* Line icons (Lucide) sized to their text/emoji slot; inherit the slot's color. */
    .lic { width: 1em; height: 1em; display: inline-block; vertical-align: -0.14em; flex-shrink: 0; }
    /* Iconography picker (Site Config) */
    .icon-mode-choices { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
    .imc-card { text-align: left; border: 1.5px solid var(--border); border-radius: 12px; padding: 14px; background: var(--card); cursor: pointer; font-family: inherit; color: var(--text); transition: border-color .15s, background .15s; }
    .imc-card:hover { border-color: var(--accent); }
    .imc-card.active { border-color: var(--accent); background: var(--accent-light); }
    .imc-row { display: flex; gap: 16px; align-items: center; font-size: var(--fs-3xl); margin-bottom: 10px; color: var(--text); }
    .imc-row .imc-ico { display: inline-flex; }
    .imc-title { font-weight: var(--fw-bold); font-size: var(--fs-lg); }
    .imc-desc { font-size: var(--fs-sm); color: var(--muted); margin-top: 2px; }
    @media (max-width: 600px) { .icon-mode-choices { grid-template-columns: 1fr; } }

    /* Header */
    header { background: var(--primary); color: var(--on-primary); padding: calc(13px + env(safe-area-inset-top, 0px)) calc(24px + env(safe-area-inset-right, 0px)) 13px calc(24px + env(safe-area-inset-left, 0px)); display: flex; align-items: center; justify-content: space-between; flex-shrink: 0; }
    .brand { display: flex; align-items: center; gap: 14px; }
    /* Fixed logo box so the header is the SAME height across Platform / State / Local,
       whether the brand-logo holds text (fallback) or an uploaded image of any size. */
    .brand-logo { font-size: var(--fs-wordmark); font-weight: var(--fw-wordmark); color: var(--accent); letter-spacing: -1px; line-height: var(--lh-none); height: 36px; display: inline-flex; align-items: center; }
    .brand-name { font-size: var(--fs-md); font-weight: var(--fw-medium); color: rgba(255,255,255,.85); line-height: var(--lh-tight); }
    /* .68 white, not the old .4: at 10px this is small text, so it needs 4.5:1. Against the
       platform's mid-tone green bar .4 measured 2.71:1 (and only ~3.8:1 even on a near-black
       bar) — it was under AA everywhere, the green just made it obvious. The green bar is the
       floor that sets this number: .65 is the exact AA threshold there, .68 keeps a margin
       (measured 4.77:1 green / 8.77:1 near-black). Don't dim it back. */
    .brand-tagline { font-size: var(--fs-2xs); color: rgba(255,255,255,.68); letter-spacing: .6px; text-transform: uppercase; }
    .userbox { display: flex; align-items: center; gap: 10px; font-size: var(--fs-md); position: relative; }
    .userbox img { width: 32px; height: 32px; border-radius: 50%; border: 2px solid rgba(255,255,255,.2); }
    .userbox .uname { color: rgba(255,255,255,.92); font-weight: var(--fw-semi); }
    .avatar-initials { width: 32px; height: 32px; border-radius: 50%; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; font-size: var(--fs-md); font-weight: var(--fw-bold); }
    /* Profile menu: avatar acts as a button that opens a dropdown */
    .ub-tenant-chip { font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--on-primary); background: color-mix(in srgb, var(--on-primary) 12%, transparent); border: 1px solid color-mix(in srgb, var(--on-primary) 18%, transparent); border-radius: 999px; padding: 6px 12px; white-space: nowrap; line-height: var(--lh-none); }
    @media (max-width: 600px) { .ub-tenant-chip { display: none !important; } }
    /* Site Config cog — sits to the LEFT of the avatar. Same round hit-target and
       hover wash as .userbox-me so the two read as one control cluster, not as a
       stray icon that wandered in from the nav. */
    .ub-cog { display: grid; place-items: center; width: 34px; height: 34px; border: 0; border-radius: 50%; background: none; padding: 0; color: rgba(255,255,255,.72); cursor: pointer; }
    .ub-cog:hover { background: rgba(255,255,255,.1); color: #fff; }
    /* "You are here." The nav marks active with an accent underline; a round button
       in the top bar can't wear one, so it takes the accent as a fill instead. */
    .ub-cog.active { background: var(--accent); color: var(--on-accent); }
    .ub-cog.active:hover { background: var(--accent); color: var(--on-accent); }
    .ub-cog .ic { display: flex; }
    .ub-cog .ic svg { width: 19px; height: 19px; display: block; }
    .userbox-me { display: flex; align-items: center; gap: 9px; font-size: var(--fs-md); padding: 3px 8px 3px 3px; border-radius: 999px; background: none; border: 0; color: inherit; font-family: inherit; cursor: pointer; }
    .userbox-me:hover, .userbox-me.open { background: rgba(255,255,255,.1); }
    .ub-caret { font-size: var(--fs-2xs); color: rgba(255,255,255,.55); transition: transform .15s ease; }
    .userbox-me.open .ub-caret { transform: rotate(180deg); }
    /* Three bands separated by dividers (see renderUserbox): identity · resources ·
       sign out. Rows breathe a little more than a nav list because this menu is short
       and each row is a different KIND of thing — tight rows made them read as one run. */
    .userbox-menu { position: absolute; top: calc(100% + 9px); right: 0; min-width: 212px; background: var(--card); border: 1px solid var(--border); border-radius: 12px; box-shadow: 0 14px 34px rgba(0,0,0,.22); padding: 7px; z-index: 80; }
    .userbox-menu.hidden { display: none; }
    .userbox-menu a { display: flex; align-items: center; gap: 11px; padding: 10px 12px; border-radius: 8px; font-size: var(--fs-md); font-weight: var(--fw-medium); color: var(--text); text-decoration: none; }
    .userbox-menu a:hover { background: var(--accent-light); color: var(--accent-dark); }
    .userbox-menu a:active { background: var(--accent-light); color: var(--accent-dark); }
    .userbox-menu a.danger { color: #b91c1c; }
    .userbox-menu a.danger:hover, .userbox-menu a.danger:active { background: #fee2e2; color: #b91c1c; }
    .userbox-menu a { touch-action: manipulation; }
    .userbox-menu .ubm-ico { width: 18px; text-align: center; font-size: var(--fs-lg); flex-shrink: 0; }
    /* Full content width, equal from both edges (card-divider standard) — the old
       5px 9px inset made a 2-divider menu look ragged. */
    .ubm-divider { height: 1px; background: var(--border); margin: 6px 0; }
    /* .install-banner styles removed with the banner itself — the first-run walkthrough
       (.howto-card) and the Home "Finish Setting Up" card (.setup-card) do this job. */
    .gear-btn { background: none; border: none; color: rgba(255,255,255,.55); font-size: var(--fs-xl); cursor: pointer; padding: 2px 4px; line-height: var(--lh-none); }
    .gear-btn:hover { color: #fff; }

    /* Nav */
    /* The nav strip follows the local's Header & Nav colour. --nav-bg has been computed
       from --primary all along (tint 8%, so the strip reads as its own band without
       leaving the palette) — it was simply never used, and every local wore DCEA's
       black under its own header. The literal stays only as a pre-theme fallback. */
    #site-nav { background: var(--nav-bg, #232323); flex-shrink: 0; position: relative; z-index: 50; }
    .nav-main { display: flex; padding: 0 16px; overflow-x: auto; scrollbar-width: none; }
    .nav-main::-webkit-scrollbar { display: none; }
    .nav-item { background: none; border: none; border-bottom: 4px solid transparent; color: rgba(255,255,255,.6); padding: 11px 15px; font-size: var(--fs-md); font-weight: var(--fw-medium); font-family: inherit; cursor: pointer; white-space: nowrap; display: flex; align-items: center; gap: 4px; transition: color .15s, border-color .15s; }
    .nav-item:hover { color: #fff; }
    .nav-item.active { color: var(--on-primary); border-bottom-color: var(--accent); }
    .nav-chevron { font-size: var(--fs-2xs); opacity: .5; transition: transform .2s; display: inline-block; }
    .nav-divider { display: flex; align-items: center; align-self: stretch; color: rgba(255,255,255,.25); font-size: var(--fs-md); padding: 0 6px; user-select: none; }
    .nav-item.open .nav-chevron { transform: rotate(180deg); opacity: 1; }
    /* Submenus are dropdown popouts anchored under their menu item — they
       overlay content instead of reserving a row, so nothing shifts. */
    .nav-sub { position: absolute; top: 100%; left: 0; z-index: 60; display: flex; flex-direction: column; min-width: 230px; background: #fff; border: 1px solid var(--border); border-radius: 0 0 10px 10px; box-shadow: 0 10px 28px rgba(0,0,0,.16); padding: 6px; }
    .nav-sub-item { background: none; border: none; color: var(--text); text-align: left; width: 100%; padding: 9px 13px; border-radius: 7px; font-size: var(--fs-md); font-weight: var(--fw-medium); font-family: inherit; cursor: pointer; white-space: nowrap; transition: background .12s, color .12s; }
    .nav-sub-item:hover { background: var(--accent-light); color: var(--accent-dark); }
    .nav-sub-item.active { background: var(--accent-light); color: var(--accent-dark); font-weight: var(--fw-semi); }
    /* ── The count that follows a destination around ──────────────────────────
       One badge class for every door a destination can have — the Resources dropdown, a
       custom menu, a header promoted to the top bar, the phone's More sheet, a Home
       button. It exists because The Local Exchange stopped having a Review button: a
       leader is told there is something waiting WHERE THEY ALREADY ARE, instead of having
       to open the board to find out. Red, not accent — this is "somebody is waiting on
       you", the same voice as the Requests tab count, and a local's accent may well be
       green. `margin-left: auto` is deliberately absent: the badge hugs the label so it
       reads as part of the name, not as a column of numbers down the menu. */
    .nav-dest-badge { display: inline-grid; place-items: center; min-width: 18px; height: 18px;
      padding: 0 5px; margin-left: 7px; vertical-align: middle; border-radius: 999px;
      background: var(--danger); color: #fff; font-size: var(--fs-xs); font-weight: var(--fw-bold);
      line-height: var(--lh-none); }
    /* On the top bar the header is the local's own colour on a dark bar, so the badge
       needs a hairline of the bar behind it to stay legible against a busy palette. */
    .nav-item .nav-dest-badge { box-shadow: 0 0 0 1.5px var(--primary, #232323); }
    /* Group header inside a dropdown — used when the inherited state menu carries
       several of the state's menus inside the one state dropdown. */
    .nav-sub-group { padding: 9px 13px 3px; font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
    .nav-sub-group:not(:first-child) { margin-top: 4px; border-top: 1px solid var(--border); padding-top: 9px; }

    /* Layout — fixed shell, scrolling happens only inside panels */
    main { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
    /* safe center: tall forms (register + keyboard open) top-align and stay fully scrollable instead of clipping */
    .centered-view { flex: 1; min-height: 0; display: grid; align-items: safe center; justify-items: center; padding: 40px 20px; overflow-y: auto; }
    #view-app { flex: 1; min-height: 0; display: flex; flex-direction: column; }
    .app-section { display: none; }
    .app-section.active { display: flex; flex-direction: column; flex: 1; min-height: 0; }
    /* generic sections scroll inside their own container; home overrides this with .home-wrap */
    /* Pages fill the content area edge-to-edge (with padding) rather than
       floating in a narrow centered column. Forms stay readable via .narrow. */
    .app-section.active > .app-container { flex: 1; min-height: 0; overflow-y: auto; overscroll-behavior: none; scrollbar-gutter: stable; padding: 26px 22px; width: 100%; }
    .app-section.active > .app-container.narrow { max-width: 880px; }

    /* Cards */
    .card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
    /* THE gutter, on the app's most-used card. 81 call sites across index.html and
       app.js hang off this one declaration, which is exactly why it was the last
       literal worth moving and not the first: it is the difference between a
       framed screen and an unframed one having the same wall.
       It had NO phone override, so every unframed card paid the desktop 22px on a
       375px screen while every framed card beside it paid 14. Now they agree. */
    .card.padded { padding: var(--pad-card); }
    .card h2 { font-size: var(--fs-2xl); margin-bottom: 8px; }
    .card p { color: var(--muted); line-height: var(--lh-body); margin-bottom: 20px; }

    /* Buttons */
    .btn { display: inline-flex; align-items: center; gap: 8px; background: var(--accent); color: var(--on-accent); border: none; border-radius: 9px; padding: 10px 20px; font-size: var(--fs-lg); font-weight: var(--fw-semi); cursor: pointer; text-decoration: none; font-family: inherit; transition: background .15s; }
    .btn:hover { background: var(--accent-hover); }
    .btn.secondary { background: #f3f4f6; color: var(--text); }
    .btn.secondary:hover { background: #e5e7eb; }
    .btn.ghost { background: transparent; color: var(--accent); border: 1.5px solid var(--accent); }
    .btn.ghost:hover { background: var(--accent-light); }
    .btn.ghost-danger { background: transparent; color: var(--danger); border: 1.5px solid var(--danger); }
    .btn.ghost-danger:hover { background: var(--danger-bg); }
    /* Solid destructive button — used by the shared confirm() dialog (opts.danger)
       and inline Delete actions. Without this rule "btn danger" fell through to a
       plain accent-orange button, so destructive confirms looked non-destructive. */
    .btn.danger { background: var(--danger); color: #fff; }
    .btn.danger:hover { background: #b91c1c; }
    .btn.sm { padding: 7px 14px; font-size: var(--fs-md); }
    .btn:disabled { opacity: .5; pointer-events: none; }
    /* Looks disabled but stays tappable — used by Create Account so a tap can pop
       the "still needed" tooltip instead of doing nothing. */
    .btn.inactive { opacity: .5; }
    .btn.inactive:active { transform: none; }
    .g-icon { width: 18px; height: 18px; display: inline-block; vertical-align: middle; }

    /* Badges */
    .badge { display: inline-block; padding: 2px 10px; border-radius: 999px; font-size: var(--fs-xs); font-weight: var(--fw-semi); white-space: nowrap; }
    .pill { display:inline-block; padding:3px 10px; border-radius:999px; font-size:var(--fs-sm); font-weight:var(--fw-semi); background:var(--accent-light); color:var(--accent-dark); text-transform:capitalize; }

    /* Dashboard */
    .greeting-row { margin-bottom: 28px; }
    .greeting-row h1 { font-size: var(--fs-4xl); font-weight: var(--fw-bold); }
    .greeting-row .sub { font-size: var(--fs-lg); color: var(--muted); margin-top: 4px; }
    .quick-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 14px; margin-bottom: 32px; }
    .quick-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 22px; cursor: pointer; transition: border-color .15s, box-shadow .15s; }
    .quick-card:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(var(--accent-rgb),.1); }
    .quick-card.accent-card { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
    .quick-card.accent-card:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
    .qc-icon { width: 38px; height: 38px; border-radius: 10px; background: var(--accent-light); color: var(--accent); display: grid; place-items: center; font-size: var(--fs-2xl); margin-bottom: 12px; }
    .quick-card.accent-card .qc-icon { background: rgba(255,255,255,.2); color: #fff; }
    .quick-card h3 { font-size: var(--fs-lg); font-weight: var(--fw-semi); margin-bottom: 3px; }
    .quick-card p { font-size: var(--fs-sm); color: var(--muted); margin: 0; line-height: var(--lh-body); }
    .quick-card.accent-card p { color: rgba(255,255,255,.8); }
    .qc-count { font-size: var(--fs-4xl); font-weight: var(--fw-bold); color: var(--accent); margin-bottom: 0; line-height: var(--lh-none); }
    .quick-card.accent-card .qc-count { color: #fff; }

    /* ── HOME IS A PAGE FRAME ────────────────────────────────────────────────
       Band above ONE card; inside the card, hairline-separated regions and exactly
       one scroller (the events list). Desktop: a 440px rail (setup callout + quick
       links) beside the events column. Phones: .hm-body and .hm-rail go
       display:contents so the frame's flex column holds the regions directly, and
       `order` pins the quick-link tiles to the bottom as a footer row.
       See THE PAGE FRAME (app.css) and the markup note on #section-home. */
    /* The wrap is .app-container itself — it takes the page gutter from there and caps its
       CHILDREN through the shared child-cap rule, like every other primary page. It must
       not set a gutter or a cap of its own (that was a 44px side margin against everyone
       else's 22 — see the card-box check in _frametest). */
    .home-wrap { flex: 1; min-height: 0; width: 100%; display: flex; flex-direction: column; }
    .home-frame { flex: 1 1 auto; min-height: 0; }
    /* Regions of the card, separated by hairlines and nothing else. */
    .home-frame > .home-announce, .home-frame > .onboard-panel, .home-frame > .hm-setup,
    .home-frame > #home-advocacy, .home-frame > #home-myreq, .home-frame > .submit-hero {
      flex: none; margin: 0; border-bottom: 1px solid var(--border); }
    .home-frame > .home-announce.hidden, .home-frame > .onboard-panel.hidden,
    .home-frame > .hm-setup.hidden, .home-frame > #home-advocacy.hidden,
    .home-frame > #home-myreq.hidden { display: none; }
    /* The live-campaign strip (advHomeStrip, from main) is a REGION like the others: it
       keeps its accent tint, which is what makes it read as the one urgent thing, but the
       card chrome comes off — a rounded tinted block floating inside a white card is the
       second-card mistake the frame exists to end. */
    .home-frame > #home-advocacy { padding: 10px 18px; }
    .home-frame > #home-advocacy .adv-strip { border-radius: 0; background: none; padding: 0; }
    /* The setup callout is one of the two pieces of nested chrome the card allows (it and
       the tiles are controls, not sections): tinted, bordered, and unmistakably a to-do. */
    .hm-setup { padding: 14px 18px; background: var(--accent-light); }
    .hm-setup .setup-card { margin: 0; background: none; border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent);
      border-radius: var(--radius); padding: 14px 16px; }
    /* The request card is a SECTION here, not a floating card — its shell comes off. */
    .home-frame > #home-myreq { padding: 12px 18px; }
    .home-frame > #home-myreq .yr-card { border: none; border-radius: 0; box-shadow: none; background: none; }
    /* Desktop body: the rail, a hairline, the events column. --hm-rail-w matches the
       dashboard's rail so the two screens read as one product. */
    /* 340, not the dashboard's 440: this rail holds a setup callout and four quick links,
       none of which is a work surface, and every pixel it does not need is one the events
       list can spend on titles that were wrapping. */
    .hm-body { --hm-rail-w: 340px; flex: 1 1 auto; min-height: 0; display: flex; }
    .hm-rail { flex: none; width: var(--hm-rail-w); min-width: 0; min-height: 0;
      display: flex; flex-direction: column; border-right: 1px solid var(--border); }
    .hm-main { flex: 1 1 auto; min-width: 0; min-height: 0; display: flex; flex-direction: column; }
    .hm-sechead { flex: none; display: flex; align-items: baseline; gap: 8px; padding: 12px 18px 8px; }
    .hm-main-head { border-bottom: 1px solid var(--border); padding-bottom: 10px; }
    .hm-sec-t { font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase;
      letter-spacing: .06em; color: var(--muted); }
    .hm-sec-link { margin-left: auto; background: none; border: none; cursor: pointer; font-family: inherit;
      font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--accent-dark); padding: 0; }
    .hm-sec-link:hover { text-decoration: underline; }
    /* ── HOME'S UPCOMING EVENTS IS THE AGENDA'S ROW ──────────────────────────
       Same markup, same classes (agendaEventHTML with `date: true, compact: true`), so the
       two surfaces cannot drift into two layouts again. What Home changes is chrome, not
       structure: the row is FLAT here — no card border, no radius, no shadow — because it
       sits inside Home's one card, and it leads with the shared date block because this list
       is not grouped under day headers the way the agenda is.
       The when-chip sits ABOVE the title rather than in a column beside it, which is what the
       phone agenda already does: with a date block on the left, a second fixed column for the
       time leaves the title a third of the row. */
    .hm-events .cev { border: 0; border-bottom: 1px solid var(--border); border-radius: 0;
      box-shadow: none; background: none; padding: 9px 18px; align-items: flex-start; gap: 12px; }
    .hm-events .cev:last-child { border-bottom: 0; }
    .hm-events .cev-body { display: flex; flex-direction: column; gap: 3px; }
    .hm-events .cev-time { order: -1; }
    /* The dated row is a two-column grid, and ALL THREE children are placed explicitly. Left
       or implicit placement, the date block dropped into the second row and rendered UNDER the
       when-line instead of beside it (seen on a phone: "MONDAY ALL DAY" above, "AUG 10" below
       it next to the title). The block spans both rows on the left; the when-line is the top
       right; the title and its metas are beneath the when-line. */
    .hm-events .cev-dated { display: grid; grid-template-columns: auto 1fr; align-items: start;
      row-gap: 2px; }
    .hm-events .cev-dated > .ev-date { grid-column: 1; grid-row: 1 / span 2; align-self: start; }
    .hm-events .cev-dated > .cev-time { grid-column: 2; grid-row: 1; flex: none; flex-direction: row;
      align-items: baseline; gap: 7px; padding: 0; justify-content: flex-start; }
    .hm-events .cev-dated > .cev-body { grid-column: 2; grid-row: 2; }
    .hm-events .cev-t1 { font-size: var(--fs-sm); font-weight: var(--fw-bold);
      letter-spacing: .06em; text-transform: uppercase; }
    /* The END time is agenda detail. On Home "3 PM" answers "when"; the range is one more
       thing per row on the surface with the least room, and rows are what this list trades in. */
    .hm-events .cev-t2 { display: none; }
    .hm-events .cev-title { font-size: var(--fs-lg); }
    /* The FEED's name ("The Night Sky") is on the agenda because that page is about the
       calendars; on Home it is a line of chrome per row on the surface with the least room —
       and every row usually names the same feed. Location stays: that one is about the event. */
    .hm-events .cev-cal { display: none; }
    .hm-events .cev-sub { margin-top: 2px; padding-left: 0; }
    /* The weekday leads the when-line, uppercase like the agenda's own day header. */
    .cev-dow { font-size: var(--fs-2xs); font-weight: var(--fw-bold); letter-spacing: .1em;
      text-transform: uppercase; color: var(--accent-dark); }
    /* DESKTOP: THE LIST FITS, IT DOES NOT SCROLL. hmTrimEvents drops rows from the end until
       it does, because everything above it on Home is optional and a crowded Home left this
       with 200px and a scrollbar — a little window onto a calendar that is one click away in
       this section's own head. overflow is hidden rather than auto so a row mid-trim can
       never flash a scrollbar. (The phone keeps `auto`: it has one region, no room to spend,
       and a thumb-scrolled list there is not the same imposition.) */
    .hm-events { flex: 1 1 auto; min-height: 0; overflow: hidden; }
    .hm-events .agenda-empty { padding: 24px 18px; text-align: center; }
    /* When rows were held back, say so — a list that silently ends at four of nine reads as
       "that's everything". The head's "View full calendar" is the door. */
    .hm-events-more::after { content: 'More on the full calendar →'; display: block;
      padding: 9px 18px 12px; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--accent-dark); }
    /* Quick links: 2-up in the rail, AT THEIR OWN HEIGHT. This was `flex: 1 1 auto` on the
       block and `grid-auto-rows: 1fr` on the grid — "fill the rail" — which with two links
       configured drew two tiles 700px tall, a pair of white columns with a word at the
       bottom. A tile is a tile: the rows size to content, the grid sits at the top of the
       rail, and whatever height is left over stays empty rather than being stretched into. */
    .hm-quick { flex: 0 0 auto; display: flex; flex-direction: column; }
    .hm-tiles { display: grid; grid-template-columns: 1fr 1fr; grid-auto-rows: auto;
      align-content: start; gap: 10px; padding: 0 18px 18px; }
    /* A floor, not a height — enough to read as a tile, and it grows for a two-line label. */
    .hm-tile { min-height: 92px; }
    .hm-tile { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px;
      background: var(--card); border: 1.5px solid var(--border); border-radius: var(--radius);
      cursor: pointer; font-family: inherit; color: var(--text); padding: 12px 10px;
      transition: border-color .15s, box-shadow .15s; }
    .hm-tile:hover { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); }
    .hm-tile-l { font-size: var(--fs-md); font-weight: var(--fw-semi); text-align: center; }
    .hm-tile-ico .bb-ico { width: 36px; height: 36px; border-radius: 10px; }
    .hm-tile-ico .bb-ico.lucide .bb-ico-svg { width: 19px; height: 19px; }
    .hm-tile-ico .bb-ico.gm svg, .hm-tile-ico .bb-ico.img img { width: 36px; height: 36px; }
    /* Zero configured links paints nothing at all — no empty grid, no section head. */
    .hm-tiles:empty { display: none; }
    .hm-quick:has(.hm-tiles:empty) { display: none; }
    /* Header logo image (replaces the "DCEA" text wordmark when uploaded) */
    #brand-logo img { height: 36px; width: auto; max-height: 36px; object-fit: contain; display: block; }
    /* Platform (Wilder) logo: the mark is two-tone (green tree + orange W), so on the
       green platform bar the green half disappears. It rides a WHITE ROUNDED SQUARE —
       an app-icon tile — which is what makes it read on green and keeps one treatment
       across both platform surfaces (the neutral front door and the Platform console).
       Do NOT drop the tile back to a bare img: the bar is green on purpose now. */
    .plat-logo { display: inline-flex; align-items: center; justify-content: center; width: 38px; height: 38px; padding: 4px; background: #fff; border-radius: 9px; box-shadow: 0 1px 3px rgba(0,0,0,.18); }
    #brand-logo .plat-logo img { height: 100%; width: auto; max-height: 100%; object-fit: contain; display: block; }
    /* Platform (Site Admin) header skin: the bar keeps the normal --primary treatment
       (Wilder green, light text) so the console and the neutral front door are visibly
       the same product. It used to go white here so the bare logo would pop — the
       white app-icon tile above does that job now, on green. */
    /* Footer affiliation */
    footer { display: flex; align-items: center; justify-content: center; gap: 12px; }
    .footer-logo { height: 34px; width: auto; }
    /* ── The submit hero ─────────────────────────────────────────────────────
       A REGION of the card now, not an accent block floating on the grey: a tinted icon
       well, the copy, and one primary button on the right. It used to be a solid orange
       slab that was the whole element's tap target — inside a white card that reads as a
       second page, and it took the accent budget the button now spends. */
    .submit-hero { display: flex; align-items: center; gap: 14px; padding: 14px 18px; }
    .submit-hero .sh-icon { width: 44px; height: 44px; border-radius: 12px; background: var(--accent-light);
      color: var(--accent); display: grid; place-items: center; flex-shrink: 0; }
    .submit-hero .sh-copy { min-width: 0; flex: 1 1 auto; }
    .submit-hero h3 { font-size: var(--fs-lg); font-weight: var(--fw-bold); margin-bottom: 2px; }
    .submit-hero p { font-size: var(--fs-md); color: var(--muted); margin: 0; }
    .submit-hero .sh-cta { flex: none; padding: 11px 22px; font-size: var(--fs-lg); }

    /* Generic scrollable panel */
    .panel { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); display: flex; flex-direction: column; min-height: 0; overflow: hidden; box-shadow: var(--shadow); }
    /* Left padding + gap match .ql-btn so the icon and text line up vertically
       with the dashboard quick-link buttons stacked directly above. */
    .panel-hdr { padding: 13px 14px; border-bottom: 1px solid var(--border); display: flex; align-items: center; gap: 10px; font-size: var(--fs-lg); font-weight: var(--fw-bold); flex-shrink: 0; }
    .panel-hdr .ic { font-size: var(--fs-xl); }   /* match the .ql-ico size on dashboard quick-link buttons */
    .panel-hdr .ph-sub { margin-left: auto; font-size: var(--fs-sm); font-weight: var(--fw-medium); color: var(--muted); }
    .panel-body { flex: 1; min-height: 0; overflow-y: auto; }
    .panel-foot { padding: 9px 18px; border-top: 1px solid var(--border); font-size: var(--fs-sm); color: var(--muted); flex-shrink: 0; }
    /* (.events-panel / .cal-block / .home-bottom are gone with the Home redesign: the
       events list is the frame's own scroller (.hm-events) and the shortcut tiles are the
       rail's 2×2 grid / the phone's pinned footer row (.hm-tiles). The measured
       "how many rows fit" logic went with them — see fitEvents in app.js.) */

    /* ── "Your request" strip ────────────────────────────────────────────────
       Reuses `.panel` — the app's card shell — and adds only what is specific to it.
       The pipeline's CURRENT step is the one accent element on the card. */
    .yr-card { flex: none; }
    .yr-hd { display: flex; align-items: center; gap: 10px; padding: 10px 14px; border-bottom: 1px solid var(--border); }
    .yr-eyebrow { font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .06em;
      text-transform: uppercase; color: var(--muted); }
    .yr-hd-r { margin-left: auto; font-size: var(--fs-xs); color: var(--muted); }
    /* The collapsed strip (setup card is up) — one tappable row, no header rule, because
       there is no body under it for the rule to separate. Sized to read as a nav row like
       the Member Directory link directly below it, not as a card with its contents missing. */
    .yr-mini .yr-mini-row { display: flex; align-items: center; gap: 10px; width: 100%;
      padding: 13px 14px; background: none; border: 0; font-family: inherit; text-align: left;
      color: var(--text); cursor: pointer; }
    .yr-mini .yr-mini-row:active { background: var(--accent-light); }
    .yr-mini-n { margin-left: auto; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--muted); }
    .yr-mini .yr-chev { color: var(--muted); }
    .yr-body { display: flex; gap: 14px; padding: 13px 14px 12px; }
    .yr-rail { flex: none; width: 3px; align-self: stretch; border-radius: 2px; background: var(--accent); }
    .yr-main { flex: 1 1 auto; min-width: 0; }
    .yr-top { display: flex; align-items: center; gap: 8px; }
    /* The chip's COLOUR is the state — read it without reading it. */
    .yr-chip { font-size: var(--fs-2xs); font-weight: var(--fw-bold); letter-spacing: .06em; text-transform: uppercase;
      border-radius: 5px; padding: 3px 7px; }
    .yr-chip-neutral { background: var(--neutral-bg); color: var(--neutral); }
    .yr-chip-accent { background: var(--accent); color: var(--on-accent); }
    .yr-chip-ok { background: var(--ok, #15803d); color: #fff; }
    .yr-cat { font-size: var(--fs-sm); color: var(--muted); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .yr-subj { font-size: var(--fs-xl); font-weight: var(--fw-semi); line-height: var(--lh-snug); margin: 7px 0 3px; }
    .yr-move { font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-snug); }
    .yr-open { cursor: pointer; }
    .yr-open:hover .yr-row-t, .yr-subj.yr-open:hover { text-decoration: underline; }
    .yr-open:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 6px; }
    /* Pipeline — four steps, dots joined by connectors. */
    .yr-pipe { display: flex; margin-top: 11px; }
    .yr-step { flex: 1 1 0; min-width: 0; display: flex; flex-direction: column; gap: 6px; }
    .yr-step:last-child { flex: none; width: 74px; }
    .yr-marker { display: flex; align-items: center; gap: 0; }
    .yr-dot { flex: none; width: 14px; height: 14px; border-radius: 50%; display: grid; place-items: center;
      background: var(--card); border: 2px solid var(--border); box-sizing: border-box; }
    .yr-bar { flex: 1 1 auto; height: 3px; background: var(--neutral-bg); }
    .yr-lbl { font-size: var(--fs-xs); color: var(--muted); line-height: var(--lh-tight); }
    .yr-step-done .yr-dot { background: var(--ok, #15803d); border-color: var(--ok, #15803d); }
    .yr-step-done .yr-bar { background: var(--ok, #15803d); }
    .yr-check { color: #fff; font-size: calc(9px * var(--fs-scale, 1)); line-height: var(--lh-none); font-weight: var(--fw-bold); }
    .yr-step-now .yr-dot { background: var(--accent); border-color: var(--accent);
      box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 18%, transparent); }
    /* --accent-dark, not --accent: bold at this size on white, #ff6600 fails AA. */
    .yr-step-now .yr-lbl { color: var(--accent-dark); font-weight: var(--fw-bold); }
    .yr-acts { flex: none; display: flex; flex-direction: column; gap: 8px; justify-content: center; }
    .yr-acts .ld-btn { padding: 11px 16px; font-size: var(--fs-md); }
    /* Trailing chevron: this button LEAVES Home for the request, so it says so. Sized off
       the label rather than the type ladder so it tracks whatever the button is set in,
       and nudged down a hair because the glyph sits high in its em box. */
    .yr-chev { margin-left: 6px; font-size: 1.15em; line-height: 0; position: relative; top: 1px; }
    /* FINISHED. The accent rail means "in motion" — on a closed case it is the card still
       asking for attention it no longer needs, so the whole edge goes green with the chip
       and the four done steps. This is the state that now waits for Got It. */
    .yr-done .yr-rail { background: var(--ok, #15803d); }
    /* More than one live request: a compact list, no pipeline — but each row keeps the
       card's vocabulary. Two lines: the title with the state chip on the right edge (the
       colour IS the state, same chip as the single card), then a muted movement line —
       category · who holds it and since when. The old one-line rows (dot · title · word)
       left the middle of a wide row empty and never named a person or a moment. */
    .yr-row { display: flex; align-items: center; gap: 12px; padding: 12px 14px; border-bottom: 1px solid var(--border); }
    .yr-row-main { flex: 1 1 auto; min-width: 0; display: grid; gap: 3px; }
    .yr-row-l1 { display: flex; align-items: center; gap: 10px; min-width: 0; }
    .yr-row-l1 .yr-chip { flex: none; white-space: nowrap; }
    .yr-row-t { flex: 1 1 auto; min-width: 0; font-size: var(--fs-lg); font-weight: var(--fw-semi);
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .yr-row-m { font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-snug);
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    /* Dismiss, on a finished row in the list. Quiet until you go near it: it sits inside a
       row that is itself a button to open the request, so it must not read as the row's
       call to action. */
    .yr-x { position: relative; flex: none; width: 26px; height: 26px; margin: -4px -4px -4px 0; padding: 0; border: 0;
      border-radius: 6px; background: none; color: var(--muted); font-size: var(--fs-sm);
      line-height: var(--lh-none); cursor: pointer; display: grid; place-items: center; }
    .yr-x:hover { background: var(--neutral-bg); color: var(--text); }
    .yr-x:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
    .yr-list-foot { display: flex; align-items: center; gap: 12px; padding: 10px 14px; }
    .yr-foot-more { font-size: var(--fs-xs); color: var(--muted); }
    /* No shortcuts configured → the row is empty: collapse it so its flex gap
       doesn't leave the calendar shy of the column bottom (calendar fills + aligns). */
    #home-shortcuts:empty { display: none; }
    /* (The .notif-nudge tile that used to live here is gone — notifications are asked for
       in exactly one place on Home now, the .setup-card at the top of the column.) */
    /* ── Home announcement banner (Site Config → Announcements) ── */
    .home-announce { display: flex; align-items: center; gap: 13px; padding: 13px 16px; border-radius: var(--radius); border: 1px solid var(--accent); background: var(--accent-light); color: var(--text); margin-bottom: 12px; box-shadow: var(--shadow); transition: box-shadow .15s, border-color .15s; }
    .home-announce.hidden { display: none; }
    .home-announce.ha-warn { border-color: var(--warn); background: var(--warn-bg); }
    .home-announce.ha-urgent { border-color: var(--danger); background: var(--danger-bg); }
    .home-announce .ha-ico { width: 40px; height: 40px; border-radius: 10px; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; flex-shrink: 0; }
    .home-announce .ha-ico .lic { width: 20px; height: 20px; }
    .home-announce.ha-warn .ha-ico { background: var(--warn); color: #fff; }
    .home-announce.ha-urgent .ha-ico { background: var(--danger); color: #fff; }
    .home-announce .ha-msg { flex: 1 1 auto; min-width: 0; font-size: var(--fs-lg); font-weight: var(--fw-semi); line-height: var(--lh-body); }
    .home-announce .ha-cta { flex-shrink: 0; padding: 7px 14px; border-radius: 9px; font-size: var(--fs-md); font-weight: var(--fw-bold); text-decoration: none; background: var(--accent); color: var(--on-accent); white-space: nowrap; }
    .home-announce.ha-warn .ha-cta { background: var(--warn); color: #fff; }
    .home-announce.ha-urgent .ha-cta { background: var(--danger); color: #fff; }
    .home-announce .ha-x { flex-shrink: 0; background: transparent; border: 0; color: inherit; opacity: .55; font-size: var(--fs-2xl); line-height: var(--lh-none); cursor: pointer; padding: 0 2px; }
    .home-announce .ha-x:hover { opacity: 1; }
    @media (max-width: 560px) { .home-announce .ha-cta { padding: 6px 10px; } }
    /* ── Announcements manager (Site Config panel) ─────────────────────────────
       Three zones: what is LIVE on Home right now (the SAVED record), the
       editable list (its own scroller, so the head above it and the Save footer
       below it never leave the screen no matter how many a local keeps), and the
       pinned Save footer.

       The zones exist because of one specific confusion: the old toggle flipped
       to "✓ Showing on Home" the instant you clicked it. That reads as DONE —
       the change is announced in the past tense — so nothing on screen ever
       asked you to save, and the banner silently never went up. So: the strip at
       the top only ever shows the saved truth, and every unsaved change names
       when it takes effect ("Goes Live on Save") instead of claiming it already
       has. Same rule as everywhere else in the app — the screen may not describe
       a state the database isn't in. */
    /* .prev-stage carries the recessed fill and border now — the same surface the
       Branding previews use. Only the spacing below it is local. */
    .ann-live { margin-bottom: 14px; }
    /* The stage is ONE SIZE, always. It doesn't grow when you expand an announcement and
       it doesn't jump when LIVE becomes PREVIEW — the pending line underneath the banner
       is rendered in every state and merely hidden when it has nothing to say, so it is
       its own height reservation (an invisible copy of the real element is exactly the
       right height by construction; a hand-written min-height was out by 12px). */
    .ann-pend.ann-pend-empty { visibility: hidden; }
    /* The switch gets a breath rather than a blink. It is THE TAG that breathes — the word
       that changed — and nothing else: pulsing the whole stage moves the banner you are
       trying to read, which is the opposite of pointing at what changed. The class still
       lands on the stage (that's the element annStageSync holds); only the pill animates.
       Fires only when the tag word actually changes, never on every keystroke. */
    @keyframes annBreath {
      0%   { opacity: .35; transform: scale(.92); }
      55%  { opacity: 1;   transform: scale(1.06); }
      100% { opacity: 1;   transform: scale(1); }
    }
    .ann-live.ann-breath .prev-tag { animation: annBreath .42s cubic-bezier(.22,.9,.3,1); }
    @media (prefers-reduced-motion: reduce) { .ann-live.ann-breath .prev-tag { animation: none; } }
    .ann-live-head { gap: 8px; }
    .ann-live-lbl { font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
    /* A live dot, not a checkmark: this is a status ("it is up"), not a receipt. */
    .ann-live-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; background: var(--ok); box-shadow: 0 0 0 3px var(--ok-bg); }
    .ann-live-dot.off { background: var(--muted); box-shadow: 0 0 0 3px var(--neutral-bg); }
    .ann-live-none { margin: 0; font-size: var(--fs-md); color: var(--muted); font-style: italic; }
    .ann-live .home-announce.ann-prev { margin: 0; }
    /* The pending line — the one place that says what Save will actually do. */
    /* Tight, because its space is held open in every state (see annPendHTML) — a gutter
       the stage always carries has to be the size of the line, not a band. --lh-snug and
       4px of padding, not --lh-body and 8px: at running-text spacing the reserved strip
       read as a chunk of empty stage rather than a slot waiting for a sentence. */
    .ann-pend { display: flex; align-items: center; gap: 7px; margin-top: 7px; padding: 4px 9px; border-radius: 7px; background: var(--warn-bg); color: var(--warn); font-size: var(--fs-sm); font-weight: var(--fw-semi); line-height: var(--lh-snug); }
    .ann-pend .lic { width: 14px; height: 14px; flex: none; }
    .ann-pend b { font-weight: var(--fw-bold); }
    /* (.ann-listhead / -t lived here — a "YOUR ANNOUNCEMENTS" divider label between the
       stage and the list. The count and "+ Add" had already moved up into the panel's
       .page-bar, leaving it naming a list nothing could mistake for anything else.) */
    /* No scroller of its own — the panel's .page-scroll is THE scroller, and a
       second one nested inside it gives you two bars and a list that stops moving
       while the page still has room. (.is-tall is still set by renderAnnList; it
       simply has nothing to do here any more.) */
    .ann-scroll { display: flex; flex-direction: column; gap: 8px; }
    .ann-item { position: relative; border: 1px solid var(--border); border-radius: 8px; background: var(--card); overflow: hidden; transition: border-color .14s, box-shadow .14s; }
    /* Severity reads off a DOT in the row — it used to be a 3px spine down the left
       edge, which on an OPEN card ran the full height of the editor and read as an
       error state on everything inside it (and, against the rounded corner, as a
       rendering artifact). A dot says the same thing at the size the fact deserves,
       and it's the device this panel already uses for the live indicator. */
    .ann-rowc-dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--accent); }
    .ann-item.sev-warn .ann-rowc-dot { background: var(--warn); }
    .ann-item.sev-urgent .ann-rowc-dot { background: var(--danger); }
    .ann-item:hover { border-color: #c9ccd2; }
    /* Live is a STATUS, so it keeps the accent frame. Open is not — it used to take the
       same accent border, which wrapped the whole editor in a colour that meant nothing
       about the announcement. The recessed title bar and the lift say "open" on their
       own, and colour is left to say only what it's for. */
    .ann-item.st-live { border-color: var(--accent); background: var(--accent-light); }
    /* …and once it IS open, even live drops the frame: the row's own "On Home" pill is
       right there saying so, and an accent border around a form full of accent controls
       is one more orange edge that isn't telling you anything you can't already see. */
    .ann-item.open, .ann-item.st-live.open { border-color: #c9ccd2; box-shadow: 0 4px 16px rgba(16,24,40,.07); }
    .ann-item.open { background: var(--card); }
    .ann-item-row { display: flex; align-items: center; gap: 6px; padding-right: 8px; }
    /* Open, the row is the editor's title bar: the recessed fill separates the thing
       being edited from the fields editing it, which a bare hairline could not. */
    .ann-item.open .ann-item-row { background: var(--bg); }
    .ann-rowc-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; padding: 11px 12px 11px 14px; border: 0; background: none; font: inherit; text-align: left; cursor: pointer; color: var(--text); }
    .ann-rowc-txt { flex: 1; min-width: 0; font-size: var(--fs-lg); font-weight: var(--fw-semi); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .ann-rowc-empty { color: var(--muted); font-style: italic; font-weight: var(--fw-body); }
    .ann-rowc-chev { flex: none; color: var(--muted); font-size: var(--fs-xl); line-height: var(--lh-none); transform: rotate(90deg); transition: transform .16s ease; }
    .ann-item.open .ann-rowc-chev { transform: rotate(-90deg); }
    /* One control, four honest states. "On Home" is the only one written in the
       present tense, and it's the only one that is true of the saved record. */
    .ann-live-btn { flex: none; display: inline-flex; align-items: center; gap: 6px; font-family: inherit; font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .02em; padding: 5px 11px; border-radius: 999px; border: 1px solid var(--border); background: var(--card); color: var(--muted); cursor: pointer; white-space: nowrap; transition: border-color .14s, background .14s, color .14s; }
    .ann-live-btn:hover { border-color: var(--accent); color: var(--accent-dark); }
    .ann-live-btn.st-live { background: var(--accent); border-color: var(--accent); color: var(--on-accent); }
    .ann-live-btn.st-live:hover { color: var(--on-accent); }
    .ann-live-btn.st-on-pending { background: var(--warn-bg); border-color: var(--warn); color: var(--warn); border-style: dashed; }
    .ann-live-btn.st-off-pending { background: var(--warn-bg); border-color: var(--warn); color: var(--warn); border-style: dashed; text-decoration: line-through; text-decoration-thickness: 1px; }
    .ann-live-btn.st-on-pending:hover, .ann-live-btn.st-off-pending:hover { border-color: var(--warn); color: var(--warn); }
    /* (.ann-del lived here — a fifth delete button, 17px glyph, borderless, its own
       hover. Announcements uses the shared cfgDelBtn now. The ROW stays a bordered
       card on purpose: it expands in place into a full editor, and the border is what
       says the editor belongs to that announcement. A flat hairline row can't contain
       anything, which is why this is the one list the row standard doesn't flatten —
       its controls match, its shape can't.) */
    .ann-item-row .blk-btn.del { flex: none; margin-right: 8px; }
    /* The open editor, inside the same item — expanding a row must not swap it for
       a differently-shaped card, or the list visibly reflows under the pointer. */
    /* THE EDITOR — three blocks, in the order you actually write one: the words, then
       how they look and where they go, then what members will see.
         MESSAGE          (full width — it is the announcement)
         STYLE | BUTTON   (two columns; both are short, so stacking them wasted a
                           screen of height and left a 1,000px void beside each)
         ☐ dismiss        (one quiet chip, left-aligned, next to the preview whose
                           × it controls — it used to be flung to the far right wall
                           of the Style row, unlabelled, with the largest hit-target
                           and the loudest fill in the panel on the least important
                           control here)
         PREVIEW */
    .ann-body { border-top: 1px solid var(--border); padding: 13px 14px 14px; display: flex; flex-direction: column; gap: 11px; }
    .ann-field { display: flex; flex-direction: column; gap: 5px; min-width: 0; }
    .ann-lbl { font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
    .ann-cols { display: grid; grid-template-columns: minmax(230px, .8fr) minmax(300px, 1.2fr); gap: 11px 18px; align-items: start; }
    /* Icon: glyph-only chips in one wrapping row. Square, so the row reads as a strip of
       pictures rather than 18 little buttons; `flex: none` because .opt-pick stretches to
       fill by default and would smear them across the panel at any width. Same .opt-pick
       selected treatment as the Style chips — the two rows are the same kind of question,
       so they must not look like two different mechanisms. */
    .ann-icos { display: flex; flex-wrap: wrap; gap: 5px; }
    .ann-ico-pick { flex: none; padding: 6px; line-height: 0; color: var(--muted); }
    .ann-ico-pick .lic { width: 17px; height: 17px; display: block; }
    .ann-ico-pick.on { color: var(--accent-dark); }
    /* Severity: pick-one chips in the app's shared .opt-pick shape. */
    .ann-seg { display: flex; gap: 5px; flex-wrap: wrap; }
    .ann-sev-pick { flex: 1 1 auto; }
    .ann-sev-pick .opt-pick-name { display: inline-flex; align-items: center; gap: 6px; }
    .ann-seg-dot { width: 7px; height: 7px; border-radius: 50%; flex: none; }
    .ann-seg-info .ann-seg-dot { background: var(--accent); }
    .ann-seg-warn .ann-seg-dot { background: var(--warn); }
    .ann-seg-urgent .ann-seg-dot { background: var(--danger); }
    /* The chosen chip wears the colour it SETS. .opt-pick.on paints every selection
       accent-orange, so picking Urgent lit an ORANGE chip with a red dot — the control
       showing one colour while setting another. Three classes deep to out-rank it
       wherever the two blocks sit in the file; specificity, not source order. */
    .ann-seg-info.opt-pick.on { border-color: var(--accent); background: var(--accent-light); }
    .ann-seg-warn.opt-pick.on { border-color: var(--warn); background: var(--warn-bg); }
    .ann-seg-urgent.opt-pick.on { border-color: var(--danger); background: var(--danger-bg); }
    .ann-seg-warn.opt-pick.on .opt-pick-name { color: var(--warn); }
    .ann-seg-urgent.opt-pick.on .opt-pick-name { color: var(--danger); }
    /* One input treatment for the three fields, with the app's standard focus ring
       (border + 3px accent wash) — they each carried their own padding, radius and a
       bare border-colour focus before. */
    .ann-msg-in, .ann-cta-row input { width: 100%; box-sizing: border-box; min-width: 0; padding: 9px 11px; border: 1px solid var(--border); border-radius: 7px; font-family: inherit; font-size: var(--fs-md); line-height: var(--lh-body); background: var(--card); color: var(--text); }
    .ann-msg-in { font-size: var(--fs-lg); min-height: 60px; resize: vertical; }
    .ann-msg-in:focus, .ann-cta-row input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    .ann-cta-row { display: flex; gap: 7px; }
    .ann-cta-row .ann-ctalabel-in { flex: 1 1 38%; }
    .ann-cta-row .ann-ctaurl-in { flex: 1 1 62%; }
    .ann-dis { align-self: flex-start; border-radius: 7px; }
    /* A 16px system checkbox beside 13px text was the biggest mark in the editor.
       Two classes deep because .opt-chip input sets width:auto and would otherwise
       win on source order. */
    .opt-chip.ann-dis input { width: 14px; height: 14px; accent-color: var(--accent); }
    /* (.ann-prev-wrap / .ann-prev-lbl lived here — the second stage, inside every open
       editor. There is one stage now, at the top of the panel; see annLiveStripHTML.) */
    .home-announce.ann-prev { margin: 0; box-shadow: none; }
    .ann-prev .ha-cta, .ann-prev .ha-x { cursor: default; }
    .ann-prev.ann-prev-empty .ha-msg { font-weight: var(--fw-body); font-style: italic; opacity: .7; }
    .ann-empty { text-align: center; padding: 30px 20px; border: 1px dashed var(--border); border-radius: 8px; color: var(--muted); }
    .ann-empty .lic { width: 26px; height: 26px; opacity: .55; }
    .ann-empty-t { margin: 8px 0 4px; font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--text); }
    .ann-empty-d { margin: 0 auto; max-width: 44ch; font-size: var(--fs-md); line-height: var(--lh-body); }
    .page-foot-inline { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin-top: 14px; }
    @media (max-width: 900px) { .ann-cols { grid-template-columns: 1fr; } }
    @media (max-width: 560px) { .ann-cta-row { flex-direction: column; } .ann-cta-row .ann-ctalabel-in { flex-basis: auto; } }
    .big-btn { display: flex; align-items: center; gap: 13px; padding: 14px 18px; border-radius: var(--radius); border: 1px solid var(--border); background: var(--card); cursor: pointer; font-family: inherit; text-align: left; text-decoration: none; color: var(--text); box-shadow: var(--shadow); transition: border-color .15s, box-shadow .15s; }
    .big-btn:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(var(--accent-rgb),.1); }
    .big-btn .bb-ico { width: 40px; height: 40px; border-radius: 10px; background: var(--accent-light); color: var(--accent); display: grid; place-items: center; font-size: var(--fs-2xl); flex-shrink: 0; }
    .big-btn h4 { font-size: var(--fs-lg); font-weight: var(--fw-bold); margin-bottom: 1px; }
    .big-btn p { font-size: var(--fs-sm); color: var(--muted); margin: 0; }
    .big-btn.store .bb-ico { background: #0f172a; color: #fff; }
    .big-btn .bb-ico.gm { background: none; padding: 0; }
    .big-btn .bb-ico.gm svg { display: block; width: 40px; height: 40px; }
    .big-btn .bb-ico.lucide .bb-ico-svg { width: 24px; height: 24px; display: block; }
    .big-btn .bb-ico.img { background: none; padding: 0; overflow: hidden; }
    .big-btn .bb-ico.img img { width: 40px; height: 40px; object-fit: cover; border-radius: 10px; display: block; }

    /* Contract search — the message list and its bubbles. (Home's always-on
       `.chatbot-rail` is gone with the Home redesign: the Contract destination has the
       same search as a full screen, and a permanent sidebar asking a question was the
       widest thing on Home doing the least. `.cbr-msgs` stays — the full Contract panel
       wears the class, see `.cv-search .cbr-msgs`.) */
    .cbr-msgs { flex: 1; min-height: 0; overflow-y: auto; padding: 14px; display: flex; flex-direction: column; gap: 10px; }
    .cb-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; font-size: var(--fs-md); line-height: var(--lh-body); }
    .cb-msg.bot { background: #f3f4f6; color: var(--text); border-bottom-left-radius: 4px; align-self: flex-start; }
    .cb-msg.user { background: var(--accent); color: var(--on-accent); border-bottom-right-radius: 4px; align-self: flex-end; }
    /* ── The "thinking" bubble ───────────────────────────────────────────────
       Three dots in a bot bubble, not the words "Searching the contract…" —
       text implies a machine working, dots imply someone answering, and this
       screen is deliberately a conversation. It is deliberately timed, too:
       see CB_BEAT / CB_THINK in app.js for why a search this fast is slowed
       down. The label stays for screen readers, which get no signal from a
       dot that moves. */
    .cb-msg.cb-typing { display: flex; align-items: center; gap: 4px; padding: 12px 14px; }
    .cb-typing i { width: 6px; height: 6px; border-radius: 50%; background: var(--muted); animation: cbType 1.2s ease-in-out infinite; }
    .cb-typing i:nth-child(2) { animation-delay: .16s; }
    .cb-typing i:nth-child(3) { animation-delay: .32s; }
    @keyframes cbType { 0%, 62%, 100% { opacity: .3; transform: translateY(0); } 30% { opacity: .95; transform: translateY(-3px); } }
    /* Static dots still read as "waiting" — the bubble arriving and leaving is
       itself the signal, so the motion is the only thing that has to go. */
    @media (prefers-reduced-motion: reduce) { .cb-typing i { animation: none; opacity: .55; } }
    /* Every bubble arrives rather than appearing — same reason as the pacing. */
    .cb-msg { animation: cbIn .18s ease-out; }
    @keyframes cbIn { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
    @media (prefers-reduced-motion: reduce) { .cb-msg { animation: none; } }
    /* ── A contract answer: a list of doorways ──────────────────────────────
       Each hit is one tappable row — the clause's own reference, the wording that
       matched, and a chevron. It reads as a result list because tapping it navigates:
       to the clause, inside the document, where the contract's real typography and
       the paragraphs either side of it are. This replaced printing the whole top
       section into the bubble and hiding the rest behind expanders, which answered
       the question twice, worse the first time. (No expanders left on purpose —
       "expand here" and "open the document" were two doors to the same room.) */
    .cb-msg.cb-answer { max-width: 96%; padding: 0; background: none; }
    .cb-lede { margin: 2px 2px 8px; font-size: var(--fs-md); color: var(--text); }
    .cb-hits { display: flex; flex-direction: column; gap: 8px; }
    .cb-ref { display: inline-block; background: var(--accent); color: var(--on-accent); font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .3px; text-transform: uppercase; padding: 3px 9px; border-radius: 5px; white-space: nowrap; }
    .cb-hit { display: block; width: 100%; text-align: left; background: var(--card); border: 1px solid var(--border); border-radius: 11px; padding: 11px 13px; font: inherit; color: var(--text); cursor: pointer; transition: border-color .12s, box-shadow .12s; }
    .cb-hit:hover { border-color: var(--accent); box-shadow: var(--shadow); }
    .cb-hit:active { background: var(--accent-light); }
    /* A payload with no idx has nowhere to go, so cbHitRow renders a div instead of a
       button. Strip the affordances too — a row that lifts on hover and shows a chevron
       is promising a tap it can't honour. */
    div.cb-hit { cursor: default; }
    div.cb-hit:hover { border-color: var(--border); box-shadow: none; }
    div.cb-hit .cb-hit-go { display: none; }
    .cb-hit-top { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 8px; }
    .cb-hit-title { flex: 1; min-width: 0; font-size: var(--fs-lg); font-weight: var(--fw-bold); line-height: var(--lh-snug); }
    .cb-hit-go { flex-shrink: 0; margin-left: auto; color: var(--accent); font-size: var(--fs-2xl); line-height: var(--lh-none); }
    /* The drill-down: which clause INSIDE the article, in the document's own numbering
       ("5.1 › (b)"). Rendered only when we actually found one — an invented citation
       in a contract is worse than none, so cbLocate stays silent when it can't tell. */
    .cb-hit-path { display: block; margin-top: 5px; font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--accent-dark); font-variant-numeric: tabular-nums; }
    .cb-hit-snip { display: block; margin-top: 5px; font-size: var(--fs-sm); line-height: var(--lh-body); color: var(--muted); }
    .cb-hit-snip mark { background: rgba(var(--accent-rgb), .2); color: var(--text); border-radius: 3px; padding: 0 1px; }
    .cb-msg .cb-foot { margin-top: 12px; padding: 0 2px; font-size: var(--fs-xs); color: var(--muted); }
    .cbr-input { border-top: 1px solid var(--border); padding: 10px; display: flex; gap: 8px; flex-shrink: 0; }
    .cbr-input input { flex: 1; border: 1px solid var(--border); border-radius: 9px; padding: 9px 11px; font-size: var(--fs-md); font-family: inherit; background: #fff; color: var(--text); }
    .cbr-input input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    .cbr-input button { background: var(--accent); color: var(--on-accent); border: none; border-radius: 9px; width: 40px; cursor: pointer; font-size: var(--fs-lg); flex-shrink: 0; }
    .cbr-input button:hover { background: var(--accent-hover); }

    /* Leadership & reps directory */
    .leader-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 16px; }
    .leader-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; box-shadow: var(--shadow); display: flex; flex-direction: column; transition: box-shadow .15s, transform .08s; }
    .leader-card:hover { box-shadow: 0 8px 22px rgba(16,24,40,.12); transform: translateY(-2px); }
    .lc-photo { width: 100%; aspect-ratio: 1 / 1; background: var(--accent-light); }
    .lc-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
    .lc-photo-init { display: grid; place-items: center; color: var(--accent-dark); font-weight: var(--fw-display); font-size: calc(56px * var(--fs-scale, 1)); }
    .lc-body { padding: 14px 16px 16px; }
    .leader-card h4 { font-size: var(--fs-xl); font-weight: var(--fw-bold); margin-bottom: 2px; }
    .leader-card .lc-role { font-size: var(--fs-xs); font-weight: var(--fw-bold); color: var(--accent-dark); text-transform: uppercase; letter-spacing: .4px; }
    .leader-card .lc-email { font-size: var(--fs-sm); color: var(--muted); margin-top: 4px; word-break: break-all; }

    /* Inline link-style button */
    .link-btn { background: none; border: none; font-family: inherit; cursor: pointer; color: var(--accent); font-weight: var(--fw-semi); padding: 0; }
    .link-btn:hover { text-decoration: underline; }

    /* Calendar (full view) */
    .cal-wrap { flex: 1; min-height: 0; display: flex; flex-direction: column; width: 100%; }
    /* The band sits on the page gutter (the wrap has no side padding of its own). The
       TOP rail is not set here: on desktop the wrap takes `padding-top: var(--page-top)`
       with every other page column (see THE CARD BOX), and the phone rule below carries
       the mobile rhythm. This used to hardcode `margin: 26px 22px 0` — a pre-token
       number that survived --page-top's arrival and left Calendar's card starting 4px
       lower than every other screen's. */
    /* The Contract section's chat composer. (The .chat-wrap/#cbrc-msgs rules that used
       to live here were the removed full-page chat's; the composer bar outlived it.) */
    .cbrc-form { flex-shrink: 0; display: flex; gap: 8px; border-top: 1px solid var(--border); padding: 12px var(--pad-card); }
    .cbrc-form input { flex: 1; border: 1px solid var(--border); border-radius: 9px; padding: 10px 12px; font-size: var(--fs-lg); font-family: inherit; background: #fff; color: var(--text); }
    .cbrc-form input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    .cbrc-form button { background: var(--accent); color: var(--on-accent); border: none; border-radius: 9px; width: 44px; cursor: pointer; font-size: var(--fs-xl); flex-shrink: 0; }
    .cbrc-form button:hover { background: var(--accent-hover); }
    .cal-frame-wrap { flex: 1; min-height: 0; padding: 14px var(--pad-card) 18px; }
    .cal-frame-wrap iframe { width: 100%; height: 100%; border: 1px solid var(--border); border-radius: var(--radius); background: #fff; }
    /* Custom mobile agenda — grouped by day with a detail sub-row per event */
    .cal-agenda { display: flex; flex-direction: column; gap: 20px; }
    .cday-hdr { display: flex; align-items: center; gap: 12px; margin: 0 2px 10px; }
    /* ── THE EVENT DATE BLOCK — one object, every calendar surface ────────────
       A month strip in the local's --primary with the day numeral on the card below
       it: the little wall calendar. It is THE date block for anything showing a
       calendar date — the dashboard's pinned footer, Home's Upcoming Events, and the
       agenda's day headers all wear this one class.
       WHY IT IS PRIMARY AND NOT ACCENT: --accent is for action and urgency (see THE
       COLOUR RULE), and a date is neither. Three surfaces had drifted into three
       different blocks — an accent-tinted pill here, a grey one on Home, this one in
       the footer — so the same fact wore three colours depending on where you read it.
       Local branding is what makes it recognisably yours, so it follows --primary.
       THE STRIP IS A TINT OF IT, NOT A SLAB — settled, twice, and this is the second time so
       it is worth writing down. A 44px tile filled with --primary is the most saturated
       object on a screen whose whole job is to be quiet, and it sits a few pixels under a
       header painted in that same colour, so it competes with the header and reads as a
       sticker. The month is a LABEL; the numeral is the thing you read. So the strip wears
       the derived band tokens the section heads already use (--band / --band-ink /
       --band-rail, one hue off the tenant's own seed): unmistakably the local's colour, at a
       tenth of the weight. I restored the solid fill once, reading "the half blue half white
       of my local" as a request for the slab; it was a request for the little wall calendar,
       which this is. Don't put the slab back. */
    .ev-date { flex: none; width: 44px; border: 1px solid var(--band-rail); border-radius: 9px; overflow: hidden;
      background: var(--card); display: flex; flex-direction: column; align-items: center; text-align: center; }
    .ev-date-m { display: block; width: 100%; background: var(--band); color: var(--band-ink);
      border-bottom: 1px solid var(--band-rail);
      font-size: var(--fs-2xs); font-weight: var(--fw-semi); letter-spacing: .08em; text-transform: uppercase; padding: 2px 0 1px; }
    /* Tabular figures: the numeral is a fixed-width column three surfaces stack vertically,
       and proportional digits make a 1 sit visibly off-centre against an 8 in the row above. */
    .ev-date-d { display: block; font-size: var(--fs-2xl); font-weight: var(--fw-bold); color: var(--text);
      font-variant-numeric: tabular-nums; letter-spacing: -.01em;
      line-height: var(--lh-tight); padding: 1px 0 3px; }
    /* Today is MARKED, not floodlit — the accent tint and accent ink, the same move the strip
       makes for --primary. A solid accent fill made every list read as one urgent row and the
       rest as filler. */
    .cday.is-today .ev-date { border-color: rgba(var(--accent-rgb), .40); }
    .cday.is-today .ev-date-m { background: var(--accent-light); color: var(--accent-dark);
      border-bottom-color: rgba(var(--accent-rgb), .28); }
    /* The day name is the header's second half, not a watermark. It sat at .32 opacity —
       a pale pink "MONDAY" measured well under any contrast floor, decorative rather than
       readable. .55 keeps it quieter than the date block beside it (which is the thing you
       scan for) while still being text a person can read. */
    .cday-dow { flex: 1; text-align: right; font-size: var(--fs-xl); font-weight: var(--fw-display); color: var(--accent-dark); opacity: .55; letter-spacing: .08em; }
    .cday-dow-today { opacity: 1; color: var(--accent); }
    .cday-dow-tmrw { opacity: .4; }
    /* (Today's fill is declared with .ev-date above, so the shared block owns both states.) */
    .cday-events { display: flex; flex-direction: column; gap: 8px; }
    /* The time column reads as a LABEL ON THE TITLE, so it aligns with the title —
       never centred against the body. Centring was invisible while an event was a
       four-line card; once the body could run 400px (description + RSVP + tools) it
       floated the start time into the middle of the row, 186px under the thing it
       names. */
    .cev { display: flex; align-items: flex-start; gap: 13px; background: var(--card); border: 1px solid var(--border); border-left: 3px solid var(--accent); border-radius: 13px; padding: 12px 14px; box-shadow: var(--shadow); }
    .cev-time { flex: 0 0 60px; display: flex; flex-direction: column; align-items: center; gap: 1px; padding-top: 2px; }
    .cev-t1 { font-size: var(--fs-md); font-weight: var(--fw-display); color: var(--accent-dark); white-space: nowrap; }
    /* Not a time — a tag. Sized and coloured so it can't be mistaken for a start time. */
    .cev-allday { font-size: var(--fs-2xs); font-weight: var(--fw-bold); line-height: var(--lh-tight); text-transform: uppercase; letter-spacing: .04em; white-space: nowrap; color: var(--accent-dark); background: var(--accent-light); border-radius: 6px; padding: 4px 6px; }
    .cev-t2 { font-size: var(--fs-xs); color: var(--muted); white-space: nowrap; }
    .cev-body { flex: 1; min-width: 0; }
    .cev-title { font-size: var(--fs-lg); font-weight: var(--fw-bold); line-height: var(--lh-snug); }
    /* Sub-items (location, calendar source, details) are indented so they read as children of the event title. */
    .cev-sub { display: flex; flex-direction: column; align-items: flex-start; gap: 3px; margin-top: 5px; padding-left: 9px; }
    .cev-meta { font-size: var(--fs-sm); color: var(--muted); display: inline-flex; align-items: center; gap: 7px; min-width: 0; max-width: 100%; }
    .cev-meta .lic { flex-shrink: 0; }
    /* All agenda sub-row icons share ONE color regardless of their text color
       (location link, accent calendar-source label, muted details). */
    .cev-meta .lic, .cev-deskic .lic { width: 13px; height: 13px; opacity: .8; color: var(--muted); }
    .cev-cal { color: var(--accent-dark); font-weight: var(--fw-semi); }
    .cev-desc { display: flex; align-items: center; gap: 7px; padding-left: 9px; font-size: var(--fs-sm); color: var(--muted); margin-top: 7px; line-height: var(--lh-body); }
    .cev-deskic { flex-shrink: 0; }
    .cev-desc-tx { min-width: 0; white-space: pre-line; word-break: break-word; }
    .cev-desc a { color: var(--accent-dark); }
    .cev-desc.is-clamped .cev-desc-tx { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
    /* Sits under the description's TEXT, not its icon: the 9px sub-row indent plus the
       13px glyph and its 7px gap — the same column .cev-desc-tx starts in. */
    .cev-more { margin: 3px 0 0 29px; padding: 0; border: 0; background: none; font: inherit; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--accent-dark); cursor: pointer; }
    .cev-more:hover { text-decoration: underline; }
    .agenda-empty { padding: 28px 20px; }

    /* ── Desktop full calendar: Month / Week / Schedule ───────────────────
       Rendered into #calendar-embed in place of the old Google iframe. The
       month grid and week columns fill the content height via a flex chain;
       Schedule reuses the .cal-agenda renderer above. */
    #calendar-embed:has(.cal-cal) { display: flex; flex-direction: column; }
    .cal-cal { flex: 1; min-height: 0; display: flex; flex-direction: column; }
    .cal-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
    .cal-nav { display: flex; align-items: center; gap: 10px; }
    .cal-nav--sched .cal-today, .cal-nav--sched .cal-steppers { visibility: hidden; }  /* Schedule has no period to page */
    .cal-today { font: inherit; font-size: var(--fs-md); font-weight: var(--fw-semi); padding: 7px 14px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); cursor: pointer; }
    .cal-today:hover { border-color: var(--accent); color: var(--accent); }
    .cal-steppers { display: inline-flex; }
    .cal-arrow { font-size: var(--fs-2xl); line-height: var(--lh-none); width: 32px; height: 32px; border: 1px solid var(--border); background: var(--card); cursor: pointer; display: grid; place-items: center; }
    .cal-arrow:first-child { border-radius: 8px 0 0 8px; }
    .cal-arrow:last-child { border-radius: 0 8px 8px 0; border-left: 0; }
    .cal-arrow:hover { color: var(--accent); border-color: var(--accent); }
    .cal-title { font-size: var(--fs-2xl); font-weight: var(--fw-display); }
    /* The view switcher IS the app's segmented control (.ld-seg-ctl - Requests' Open/Complete,
       the dashboard's view toggle, this). It used to be a bespoke strip with an accent-filled
       active segment: a fourth switcher, drawn a fourth way, for the same job. */
    .cal-views .ld-seg-btn { font-size: var(--fs-md); padding: 7px 16px; }
    .cal-title { white-space: nowrap; }
    .cal-view-body { flex: 1; min-height: 0; overflow-y: auto; }

    /* Month grid */
    .cal-month { display: flex; flex-direction: column; height: 100%; }
    .cal-dow { display: grid; grid-template-columns: repeat(7, 1fr); margin-bottom: 6px; }
    .cal-dow span { text-align: center; font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
    .cal-grid { flex: 1; min-height: 0; display: grid; grid-template-columns: repeat(7, 1fr); grid-auto-rows: 1fr; gap: 6px; }
    .cal-cell { border: 1px solid var(--border); border-radius: 10px; background: var(--card); padding: 5px 6px; display: flex; flex-direction: column; gap: 4px; min-height: 0; overflow: hidden; }
    .cal-cell.out { background: transparent; border-color: transparent; }
    .cal-cell.out .cal-cnum { opacity: .35; }
    .cal-cell.has { cursor: pointer; }
    .cal-cell.has:hover { border-color: var(--accent); }
    .cal-cnum { font-size: var(--fs-sm); font-weight: var(--fw-bold); align-self: flex-start; line-height: 22px; width: 22px; height: 22px; text-align: center; border-radius: 50%; }
    /* TODAY is a filled circle on the NUMBER, not a border on the cell: a bordered cell reads
       as "selected", and where today is, is the first thing every reader looks for. */
    .cal-cell.today .cal-cnum { background: var(--accent); color: var(--on-accent); }
    .cal-cell.has:hover { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); }
    .cal-cevs { display: flex; flex-direction: column; gap: 3px; min-height: 0; overflow: hidden; }
    .cal-chip { display: flex; align-items: center; gap: 5px; font-size: var(--fs-xs); line-height: var(--lh-snug); white-space: nowrap; overflow: hidden; }
    .cal-chip-tx { overflow: hidden; text-overflow: ellipsis; }
    .cal-chip-t { color: var(--muted); font-weight: var(--fw-semi); flex: 0 0 auto; }
    .cal-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--cc, var(--accent)); flex: 0 0 auto; }
    /* An all-day event has no time to lead with, so it reads as a BAR in its calendar's colour:
       a 3px rail, a 14% wash, the colour's own text. It used to be a solid fill with white text
       - at 11px on a light hue that is a legibility gamble, and three of them turned a week into
       a bar chart. */
    .cal-chip.allday { border-left: 3px solid var(--cc, var(--accent));
      background: color-mix(in srgb, var(--cc, var(--accent)) 14%, transparent);
      color: color-mix(in srgb, var(--cc, var(--accent)) 78%, #000);
      border-radius: 5px; padding: 1px 6px 1px 5px; font-weight: var(--fw-semi); }
    .cal-chip.allday .cal-chip-tx { color: inherit; }
    /* A past event stays on the grid - it just stops being a plan. */
    .cal-chip.is-past { opacity: .55; }
    .cal-chip.is-past .cal-chip-t { color: var(--muted); }
    /* "+2 more" is a real button: it opens the day. */
    .cal-more { font: inherit; font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--accent-dark);
      background: none; border: 0; padding: 1px 2px; text-align: left; cursor: pointer; }
    .cal-more:hover { text-decoration: underline; }

    /* Week columns — agenda-style blocks per day (mobile concept, 7-wide) */
    .cal-week { display: grid; grid-template-columns: repeat(7, 1fr); gap: 8px; height: 100%; }
    .cal-wk-col { display: flex; flex-direction: column; min-height: 0; border: 1px solid var(--border); border-radius: 10px; background: var(--card); overflow: hidden; }
    .cal-wk-col.today { border-color: var(--accent); }
    .cal-wk-hdr { text-align: center; padding: 8px 4px; border-bottom: 1px solid var(--border); }
    .cal-wk-col.today .cal-wk-hdr { background: var(--accent-light); }
    .cal-wk-dow { display: block; font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
    .cal-wk-num { display: block; font-size: var(--fs-2xl); font-weight: var(--fw-display); }
    .cal-wk-col.today .cal-wk-num { color: var(--accent); }
    .cal-wk-evs { flex: 1; min-height: 0; overflow-y: auto; padding: 7px; display: flex; flex-direction: column; gap: 6px; }
    .cal-wk-evs:has(.cal-wk-block) { cursor: pointer; }
    /* The wash is the CALENDAR'S colour, not the accent - that is the whole point of assigning a
       colour per feed. Every block used to wear --accent-light, so four calendars looked like one
       and the 3px rail was the only tell. */
    .cal-wk-block { border-left: 3px solid var(--cc, var(--accent));
      background: color-mix(in srgb, var(--cc, var(--accent)) 12%, transparent);
      border-radius: 6px; padding: 5px 8px; display: flex; flex-direction: column; gap: 1px; }
    .cal-wk-block.is-past { opacity: .55; }
    .cal-wk-t { display: block; font-size: var(--fs-xs); font-weight: var(--fw-bold);
      color: color-mix(in srgb, var(--cc, var(--accent)) 78%, #000); }
    .cal-wk-title { display: block; font-size: var(--fs-md); font-weight: var(--fw-semi); line-height: var(--lh-snug); }
    .cal-wk-loc { display: block; font-size: var(--fs-2xs); color: var(--muted);
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    /* Counts read as a sentence - "12 going / 3 maybe" - with only the numbers bold. A badge
       would make the count the loudest thing in a column about what is happening. */
    .cal-wk-counts { display: block; font-size: var(--fs-2xs); color: var(--muted); }
    .cal-wk-counts b { color: var(--text); font-weight: var(--fw-bold); }
    .cal-wk-mine { display: inline-block; margin-top: 2px; font-size: var(--fs-2xs); font-weight: var(--fw-bold);
      color: var(--ok); background: var(--ok-bg); border-radius: 999px; padding: 1px 7px; }
    .cal-wk-empty { color: var(--muted); opacity: .5; text-align: center; font-size: var(--fs-md); padding: 8px 0; }

    /* SCHEDULE - day groups, and the only dividers are BETWEEN DAYS. A hairline above every day
       header except the first, and none between the rows inside a day: divided row-by-row, fifty
       events read as fifty unrelated things; divided day-by-day they read as a week. */
    .cal-sched { display: flex; flex-direction: column; }
    .cal-sd-hdr { display: flex; align-items: center; gap: 12px; padding: 14px 2px 10px; }
    .cal-sd:not(.is-first) .cal-sd-hdr { border-top: 1px solid var(--border); }
    .cal-sd-dow { font-size: var(--fs-md); font-weight: var(--fw-bold); }
    .cal-sd-count { margin-left: auto; font-size: var(--fs-xs); color: var(--muted); }
    /* Rows sit past the date mark, so a day reads as one block of time rather than a list that
       happens to have a heading. */
    .cal-sd-rows { padding-left: 56px; display: flex; flex-direction: column; }
    .cal-sd-rows .cev { border: 0; border-radius: 0; box-shadow: none; background: none; padding: 8px 2px; }
    @media (max-width: 760px) { .cal-sd-rows { padding-left: 0; } }

    @media (max-width: 760px) {
      /* THE PHONE'S MONTH IS DOTS. A 390px screen gives a cell ~46px, and a chip in 46px is
         "E…" — a truncation that tells you an event exists and nothing else, which is what the
         dot already says without pretending to be a label. So the cell carries the marks and the
         count, and the TAP carries the detail (the day popover). */
      .cal-cevs { flex-direction: row; flex-wrap: wrap; gap: 3px; align-items: center; }
      .cal-chip { gap: 0; }
      .cal-chip .cal-chip-t, .cal-chip .cal-chip-tx { position: absolute; width: 1px; height: 1px;
        margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
      .cal-chip.allday { border-left-width: 0; padding: 0; border-radius: 50%;
        width: 6px; height: 6px; background: var(--cc, var(--accent)); }
      .cal-more { font-size: var(--fs-2xs); padding: 0; }
      .cal-cnum { font-size: var(--fs-xs); line-height: 20px; width: 20px; height: 20px; }
      /* EVERY CELL IS A BOX, INCLUDING THE ADJACENT MONTH'S. Desktop drops the border and
         fill on an out-of-month day, which works where a row is 90px tall and the lattice
         still reads. On a phone the rows are twice that, so a week made of five invisible
         cells and one bordered one (Aug 1, alone on its row) reads as a rendering fault —
         and a trailing day with an event drew a dot floating in open space. The faded
         NUMBER is the out-of-month signal; the box is the grid. */
      .cal-cell.out { background: var(--card); border-color: var(--border); }
      /* THE CARD'S GUTTER. #calendar-embed carries `padding: 8px 0` because the agenda's
         rows take the gutter themselves and paint their dividers wall to wall. Month and
         the toolbar have no such rule, so they inherited a ZERO side gutter: the Today
         button and the SAT column landed flat against the card's walls while the title
         band directly above them sat 14px in. The gutter belongs to whatever isn't
         already spanning it. */
      .cal-toolbar, .cal-month, .cal-week { padding-inline: var(--pad-card); }
      /* Schedule's day header sat on a 2px pad while its own event rows are on the 14px
         one below — the date chip and the event it labels started in different columns. */
      .cal-sd-hdr { padding-inline: var(--pad-card); }
      .cal-sd-rows .cev { padding-inline: var(--pad-card); }
      /* SCHEDULE HAS NO PERIOD TO PAGE, SO IT SPENDS NO ROW ON ONE. Desktop can afford to
         leave Today and the steppers in place but invisible — they're beside the switcher
         on one line. Here .cal-nav takes a full-width row of its own, so Schedule opened
         with a blank line of hidden buttons under a heading that read "Schedule" directly
         above a segmented control whose lit segment already said Schedule. */
      .cal-nav--sched { display: none; }
      /* WEEK IS NOT OFFERED HERE — seven columns on this screen is a grid of dots, not a week.
         (loadCalendar also falls back to Schedule if a saved choice says week.) */
      .cal-views .ld-seg-btn[data-calview="week"] { display: none; }
      /* The title takes the room that is left and ellipsises rather than running off the edge. */
      .cal-title { min-width: 0; flex: 1 1 auto; overflow: hidden; text-overflow: ellipsis;
        font-size: var(--fs-xl); }
      .cal-toolbar { gap: 8px; }
      .cal-nav { min-width: 0; flex: 1 1 100%; }
    }

    /* Day popover (Month + Week) — reuses the agenda event card inside */
    .cal-pop-ov { position: fixed; inset: 0; background: rgba(0, 0, 0, .45); display: grid; place-items: center; z-index: 1000; padding: 20px; }
    .cal-pop { background: var(--card); border-radius: 16px; width: min(520px, 100%); max-height: 80vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0, 0, 0, .3); }
    .cal-pop-hdr { display: flex; align-items: center; gap: 12px; padding: 16px 20px; border-bottom: 1px solid var(--border); }
    .cal-pop-ttl { min-width: 0; flex: 1 1 auto; }
    .cal-pop-hdr h3 { margin: 0; font-size: var(--fs-xl); font-weight: var(--fw-display); }
    .cal-pop-when { display: block; font-size: var(--fs-sm); color: var(--muted); }
    .cal-pop-x { font-size: var(--fs-xl); background: none; border: 0; cursor: pointer; color: var(--muted); padding: 4px; }
    .cal-pop-body { padding: 16px 20px; overflow-y: auto; }

    /* (Home's "Upcoming Events" no longer has a row type of its own. It renders the AGENDA's
       row — same function, same .cev classes, with the date block on and the description and
       RSVP controls off — so the two surfaces cannot drift into two layouts again. Home's
       chrome overrides live with the rest of the Home frame, under .hm-events.) */
    @media (max-width: 760px) {
      /* The card takes the phone gutter as a MARGIN so its edges land on the band's,
         and keeps its own inner padding for the calendar's toolbar. (It used to be
         full-bleed with the gutter as padding, which read as a band floating 14px in
         from a card that touched both screen edges.) */
      /* ── THE AGENDA SCROLLS, AND ITS EVENTS ARE ROWS ─────────────────────────
         Two things were wrong with this screen, and the first one hid the second.

         IT DIDN'T SCROLL. This rule has always said `overflow-y: auto`, but the same
         element also carries .page-frame, whose `overflow: hidden` is declared later
         at equal specificity and therefore won. Measured with the calendars seeded:
         20,569px of agenda inside a 649px box, clipped, with no way to reach it — you
         could see two events and the other 48 did not exist. Written at id strength so
         the frame can keep its clip everywhere else (it is what rounds the card).

         AND ITS EVENTS WERE CARDS: a 1px border, a 13px radius and a shadow each,
         stacked with an 8px gap inside the one card the page already is — the exact
         nesting the Requests list and the Member Directory were converted out of. They
         are rows now: one hairline between them, the day header above its own group,
         and the fill reaching the card's wall. The side padding moves off the frame and
         onto the rows so they can span it, which is why the header takes the gutter
         explicitly here. */
      /* 16px at the bottom, not 14 — the phone's page rhythm is `.app-container`'s
         16px 14px, so a 14px bottom left Calendar's card sitting 2px lower than every
         other screen's. See THE CARD BOX. */
      .cal-frame-wrap { margin: 0 14px 16px; padding: 8px 0 12px; }
      #calendar-embed.cal-frame-wrap { overflow-y: auto; overscroll-behavior: contain; }
      /* A DAY BREAK IS THE BIGGEST BREAK IN THIS LIST, SO IT GETS A LINE TOO. Events
         inside a day were separated by a rail and days were separated by nothing but
         14px of air — which read as an omission, because the one boundary that changes
         what you are looking at was the only one the eye had to infer. The gap becomes
         balanced padding either side of a rail instead: 12px of the last event's own
         padding above it, 12px (7 + the header's 5) below, so the line sits centred
         between the day it ends and the day it opens rather than tucked under a header.
         Same 1px rail as the event divider, full-bleed like every divider in this app
         (see THE CARD DIVIDER) — the hierarchy comes from the whitespace and the header
         band around it, not from a heavier or a shorter line. */
      .cal-agenda { gap: 0; }
      .cday + .cday { border-top: var(--rail); padding-top: 7px; }
      /* THE DAY STAYS ON SCREEN. Fifty events across thirty-five days is a long scroll
         with no landmarks in it — you read "Perseids Meteor Shower" and have no idea
         which day you're looking at, because its header left the top of the card four
         swipes ago. The header is the row's context, so it rides with the group and is
         replaced by the next day's, not scrolled away from it. The gutter moves from
         margin to PADDING so the stuck header paints wall to wall and the rows pass
         cleanly under it rather than beside it. */
      .cday-hdr { position: sticky; top: 0; z-index: 2; margin: 0 0 8px; padding: 5px var(--pad-card) 7px; background: var(--card); }
      .cday-events { gap: 0; }
      .cev { border: 0; border-bottom: var(--rail); border-radius: 0; box-shadow: none;
        background: var(--card); padding: 12px var(--pad-card); }
      .cev:last-child { border-bottom: 0; }
      /* ── THE WHEN IS AN EYEBROW ON A PHONE, NOT A COLUMN ────────────────────
         The desktop row puts the time in a 60px column beside the title, which is
         right at 1500 and wrong at 390: it spends a sixth of the screen on "ALL DAY"
         and leaves the title wrapping in what's left ("Constellation Lyra Evening /
         Culmination" over two lines with a column of white beside it).
         Stacked, the time reads as the label above the title — the same shape the
         meeting-log entry in a request's timeline uses, and the same shape Home's
         Upcoming Events row now uses — they are the SAME row. CSS only, no markup change, so
         desktop agenda is untouched. */
      .cev { flex-direction: column; gap: 4px; }
      .cev-time { flex: none; flex-direction: row; align-items: baseline; gap: 7px; padding-top: 0; }
      .cev-t1 { font-size: var(--fs-sm); font-weight: var(--fw-bold); letter-spacing: .06em; text-transform: uppercase; }
      .cev-t2 { font-size: var(--fs-xs); }
      .cev-body { width: 100%; }
      /* A leader carries BOTH affordances on every one of fifty events, so the pair
         missing a shared line by three pixels costs a stacked row per event. Trimmed
         to fit one. (Written at two-class strength: both pills are styled further down
         the sheet, and a media query buys no specificity.) */
      .cev .cev-acts { gap: 6px; }
      .cev-acts .rsvp-enable { padding: 4px 9px; }
      /* Conform Calendar/Contract to the Member Directory mobile gutter (14px), so
         their headers + content line up with the app-wide standard on phones. */
      .cal-wrap > .page-head { margin: 16px 14px 0; }
    }
    /* (A calendar source is a plain .list-row now — see THE CONFIG ROW. Its
       columns are declared with the other Site Config lists, so this screen
       can't drift away from its neighbours again.) */

    /* ── Supply Closet — a give-away board dressed as a storefront ────────────
       A retail product-listing feel (Target/Amazon/Walmart): a hero "front
       window", then a grid of product tiles with a Free price treatment, a
       New/Claimed flag, a pickup line and one prominent "I'll Take It" CTA. */
    /* The closet's band is now the PLAIN band — no variant, no class of its own. It was
       an `.accent` variant carrying a gift mark and a two-line text block, and the cost
       of that was an edge: `height: auto` on the title row let the 46px icon set the
       row's height, so the closet's card top → divider measured 77px where every other
       screen in the app measures 57. A few millimetres for an icon is not worth being
       the one page that doesn't line up. (This is the same lesson .supply-hero already
       taught once, when the closet had a whole second header component of its own.)
       What survives is the personality that doesn't move an edge: the tagline rides the
       band's ordinary .page-head-meta slot, and keeps its green mark. */
    #supply-hero-subtitle em { font-style: normal; color: var(--ok); font-weight: var(--fw-bold); }
    /* The count KEEPS ITS GREEN — it is the closet's one number and it reads as a promise
       ("47 free things"), not a statistic — but it is no longer a capsule. It sits in the
       bar's count slot beside the filter chips, and once those became 9px controls the
       capsule was the one object in the row whose shape promised a click it doesn't
       answer. Colour carries the promise; the shape now says "label", like every other
       screen's count slot. */
    #supply-count { width: auto; font-size: var(--fs-sm); font-weight: var(--fw-display); color: var(--ok); }
    #supply-new .ic { width: 14px; height: 14px; }

    /* Search + filters — the bar owns the row; this is the search box's own chrome. */
    .supply-controls { gap: 12px; }
    .supply-search-wrap { position: relative; flex: 1; min-width: 220px; }
    .supply-search-wrap .ssw-ic { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: var(--muted); pointer-events: none; }
    #supply-search { width: 100%; padding: 10px 12px 10px 36px; border: 1px solid var(--border); border-radius: 10px; font-size: var(--fs-md); font-family: inherit; background: var(--card); color: var(--text); }
    #supply-search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    /* Desktop keeps the pill strip and the bar's Post button; the phone twins below are
       inert here. (Both directions are written explicitly — a control that is "hidden by
       not being styled" comes back the day someone touches the other rule.) */
    .supply-controls .supply-filter-btn { display: none; }
    #supply-count .sc-cnt-short { display: none; }

    /* Product grid + tile */
    .supply-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(248px, 1fr)); gap: 18px; }
    .supply-card { background: var(--card); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; display: flex; flex-direction: column; box-shadow: var(--shadow); transition: border-color .14s, box-shadow .14s, transform .14s; }
    /* The card body is a real <button> (see supplyCard in app.js) — strip the UA chrome
       and let it lay out exactly as the old div did. */
    .supply-card-open { all: unset; box-sizing: border-box; display: flex; flex-direction: column; flex: 1;
      cursor: pointer; font-family: inherit; color: var(--text); text-align: left; min-width: 0; }
    .supply-card:hover { border-color: var(--accent); box-shadow: 0 8px 22px rgba(var(--accent-rgb),.14); transform: translateY(-2px); }
    .supply-card:focus-within { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.18); }
    /* Hover is a desktop-only affordance; a phone gets a press instead of nothing. */
    .supply-card-open:active { background: var(--wash, #f6f7f8); }
    .sc-cta:active { transform: scale(.985); }
    .supply-card .photo-wrap { position: relative; overflow: hidden; }
    .supply-photo { width: 100%; aspect-ratio: 1/1; object-fit: cover; background: var(--wash, #f3f4f6); display: block; transition: transform .3s ease; }
    .supply-card:hover .supply-photo { transform: scale(1.045); }
    .supply-photo.placeholder { display: grid; place-items: center; color: var(--muted); }
    .supply-photo.placeholder .ic { width: 48px; height: 48px; opacity: .45; }
    /* Availability chip (top-right) + New/Yours flag (top-left) */
    .supply-status-tag { position: absolute; top: 10px; right: 10px; font-size: var(--fs-2xs); font-weight: var(--fw-display); padding: 4px 10px; border-radius: 999px; letter-spacing: .01em; box-shadow: 0 1px 3px rgba(0,0,0,.12); }
    .supply-status-tag.is-avail { background: var(--ok-bg); color: var(--ok); }
    .supply-status-tag.is-claimed { background: rgba(17,24,39,.82); color: #fff; }
    .sc-badge { position: absolute; top: 10px; left: 10px; display: inline-flex; align-items: center; gap: 5px; font-size: var(--fs-2xs); font-weight: var(--fw-display); padding: 4px 9px; border-radius: 999px; letter-spacing: .01em; box-shadow: 0 1px 3px rgba(0,0,0,.14); }
    .sc-badge .ic { width: 12px; height: 12px; }
    .sc-badge-new { background: var(--accent); color: var(--on-accent, #fff); }
    .sc-badge-mine { background: rgba(17,24,39,.82); color: #fff; }
    .supply-card.is-claimed .photo-wrap { opacity: .55; }
    .supply-card.is-claimed .supply-body h4 { color: var(--muted); }
    .supply-card.is-claimed .sc-price { color: var(--muted); }

    .supply-body { padding: 14px 16px 16px; flex: 1; display: flex; flex-direction: column; }
    .sc-price { display: flex; align-items: baseline; gap: 6px; font-size: var(--fs-2xl); font-weight: var(--fw-display); color: var(--ok); line-height: var(--lh-none); margin-bottom: 7px; }
    .sc-price-sub { font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--muted); letter-spacing: .01em; }
    .supply-body h4 { font-size: var(--fs-lg); font-weight: var(--fw-bold); margin-bottom: 5px; line-height: var(--lh-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
    .supply-body .sc-desc { font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); margin-bottom: 9px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
    .supply-body .sc-desc-empty { font-style: italic; opacity: .7; }
    .sc-pickup { font-size: var(--fs-sm); color: var(--muted); display: flex; align-items: center; gap: 6px; margin-bottom: 11px; }
    .sc-pickup .ic { width: 13px; height: 13px; opacity: .75; flex: 0 0 auto; }
    /* Footer: the poster, shown like a mini directory identity (avatar + name + date) */
    .supply-foot { display: flex; align-items: center; gap: 9px; padding-top: 11px; margin-top: auto; border-top: 1px solid var(--border); }
    .sc-poster-av { width: 26px; height: 26px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; }
    .sc-poster-ph { display: grid; place-items: center; background: var(--wash-orange, #fff4ec); color: var(--accent-dark); font-size: var(--fs-sm); font-weight: var(--fw-bold); }
    .supply-poster { font-size: var(--fs-xs); color: var(--text); font-weight: var(--fw-semi); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; display: flex; flex-direction: column; }
    .sc-poster-date { font-weight: var(--fw-body); color: var(--muted); font-size: var(--fs-2xs); }
    /* The "Add to cart" beat — one primary action per tile */
    .sc-cta { margin-top: 12px; width: 100%; display: inline-flex; align-items: center; justify-content: center; gap: 7px;
      padding: 10px 12px; border-radius: 11px; font-size: var(--fs-md); font-weight: var(--fw-bold); font-family: inherit;
      border: 1px solid transparent; cursor: pointer; transition: filter .12s, background .12s, border-color .12s, color .12s; }
    .sc-cta .ic { width: 15px; height: 15px; }
    .sc-cta.is-take { background: var(--accent); color: var(--on-accent, #fff); border-color: var(--accent); }
    .sc-cta.is-take:hover { filter: brightness(1.06); }
    .sc-cta.is-mine { background: var(--card); color: var(--text); border-color: var(--border); }
    .sc-cta.is-mine:hover { border-color: var(--accent); color: var(--accent-dark); }
    .sc-cta.is-claimed { background: var(--wash, #f3f4f6); color: var(--muted); cursor: pointer; }
    .sc-cta.is-claimed:hover { border-color: var(--border); }
    /* The CTA is now a sibling of the card button, not a child, so it carries the
       body's side padding itself. */
    .supply-card > .sc-cta { margin: 0 16px 16px; width: auto; }

    /* After posting or editing, draw the eye to the card that changed — otherwise the
       poster is handed back a grid of twenty tiles with no idea which one is theirs. */
    .supply-card.just-changed { border-color: var(--accent); animation: sc-flash 1.6s ease; }
    @keyframes sc-flash {
      0%, 100% { box-shadow: var(--shadow); }
      18%, 62% { box-shadow: 0 0 0 3px rgba(var(--accent-rgb), .32); }
    }
    @media (prefers-reduced-motion: reduce) { .supply-card.just-changed { animation: none; } }

    /* Loading: card-shaped placeholders, so the grid keeps its geometry and nothing
       jumps when the real cards land. */
    .supply-card.is-skeleton { pointer-events: none; box-shadow: none; }
    .supply-card.is-skeleton .photo-wrap { aspect-ratio: 1/1; }
    .sk-block, .sk-line { background: linear-gradient(90deg, var(--wash, #f1f3f4) 25%, #e7eaec 37%, var(--wash, #f1f3f4) 63%);
      background-size: 400% 100%; animation: sk-shimmer 1.4s ease infinite; }
    .sk-block { width: 100%; height: 100%; }
    .sk-line { height: 11px; border-radius: 5px; margin-bottom: 9px; }
    .sk-line-sm { width: 38%; }
    .sk-line-lg { width: 72%; margin-bottom: 0; }
    @keyframes sk-shimmer { from { background-position: 100% 0; } to { background-position: 0 0; } }
    @media (prefers-reduced-motion: reduce) { .sk-block, .sk-line { animation: none; } }

    /* ── The Post drawer ──────────────────────────────────────────────────────
       Phone only, and dead on desktop where the bar has room for the button.
       SHARED, not the closet's: the Supply Closet and The Local Exchange are the same
       screen with different nouns — a board you come to BROWSE, where posting is the rare
       visit — so they get the same gesture rather than two drawers that drift. Renamed
       from .supply-drawer when the Exchange adopted it; the ids stay per-screen. */
    .post-drawer { display: none; }

    @media (max-width: 760px) {
      /* (The hero's own phone rules went with it — the band carries the phone treatment
         for every page now, title size included: the closet's band is the plain band.) */
      .supply-grid { grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); gap: 12px; }
      .supply-body { padding: 11px 12px 13px; }
      .supply-card > .sc-cta { margin: 0 12px 13px; }

      /* ONE row: search grows, count and funnel are fixed. The pill strip and the bar's
         Post button are the two things that made it four. */
      #supply-filters, .supply-controls .page-acts { display: none; }
      .supply-controls .supply-filter-btn { display: inline-flex; flex: 0 0 auto; }
      /* Written as .page-bar.supply-controls on purpose: the generic phone rules for
         .page-bar (wrap, gap, and the 14px bottom margin) are further down this file,
         so a single-class selector here loses to them and the count wraps to a second
         row — which is the whole thing this change is undoing. */
      .page-bar.supply-controls { gap: 8px; flex-wrap: nowrap; }
      .supply-search-wrap { min-width: 0; }
      #supply-count { flex: 0 0 auto; }
      #supply-count .sc-cnt-long { display: none; }
      #supply-count .sc-cnt-short { display: inline; }

      /* The drawer sits between the bar and the scroller and pushes the grid down when
         it opens. Collapsed it is the handle and nothing else, so the cost of having a
         Post button on this screen is ~18px instead of a 44px button's whole row. */
      .post-drawer { display: block; flex: none; }
      .page-bar.supply-controls { margin-bottom: 0; }
      .post-drawer + .page-scroll { padding-top: 8px; }
      /* max-height, not `height: auto` — a height transition needs two numbers. 96px is
         comfortably over the tray's real height (a 44px button + 10px of padding); the
         tray is never taller, so nothing is ever clipped. */
      .sd-tray { max-height: 0; overflow: hidden; transition: max-height .22s ease; }
      .post-drawer.open .sd-tray { max-height: 96px; }
      .sd-tray .btn { width: 100%; min-height: 44px; justify-content: center; margin-top: 10px; }
      .sd-tray .btn .ic { width: 14px; height: 14px; }
      /* The handle is the whole width so it can't be missed and can't be mis-tapped —
         a 4px grip drawn in the middle of an 18px target. */
      .sd-handle { display: block; width: 100%; padding: 7px 0; border: none; background: none; cursor: pointer; -webkit-tap-highlight-color: transparent; }
      .sd-grip { display: block; width: 34px; height: 4px; margin: 0 auto; border-radius: 999px; background: var(--border); transition: background .14s, width .14s; }
      .post-drawer.open .sd-grip { width: 46px; background: var(--accent); }
      .sd-handle:active .sd-grip { background: var(--accent); }
      @media (prefers-reduced-motion: reduce) {
        .sd-tray, .sd-grip { transition: none; }
      }
    }
    /* At 375px a two-up grid gives each tile ~155px to carry EIGHT things: photo, price,
       title, description, stock, handoff, poster, CTA. It reads as a wall. One-up below
       420 gives the same information room to be read, and the photo becomes a 4:3 banner
       rather than a square that pushes everything else off the first screen. */
    @media (max-width: 420px) {
      .supply-grid { grid-template-columns: 1fr; gap: 14px; }
      .supply-photo { aspect-ratio: 4/3; }
      .supply-card .sc-desc { -webkit-line-clamp: 3; }
      .sc-cta { min-height: 44px; }
    }

    /* Item detail modal */
    .supply-detail-card { max-width: 460px; }
    .supd-wrap .photo-wrap { position: relative; margin: -4px 0 14px; border-radius: var(--radius); overflow: hidden; }
    .supd-photo { width: 100%; max-height: 300px; object-fit: cover; display: block; background: var(--wash, #f3f4f6); }
    .supd-photo.placeholder { aspect-ratio: 4/3; display: grid; place-items: center; color: var(--muted); }
    .supd-photo.placeholder .ic { width: 56px; height: 56px; opacity: .5; }
    .supd-loc { font-size: var(--fs-sm); color: var(--muted); display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
    .supd-loc .ic { width: 14px; height: 14px; opacity: .7; }
    .supd-desc { font-size: var(--fs-md); line-height: var(--lh-body); color: var(--text); margin: 0 0 14px; white-space: pre-wrap; }
    .supd-claim-note { display: flex; align-items: center; gap: 7px; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--muted); background: var(--wash, #f7f8fa); border-radius: 9px; padding: 8px 12px; margin-bottom: 14px; }
    .supd-claim-note .ic { width: 15px; height: 15px; }
    .supd-poster-lite { font-size: var(--fs-sm); color: var(--muted); display: flex; align-items: center; gap: 7px; margin-bottom: 12px; }
    .supd-hint { font-size: var(--fs-sm); color: var(--muted); text-align: center; margin: 4px 0 14px; }
    .supd-actions { display: flex; gap: 8px; flex-wrap: wrap; }
    .supd-actions .btn { flex: 1; min-width: 130px; justify-content: center; }

    /* ── Supply Closet: quantity, waitlist, the poster's roster ──────────── */
    /* Stock line — the one line that says whether it's worth tapping. */
    .sc-stock, .supd-stock { display: flex; align-items: center; gap: 7px; font-size: var(--fs-sm); font-weight: var(--fw-semi); margin-bottom: 9px; }
    .supd-stock { font-size: var(--fs-md); margin: 0 0 12px; }
    .sc-dot { width: 7px; height: 7px; border-radius: 50%; background: currentColor; flex: 0 0 auto; }
    .sc-stock-ok { color: var(--ok); }
    .sc-stock-wait { color: var(--warn); }
    .sc-stock-gone { color: var(--muted); }
    .sc-badge-standing { background: var(--accent); color: var(--on-accent, #fff); }
    .sc-cta-n { display: inline-grid; place-items: center; min-width: 18px; height: 18px; padding: 0 5px; margin-left: 2px;
                border-radius: 999px; background: var(--accent); color: var(--on-accent, #fff); font-size: var(--fs-xs); font-weight: var(--fw-display); }
    .sc-cta.is-mine .sc-cta-n { background: var(--accent); }
    .sc-cta.is-wait { background: var(--warn-bg); color: var(--warn); border-color: transparent; }
    .sc-cta.is-wait:hover { filter: brightness(.97); }
    .sc-cta.is-yours { background: var(--ok-bg); color: var(--ok); border-color: transparent; }
    .sc-cta.is-waiting { background: var(--warn-bg); color: var(--warn); border-color: transparent; }

    /* Handoff chips — what the poster is actually willing to do. */
    /* Each handoff option carries its own explanation. It used to live in a data-tip —
       a hover tooltip, i.e. invisible on the device most members read this on. */
    .supd-methods { display: grid; gap: 7px; margin-bottom: 14px; }
    .supd-chip { display: flex; align-items: flex-start; gap: 9px; font-size: var(--fs-sm); padding: 9px 12px;
                 border-radius: 11px; background: var(--wash, #f3f4f6); color: var(--text); border: 1px solid var(--border); }
    .supd-chip.is-plain { background: transparent; color: var(--muted); font-weight: var(--fw-medium); align-items: center; }
    .supd-chip .ic { width: 15px; height: 15px; opacity: .75; flex: 0 0 auto; margin-top: 1px; }
    .supd-chip-t { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
    .supd-chip-t b { font-weight: var(--fw-semi); }
    .supd-chip-t small { font-size: var(--fs-xs); color: var(--muted); line-height: var(--lh-body); }
    /* A listing can change after somebody claimed it. Say so quietly, so a member who
       remembers "Room 204" isn't left doubting themselves. */
    .supd-edited { display: flex; align-items: center; gap: 6px; font-size: var(--fs-xs); color: var(--muted); margin-bottom: 10px; }
    .supd-edited .ic { width: 12px; height: 12px; opacity: .7; }

    /* Where this member stands. */
    .supd-standing { border-radius: 11px; padding: 11px 14px; margin-bottom: 14px; border: 1px solid transparent; }
    .supd-standing.is-yours { background: var(--ok-bg); border-color: color-mix(in srgb, var(--ok) 22%, transparent); }
    .supd-standing.is-wait { background: var(--warn-bg); border-color: color-mix(in srgb, var(--warn) 22%, transparent); }
    .supd-standing-hd { display: flex; align-items: center; gap: 7px; font-size: var(--fs-md); font-weight: var(--fw-bold); }
    .supd-standing.is-yours .supd-standing-hd { color: var(--ok); }
    .supd-standing.is-wait .supd-standing-hd { color: var(--warn); }
    .supd-standing .ic { width: 15px; height: 15px; }
    .supd-standing p { font-size: var(--fs-sm); color: var(--muted); margin: 6px 0 0; line-height: var(--lh-body); }
    .supd-standing-h { font-weight: var(--fw-semi); color: var(--text) !important; }

    /* The poster's roster. */
    .supd-roster { margin-bottom: 14px; }
    .supd-meter { margin-bottom: 14px; }
    .supd-meter-bar { height: 7px; border-radius: 999px; background: var(--wash, #f3f4f6); overflow: hidden; margin-bottom: 7px; }
    .supd-meter-bar span { display: block; height: 100%; border-radius: 999px; background: var(--ok); transition: width .25s ease; }
    .supd-meter-txt { font-size: var(--fs-sm); color: var(--muted); }
    .supd-rh { display: flex; align-items: center; gap: 7px; font-size: var(--fs-sm); font-weight: var(--fw-bold); text-transform: uppercase;
               letter-spacing: .05em; color: var(--muted); margin: 16px 0 9px; }
    .supd-rh .ic { width: 14px; height: 14px; }
    .supd-none { font-size: var(--fs-sm); color: var(--muted); font-style: italic; background: var(--wash, #f7f8fa);
                 border-radius: 10px; padding: 12px 14px; line-height: var(--lh-body); }
    .supr-row { display: flex; gap: 11px; align-items: flex-start; border: 1px solid var(--border); border-radius: 12px;
                background: var(--card); padding: 12px 14px; margin-bottom: 8px; }
    .supr-row.is-done { opacity: .62; }
    .supr-av { width: 34px; height: 34px; border-radius: 50%; object-fit: cover; flex: 0 0 auto; }
    .supr-av-ph { display: grid; place-items: center; background: var(--wash-orange, #fff4ec); color: var(--accent-dark); font-size: var(--fs-lg); font-weight: var(--fw-bold); }
    .supr-main { flex: 1 1 auto; min-width: 0; }
    .supr-name { font-size: var(--fs-md); font-weight: var(--fw-bold); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
    .supr-tag { font-size: var(--fs-2xs); font-weight: var(--fw-display); text-transform: uppercase; letter-spacing: .04em; padding: 2px 8px; border-radius: 999px; }
    .supr-tag.is-wait { background: var(--warn-bg); color: var(--warn); }
    .supr-tag.is-done { background: var(--ok-bg); color: var(--ok); }
    .supd-contacts { display: flex; flex-direction: column; gap: 3px; margin-top: 5px; }
    .supd-contact { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-sm); color: var(--accent-dark); text-decoration: none; }
    .supd-contact:hover { text-decoration: underline; }
    .supd-contact.is-muted { color: var(--muted); }
    .supd-contact.is-muted:hover { text-decoration: none; }
    .supd-contact .ic { width: 13px; height: 13px; opacity: .8; flex: 0 0 auto; }
    .supd-shared { font-size: var(--fs-2xs); font-weight: var(--fw-bold); color: var(--ok); background: var(--ok-bg); border-radius: 999px; padding: 1px 7px; margin-left: 4px; }
    .supd-shared-note { display: flex; align-items: center; gap: 7px; font-size: var(--fs-xs); color: var(--ok); margin-top: 9px; }
    .supd-shared-note .ic { width: 13px; height: 13px; }
    .supr-handoff { font-size: var(--fs-sm); color: var(--text); display: flex; align-items: center; gap: 6px; margin-top: 6px; flex-wrap: wrap; }
    .supr-handoff .ic { width: 13px; height: 13px; opacity: .75; }
    .supr-note { font-size: var(--fs-sm); color: var(--muted); font-style: italic; line-height: var(--lh-body); margin-top: 6px; }
    .supr-when { font-size: var(--fs-xs); color: var(--muted); margin-top: 6px; }
    .supr-acts { display: flex; flex-direction: column; gap: 6px; flex: 0 0 auto; }
    .supr-acts .btn.tiny { font-size: var(--fs-sm); padding: 5px 10px; white-space: nowrap; }

    /* The claim sheet. */
    .supply-claim-card { max-width: 460px; }
    .supc-item { font-size: var(--fs-xl); font-weight: var(--fw-bold); margin-bottom: 4px; }
    .supc-lead { font-size: var(--fs-md); color: var(--muted); line-height: var(--lh-body); margin: 0 0 16px; }
    .supc-checks { display: flex; flex-direction: column; gap: 7px; }
    .supc-opt { display: flex; align-items: flex-start; gap: 10px; border: 1px solid var(--border); border-radius: 11px;
                padding: 10px 13px; cursor: pointer; transition: border-color .14s, background .14s; }
    .supc-opt:hover { border-color: var(--accent); }
    .supc-opt input { margin-top: 2px; flex: 0 0 auto; accent-color: var(--accent); }
    .supc-opt:has(input:checked) { border-color: var(--accent); background: var(--wash-orange, #fff8f4); }
    .supc-opt-b { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: var(--fs-md); }
    .supc-opt-b .ic { width: 15px; height: 15px; opacity: .8; flex: 0 0 auto; }
    .supc-opt-b small { flex: 1 1 100%; font-size: var(--fs-sm); color: var(--muted); font-weight: var(--fw-body); padding-left: 23px; }
    #supc-methods { display: flex; flex-direction: column; gap: 7px; }
    .supc-single { display: flex; align-items: center; gap: 8px; font-size: var(--fs-md); background: var(--wash, #f7f8fa);
                   border-radius: 10px; padding: 10px 13px; }
    .supc-single .ic { width: 15px; height: 15px; opacity: .8; }
    .supc-check { display: flex; align-items: flex-start; gap: 9px; font-size: var(--fs-sm); color: var(--muted);
                  line-height: var(--lh-body); margin-bottom: 14px; cursor: pointer; }
    .supc-check input { margin-top: 2px; flex: 0 0 auto; accent-color: var(--accent); }
    .supc-privacy { display: flex; align-items: flex-start; gap: 8px; font-size: var(--fs-sm); color: var(--muted);
                    line-height: var(--lh-body); background: var(--wash, #f7f8fa); border-radius: 10px; padding: 10px 13px; margin-bottom: 14px; }
    .supc-privacy .ic { width: 14px; height: 14px; flex: 0 0 auto; margin-top: 1px; opacity: .8; }
    @media (max-width: 640px) {
      /* Three nowrap buttons beside a name inside a ~295px sheet is a row nobody can
         hit. They go full width under the person, each a real 44px target. */
      .supr-row { flex-wrap: wrap; }
      .supr-acts { flex-direction: row; width: 100%; padding-left: 0; margin-top: 10px; }
      .supr-acts .btn.tiny { flex: 1; min-height: 44px; padding: 5px 8px; font-size: var(--fs-xs); }
      .supd-actions .btn { min-height: 44px; }
    }

    /* ── Member Businesses ──────────────────────────────────────────────── */
    /* Browse layout. The `width: var(--page-col)` that used to sit HERE is now in the
       desktop block at the bottom of this file, with the rest of the width standard, and
       that move is a bug fix rather than tidying:
         --page-col is defined only inside @media (min-width: 761px). Written unscoped,
         the declaration was INVALID on a phone (an undefined custom property makes the
         whole declaration invalid at computed-value time), so width fell back to `auto`
         — and .app-section is a flex column, so an auto-width flex item with the default
         `min-width: auto` refuses to shrink below its content. The container measured
         653px inside a 375px viewport, and `body { overflow: hidden }` meant the missing
         278px were not scrollable to, just gone. Every other page puts the column on the
         container's CHILDREN inside the desktop query; this was the one that didn't. */
    /* THE BAND AND THE BAR ARE THE STANDARD ONES. This screen used to draw its own —
       a .biz-browse card box, a .biz-hero-band with a kicker and a 40px headline, and
       a .biz-filter-bar under it on a #fafaf9 fill — three components that restated
       what .page-frame / .page-head / .page-bar already say, on their own 26px gutter
       and their own hairlines. It was the only screen excluded from _frametest's FRAMED
       array by name, so none of it was checked.
       What is left below is the part that is genuinely this screen's: the category chip
       rail, the shuffle caption, and the disclaimer. Everything else is inherited.
       .biz-filter-bar survives ONLY as a modifier on .page-bar, because this bar carries
       a scrolling chip rail no other screen has and the rail needs a row it can wrap
       onto at narrow widths. */
    .biz-filter-bar { flex-wrap: wrap; }
    /* .page-search gives it the growing slot and the input sizing; this adds the icon
       the frame's search box doesn't position on its own. */
    .biz-search-wrap { position: relative; }
    .biz-search-wrap .ssw-ic { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); width: 15px; height: 15px; color: var(--muted); pointer-events: none; }
    #biz-search { width: 100%; height: 38px; padding: 0 12px 0 36px; border: 1px solid var(--border); border-radius: 10px;
      font-size: var(--fs-md); font-family: inherit; background: var(--card); color: var(--text); }
    #biz-search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    /* Hidden together, and by the BAR's own logic — see bizTuneChrome(). The bar itself
       must stay: it carries "Submit Your Business", which is the one thing a member with
       nothing to browse yet actually needs. It used to be the whole bar that hid.
       Written at ID specificity because .biz-shuffle-note sets `display: flex` on a
       bare class, and a bare `.hidden` only ties with it. */
    #biz-search-wrap.hidden, #biz-chips.hidden, #biz-shuffle-note.hidden { display: none; }
    /* Desktop shows the rail; the phone's funnel is inert here. Both directions written
       explicitly — a control that is "hidden by not being styled" comes back the day
       someone touches the other rule. (Same note the Supply Closet's pair carries.) */
    .biz-filter-bar .biz-filter-btn { display: none; }
    /* Category chip rail. ON DESKTOP IT WRAPS. A horizontal scroller with a hidden
       scrollbar is a PHONE idiom — it works because a thumb can swipe it and because the
       rail bleeds to the card's wall, which is the visual promise that it continues. At
       1500px there is no swipe, no scrollbar and no bleed, so the row simply ended:
       "Childcare & Tutoring" was clipped mid-word and four categories after it were
       reachable only by a gesture a mouse doesn't have. Nine categories is a SET, not a
       feed — a set wraps. The scroll is restored inside the phone query below. */
    .biz-chip-rail { display: flex; flex-wrap: wrap; gap: 8px; flex: 1; min-width: 0; }
    /* RADIUS ENCODES PRESSABILITY. A 999px capsule is a LABEL about the data — the card's
       Pending/Draft mark, a category on a listing, a count. A 9px rect is a CONTROL. These
       chips filter the board, so they are the second thing, and they take the same box as
       the .icon-btn and .btn they share the bar with: 38px tall, 9px corners, --card on
       --border, accent on hover. As capsules they sat beside a square-cornered lucide
       button doing the same job, which left radius meaning nothing — a leader could not
       tell by looking which objects in the row answered a click. */
    .biz-chip { height: 38px; padding: 0 14px; border-radius: 9px; border: 1px solid var(--border); background: var(--card);
      font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--text); cursor: pointer; white-space: nowrap;
      transition: background .14s, color .14s, border-color .14s; flex-shrink: 0; }
    .biz-chip.active { background: var(--text); color: #fff; border-color: var(--text); }
    .biz-chip:hover:not(.active) { border-color: var(--accent); color: var(--accent); }
    .biz-shuffle-note { display: flex; align-items: center; gap: 5px; font-size: var(--fs-sm); color: var(--muted); white-space: nowrap; margin-left: auto; }
    .biz-shuffle-note .ic { width: 14px; height: 14px; }
    /* Disclaimer */
    .biz-disclaimer { font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); margin: 0; padding: 0 26px 22px; font-style: italic; }
    /* Card grid */
    .biz-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 22px; padding: 24px 26px 0; }
    .biz-card { background: var(--card); border: 1px solid var(--border); border-radius: 14px; overflow: hidden;
      display: flex; flex-direction: column; cursor: pointer; transition: border-color .15s, box-shadow .15s, transform .12s; }
    .biz-card:hover { border-color: #b8b5b0; box-shadow: 0 6px 18px rgba(0,0,0,.08); }
    .biz-card:active { transform: scale(.985); }
    .biz-card:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.18); }
    /* Card photo */
    .biz-cover-wrap { position: relative; overflow: hidden; }
    .biz-cover { width: 100%; height: 190px; object-fit: cover; display: block; }
    .biz-cover-gen { width: 100%; height: 190px; display: flex; align-items: center; justify-content: center;
      font-size: 44px; font-weight: var(--fw-display); letter-spacing: .02em; }
    /* Owner pill on photo */
    .biz-owner-pill { position: absolute; left: 14px; bottom: 14px; display: flex; align-items: center; gap: 7px;
      height: 34px; padding: 0 13px 0 5px; border-radius: 999px; background: rgba(255,255,255,.94);
      backdrop-filter: blur(6px); -webkit-backdrop-filter: blur(6px); pointer-events: none; }
    .biz-pill-avatar { width: 26px; height: 26px; border-radius: 50%; background: var(--accent); display: grid; place-items: center;
      font-size: var(--fs-2xs); font-weight: var(--fw-display); color: #fff; flex-shrink: 0; }
    .biz-pill-name { font-size: var(--fs-sm); font-weight: var(--fw-bold); color: var(--text); }
    .biz-pill-building { font-size: var(--fs-sm); color: var(--muted); }
    /* Card body */
    .biz-card-body { padding: 15px 16px 14px; flex: 1; display: flex; flex-direction: column; }
    .biz-card-body h4 { font-size: var(--fs-xl); font-weight: var(--fw-display); margin: 0; line-height: var(--lh-snug); letter-spacing: -.015em;
      display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
    .biz-card-tagline { font-size: var(--fs-lg); color: var(--muted); margin: 3px 0 0; line-height: var(--lh-snug);
      display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; }
    /* Offer block on card */
    .biz-offer { display: flex; align-items: baseline; gap: 6px; flex-wrap: wrap;
      background: var(--accent-light); border: 1px solid rgba(var(--accent-rgb),.28); border-radius: 10px;
      padding: 11px 13px; margin-top: 10px; }
    .biz-offer-mag { font-size: var(--fs-2xl); font-weight: var(--fw-display); letter-spacing: -.02em; color: var(--accent-dark); }
    .biz-offer-terms { font-size: var(--fs-md); font-weight: var(--fw-display); color: var(--accent-dark); }
    .biz-offer-mech { font-size: var(--fs-sm); color: var(--warn); width: 100%; }
    /* Meta row at card bottom */
    .biz-card-meta { display: flex; align-items: center; gap: 6px; margin-top: auto; padding-top: 12px;
      font-size: var(--fs-sm); color: var(--muted); }
    .biz-cat-chip { display: inline-block; padding: 3px 8px; border-radius: 5px; background: var(--band, var(--neutral-bg));
      color: var(--band-ink, var(--neutral)); font-size: var(--fs-sm); font-weight: var(--fw-bold); }
    /* Status badges (owner's own drafts/pending — NOT in the public feed) */
    .biz-card-status { position: absolute; top: 10px; left: 10px; display: inline-flex; align-items: center; gap: 5px;
      font-size: var(--fs-xs); font-weight: var(--fw-display); padding: 4px 10px; border-radius: 999px;
      letter-spacing: .01em; box-shadow: 0 1px 3px rgba(0,0,0,.14); }
    .biz-card-status.is-pending { background: var(--warn-bg); color: var(--warn); }
    .biz-card-status.is-draft { background: var(--wash, #f2f4f7); color: var(--muted); }
    .biz-card-status.is-declined { background: var(--danger-bg); color: var(--danger); }
    .biz-card-status.is-review { background: var(--accent-bg, #e8f0fe); color: var(--accent-dark); }
    /* ── Listing detail page ── */
    /* Room at the bottom for the sticky action bar to rest in without covering the last
       lines of the listing. Owned here, by the element that contains both. */
    .biz-listing { width: 100%; margin-inline: auto; padding-bottom: 8px; }   /* --page-col applies in the desktop block */
    .biz-listing-body { background: var(--card); border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden; position: relative; }
    .bizl-hero { width: 100%; height: 300px; object-fit: cover; display: block; }
    .bizl-hero-gen { width: 100%; height: 300px; display: flex; align-items: center; justify-content: center;
      font-size: 64px; font-weight: var(--fw-display); }
    .bizl-scrim { position: absolute; top: 0; left: 0; right: 0; height: 300px; pointer-events: none;
      background: linear-gradient(180deg, rgba(0,0,0,.45) 0%, rgba(0,0,0,0) 34%, rgba(0,0,0,0) 55%, rgba(0,0,0,.62) 100%); }
    /* Sits over an arbitrary member photo, so it carries its OWN contrast rather than
       trusting the picture underneath it — a translucent scrim over a bright cover is
       a grey blob, which is exactly what this looked like in the wild. */
    .bizl-back { position: absolute; top: 14px; left: 14px; width: 44px; height: 44px; border-radius: 50%;
      background: rgba(17,24,39,.68); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
      border: 1px solid rgba(255,255,255,.24); box-shadow: 0 2px 10px rgba(0,0,0,.32);
      color: #fff; cursor: pointer; display: grid; place-items: center; z-index: 2; }
    .bizl-back:hover { background: rgba(17,24,39,.86); }
    .bizl-back:active { transform: scale(.94); }
    .bizl-back .ic { width: 22px; height: 22px; }
    .bizl-title-block { position: absolute; left: 20px; right: 20px; bottom: 18px; z-index: 1; pointer-events: none; }
    .bizl-cat-chip { display: inline-block; padding: 3px 8px; border-radius: 5px; background: rgba(255,255,255,.92);
      color: var(--band-ink, var(--neutral)); font-size: var(--fs-xs); font-weight: var(--fw-display);
      letter-spacing: .06em; text-transform: uppercase; margin-bottom: 6px; }
    .bizl-name { font-size: var(--fs-4xl); font-weight: var(--fw-display); letter-spacing: -.022em;
      color: #fff; text-shadow: 0 1px 12px rgba(0,0,0,.4); margin: 0; line-height: var(--lh-tight); }
    .bizl-tagline { font-size: var(--fs-lg); color: rgba(255,255,255,.9); margin: 2px 0 0; }
    /* Owner card */
    .bizl-owner { margin: 18px 20px; padding: 13px 14px; background: #fafaf9; border: 1px solid var(--border);
      border-radius: 12px; display: flex; align-items: center; gap: 12px; }
    .bizl-owner-avatar { width: 40px; height: 40px; border-radius: 50%; background: var(--accent); display: grid; place-items: center;
      font-size: var(--fs-lg); font-weight: var(--fw-display); color: #fff; flex-shrink: 0; }
    .bizl-owner-info { min-width: 0; }
    .bizl-owner-label { font-size: var(--fs-xs); font-weight: var(--fw-display); letter-spacing: .09em;
      text-transform: uppercase; color: var(--muted); }
    .bizl-owner-name { font-size: var(--fs-lg); font-weight: var(--fw-display); }
    .bizl-owner-detail { font-size: var(--fs-sm); color: var(--muted); }
    /* Offer panel on listing page */
    .bizl-offer { margin: 0 20px 16px; border-radius: 14px; background: var(--accent-light);
      border: 1px solid rgba(var(--accent-rgb),.28); overflow: hidden; }
    .bizl-offer-eyebrow { display: flex; align-items: center; gap: 6px; padding: 9px 16px;
      background: rgba(var(--accent-rgb),.1); font-size: var(--fs-xs); font-weight: var(--fw-display);
      letter-spacing: .1em; text-transform: uppercase; color: var(--accent-dark); }
    .bizl-offer-eyebrow .ic { width: 13px; height: 13px; }
    .bizl-offer-body { padding: 16px; }
    .bizl-offer-mag { font-size: var(--fs-offer-hero); font-weight: var(--fw-display); letter-spacing: -.03em;
      line-height: var(--lh-none); color: var(--accent-dark); }
    .bizl-offer-terms { font-size: var(--fs-xl); font-weight: var(--fw-display); color: var(--accent-dark); margin-top: 4px; }
    .bizl-offer-mech { font-size: var(--fs-md); color: var(--warn); line-height: var(--lh-body); margin-top: 6px; }
    /* About section */
    .bizl-about { padding: 0 20px 16px; }
    .bizl-micro { font-size: var(--fs-xs); font-weight: var(--fw-display); letter-spacing: .1em; text-transform: uppercase;
      color: var(--muted); margin: 0 0 6px; }
    .bizl-about p { font-size: var(--fs-lg); line-height: var(--lh-loose); margin: 0; text-wrap: pretty; white-space: pre-wrap; }
    /* Detail rows */
    .bizl-rows { padding: 0 20px; }
    .bizl-row { display: flex; align-items: center; gap: 10px; padding: 12px 0; border-top: 1px solid var(--border);
      font-size: var(--fs-lg); color: var(--text); }
    .bizl-row .ic { width: 16px; height: 16px; color: #9ca09e; flex-shrink: 0; }
    /* Why a listing isn't live, and the one button back — declined or unpublished. */
    .bizl-feedback { background: var(--danger-bg); color: var(--danger); border-radius: 10px;
      padding: 12px 14px; margin: 16px 20px; font-size: var(--fs-md); line-height: var(--lh-body); }
    .bizl-feedback-act { margin-top: 10px; }
    /* Gallery */
    .bizl-gallery { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
      gap: 8px; padding: 0 20px 16px; }
    .bizl-gallery img { width: 100%; aspect-ratio: 1; object-fit: cover; border-radius: var(--radius); }
    /* Disclaimer */
    /* The 120px here was a hand-tuned guess at the sticky action bar's height, and it
       guessed wrong — the bar clipped this sentence mid-line. The bar is a SIBLING of
       the body, so the room it needs belongs to the container, not to this paragraph. */
    .bizl-disclaimer { font-size: var(--fs-sm); font-style: italic; color: var(--muted); padding: 16px 20px 22px; }
    /* Leader actions in listing */
    .bizl-leader-acts { display: flex; gap: 8px; padding: 16px 20px; border-top: 1px solid var(--border); }
    /* Action bar */
    .biz-action-bar { position: sticky; bottom: 0; display: flex; align-items: center; gap: 10px;
      padding: 12px 16px 20px; background: rgba(255,255,255,.96); backdrop-filter: blur(10px);
      -webkit-backdrop-filter: blur(10px); border-top: 1px solid var(--border); border-radius: 0 0 var(--radius) var(--radius); }
    .biz-action-bar .biz-act-call { flex: 1; height: 48px; border-radius: 12px; background: var(--accent); color: #fff;
      font-size: var(--fs-xl); font-weight: var(--fw-display); border: none; cursor: pointer;
      display: inline-flex; align-items: center; justify-content: center; gap: 8px; font-family: inherit;
      transition: background .14s; }
    .biz-action-bar .biz-act-call:hover { background: var(--accent-hover); }
    .biz-action-bar .biz-act-icon { width: 48px; height: 48px; border-radius: 12px; background: var(--card);
      border: 1px solid var(--border); display: grid; place-items: center; cursor: pointer; flex-shrink: 0;
      transition: border-color .14s; }
    .biz-action-bar .biz-act-icon:hover { border-color: var(--accent); }
    .biz-action-bar .biz-act-icon .ic { width: 18px; height: 18px; color: var(--text); }
    /* (The Review button and its badge lived here. Both are gone — review is not a mode.
       The count now rides on the NAV entry: .nav-dest-badge, down with the nav styles.) */
    /* Edit/create modal */
    .biz-edit-card { max-width: 600px; }
    .bize-steps { display: flex; gap: 6px; margin-bottom: 16px; }
    .bize-step { flex: 1; height: 4px; border-radius: 2px; background: var(--border); transition: background .2s; }
    .bize-step.done { background: var(--accent); }
    .bize-step.active { background: var(--accent); }
    .bize-section { display: none; }
    .bize-section.active { display: block; }
    .bize-preview { background: var(--wash, #f7f8fa); border-radius: 12px; padding: 14px; margin-bottom: 16px; }
    .bize-cat-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 8px; }
    .bize-cat-btn { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 12px 6px; background: var(--card);
      border: 1px solid var(--border); border-radius: 10px; cursor: pointer; font: inherit; font-size: var(--fs-sm);
      font-weight: var(--fw-semi); color: var(--text); transition: border-color .14s; text-align: center; }
    .bize-cat-btn:hover { border-color: var(--accent); }
    .bize-cat-btn.active { border-color: var(--accent); background: var(--wash-orange, #fff8f4); color: var(--accent-dark); }
    .bize-cat-btn .ic { width: 22px; height: 22px; color: var(--accent); }
    .bize-perk-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
    .bize-perk-chip { display: inline-block; padding: 6px 12px; border: 1px solid var(--border); border-radius: 999px;
      font-size: var(--fs-sm); cursor: pointer; color: var(--muted); background: var(--card); transition: border-color .14s, color .14s; }
    .bize-perk-chip:hover { border-color: var(--accent); color: var(--accent-dark); }
    .bize-upload-zone { display: flex; align-items: center; justify-content: center; gap: 8px; padding: 24px 14px; border: 2px dashed var(--border);
      border-radius: 12px; cursor: pointer; color: var(--muted); font-size: var(--fs-md); font-weight: var(--fw-semi); transition: border-color .14s; }
    .bize-upload-zone:hover { border-color: var(--accent); color: var(--accent-dark); }
    .bize-upload-zone .ic { width: 20px; height: 20px; }
    .bize-upload-zone.is-busy { border-style: solid; color: var(--muted); pointer-events: none; }
    .bize-img-preview { max-width: 100%; max-height: 200px; object-fit: contain; border-radius: var(--radius); margin-top: 8px; }
    .bize-img-wrap { position: relative; display: inline-block; max-width: 100%; }
    .bize-opt { font-weight: var(--fw-body); color: var(--muted); }
    .bize-nav { display: flex; justify-content: space-between; gap: 8px; margin-top: 16px; }
    .bize-attest { display: flex; align-items: flex-start; gap: 9px; font-size: var(--fs-sm); color: var(--muted);
      line-height: var(--lh-body); margin: 14px 0; cursor: pointer; }
    .bize-attest input { margin-top: 2px; flex: 0 0 auto; accent-color: var(--accent); }
    /* Decline modal */
    .biz-decline-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 12px; }
    .biz-decline-chip { display: inline-block; padding: 6px 12px; border: 1px solid var(--border); border-radius: 999px;
      font-size: var(--fs-sm); cursor: pointer; color: var(--muted); background: var(--card); transition: border-color .14s, color .14s; }
    .biz-decline-chip:hover { border-color: var(--accent); color: var(--accent-dark); }
    /* Responsive */
    @media (max-width: 1100px) {
      .biz-grid { grid-template-columns: repeat(2, 1fr); }
    }
    @media (max-width: 760px) {
      .biz-filter-bar { padding-bottom: 12px; }
      .biz-grid { grid-template-columns: 1fr; gap: 18px; padding: 18px; }
      .biz-cover, .biz-cover-gen { height: 210px; }
      .biz-disclaimer { padding: 0 18px 18px; }
      .bizl-hero, .bizl-hero-gen, .bizl-scrim { height: 250px; }
      .biz-edit-card { max-width: 100%; }
      /* "Submit Your Business" is GONE from the bar on a phone — it moved into the Post
         drawer below (the Supply Closet's gesture, same reasoning: this is a board you
         come to browse, and a permanent 44px row for the rare visit is the wrong trade
         on an 812px screen). */
      #biz-hero-acts { display: none; }
      /* The drawer sits between the filter bar and the grid and pushes the board down
         when it opens — collapsed it costs the handle and nothing else. */
      .biz-filter-bar { margin-bottom: 0; }
      #biz-drawer { padding: 0 16px; }
    }
    /* The header was costing 286px before the first business on an 812px phone — more
       than a third of the screen spent on chrome, and the board is the point. Most of
       that is gone by construction now: the band is the standard single title row.
       These are the two pieces of copy that still have to go, and both were already
       being dropped here before the conversion — the kicker as .biz-eyebrow, the
       paragraph as part of the band. They are the band's meta and the bar's lede now,
       so they are hidden by their new names.
       The meta especially: .page-head-meta is `white-space: nowrap` and nothing else
       drops it, so left in place "Shop local, support a colleague" sits on the title
       row at 375 and pushes the row wider than the card. */
    @media (max-width: 640px) {
      #biz-hero-eyebrow { display: none; }
      .biz-filter-bar .page-lede { display: none; }
      .biz-filter-bar { gap: 8px; }
      /* A caption explaining the sort order is a desktop nicety, not worth a row here. */
      .biz-shuffle-note { display: none; }
      .biz-grid { padding: 14px 16px 0; gap: 14px; }
      .biz-cover, .biz-cover-gen { height: 165px; }
      .biz-disclaimer { padding: 14px 16px 16px; }
    }
    /* Below 760 there was NOTHING — the whole block stopped here, and the filter bar,
       the wizard's category grid and the detail view's back button were all still laid
       out for a desktop. These are the rules that were missing. */
    @media (max-width: 640px) {
      /* One flex row holding a 380px-basis search box, a scrolling chip rail and the
         shuffle note: at 375 the search collapsed to its 200px minimum and the other two
         fought over what was left. Stack them. */
      .biz-filter-bar { flex-wrap: wrap; gap: 10px; }
      /* WRITTEN AT .page-bar STRENGTH ON PURPOSE — the same trap the Members toolbar
         documents further up. A bare `.biz-search-wrap` ties with the generic
         `.page-bar > .page-search { flex: 1 1 auto }` and loses on source order (that
         rule is 4,000 lines further down), so the box took its intrinsic width and the
         chip rail tried to share the row with it. */
      /* The search SHARES its row with the funnel now — it used to take the full width
         and push the rail onto a row of its own. */
      .page-bar.biz-filter-bar > .biz-search-wrap { flex: 1 1 auto; min-width: 0; }
      #biz-search { height: 44px; }
      /* THE CHIP RAIL IS DESKTOP-ONLY. On a phone it was a 44px row under the search
         showing three of nine categories, the rest behind a swipe with no scrollbar and
         no affordance. The funnel says the same thing in 44×44 inline with the search,
         and the sheet it opens shows all nine at once with their counts — more filter,
         a row less chrome. */
      #biz-chips { display: none; }
      /* `grid`, NOT inline-flex — .icon-btn centres its glyph with `place-items: center`,
         which needs the grid box. (As a flex item the inline- prefix is blockified away
         anyway, so inline-flex bought nothing and cost the centring.) */
      .biz-filter-bar .biz-filter-btn { display: grid; flex: 0 0 44px; width: 44px; height: 44px; }
      .biz-filter-bar .biz-filter-btn.hidden { display: none; }
      /* 34px is under every touch-target guideline, and this is the ONLY way out of the
         detail view. */
      .bizl-back { width: 44px; height: 44px; }
      /* The category picker was two cramped columns of ten buttons with labels like
         "Photography & Events" wrapping inside them. One column reads. */
      .bize-cat-grid { grid-template-columns: 1fr 1fr; }
      .bize-cat-btn { min-height: 64px; }
      .bize-nav .btn { flex: 1; min-height: 44px; justify-content: center; }
      .biz-action-bar { padding-bottom: calc(10px + env(safe-area-inset-bottom)); }
    }
    @media (max-width: 380px) {
      .bize-cat-grid { grid-template-columns: 1fr; }
    }
    /* The step-5 preview renders the REAL card, so it needs the card's own width back —
       a board card is a grid cell, and in the wizard it is a standalone illustration. */
    /* ── The live card ──────────────────────────────────────────────────────
       The whole point of this form: a member isn't filling in a database record, they're
       watching their own business appear. It stays on screen for every step, sticky, so
       the thing they're making is never out of sight — and it is the REAL card component,
       so it cannot promise something the board won't deliver. */
    .bize-live { position: sticky; top: 0; z-index: 3; margin: 0 0 18px;
      padding: 12px 0 14px; background: var(--card); }
    .bize-live-cap { font-size: var(--fs-2xs); font-weight: var(--fw-display); letter-spacing: .1em;
      text-transform: uppercase; color: var(--muted); text-align: center; margin-bottom: 9px; }
    .bize-live-card { max-width: 300px; margin-inline: auto; transition: opacity .2s; }
    /* Before there's a name it's a ghost of a card — visibly the shape of what they're
       about to have, without pretending to be finished. */
    .bize-live.is-empty .bize-live-card { opacity: .5; }
    .bize-live .biz-card { animation: bize-pop .18s ease; }
    @keyframes bize-pop { from { transform: scale(.985); } to { transform: none; } }
    @media (prefers-reduced-motion: reduce) { .bize-live .biz-card { animation: none; } }
    /* Desktop has the room to put the card BESIDE the form, where it reads as a companion
       rather than something the form has to scroll past. */
    @media (min-width: 860px) {
      .biz-edit-card { max-width: 880px; }
      .biz-edit-card .modal-body { display: grid; grid-template-columns: 300px 1fr; gap: 26px; align-items: start; }
      .bize-steps, .bize-nav, #bize-msg { grid-column: 1 / -1; }
      .bize-live { grid-column: 1; grid-row: 2 / 99; margin-bottom: 0; align-self: start; top: 8px; }
      .bize-section { grid-column: 2; }
      .bize-final .bize-preview { display: none; }   /* the card is already beside them */
    }
    .bize-final-hd { font-size: var(--fs-2xl); font-weight: var(--fw-display); margin-bottom: 4px; }
    .bize-final-lede { font-size: var(--fs-md); color: var(--muted); margin: 0 0 14px; line-height: var(--lh-body); }

    .bize-preview-card { max-width: 300px; }
    .biz-card.is-preview { cursor: default; }
    .biz-card-sub { font-size: var(--fs-sm); color: var(--muted); margin-top: 6px; }
    /* The card is a real <button> now (see bizCardHTML) — strip the UA chrome. */
    .biz-card { all: unset; box-sizing: border-box; position: relative; background: var(--card); border: 1px solid var(--border);
      border-radius: 14px; overflow: hidden; display: flex; flex-direction: column; cursor: pointer;
      font-family: inherit; color: var(--text); text-align: left;
      transition: border-color .15s, box-shadow .15s, transform .12s; }
    .biz-card:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.18); }
    .biz-card:active { transform: scale(.995); }
    /* A button mid-request says so and cannot be pressed again. */
    .btn.is-busy { opacity: .75; pointer-events: none; }
    .bizl-hero-wrap { position: relative; }
    .bizl-loading { padding: 40px; }

    /* ── The reviewer's footer ──────────────────────────────────────────────
       Added ON to the bottom of the listing, after everything a member would see, and
       styled as a separate slab so it reads as "your job", not as part of the business's
       own page. It replaced two unlabelled buttons sitting mid-page, which told a leader
       neither what they were approving nor what declining would do to it. */
    .bizl-review { margin: 8px 20px 0; padding: 16px 18px; border-radius: 14px;
      background: var(--warn-bg); border: 1px solid rgba(0,0,0,.06); }
    .bizl-review-hd { display: flex; align-items: center; gap: 8px; font-size: var(--fs-md);
      font-weight: var(--fw-display); color: var(--warn); letter-spacing: .01em; }
    .bizl-review-hd .ic { width: 16px; height: 16px; }
    .bizl-review-lede { font-size: var(--fs-md); color: var(--text); line-height: var(--lh-body); margin: 8px 0 14px; }
    .bizl-review-acts { display: flex; gap: 10px; }
    .bizl-review-acts .btn { flex: 1; justify-content: center; min-height: 44px; }

    /* A listing waiting on this leader: the card is UNCHANGED — reviewing the real
       presentation is the entire point — and everything that marks it sits outside or
       across it. Dashed frame, warm tint behind, flag over the corner of the cover. */
    /* The ring is drawn INSIDE the card's own box. Outside, it lands in the scroller's
       padding — which is asymmetric, 18px left and 14px right for the scrollbar — so the
       frame sat tight against one edge and inset from the other, and the whole card read
       as crooked. A ring that costs no layout can't do that.
       It is a PSEUDO-ELEMENT and not an `outline`, because an inset outline is painted in
       the card's own layer while .biz-cover-wrap is a POSITIONED descendant — so the cover
       painted straight over the top and left runs and only the body half of the frame
       survived. Half a dashed box reads as a rendering fault, not as a flag. An absolutely
       positioned ring above every layer in the card closes it, and takes the card's radius,
       which an inset outline does not. */
    .biz-card.needs-review { background: var(--warn-bg); }
    .biz-card.needs-review::after { content: ''; position: absolute; inset: 3px; z-index: 3;
      border: 2px dashed var(--warn); border-radius: 11px; pointer-events: none; }
    /* Above the ring (3): the flag is pinned OVER the corner, so the dashes pass behind it
       rather than being drawn across its face. */
    .biz-review-flag { position: absolute; top: 0; right: 0; z-index: 4;
      padding: 6px 12px; border-bottom-left-radius: 12px;
      background: var(--warn); color: #fff; font-size: var(--fs-2xs);
      font-weight: var(--fw-display); letter-spacing: .02em; box-shadow: 0 2px 8px rgba(0,0,0,.18); }
    .biz-empty { border: 2px dashed var(--border); border-radius: 14px; padding: 40px 20px; text-align: center; }

    /* Help & Support — a plain-language, role/device-aware guide */
    .help-devtoggle { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 18px; }
    .help-dt-label { font-size: var(--fs-md); color: var(--muted); font-weight: var(--fw-semi); }
    .help-dt-seg { display: inline-flex; background: var(--wash, #f2f4f7); border: 1px solid var(--border); border-radius: 999px; padding: 3px; gap: 2px; }
    .help-dt-btn { display: inline-flex; align-items: center; gap: 6px; border: none; background: transparent; color: var(--muted); font: inherit; font-size: var(--fs-md); font-weight: var(--fw-semi); padding: 6px 14px; border-radius: 999px; cursor: pointer; }
    .help-dt-btn .ic { width: 15px; height: 15px; }
    .help-dt-btn.on { background: var(--card); color: var(--accent-dark); box-shadow: var(--shadow); }
    .help-topics { display: flex; flex-direction: column; gap: 10px; margin-bottom: 22px; }
    .help-topic { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; }
    .help-topic > summary { list-style: none; cursor: pointer; display: flex; align-items: center; gap: 11px; padding: 15px 18px; font-size: var(--fs-xl); font-weight: var(--fw-semi); color: var(--text); }
    .help-topic > summary::-webkit-details-marker { display: none; }
    /* Colored icon chip per topic (tint set inline via --htc) */
    .help-t-ic { width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center; flex: 0 0 auto; background: color-mix(in srgb, var(--htc, var(--accent)) 14%, transparent); color: var(--htc, var(--accent)); }
    .help-t-ic .ic { width: 18px; height: 18px; }
    .help-t-title { flex: 1; }
    .help-chev { color: var(--muted); font-size: var(--fs-2xl); transition: transform .15s; }
    .help-topic[open] > summary .help-chev { transform: rotate(90deg); }
    .help-topic[open] > summary { border-bottom: 1px solid var(--border); }
    .help-t-body { padding: 4px 18px 18px; font-size: var(--fs-lg); line-height: var(--lh-loose); color: var(--ink-soft, #2b2b2b); }
    .help-t-body p { margin: 10px 0; }
    .help-t-body b { color: var(--text); }
    .help-t-body .help-ul { margin: 10px 0; padding-left: 22px; }
    .help-ul li { margin: 7px 0; }
    /* Welcome hero at the top of Help */
    .help-hero { display: flex; align-items: center; gap: 14px; background: linear-gradient(135deg, color-mix(in srgb, var(--accent) 10%, var(--card)), var(--card)); border: 1px solid var(--border); border-radius: var(--radius); padding: 16px 18px; margin-bottom: 18px; }
    .help-hero-icon { width: 52px; height: 52px; border-radius: 13px; flex: 0 0 auto; box-shadow: var(--shadow); }
    .help-hero-tx h3 { margin: 0 0 2px; font-size: var(--fs-xl); }
    .help-hero-tx p { margin: 0; font-size: var(--fs-md); color: var(--muted); line-height: var(--lh-body); }
    /* Numbered steps with friendly colored circles */
    .help-steps2 { list-style: none; margin: 14px 0; padding: 0; counter-reset: hstep; }
    .help-steps2 > li { display: flex; align-items: flex-start; gap: 12px; margin: 0 0 12px; }
    .help-step-n { counter-increment: hstep; flex: 0 0 auto; width: 26px; height: 26px; border-radius: 50%; background: var(--accent); color: var(--on-accent, #fff); display: grid; place-items: center; font-size: var(--fs-md); font-weight: var(--fw-display); margin-top: 1px; }
    .help-step-n::before { content: counter(hstep); }
    .help-step-tx { flex: 1; padding-top: 2px; }
    /* Soft colored callouts */
    .help-note { display: flex; gap: 10px; align-items: flex-start; border-radius: 10px; padding: 11px 13px; font-size: var(--fs-md); line-height: var(--lh-body); margin: 12px 0; border: 1px solid var(--border); background: var(--wash, #f7f8fa); }
    .help-note-ic { flex: 0 0 auto; font-size: var(--fs-lg); line-height: var(--lh-snug); }
    .help-note-tip { background: var(--wash-orange, #fff4ec); border-color: var(--wash-orange-line, #ffd9bf); }
    .help-note-warn { background: var(--warn-bg); border-color: color-mix(in srgb, var(--warn) 30%, transparent); }
    /* Simple themeable illustration */
    .help-illus { display: flex; justify-content: center; margin: 6px 0 14px; }
    .help-illus svg { width: 100%; max-width: 260px; height: auto; }
    .help-illus-wide svg { max-width: 330px; }
    /* Feedback box (member-facing, bottom of Help) */
    /* Guide ⇄ Feedback rides the shared tab strip in the band (.page-tabs), the same
       object Contract and Site Config's hubs use. */
    .help-fb { margin-top: 4px; }
    /* Destination chooser — who is going to read this note */
    .help-fb-dests { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin: 14px 0 10px; }
    @media (max-width: 640px) { .help-fb-dests { grid-template-columns: 1fr; } }
    .help-fb-dest { display: flex; align-items: flex-start; gap: 10px; text-align: left; border: 1px solid var(--border); background: var(--card); border-radius: 12px; padding: 12px 13px; font: inherit; color: var(--text); cursor: pointer; }
    .help-fb-dest:hover { border-color: color-mix(in srgb, var(--accent) 45%, var(--border)); }
    .help-fb-dest.on { border-color: var(--accent); background: var(--wash-orange, #fff4ec); box-shadow: 0 0 0 1px var(--accent) inset; }
    .help-fb-dest-ic { width: 20px; height: 20px; flex: 0 0 auto; margin-top: 1px; color: var(--muted); }
    .help-fb-dest.on .help-fb-dest-ic { color: var(--accent-dark); }
    .help-fb-dest-tx { display: block; }
    .help-fb-dest-tx b { display: block; font-size: var(--fs-md); font-weight: var(--fw-bold); }
    .help-fb-dest-tx small { display: block; font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); margin-top: 2px; }
    .help-fb-who { margin: 0 0 4px; font-size: var(--fs-sm); color: var(--muted); font-style: italic; }
    .help-fb-cats { display: flex; gap: 8px; flex-wrap: wrap; margin: 12px 0; }
    .help-fb-cat { border: 1px solid var(--border); background: var(--card); color: var(--text); font: inherit; font-size: var(--fs-md); font-weight: var(--fw-semi); padding: 8px 13px; border-radius: 999px; cursor: pointer; }
    .help-fb-cat.on { border-color: var(--accent); background: var(--wash-orange, #fff4ec); color: var(--accent-dark); }
    .help-fb-body { width: 100%; border: 1px solid var(--border); border-radius: 10px; padding: 11px 13px; font: inherit; font-size: var(--fs-lg); color: var(--text); background: #fff; resize: vertical; min-height: 96px; }
    .help-fb-body:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    .help-fb-foot { display: flex; align-items: center; justify-content: flex-end; gap: 14px; margin-top: 12px; }
    .help-fb-msg { font-size: var(--fs-md); margin-right: auto; }
    .help-fb-msg.ok { color: var(--ok); font-weight: var(--fw-semi); }
    .help-fb-msg.err { color: var(--danger, #b42318); font-weight: var(--fw-semi); }

    /* Feedback inbox (Site Admin, in Settings) */
    .ss-badge { display: inline-grid; place-items: center; min-width: 18px; height: 18px; padding: 0 5px; margin-left: 6px; background: var(--danger, #b42318); color: #fff; font-size: var(--fs-xs); font-weight: var(--fw-bold); border-radius: 999px; }
    .um-stat-hot b { color: var(--danger, #b42318); }
    /* A .list-row that grows to its message — see THE CONFIG ROW. It used to be a
       bordered card with a 10px gap, the same shape Buildings was pulled out of.
       Two actions in the end slot, so --list-act-slot holds two 26px buttons.
       The LOCAL inbox's columns live with every other list's, down in COLUMNIZED ROWS. */
    .fb-item { padding-top: 10px; padding-bottom: 11px; }
    .fb-item .list-row-lead, .fb-item .list-row-end { margin-top: 1px; }
    /* (.fb-item.is-done lived here, dimming a "handled" row to .66. The local inbox has no
       done state any more — a note is read or it isn't — and the read ones must NOT be
       faded: in a list where nearly everything is read, dimming the majority is dimming the
       list. Unread is what's marked; see .fb-item.is-new down in COLUMNIZED ROWS.
       The platform inbox below still sets it, and still means "handled".) */
    .fb-item.is-done { opacity: .66; }
    /* ── The PLATFORM app-feedback inbox (Site Admin → Platform → App Feedback) ──
       Still the stacked card shape: it is a different screen, one tier up, and it is not
       on a config list's grid. These five classes serve it and nothing else — the local
       inbox stopped using them when it columnized. */
    .fb-item-top { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; margin-bottom: 4px; }
    .fb-cat-tag { font-size: var(--fs-sm); font-weight: var(--fw-bold); color: var(--text); }
    .fb-new-dot { font-size: var(--fs-2xs); font-weight: var(--fw-bold); color: var(--on-accent); background: var(--accent); padding: 2px 8px; border-radius: 999px; }
    .fb-meta { font-size: var(--fs-xs); color: var(--muted); }
    .fb-meta a { color: var(--accent-dark); }
    .fb-body { font-size: var(--fs-md); line-height: var(--lh-body); color: var(--ink-soft, #2b2b2b); white-space: pre-wrap; }
    /* (.fb-actions lived here — a row of labelled buttons under every message. They're
       icon buttons in the shared end slot now, where a row's actions live. .fb-main went
       with the local inbox's single content cell — its columns are cells, not a wrapper.) */

    /* Modal */
    .modal-overlay { position: fixed; inset: 0; background: rgba(17,24,39,.55); display: grid; place-items: center; z-index: 1000; padding: 20px; }
    .modal-card { background: var(--card); border-radius: 14px; width: 100%; max-width: 480px; max-height: 90vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 20px 60px rgba(0,0,0,.3); }
    .modal-hdr { padding: 16px 20px; border-bottom: 1px solid var(--border); display: flex; align-items: center; }
    .modal-hdr h3 { font-size: var(--fs-xl); font-weight: var(--fw-bold); flex: 1; }
    .modal-x { background: none; border: none; font-size: var(--fs-3xl); line-height: var(--lh-none); color: var(--muted); cursor: pointer; padding: 0 4px; }
    .modal-x:hover { color: var(--text); }
    .modal-body { padding: 20px; overflow-y: auto; }

    /* ── BOTTOM SHEETS (opt-in: .modal-overlay.as-sheet) ──────────────────────
       A centred card with 20px of dark all round is a desktop dialog. On a phone the
       same markup reads as a website's popup, not as part of the app — and with the
       keyboard up there is barely a card left. Adding `as-sheet` to the OVERLAY makes
       it rise from the bottom edge below 640px and changes nothing at all above it,
       so a feature opts in one modal at a time and every other dialog in the app is
       untouched. Body scroll-lock, Escape and focus handling come from sheetOpen() /
       sheetClose() in app.js — the CSS only decides where the card sits. */
    body.modal-open { overflow: hidden; }
    @media (max-width: 640px) {
      .modal-overlay.as-sheet { place-items: end stretch; padding: 0; }
      .modal-overlay.as-sheet > .modal-card {
        max-width: none; width: 100%;
        /* dvh, not vh: iOS's URL bar makes vh taller than the visible viewport, which
           puts the sheet's own action row under the browser chrome. */
        max-height: 92dvh;
        border-radius: 18px 18px 0 0;
        animation: sheet-rise .22s cubic-bezier(.22,.61,.36,1);
        padding-bottom: env(safe-area-inset-bottom);
      }
      /* The grab handle is the affordance that says "this drags/dismisses" even where
         it only taps — without it a sheet reads as a page that lost its top. */
      .modal-overlay.as-sheet > .modal-card .modal-hdr { position: relative; padding-top: 22px; }
      .modal-overlay.as-sheet > .modal-card .modal-hdr::before {
        content: ''; position: absolute; top: 8px; left: 50%; transform: translateX(-50%);
        width: 38px; height: 4px; border-radius: 999px; background: var(--border);
      }
      .modal-overlay.as-sheet > .modal-card .modal-x { min-width: 44px; min-height: 44px; }
    }
    @keyframes sheet-rise { from { transform: translateY(14%); opacity: .6; } to { transform: none; opacity: 1; } }
    @media (prefers-reduced-motion: reduce) {
      .modal-overlay.as-sheet > .modal-card { animation: none; }
    }

    /* District import modal */
    .dim-card { max-width: 600px; }
    .dim-desc { font-size: var(--fs-md); color: var(--muted); margin: 0 0 14px; line-height: var(--lh-body); }
    .dim-error { font-size: var(--fs-md); color: var(--danger); margin-bottom: 12px; }
    .dim-loading { font-size: var(--fs-md); color: var(--muted); text-align: center; padding: 28px 0; }
    .dim-controls { display: flex; align-items: center; gap: 8px; margin-bottom: 10px; }
    .dim-count { font-size: var(--fs-sm); color: var(--muted); margin-left: 4px; }
    .dim-list { display: flex; flex-direction: column; gap: 2px; max-height: 360px; overflow-y: auto; }
    .dim-row { display: flex; align-items: flex-start; gap: 10px; padding: 9px 10px; border-radius: 8px; cursor: pointer; transition: background .1s; }
    .dim-row:hover { background: var(--hover, rgba(0,0,0,.04)); }
    .dim-row-exists { opacity: .55; cursor: default; }
    .dim-row-link { background: var(--accent-light, #f0f4ff); }
    .dim-tag-link { color: var(--accent, #2952a3); }
    .dim-row input[type=checkbox] { margin-top: 2px; flex-shrink: 0; accent-color: var(--accent); width: 15px; height: 15px; }
    .dim-info { flex: 1; min-width: 0; }
    .dim-name { font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); }
    .dim-sub { font-size: var(--fs-sm); color: var(--muted); margin-top: 2px; display: flex; flex-wrap: wrap; gap: 5px; align-items: center; }
    .dim-badge { display: inline-block; font-size: var(--fs-xs); font-weight: var(--fw-semi); padding: 1px 7px; border-radius: 999px; background: var(--accent-light, #f0f4ff); color: var(--accent-dark, #2952a3); }
    .dim-badge-muted { background: var(--hover, #f3f4f6); color: var(--muted); }
    .dim-tag { font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--muted); margin-left: 4px; }
    .dim-foot { padding: 14px 20px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; flex-shrink: 0; }
    /* Save-layout modal: name input + click-to-overwrite chips (replaces the OS prompt) */
    .savelayout-h { font-size: var(--fs-sm); font-weight: var(--fw-bold); letter-spacing: .03em; text-transform: uppercase; color: var(--muted); margin: 16px 0 8px; }
    .savelayout-list { display: flex; flex-wrap: wrap; gap: 7px; }
    .savelayout-chip { display: inline-flex; align-items: center; gap: 6px; padding: 7px 11px; background: var(--card); border: 1.5px solid var(--border); border-radius: 999px; font-family: inherit; font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); cursor: pointer; transition: border-color .12s, background .12s, color .12s; }
    .savelayout-chip:hover { border-color: var(--accent); }
    .savelayout-chip.sel { border-color: var(--accent); background: var(--accent); color: var(--on-accent); }
    .savelayout-warn { margin-top: 12px; padding: 9px 12px; border-radius: 9px; font-size: var(--fs-md); font-weight: var(--fw-semi); background: var(--accent-light); border: 1px solid rgba(var(--accent-rgb),.35); color: var(--accent-dark); }
    /* Page broadcast dialog (#bcast-modal): the reach split leads, because it is the
       fact the author is writing FOR. Chips are fast fills, not a fence. */
    .bcast-reach { padding: 12px 14px; border-radius: 10px; background: var(--accent-light); border: 1px solid rgba(var(--accent-rgb),.28); margin-bottom: 16px; }
    .bcast-reach-line { font-size: var(--fs-lg); color: var(--accent-dark); }
    .bcast-reach-line b { font-weight: var(--fw-bold); }
    .bcast-hint { margin: 6px 0 0; font-size: var(--fs-sm); line-height: var(--lh-body); color: var(--muted); }
    .bcast-lbl { display: block; font-size: var(--fs-sm); font-weight: var(--fw-bold); letter-spacing: .03em; text-transform: uppercase; color: var(--muted); margin: 14px 0 6px; }
    .bcast-lbl:first-of-type { margin-top: 0; }
    #bcast-modal textarea { width: 100%; resize: vertical; font-family: inherit; font-size: var(--fs-md); line-height: var(--lh-body); }
    .bcast-presets { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
    .bcast-chip { font-family: inherit; font-size: var(--fs-sm); font-weight: var(--fw-medium); padding: 5px 10px; border-radius: 999px; border: 1px solid var(--border); background: var(--card); color: var(--muted); cursor: pointer; transition: border-color .12s, color .12s; }
    .bcast-chip:hover { border-color: var(--accent); color: var(--accent-dark); }
    .bcast-window { display: inline-flex; border: 1px solid var(--border); border-radius: 8px; overflow: hidden; }
    .bcast-seg { font-family: inherit; font-size: var(--fs-md); font-weight: var(--fw-medium); min-height: 44px; padding: 7px 16px; border: 0; background: var(--card); color: var(--muted); cursor: pointer; border-right: 1px solid var(--border); }
    .bcast-seg:last-child { border-right: 0; }
    .bcast-seg.active { background: var(--accent); color: var(--on-accent); font-weight: var(--fw-semi); }
    /* Reusable in-app confirm dialog (replaces window.confirm) */
    .uiconfirm-msg { font-size: var(--fs-lg); line-height: var(--lh-body); white-space: pre-line; color: var(--text); }
    /* A label never breaks mid-phrase: "Save &\nLeave" reads as broken chrome, and
       the local's typeface is now a variable (a wide face costs this row real
       width). The card is sized for the three-button form in index.html. */
    #uiconfirm-modal .btn-row .btn { white-space: nowrap; }
    .form-row { display: flex; gap: 12px; }
    .form-row .form-field { flex: 1; }
    .sup-preview { margin-top: 8px; position: relative; }
    .sup-preview img { width: 100%; max-height: 200px; object-fit: contain; border-radius: 9px; border: 1px solid var(--border); background: #f9fafb; }
    /* Downscaling a camera photo takes a beat on a phone — say so rather than showing
       an empty box that reads as a broken picker. */
    .sup-preview.is-loading { min-height: 120px; border-radius: 9px; border: 1px solid var(--border);
      background: #f9fafb; display: grid; place-items: center; }
    .sup-preview.is-loading img { display: none; }
    .sup-preview.is-loading::after { content: 'Preparing photo…'; font-size: var(--fs-sm); color: var(--muted); }
    .sup-preview.is-loading .sup-preview-x { display: none; }
    /* Having picked the wrong photo, there was previously no way to un-pick it. */
    .sup-preview-x { position: absolute; top: 8px; right: 8px; width: 32px; height: 32px; border-radius: 50%;
      border: 1px solid var(--border); background: var(--card); color: var(--text); font-size: var(--fs-xl);
      line-height: var(--lh-none); cursor: pointer; display: grid; place-items: center; box-shadow: 0 2px 6px rgba(0,0,0,.14); }
    .sup-preview-x:hover { background: var(--danger-bg); color: var(--danger); border-color: var(--danger); }

    /* ── THE AUTHORING CANVAS — Composer · Page Designer · GroupMe ────────────
       One canvas, three studios: an editor column and a live device preview, side
       by side inside the page frame. The .compose-* names are historical (the email
       Composer was first); all three wear them, so a change here is a change to all
       three — check GroupMe before touching a shared rule.
       (Retired with the frame conversion: .compose-subject — the subject moved into
       the Send flow and nothing has worn the class since; .compose-recip; .compose-top
       and .gm-comp-title, both left behind when GroupMe took the standard band;
       .compose-addrow .addrow-end, which never had a producer; .dsgn-head, worn last
       by the Composer's floating Back button, now the band's trail.) */
    /* (.compose-wrap is gone. It was GroupMe's own page column — no gutter, no cap —
       and it was the last of the three composers not on .app-container. The other two
       are .page-frame children now.) */
    /* (The bespoke .compose-actionbar is gone — the Composer's commits sit in the
       standard .page-foot now, with Save Layout beside Print and Send or Share.) */
    #compose-share-btn { display: inline-flex; align-items: center; gap: 7px; }
    #compose-share-btn .ic { font-size: var(--fs-lg); }
    .compose-from { font-size: var(--fs-sm); color: var(--muted); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
    .compose-quota { font-size: var(--fs-sm); color: var(--muted); }
    .compose-attachments { display: flex; gap: 6px; flex-wrap: wrap; }
    .send-attach { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }   /* attach button + chips, inside the Send modal's email panel */
    .attach-chip { display: inline-flex; align-items: center; gap: 5px; background: var(--accent-light); color: var(--accent-dark); border-radius: 999px; padding: 3px 10px; font-size: var(--fs-sm); }
    .attach-chip button { background: none; border: none; cursor: pointer; color: var(--accent-dark); font-size: var(--fs-sm); padding: 0 0 0 2px; }
    .compose-quota.low { color: var(--accent-dark); font-weight: var(--fw-semi); }
    .compose-quota.over { color: #b91c1c; font-weight: var(--fw-bold); }
    .compose-banner { flex-shrink: 0; padding: 8px 20px; background: var(--accent-light); color: var(--accent-dark); font-size: var(--fs-md); border-bottom: 1px solid var(--border); }
    .compose-banner a { color: var(--accent-dark); font-weight: var(--fw-bold); }
    /* The layout being edited powers an automated email and no longer contains the
       block that email is built around — the edit will save but the email will keep
       the old design, so this has to read as a problem, not as a note. */
    .compose-banner.warn { background: var(--warn-bg); color: var(--warn); border-bottom-color: var(--warn); }
    .compose-banner.warn a { color: var(--warn); }
    /* The editor column's minimum is the narrowest a block card reads at; the preview's
       is the narrowest a scaled device frame is still worth looking at. Their sum has to
       clear the stack breakpoint below (400 + 520 = 920 < 1000) or the columns would
       collide before the layout ever stacks. Both studios sized this differently (520 vs
       560) for no reason anyone recorded; the more generous minimum wins. */
    .compose-body { flex: 1; min-height: 0; display: grid; grid-template-columns: minmax(400px, 480px) minmax(520px, 1fr); }
    /* The editor column's side padding IS the card's gutter — it is the wall between the
       card and the first thing you read, which is what --pad-card names. (It was a
       literal 18px, so it neither matched the app's 22 nor narrowed to 14 on a phone.) */
    .compose-editor { min-height: 0; overflow-y: auto; scrollbar-gutter: stable; padding: 16px var(--pad-card); border-right: 1px solid var(--border); }
    .compose-addrow { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-bottom: 14px; position: relative; }
    .add-toggle { display: inline-flex; align-items: center; gap: 7px; background: var(--accent-light); color: var(--accent-dark); border: 1px solid rgba(var(--accent-rgb),.35); border-radius: 9px; padding: 8px 14px; font-size: var(--fs-md); font-weight: var(--fw-bold); font-family: inherit; cursor: pointer; }
    /* A step deeper than the --accent-light base, DERIVED from the tenant's accent. It
       was #ffedd5 — a Tailwind peach over a brand-coloured base, so on any local that
       isn't orange the button changed hue on hover. */
    .add-toggle:hover { background: rgba(var(--accent-rgb),.16); }
    /* Block catalog: a floating popover so it never pushes the builder down.
       Tile styling mirrors the Content Pages designer's "+ Add a Block"
       palette (.pg-palette/.pg-addblk) so both editors feel like one system. */
    .compose-addbar { position: absolute; top: 100%; left: 0; margin-top: 6px; z-index: 30; width: 100%; max-width: 500px; max-height: 56vh; overflow-y: auto; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 10px 12px; box-shadow: 0 14px 34px rgba(16,24,40,.18); }
    .addgrp { margin-bottom: 10px; }
    .addgrp:last-child { margin-bottom: 0; }
    .addgrp-label { font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .5px; color: var(--muted); margin-bottom: 6px; }
    /* The tiles themselves are .pg-addblk (see the Content Pages block below) —
       ONE component for both editors. .addtile/.at-ico/.at-label were the
       Composer's separate Lucide-icon version and are gone with it. */
    .add-toggle.alt { background: var(--card); color: var(--text); border-color: var(--border); }
    .add-toggle.alt:hover { background: var(--bg); border-color: var(--accent); }
    /* Color theme popover */
    .compose-themebar { position: absolute; top: 100%; left: 0; margin-top: 6px; z-index: 30; width: 100%; max-width: 360px; max-height: 62vh; overflow-y: auto; background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 12px; box-shadow: 0 14px 34px rgba(16,24,40,.18); }
    .theme-presets { display: grid; grid-template-columns: repeat(2, 1fr); gap: 6px; margin-bottom: 11px; }
    .theme-chip { display: flex; align-items: center; gap: 8px; padding: 7px 9px; background: var(--bg); border: 1px solid var(--border); border-radius: 9px; cursor: pointer; font-family: inherit; }
    .theme-chip:hover { border-color: var(--accent); }
    .tc-sw { width: 26px; height: 18px; border-radius: 4px; position: relative; flex-shrink: 0; display: inline-block; border: 1px solid rgba(0,0,0,.08); }
    .tc-sw > span { position: absolute; right: 2px; bottom: 2px; width: 9px; height: 9px; border-radius: 2px; }
    .tc-name { font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--text); text-align: left; line-height: var(--lh-tight); }
    /* Light/Dark edit-mode toggle in the colors palette */
    .theme-modes { display: flex; gap: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 9px; padding: 3px; margin-bottom: 12px; }
    .tm-tab { flex: 1; display: inline-flex; align-items: center; justify-content: center; gap: 6px; background: none; border: none; border-radius: 7px; padding: 6px 10px; font-size: var(--fs-md); font-weight: var(--fw-semi); font-family: inherit; color: var(--muted); cursor: pointer; }
    .tm-tab.on { background: var(--card); color: var(--text); box-shadow: 0 1px 3px rgba(16,24,40,.12); }
    .tm-tab .ic { font-size: var(--fs-md); }
    .theme-darknote { font-size: var(--fs-xs); color: var(--muted); line-height: var(--lh-body); margin: 0 0 10px; }
    .theme-rows { display: flex; flex-direction: column; gap: 8px; }
    .theme-row { display: flex; align-items: center; gap: 9px; }
    .theme-row.inheriting input[type=color], .theme-row.inheriting .theme-hex { opacity: .5; }
    .theme-row.inheriting .theme-hint { color: var(--accent-dark); opacity: .85; }
    .theme-row input[type=color] { width: 34px; height: 30px; padding: 0; border: 1px solid var(--border); border-radius: 7px; background: none; cursor: pointer; flex-shrink: 0; }
    .theme-meta { flex: 1; min-width: 0; }
    .theme-lbl { font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); }
    .theme-hint { font-size: var(--fs-xs); color: var(--muted); }
    .theme-hex { width: 80px; flex-shrink: 0; padding: 6px 8px; border: 1px solid var(--border); border-radius: 7px; font-size: var(--fs-sm); font-family: ui-monospace, "SF Mono", Menlo, monospace; text-transform: lowercase; background: #fff; color: var(--text); }
    .theme-hex:focus { outline: none; border-color: var(--accent); }
    .theme-actions { display: flex; gap: 8px; margin-top: 12px; }
    .theme-actions .btn { flex: 1; }
    /* Rename/delete on a saved-layout row — revealed on hover by .tpl-row-acts */
    .tpl-del, .tpl-rename { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: 50%; border: none; background: rgba(0,0,0,.06); color: var(--muted); line-height: var(--lh-none); cursor: pointer; padding: 0; }
    .tpl-del:hover { background: var(--danger); color: #fff; }
    .tpl-rename:hover { background: var(--accent); color: var(--on-accent); }
    .tpl-del .lic, .tpl-rename .lic { width: 12px; height: 12px; }
    /* Per-block mobile/dark overrides */
    .blk-view { margin-top: 9px; border-top: 1px dashed var(--border); padding-top: 7px; }
    .blk-view summary { font-size: var(--fs-xs); color: var(--muted); cursor: pointer; font-weight: var(--fw-bold); list-style: none; user-select: none; }
    .blk-view summary::-webkit-details-marker { display: none; }
    .blk-view summary:hover { color: var(--accent-dark); }
    .blk-check { display: flex; align-items: center; gap: 7px; font-size: var(--fs-md); color: var(--text); margin: 8px 0; }
    .blk-check input { width: auto; margin: 0; }
    .blk-view-row { display: flex; align-items: center; gap: 8px; font-size: var(--fs-md); color: var(--text); margin: 6px 0; }
    .blk-view-row span { flex: 1; }
    .blk-view-row input[type=color] { width: 34px; height: 26px; padding: 0; border: 1px solid var(--border); border-radius: 6px; background: none; cursor: pointer; flex-shrink: 0; }
    .blk-view-row .blk-btn { width: 24px; height: 24px; }
    .blk-view-hint { font-size: var(--fs-xs); color: var(--muted); margin-top: 7px; }
    /* Rich-text editor */
    .rt-toolbar { display: flex; gap: 2px; flex-wrap: wrap; margin-bottom: 6px; }
    .rt-toolbar button { background: #fff; border: 1px solid var(--border); border-radius: 6px; min-width: 30px; padding: 4px 7px; font-size: var(--fs-md); font-family: inherit; cursor: pointer; color: var(--text); }
    .rt-toolbar button:hover { border-color: var(--accent); }
    .rt-edit { min-height: 70px; border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px; font-size: var(--fs-md); line-height: var(--lh-body); background: #fff; color: var(--text); }
    .rt-edit:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    /* ── Signature: editor beside a live preview ──
       This tab used to be a bare toolbar and a contenteditable on the panel background —
       no card, and no way to see the thing you were writing until you sent an email to
       yourself. Same split as the rest of Branding: change this | watch this. */
    /* stretch + a growing editor: the two boxes are a matched pair, and the writing
       area is the thing that should absorb whatever height the preview needs. */
    .sig-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 14px; align-items: stretch; }
    .sig-editor { display: flex; flex-direction: column; }
    .sig-editor .rt-edit { min-height: 132px; flex: 1 1 auto; }
    .sig-stage { display: flex; flex-direction: column; }
    .sig-editor .rt-toolbar { margin-bottom: 8px; }
    /* A sheet of white with a real message on it: a signature is only ever read at the
       end of something, and its size and colour only make sense against that. */
    /* Every value here comes from EM (public/js/email-tokens.js), set on the element by
       sigApplyEmailTokens() — the same ladder lib/email-theme.js renders real mail with.
       The fallbacks match EM today and exist only so an unstyled first paint isn't naked;
       the tokens are the source of truth. NOTE the body size: emails are 16px, not the
       app's 13px, and previewing at app scale would misrepresent every signature. */
    .sig-stage .sig-mail-wrap { background: var(--em-page, #f1f2f4); border-radius: 10px; padding: 14px; }
    .sig-mail { background: var(--em-card, #fff); border: 1px solid var(--em-line, #e4e7eb); border-radius: 8px; padding: 16px 18px;
                color: var(--em-ink, #3f4753); font-family: var(--em-font, inherit); font-size: var(--em-body, 16px); line-height: var(--em-lh, 1.6); }
    .sig-mail-body p { margin: 0 0 12px; }
    .sig-mail-sig { border-top: 1px solid var(--em-line, #e4e7eb); padding-top: 13px; margin-top: 15px; word-break: break-word; }
    .sig-mail-sig img { max-width: 100%; height: auto; }
    .sig-empty { color: #9aa0a6; font-style: italic; }
    .sig-stage .ai-plat-note { margin-top: 10px; }
    @media (max-width: 900px) { .sig-grid { grid-template-columns: minmax(0, 1fr); } }
    .rt-edit a { color: var(--accent); }
    .blk { position: relative; background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 12px; margin-bottom: 10px; box-shadow: var(--shadow); transition: border-color .12s, box-shadow .12s; }
    .blk:hover { border-color: #c9ccd2; box-shadow: 0 4px 14px rgba(16,24,40,.08); }
    /* ── "Look here" — clicking a block in the live preview ────────────────────
       Deliberately a two-beat sequence, and deliberately NOT instant: the panel
       is still gliding when the click lands, so the cue is fired by JS only once
       the scroll has SETTLED (see focusEditorBlock). Competing with the scroll
       just reads as jitter — arriving after it reads as "and… here it is."
       Beat 1: the card takes a breath — lifts, overshoots slightly, settles.
       Beat 2: a single halo ripples outward from the border and dissolves.
       No shaking/vibrating: a shudder reads as an ERROR, and nothing is wrong. */
    .blk.blk-focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light), 0 6px 20px rgba(var(--accent-rgb),.20); animation: blkFocus 1.5s cubic-bezier(.22,.9,.3,1) both; z-index: 1; }
    @keyframes blkFocus {
      0%   { transform: scale(1)     translateY(0);    box-shadow: 0 0 0 0 rgba(var(--accent-rgb),0),  var(--shadow); }
      18%  { transform: scale(1.028) translateY(-3px); box-shadow: 0 0 0 4px var(--accent-light), 0 12px 26px rgba(var(--accent-rgb),.28); }
      42%  { transform: scale(.996)  translateY(0);    box-shadow: 0 0 0 3px var(--accent-light), 0 5px 16px rgba(var(--accent-rgb),.20); }
      62%  { transform: scale(1.006) translateY(-1px); }
      100% { transform: scale(1)     translateY(0);    box-shadow: 0 0 0 3px var(--accent-light), 0 6px 20px rgba(var(--accent-rgb),.20); }
    }
    /* The halo. Inset slightly so it emerges from UNDER the card's own edge
       rather than appearing as a second border sitting on top of it. */
    .blk.blk-focus::after {
      content: ''; position: absolute; inset: 2px; border-radius: 12px;
      border: 2px solid rgba(var(--accent-rgb),.55); pointer-events: none;
      animation: blkHalo .9s cubic-bezier(.16,.8,.3,1) .12s both;
    }
    @keyframes blkHalo {
      0%   { opacity: 0;  transform: scale(1); }
      12%  { opacity: .9; }
      100% { opacity: 0;  transform: scale(1.055); border-width: 1px; }
    }
    /* The cue is decoration; the ring + focused field carry the meaning without it. */
    @media (prefers-reduced-motion: reduce) {
      .blk.blk-focus { animation: none; }
      .blk.blk-focus::after { animation: none; content: none; }
    }
    /* Drop feedback lives in the shared .dnd-* rules — see public/js/dnd.js. */
    .blk-head { display: flex; align-items: center; gap: 6px; margin-bottom: 8px; }
    /* A real <button> now (focusable + keyboard-reorderable), so it has to shed the
       UA button chrome and opt back into the app's font like every other control. */
    .blk-drag { cursor: grab; color: #b6bbc4; font-size: var(--fs-lg); padding: 0 2px; user-select: none; background: none; border: 0; font-family: inherit; line-height: var(--lh-none); display: inline-flex; align-items: center; }
    .blk-drag:active { cursor: grabbing; }
    .blk-drag:hover { color: var(--accent); }
    .blk-type { font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .5px; color: var(--muted); flex: 1; }
    .blk-btn { background: none; border: 1px solid var(--border); border-radius: 6px; width: 26px; height: 26px; cursor: pointer; font-size: var(--fs-md); color: var(--muted); font-family: inherit; }
    .blk-btn:hover { border-color: var(--accent); color: var(--accent-dark); }
    .blk-btn.del:hover { border-color: #ef4444; color: #ef4444; }
    .blk-btn:disabled { opacity: .35; cursor: default; border-color: var(--border); color: var(--muted); }
    .blk input, .blk textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 7px; font-size: var(--fs-md); font-family: inherit; background: #fff; color: var(--text); margin-bottom: 6px; }
    .blk input:focus, .blk textarea:focus { outline: none; border-color: var(--accent); }
    .blk textarea { resize: vertical; min-height: 60px; }
    .blk .blk-row { display: flex; gap: 6px; align-items: center; }
    .blk .blk-row > * { margin-bottom: 0; }
    /* The shared icon cell (pick · emoji · upload · clear) rides one size down in
       the composer: its editor column is narrower than the Content config form,
       and the heading input beside it needs the room. */
    .blk .df-iconcell { gap: 4px; }
    .blk .df-iconbtn { width: 34px; height: 34px; flex: 0 0 34px; font-size: var(--fs-xl); }
    .blk .df-iconbtn svg { width: 22px; height: 22px; }
    .blk .df-iconbtn.ghost svg { width: 17px; height: 17px; }
    .blk .df-icon { flex: 0 0 42px; text-align: center; }
    .blk-img-preview { width: 100%; max-height: 120px; object-fit: contain; border-radius: 6px; background: #f3f4f6; margin-bottom: 6px; display: block; }
    /* The shared block editor (public/js/block-editor.js) emits the Designer's field
       classes, so a generated form looks the same in both studios. Inside a Composer
       card the element rules (.blk input) would otherwise out-specify them. */
    /* The Composer's card carries the SAME form as the Designer's now — one
       generator, one spacing, one item treatment. What is left here is only the
       de-scoping the element rules above force: `.blk input` sets a width, a
       background and a 6px bottom margin on every input inside a Composer card, so
       the shared classes have to out-specify it or a generated form would come out
       with the old chain's rhythm. (This block used to be a second design — 10px
       between fields against the Designer's 14px, a 9px item radius against 12px,
       UPPERCASE row labels against sentence case. Same blocks, two looks, which is
       the drift the shared editor exists to end.) */
    .blk .pg-field.bed-last { margin-bottom: 0; }
    .blk input.pg-input, .blk textarea.pg-input, .blk select.pg-select { width: 100%; padding: 8px 11px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-md); font-family: inherit; background: var(--card); color: var(--text); margin-bottom: 0; }
    .blk textarea.bed-area { min-height: 68px; resize: vertical; }
    .blk input.pg-input[type="color"] { padding: 2px; height: 32px; }
    .blk .pg-check { display: flex; align-items: center; gap: 7px; font-size: var(--fs-md); margin: 4px 0 10px; }
    .blk .pg-check input { width: auto; margin: 0; }
    .blk .pg-del-x { background: none; border: 0; color: var(--muted); cursor: pointer; font-size: var(--fs-md); padding: 2px 6px; border-radius: 6px; font-family: inherit; }
    .blk .pg-del-x:hover { color: var(--danger); background: var(--danger-bg); }
    /* A 54px thumbnail is a flex item beside two buttons; without this it is the
       one thing in the row that shrinks when the column gets narrow. */
    .blk .pg-imgprev { flex: none; }
    .compose-preview { min-height: 0; display: flex; flex-direction: column; }   /* the stage below paints the recess; a second grey here covered nothing */
    .cp-toolbar { flex-shrink: 0; display: flex; align-items: center; gap: 10px; padding: 10px 16px; border-bottom: 1px solid var(--border); background: var(--card); }
    .cp-label { font-size: var(--fs-md); font-weight: var(--fw-bold); flex: 1; }
    .cp-page { border: 1px solid var(--border); border-radius: 7px; padding: 5px 9px; font-size: var(--fs-md); font-family: inherit; background: var(--card); color: var(--text); cursor: pointer; }
    .cp-page:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
    .cp-tab { background: none; border: 1px solid var(--border); border-radius: 7px; padding: 5px 11px; font-size: var(--fs-md); font-family: inherit; cursor: pointer; color: var(--muted); }
    .cp-tab.on { border-color: var(--accent); background: var(--accent-light); color: var(--accent-dark); font-weight: var(--fw-semi); }
    /* v2: Desktop/Mobile/Dark as one clean segmented control (matches the colors Light/Dark toggle) */
    .cp-toggle { display: flex; gap: 3px; background: var(--bg); border: 1px solid var(--border); border-radius: 9px; padding: 3px; }
    .cp-toggle .cp-tab { border: none; background: none; border-radius: 7px; padding: 5px 11px; color: var(--muted); font-weight: var(--fw-semi); display: inline-flex; align-items: center; gap: 6px; }
    .cp-toggle .cp-tab.on { background: var(--card); color: var(--text); box-shadow: 0 1px 3px rgba(16,24,40,.14); }
    .cp-toggle .cp-tab .ic { font-size: var(--fs-md); }
    .cp-stage { flex: 1; min-height: 0; overflow: auto; display: flex; justify-content: safe center; align-items: safe center; padding: 22px; background: #e7e5e4; }
    .cp-stage.dark { background: #1b1b1b; }
    /* v3: the email sits inside a fixed-size emulated screen — a laptop browser
       window (desktop) or an iPhone (mobile) — so the preview always reflects how
       the message lands on a real device: empty space when short, scroll when long.
       The device keeps its true pixel size; zoom (see applyPreviewZoom) scales it. */
    .cp-window { position: relative; flex: 0 0 auto; border-radius: 12px; background: #fff; box-shadow: 0 18px 50px rgba(16,24,40,.22); overflow: hidden; }
    .cp-stage.dark .cp-window { box-shadow: 0 18px 50px rgba(0,0,0,.55); }
    /* Mobile preview: a simple generic phone frame (no Mac chrome). */
    /* Mobile preview: a clean iPhone 17 Pro Max silhouette (deliberately simple —
       titanium rail, Dynamic Island, rounded screen). Screen is 440pt wide. */
    .cp-window.phone { background: #1d1d1f; padding: 14px 13px; border-radius: 58px; box-shadow: inset 0 0 0 2px #38383c, 0 22px 60px rgba(16,24,40,.34); }
    .cp-window.phone .cp-winbar { display: none; }
    /* Dynamic Island */
    .cp-window.phone::before { content: ""; position: absolute; top: 27px; left: 50%; transform: translateX(-50%); width: 106px; height: 30px; background: #000; border-radius: 16px; z-index: 3; }
    /* ONE phone, both studios. The silhouette is shared (same 58px shell, same island),
       so two screen heights inside it — 932 here, 880 in the Designer — was drift, not a
       decision: the same handset, two sizes, depending which studio you opened.
       WRITTEN AT ID STRENGTH ON PURPOSE. The desktop sizes are `#cp-frame` and
       `.dp-frame` — an id and a class — so a plain `.cp-window.phone iframe` (0,2,1)
       LOST to `#cp-frame` (1,0,0) and the phone rendered its screen 1024×720: a wide,
       squat handset showing a desktop-width email. `.mobile` is the class
       makeDevicePreview already toggles onto the frame, so matching it beside each
       id/class is exact rather than a specificity trick. */
    #cp-frame.mobile, .dp-frame.mobile { width: 430px; height: 932px; border-radius: 46px; }
    /* ── The welcome panel: what fills the preview pane before there IS a preview ──
       It is the SCROLLER for this pane while it is up, which keeps the frame's
       "two scrollers" shape true in both states (see FRAMED → compose) and means a
       short window scrolls the card rather than clipping it. */
    /* On the card, not on a wash. A gradient here was the panel decorating ITSELF —
       every other pane in this app is white and lets its contents carry the colour,
       and the brand mark and the three icons are already doing that. */
    .compose-welcome { flex: 1; min-height: 0; overflow-y: auto; display: flex; align-items: center; justify-content: center;
      padding: var(--pad-card); background: var(--card); }
    .cw-card { max-width: 52ch; text-align: left; }
    .cw-logo { display: block; height: 46px; width: auto; max-width: 220px; object-fit: contain; margin-bottom: 20px; }
    /* No logo configured — the wordmark the emails themselves fall back to, so the
       first impression matches what members will actually receive. */
    .cw-mark { display: inline-flex; align-items: center; justify-content: center; margin-bottom: 20px;
      padding: 9px 15px; border-radius: 12px; background: var(--accent); color: var(--on-accent);
      font-size: var(--fs-xl); font-weight: var(--fw-display); letter-spacing: -.01em; }
    .cw-h { margin: 0 0 10px; font-size: var(--fs-3xl); font-weight: var(--fw-display); line-height: var(--lh-tight); color: var(--text); }
    .cw-p { margin: 0 0 22px; font-size: var(--fs-lg); line-height: var(--lh-body); color: var(--muted); }
    .cw-p b { color: var(--text); font-weight: var(--fw-semi); }
    .cw-list { list-style: none; margin: 0 0 22px; padding: 0; display: flex; flex-direction: column; gap: 14px; }
    .cw-list li { display: flex; gap: 12px; align-items: flex-start; font-size: var(--fs-md); line-height: var(--lh-body); color: var(--muted); }
    .cw-list b { color: var(--text); font-weight: var(--fw-semi); }
    .cw-ic { flex: none; display: grid; place-items: center; width: 30px; height: 30px; border-radius: 9px;
      background: var(--card); border: 1px solid var(--border); color: var(--accent-dark); }
    .cw-ic .lic { width: 16px; height: 16px; }
    .cw-foot { margin: 0; font-size: var(--fs-sm); color: var(--muted); opacity: .85; }

    .cp-winbar { display: flex; align-items: center; gap: 10px; padding: 9px 14px; background: #f1f1f0; border-bottom: 1px solid #e2e0dc; }
    .cp-dots { display: flex; gap: 6px; flex-shrink: 0; }
    .cp-dots i { width: 11px; height: 11px; border-radius: 50%; display: block; }
    .cp-dots i:nth-child(1) { background: #f87171; } .cp-dots i:nth-child(2) { background: #fbbf24; } .cp-dots i:nth-child(3) { background: #34d399; }
    .cp-winurl { flex: 1; text-align: center; font-size: var(--fs-xs); color: #6b7280; background: #fff; border-radius: 6px; padding: 3px 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    /* ── The three device viewports, and why each is the size it is ────────────
       Each renders at REAL size and scrolls internally; zoom scales the whole device.
       The phone is shared (above). The two desktop sizes differ ON PURPOSE, because
       they emulate different things:
         #cp-frame  1024×720   an email client's reading pane — the 600px email card
                               fills it the way it fills a real one, rather than
                               floating as a thin strip in a huge viewport.
         .dp-frame  1920×1080  a full desktop browser, so a page's --page-max cap
                               renders as the centred column a visitor actually sees.
         #cp-frame.paper 816×1056   US Letter at 96dpi. LOAD-BEARING: SHEET_H in
                               app.js and scripts/_postertest.mjs both encode 1056,
                               and the one-sheet meter reads it. Do not touch. */
    #cp-frame { width: 1024px; height: 720px; border: none; background: #fff; display: block; }
    .cp-zoom { display: flex; align-items: center; gap: 2px; }
    .cp-zoom-lbl { font-size: var(--fs-xs); color: var(--muted); min-width: 36px; text-align: center; }
    /* Poster designs preview as a real sheet of US Letter — 8.5×11in at 96dpi — so
       what you see is literally the page that comes out of the printer. No browser
       chrome, no phone silhouette: just paper with a drop shadow. */
    #cp-frame.paper { width: 816px; height: 1056px; }
    /* The one-sheet meter. Poster mode only — it reads the measured height of the
       sheet above (see updatePosterFit); the 1056px here and there are the same
       11in at 96dpi and must stay in step. */
    .cp-fit { margin-left: auto; font-size: var(--fs-sm); font-weight: var(--fw-semi); padding: 3px 10px; border-radius: 999px; white-space: nowrap; }
    .cp-fit.ok { color: var(--ok); background: var(--ok-bg); }
    .cp-fit.over { color: var(--warn); background: var(--warn-bg); }
    .cp-window.paper { border-radius: 2px; box-shadow: 0 14px 44px rgba(16,24,40,.28), 0 0 0 1px rgba(16,24,40,.08); }
    .cp-window.paper .cp-winbar { display: none; }
    /* One stack breakpoint for every studio on this canvas. It was 900 for the
       Composer and GroupMe and 1000 for the Designer, so the identical two-pane layout
       collapsed at two different widths depending which one you had open. 1000 wins:
       the columns' own minimums (400 + 520) leave nothing to spare much below it. */
    @media (max-width: 1000px) { .compose-body { grid-template-columns: 1fr; } .compose-editor { border-right: none; border-bottom: 1px solid var(--border); } }

    /* ── Page Designer (#section-designer) — page-side twin of the Composer ─────
       The Designer's SHELL is the page frame now (.app-container > .page-head.bare +
       .page-frame > .compose-body + .page-foot — the same shared canvas classes
       GroupMe carries). What remains below is the Designer's editor INTERNALS: the
       add-row/palette and the accordion block cards. */
    /* Back button leading the breadcrumb — Back from the editor lands on the chooser
       (Composer) / the page list (Designer), which doubles as "change layout". */
    .crumb-back { display: inline-flex; align-items: center; gap: 5px; flex: none; background: none; border: 1px solid var(--border); border-radius: 8px; padding: 5px 11px; font: inherit; font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); cursor: pointer; }
    .crumb-back:hover { border-color: var(--accent); color: var(--accent-dark); }
    /* Breadcrumb (shared, new): keeps users oriented after popping out of the sidebar */
    .crumbs { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; font-size: var(--fs-md); }
    .crumb { background: none; border: none; padding: 0; font: inherit; color: var(--accent-dark); font-weight: var(--fw-semi); cursor: pointer; }
    button.crumb:hover { text-decoration: underline; }
    .crumb.current { color: var(--text); font-weight: var(--fw-bold); cursor: default; }
    .crumbs span.crumb:not(.current) { color: var(--muted); cursor: default; }   /* non-link category label (e.g. "Communications") */
    .crumb-sep { color: var(--muted); }
    /* (.dsgn-body/.dsgn-editor/.dsgn-preview/.dsgn-foot are gone — the shell is the
       shared .compose-* canvas inside .page-frame, and the footer is .page-foot.) */

    /* ── The Designer's bar: readership, beside the page ───────────────────────
       The generic .page-bar assumes it sits inside a padded .page-body and supplies
       its own bottom margin. This card has no padding — the two-pane canvas fills it
       edge to edge — so the bar carries the gutter itself, and its hairline IS the
       separator from the canvas, with no 14px of dead grey under it. */
    /* MARGIN, not padding, for the side gutter: everywhere else the bar sits inside a
       padded .page-body, so its box — and therefore its hairline — is already inset from
       both card walls. That is the card-divider standard (equal distance from both
       edges, never running across the card's own border), and padding here would have
       stretched the rule wall-to-wall instead. Bottom margin is zero because the canvas
       starts at the rule; the 14px the generic bar leaves would be dead grey. */
    #section-designer .page-bar { margin: 0 var(--pad-card); padding: 13px 0; }
    #section-designer .page-bar .page-acts { display: flex; gap: 8px; flex: none; }
    /* Numbers stay quiet. This is context for a decision, not a scoreboard — the bold
       figures inside the sentence carry the emphasis (see .page-lede b). */
    #dsgn-stats { color: var(--muted); }
    /* The Composer band's quota chip. Quiet until it matters. */
    #compose-band-meta.is-low { color: var(--warn); font-weight: var(--fw-semi); }

    /* ── The Readers drawer ───────────────────────────────────────────────────
       Slides over the PREVIEW pane, not the whole screen: the editor stays live behind
       it, because these numbers exist to change what you do next and you should be able
       to do it without dismissing them first. */
    .compose-preview { position: relative; }
    .dsgn-drawer { position: absolute; inset: 0; z-index: 40; display: flex; flex-direction: column;
      background: var(--card); border-left: 1px solid var(--border); box-shadow: -10px 0 30px rgba(16,24,40,.10); }
    .dsgn-drawer-hd { flex: none; display: flex; align-items: center; gap: 12px; padding: 13px var(--pad-card);
      border-bottom: 1px solid var(--border); }
    .dsgn-drawer-hd h3 { margin: 0; flex: 1; font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--text); }
    .dsgn-drawer-x { flex: none; width: 30px; height: 30px; border: none; border-radius: 8px; background: none;
      color: var(--muted); font-size: var(--fs-xl); line-height: var(--lh-none); cursor: pointer; display: grid; place-items: center; }
    .dsgn-drawer-x:hover { background: var(--neutral-bg); color: var(--text); }
    .dsgn-drawer-body { flex: 1; min-height: 0; overflow-y: auto; padding: 16px var(--pad-card); }
    /* The privacy line is PINNED, not scrolled past. It is the one claim on this screen
       a member would care about, and a promise you have to scroll to find is not one
       you are really making. */
    .dsgn-drawer-privacy { flex: none; margin: 0; padding: 11px var(--pad-card); border-top: 1px solid var(--border);
      background: var(--neutral-bg); color: var(--muted); font-size: var(--fs-sm); }
    .dsgn-rd-top { margin: 0 0 16px; font-size: var(--fs-md); color: var(--muted); }
    .dsgn-rd-top b { color: var(--text); font-weight: var(--fw-semi); }
    .dsgn-rd-sec + .dsgn-rd-sec { margin-top: 22px; padding-top: 18px; border-top: 1px solid var(--border); }
    .dsgn-rd-sec h4 { margin: 0 0 10px; font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase;
      letter-spacing: .6px; color: var(--muted); }
    .dsgn-rd-note { margin: 10px 0 0; font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); }
    .dsgn-rd-row { display: flex; align-items: baseline; gap: 10px; padding: 7px 0; font-size: var(--fs-md); }
    .dsgn-rd-row + .dsgn-rd-row { border-top: 1px solid var(--border); }
    .dsgn-rd-when { flex: none; width: 56px; color: var(--muted); font-size: var(--fs-sm); }
    .dsgn-rd-what { flex: 1; min-width: 0; color: var(--text); }
    .dsgn-rd-what b { font-weight: var(--fw-semi); }
    .dsgn-rd-n { flex: none; font-weight: var(--fw-bold); color: var(--accent-dark); }
    /* Stretched to the drawer's width, so the box has to be TALL or every slope
       flattens into a straight line — see dsgnSparkHTML. The stroke is
       vector-effect:non-scaling, or the horizontal stretch would thin it to a hair. */
    .dsgn-spark { display: block; width: 100%; height: 96px; }
    .dsgn-spark-ends { display: flex; justify-content: space-between; margin-top: 4px; font-size: var(--fs-xs); color: var(--muted); }

    /* ── "Who Can Find This" — the same drawer, the other question ────────────
       A content page is live the moment it saves, and nothing said so; the only clue
       was a grey line reading "Menu id: welcome-page", which is the word for it in the
       storage layer and answers a question nobody asked. This states the status, hands
       over the LINK (the word "id" never appears), and computes reachability out of
       the menus the leader already maintains. */
    .wcf-state { padding: 12px 14px; border-radius: 10px; font-size: var(--fs-md); line-height: var(--lh-body); margin-bottom: 18px; }
    .wcf-state b { font-weight: var(--fw-semi); }
    .wcf-live { background: var(--ok-bg); color: var(--text); border: 1px solid var(--ok); }
    .wcf-draft { background: var(--neutral-bg); color: var(--text); border: 1px solid var(--border); }
    .wcf-link { display: flex; gap: 8px; align-items: center; }
    .wcf-link .pg-input { flex: 1; min-width: 0; font-size: var(--fs-sm); }
    .wcf-reach { margin: 0; font-size: var(--fs-md); color: var(--muted); line-height: var(--lh-body); }
    .wcf-reach.ok { color: var(--text); }
    .wcf-reach b { color: var(--text); font-weight: var(--fw-semi); }
    .wcf-menus { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px; }
    /* Page preview device screens: desktop laptop browser / iPhone (mobile). */
    .dp-frame { width: 1920px; height: 1080px; border: none; background: #fff; display: block; }   /* see the viewport note above; the phone size is the shared one */
    /* Top "+ Add a Block" + palette (mirrors the Composer's add-row). */
    .dsgn-addrow { position: relative; margin-bottom: 14px; }
    .dsgn-addrow .add-toggle.is-open { background: var(--card); color: var(--text); border-color: var(--border); }
    /* Both editors' palettes are the same component (blockPaletteHTML) — and now the
       same container AND the same behaviour: it floats over the canvas, closes when
       you pick, and closes when you click away. The Designer's used to open inline
       and push the whole block list down, then stay open after a pick, so adding
       three blocks meant the list jumped three times under a panel that never left. */
    .dsgn-addbar { position: absolute; top: 100%; left: 0; margin-top: 6px; z-index: 30; width: 100%; max-width: 500px; max-height: 56vh; overflow-y: auto; background: var(--card); border: 1px solid var(--border); border-radius: var(--radius, 12px); padding: 10px 12px; box-shadow: 0 14px 34px rgba(16,24,40,.18); }
    /* Accordion block list: summary row that expands in place to edit. */
    .dsgn-blocks { display: flex; flex-direction: column; gap: 9px; }
    .dsgn-blk { position: relative; border: 1px solid var(--border); border-radius: 11px; background: var(--card,#fff); transition: border-color .12s, box-shadow .12s; }
    .dsgn-blk:hover { border-color: #c9ccd2; }
    .dsgn-blk.open { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
    .dsgn-blk.dragging { opacity: .4; }
    .dsgn-blk-head { display: flex; align-items: center; gap: 10px; padding: 10px 12px; }
    /* The title area is a real disclosure BUTTON (aria-expanded + aria-controls), so
       the block list is operable and announceable from the keyboard — it used to be a
       div with an onclick, reachable only by the ✎ caret beside it. Chrome stripped;
       it has to read as the row it replaced, not as a control. */
    button.dsgn-blk-meta { display: block; text-align: left; background: none; border: 0; padding: 0; font: inherit; color: inherit; cursor: pointer; }
    button.dsgn-blk-meta:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 5px; }
    /* See .blk-drag — a real <button> for keyboard reorder, chrome stripped. */
    .dsgn-grip { cursor: grab; color: var(--muted); font-size: var(--fs-xl); line-height: var(--lh-none); user-select: none; flex: none; background: none; border: 0; padding: 0 2px; font-family: inherit; }
    .dsgn-grip:active { cursor: grabbing; }
    .dsgn-grip:hover { color: var(--accent); }
    .dsgn-blk-head:hover .dsgn-grip { color: var(--accent); }
    .dsgn-blk-meta { flex: 1; min-width: 0; }
    .dsgn-blk-type { display: block; font-weight: var(--fw-bold); font-size: var(--fs-md); color: var(--text); }
    .dsgn-blk-sum { display: block; font-size: var(--fs-sm); color: var(--muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .dsgn-blk-acts { display: flex; align-items: center; gap: 4px; flex: none; }
    /* ── THE EXPANDED BLOCK ────────────────────────────────────────────────────
       One open card, one rhythm, both studios. The Composer and the Designer used
       to disagree about all of it — 10px between fields here, 14px there; a 9px
       item radius here, 12px there — because each had grown its own form code and
       therefore its own spacing. The form is generated from one file now
       (public/js/block-editor.js), so this is where the SPACING is decided once.

       The gutter is 14px on purpose and not --pad-card: this card is already
       nested two levels deep inside the page card, and a third full gutter would
       spend a third of a 400px editor column on margin. The head above it uses the
       same 12px, so the body's fields hang on the head's text, not inboard of it. */
    .dsgn-blk-body { padding: 12px 14px 14px; border-top: 1px dashed var(--border); }
    .dsgn-blk-body > *:last-child { margin-bottom: 0; }
    /* What the block IS, before the boxes that configure it. Straight from the
       registry's own `desc` — the same sentence the palette tile's tooltip shows,
       which the editor used to throw away. A teacher opening "Goal Thermometer"
       met "Current / Goal / Unit" and had to work the rest out. */
    .bed-lede { margin: 0 0 12px; font-size: var(--fs-sm); line-height: var(--lh-body); color: var(--muted); }
    /* Two short fields that are ONE fact — a date and a time, a current and a goal.
       Stacked, they spent two rows saying so and pushed the rest below the fold.
       Which fields pair is declared in block-editor.js (PAIRS), never guessed. */
    .pg-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; align-items: start; }
    .pg-pair > .pg-field { margin-bottom: 0; min-width: 0; }
    /* A list with nothing in it says so. A bare label over a lone "+ Add" button
       reads as something that failed to load rather than as an empty list. */
    .bed-empty { margin: 0 0 8px; font-size: var(--fs-sm); color: var(--muted); font-style: italic; }
    .bed-add { margin-top: 2px; }
    /* A note that belongs to the block rather than to one of its fields. */
    .bed-note { margin: 10px 0 0; font-size: var(--fs-sm); line-height: var(--lh-body); color: var(--muted); }
    .bed-note a { color: var(--accent); }
    .bed-warn { color: var(--warn); font-weight: var(--fw-semi); }
    /* The "…or paste an address" box under an image widget is the LAST resort of
       the three ways in, so it recedes rather than competing with the two buttons
       above it. */
    .bed-imgurl { margin-top: 8px; }
    .pg-optrow-img { align-items: flex-start; }
    .pg-optrow-img > .pg-field { flex: 1; min-width: 0; margin-bottom: 0; }
    /* Drop feedback lives in the shared .dnd-* rules — see public/js/dnd.js.
       Narrow-width stacking rides the shared .compose-body media rule above. */
    /* ── The studios stand down on a phone ────────────────────────────────────
       Authoring is desktop-only by decision (docs/Composer-Designer-Plan.md §1.3), and
       what a decision like that owes the reader is a sentence, not a broken layout: a
       400px editor column stacked on a 1024px device frame is what they got before.
       Scoped to the two studios ONLY — GroupMe shares this canvas and genuinely works
       on a phone, so it keeps its stacked layout. The band stays up either way, so
       Back is always reachable. */
    .studio-placard { display: none; }
    @media (max-width: 760px) {
      #section-compose .compose-body, #section-compose .page-foot, #section-compose .compose-banner,
      /* The stats bar stands down with the canvas it describes: it is a fact about a
         page you cannot edit at this width, and leaving it up would put a bar and a
         hairline above a placard that is meant to be the only thing on the card. */
      #section-designer .compose-body, #section-designer .page-foot, #section-designer .page-bar { display: none; }
      #section-compose .studio-placard, #section-designer .studio-placard {
        display: flex; flex: 1; min-height: 0; flex-direction: column; align-items: center; justify-content: center;
        gap: 10px; text-align: center; padding: var(--pad-card); margin: var(--pad-card);
        border: 1px dashed var(--border); border-radius: var(--radius, 12px); color: var(--muted); }
      .studio-placard .ic { font-size: var(--fs-4xl); color: var(--accent); line-height: var(--lh-none); }
      .studio-placard b { font-size: var(--fs-lg); color: var(--text); }
      .studio-placard span:not(.ic) { font-size: var(--fs-md); line-height: var(--lh-body); max-width: 34ch; }
    }
    /* GroupMe composer: the same authoring canvas, with a chat-phone preview. */
    .gm-stage { background: #e9eef3; align-items: flex-start; }
    .gm-phone { width: 340px; max-width: 100%; background: #fff; border-radius: 18px; box-shadow: 0 10px 30px rgba(16,24,40,.18); overflow: hidden; }
    .gm-phone-hdr { background: #00aff0; color: #fff; padding: 12px 16px; font-weight: var(--fw-bold); font-size: var(--fs-lg); }
    .gm-phone-body { padding: 16px; background: #f4f6f8; min-height: 170px; }
    .gm-bubble { background: #fff; border-radius: 4px 14px 14px 14px; padding: 10px 13px; font-size: var(--fs-lg); line-height: var(--lh-body); color: #111827; box-shadow: 0 1px 2px rgba(0,0,0,.08); white-space: pre-wrap; word-break: break-word; max-width: 88%; }
    .gm-bubble .gm-sender { font-size: var(--fs-sm); font-weight: var(--fw-bold); color: #0a7fb3; margin-bottom: 3px; }
    .gm-bubble a { color: #1d72c2; word-break: break-all; }
    .gm-bubble .gm-empty { color: #9aa3ad; }

    /* ── Send / Share popup (destination chooser) ───────────────────────── */
    .send-tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
    .send-tile { display: flex; flex-direction: column; align-items: center; gap: 7px; padding: 20px 10px; background: var(--card); border: 1.5px solid var(--border); border-radius: 12px; cursor: pointer; font-family: inherit; transition: border-color .12s, transform .08s, background .12s; }
    .send-tile:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-light); transform: translateY(-2px); }
    .send-tile:disabled { opacity: .45; cursor: not-allowed; }
    .send-tile .st-ico { font-size: calc(25px * var(--fs-scale, 1)); line-height: var(--lh-none); }
    .send-tile .st-ico .lic { width: 26px; height: 26px; }
    .send-tile .st-name { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--text); }
    .send-tile .st-sub { font-size: var(--fs-xs); color: var(--muted); text-align: center; line-height: var(--lh-snug); }
    .send-back { background: none; border: none; color: var(--muted); font-size: var(--fs-md); font-weight: var(--fw-semi); font-family: inherit; cursor: pointer; padding: 0; margin-bottom: 14px; }
    .send-back:hover { color: var(--accent-dark); }
    .send-from-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; padding-bottom: 14px; border-bottom: 1px solid var(--border); }
    .send-from-row:empty { display: none; }
    .send-field { margin-bottom: 12px; }
    .send-field label { display: block; font-size: var(--fs-sm); font-weight: var(--fw-bold); color: var(--muted); margin-bottom: 5px; }
    .send-field input { width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 9px; font-size: var(--fs-lg); font-family: inherit; background: #fff; color: var(--text); }
    .send-field input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    /* ── Send To: choosing an audience ────────────────────────────────────────
       Radio-cards rather than a <select>: a teacher is choosing between four kinds
       of thing, and the sub-line is what tells them which. The count under it is
       the server's answer, never a number computed here — the browser does not
       have the roster (routes/audience.js). */
    .aud-pick { display: flex; flex-direction: column; gap: 6px; }
    .aud-opt { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; text-align: left; width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 9px; background: var(--card); font-family: inherit; cursor: pointer; }
    .aud-opt:hover { border-color: var(--accent); }
    .aud-opt.on { border-color: var(--accent); background: var(--accent-light); box-shadow: 0 0 0 2px rgba(var(--accent-rgb),.18); }
    .aud-opt-name { font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); }
    .aud-opt-sub { font-size: var(--fs-sm); color: var(--muted); }
    .aud-chips { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 2px 6px 12px; }
    .aud-chip { display: inline-flex; align-items: center; gap: 6px; padding: 5px 11px; border: 1px solid var(--border); border-radius: 999px; background: var(--card); font-family: inherit; font-size: var(--fs-sm); color: var(--text); cursor: pointer; }
    .aud-chip:hover { border-color: var(--accent); }
    .aud-chip.on { border-color: var(--accent); background: var(--accent-light); color: var(--accent-dark); font-weight: var(--fw-semi); }
    .aud-n { font-size: var(--fs-xs); color: var(--muted); }
    .aud-chip.on .aud-n { color: var(--accent-dark); }
    .aud-none, .aud-hint { font-size: var(--fs-sm); color: var(--muted); }
    .aud-count { margin-top: 8px; min-height: 20px; }
    /* The number leads. It is the one fact that decides whether to press Send. */
    .aud-big { font-size: var(--fs-lg); color: var(--text); }
    .aud-miss { background: none; border: 0; padding: 0 0 0 4px; font-family: inherit; font-size: var(--fs-sm); color: var(--accent-dark); text-decoration: underline; cursor: pointer; }
    .aud-bcc { font-size: var(--fs-sm); color: var(--muted); margin-top: 2px; }
    .aud-more { margin: 4px 0 10px; }
    .aud-more > summary { font-size: var(--fs-sm); color: var(--muted); cursor: pointer; padding: 4px 0; }
    .send-actions { display: flex; align-items: center; gap: 10px; justify-content: flex-end; margin-top: 18px; flex-wrap: wrap; }

    /* ── "Pick up where you left off?" ────────────────────────────────────────
       Leads the chooser, and is the one thing on that screen allowed to draw a box:
       it is not a starting point among starting points, it is work the leader already
       did. Accent-tinted rather than warning-coloured — nothing has gone wrong, the
       app is handing something back. The Designer's twin below shares the shape. */
    .tpl-resume, .dsgn-resume {
      display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
      padding: 13px 15px; margin-bottom: 20px;
      background: var(--accent-light); border: 1px solid var(--accent); border-radius: 10px;
    }
    .tpl-resume-body { flex: 1 1 240px; min-width: 0; }
    .tpl-resume-h, .dsgn-resume-h { font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); }
    .tpl-resume-p, .dsgn-resume-p { font-size: var(--fs-sm); color: var(--muted); margin-top: 2px; }
    .tpl-resume-note { display: block; font-size: var(--fs-xs); color: var(--muted); margin-top: 5px; }
    .tpl-resume-acts, .dsgn-resume-acts { display: flex; gap: 8px; flex-shrink: 0; }
    /* The Designer's sits in a 400px editor column, so it stacks instead of sitting
       in one row, and its actions go full width rather than crowding the edge. */
    .dsgn-resume { display: block; margin-bottom: 14px; }
    .dsgn-resume-acts { margin-top: 10px; }
    /* Someone else saved this page while the draft sat in a closed tab. The only
       genuinely risky restore, so it is the only one that changes colour. */
    .dsgn-resume.is-stale { background: var(--warn-bg); border-color: var(--warn); }
    .dsgn-resume-warn { font-size: var(--fs-sm); color: var(--text); margin-top: 8px; }

    /* Template starter (empty state). Rows grouped by purpose, not a wall of tiles —
       the description is what distinguishes them, so it gets real estate. */
    .tpl-empty { padding: 6px 2px 14px; }
    .tpl-empty-h { font-size: var(--fs-2xl); font-weight: var(--fw-display); color: var(--text); }
    .tpl-empty-p { font-size: var(--fs-md); color: var(--muted); margin: 5px 0 22px; max-width: 46ch; }
    .tpl-group + .tpl-group { margin-top: 22px; }
    .tpl-group-h { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .6px; color: var(--muted); padding-bottom: 8px; border-bottom: 1px solid var(--border); margin-bottom: 8px; }
    .tpl-group-sub { text-transform: none; letter-spacing: 0; font-weight: var(--fw-body); font-size: var(--fs-xs); color: var(--muted); opacity: .85; }
    .tpl-rows { display: flex; flex-direction: column; }
    .tpl-row { display: flex; align-items: flex-start; gap: 13px; width: 100%; text-align: left; padding: 12px 10px; background: none; border: none; border-radius: 9px; cursor: pointer; font-family: inherit; transition: background .12s; }
    .tpl-row + .tpl-row { box-shadow: inset 0 1px 0 var(--border); }
    .tpl-row:hover { background: var(--accent-light); box-shadow: none; }
    .tpl-row:hover + .tpl-row { box-shadow: none; }
    .tpl-row-ico { flex: 0 0 auto; display: flex; align-items: center; justify-content: center; width: 36px; height: 36px; border-radius: 9px; background: var(--card); border: 1px solid var(--border); color: var(--text); font-size: var(--fs-xl); }
    .tpl-row-ico .lic { width: 18px; height: 18px; }
    .tpl-row:hover .tpl-row-ico { border-color: var(--accent); color: var(--accent-dark); }
    /* ── A picture of the thing, not an icon naming its topic ─────────────────
       The row's mark used to be one glyph, which told you a template's SUBJECT and
       nothing about its SHAPE — the one thing a chooser of layouts exists to show.
       Two layers, deliberately stacked: the block WIREFRAME underneath (drawn from
       the design's own blocks, so it can never drift from what it builds) and, over
       it, the real snapshot taken when the layout was saved or the email was sent.
       If that image 404s — saved before thumbnails existed, or the browser could not
       snapshot — it removes itself and the wireframe is already there. No fallback
       handler, no broken-image flash, no empty box. */
    .tpl-row-thumb { position: relative; flex: 0 0 auto; width: 74px; height: 52px; border-radius: 8px; overflow: hidden;
      background: var(--card); border: 1px solid var(--border); }
    .tpl-row-thumb .pgw { position: absolute; inset: 5px; gap: 3px; }
    .tpl-row-thumb .tpl-shot { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; object-position: top center; background: #fff; }
    .tpl-row:hover .tpl-row-thumb { border-color: var(--accent); }
    /* Filter, shown only once there is enough to filter (see renderBlocks). */
    .tpl-filter { margin: 0 0 18px; }
    .tpl-filter .pg-input { width: 100%; }
    .tpl-row-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; flex: 1; }
    .tpl-row-name { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--text); }
    .tpl-row-desc { font-size: var(--fs-sm); line-height: var(--lh-body); color: var(--muted); }
    .tpl-tag { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-2xs); font-weight: var(--fw-semi); text-transform: uppercase; letter-spacing: .4px; color: var(--muted); background: var(--bg); border: 1px solid var(--border); border-radius: 999px; padding: 2px 7px; }
    .tpl-tag .lic { width: 11px; height: 11px; }
    .tpl-row-acts { flex: 0 0 auto; display: flex; gap: 4px; opacity: 0; align-self: center; }
    .tpl-row:hover .tpl-row-acts, .tpl-row:focus-within .tpl-row-acts { opacity: 1; }

    /* Request types */
    /* (Request-type rows are columnized — Request Type | Routes To — by the
       --list-cols rule above. The old per-row "routes to →" label is gone: it was
       a column heading printed once per row.) */

    /* User management */
    /* (The access dropdown's width comes from the positions --list-cols grid.) */
    /* (.pos-acts / .pos-lock lived here — a second, slightly different end slot and
       a 15px lock in a section whose every other lead icon was 16px. Both are the
       shared slots now; see THE CONFIG ROW.) */
    /* Locked anchors (President top / Member bottom): read-only, faintly tinted, lock glyph in place of the grip. */
    .pos-row.pos-locked { opacity: .82; }
    /* "1 per [25] members" — the coverage target, inline in the Building
       Representative row. The row's locked dim is a parent opacity, which a child
       cannot undo, so the editable field earns its contrast from the text colour
       instead: label muted, number in --text. */
    .pos-cover { display: flex; align-items: center; gap: 6px; min-width: 0; font-size: var(--fs-sm); color: var(--muted); white-space: nowrap; }
    .pos-cover-in { flex: none; width: calc(58px * var(--fs-scale, 1)); padding: 5px 7px; text-align: center; color: var(--text); font-weight: var(--fw-semi); }
    /* Buildings: each is an editable card with name + address + phone */
    .bldg-row { padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: #fff; margin-bottom: 8px; }
    .bldg-top { display: flex; align-items: center; gap: 8px; }
    .bldg-top .pos-name { flex: 1; font-weight: var(--fw-semi); }
    .bldg-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; margin-top: 8px; }
    .bldg-field { display: flex; flex-direction: column; gap: 3px; }
    .bldg-field span { font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
    .bldg-field input { padding: 7px 10px; border: 1px solid var(--border); border-radius: 7px; font-size: var(--fs-md); font-family: inherit; background: #fff; color: var(--text); }
    .bldg-field input:focus { outline: none; border-color: var(--accent); }
    @media (max-width: 560px) { .bldg-fields { grid-template-columns: 1fr; } }
    .bldg-addr-row { margin-top: 8px; }
    /* Buildings LIST row — columnized (icon · name/short · address · phone · chevron)
       so the data spreads across the wide panel instead of cramming left. */
    /* Buildings: a .list-row in columns. It used to be a bordered white card with an
       8px gap — 70px per school — which made it the one list in Site Config that
       looked like a different product. Same row, same height, same hover now.
       (.bl-av lived here: a 28px accent-filled circle where every other list in the
       section drew a plain 16px muted glyph. It was also 28px wide inside a 34px
       column, so the schoolhouse sat 3px left of every other tab's lead icon. It is
       a .list-row-lead like the rest now — the circle survives only in the building's
       DETAIL header (.dtl-av), where an identity block is the right place for it.) */
    .bl-name { min-width: 0; display: flex; align-items: baseline; gap: 7px; }
    .bl-nm { font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .bl-short { flex: none; font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--muted); letter-spacing: .02em; white-space: nowrap; }
    .bl-cell { min-width: 0; font-size: var(--fs-md); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .bl-phone { font-variant-numeric: tabular-nums; color: var(--muted); }
    .bl-muted { color: var(--muted); }
    /* (.bl-chev lived here — a bold text &rsaquo; tinted --accent-dark. The chevron is
       a shared, reserved slot now: .list-chev under THE CONFIG ROW.)
       The phone restack for this row lives with the rest of the column definitions
       under THE CONFIG ROW — it has to, see the note there. */
    /* Structured address boxes (Street / City / State / ZIP) */
    .addr-grp { display: flex; flex-direction: column; gap: 8px; }
    .addr-grp > input, .addr-grp > .addr-row { margin-bottom: 0 !important; }
    .addr-grp .addr-row { display: flex; gap: 8px; }
    .addr-grp .addr-row > * { margin-bottom: 0 !important; min-width: 0; }
    .addr-grp .addr-state { flex: 0 0 92px !important; width: auto !important; }
    .addr-grp .addr-zip { flex: 1 1 0 !important; width: auto !important; }
    .um-stats { display: flex; gap: 6px; flex-wrap: wrap; }
    /* Roster totals ride INSIDE the search row (the Member Directory toolbar does the same
       with #members-stats), so the strip costs ZERO vertical space at any width: the pills
       are shorter than the 38px controls they sit beside, and the search field — which
       already grows to fill — just gives up the width. Putting them in .panel-head instead
       pushed the header to two lines once the description ran long. */
    /* Shrinkable (not 0 0 auto): once .um-controls wraps on a phone the strip gets its own
       narrow line, and it has to be allowed to shrink so the pills wrap instead of clipping. */
    .um-controls .um-stats { flex: 0 1 auto; min-width: 0; margin-left: auto; }
    .um-stat { font-size: var(--fs-xs); color: var(--muted); background: var(--bg); border: 1px solid var(--border); border-radius: 999px; padding: 2px 9px; }
    .um-stat b { color: var(--text); }
    /* The two account-state pills — Pending and No Account Yet — and the ONLY two users of
       this modifier. It was called `warn` and painted in the accent, so the pills counting
       the two waiting states didn't match the chips marking them in the rows below: the same
       screen said "these two things are alike" at the top and "these are different species"
       in the list. On the warn token now, which is what the class always claimed. */
    .um-stat.warn { background: var(--warn-bg); border-color: rgba(var(--warn-rgb),.38); color: var(--warn); }
    .um-stat.warn b { color: var(--warn); }
    .um-stat-btn { cursor: pointer; }
    .um-stat-btn:hover { border-color: var(--accent); }
    .um-stat-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
    .um-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 4px; }
    #um-search { flex: 1; min-width: 200px; padding: 8px 11px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-md); font-family: inherit; background: #fff; color: var(--text); }
    #um-search:focus { outline: none; border-color: var(--accent); }
    .um-filters { display: flex; gap: 6px; flex-wrap: wrap; }
    /* Pills that carry a count (Supply Closet). A config screen's filter is NOT a
       pill strip — it's the funnel + .mf-sheet in the head's action cluster, the
       same control the Member Directory and Requests use (see openFbFilterSheet). */
    .filter-btn { display: inline-flex; align-items: center; gap: 7px; }
    /* The count is a chip on the pill, not a trailing number in the same colour
       and size as the word beside it — it answers a different question. */
    .um-filters .fb-n { flex: none; min-width: calc(18px * var(--fs-scale, 1)); padding: 1px 5px; border-radius: 999px; background: var(--neutral-bg); color: var(--muted); font-size: var(--fs-2xs); font-weight: var(--fw-bold); line-height: var(--lh-body); text-align: center; }
    .filter-btn.on .fb-n { background: var(--accent); color: var(--on-accent); }
    /* Compact filter dropdowns + icon-only action buttons (member directory) */
    .members-filter { padding: 9px 10px; border: 1px solid var(--border); border-radius: 9px; font-size: var(--fs-md); font-family: inherit; background: #fff; color: var(--text); cursor: pointer; }
    .members-filter:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    /* Directory toolbar: it IS the page bar (.page-bar owns the row — height, hairline,
       margins, the slots). What's left here is the tighter gap the icon cluster wants. */
    .members-toolbar { column-gap: 8px; row-gap: 10px; }
    .members-toolbar .members-search-wrap { margin-bottom: 0; }
    .members-tools { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
    .members-toolbar #members-stats { margin-bottom: 0; margin-left: auto; }
    /* Copy-emails is a toolbar child, not a member of the scrolling tools strip —
       it keeps a slightly wider gap so a half-scrolled icon never appears to touch it. */
    .members-toolbar > #members-emails { margin-left: 4px; }
    @media (max-width: 760px) {
      /* Search and filter icons share one row; count, export, and print hidden on mobile.
         WRITTEN AT .page-bar STRENGTH ON PURPOSE. This rule has always said `flex: 1 1 0`,
         and the field still took the whole row with the icons wrapped underneath it: the
         generic `.page-bar > .page-search { flex: 1 1 auto }` ties on specificity and wins
         on source order, and `auto` means an <input>'s intrinsic ~200px — which plus four
         38px icons does not fit 390px. Basis 0 is what lets the field give way to them. */
      .page-bar.members-toolbar > .members-search-wrap { flex: 1 1 0; min-width: 0; }
      .members-toolbar .members-search-wrap { flex: 1 1 0; min-width: 0; }
      .members-tools { flex: 0 0 auto; flex-wrap: nowrap; gap: 8px; }
      .members-tools > * { flex: 0 0 auto; }
      .members-filter { font-size: var(--fs-sm); padding: 8px 9px; }
      #members-stats, #members-export, #members-labels { display: none; }
      /* THE CLEAR BUTTON KEEPS ITS RESERVED SLOT, on the phone too (Eric's call). It
         is laid out but invisible until a filter engages (.filter-clear.hidden), and
         it earns the space twice over: the toolbar doesn't jump when a filter turns
         on, and the empty slot is what visually separates the three filter icons from
         the copy-emails button beside them.
         So the field buys its room from its own padding instead. The 36px right lane
         belongs to the clear-✕ inside the box, which doesn't exist until something is
         typed — and while it doesn't, that lane is the difference between a field that
         can show its own placeholder and one that reads "Sea…". */
      .members-search-wrap:has(> .search-x.hidden) .members-search { padding-right: 11px; }
      /* …and 4px off the magnifier's lane (it ends at 28px, so 30 still clears it) and
         2px out of each gap in the icon strip. Small numbers, but the field is ~100px
         at this width: they are what put "Search…" inside it rather than through it. */
      .members-toolbar .members-search { padding-left: 30px; }
      .members-toolbar .members-tools { gap: 6px; }
      /* THE BAR'S RULE REACHES THE WALL, like the rows under it. It was stopping one
         gutter short on each side (29→361 against the card's 15→375) while every row
         below it had already been bled out to the wall — so the one line that is
         supposed to close the toolbar was the only inset edge on the screen, and it
         read as a stray line rather than the bar's own floor. Bled with negative side
         margins and matching padding, so the rule moves and the controls don't.
         BOTH list screens: they are the same bar (see THE TWO BARS ARE ONE BAR), and a
         fix that lands on one of them is half a fix. Still written as two ids rather
         than as `.page-frame .page-bar`, because the config panels and the form screens
         put a bar inside a padded column where a bleed would overshoot the card. */
      #section-members .page-bar,
      #section-requests .page-bar { margin-left: calc(-1 * var(--pad-card)); margin-right: calc(-1 * var(--pad-card));
        padding-left: var(--pad-card); padding-right: var(--pad-card); }
    }
    .icon-btn { width: 38px; height: 38px; flex: 0 0 38px; border: 1px solid var(--border); border-radius: 9px; background: var(--card); color: var(--text); cursor: pointer; display: grid; place-items: center; padding: 0; transition: border-color .15s, color .15s; }
    .icon-btn:hover { border-color: var(--accent); color: var(--accent); }
    .icon-btn .lic { width: 18px; height: 18px; }
    .icon-btn .ic { font-size: var(--fs-xl); line-height: var(--lh-none); }
    /* Member directory search field — magnifier prefix + rounded input. */
    .members-search-wrap { position: relative; display: flex; align-items: center; }
    .members-search-wrap .mss-ic { position: absolute; left: 11px; width: 17px; height: 17px; color: var(--muted); display: flex; pointer-events: none; }
    .members-search-wrap .mss-ic .lic { width: 17px; height: 17px; }
    .members-search { width: 100%; padding: 10px 36px 10px 34px; border: 1px solid var(--border); border-radius: 10px; font-size: var(--fs-lg); font-family: inherit; background: #fff; color: var(--text); }
    .members-search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    /* Clear-the-search "X": pops in on the right only when the field has text
       (toggled in JS via .hidden). Shared by the directory + requests search. */
    .search-x { position: absolute; right: 7px; top: 50%; transform: translateY(-50%); display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; padding: 0; border: none; border-radius: 50%; background: #e5e7eb; color: #4b5563; cursor: pointer; }
    .search-x::after { content: ''; position: absolute; inset: -8px; }   /* invisible ~38px tap target around the 22px circle */
    .search-x svg { width: 11px; height: 11px; display: block; transform: translate(-1px, -2px); }   /* optical nudge: 1px left, 2px up */
    .search-x:hover { background: #d1d5db; color: var(--text); }
    /* "Clear filters" pill — pops in between the filter buttons and the count
       when any filter is active (toggled in JS). Shared by directory + requests. */
    /* Small text link, NOT a chip — sits between the filters and the count without
       adding height or forcing the toolbar row to wrap. */
    .filter-clear { flex: 0 0 auto; padding: 2px 4px; border: none; background: none; color: var(--accent); font-size: var(--fs-sm); font-weight: var(--fw-semi); font-family: inherit; cursor: pointer; white-space: nowrap; }
    .filter-clear:hover { color: var(--accent-dark); text-decoration: underline; }
    /* The slot is PERMANENT: hiding Clear must not re-flow the toolbar, or every
       icon to its right shifts the moment a filter is applied or cleared. So the
       hidden state is visibility, not display — the space stays reserved. */
    .filter-clear.hidden { display: inline-block !important; visibility: hidden; pointer-events: none; }
    /* Filter dropdowns are now icon buttons; an accent dot marks an active filter. */
    .mfilter { position: relative; }
    .mfilter.on { border-color: var(--accent); color: var(--accent); background: var(--accent-light); }
    .mfilter.on::before { content: ''; position: absolute; top: -3px; right: -3px; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); border: 2px solid var(--card); z-index: 1; }
    /* Ghost-tap lockout: pointer-events off briefly after section load */
    #members-tools.mf-locked .mfilter { pointer-events: none; }
    /* Filter chooser: a bottom sheet (like the maps chooser) so the tools strip's
       horizontal scroll can't clip it on phones. */
    .mf-sheet { position: fixed; inset: 0; z-index: 130; background: rgba(0,0,0,.45); display: flex; align-items: flex-end; justify-content: center; }
    .mf-pop { background: var(--card); width: 100%; max-width: 440px; max-height: 72vh; overflow-y: auto; border-radius: 16px 16px 0 0; padding: 6px 8px calc(12px + env(safe-area-inset-bottom, 0)); box-shadow: 0 -4px 18px rgba(0,0,0,.18); animation: sheetUp .18s ease; }

    /* ── THE DESKTOP DROPDOWN ────────────────────────────────────────────────
       Same chooser, two shapes. On a phone it is a bottom sheet with a dim
       behind it — correct there, and the reason this component was built that
       way. On a desktop that shape reads as a phone popup that has wandered
       onto a monitor: a dialog in the middle of the screen, dimming a page you
       were reading, to answer "which grade?".

       A desktop dropdown belongs TO ITS FIELD. It hangs off the trigger, is the
       trigger's width, and dims nothing — the same contract a native <select>
       has, which is the one thing the OS list got right. What the native list
       gets WRONG is everything else: it is the 1995 system widget, and its
       options cannot be styled at all, so a native <select> can never match
       this app. That is the trade this class exists to settle — keep the
       attachment, drop the grey.

       .anchored is set by openMiSheet() when it has a trigger to hang from and
       the viewport is desktop. Position is written inline (fixed coords from
       the trigger's rect) because it depends on where the field is. Everything
       that is a LOOK lives here. */
    .mf-sheet.anchored { background: none; display: block; }
    .mf-pop.anchored {
      position: fixed; width: auto; max-width: none; max-height: 320px;
      /* margin:0 is load-bearing — the sheet variant carries `margin: 0 16px` to hold
         itself off the phone's edges, and on a fixed-position panel that silently
         shifted the whole dropdown 16px right of the field it is supposed to hang from. */
      margin: 0;
      border: 1px solid var(--border); border-radius: 10px;
      padding: 5px; box-shadow: 0 6px 20px rgba(16,24,40,.16), 0 1px 3px rgba(16,24,40,.10);
      /* Fade only, no transform. A translate on the panel offsets its border box, so
         the coordinates JS just measured stop describing where it actually is —
         which read as the dropdown landing 4px above the field it is anchored to. */
      animation: dropIn .12s ease;
    }
    /* Tighter than the sheet's: a sheet is a destination and can afford 13px
       rows, a dropdown is a glance. This is the same row height the rest of the
       app's dense lists use. */
    .mf-pop.anchored .mf-opt { padding: 7px 9px; font-size: var(--fs-md); border-radius: 7px; }
    .mf-pop.anchored .mf-title { padding: 6px 9px 4px; font-size: var(--fs-2xs); }
    .mf-pop.anchored .mf-check, .mf-pop.anchored .mf-check .lic { width: 15px; height: 15px; }
    @keyframes dropIn { from { opacity: 0; } to { opacity: 1; } }
    .mf-title { font-size: var(--fs-sm); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: 12px 12px 6px; }
    .mf-opt { display: flex; align-items: center; justify-content: space-between; gap: 12px; width: 100%; text-align: left; background: none; border: none; border-radius: 10px; padding: 13px 12px; font-size: var(--fs-lg); font-family: inherit; color: var(--text); cursor: pointer; }
    .mf-opt:hover { background: var(--accent-light); }
    .mf-opt.sel { color: var(--accent-dark); font-weight: var(--fw-semi); }
    .mf-count { font-size: var(--fs-sm); color: var(--muted); flex: none; font-variant-numeric: tabular-nums; }
    .mf-check { display: inline-flex; align-items: center; width: 18px; height: 18px; color: var(--accent); flex: none; }
    .mf-check .lic { width: 18px; height: 18px; }
    /* Leading "mine ♥" gutter: a fixed first column so every option's label lines up, with the
       heart badge (when the row matches the viewer's own) always in the same spot. */
    .mf-gutter { display: inline-flex; align-items: center; justify-content: flex-start; flex: none; width: 20px; }
    .mf-gutter .lic { width: 16px; height: 16px; }
    .mf-subj-ic { display: inline-flex; flex: none; }
    .mf-subj-ic svg { width: 16px; height: 16px; }
    .mf-opt .mf-slabel { flex: 0 1 var(--mf-lbl-w, auto); }
    .mf-mine { position: relative; display: inline-flex; align-items: center; }
    .mf-mine > .lic { color: var(--muted); }
    .mf-heart { position: absolute; bottom: -3px; right: -4px; font-size: var(--fs-sm); line-height: var(--lh-none); color: var(--accent); }
    @media (min-width: 600px) { .mf-sheet { align-items: center; } .mf-pop { border-radius: 14px; margin: 0 16px; max-height: 80vh; } }
    /* Download (CSV) + print labels are desktop-only roster tools — hide on phones. */
    @media (max-width: 760px) { #members-export, #members-labels { display: none !important; } }
    /* Multi-select subject chips (sign-up + profile) */
    .subj-field { display: flex; flex-direction: column; gap: 5px; }
    .subj-label { font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--muted); }
    .subj-chips { display: flex; flex-wrap: wrap; gap: 6px; }
    .subj-chip { display: inline-flex; align-items: center; gap: 6px; padding: 6px 12px; border: 1.5px solid var(--border); border-radius: 999px; background: var(--card); color: var(--text); font-size: var(--fs-sm); font-family: inherit; cursor: pointer; transition: border-color .12s, background .12s, color .12s; }
    .subj-chip:hover { border-color: var(--accent); }
    .subj-chip.on { border-color: var(--accent); background: var(--accent); color: var(--on-accent); font-weight: var(--fw-semi); }
    .subj-chip-ic { width: 15px; height: 15px; flex: none; }
    .subj-chip.on .subj-chip-ic, .subj-chip.on .subj-chip-ic g { stroke: #fff !important; color: #fff !important; }   /* selected chip: icon (+ two-tone groups / fill accents) go white for contrast */
    /* Grade chips (multi-select; like subject chips but plain, no icon) */
    .grade-chips { display: flex; flex-wrap: wrap; gap: 6px; }
    .grade-chip { padding: 6px 12px; border: 1.5px solid var(--border); border-radius: 999px; background: var(--card); color: var(--text); font-size: var(--fs-sm); font-family: inherit; cursor: pointer; transition: border-color .12s, background .12s, color .12s; }
    .grade-chip:hover { border-color: var(--accent); }
    .grade-chip.on { border-color: var(--accent); background: var(--accent); color: var(--on-accent); font-weight: var(--fw-semi); }
    /* Multi-building placements: a building <select> + its room, one row per building */
    .placements { display: flex; flex-direction: column; gap: 8px; }
    /* align-items:stretch so the building selector, room #, and delete button all
       share one height and line up top and bottom (no ragged center-align). */
    .place-row { display: flex; gap: 8px; align-items: stretch; }
    .place-bldg, .place-room { padding: 8px 11px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-lg); font-family: inherit; background: #fff; color: var(--text); min-width: 0; }
    .place-bldg { flex: 1 1 auto; }
    .place-room { flex: 0 0 84px; }
    .place-rm { flex: none; width: 40px; display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--border); border-radius: 8px; background: var(--card); color: var(--muted); cursor: pointer; transition: border-color .12s, color .12s; }
    .place-rm svg { width: 16px; height: 16px; }
    .place-rm:hover { border-color: var(--danger); color: var(--danger); }
    .place-add { align-self: flex-end; background: none; border: none; color: var(--accent); font-size: var(--fs-sm); font-weight: var(--fw-semi); font-family: inherit; cursor: pointer; padding: 2px 0; }
    .place-add:hover { text-decoration: underline; }
    /* Site Config → Class subjects: table-style list */
        .subj-filter { flex: none; width: calc(190px * var(--fs-scale, 1)); padding: 6px 10px; font-size: var(--fs-md); border: 1px solid var(--border); border-radius: 7px; background: var(--card); color: var(--text); font-family: inherit; }
    .subj-filter:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
    /* The sort control IS the column label — same words, same size, same colour as
       every other .list-th; the only difference is that this one is pressable. It
       carries NO padding of its own: 2px top and bottom is what made this the one
       header in Site Config 4px taller than the rest, which pushed the whole list
       down 4px and read as "Class Subjects has bigger rows". A control never sets
       the height of the band it sits in — see .list-head's fixed height. */
        .subj-hd-btn { background: none; border: none; font: inherit; font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); cursor: pointer; padding: 0; white-space: nowrap; }
    .subj-hd-btn:hover { color: var(--text); }
    .subj-hd-btn.active { color: var(--accent); }
    .subj-hd-btn { flex: 1 1 auto; text-align: left; }
    .subj-cfg + .subj-cfg .list-row { box-shadow: inset 0 1px 0 var(--border); }
    .subj-cfg .list-row:hover, .subj-cfg:hover + .subj-cfg .list-row { box-shadow: none; }
    /* Chrome only — the box and the glyph inside it are the shared lead-mark sizes
       (--list-mark / --list-glyph under THE CONFIG ROW). This used to set its own
       28px box and 19px glyph, which is what made the icon column change weight
       between Class Subjects and every other list.

       AT REST IT IS A PLAIN GLYPH, exactly like the lead mark on every other list.
       It used to sit at rest in a white bordered box, so the one pressable icon
       column in Site Config was also the only one drawn as a box — the same icon
       column read heavier here than three tabs over, which is what "these rows are
       taller" was describing. Pressability shows up when you reach for it (hover,
       focus) instead of being painted on all day. The BOX still exists at
       --list-mark, so the hit target never shrank; only its resting paint did. */
    .subj-icon-btn { display: inline-flex; align-items: center; justify-content: center; flex: none; padding: 0; border: 1px solid transparent; border-radius: 8px; background: none; cursor: pointer; transition: border-color .12s, background .12s, box-shadow .12s; }
    .subj-icon-btn:hover { border-color: var(--border); background: var(--card); box-shadow: 0 0 0 3px var(--accent-light); }
    .subj-icon-btn:focus-visible { outline: none; border-color: var(--accent); background: var(--card); box-shadow: 0 0 0 3px var(--accent-light); }
    .subj-picker { margin: 8px 0 4px 0; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--accent-light); }
    /* Says which subject you're editing, and how to leave. The panel opens between the
       rows and pushes the row it belongs to out of view, so it has to name its own
       subject — and carry the way out, rather than relying on the button it covered. */
    .subj-pick-head { display: flex; align-items: center; gap: 10px; margin: -2px 0 9px; }
    .subj-pick-title { flex: 1 1 auto; min-width: 0; font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .subj-pick-title b { color: var(--text); }
    .subj-pick-done { flex: none; font-size: var(--fs-xs); font-weight: var(--fw-semi); font-family: inherit; color: var(--on-accent); background: var(--accent); border: none; border-radius: 999px; padding: 4px 14px; cursor: pointer; }
    .subj-pick-done:hover { background: var(--accent-hover); }
    .subj-pick-row { display: flex; align-items: center; gap: 10px; }
    .subj-pick-row + .subj-pick-row { margin-top: 10px; }
    .subj-pick-lbl { font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); width: 42px; flex: none; }
    .subj-pick-grid { display: flex; flex-wrap: wrap; gap: 6px; max-height: 190px; overflow-y: auto; padding: 1px; }
    .subj-opt { position: relative; display: inline-flex; align-items: center; justify-content: center; width: 34px; height: 34px; padding: 0; border: 1.5px solid var(--border); border-radius: 8px; background: #fff; cursor: pointer; transition: border-color .12s, box-shadow .12s; }
    .subj-opt:hover { border-color: var(--accent); }
    .subj-opt.on { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-light); }
    .subj-opt-svg { width: 18px; height: 18px; }
    /* Suggested-icons strip + "show all" toggle (subject picker) */
    .subj-pick-row.icon { align-items: flex-start; }
    .subj-pick-iconwrap { flex: 1; min-width: 0; }
    .subj-sugg-lbl { font-size: var(--fs-2xs); font-weight: var(--fw-bold); color: var(--accent); margin: 2px 0 6px; }
    .subj-showall { display: inline-block; margin: 8px 0 2px; padding: 3px 10px; font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--accent); background: none; border: 1px solid var(--border); border-radius: 999px; cursor: pointer; }
    .subj-showall:hover { border-color: var(--accent); }
    .subj-allgrid { margin-top: 2px; }
    /* tiny split-circle badge marks the two-tone-capable icons in the grid */
    .subj-opt.tt::after { content: ''; position: absolute; right: 2px; bottom: 2px; width: 7px; height: 7px; border-radius: 50%; background: linear-gradient(135deg, var(--accent) 0 50%, #cbd5e1 50% 100%); box-shadow: 0 0 0 1px #fff; }
    .subj-pick-colors { display: flex; flex-wrap: wrap; gap: 6px; }
    .subj-sw { position: relative; width: 24px; height: 24px; padding: 0; border: 2px solid #fff; border-radius: 50%; box-shadow: 0 0 0 1px var(--border); cursor: pointer; }
    .subj-sw.on { box-shadow: 0 0 0 2px var(--accent); }
    /* School colors lead the row — a faint ring flags them as the brand palette */
    .subj-sw.brand { box-shadow: 0 0 0 1px var(--accent); }
    .subj-sw.brand.on { box-shadow: 0 0 0 2px var(--accent); }
    /* Quick-swap pills (invert / rotate brand combo) */
    .subj-quick { display: flex; flex-wrap: wrap; gap: 6px; }
    .subj-quickbtn { font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--accent); background: none; border: 1px solid var(--border); border-radius: 999px; padding: 3px 10px; cursor: pointer; }
    .subj-quickbtn:hover { border-color: var(--accent); }
    /* "no accent" swatch: white with a diagonal slash, meaning single-tone */
    .subj-sw.none { background: #fff; }
    .subj-sw.none::after { content: ''; position: absolute; inset: 0; border-radius: 50%; background: linear-gradient(45deg, transparent 44%, #cbd5e1 44% 56%, transparent 56%); }
    .role-badge { font-size: var(--fs-2xs); font-weight: var(--fw-bold); padding: 1px 6px; border-radius: 3px; text-transform: uppercase; letter-spacing: .3px; white-space: nowrap; flex-shrink: 0; }
    .role-badge.pending { background: var(--accent-light); color: var(--accent-dark); }
    .role-badge.member { background: var(--neutral-bg); color: var(--neutral); }
    .role-badge.rep { background: #f5f3ff; color: #6d28d9; }
    .role-badge.senior { background: #fef2f2; color: #b91c1c; }
    .role-badge.admin { background: #161616; color: #fff; }
    .role-badge.hub { background: #ecfdf5; color: #065f46; }
    .role-badge.test { background: var(--warn-bg); color: var(--warn); }
    /* A disabled account. Neutral, not danger: nothing is wrong with the row — it is
       switched off on purpose, and the whole point of disabling is that it's reversible. */
    .role-badge.off { background: var(--neutral-bg); color: var(--muted); }
    .tu-creds { display: flex; flex-direction: column; gap: 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; }
    .tu-creds div { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; font-size: var(--fs-md); }
    .tu-creds span { color: var(--muted); flex-shrink: 0; }
    .tu-creds b { font-weight: var(--fw-semi); word-break: break-all; text-align: right; }
    .ur-you { font-size: var(--fs-2xs); font-weight: var(--fw-bold); color: var(--muted); background: var(--bg); border-radius: 999px; padding: 1px 7px; }
    /* Columns: avatar · name · callouts · email · invite · title · chevron. Callouts is 132px
       because the "No account yet" chip TAKES OVER that cell and has to fit on one line. The
       invite column sits between email and title (the action next to the address it goes to)
       and is 128px, not icon-width: it has to carry "Sent just now" BESIDE the button. Whether
       an invite went out is the one thing a leader can't reconstruct by looking, so it is
       printed in the row, never hidden in a tooltip. */
    /* The roster is a .list-row list — see THE CONFIG ROW. It used to be a parallel row
       system: its own 30px avatar column, 12px gap, 7px/10px padding, border-top
       separators, a 14px text-chevron column and a header row with its own border and
       label styling. Every one of those had a counterpart in the shared row, set to a
       different number. Only the COLUMNS are Members-specific now, and they live here,
       beside every other list's, in the one place column definitions live.
       Callouts is up to three icons (committee, building rep, push-off) centred as a
       group; the header sits centred over the same column.
       ACCOUNT is 210px, up from 128px: it holds a status AND a labelled button now
       ("Not invited · Invite", "Signed up 2d ago · Approve") where it used to hold a
       status and a 26px envelope. The extra 82px comes off the email track, which is
       the widest thing here. Most rows leave this column empty — that is the cost of a
       column, and it buys the two states that need it a place that is always the same. */
    #users-list-view { --list-cols: var(--list-lead) minmax(130px,1fr) calc(132px * var(--fs-scale, 1)) minmax(130px,2fr) calc(210px * var(--fs-scale, 1)) minmax(120px,150px) var(--list-end); }
    /* Select mode adds ONE leading column for the tick box. Every FIXED track holds its
       width — measured: callouts, invite, title and the end slot don't move at all — so
       the roster's landmarks stay put; the two fr columns (name, email) absorb the 24px
       between them. (The comment here used to claim nothing moved, which was never true
       of the fr tracks.) */
    #users-list-view:has(#users-list.picking) { --list-cols: calc(24px * var(--fs-scale, 1)) var(--list-lead) minmax(130px,1fr) calc(132px * var(--fs-scale, 1)) minmax(130px,2fr) calc(210px * var(--fs-scale, 1)) minmax(120px,150px) var(--list-end); }
    /* Members: search row, select bar and column header are FIXED furniture; only the roster
       scrolls under them. The panel's .page-body is normally the scroller (see .page-frame
       .page-body) — while the list view is up we hand that job to #users-list instead, so the
       controls stay on screen. :has() keeps it conditional: when the detail or import view is
       open, the second rule below restores overflow-y:auto so that taller content can scroll.
       (The .page-stacked class always forces overflow:hidden, so we need an explicit higher-
       specificity override — the :has(#...) selector wins 1-4-0 vs the class rule's 0-3-0.) */
    .ss-panel[data-panel="users"] .page-body:has(> #users-list-view:not(.hidden)) { overflow: hidden; display: flex; flex-direction: column; }
    .ss-panel[data-panel="users"] .page-body:has(> #user-detail-view:not(.hidden)),
    .ss-panel[data-panel="users"] .page-body:has(> #roster-import-view:not(.hidden)) { overflow-y: auto; }
    #users-list-view { display: flex; flex-direction: column; min-height: 0; flex: 1 1 auto; }
    /* Same gutter deal as .page-scroll: the header is a sibling of the scroller, so both
       sides reserve the scrollbar or a 500-row roster slides its labels off its columns. */
    #users-list { overflow-y: auto; min-height: 0; flex: 1 1 auto; scrollbar-gutter: stable; }
    .user-row .ur-pick { display: flex; align-items: center; justify-content: center; color: var(--muted); }
    .user-row .ur-pick input { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; margin: 0; }
    .user-row .ur-pick .lic { width: 13px; height: 13px; opacity: .45; }
    .user-row.picked { background: var(--accent-light); }
    /* Pre-seeded roster row nobody has claimed yet. Reads as a state, not an error — these
       people are expected, they just haven't signed up. */
    /* "Not a member yet" is the loudest fact about a row, so it does NOT whisper. It used to
       be grey-on-grey and vanished in a list of 200. Amber (--warn), not red: a pre-seeded
       roster row is the expected state after an import, not a fault. The row carries a
       matching left edge so the eye can scan the state down the column without reading. */
    /* ── THE ACCOUNT-STATE CHIP — ONE SHAPE, THREE TONES ────────────────────
       "No account yet", "Pending" and "Disabled" are read in the same lane, one under
       the other, so they are ONE chip with one shape and differ only in tone and word.
       They used to be three separately-written rules, and the two that mean the same
       KIND of thing had drifted apart: Pending wore the accent while No-account-yet
       wore amber, so two states that are both "this person can't use the app yet, and
       someone has to act" read as two unrelated species — while the stat pills above
       the list showed both of them in amber. The list disagreed with its own header.

       Amber for both waiting states. The accent is this app's ACTION colour, and the
       status palette is ok / warn / danger / neutral (see the status tokens) — a state
       wearing the brand colour was borrowing a meaning it doesn't have. What separates
       them is the word, and the button beside it: a filled Approve because someone is
       waiting on that decision, a secondary Invite because it is a routine send.

       Disabled stays NEUTRAL, and that difference is real: it is not a thing waiting to
       happen, it is a decision already made and reversible in one click. */
    .ur-stub, .ur-pend, .ur-off {
      font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase;
      letter-spacing: .04em; border-radius: 999px; padding: 1px 7px; white-space: nowrap; flex-shrink: 0;
      border: 1px solid transparent;
    }
    .ur-stub-dot, .ur-pend-dot, .ur-off-dot { display: inline-block; width: 5px; height: 5px; border-radius: 50%; margin-right: 5px; vertical-align: 1px; }
    .ur-stub, .ur-pend { color: var(--warn); background: var(--warn-bg); border-color: rgba(var(--warn-rgb), .38); }
    .ur-stub-dot, .ur-pend-dot { background: var(--warn); }
    .ur-off { color: var(--muted); background: var(--neutral-bg); border-color: var(--border); }
    .ur-off-dot { background: var(--muted); }
    /* No row-level marker on either waiting state: the chip in the Callouts column says
       it, and a second signal on the same row was just noise. (.stub / .pending stay on
       the row — the account cell and the phone's layout key off them.) */
    /* The row itself dims — the one place a row-level marker earns its keep, because
       "this person is not active" is exactly the thing you want to skim past in a roster
       of 400. Name and email only: the chip that explains it must stay at full strength. */
    .user-row.disabled-row .ur-name, .user-row.disabled-row .ur-email, .user-row.disabled-row .ur-position { opacity: .55; }
    /* ── THE ACCOUNT CELL'S ACTION — ONE BUTTON, TWO WEIGHTS ────────────────
       Approve and Invite are the same control at two emphases, not two controls. Both
       are a .btn.sm sized down to the row, icon + word, at the end of the cell after the
       status text. Invite used to be a bare 26px envelope with no label, so the column
       showed a verb on a pending row and a glyph on the row under it — and you had to
       hover the glyph to learn what it did.
       Filled vs secondary is the whole difference, and it is the right one to keep: an
       approval is a decision a real person is waiting on, an invite is one of hundreds
       of routine sends after a roster import. */
    /* flex:none — the button is the one thing in this cell that must never be squeezed.
       Its label can't wrap or ellipsise, so under pressure it grew straight through the
       column and sat on top of Title. The status text takes the squeeze instead: it is
       the part that degrades gracefully, and it already ellipsises. */
    .ur-act { flex: none; min-width: 0; padding: 4px 10px; gap: 5px; font-size: var(--fs-sm); white-space: nowrap; margin-left: auto; }
    .ur-act .lic { width: 13px; height: 13px; }
    .ur-act.working, .ur-act.sending, .ur-act:disabled { opacity: .55; cursor: default; }
    .um-select-bar { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; padding: 8px 10px; margin-bottom: 4px; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; }
    .um-select-bar .usb-count { flex: 1; min-width: 120px; font-size: var(--fs-sm); color: var(--muted); }
    .um-select-bar .usb-count b { color: var(--text); }
    #um-select-btn.on { border-color: var(--accent); color: var(--accent-dark); }
    /* ── THE CALLOUTS COLUMN — ONE SLOT PER CALLOUT ──────────────────────────
       Four possible callouts, four fixed slots, always in the same order: test
       account · committee · building rep · no push. A slot is drawn whether or
       not this person carries that callout.

       Packed left-to-right instead — which is what a flex row with a gap does —
       a member with no committee seat slid their rep icon into the committee's
       place, so the column was four glyph types landing on whatever x their
       neighbours left them. Reading it meant identifying every glyph on every
       row. With a lane each, the flasks line up under the flasks and an empty
       slot is itself information. (Test Account moved in from the end of the
       name, where it trailed a name of any length and so landed on a new x on
       every single row — see renderUsers.) */
    .user-row .ur-callouts { --ur-co-slot: calc(22px * var(--fs-scale, 1));
      display: grid; grid-template-columns: repeat(4, var(--ur-co-slot)); justify-content: center; align-items: center; margin-right: 22px; }
    .user-row .ur-co { display: grid; place-items: center; }
    /* When the chip takes the cell over it gets the whole track — the 22px right gutter that
       keeps the icon GROUP clear of Email would otherwise crop the last letters. */
    .user-row .ur-callouts.is-stub,
    .user-row .ur-callouts.is-off,
    .user-row .ur-callouts.is-pend { display: flex; align-items: center; margin-right: 0; justify-content: center; min-width: 0; }
    #users-head .ur-hd-callouts { display: flex; justify-content: center; margin-right: 22px; }
    .user-row .ur-rep-ic { display: inline-flex; color: var(--muted); }
    /* One-click invite. Quiet until hovered — it's an action on someone else's behalf sitting
       in a scannable list, so it must not compete with the amber state chip for attention. */
    .ur-invite-cell { display: flex; align-items: center; gap: 7px; min-width: 0; }
    /* The written record of the send. Amber while nobody has been emailed yet (it's the thing
       still to do), plain muted once one has gone out (done, not urgent). */
    .ur-invite-when { flex: 1 1 auto; min-width: 0; font-size: var(--fs-xs); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .ur-invite-when.never { color: var(--warn); }
    .ur-invite-when b { font-weight: var(--fw-semi); color: var(--text); }
    /* (.ur-invite's own 26px icon-box styling is gone — it is a .btn.sm.secondary now,
       and shares .ur-act's sizing with Approve.) */
    #users-head .ur-hd-invite { text-align: center; }
    /* Nudge the Email column's content right ~an inch for breathing room after
       Committee — padding the column's own children keeps Committee fixed in place. */
    .user-row .ur-email, #users-head .ur-hd-email { padding-left: 80px; }
    /* Sort buttons inside the shared header — the same job .subj-hd-btn does on Class
       Subjects. They inherit the header's type; only the hover/active state is theirs. */
    .ur-hd-btn { background: none; border: none; font: inherit; color: inherit; cursor: pointer; padding: 0; white-space: nowrap; text-align: left; justify-self: start; }
    .ur-hd-btn:hover { color: var(--text); }
    .ur-hd-btn.active { color: var(--accent-dark); }
    /* (.user-row's own hover, separator, radius and header styling lived here. They are
       the shared row's now — one hairline, one hover, one focus ring.) */
    /* A pending row is NOT tinted. It used to paint the whole row accent-light, which was
       the third signal on a row that already carries the chip and the Approve button —
       and the one thing that made "waiting for approval" and "no account yet" look like
       different kinds of row when they are the same kind. Pending still sorts to the top
       of the list, so the block is grouped by position; the chip and the button say the
       rest. (Removing the tint also retires the three box-shadow rules that existed only
       to patch the seam where the tint met an untinted row.) */
    .user-row .ur-avatar { width: var(--list-mark); height: var(--list-mark); border-radius: 50%; object-fit: cover; flex-shrink: 0; }
    .user-row .ur-avatar.init { background: var(--accent-light); color: var(--accent-dark); display: grid; place-items: center; font-weight: var(--fw-bold); font-size: var(--fs-xs); }
    .user-row .ur-name { font-size: var(--fs-md); font-weight: var(--fw-semi); display: flex; align-items: center; gap: 6px; }
    .user-row .ur-email { font-size: var(--fs-sm); color: var(--muted); }
    .user-row .ur-badge { display: flex; align-items: center; }
    .user-row .ur-position { font-size: var(--fs-sm); color: var(--text); text-align: left; }
    .user-row .ur-position.untitled { color: var(--muted); }
    .user-row .ur-hub-ic { display: flex; align-items: center; color: #065f46; flex-shrink: 0; }
    .user-row .ur-hub-ic .lic { width: 12px; height: 12px; }
    .user-row .ur-test-ic { display: flex; align-items: center; color: var(--warn); flex-shrink: 0; }
    .user-row .ur-test-ic .lic { width: 12px; height: 12px; }
    /* Now sits in the Callouts column (was trailing the name), so the column's gap
       handles spacing — no margin of its own. */
    .user-row .ur-no-push { display: inline-flex; align-items: center; color: var(--danger); flex-shrink: 0; }
    .user-row .ur-no-push .lic, .user-row .ur-rep-ic .lic { width: 12px; height: 12px; }
    .ud-push-status { display: inline-flex; align-items: center; gap: 5px; font-size: var(--fs-md); padding: 4px 0; }
    .ud-push-status .lic { width: 15px; height: 15px; }
    .ud-push-status.on { color: #065f46; }
    .ud-push-status.off { color: var(--danger); }
    .user-row .ur-controls { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
    .user-row select { padding: 6px 9px; border: 1px solid var(--border); border-radius: 7px; font-size: var(--fs-sm); font-family: inherit; background: #fff; color: var(--text); cursor: pointer; }
    .user-row select:focus { outline: none; border-color: var(--accent); }
    /* (.ur-chev lived here — a bold text &rsaquo;, the roster's own answer to the thing
       .list-chev now answers for every list.) */

    /* ══ THE ROSTER ON A PHONE ══════════════════════════════════════════════
       Seven columns is a desktop answer. On a ~390px screen the same grid gives
       the name about forty pixels and puts the action off the right edge, so the
       row is re-cut into two lines against the same DOM: the avatar and the
       chevron keep their rails full height, the name sits over the email, and the
       state chip sits over the action that resolves it. Nothing is invented and
       nothing is a second renderer — only the tracks change.

       Title is the one thing dropped. It's the ONE column that answers a question
       nobody opened this screen on a phone to ask, and the detail screen (one tap
       away, and the row's whole purpose) carries it in full. */
    @media (max-width: 760px) {
      /* The tracks go through --list-cols like every other list's, NOT through a
         grid-template-columns of our own: the shared `.list-cols .list-row` rule that
         reads the variable is two classes deep and sits ~1,400 lines below this, so a
         local `.user-row { grid-template-columns: … }` would simply be overruled and
         the roster would keep its seven desktop columns on a phone. */
      #users-list-view { --list-cols: var(--list-lead) minmax(0, 1fr) auto var(--list-end); }
      #users-list-view:has(#users-list.picking) { --list-cols: calc(24px * var(--fs-scale, 1)) var(--list-lead) minmax(0, 1fr) auto var(--list-end); }
      /* Everything below is placed EXPLICITLY. Auto-placement would flow the second
         line's items into whatever holes the first line left, so a row with no state
         chip would put the email where the name is. */
      #users-list .user-row { grid-template-rows: auto auto; column-gap: 11px; row-gap: 1px; padding: 9px 12px; }
      #users-list .user-row .ur-pick      { grid-column: 1; grid-row: 1 / 3; }
      #users-list .user-row .list-row-lead { grid-column: 1; grid-row: 1 / 3; }
      /* Names WRAP here instead of ellipsing. .list-cell truncates on one line, which is
         right for a desktop column of fixed width — but here the state chip beside it is
         nowrap by design, so a long name simply lost its end ("Nakamur…"). A phone has
         rows to spare and no column to keep straight; give it the second line. */
      #users-list .user-row .ur-name      { grid-column: 2; grid-row: 1; min-width: 0; overflow-wrap: anywhere;
        white-space: normal; overflow: visible; text-overflow: clip; }
      #users-list .user-row .ur-email     { grid-column: 2; grid-row: 2; min-width: 0; padding-left: 0; overflow-wrap: anywhere; }
      /* Four fixed slots is a DESKTOP argument — it buys a lane you can scan a 400-row
         column down. Here the callouts sit in a right-hand stack beside a two-line row,
         there is no column to scan, and four empty 22px slots on an unremarkable member
         is 88px taken off their name. Slots collapse to their content. */
      #users-list .user-row .ur-callouts  { grid-column: 3; grid-row: 1; justify-content: flex-end; margin-right: 0;
        grid-template-columns: repeat(4, auto); gap: 4px; }
      #users-list .user-row .ur-invite-cell { grid-column: 3; grid-row: 2; justify-content: flex-end; }
      #users-list .user-row .list-row-end  { grid-column: 4; grid-row: 1 / 3; }
      /* Select mode inserts the tick-box track ahead of everything, so each item shifts
         one column right. */
      #users-list.picking .user-row .list-row-lead   { grid-column: 2; }
      #users-list.picking .user-row .ur-name        { grid-column: 3; }
      #users-list.picking .user-row .ur-email       { grid-column: 3; }
      #users-list.picking .user-row .ur-callouts    { grid-column: 4; }
      #users-list.picking .user-row .ur-invite-cell { grid-column: 4; }
      #users-list.picking .user-row .list-row-end    { grid-column: 5; }
      #users-list .user-row .ur-position  { display: none; }
      #users-list .user-row .ur-invite-when { max-width: 92px; }
      /* The phone keeps Invite compact. A labelled button in an `auto` right-hand column
         sets that column's width and takes it straight off the name — the very trap the
         pending row's third line below exists to avoid, and there are hundreds of stub
         rows after an import against one or two pending ones, so they cannot have that
         line. The word is a desktop affordance; here the envelope sits next to "Not
         invited", which is the same sentence in two pieces. */
      #users-list .user-row .ur-invite .ur-act-lbl { display: none; }
      #users-list .user-row .ur-invite { padding: 5px 9px; }
      /* A pending row gets a THIRD line for Approve instead of squeezing it into the
         right-hand column. That column is an `auto` track, so the 96px button was
         setting its width and taking it straight off the name — 92px of name against
         176px on every other row, which is what wrapped "Member 1 - Aldrin HS" onto
         two lines and clipped the address under it. With the button on its own line
         the column sizes to the PENDING chip alone, the name and the email each get
         the full width, and the button becomes a proper tap target rather than a
         cramped one. The row is still taller than its neighbours, which is correct —
         it is the one row asking for a decision. */
      #users-list .user-row.pending .ur-name  { grid-column: 2 / 4; grid-row: 1; }
      #users-list .user-row.pending .ur-email { grid-column: 2 / 4; grid-row: 2; }
      /* State and action get a line EACH, and both span the row.
         They used to share the third line — chip in column 2, action in column 3 — and
         that is where they collided. Column 3 is an `auto` track, so "Signed up Jul 30"
         plus a 96px Approve set it to 190px and left column 2 with 38 of the 370 the row
         has. The chip needs 76, so it overflowed its own track and printed straight
         through the date beside it: measured 91–167 against 141–228, two live pieces of
         text 26px on top of each other.

         Spanning both across the full width and letting one align left and the other
         right ALMOST works — 76 + 190 fits 430px with room, and lands exactly touching
         at 375. "Exactly touching" is this bug again on the next phone, so the action
         takes the fourth line instead. Four lines on the one row that is asking for a
         decision, which is the trade this block already argued for when it gave the row
         a third. */
      #users-list .user-row.pending { grid-template-rows: auto auto auto auto; }
      #users-list .user-row.pending .ur-callouts {
        grid-column: 2 / -1;
        grid-row: 3;
        justify-content: flex-start;
        margin-top: 7px;
      }
      #users-list .user-row.pending .ur-invite-cell {
        grid-column: 2 / -1;
        grid-row: 4;
        justify-content: flex-end;
        margin-top: 6px;
      }
      /* The chevron rail spans every line the row now has, not the first two. */
      #users-list .user-row.pending .list-row-end { grid-row: 1 / -1; }
      #users-list.picking .user-row.pending .ur-name,
      #users-list.picking .user-row.pending .ur-email { grid-column: 3 / 5; }
      #users-list.picking .user-row.pending .ur-callouts,
      #users-list.picking .user-row.pending .ur-invite-cell { grid-column: 3 / -1; }
      /* Column labels describing columns that no longer exist. Sorting stays reachable
         through the funnel and the search box, which are the two controls a phone uses. */
      #users-head { display: none; }
      /* The roster keeps its own scroller here rather than growing the page, and it is
         CAPPED. Both errands this screen exists for on a phone live at the two ends of
         the card — the pending block sorts to the top of the list, the invite form is
         the footer — and an uncapped list puts four hundred members between them.
         flex-basis auto (not the desktop `1 1 auto` that fills a page-height column):
         a small local's roster is exactly as tall as its rows, and a big one stops at
         44vh and scrolls, so the invite form is on screen either way. */
      #users-list { flex: 0 1 auto; max-height: 44vh; }
      /* ── The roster toolbar at phone width ──────────────────────────────────
         .page-acts is sized for a desktop card: flex:none, with a 168px floor under
         every control. On a 390px screen that puts two buttons in the space of one
         and lands "Import Roster" printed across "Select".
         Two lines, and only two: search + count, then the pills with the funnel on
         their right. The stat pills live INSIDE .page-acts in the markup, so the
         cluster is what goes full-width and the funnel is pushed to its end — laying
         them out as siblings of the search instead leaves the funnel alone on a line
         of its own with the pills stranded under it. */
      .page-bar.um-controls { gap: 8px 10px; }
      #um-search { flex: 1 1 140px; min-width: 0; }
      .um-controls .page-count { flex: 0 0 auto; width: auto; text-align: right; }
      .um-controls .page-acts { flex: 1 1 100%; min-width: 0; flex-wrap: wrap; align-items: center; gap: 6px; }
      .um-controls .page-acts > .btn { min-width: 0; padding-left: 12px; padding-right: 12px; }
      /* display:contents so the individual pills become flex items of the cluster and the
         funnel can tuck onto the end of whatever line they finish on. As its own flex box
         the strip took a full line and pushed the funnel onto a third, empty one. */
      .um-controls .um-stats { display: contents; }
      .um-controls .page-acts > .icon-btn { order: 9; margin-left: auto; }
      /* Bulk select and the CSV import wizard are desk work — you cannot usefully pick a
         spreadsheet out of a phone, and a bulk-invite run is not why anyone opened this
         from a bottom sheet. Same call the Member Directory makes one screen over about
         export and mailing labels. Both stay one tap away on a desktop. */
      #um-select-btn, #um-import-btn { display: none !important; }
      /* Approve is the reason a leader opened this on a phone — give it a real tap target. */
      .ur-approve { padding: 7px 14px; }
      /* Invite: one field per line, and the button owns the last one. The desktop row wraps
         at this width into a ragged two-and-a-half lines with a 132px button stranded at the
         end of it; stacking is both readable and the same order you'd say it out loud —
         who, at what address, at what level, send.
         Hung off #inv-foot rather than .inv-foot: the desktop flex row and the 190px title
         select are declared ~1,500 lines further down, so a class-for-class override here
         loses on source order and the footer keeps its two-up desktop layout on a phone. */
      /* Collapsed by default: one button where four stacked fields would be. */
      #inv-open { display: flex; width: 100%; justify-content: center; padding-top: 11px; padding-bottom: 11px; }
      #inv-foot.open #inv-open { display: none; }
      /* Collapse control. It gets a row of its own at the top of the panel: the
         footer is a column flex, so align-self:flex-end parks it at the right edge
         at its natural size, and the column's 9px gap keeps it off the first
         field's label. It used to be absolutely positioned in the corner, which
         cost no height but sat on top of "FULL NAME (Optional)". */
      #inv-foot.open .inv-close { display: grid; align-self: flex-end; }
      #inv-foot:not(.open) .inv-row,
      #inv-foot:not(.open) #inv-msg { display: none; }
      #inv-foot .inv-row { display: block; }
      #inv-foot .inv-field { margin-bottom: 9px; }
      #inv-foot .inv-row input,
      #inv-foot .inv-row select,
      #inv-foot #inv-position { width: 100%; box-sizing: border-box; }
      #inv-foot .inv-row > .btn { width: 100%; margin-left: 0; padding-top: 11px; padding-bottom: 11px; }
      .page-frame .page-foot { padding: 12px var(--pad-card) calc(12px + env(safe-area-inset-bottom, 0px)); }
      /* THE GUTTER on a phone. Declared as the token rather than as a padding
         on .page-body, because everything that bleeds to the card wall has to
         bleed by the SAME number — and when this was a literal, they didn't.
         The members-quick full-bleed rules below still say -22px/22px from
         when the phone gutter WAS 22, so they overshoot the card by 8px a
         side to this day (invisible only because .page-frame clips). Anything
         written against --pad-card can't drift like that. */
      /* …and an EXHIBIT takes no extra indent on a phone. The indent is what keeps a
         composition from reading as parked against the wall in a room far wider than
         its measure; a 375px screen has no such room, and 34px off a 347px column is
         a tenth of the line. Zeroing the token (rather than unsetting the rule that
         uses it) keeps ONE expression of the reading edge — gutter + indent — true at
         both widths, which is also exactly what _frametest asserts. */
      :root { --pad-card: calc(14px * var(--sp-scale, 1)); --pad-exhibit: 0px; }
    }
    /* ── Members reached the quick way (phone More sheet → openMembersQuick) ──
       Strip the config chrome for the visit. The sidebar is a list of screens they
       didn't come for, and the Members/Security Log strip offers a wrong turn into an
       audit log on a two-tap errand. Scoped to the class AND to phones: on a desktop
       there is no More sheet to arrive from, and Site Config's own navigation is the
       point of the screen. */
    @media (max-width: 760px) {
      #section-content.members-quick .settings-sidebar,
      #section-content.members-quick #users-subtabs,
      #section-content.members-quick .section-header { display: none; }
      /* Symmetric vertical padding so "Members" reads as centred, not bottom-heavy.
         The bottom half of that symmetry is the title row's own --hub-head-gap (13px,
         the same 13 this used to write itself), which is also what carries the band's
         hairline now — so the head adds nothing below the row, or the line would float
         13px above the card. See THE BAND'S HAIRLINES. */
      #section-content.members-quick .page-head { border-radius: var(--radius) var(--radius) 0 0; padding-top: 13px; padding-bottom: 0; }
      /* Hide the "Members" hub head when drilling into a detail screen — the member's
         own name/avatar row (.dtl-top) becomes the effective header there.
         Restore the card's top radius: the hub head normally provided the rounded
         top edge; without it the card top was flat. */
      #section-content.members-quick:has(#user-detail-view:not(.hidden)) .page-head { display: none; }
      /* …and because that band is what carries this screen's crumb, hiding it took the
         only way back with it — the detail screen had no back control at all, just the
         tab bar. .dtl-head is the phone's title band here, so the crumb row rides under
         the identity block: the same .page-crumb, the same .crumb-back, one row, last
         thing before the scroller, exactly where the Directory's own crumb sits.
         Its side gutter is the header's 14px rather than .page-crumb's desktop 22px
         bleed, and the top margin goes — the identity block above it already paid it. */
      #section-content.members-quick:has(#user-detail-view:not(.hidden)) .dtl-head > .dtl-crumb {
        display: flex; margin: 0; padding: 0 14px;
      }
      /* No band above it here, so the card is a card again: its own rounded top corners
         AND its own top border. A fused card drops that border because the band above
         drew the seam's one line (THE BAND'S HAIRLINES); with the band gone there is no
         band to have drawn it, and the card's top edge would simply be missing. */
      #section-content.members-quick:has(#user-detail-view:not(.hidden)) .ss-panel[data-panel="users"] .page-frame {
        border-top: 1px solid var(--border);
        border-top-left-radius: var(--radius);
        border-top-right-radius: var(--radius);
      }
      /* Single-row control bar: [filter icon] [←clear gutter→] [search, flex:1].
         Count, stat chips, and bulk-action buttons all hidden. The mobile base
         rules (~line 1877) make .page-acts full-width with the filter pushed right
         via margin-left:auto + order:9 on .icon-btn — all reset here so .page-acts
         shrinks to just the filter icon at the left edge.
         padding-top balances .page-bar's padding-bottom (13px) so the row reads
         centred vertically. */
      /* Vertical balance. The row's rule sits 14px below the search box (13px of
         padding + the 1px border), so the space above it has to total 14px too.
         The row's own padding-top can't do that job — the card body's 22px top
         padding stacks on top of it, which is what made the band above the box
         read as 35px against 14px below. So the row contributes none and the body
         supplies the whole gap. Scoped to the list view: the member detail screen
         keeps the roomier 22px. */
      #section-content.members-quick:has(#users-list-view:not(.hidden)) .page-body { padding-top: 14px; padding-bottom: 0; }
      /* Full-bleed rule. The row sits inside .page-body's 22px side padding, so
         its border-bottom stopped 23px short of each card edge while the hub head
         above it ran edge to edge. Bleed out by exactly that padding and add the
         same amount back as the row's own padding: the divider now spans the card,
         the filter and search stay on their original left/right edges. Symmetric
         on both sides — never a one-sided pull. */
      #section-content.members-quick .um-controls {
        flex-wrap: nowrap;
        padding-top: 0;
        margin-left: calc(-1 * var(--pad-card));
        margin-right: calc(-1 * var(--pad-card));
        padding-left: var(--pad-card);
        padding-right: var(--pad-card);
        /* .page-bar's 14px bottom margin left the column header floating in white
           below the rule. The header band butts straight up against it instead. */
        margin-bottom: 0;
      }
      #section-content.members-quick .um-controls .page-count,
      #section-content.members-quick .um-controls .um-stats,
      #section-content.members-quick .um-controls #um-select-btn,
      #section-content.members-quick .um-controls #um-import-btn { display: none; }
      /* display:contents DISSOLVES .page-acts so the funnel becomes a direct flex
         item of .um-controls. Overriding its properties one at a time kept losing:
         the narrow-width base rule (~line 3546) gives .page-acts width:100% AND
         justify-content:flex-end, which pinned the funnel to the right edge of a
         full-width box and shoved the search input off-screen past it. With the box
         gone from layout, neither declaration has anything to apply to. */
      #section-content.members-quick .um-controls .page-acts { display: contents; }
      #section-content.members-quick .um-controls .um-filter-btn { order: 0; margin-left: 0; }
      /* Clear needs no rule of its own: .page-acts is display:contents, so it is a
         direct flex item here, and its default order 0 puts it right after the
         funnel (source order) and ahead of the search box's order 1. */
      #section-content.members-quick .um-controls #um-search { order: 1; flex: 1 1 0; min-width: 0; }
      /* NO column header here. The phone grid is one column of people, so the band
         reduced to a single "MEMBER" label with a sort arrow — a grey bar carrying
         no information the rows beneath it don't already say. The base mobile rule
         (`#users-head { display: none }`) already made that call; members-quick
         does not override it. Sorting stays reachable through the funnel.
         The roster IS this area, not a card floating inside it: drop the boxed
         treatment (`.list-head + *` puts a border and a 10px bottom radius on the
         list), bleed out to the card edges like the toolbar above, and fill the
         remaining height instead of stopping at the phone's 44vh cap. Side padding
         is 10px, not the toolbar's 22px, because rows carry .list-row's own 12px —
         that split is what lands row content on the same x as the search box.

         …and that 10px is now the ROW's, not the list's — see THE ROW REACHES THE
         WALL. The list bled out by the gutter and then re-paid 10 of it, so a hovered
         or selected row stopped 10px short of a card whose band rule runs edge to
         edge: the same indent the Requests tint had, four pixels smaller. The split
         moves inside the row (12 → 22), so the fill spans the card and the content
         stays on the exact x the 10+12 already put it on. */
      #section-content.members-quick #users-list {
        flex: 1 1 auto;
        max-height: none;
        margin-left: calc(-1 * var(--pad-card));
        margin-right: calc(-1 * var(--pad-card));
        padding-left: 0;
        padding-right: 0;
        border: 0;
        border-radius: 0;
      }
      #section-content.members-quick #users-list .user-row { padding-left: 22px; padding-right: 22px; }
      /* Detail view: #content-back (the section-level bar already styled by
         .app-section.active > .back-btn) is the back control. The in-card grey bar
         inside .dtl-head is hidden so there's no double back control on mobile. */
      #section-content.members-quick .dtl-head > .back-btn { display: none; }
      /* .dtl-head sits OUTSIDE .page-body so the identity row is naturally above the
         scroll — no sticky needed. Tighten the avatar and name to take less pinned height. */
      #section-content.members-quick .dtl-head > .dtl-top {
        padding: 9px 14px;
      }
      #section-content.members-quick .dtl-head > .dtl-top .dtl-av {
        width: 38px;
        height: 38px;
        font-size: var(--fs-md);
      }
      #section-content.members-quick .dtl-head > .dtl-top .dtl-nm { font-size: var(--fs-md); }
      /* ── Danger zone behind a deliberate pull ────────────────────────────────
         Hidden until the panel earns it (see wireDangerZoneUnlock). The strip in
         its place explains the gesture and fills a pip per pull, so the unlock is
         legible rather than a secret. Once open it stays open for the visit. */
      /* (The phone's copies of "hide it until unlocked" and of the zone's own spacing
         lived here, keyed through #user-detail-view. The zone is in the frame's footer
         now — one band under the save bar — so both are stated once, unscoped by entry
         point, beside the desktop rules. `members-quick` is set by the DOOR you came in
         through, not by the viewport, and this spacing never differed between them. */
      /* The strip rides IN the save footer, left of the button — it belongs to the
         same pinned band, not to a row of its own under it. flex:1 takes the space
         the button leaves; when the zone opens and the strip goes, the button falls
         back to the footer's flex-end on its own. */
      #section-content.members-quick .page-frame:has(#user-detail-view:not(.hidden):not(.dz-open)) #ud-foot .ud-dz-lock {
        display: flex;
        align-items: center;
        gap: 7px;
        flex: 1 1 auto;
        min-width: 0;
        padding: 7px 9px;
        border: 1px dashed var(--border);
        border-radius: 9px;
        color: var(--muted);
        font-size: var(--fs-xs);
        user-select: none;
      }
      #section-content.members-quick .ud-dz-lock .ic { width: 13px; height: 13px; flex: none; }
      /* Ellipsis rather than wrap: a second line would grow a band that is pinned
         over the form for the whole visit. */
      #section-content.members-quick .ud-dz-lock-t {
        flex: 1 1 auto;
        min-width: 0;
        overflow: hidden;
        text-overflow: ellipsis;
        white-space: nowrap;
      }
      #section-content.members-quick .ud-dz-pips { display: flex; gap: 5px; flex: none; }
      #section-content.members-quick .ud-dz-pips i {
        width: 6px;
        height: 6px;
        border-radius: 50%;
        background: var(--border);
        transition: background .15s;
      }
      /* One pip per completed pull. */
      #section-content.members-quick .ud-dz-lock[data-pulls="1"] .ud-dz-pips i:nth-child(-n+1),
      #section-content.members-quick .ud-dz-lock[data-pulls="2"] .ud-dz-pips i:nth-child(-n+2),
      #section-content.members-quick .ud-dz-lock[data-pulls="3"] .ud-dz-pips i { background: var(--danger); }
      /* Desktop's inline save row is replaced by the pinned footer on mobile. */
      #section-content.members-quick .ud-inline-save { display: none; }
      /* The bar itself is the frame's .page-foot now (#ud-foot) — it was sticky INSIDE
         the scroller, which is what put the card's scrollbar down the side of it. All
         that is left for the phone is the shadow: .page-foot draws a rule, and against a
         form that scrolls under it the rule alone does not read as "there is more above
         this". Everything else — band, padding, full-bleed, clipping to the card's
         corners — .page-foot already owns. */
      #section-content.members-quick #ud-foot { box-shadow: 0 -4px 10px -4px rgba(16,24,40,.12); }
    }
    /* User detail / edit screen (shown in place of the list) */
    /* ── THE SAVE BAR IS A SIBLING OF THE SCROLLER, NOT A STICKY CHILD OF IT ──
       It was `position: sticky; bottom: 0` inside .page-body, with that body's bottom
       padding zeroed so it could land flush. That worked, and it cost the screen its
       right-hand edge: a scrollbar belongs to the scroller, so it ran the full height of
       the card and straight down the side of the bar, and the bar stopped a scrollbar's
       width short of the wall to leave room for it. Nothing rendered inside a scroller
       can cover that column — the bar had to come out.
       It is #ud-foot now, the frame's own .page-foot, which is what every other footer on
       this panel already is (#inv-foot, #ri-foot) and what the building screen has always
       used. .page-foot carries the band, the rule and the padding; all that is left here
       is the row's own arrangement and when it shows.
       Knock-on, and the reason several rules below got simpler: the scroller keeps its
       bottom padding now, so the danger zone no longer has to hand a gutter back. */
    /* (Its layout is two stacked bands — see THE MEMBER ZONE, IN THE FOOTER below. The
       row's own flex-end arrangement lives there with the band it belongs to.) */
    /* The bar is filled per open; an empty one is hidden by closeUserDetail rather than
       left as invisible chrome with a border-top. */
    #ud-foot:empty { display: none; }
    .ss-panel[data-panel="users"] .page-body:has(> #user-detail-view:not(.hidden)) .ud-inline-save { display: none; }
    /* Pinned member identity header — lives outside .page-body so it never scrolls.
       Two stacked parts: the back bar and the member identity row (avatar, name, email,
       badges).

       The back bar is CARD-COLOURED with a rule above and below, not a grey band. Grey
       is this app's colour for chrome that sits OUTSIDE a card — the section-level back
       banners bleed edge-to-edge against the page. This one is inside the card, under
       the tabs, so a grey fill read as a foreign strip wedged into the panel rather than
       a row of it. Same fill as everything around it; the two hairlines do the
       separating. */
    .dtl-head { flex: none; border-bottom: 1px solid var(--border); }
    .dtl-head > .back-btn {
      display: flex; align-items: center;
      width: 100%; box-sizing: border-box; margin: 0;
      background: var(--card); padding: 9px var(--pad-card);
      border-top: 1px solid var(--border);
      border-bottom: 1px solid var(--border);
    }
    .dtl-head > .dtl-top { margin: 0; padding: 12px var(--pad-card) 14px; }
    /* ── THE DETAIL SCREEN BACKBONE ───────────────────────────────────────────
       One shape for every "open one row out of a Site Config list" screen:

         .dtl-head   pinned identity header, a sibling of .page-body so it never
                     scrolls away — avatar/glyph, name, sub-line, badge row
         .dtl-body   the scrolling form: .card-sep + .sec-sub-title section
                     breaks and .mi-field icon-gutter rows, nothing else

       Member detail and Building detail both render through detailHeadHTML() /
       detailSection() / detailRow() in app.js. Before this existed the two were
       independent vocabularies (.ud-* and .bd-*) that had already drifted apart
       on avatar size, name size, column width and where the header lived — the
       building screen kept its identity block INSIDE the scroller and capped its
       form at 660px, so it read as a different app. A third detail screen joins
       these classes; it does not mint a fourth set. */
    .dtl-top { display: flex; align-items: center; gap: 14px; margin: 12px 0 4px; }
    .dtl-top > .dtl-idt { min-width: 0; }   /* let a long name/address shrink instead of widening the row */
    .dtl-av { width: 52px; height: 52px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
    .dtl-av.init { background: var(--accent-light); color: var(--accent-dark); display: grid; place-items: center; font-weight: var(--fw-bold); font-size: var(--fs-2xl); }
    .dtl-av .lic { width: 26px; height: 26px; }
    .dtl-nm { font-size: var(--fs-xl); font-weight: var(--fw-bold); overflow-wrap: anywhere; }
    .dtl-sub { font-size: var(--fs-md); color: var(--muted); overflow-wrap: anywhere; }
    .dtl-badges { margin-top: 5px; display: flex; gap: 6px; flex-wrap: wrap; align-items: center; }
    /* An identity pill in the badge row (a building's short name) — same pill as .profile-role. */
    .dtl-chip { display: inline-block; font-size: var(--fs-2xs); font-weight: var(--fw-display); text-transform: uppercase; letter-spacing: .05em; padding: 2px 9px; border-radius: 999px; background: var(--accent-light); color: var(--accent-dark); }
    /* A verified/OK marker in the badge row (a building's geocoded pin). */
    .dtl-pin { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-sm); color: var(--ok); }
    .dtl-pin .lic { width: 13px; height: 13px; }
    /* Block, so the caption takes its own line above the control. The 4px is the
       building screen's old value, kept for BOTH: measured against .mi-fic's 31px
       re-centring it leaves the glyph ~4px below its control's middle, where the
       member screen's marginless caption left it ~8px below. One caption rhythm,
       and the better-centred of the two. */
    .dtl-lbl { display: block; margin-bottom: 4px; }
    .dtl-hint { margin: 6px 0 0; font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); }
    /* A hint that INTRODUCES its control instead of following it (the grades explainer). */
    .dtl-hint-top { margin: 0 0 9px; }
    /* Section breaks are tighter than a settings card's — a detail screen already sits
       inside the config card's own padding. */
    /* #section-content-qualified so this wins over that section's own 14px .card-sep
       rule (an id beats two classes) — the detail screens live inside it. */
    #section-content .dtl-body .card-sep { margin: 12px 0; }
    .dtl-body .sec-sub-title { margin-bottom: 6px; }
    /* Self-view notice sits right under the badge row; give it top breathing room and a
       subtle tinted box so it reads as a notice, not body copy. */
    #user-detail-view > p.set-desc { margin: 10px 0 0; padding: 8px 12px; background: var(--neutral-bg); border-radius: 8px; }
    /* A stacked page delegates its scrolling to .page-scroll — the list's inset panel.
       A detail screen is NOT in that panel (it is a record, not a table: the panel's
       hairline box and reserved scrollbar gutter are the list's chrome), so while one
       is open the page body does the scrolling itself, exactly as the member screen's
       does. Without this the form simply could not be scrolled past its first screen. */
    .page-frame .page-body.page-stacked:has(> .dtl-body:not(.hidden)) { overflow-y: auto; }
    /* A detail screen's first section break has nothing above it to separate FROM —
       .dtl-head already draws that line as its own border-bottom.
       :first-CHILD, not :first-of-type — the member screen's self-view notice and
       pending-approval box render above the rule when they apply, and then it is a
       real divider doing a real job. first-of-type matched it there too and flattened it. */
    .dtl-body > .card-sep:first-child { display: none; }
    .ud-section-label { font-size: var(--fs-2xs); font-weight: var(--fw-semi); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin: 18px 0 8px; }
    .ud-danger { margin-top: 24px; padding-top: 16px; border-top: 1px solid var(--border); display: flex; flex-direction: column; gap: 10px; align-items: flex-start; }
    /* Desktop lock — the SLIDE TRACK, the same control the request detail uses to
       complete or escalate (.slide-confirm / wireSlide). It replaced a button captioned
       "double-click to unlock", which asked for a gesture nothing else in the app uses
       AND sat directly above the Delete button it was guarding. A slide can't be done
       by accident and needs no instructions beyond its own label.
       Compact: 34px to sit in the save footer's band beside the Save button, against the
       48px the request card can afford. Muted rather than accent — this unlocks a
       destructive section, it does not perform one. */
    .slide-dz { display: none; }
    /* THE PAINT LIVES HERE, not in the three show-rules that used to carry it. Those
       decide VISIBILITY — one per screen, each id-scoped — so colours written into them
       could never be out-specified by `.done`, which is one shared rule. The whole
       arrival state below was therefore dead: the rail stayed grey, the knob inherited
       `.slide-confirm.done`'s opacity:0 and simply vanished, and the label took that
       rule's `color: #fff` onto a light rail. Nobody caught it because the state lasts
       one frame before .dz-open hides the track.
       Doubled class so it beats plain `.slide-confirm` (declared ~5,000 lines later)
       without needing an id or an !important. */
    .slide-confirm.slide-dz { background: var(--neutral-bg); border-color: var(--border); }
    /* :has(), not a descendant selector — the track lives in the frame's footer now, which
       is a SIBLING of the record that carries `dz-open`. Same reason the building screen's
       rule has always been written this way. */
    /* Descendant inside the footer, not a direct child: the bar's controls sit in a
       .ud-foot-row band now (the zone is the second band), and a `>` path that assumed
       they were the footer's own children silently matched nothing — no lock at all. */
    #section-content:not(.members-quick) .page-frame:has(#user-detail-view:not(.hidden):not(.dz-open)) #ud-foot .slide-dz {
      display: block; height: 34px; flex: 1 1 auto; max-width: 320px; margin-right: auto;
    }
    /* IT SLIDES LEFT (wireSlide dir:-1), on all three screens that have one. Every
       unlock track is the LEFT-hand control of its bar — destructive/utility left, the
       positive action right, per the action-bar convention — so a rightward drag pushed
       the handle toward Save Changes or Slide to Complete, the two controls it must never
       be confused with. Travelling back toward its own wall keeps the two gestures
       opposed, exactly as Complete (→) and Escalate (←) are on the request detail.
       Geometry only, deliberately NOT the .slide-rtl class: that carries Escalate's warn
       palette, and this track is muted because it unlocks a destructive section rather
       than performing one. The chevrons in the markup point left to match. */
    /* ── IT IS THE OPPOSITE OF A POSITIVE BUTTON, AND THAT IS A SHAPE, NOT A HUE ──
       The knob was a solid --muted disc with a white chevron: the heaviest thing in a
       bar whose actual action — Save Changes, Slide to Complete — sits beside it in the
       tenant's accent. The one control that must not be mistaken for the primary one was
       the one drawing the eye.

       It does NOT take the accent. That was the obvious idea and it is the wrong one:
       this track's whole job is to not look like the action next to it, and two
       brand-coloured sliders in one bar — one meaning "finish this", one meaning "expose
       delete" — is the same confusion the leftward direction was introduced to kill.
       Nor a computed complement of the accent: a local's brand can be any colour, so an
       opposite hue lands wherever it lands, including on --danger's red.

       The app's positive control is a SOLID accent fill with computed on-accent ink. The
       opposite of solid is OUTLINE — the same distinction .btn vs .btn.ghost already
       draws. So the knob is a card-coloured disc with a hairline and muted ink, riding a
       neutral rail: one quiet outlined object that recedes behind the accent beside it.
       Colour is saved for the moment it means something — arrival, below. */
    /* CENTRED, not inset by a number copied from the full-size track. The 3px here came
       from .slide-confirm, where it is correct: a 40px handle in a 48px rail leaves
       (48 - 2×1 border - 40) / 2 = 3. This rail is 34 with a 28px knob, where the same
       arithmetic gives 2 — so 3px hung the knob 4px from the top and 2px from the bottom,
       and pushed it toward the rail's right edge. `top: 0; bottom: 0; margin-block: auto`
       centres it exactly whatever the border rounds to on the device, and 2px is the
       matching horizontal inset, so the knob nests evenly on the three sides it touches. */
    .slide-dz .slide-fill { top: 0; bottom: 0; margin-block: auto; left: auto; right: 2px; height: 28px; width: 28px; background: rgba(0,0,0,.04); }
    .slide-dz .slide-handle { top: 0; bottom: 0; margin-block: auto; left: auto; right: 2px; width: 28px; height: 28px; background: var(--card); border: 1px solid var(--border); color: var(--muted); }
    /* The label is centred in THE RAIL MINUS THE KNOB, not in the whole rail. The knob
       occupies the rightmost 30px (28 wide + its 2px inset), so that is the padding —
       22px was a guess and left the text leaning 8px toward the knob, 26px of air on one
       side and 17px on the other. (.slide-confirm's own 30px under-compensates for its
       44px knob the same way; matching its arithmetic beats copying its number, because
       the two rails are different sizes.) */
    /* justify-content packs the two auto columns together — its default (normal ≈
       stretch) hands each column half the rail's free space, so place-items centred the
       lock in ITS half and the icon floated a quarter-rail left of its own caption. */
    .slide-dz .slide-label { padding-left: 0; padding-right: 30px; gap: 6px; grid-auto-flow: column; justify-content: center; font-size: var(--fs-sm); color: var(--muted); }
    /* Flex-centre the glyph in its box: the injected .lic svg is inline-block on a text
       baseline (vertical-align -0.14em), so inside the blockified 13px grid item the
       lock drew a hair high of the caption even though the BOX was centred exactly. */
    .slide-dz .slide-label .ic { width: 13px; height: 13px; flex: none; display: flex; align-items: center; justify-content: center; }
    .slide-dz .slide-label .ic svg { width: 13px; height: 13px; display: block; }
    /* Arrived — the ONE moment this control is allowed to be loud, and the only place it
       borrows a colour. It is visible for just the instant before .dz-open hides the
       track, but without it the handle lands on an unchanged rail and the slide reads as
       a no-op that happened to work. The knob fills solid here (it has done its job), so
       it takes the white ink and the matching edge with it. */
    /* Three classes, and each one is load-bearing: `.slide-confirm.done` is the COMPLETE
       slider's arrival (solid green, knob faded to nothing, white label) and it is
       declared thousands of lines below this, so at equal weight it wins. These have to
       out-rank it, not merely differ from it. */
    .slide-confirm.slide-dz.done { background: var(--danger-bg); border-color: var(--danger); }
    .slide-confirm.slide-dz.done .slide-handle { opacity: 1; background: var(--danger); border-color: var(--danger); color: #fff; }
    .slide-confirm.slide-dz.done .slide-label { color: var(--danger); }
    /* Hide the DZ section until unlocked on desktop. The track lives in the save footer,
       so the old sibling combinator no longer applies — target directly instead. */
    /* One rule for both entry points, and it reaches the FOOTER, because that is where
       the member zone lives now — under the save bar, which is the order it has always
       wanted. (There were two of these, one per `members-quick` branch, saying the same
       thing about the same element.) */
    .page-frame:has(#user-detail-view:not(.hidden):not(.dz-open)) > #ud-foot > .ud-section-label,
    .page-frame:has(#user-detail-view:not(.hidden):not(.dz-open)) > #ud-foot > .ud-danger { display: none; }
    /* ── The BUILDING screen's copy of the lock ──────────────────────────────
       Identical gesture, one structural difference: a building's Save bar is the
       page frame's own .page-foot — a sibling of the scroller, static markup —
       not a footer the record renders for itself. So the track rides in a slot
       there (#bd-dz-slot, filled by openBuildingDetail) and the "is it unlocked"
       class still lives on the view, which is why the track's rule reaches for it
       with :has() instead of a descendant selector.

       The member-count blocker stays exactly as it was: it decides whether Delete
       is ENABLED. This decides whether the zone is on screen at all. One is a fact
       about the data, the other is "I meant to come here" — and delete used to be
       reachable by simply scrolling to the end of the form, which is the same
       gesture as reading it. */
    /* The zone is the FOOTER's second band now, not the end of the form — so, like the
       member screen's, it is reached through the frame rather than through the record
       that carries `dz-open`. */
    .page-frame:has(#building-detail-view:not(.dz-open)) > .page-foot > #bd-dz-zone { display: none; }
    /* display:contents so the TRACK is the flex item, not the box around it: the slot is
       empty on the list screen and its child is display:none once unlocked, and either
       way a real flex item would hold 300px of footer open for nothing. */
    .dz-slot { display: contents; }
    /* Descendant inside the footer, not a `>` chain through it: the controls sit in a
       .pf-row band now, and a direct-child path that assumed otherwise would match
       nothing and show no lock at all — which is exactly what it did on the member
       screen when that footer gained its row. */
    .page-frame:has(#building-detail-view:not(.hidden):not(.dz-open)) > .page-foot .slide-dz {
      display: block; height: 34px; flex: 0 1 300px; min-width: 0;
    }
    /* ── THE BUILDING FOOTER IS TWO BANDS ────────────────────────────────────
       Controls, then the zone the lock in them opens — the same shape #ud-foot has, for
       the same reason: the zone used to render at the end of the RECORD, which put it
       above the bar guarding it. The footer's own padding moves to the bands so the
       zone's rule can run wall to wall, and the second band collapses to nothing on the
       list screen and on a staged building with nothing to delete. */
    .ss-panel[data-panel="buildings"] .page-frame > .page-foot {
      flex-direction: column; align-items: stretch; gap: 0; padding: 0;
    }
    .ss-panel[data-panel="buildings"] .page-foot > .pf-row {
      display: flex; align-items: center; justify-content: flex-end; gap: 12px;
      padding: 13px var(--pad-card);
    }
    #bd-dz-zone:empty { display: none; }
    #bd-dz-zone > .ud-section-label {
      margin: 0; padding: 14px var(--pad-card) 0;
      border-top: 1px solid var(--border);
    }
    #bd-dz-zone > .ud-danger {
      margin: 8px 0 0; padding: 0 var(--pad-card) var(--pad-card);
      border-top: 0; gap: 12px;
    }
    /* Desktop danger zone — the same two fixes the phone view got at .members-quick
       above, which desktop never received:
         1. The rule belongs to the HEADING, not to the block under it. It lived on
            .ud-danger, so the order on screen was "DANGER ZONE", 12px of nothing, a
            rule, then the sentence the heading had already introduced — the label
            floating alone above a line that separated it from its own content.
         2. .page-body's bottom padding is zeroed here (line ~2588, so the sticky save
            footer lands flush at bottom:0), which means nothing below that footer has
            a gutter left: Delete sat directly on the card's bottom edge. This block is
            the last thing on the screen, so it owns the rest of the gutter — one
            --pad-card, the same value .page-body would have spent.
       Keyed on .dtl-body, not #user-detail-view: the BUILDING detail screen renders the
       same danger zone, behind the same slide, and the two must not fork the way their
       headers once did. (It carries a second, different guard on top — the assignment
       count that disables Delete outright.)

       And NOT keyed on :not(.members-quick), which is what the rest of this desktop block
       uses. `members-quick` is set by the ENTRY POINT (the phone's More sheet → Members),
       not by the viewport, while the styling it selects for lives inside a phone media
       query. So a members-quick visit at desktop width matched NEITHER branch and fell
       back to the raw .ud-danger defaults — the exact broken layout this section fixes.
       These three are plain layout, so they apply at every width; the phone rules above
       carry two ids and still win where they should. */
    #section-content .dtl-body .ud-section-label {
      margin: 14px 0 0;
      padding-top: 14px;
      border-top: 1px solid var(--border);
    }
    /* (A rule that zeroed this margin when a sticky save footer sat directly above it
       lived here. The footer is a frame-level sibling now, not the heading's previous
       element, so there is nothing to cancel: the heading keeps its own 14px gap from the
       last field, which is what it should have had all along.) */
    #section-content .dtl-body .ud-danger {
      margin-top: 8px;
      padding-top: 0;
      border-top: 0;
      gap: 12px;
    }
    /* (The member screen used to zero its scroller's bottom padding so the sticky footer
       could land flush, which left the danger zone — the last thing below that footer —
       owing the gutter back out of its own padding. The footer is a sibling of the
       scroller now, the scroller keeps its padding, and a rule handing the gutter back a
       second time would have produced two.) */
    /* ── THE MEMBER ZONE, IN THE FOOTER ──────────────────────────────────────
       The rules above are keyed on .dtl-body, which is the RECORD — still right for the
       building screen, whose zone is the last thing in its scroller. The member zone is
       the second band of #ud-foot now (under the save bar, which is the order it has
       always wanted), so it is outside .dtl-body and states its own. Same numbers, so the
       two screens read identically; only the mount differs.
       The footer is a column here: a row for the bar, then this. Both bands carry the
       .page-foot gutter themselves, which is why the footer's own padding is zeroed. */
    #ud-foot { flex-direction: column; align-items: stretch; gap: 0; padding: 0; }
    #ud-foot > .ud-foot-row {
      display: flex; align-items: center; justify-content: flex-end; gap: 10px;
      padding: 13px var(--pad-card);
    }
    /* The heading's rule is the band's own divider — it separates the zone from the bar
       above it, so it runs wall to wall rather than being inset like a section title
       inside the form. */
    #ud-foot > .ud-section-label {
      margin: 0; padding: 14px var(--pad-card) 0;
      border-top: 1px solid var(--border);
    }
    #ud-foot > .ud-danger {
      margin: 8px 0 0; padding: 0 var(--pad-card) var(--pad-card);
      border-top: 0; gap: 12px;
    }
    /* ── THE WAY BACK ────────────────────────────────────────────────────────
       Unlocking used to be one-way for the rest of the visit — nothing on screen put
       the guard back, and the slot the lock had occupied simply emptied. This takes
       that slot instead: same place, same 34px height, so the bar keeps its shape when
       the zone opens and the pair reads as one switch rather than a control that
       vanishes. Muted like the lock it replaces — re-locking is the safe direction. */
    .dz-relock { display: none; }
    .page-frame:has(#user-detail-view.dz-open) #ud-foot .dz-relock,
    #section-ticket .td-grid.dz-open .dz-relock,
    .page-frame:has(#building-detail-view.dz-open) > .page-foot .dz-relock {
      display: inline-flex; align-items: center; gap: 6px; flex: none;
      height: 34px; padding: 0 14px; margin-right: auto;
      /* 9px — .btn's radius. It is a BUTTON, and the app's buttons have corners; the pill
         it started as is the shape of the slide track it replaces, and borrowing that
         made it read as another track rather than as the thing you press. */
      background: var(--neutral-bg); border: 1px solid var(--border); border-radius: 9px;
      font-family: inherit; font-size: var(--fs-sm); font-weight: var(--fw-semi);
      color: var(--muted); cursor: pointer; line-height: var(--lh-none);
    }
    .dz-relock:hover { border-color: var(--muted); color: var(--text); }
    .dz-relock .ic { width: 13px; height: 13px; flex: none; }
    /* The phone card is the one mount where the relock is not in a bar — it is the first
       thing in the section, where the pull strip was. So it needs the gap the strip's own
       row used to provide, and a thumb-sized target rather than a bar-sized one.
       Carries `.dz-open` so it matches the show-rule's specificity: without it the height
       above wins and the button stays at the bar's 34px under a thumb. */
    #section-ticket .td-grid.dz-open .td-dz .dz-relock { height: 40px; margin-bottom: 12px; }
    /* The pull-to-unlock strip. OFF at every width by default — the phone Members view
       and the request detail each opt in. Only `display` is theirs; everything the strip
       LOOKS like lives here, unscoped, so the two can't drift into two different strips.
       (The Members copy below still carries its own — it is inside a phone media query
       and predates this; identical values, higher specificity, same result.) */
    .ud-dz-lock { display: none; }
    .ud-dz-lock .ic { width: 13px; height: 13px; flex: none; }
    /* Ellipsis rather than wrap — the strip is one line by design. */
    .ud-dz-lock-t { flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .ud-dz-pips { display: flex; gap: 5px; flex: none; }
    .ud-dz-pips i { width: 6px; height: 6px; border-radius: 50%; background: var(--border); transition: background .15s; }
    /* One pip per completed pull. */
    .ud-dz-lock[data-pulls="1"] .ud-dz-pips i:nth-child(-n+1),
    .ud-dz-lock[data-pulls="2"] .ud-dz-pips i:nth-child(-n+2),
    .ud-dz-lock[data-pulls="3"] .ud-dz-pips i { background: var(--danger); }
    .ud-danger-hint { font-size: var(--fs-sm); line-height: var(--lh-body); color: var(--muted); }
    .ud-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 12px 18px; }
    .ud-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
    .ud-field input, .ud-field select, .ud-field textarea { width: 100%; box-sizing: border-box; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-md); font-family: inherit; background: #fff; color: var(--text); }
    .ud-field input:focus, .ud-field select:focus, .ud-field textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    .ud-field textarea { min-height: 72px; resize: vertical; }
    .ud-field .ur-hub { font-size: var(--fs-md); display: flex; align-items: center; gap: 6px; }
    /* building rows/detail use a school glyph in the avatar circle */
    .ur-avatar .lic { width: 19px; height: 19px; }
    .ud-map { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-sm); color: var(--accent); text-decoration: none; }
    .ud-map:hover { text-decoration: underline; }
    .ud-map .lic { width: 13px; height: 13px; color: var(--muted); }
    .ur-hub { display: flex; align-items: center; gap: 5px; font-size: var(--fs-sm); color: var(--muted); white-space: nowrap; }
    .ur-bio-btn { background: none; border: 1px solid var(--border); border-radius: 7px; padding: 6px 10px; font-size: var(--fs-sm); font-family: inherit; cursor: pointer; color: var(--muted); }
    .ur-bio-btn:hover { border-color: var(--accent); color: var(--accent-dark); }
    .ur-bio-edit { flex-basis: 100%; }
    .ur-bio-edit textarea { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-md); font-family: inherit; resize: vertical; min-height: 56px; margin-top: 4px; }
    .ur-bio-edit textarea:focus { outline: none; border-color: var(--accent); }
    .pending-tag { font-size: var(--fs-2xs); font-weight: var(--fw-bold); padding: 1px 7px; border-radius: 999px; background: var(--accent-light); color: var(--accent-dark); text-transform: uppercase; }
    .lc-bio { font-size: var(--fs-md); color: var(--text); line-height: var(--lh-body); margin-top: 6px; }

    /* Integrations (per-user connections) */
    .integration-row { display: flex; align-items: center; gap: 14px; }
    .ir-info { flex: 1; min-width: 0; }
    .ir-info h3 { font-size: var(--fs-xl); font-weight: var(--fw-bold); margin-bottom: 3px; }
    .ir-status { font-size: var(--fs-md); color: var(--muted); }
    .ir-actions { display: flex; gap: 8px; flex-wrap: wrap; flex-shrink: 0; }
    /* Stacked variant: a full-width input on its own line with the button dropped
       below it (GroupMe link, which pairs a token field with its Connect button). */
    .ir-actions.ir-stack { flex-direction: column; align-items: stretch; flex-wrap: nowrap; }
    .ir-actions.ir-stack > .btn { align-self: flex-start; }
    .intg-card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: var(--pad-card); box-shadow: var(--shadow); }   /* match .card.padded so content lines up with the other profile cards — same token, so they cannot drift apart again */
    .intg-ico { width: 26px; height: 26px; border-radius: 7px; display: grid; place-items: center; font-size: var(--fs-xl); flex-shrink: 0; }
    .intg-ico.canva { background: transparent; padding: 0; overflow: hidden; }
    .intg-ico.canva img { width: 26px; height: 26px; display: block; }
    .intg-ico.gmail { background: #fff; border: 1px solid var(--border); padding: 4px; overflow: hidden; }
    .intg-ico.gmail img { width: 100%; height: 100%; display: block; object-fit: contain; }
    /* Site Settings grouping */
    .settings-group-label { font-size: var(--fs-xs); font-weight: var(--fw-display); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 26px 3px 11px; display: flex; align-items: center; gap: 8px; }
    .settings-group-label::after { content: ""; flex: 1; height: 1px; background: var(--border); }
    .settings-group-label:first-of-type { margin-top: 10px; }
    /* These two are off-ladder half-pixel literals (Stage C), but they're the body
       copy and title of EVERY settings panel — the most-read text in the admin UI.
       Left un-multiplied they'd sit stubbornly at 13.5px while the rest of the page
       grew with --fs-scale, which is precisely the drift a reader notices. The base
       values are unchanged, so at scale 1 they render byte-identically; they just
       participate now. The other ~565 literals still wait for Stage C. */
    .set-desc { color: var(--muted); font-size: var(--fs-md); line-height: var(--lh-body); }
    .set-card-title { font-size: var(--fs-xl); font-weight: var(--fw-bold); margin-bottom: 5px; }
    /* Card header row: title on the left, an inline action button on the right
       (like the Requests page header). */
    .set-card-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
    .set-card-head .set-card-title { margin-bottom: 0; }
    /* Hide an action WITHOUT collapsing its row — the control goes, its slot stays,
       so nothing beside it moves (a .set-card-head's title/description don't shift
       up; a .page-bar's count doesn't slide across as you step into a sub-view).
       Written UNSCOPED on purpose, for the same reason .crumb-back is: it was
       declared as `.set-card-head .tuck-hidden`, and Content Pages' "+ Add Page"
       lives in .page-acts, not in a .set-card-head — so toggling the class there
       landed and did nothing at all. The button stayed on screen beside the chooser's
       Cancel and the pair shunted sideways, which is the bug this class exists to
       prevent. A utility that silently no-ops outside one ancestor isn't a utility. */
    .tuck-hidden { visibility: hidden; }
    /* Service-panel header: brand-logo chip beside the title (Settings panels).
       Distinct from .set-card-head (which is a title-vs-action space-between row). */
    .svc-head { display: flex; align-items: center; gap: 11px; margin-bottom: 6px; }
    /* Inside the standard head, the service's logo + name is a fixed leading block, like
       a row's lead slot: it must not grow, wrap, or push the head off its one height.
       (Email (SMTP) and Text Messaging (Twilio) wrapped their titles and made those two
       screens' heads 61px and 82px against everything else's 53px.) */
    .page-bar > .svc-head { flex: none; margin-bottom: 0; white-space: nowrap; }
    /* Platform → Integrations & Services: fields sit two-up instead of one tall stack,
       so each service panel fits the viewport without scrolling. */
    .svc-duo, .svc-form { display: grid; grid-template-columns: 1fr 1fr; gap: 0 18px; align-items: start; }
    .svc-form .field-wide, .svc-form details.field, .svc-form .msg { grid-column: 1 / -1; }
    .svc-duo .status-blk { margin-bottom: 16px; }
    @media (max-width: 900px) { .svc-duo, .svc-form { grid-template-columns: 1fr; } }
    /* Platform → Site Admins: columnar rows + static header, matching the Members list. */
    /* The Platform console's admin lists are config rows too — same lead slot, same
       hairline. Only the columns are theirs. (They used to borrow .user-row and
       .ur-header wholesale from the Members roster, which is why restyling the roster
       reached into the Platform console at all.) */
    #padmin-list, #sadmin-list { --list-cols: var(--list-lead) minmax(140px, 1.2fr) minmax(200px, 1.8fr) minmax(130px, 1fr) calc(92px * var(--fs-scale, 1)); }
    .adm-row { cursor: default; }
    .adm-row .ur-email { padding-left: 0 !important; }
    .adm-row .ur-last { font-size: var(--fs-sm); color: var(--muted); }
    .adm-row .ur-act { display: flex; justify-content: flex-end; }
    .svc-head .set-card-title { margin-bottom: 0; }
    .svc-logo { width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center; flex-shrink: 0; overflow: hidden; background: #fff; border: 1px solid var(--border); color: var(--accent); }
    .svc-logo .lic { width: 17px; height: 17px; }
    .svc-logo.brand { border: none; background: transparent; }
    .svc-logo.brand.boxed { background: #fff; border: 1px solid var(--border); }
    .svc-logo.brand img { width: 100%; height: 100%; object-fit: contain; display: block; }
    .svc-logo.brand.boxed img { padding: 3px; box-sizing: border-box; }
    /* Consistent breathing room under each panel's intro paragraph so panels no
       longer need ad-hoc inline margins. */
    #section-settings .set-desc { margin-bottom: 14px; }
    .page-intro { color: var(--muted); font-size: var(--fs-md); line-height: var(--lh-body); margin-bottom: 2px; }
    .page-intro a { color: var(--accent); font-weight: var(--fw-semi); }
    /* ── AUTOMATED EMAILS — five mappings on the left, the email on the right ──
       SET ON THE LEFT, WATCH ON THE RIGHT. That is the shape App Icon, App Header
       and Letterhead already use, and this screen now uses the same `.prev-stage`
       they do: the recessed fill, the border, and the PREVIEW pill that tells a
       stage from a control panel. There is nothing else to invent here — five
       situations, five `email → layout` rows, and the email.

       It used to be a master–detail rail feeding a bordered second card inside
       `.page-scroll`, with a fixed 460px iframe. On a 720px window that put 747px
       of content into a 248px scroller: a box inside a box, with the email's own
       button cut off mid-word. Now the grid fits the frame and the SHEET scales to
       fit the stage (sysmailFitPaper) — the whole email, in view, at once. */
    /* The mapping column gets the wider half of the split it deserves: it holds five
       dropdowns whose options are layout NAMES, and a name that ellipsises in the
       control is a mapping you can't read. The stage loses nothing by it — the sheet
       is height-bound at every realistic window, so the width it gives up was grey. */
    .sysmail-grid { display: grid; grid-template-columns: minmax(0, 430px) minmax(0, 1fr);
      grid-template-rows: minmax(0, 1fr); gap: 16px; flex: 1 1 auto; min-height: 0; }
    /* The mapping column. Five rows, and each one is the whole statement: this
       email, that design. Nothing else lives here — the hint, the subject and the
       two editing doors all belong to the email you are LOOKING at, so they are on
       the stage with it. */
    .sysmail-map { min-width: 0; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; }
    .sysmail-map .sec-sub-title { flex: none; }
    /* ── THE MAPPING IS A TABLE, NOT A BESPOKE RAIL ─────────────────────────
       It was five two-line tiles — the name on its own line as a bare button,
       the dropdown beneath it — and the ONLY clickable thing was the name: a
       ~145px word inside a 400px row, with nothing to say the rest of the row
       was inert. So it is the house table now: .list-row on the shared
       --list-cols grid (declared with every other list's, down in COLUMNIZED
       ROWS), capped by a .list-head that names the two columns and draws the
       box. Three wins fall out of it — the whole 44px row is the target (44 is
       the standard touch target, which is this fix stated as a number), the
       email and its design read across one line instead of down two, and the
       column lands on the same grid as Buildings and Request Types one tab over.

       NO END SLOT, which is the one place this list departs from the section.
       The rail is reserved everywhere else so a delete button sits on the same
       x on every screen; these rows carry no action and open no screen, so it
       would reserve 46px of a 430px column for nothing — and take it out of the
       layout NAMES this column was widened to keep readable. */
    .sysmail-map .list-head { flex: none; scrollbar-gutter: auto; }   /* caps a list, not a scroller — no gutter to reserve */
    .sysmail-rows { flex: none; overflow: hidden; }                   /* clip the last row to the box's bottom corners */
    /* The selected row is the one on the stage. A filled row with an accent edge
       rather than the old ring: inside a bounded table a hover is already a
       full-width band (see THE LIST IS A BOUNDED TABLE), so a rounded ring on one
       row was the only thing in the box with corners. The 3px edge is paid for out
       of the row's own left padding, so the columns don't shift when it lights.
       Written under .sysmail-rows so it outranks .list-row:hover — otherwise
       hovering the selected row un-selects it, visually. */
    .sysmail-rows .sysmail-row.active { background: var(--accent-light); border-left: 3px solid var(--accent); padding-left: 9px; }
    .sysmail-rows .sysmail-row.active .list-row-lead { color: var(--accent-dark, var(--accent)); }
    .sysmail-rows .sysmail-row.active .sysmail-name { font-weight: var(--fw-bold); }
    .sysmail-name { min-width: 0; font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text);
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    /* .list-sel brings the height, border, radius and focus ring every field in
       Site Config uses; all this adds is "fill your column". */
    .sysmail-sel { width: 100%; min-width: 0; }
    /* ── THE STAGE ─────────────────────────────────────────────────────────
       .prev-stage supplies the recessed fill, the border and the padding. What
       is local is that this stage is a COLUMN that fits: the head, the hint and
       the subject are `flex: none`, .sysmail-paper takes the rest, and the sheet
       inside it is scaled to whatever is left. */
    .sysmail-stage { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
    .sysmail-stage > .prev-head { flex: none; }
    /* ── WHEN THIS ONE SENDS ────────────────────────────────────────────────
       The single fact a leader opens this screen to check, so it gets a bar of its
       own above the message rather than being the first clause of a muted sentence
       ("Sent when a member asks to reset their password. Its Action Button becomes
       the reset link." — two unrelated facts, one paragraph, neither prominent).
       Reads as a rule the app follows: a trigger glyph, an eyebrow, the condition.

       IT IS NOT A FIELD, AND IT MUST NOT LOOK LIKE ONE. It used to be a white,
       square, full-width box with an uppercase grey eyebrow and near-black value —
       which is precisely the envelope line six pixels below it, so the stage read as
       two rows of the same form and "Sends when" outweighed the subject it was
       sitting on top of. The two are different KINDS of thing: the trigger is the
       app annotating this email, the envelope is the email itself. So they no longer
       share a shape or a colour — this one is a tinted pill that hugs its own text,
       accent ink throughout, one step down the type scale, and it never touches the
       stage's far edge. The white rectangle belongs to the mail. */
    .sysmail-trigger { flex: none; align-self: flex-start; max-width: 100%;
      display: flex; align-items: center; gap: 8px; margin-bottom: 11px;
      padding: 6px 14px 6px 11px; border-radius: 999px; background: var(--accent-light); border: 0; }
    .sysmail-trigger .lic { width: 15px; height: 15px; flex: none; color: var(--accent-dark, var(--accent)); }
    /* The eyebrow is the same accent as the value, held back by weight and size
       rather than by a second colour — a grey label on a tinted pill reads as a
       leftover from the box this replaced. */
    .sysmail-trig-l { flex: none; font-size: var(--fs-2xs); font-weight: var(--fw-bold);
      text-transform: uppercase; letter-spacing: .1em; color: var(--accent-dark, var(--accent)); opacity: .72; }
    .sysmail-trig-t { min-width: 0; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--accent-dark, var(--accent));
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    /* ── THE MESSAGE — an envelope line, then the mail ──────────────────────
       A box on the stage on purpose: this is a SIMULATED EMAIL, which is the
       documented exception to "the page card is the only card" (Space-Rollout lists
       the Signature preview's .sig-mail under the same reasoning). The subject used
       to be a run of muted 12px prose — "Built-in design · Subject · Reset your
       password" — where the label outweighed the value. Now the label is an eyebrow,
       the subject is the loudest text on the stage, and which design it came from
       sits at the far edge as a chip. */
    .sysmail-sheet { flex: 1 1 0; min-height: 0; min-width: 0; display: flex; flex-direction: column;
      background: var(--card); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
    .sysmail-env { flex: none; display: flex; align-items: baseline; gap: 10px; padding: 9px 13px;
      border-bottom: 1px solid var(--border); }
    .sysmail-env-l { flex: none; font-size: var(--fs-2xs); font-weight: var(--fw-bold);
      text-transform: uppercase; letter-spacing: .1em; color: var(--muted); }
    .sysmail-env-s { flex: 1 1 auto; min-width: 0; font-size: var(--fs-lg); font-weight: var(--fw-bold);
      color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    /* Quiet by default and accented only when it ISN'T the built-in: the exceptional
       state is the one worth drawing the eye. */
    .sysmail-env-src { flex: none; align-self: center; font-size: var(--fs-xs); font-weight: var(--fw-semi);
      padding: 3px 9px; border-radius: 999px; background: var(--bg); color: var(--muted); white-space: nowrap; }
    .sysmail-env-src.on { background: var(--accent-light); color: var(--accent-dark, var(--accent)); }
    /* THE PAPER. Same idea as the letterhead sheet: the flexible box absorbs the
       slack and the sheet fits inside it. Different mechanism, because an email has
       no fixed aspect ratio — it is rendered at its natural width and SCALED, so a
       tall invite and a short 2FA code both land whole. `overflow: hidden` is what
       makes the scale authoritative: nothing here is allowed to scroll. */
    .sysmail-paper { flex: 1 1 0; min-height: calc(140px * var(--fs-scale, 1)); min-width: 0;
      display: flex; justify-content: center; align-items: flex-start; overflow: hidden; background: var(--bg); }
    .sysmail-prev-empty b { font-weight: var(--fw-semi); color: var(--text); }
    /* Rendered at a fixed natural width and then scaled — NOT sized to the stage.
       The email's own stylesheet goes full-bleed under 620px, so a stage-width
       iframe would preview the PHONE layout on a desktop and change shape as the
       window moved. One render width means one email, drawn once, shown smaller. */
    .sysmail-prev-frame { flex: none; width: 640px; border: 0; background: #fff; display: block;
      transform-origin: top center; }
    .sysmail-prev-empty { flex: 1 1 auto; display: grid; place-items: center; padding: 24px 20px;
      font-size: var(--fs-sm); color: var(--muted); text-align: center; line-height: var(--lh-body); }
    /* A phone can't hold two panes side by side, so they stack and the grid itself
       becomes the scroller. The paper takes a fixed height there: a flexible box
       inside a scroller has nothing to be flexible against. */
    @media (max-width: 860px) {
      .sysmail-grid { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto; overflow-y: auto; }
      .sysmail-map { overflow: visible; }
      .sysmail-sheet { flex: none; }
      .sysmail-paper { flex: none; height: calc(360px * var(--fs-scale, 1)); }
    }

    /* Branding panel — logo tiles, text field grid, welcome splash blocks. */
    /* One row per logo slot: fixed thumbnail · flexible copy · actions pinned right.
       (Was a 3-up grid of tall tiles — in a half-width column the buttons wrapped into
       a vertical stack and the panel needed its own scrollbar. Sideways, it fits.) */
    /* ── The media card: ONE component, every image this app asks a local for ──
       Anatomy, top to bottom: title + one-line purpose · a STAGE showing where the
       image actually lands · the same three actions · a one-line spec hint · options.
       The logo and the welcome splash are two instances of it. They used to be a tile
       grid beside a bordered block with two button vocabularies and two heading
       styles, which is what "designed at different times" looks like. */
    /* stretch, not start: the two cards are a PAIR and a ragged bottom edge reads as
       one of them being unfinished. .media-opts is pushed to the bottom so the
       option blocks line up with each other too, not just the card borders. */
    .media-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(360px, 1fr)); gap: 14px; align-items: stretch; }
    /* ── …and the same shell under a name that doesn't say "media" ───────────
       A Site Config panel that has two or three unrelated things to say puts each
       in one of these: a bordered, rounded, padded box, several across, equal
       height. That is what Branding → Images already looks like, and it is the
       shape the section is consistent in — so a screen that needs it reaches for
       .set-cards / .set-card rather than inventing a fourth container. Same
       declaration as .media-card, listed together so the two can never drift.
       (.set-card-title already existed as the generic title for this box.) */
    .media-card, .set-card { border: 1px solid var(--border); border-radius: 12px; background: var(--card); padding: 12px 14px; min-width: 0; }
    /* The gap belongs to .media-card ALONE. Its children are three composed blocks with
       no margins of their own; a .set-card holds ordinary form content that carries its
       own, and every gap would then stack on a margin — and, worse, be charged for the
       children that render at zero height (a hidden field, an empty .msg). Three of
       those cost 36px of a card that had 50 to give. */
    .media-card { display: flex; flex-direction: column; gap: 12px; }
    /* The grid is auto-fit by default like .media-grid's; a screen whose boxes hold
       different amounts sets its own tracks (see .lgl-cards). */
    .set-cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 14px; align-items: stretch; }
    .media-title { margin: 0; font-size: var(--fs-lg); font-weight: var(--fw-bold); }
    .media-lede { margin: 3px 0 0; line-height: var(--lh-body); }
    .media-body { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 14px; align-items: start; }
    .media-ctl { min-width: 0; display: flex; flex-direction: column; gap: 7px; }
    .media-actions { display: flex; gap: 6px; flex-wrap: wrap; }
    .media-actions .btn { white-space: nowrap; }
    .media-hint { margin: 0; line-height: var(--lh-body); }
    .media-opts { margin-top: auto; }
    /* The stage. A shot is a swatch of the SURFACE the image lands on — the dark bar,
       the light auth card, the phone-shaped splash — so the preview answers "will this
       read there?", which is the only question these settings exist to answer. */
    .media-stage-pair { display: grid; grid-template-columns: auto; gap: 3px; justify-items: center; }
    .media-shot { width: 132px; height: 46px; border-radius: 8px; border: 1px solid var(--border); display: grid; place-items: center; overflow: hidden; padding: 6px; text-align: center; }
    .media-shot.on-dark { background: var(--primary); border-color: var(--primary); }
    .media-shot.on-light { background: #fff; }
    .media-shot.portrait { width: 92px; height: 115px; background: var(--bg); }
    .media-shot.portrait.mini { width: 38px; height: 48px; padding: 3px; border-radius: 6px; }
    .media-shot-img { max-width: 100%; max-height: 100%; object-fit: contain; }
    .media-shot-img[data-plate] { background: var(--logo-plate, #fff); padding: 3px 7px; box-sizing: border-box; }
    .media-shot-img[data-plate="square"] { border-radius: 2px; }
    .media-shot-img[data-plate="round"] { border-radius: 7px; }
    .media-shot-empty { color: var(--muted); font-size: var(--fs-xs); }
    .media-shot.on-dark .media-shot-empty { color: rgba(255,255,255,.65); }
    .media-shot-cap { font-size: var(--fs-xs); color: var(--muted); }
    /* ── The option vocabulary, shared with the Letterhead tab ──
       Exactly two ways to ask a question anywhere in Branding: .opt-pick ("choose one")
       and .opt-chip ("on or off"). The .lh-* selectors below are the Letterhead tab's
       original names for the same two things, kept so its markup is untouched — the
       point is that they now resolve to ONE set of declarations, not two that drift. */
    .opt-group + .opt-group { margin-top: 10px; }
    .opt-group-head { display: flex; align-items: baseline; gap: 8px; font-weight: var(--fw-bold); font-size: var(--fs-md); margin-bottom: 7px; }
    .opt-group-note { font-size: var(--fs-xs); font-weight: var(--fw-body); color: var(--muted); }
    .opt-line { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; }
    .opt-line + .opt-line { margin-top: 7px; }
    .opt-line-sub { margin-top: 9px; padding-top: 9px; border-top: 1px solid var(--border); }
    .opt-line-lbl { flex: 0 0 58px; font-size: var(--fs-sm); color: var(--muted); }
    .opt-sub-txt { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1 1 auto; font-size: var(--fs-sm); }
    .opt-picks { display: flex; gap: 5px; flex: 1 1 auto; }
    .opt-chips { display: flex; gap: 6px; flex-wrap: wrap; flex: 1 1 auto; }
    .opt-pick, .lh-src-opt { display: block; padding: 6px 11px; border: 1px solid var(--border); border-radius: 8px;
      cursor: pointer; position: relative; text-align: center; background: var(--card); flex: 1 1 auto; }
    .opt-pick:hover, .lh-src-opt:hover { border-color: var(--muted); }
    .opt-pick.on, .lh-src-opt.on { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, transparent); }
    .opt-pick input, .lh-src-opt input { position: absolute; opacity: 0; pointer-events: none; }
    .opt-pick-name, .lh-src-name { font-size: var(--fs-sm); }
    .opt-pick.on .opt-pick-name, .lh-src-opt.on .lh-src-name { font-weight: var(--fw-bold); color: var(--accent); }
    .opt-chip, .lh-opt { display: flex; align-items: center; gap: 8px; margin: 0; padding: 6px 10px; cursor: pointer;
      border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-sm); background: var(--card); }
    .opt-chip:hover, .lh-opt:hover { border-color: var(--muted); }
    .opt-chip:has(input:checked), .lh-opt:has(input:checked) { border-color: var(--accent); background: color-mix(in srgb, var(--accent) 7%, transparent); }
    .opt-chip input, .lh-opt input { flex-shrink: 0; margin: 0; width: auto; }
    .opt-swatch { flex: none; width: 38px; height: 30px; padding: 2px; border: 1px solid var(--border); border-radius: 8px; background: var(--card); cursor: pointer; }
    .opt-swatch:disabled { opacity: .35; cursor: default; }
    .opt-num { flex: none; font-size: var(--fs-sm); color: var(--muted); }
    .opt-num input { width: 58px; padding: 5px 7px; border: 1px solid var(--border); border-radius: 7px; font-size: var(--fs-md); font-family: inherit; margin: 0 3px; }
    /* The plate on the REAL surfaces. Styled on the element that ALREADY holds the
       logo — #brand-logo is rewritten by several header painters, so a wrapper would be
       destroyed on the next repaint. --logo-plate carries the local's chosen colour. */
    #brand-logo[data-plate], .auth-logo[data-plate] { background: var(--logo-plate, #fff); }
    #brand-logo[data-plate] { padding: 3px 8px; box-sizing: border-box; }
    .auth-logo[data-plate] { padding: 8px 14px; box-sizing: content-box; }
    #brand-logo[data-plate="square"], .auth-logo[data-plate="square"] { border-radius: 2px; }
    #brand-logo[data-plate="round"] { border-radius: 8px; }
    .auth-logo[data-plate="round"] { border-radius: 12px; }
    .brand-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 11px 14px; }
    .brand-fields .bf { min-width: 0; display: flex; flex-direction: column; gap: 4px; }
    .brand-fields .bf-wide { grid-column: 1 / -1; }
    .brand-fields .bf label { font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--text); }
    .brand-fields .bf input { width: 100%; padding: 8px 11px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-md); font-family: inherit; }
    .brand-fields .bf input:focus { outline: none; border-color: var(--accent); }
    /* Standalone text box in the Text Strings panel (phone-contact reminder). */
    .brand-textarea { width: 100%; box-sizing: border-box; padding: 10px 12px; border: 1px solid var(--border); border-radius: 9px; font: inherit; font-size: var(--fs-md); line-height: var(--lh-body); resize: vertical; background: var(--card); color: var(--text); }
    .brand-textarea:focus { outline: none; border-color: var(--accent); }
    /* Brand Colors editor — a 2×2 grid of swatch cards (was four stacked rows,
       which wasted height and forced the panel to scroll). Actions + preview span
       the full width below. */
    #brand-colors { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 12px 14px; align-items: stretch; }
    #brand-colors #bc-preview-wrap { grid-column: 1 / -1; grid-row: 1; }
    @media (max-width: 1250px) { #brand-colors { grid-template-columns: 1fr 1fr; } }
    @media (max-width: 620px)  { #brand-colors { grid-template-columns: 1fr; } }
    /* Two-column Site Config panels (Logos) — the sections sat in one tall stack;
       side-by-side columns let the panel fit the viewport without scrolling.
       .comms-* rode along here until the Email & Calendar panel was split into four
       single-purpose panels (Connections/Calendars/Signature/Event Reminders); none of
       them needs two columns, so those selectors are gone. */
    /* (.logos-cols / .logos-col are gone — the panel's two halves were a hand-built
       two-column split with a hairline between them. They are now two cards in
       .media-grid, which wraps on its own at narrow widths.) */
    .logos-xref { margin: 13px 0 0; padding-top: 11px; border-top: 1px solid var(--border); }
    .logos-xref .link-btn { font-size: inherit; }
    /* (The rep-coverage target's own styles are gone: it stopped being a section
       below the titles list and became an inline cell on the Building
       Representative row — see .pos-cover.) */
    /* A colour card IS a media card — same shell, same title, same lede. It was a
       near-copy with its own border radius and padding, which is how two panels in
       one section end up almost matching. .media-card carries the shell (see the
       Branding block above); only the swatch row below is specific to colour. */
    .bc-row { display: flex; flex-direction: column; gap: 8px; }
    .bc-head { display: flex; align-items: center; gap: 9px; }
    .bc-name { margin: 0; font-weight: var(--fw-bold); font-size: var(--fs-lg); }
    .bc-desc { flex: 1; font-size: var(--fs-sm); line-height: var(--lh-body); margin: 0; }
    .bc-control { display: flex; align-items: center; gap: 8px; }
    /* One merged color control: a clickable chip (native picker) fused to the hex field. */
    .bc-field { flex: 1; min-width: 0; display: flex; align-items: center; height: 38px; border: 1px solid var(--border); border-radius: 9px; overflow: hidden; background: #fff; }
    .bc-field:focus-within { border-color: var(--accent); }
    .bc-chip { position: relative; width: 40px; height: 100%; flex: none; cursor: pointer; border-right: 1px solid var(--border); }
    .bc-chip input[type=color] { position: absolute; inset: -4px; width: calc(100% + 8px); height: calc(100% + 8px); border: none; padding: 0; background: none; cursor: pointer; opacity: 0; }
    .bc-chip-face { position: absolute; inset: 0; pointer-events: none; }
    .bc-hex { flex: 1; min-width: 0; border: none; background: none; padding: 0 11px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: var(--fs-md); text-transform: lowercase; color: var(--text); }
    .bc-hex:focus { outline: none; }
    .bc-suggest { flex: none; display: inline-flex; align-items: center; gap: 5px; height: 38px; padding: 0 12px; border: 1px solid var(--border); border-radius: 9px; background: var(--card); font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); cursor: pointer; white-space: nowrap; }
    .bc-suggest:hover { border-color: var(--accent); color: var(--accent-dark); }
    .bc-sug-ic { display: inline-flex; }
    .bc-sug-ic .lic { width: 14px; height: 14px; }
    /* .bc-sw / .bc-sw-face are shared with the App Icon panel's background swatch — keep. */
    .bc-sw { position: relative; width: 46px; height: 46px; flex-shrink: 0; border-radius: 10px; overflow: hidden; box-shadow: inset 0 0 0 1px rgba(0,0,0,.12); cursor: pointer; }
    .bc-sw input[type=color] { position: absolute; inset: -4px; width: calc(100% + 8px); height: calc(100% + 8px); border: none; padding: 0; background: none; cursor: pointer; opacity: 0; }
    .bc-sw-face { position: absolute; inset: 0; pointer-events: none; }
    /* (.bc-actions is gone with the "Match saved" button — reverting is the
       footer's Discard now, on every panel.) */
    /* Framed like .ai-stage / .hdrp-stage — every branding preview lives in the
       same bordered stage with the same uppercase caption. */
    /* A PREVIEW SURFACE, not a control group. It sat in the same white card as the four
       editors, in the same column rhythm, so it read as a fifth thing you could set.
       Recessed (page-coloured, inset border) + a "Preview" tag = a stage you watch. */
    /* ── THE PREVIEW SURFACE — one stage, one caption, everywhere ───────────
       .prev-stage + previewHead() is the whole component: Brand Colors, App Icon,
       App Header, Letterhead, System Emails, Announcements, Menus & Buttons and the
       e-mail Signature all wear it, so "watch this" looks the same wherever you meet
       it. Brand Colors used to spell the caption as .bc-prev-head/.bc-prev-tag and
       the stage as a bare #id — a private copy of these rules that had already
       started to drift (its own margin-top reset, its own grid placement). If a
       preview needs something local, add a class BESIDE .prev-stage; don't fork it. */
    #bc-preview-wrap { grid-column: 1 / -1; }
    .prev-stage { border: 1px solid var(--border); border-radius: 12px; padding: 13px 16px; background: var(--bg); }
    .prev-head { display: flex; align-items: center; gap: 9px; font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin: 0 0 9px; }
    .prev-tag { flex: none; letter-spacing: .06em; padding: 2px 8px; border-radius: 999px; background: var(--accent); color: var(--on-accent, #fff); }
    /* Wide band: the sample app on the left, the derived shades and their note beside it. */
    .bc-prev-body { display: grid; grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: 16px; align-items: center; }
    .bc-prev-derived { min-width: 0; display: flex; flex-direction: column; gap: 9px; }
    .bc-prev-note { margin: 0; }
    @media (max-width: 900px) { .bc-prev-body { grid-template-columns: minmax(0, 1fr); } }
    /* Generated-shade chips. Deliberately .bc-shade, not .bc-chip — .bc-chip above is
       the 40px picker swatch fused into a field, and the two names collided. */
    .bc-shades { display: flex; flex-wrap: wrap; gap: 8px; }
    .bc-shade { display: flex; align-items: center; gap: 7px; min-width: 0; border: 1px solid var(--border); border-radius: 8px; padding: 5px 10px 5px 6px; font-size: var(--fs-sm); background: var(--card); }
    .bc-shade-sw { width: 20px; height: 20px; flex: none; border-radius: 5px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.12); }
    .bc-shade-l { font-weight: var(--fw-semi); white-space: nowrap; }
    .bc-shade-v { color: var(--muted); font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: var(--fs-xs); white-space: nowrap; }
    .bc-sample { margin-top: 12px; border-radius: 12px; padding: 16px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.08); }
    .bc-sample-bar { height: 34px; border-radius: 8px; display: flex; align-items: center; padding: 0 12px; margin-bottom: 12px; }
    .bc-sample-card { border-radius: 10px; padding: 16px; box-shadow: 0 1px 3px rgba(0,0,0,.12); display: flex; align-items: center; gap: 16px; }
    .bc-sample-btn { border: none; border-radius: 9px; color: #fff; font-size: var(--fs-md); font-weight: var(--fw-semi); padding: 9px 16px; font-family: inherit; }
    .bc-sample-link { font-size: var(--fs-md); font-weight: var(--fw-semi); text-decoration: underline; }
    /* App Icon generator (Site Config → App Icon) */
    /* Header row: the two name fields and the explainer share one line. */
    .ai-head-row { display: grid; grid-template-columns: minmax(0, 1.3fr) 1fr; gap: 18px; align-items: center; margin-bottom: 11px; padding-bottom: 11px; border-bottom: 1px solid var(--border); }
    .ai-head-row .ai-lead { margin: 0; font-size: var(--fs-sm); }
    @media (max-width: 900px) { .ai-head-row { grid-template-columns: 1fr; gap: 10px; } }
    .ai-names { display: grid; grid-template-columns: 2fr 1fr; gap: 14px; align-items: end; }
    .ai-names .bf { display: flex; flex-direction: column; gap: 3px; }
    .ai-names label { font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--muted); }
    .ai-names input { padding: 6px 9px; border: 1px solid var(--border); border-radius: 8px; font-family: inherit; font-size: var(--fs-md); }
    .ai-names input:focus { outline: none; border-color: var(--accent); }
    .ai-hint { font-weight: var(--fw-medium); text-transform: none; opacity: .7; }
    .ai-lead { margin: 2px 0 14px; }
    .ai-side-note { margin-top: 8px; }
    @media (max-width: 520px) { .ai-names { grid-template-columns: 1fr; } }
    /* Same geometry as .hdrp-grid on purpose — App Icon and App Header are sibling
       panels and should feel like one room: 260px controls left, framed stage right. */
    /* Preview on top, settings beneath — the shape Colors uses. It was a 260px
       control column beside the stage, which squeezed every hint into four
       words a line while the stage had room to spare. */
    .ai-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 10px; align-items: start; }
    /* Controls: seed tile left, its actions + note beside it (they stacked before). */
    /* Four columns on one row: the seed tile, its actions, then the two settings
       side by side. Stacked, the settings made this card taller than the preview. */
    .ai-controls { min-width: 0; display: grid; grid-template-columns: 62px minmax(0, 1.15fr) minmax(0, 1fr) minmax(0, 1fr); gap: 4px 16px; align-items: start; }
    .ai-controls .ai-names { grid-column: 1 / -1; grid-row: 1; margin-bottom: 2px; }
    .ai-controls .ai-seed-tile { grid-row: 2 / span 2; max-width: 62px; border-radius: 12px; }
    .ai-controls .ai-side-actions { grid-column: 2; grid-row: 2; margin-top: 0; }
    .ai-controls .ai-side-note { grid-column: 2; grid-row: 3; margin-top: 6px; font-size: var(--fs-sm); }
    .ai-controls .ai-ctrl-sep { display: none; }
    /* Placed by name, not by position: every child here is a <div>, so nth-of-type
       matched the seed tile and quietly put both settings in the wrong columns. */
    .ai-controls > .bf { grid-row: 2; margin-bottom: 0; }
    .ai-controls .ai-bf-bg { grid-column: 3; grid-row: 2 / span 2; }
    .ai-controls .ai-bf-size { grid-column: 4; grid-row: 2 / span 2; }
    @media (max-width: 1100px) { .ai-controls { grid-template-columns: 116px minmax(0, 1fr) minmax(0, 1fr); }
      .ai-controls .ai-bf-bg { grid-column: 3; grid-row: 2; }
      .ai-controls .ai-bf-size { grid-column: 3; grid-row: 3; } }
    .ai-seed-tile { width: 100%; aspect-ratio: 1/1; max-width: 150px; border-radius: 22px; box-shadow: inset 0 0 0 1px rgba(0,0,0,.12), var(--shadow); display: flex; align-items: center; justify-content: center; overflow: hidden; }
    .ai-seed-img { width: 78%; height: 78%; object-fit: contain; }
    .ai-seed-empty { font-size: var(--fs-xs); font-weight: var(--fw-semi); color: rgba(255,255,255,.85); text-shadow: 0 1px 2px rgba(0,0,0,.35); }
    .ai-side-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 11px; }
    .ai-ctrl-sep { margin: 16px 0; }
    .ai-controls .bf { display: flex; flex-direction: column; gap: 6px; margin-bottom: 0; }
    .ai-controls .bf > label { font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--muted); }
    .ai-bg-row { display: flex; align-items: center; gap: 12px; }
    .ai-swatches { display: flex; gap: 8px; }
    .ai-sw { width: 30px; height: 30px; border-radius: 8px; border: none; cursor: pointer; box-shadow: inset 0 0 0 1px rgba(0,0,0,.15); padding: 0; }
    .ai-sw.on { box-shadow: inset 0 0 0 1px rgba(0,0,0,.15), 0 0 0 2px var(--card), 0 0 0 4px var(--accent); }
    .ai-range { width: 100%; accent-color: var(--accent); }
    .ai-size-hint { display: block; font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); }
    /* Preview stage — how the icon really appears on each platform (iOS rounded
       square; Android masked by the launcher's shape, with the safe-zone circle). */
    /* The two platform previews sit side-by-side (they stacked before, forcing the
       panel to scroll); the separator hr becomes a vertical divider on the right cell. */
    .ai-stage { min-width: 0; border: 1px solid var(--border); border-radius: 12px; padding: 13px 16px; background: var(--bg); display: grid; grid-template-columns: 1fr 1.2fr; gap: 0 20px; align-items: start; }
    .ai-stage > .prev-head, .hdrp-stage > .prev-head { grid-column: 1 / -1; }
    .ai-stage > .card-sep { display: none; }
    .ai-stage .card-sep + .ai-plat { border-left: 1px solid var(--border); padding-left: 20px; }
    @media (max-width: 1150px) {
      .ai-stage { display: block; }
      .ai-stage > .card-sep { display: block; }
      .ai-stage .card-sep + .ai-plat { border-left: 0; padding-left: 0; }
    }
    .ai-plat-head { font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin-bottom: 7px; }
    .ai-plat-body { display: flex; align-items: flex-start; gap: 14px; flex-wrap: wrap; }
    .ai-plat-note { flex: 1; min-width: 165px; font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-loose); margin: 4px 0 0; }
    .ai-plat-note b { color: var(--text); font-weight: var(--fw-semi); }
    .ai-and-row { display: flex; gap: 12px; }
    .ai-fig { margin: 0; text-align: center; }
    .ai-plat + .ai-plat, .ai-stage .ai-plat { align-self: start; }
    .ai-tile { position: relative; width: 62px; height: 62px; }
    .ai-tile-ios { width: 70px; height: 70px; }
    .ai-tile canvas, .ai-and-img { display: block; width: 100%; height: 100%; }
    .ai-tile-ios canvas { border-radius: 23%; box-shadow: inset 0 0 0 1px rgba(0,0,0,.10), var(--shadow); }
    .ai-and-img { box-shadow: inset 0 0 0 1px rgba(0,0,0,.10); background: var(--bg); }
    .ai-mask-circle { border-radius: 50%; }
    .ai-mask-squircle { border-radius: 32%; }
    .ai-mask-rounded { border-radius: 20%; }
    .ai-safe { position: absolute; inset: 10%; border: 1.6px dashed #fff; border-radius: 50%; mix-blend-mode: difference; pointer-events: none; }
    .ai-devlbl { font-size: var(--fs-xs); color: var(--text); margin-top: 4px; font-weight: var(--fw-semi); }
    .ai-devsub { display: block; font-size: var(--fs-2xs); color: var(--muted); font-weight: var(--fw-medium); margin-top: 1px; }
    @media (max-width: 640px) { .ai-seed-tile { max-width: 150px; } .ai-and-row { flex-wrap: wrap; } }
    /* Header preview (Site Config → App Branding → Header) ──────────────────
       A REPLICA of the header, not a reuse of it: the live bar's phone sizing
       lives behind @media (max-width:760px), which never matches on the desktop
       this panel is edited from — so an inherited `header` would silently render
       the DESKTOP bar and the preview would be a lie. Every number below is
       copied from the real rules (header padding 11/16, .brand gap 14, name 13px
       @500 .85 white, tagline 10px uppercase .6 tracking .68 white, avatar 32px);
       if you change one there, change it here. Colors come through the same live
       CSS vars, so an unsaved palette edit previews correctly for free. */
    .hdrp-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 14px; align-items: start; }
    /* One row: the slider takes the width it needs, its reset sits beside it. Stacked,
       a single slider filled a card as tall as the preview above it. */
    .hdrp-controls { min-width: 0; display: grid; grid-template-columns: minmax(0, 1fr) auto; gap: 8px 18px; align-items: end; }
    .hdrp-controls .hdrp-actions { margin: 0; }
    .hdrp-controls .hdrp-note { grid-column: 1 / -1; margin-top: 2px; }
    @media (max-width: 620px) { .hdrp-controls { grid-template-columns: minmax(0, 1fr); } }
    .hdrp-controls .bf { display: flex; flex-direction: column; gap: 8px; }
    .hdrp-controls .bf > label { font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--muted); }
    .hdrp-val { color: var(--accent-dark); font-variant-numeric: tabular-nums; }
    .hdrp-ends { display: flex; justify-content: space-between; gap: 10px; font-size: var(--fs-xs); color: var(--muted); line-height: var(--lh-tight); }
    .hdrp-ends span:last-child { text-align: right; }
    .hdrp-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 14px; }
    .hdrp-note { margin-top: 12px; font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-loose); }
    .hdrp-note b { color: var(--text); font-weight: var(--fw-semi); }
    /* Same framed two-platform stage as .ai-stage: phone left, desktop right,
       the separator hr becoming a vertical divider — one preview language for
       both "On the Phone" panels. The phone column is sized to the pinned 390px
       mock (+ its bezel); narrow panels stack, then .hdrp-scroll scrolls it. */
    .hdrp-stage { min-width: 0; border: 1px solid var(--border); border-radius: 12px; padding: 13px 16px; background: var(--bg); display: grid; grid-template-columns: minmax(0, 404px) 1fr; gap: 0 20px; align-items: start; }
    .hdrp-stage > .card-sep { display: none; }
    .hdrp-stage .card-sep + .ai-plat { border-left: 1px solid var(--border); padding-left: 20px; }
    @media (max-width: 1150px) {
      .hdrp-stage { display: block; }
      .hdrp-stage > .card-sep { display: block; }
      .hdrp-stage .card-sep + .ai-plat { border-left: 0; padding-left: 0; }
    }
    /* The phone is pinned to a real 390px logical width — scaling it would make the
       one thing this panel exists to answer ("does my name still fit?") unreadable.
       Narrow panels scroll it rather than squeeze it. */
    .hdrp-scroll { overflow-x: auto; padding-bottom: 4px; }
    /* The phone preview is THE BAR at 390px — same treatment as the desktop bar beside
       it, which was always just a clean rounded strip. It used to be a full device mock:
       bezel, notch, status strip, fake page. Once the fake page came out to make the
       panel fit, what was left read as a clipped fragment rather than a phone — and the
       caption above it ("Phone · 390px wide") was already doing that job in words. */
    .hdrp-phone { width: 390px; border-radius: 10px; overflow: hidden; }
    .hdrp-bar { background: var(--primary); display: flex; align-items: center; justify-content: space-between; gap: 12px; }
    /* Reserve the TALLEST bar the slider can produce, so dragging never changes the
       preview's height. It used to grow with the logo, which reflowed everything below
       it — including the slider you were still holding — so the control walked out from
       under the cursor mid-drag. --hdrp-logo-max is written from HDR_LOGO_H.max at
       render, so this can't drift from the clamp the server actually enforces. */
    .hdrp-bar-m { padding: 11px 15px; min-height: calc(var(--hdrp-logo-max, 44px) + 22px); box-sizing: border-box; }
    /* Same reason, one level in: the logo box is what changes size, and a shorter one
       must not let the row collapse. */
    .hdrp-bar-m .hdrp-logo { min-height: var(--hdrp-logo-max, 44px); }
    .hdrp-bar-d { padding: 13px 24px; border-radius: 10px; }
    .hdrp-brand { display: flex; align-items: center; gap: 14px; min-width: 0; }
    .hdrp-logo { display: inline-flex; align-items: center; flex-shrink: 0; }
    .hdrp-logo img { width: auto; object-fit: contain; display: block; }
    .hdrp-word { font-size: var(--fs-wordmark); font-weight: var(--fw-wordmark); color: var(--accent); letter-spacing: -1px; line-height: var(--lh-none); }
    .hdrp-txt { min-width: 0; }
    .hdrp-name { display: block; font-size: var(--fs-md); font-weight: var(--fw-medium); color: rgba(255,255,255,.85); line-height: var(--lh-tight); }
    .hdrp-tag { display: block; font-size: var(--fs-2xs); color: rgba(255,255,255,.68); letter-spacing: .6px; text-transform: uppercase; }
    .hdrp-user { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
    .hdrp-uname { font-size: var(--fs-md); color: rgba(255,255,255,.92); font-weight: var(--fw-semi); }
    .hdrp-av { width: 32px; height: 32px; border-radius: 50%; background: var(--accent); border: 2px solid rgba(255,255,255,.2); flex-shrink: 0; }
    /* A few muted page rows under the bar — enough for the eye to read it as the
       top of a screen instead of a floating strip. */

    /* Platform / IT console */
    .it-chip { font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .03em; text-transform: uppercase; color: var(--muted); background: var(--bg); border-radius: 999px; padding: 2px 8px; white-space: nowrap; }
    .it-chip.it-warn { color: #fff; background: var(--warn, #b45309); }
    .it-admin { background: var(--bg); border-radius: 6px; padding: 2px 4px 2px 8px; display: inline-flex; align-items: center; gap: 2px; }
    .it-x { border: none; background: none; cursor: pointer; color: var(--muted); font-size: var(--fs-xl); line-height: var(--lh-none); padding: 0 4px; }
    /* IT Tenant Dashboard — single card with stats bar + map/detail */
    #section-it .ss-panel.active > .it-dash-card { flex: 1 1 0; min-height: 0; }
    .it-dash-card { display: flex; flex-direction: column; overflow: hidden; padding: 0 !important; }
    .it-dash-stats { border-bottom: 1px solid var(--border); flex-shrink: 0; }
    .it-dash-hd { display: flex; align-items: center; justify-content: space-between; gap: 16px; padding: 11px 20px; flex-wrap: wrap; }
    .it-dash-title { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--text); white-space: nowrap; }
    /* Names the world the counters cover — without it, "0 Members" on the moon reads as a bug. */
    .it-dash-world { font-weight: var(--fw-semi); color: var(--muted); }
    .it-dash-counters { display: flex; align-items: center; gap: 0; }
    .it-dash-stat { display: flex; align-items: baseline; gap: 6px; padding: 2px 14px; }
    .it-dash-stat-v { font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--text); }
    .it-dash-stat-l { font-size: var(--fs-sm); color: var(--muted); font-weight: var(--fw-medium); }
    .it-dash-stats-divider { width: 1px; height: 16px; background: var(--border); align-self: center; flex-shrink: 0; }
    .it-dash-layout { display: flex; align-items: stretch; flex: 1 1 0; min-height: 0; }
    .it-map-col { flex: 1 1 auto; min-width: 0; min-height: 0; position: relative; }
    .it-detail-col { flex: 0 0 310px; border-left: 1px solid var(--border); overflow-y: auto; scrollbar-gutter: stable; display: flex; flex-direction: column; }
    .it-map-col { position: relative; }
    .it-map-back-btn { position: absolute; top: 12px; left: 12px; z-index: 10; transition: opacity .2s ease; pointer-events: auto; background: var(--card) !important; box-shadow: 0 1px 4px rgba(0,0,0,.12); }
    #it-us-map-wrap { position: absolute; inset: 10px; display: flex; align-items: center; justify-content: center; }
    .it-us-map { width: 100%; height: 100%; display: block; }
    .usm-d { stroke: var(--bg); stroke-width: 0.8; cursor: pointer; transition: filter .1s ease; }
    .usm-d.usm-empty { fill: var(--neutral-bg); stroke: var(--border); stroke-width: 0.6; }
    .usm-d.usm-active { fill: var(--ok); }
    .usm-d:hover { filter: brightness(1.10); }
    .usm-d.usm-sel { fill: var(--accent) !important; filter: none !important; }
    .usm-d.usm-districted { fill: color-mix(in srgb, var(--border) 40%, var(--neutral-bg)) !important; stroke: transparent !important; filter: none !important; pointer-events: none !important; }
    /* ── Moon base map (Luna test sandbox) — a real lunar surface ──────────────
       The disc is a lit sphere (radial gradient) with mare albedo patches, regolith
       grain, and 3D-shaded crater bowls drawn ABOVE the region fills; fills are
       therefore translucent tints so the terrain shows through. Claimed/selected
       regions keep semantic hues (--ok / --accent) at reduced fill-opacity. */
    .it-moon-disc { stroke: #565b64; stroke-width: 1.5; }
    .it-moon-shade, .it-moon-deco { pointer-events: none; }
    .it-moon-mare { fill: #8e9096; opacity: .38; }
    .it-map-moon .usm-d { stroke: rgba(28, 32, 40, .5); stroke-width: 1.1; }
    .it-map-moon .usm-d.usm-empty { fill: rgba(220, 224, 232, .14); stroke: rgba(28, 32, 40, .45); }
    .it-map-moon .usm-d.usm-active { fill-opacity: .5; }
    .it-map-moon .usm-d.usm-sel { fill-opacity: .55; }
    .it-map-moon .usm-d.usm-districted { fill: rgba(210, 214, 222, .08) !important; }
    .it-map-moon .it-dm-empty { fill: rgba(238, 241, 246, .3); }
    .it-map-moon .it-dm-claimed { fill-opacity: .6; }
    .it-moon-crater-rim { fill: none; stroke: rgba(38, 42, 50, .22); stroke-width: .6; }
    .it-moon-rille { fill: none; stroke: rgba(38, 42, 50, .38); stroke-width: 1.6; stroke-linecap: round; }
    .it-moon-rille-lip { fill: none; stroke: rgba(255, 255, 255, .32); stroke-width: 1.6; stroke-linecap: round; }
    /* World switcher — a small disc showing the world you'd flip TO. */
    .it-world-btn { position: absolute; right: 12px; bottom: 12px; z-index: 10; width: 46px; height: 46px;
      padding: 0; border-radius: 50%; border: 1px solid var(--border); background: var(--card);
      box-shadow: 0 2px 6px rgba(0,0,0,.16); cursor: pointer; display: flex; align-items: center;
      justify-content: center; transition: transform .15s ease, box-shadow .15s ease; }
    .it-world-btn:hover { transform: scale(1.08); box-shadow: 0 3px 10px rgba(0,0,0,.22); }
    .it-world-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
    .it-world-btn svg { width: 38px; height: 38px; display: block; }
    .it-world-moon .iwb-body { fill: #c8cdd6; stroke: #7a8494; stroke-width: 1; }
    .it-world-moon .iwb-crater { fill: #adb5c0; }
    .it-world-earth .iwb-body { fill: #7ba5d6; stroke: #4d7aab; stroke-width: 1; }
    .it-world-earth .iwb-land { fill: #7eb26f; }
    /* IT detail panel — card layout */
    .it-detail-empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; font-size: var(--fs-md); color: var(--muted); padding: 20px; text-align: center; }
    .it-detail-hd { padding: 14px 16px 12px; border-bottom: 1px solid var(--border); flex-shrink: 0; }
    .it-detail-title { font-size: var(--fs-xl); font-weight: var(--fw-bold); display: flex; align-items: center; gap: 8px; line-height: var(--lh-tight); margin-bottom: 8px; }
    .it-detail-hd-stats { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 0; margin-top: 0; }
    .it-hd-stat { font-size: var(--fs-xs); color: var(--muted); font-weight: var(--fw-medium); padding: 4px 0; text-align: center; border-right: 1px solid var(--border); }
    .it-hd-stat:first-child { text-align: left; border-left: none; }
    .it-hd-stat:last-child { text-align: right; border-right: none; }
    .it-hd-val { display: block; font-size: var(--fs-2xl); font-weight: var(--fw-bold); color: var(--text); line-height: var(--lh-tight); }
    /* Tenant list — single scrollable container */
    .it-tenant-list { flex: 1 1 0; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; padding: 10px 12px; display: flex; flex-direction: column; gap: 0; }
    .it-tenant-list.it-list-capped { flex: none; max-height: calc(6 * 98px); }
    /* State org row — sticky, brand green tint */
    .it-trows { display: flex; flex-direction: column; gap: 6px; }
    .it-trow { border: 1px solid var(--border); border-radius: 9px; background: var(--card); cursor: pointer; transition: border-color .12s, background .12s; overflow: hidden; }
    .it-trow-hq { background: color-mix(in srgb, var(--ok, #15803d) 10%, transparent); border: 1.5px solid color-mix(in srgb, var(--ok, #15803d) 45%, transparent); position: sticky; top: 0; z-index: 2; }
    .it-tenant-list > .it-trow-hq { margin-bottom: 6px; }
    .it-trow-hq:hover { background: color-mix(in srgb, var(--ok, #15803d) 16%, transparent) !important; }
    .it-trow-hq-empty { opacity: .7; border-style: dashed !important; }
    .it-trow:not(.it-trow-hq):hover { border-color: var(--accent); }
    .it-trow.it-suspended { opacity: .6; }
    .it-trow-body { display: flex; flex-direction: column; }
    .it-trow-top { display: flex; align-items: center; gap: 10px; padding: 9px 10px 8px; min-height: 54px; box-sizing: border-box; }
    .it-trow-name { font-weight: var(--fw-semi); font-size: var(--fs-md); flex: 1 1 auto; min-width: 0; line-height: var(--lh-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; min-height: calc(12.5px * 1.35 * 2); }
    .it-trow-footer { display: grid; grid-template-columns: 1fr 1fr 1fr 1fr; border-top: 1px solid var(--border); margin: 0 10px; }
    .it-trow-footer span { font-size: var(--fs-2xs); color: var(--muted); padding: 4px 0; text-align: center; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .it-trow-footer span:first-child { text-align: left; }
    .it-trow-footer span:last-child { text-align: right; }
    .it-trow-footer span:not(:last-child) { border-right: 1px solid var(--border); padding-right: 8px; }
    .it-trow-footer span:not(:first-child) { padding-left: 8px; }
    .it-trow-active { border-color: var(--accent, #ff6600) !important; outline: none; }
    .it-trow-hq.it-trow-active { border-color: var(--accent, #ff6600) !important; }
    /* (.it-activate-btn removed with the "operate this local" control — a Site Admin
       cannot enter a local, so nothing renders that button any more.) */
    .it-active-dot { color: var(--accent, #ff6600); font-size: var(--fs-xl); line-height: var(--lh-none); flex-shrink: 0; display: flex; align-items: center; width: 31px; justify-content: center; }
    .it-trow-empty { font-size: var(--fs-sm); color: var(--muted); padding: 14px 2px; }
    /* IT district choropleth (state drill-down — overlay <g> injected into US SVG) */
    .it-dm-d { stroke: var(--border); stroke-width: 0.5; cursor: pointer; transition: filter .1s ease; }
    .it-dm-d:hover { filter: brightness(1.12); }
    .it-dm-claimed { fill: var(--ok, #15803d); stroke: color-mix(in srgb, var(--ok, #15803d) 70%, black); }
    .it-dm-empty { fill: var(--neutral-bg); stroke-width: 0.6; }
    .it-dm-tip { position: absolute; pointer-events: none; opacity: 0; transform: translate(-50%, -120%);
      background: var(--text); color: var(--card); font-size: var(--fs-sm); line-height: var(--lh-snug);
      padding: 6px 9px; border-radius: 8px; white-space: nowrap; transition: opacity .1s;
      z-index: 4; box-shadow: 0 4px 16px rgba(0,0,0,.18); }
    .it-trow-count { background: var(--neutral-bg); border-radius: 999px; padding: 1px 7px; font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--text); }
    /* ── Tenant detail panel ─────────────────────────────────────────────── */
    .sd-panel.it-tenant-panel { max-width: 760px; width: 760px; height: 82vh; max-height: 82vh; display: flex; flex-direction: column; overflow: hidden; padding: 0; }
    .it-tenant-panel .sd-panel-head { flex-shrink: 0; background: var(--primary); color: var(--on-primary); padding: 28px 28px 22px; border-radius: 14px 14px 0 0; margin-bottom: 0; }
    .it-tenant-panel .sd-panel-name { font-size: var(--fs-3xl); color: inherit; }
    .it-tenant-panel .sd-panel-head .btn { color: var(--on-primary); border-color: color-mix(in srgb, var(--on-primary) 30%, transparent); }
    .it-tenant-panel .sd-panel-head .btn:hover { background: color-mix(in srgb, var(--on-primary) 12%, transparent); }
    .it-tenant-panel #itm-meta { display: none; }
    #itm-body { flex: 1 1 0; overflow-y: auto; padding: 4px 28px 16px; }
    .itm-section { padding: 18px 0; border-top: 1px solid var(--border); }
    .itm-section:first-child { border-top: none; }
    .itm-section-label { font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 10px; }
    .itm-field-row { display: flex; gap: 8px; align-items: center; }
    .itm-input { flex: 1 1 auto; min-width: 0; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-family: inherit; font-size: var(--fs-md); background: var(--card); color: var(--text); }
    .itm-admins { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
    .itm-section-actions .itm-action-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
    /* Badges in modal header */
    .itm-badge { display: inline-block; font-size: var(--fs-2xs); font-weight: var(--fw-bold); letter-spacing: .04em; padding: 2px 7px; border-radius: 20px; vertical-align: middle; line-height: var(--lh-loose); }
    .itm-badge-local { background: color-mix(in srgb, var(--accent) 22%, transparent); color: var(--accent); }
    .itm-badge-hq { background: color-mix(in srgb, var(--ok,#15803d) 18%, transparent); color: var(--ok,#15803d); }
    .itm-badge-ok { background: color-mix(in srgb, var(--ok,#15803d) 18%, transparent); color: var(--ok,#15803d); }
    .itm-badge-warn { background: color-mix(in srgb, var(--warn,#b45309) 20%, transparent); color: var(--warn,#b45309); }
    /* header badges on dark banner need readable fills */
    .it-tenant-panel .sd-panel-head .itm-badge-local { background: rgba(var(--accent-rgb),.25); color: #ffb980; }
    .it-tenant-panel .sd-panel-head .itm-badge-hq { background: rgba(34,197,94,.2); color: #86efac; }
    .it-tenant-panel .sd-panel-head .itm-badge-ok { background: rgba(34,197,94,.2); color: #86efac; }
    .it-tenant-panel .sd-panel-head .itm-badge-warn { background: rgba(245,158,11,.22); color: #fcd34d; }
    /* Inline rename in the title */
    .itm-edit-btn { background: none; border: none; cursor: pointer; color: inherit; padding: 0 3px; vertical-align: middle; line-height: var(--lh-none); opacity: .55; }
    .itm-edit-btn:hover { opacity: 1; color: var(--accent); }
    .itm-edit-btn .ic { font-size: var(--fs-lg); }
    .itm-rename-input { font-size: var(--fs-2xl); font-weight: var(--fw-bold); font-family: inherit; border: none; border-bottom: 2px solid var(--accent); background: transparent; color: inherit; padding: 0 4px 1px; outline: none; min-width: 0; width: 280px; }
    .itm-rename-ok, .itm-rename-cancel { background: none; border: none; cursor: pointer; padding: 0 4px; vertical-align: middle; }
    .itm-rename-ok { color: var(--ok,#15803d); }
    .it-tenant-panel .sd-panel-head .itm-rename-ok { color: #86efac; }
    .itm-rename-cancel { color: var(--muted); }
    .it-tenant-panel .sd-panel-head .itm-rename-cancel { color: color-mix(in srgb, var(--on-primary) 50%, transparent); }
    .itm-rename-ok .ic, .itm-rename-cancel .ic { font-size: var(--fs-2xl); }
    /* Stat grid — 4 even columns, icon left value right */
    .itm-stat-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0; padding: 10px 0 14px; border-bottom: 1px solid var(--border); }
    .itm-stat-grid .itm-stat { display: flex; align-items: flex-start; gap: 8px; padding: 8px 4px; min-width: 0; }
    .itm-stat-grid .itm-stat:not(:last-child) { border-right: 1px solid var(--border); padding-right: 12px; }
    .itm-stat-grid .itm-stat:not(:first-child) { padding-left: 12px; }
    .itm-stat-ic { font-size: var(--fs-xl); color: var(--accent); flex-shrink: 0; margin-top: 1px; }
    .itm-stat-val { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--text); line-height: var(--lh-snug); min-width: 0; }
    .itm-stat-pend { font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--warn,#b45309); }
    /* Leadership table */
    .itm-users-loading { color: var(--muted); font-size: var(--fs-sm); display: flex; align-items: center; gap: 6px; padding: 6px 0; }
    .itm-no-leaders { font-size: var(--fs-sm); padding: 8px 0 2px; }
    .itm-users-wrap { border: 1px solid var(--border); border-radius: 10px; background: var(--card); overflow: hidden; }
    .itm-users-table { font-size: var(--fs-md); }
    .itm-users-head { display: grid; grid-template-columns: 1.3fr 1.1fr 2fr 1fr; gap: 8px; padding: 9px 14px; background: var(--primary); font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .05em; color: var(--on-primary); }
    .itm-user-row { display: grid; grid-template-columns: 1.3fr 1.1fr 2fr 1fr; gap: 8px; padding: 11px 14px; border-bottom: 1px solid var(--border); align-items: center; }
    .itm-user-row:last-child { border-bottom: none; }
    .itm-user-row:hover { background: color-mix(in srgb, var(--accent) 4%, var(--card)); }
    .itm-user-name { font-weight: var(--fw-semi); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .itm-user-title { font-size: var(--fs-sm); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .itm-user-email { color: var(--accent); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-sm); }
    .itm-user-email:hover { text-decoration: underline; }
    .itm-user-role { color: var(--muted); font-size: var(--fs-sm); white-space: nowrap; }
    /* Snapshots section */
    .itm-section-label-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 10px; }
    .itm-section-label-row .itm-section-label { margin-bottom: 0; }
    .itm-snap-list { display: flex; flex-direction: column; gap: 0; border: 1px solid var(--border); border-radius: 10px; overflow: hidden; background: var(--card); }
    .itm-snap-row { display: grid; grid-template-columns: auto 1fr auto; gap: 10px; align-items: center; padding: 10px 14px; border-bottom: 1px solid var(--border); font-size: var(--fs-md); }
    .itm-snap-row:last-child { border-bottom: none; }
    .itm-snap-row:hover { background: color-mix(in srgb, var(--accent) 4%, var(--card)); }
    .itm-snap-date { white-space: nowrap; font-weight: var(--fw-semi); }
    .itm-snap-file { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-xs); font-family: monospace; color: var(--muted); }
    .itm-snap-acts { display: flex; gap: 6px; flex-shrink: 0; }
    .ok-text { color: var(--ok, #1a7f4b); font-weight: var(--fw-semi); }
    .warn-text { color: var(--warn, #b4690e); font-weight: var(--fw-semi); }
    /* State fan-out (Phase 5) + state branding (Phase 6) */
    .state-content-new { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 6px; }
    .state-content-new input { flex: 1 1 200px; min-width: 0; padding: 9px 11px; border: 1px solid var(--border); border-radius: 8px; font-family: inherit; font-size: var(--fs-md); }
    .state-content-body { width: 100%; margin-top: 8px; padding: 9px 11px; border: 1px solid var(--border); border-radius: 8px; font-family: inherit; font-size: var(--fs-md); resize: vertical; box-sizing: border-box; }
    .state-content { margin-top: 12px; }
    .sc-row { display: flex; align-items: center; gap: 12px; border: 1px solid var(--border); border-radius: 10px; background: var(--card); padding: 10px 14px; margin-bottom: 8px; }
    .sc-kind { font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); background: var(--bg); border-radius: 999px; padding: 2px 9px; flex-shrink: 0; }
    .sc-main { flex: 1 1 auto; min-width: 0; }
    .sc-label { font-weight: var(--fw-semi); font-size: var(--fs-lg); }
    .sc-url { font-size: var(--fs-sm); color: var(--muted); word-break: break-all; }
    .state-page-body p { line-height: var(--lh-loose); margin: 0 0 12px; }
    #state-branding .bf { display: flex; flex-direction: column; gap: 5px; margin-bottom: 12px; }
    #state-branding .bf label { font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--muted); }
    #state-branding .bf input { padding: 9px 11px; border: 1px solid var(--border); border-radius: 8px; font-family: inherit; font-size: var(--fs-lg); }
    .sb-colors { display: flex; flex-wrap: wrap; gap: 16px; margin: 10px 0 4px; }
    .sb-color { display: flex; align-items: center; gap: 8px; }
    .sb-cl { font-size: var(--fs-md); color: var(--muted); }
    /* State monitoring dashboard */
    .sd-attn-head, .sd-all-head { font-size: var(--fs-md); font-weight: var(--fw-bold); margin: 4px 0 9px; }
    .sd-attn-head .muted, .sd-all-head .muted { font-weight: var(--fw-medium); }
    .sd-all-head { margin-top: 18px; }
    .sd-attn { display: flex; align-items: center; justify-content: space-between; gap: 12px; background: var(--card); border: 1px solid var(--border); border-left: 3px solid var(--warn, #b4690e); border-radius: 9px; padding: 11px 14px; margin-bottom: 8px; cursor: pointer; transition: box-shadow .12s; }
    .sd-attn:hover { box-shadow: var(--shadow); }
    .sd-attn.sd-h-help { border-left-color: var(--danger, #c0392b); }
    .sd-attn-name { font-weight: var(--fw-bold); font-size: var(--fs-lg); }
    .sd-attn-why { font-size: var(--fs-sm); margin-top: 2px; }
    .sd-pill { font-size: var(--fs-xs); font-weight: var(--fw-bold); border-radius: 999px; padding: 3px 10px; white-space: nowrap; }
    .sd-pill-ok { background: var(--ok-bg, #e8f5ee); color: var(--ok, #1a7f4b); }
    .sd-pill-warn { background: var(--warn-bg, #fdf3e3); color: var(--warn, #b4690e); }
    .sd-pill-help { background: var(--danger-bg, #fdecea); color: var(--danger, #c0392b); }
    .sd-dot { font-size: var(--fs-md); white-space: nowrap; }
    .sd-dot-ok { color: var(--ok, #1a7f4b); } .sd-dot-warn { color: var(--warn, #b4690e); } .sd-dot-help { color: var(--danger, #c0392b); }
    .sd-table-wrap { border: 1px solid var(--border); border-radius: 10px; overflow: auto; background: var(--card); }
    .sd-table { width: 100%; border-collapse: collapse; font-size: var(--fs-md); }
    .sd-table th { text-align: left; color: var(--muted); font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .03em; font-weight: var(--fw-semi); padding: 9px 12px; white-space: nowrap; }
    .sd-table td { padding: 9px 12px; border-top: 1px solid var(--border); }
    .sd-table .sd-n, .sd-table th.sd-n { text-align: right; }
    .sd-name { font-weight: var(--fw-semi); }
    .sd-bad { color: var(--danger, #c0392b); font-weight: var(--fw-bold); }
    .sd-row { cursor: pointer; }
    .sd-row:hover td { background: var(--bg); }
    .sd-overlay { position: fixed; inset: 0; background: rgba(0,0,0,.4); display: flex; align-items: center; justify-content: center; z-index: 300; padding: 20px; }
    .sd-panel { background: var(--card); border-radius: 14px; max-width: 640px; width: 100%; max-height: 88vh; overflow: auto; padding: 20px 22px; box-shadow: 0 12px 40px rgba(0,0,0,.25); }
    .sd-panel-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; margin-bottom: 14px; }
    .sd-panel-name { font-size: var(--fs-2xl); font-weight: var(--fw-bold); display: flex; align-items: center; gap: 10px; }
    .sd-flags { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 16px; }
    .sd-flag { font-size: var(--fs-sm); background: var(--danger-bg, #fdecea); color: var(--danger, #c0392b); border-radius: 7px; padding: 3px 9px; }
    .sd-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 16px; }
    .sd-m { background: var(--bg); border-radius: 9px; padding: 10px 12px; }
    .sd-m-v { font-size: var(--fs-2xl); font-weight: var(--fw-bold); }
    .sd-m-l { font-size: var(--fs-xs); color: var(--muted); margin-top: 1px; }
    .sd-trends { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
    .sd-trend-l { font-size: var(--fs-sm); color: var(--muted); margin-bottom: 4px; }
    .sd-spark { width: 100%; height: 44px; display: block; }
    .sd-spark-last { font-size: var(--fs-sm); font-weight: var(--fw-bold); color: var(--accent); }
    @media (max-width: 560px) { .sd-metrics { grid-template-columns: repeat(2, 1fr); } .sd-trends { grid-template-columns: 1fr; } }
    /* (The .wel-* block is gone. The welcome splash had its own preview, its own
       button row, its own duration field and its own five-line callout — a second
       implementation of the logo editor that happened to look different. It is now
       splashCard(), the second instance of .media-card.) */
    @media (max-width: 620px) {
      /* Phone: the actions drop below the copy rather than squeezing it to one word. */
      .brand-row { grid-template-columns: auto minmax(0, 1fr); }
      .brand-row-actions { grid-column: 1 / -1; justify-content: flex-start; }
      .brand-fields { grid-template-columns: 1fr; }
      .media-body { grid-template-columns: minmax(0, 1fr); }
      .opt-line-lbl { flex-basis: 100%; }
    }
    /* Profile page */
    .profile-head { display: flex; align-items: center; gap: 15px; }
    .profile-head > div { min-width: 0; }   /* shrink the text column so a long name/email can't widen the row past the viewport */
    /* Your own avatar (My Profile) matches the header userbox circle: solid brand
       orange with white initials — not the pale directory-row tint. */
    .profile-avatar { width: 58px; height: 58px; border-radius: 50%; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; font-size: var(--fs-3xl); font-weight: var(--fw-display); flex-shrink: 0; overflow: hidden; }
    .profile-avatar img { width: 100%; height: 100%; object-fit: cover; }
    .profile-name { font-size: var(--fs-2xl); font-weight: var(--fw-display); line-height: var(--lh-tight); overflow-wrap: anywhere; }
    .profile-email { font-size: var(--fs-md); color: var(--muted); margin-top: 1px; overflow-wrap: anywhere; }
    .profile-role { display: inline-block; margin-top: 6px; font-size: var(--fs-2xs); font-weight: var(--fw-display); text-transform: uppercase; letter-spacing: .05em; padding: 2px 9px; border-radius: 999px; background: var(--accent-light); color: var(--accent-dark); }
    .profile-group-label { font-size: var(--fs-xs); font-weight: var(--fw-display); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin: 26px 3px 11px; }
    /* iOS-style master/detail Site Settings */
    /* The state org's OWN workspace-app card — accent-bordered so it reads as a distinct action. */
    .hq-card { margin-bottom: 18px; border-color: var(--accent); }
    .hq-card .btn.primary { margin-top: 4px; }
    .settings-shell { display: grid; grid-template-columns: 234px 1fr; gap: 20px; align-items: start; }
    .settings-sidebar { position: sticky; top: 14px; background: #fff; border: 1px solid var(--border); border-radius: 14px; padding: 8px; box-shadow: 0 1px 3px rgba(0,0,0,.04); }
    .ss-group { font-size: var(--fs-2xs); font-weight: var(--fw-display); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); padding: 13px 10px 5px; }
    .settings-sidebar > .ss-group:first-child { padding-top: 6px; }
    .ss-nav { display: flex; align-items: center; gap: 11px; width: 100%; text-align: left; background: none; border: none; border-radius: 9px; padding: 9px 11px; font-size: var(--fs-md); font-family: inherit; color: var(--text); cursor: pointer; font-weight: var(--fw-medium); white-space: nowrap; transition: background .12s; }
    .ss-nav:hover { background: var(--bg); }
    .ss-nav.active { background: var(--accent-light); color: var(--accent-dark); font-weight: var(--fw-bold); }
    /* Sidebar service icons are uniform 20px rounded chips: a white tile with the
       accent glyph for non-branded rows (System Email, Security), and the real
       brand logo for Google/Canva/GroupMe — matching the My Integrations look. */
    .ss-nav .ss-ico { width: 20px; height: 20px; border-radius: 5px; display: grid; place-items: center; flex-shrink: 0; overflow: hidden; background: #fff; border: 1px solid var(--border); color: var(--accent); }
    .ss-nav .ss-ico .lic { width: 12.5px; height: 12.5px; }
    .ss-nav .ss-ico-brand { border: none; background: transparent; }              /* full-bleed brand tiles (Canva circle, GroupMe square) */
    .ss-nav .ss-ico-brand.boxed { background: #fff; border: 1px solid var(--border); }  /* bare multicolor marks (Google G) keep a white tile */
    .ss-nav .ss-ico-brand img { width: 100%; height: 100%; object-fit: contain; display: block; }
    .ss-nav .ss-ico-brand.boxed img { padding: 2px; box-sizing: border-box; }
    .ss-nav .ss-chev { margin-left: auto; color: var(--border); font-size: var(--fs-lg); }
    .ss-nav.active .ss-chev { color: var(--accent); }
    .settings-content { min-width: 0; }
    /* App Branding hub — one roof over Logos & Text / Colors / App Icon.
       Sits above the active sub-panel; stays put while the panel scrolls. */
    /* ── The header zone is a FIXED box, on every panel ─────────────────────
       It used to be as tall as whatever copy landed in it: 73px where the
       description fit one line, 80 where it wrapped to two, 99.5 at a narrow
       window — so the card below moved every time you clicked a different item
       in the sidebar. Two constants own it now:

         --hub-intro-h   the title box. Fixed and scaled with the type ladder.
         --hub-head-gap  the space between that box and the divider.

       THE BAND IS A TITLE, NOTHING ELSE. It used to carry a sentence describing
       the hub, on all nine of them — copy you read once and then scrolled past
       forever, holding two lines of the fold hostage on every screen in Site
       Config. What a screen does is said by that screen's own .page-lede, where
       you're actually looking when you need it. Don't put the paragraph back.

       Card top → divider is therefore the same on all of them: hub or
       standalone, tabs or no tabs — because ONE element draws it. See the
       hairline rule on .page-head-row below. */
    /* --page-tab-h is the height of the band's SECOND ROW, written once so the tab strip
       and the breadcrumb trail agree by construction: 10px padding top and bottom around
       one line of the tab's own type. Change the type scale and both move together. */
    .page-head { --page-tab-h: calc(20px + 13.5px * var(--fs-scale, 1) * 1.45);
      --hub-intro-h: calc(26px * var(--fs-scale, 1)); --hub-head-gap: 13px;
      flex: none; margin-bottom: 0; background: var(--card); border: 1px solid var(--border); border-bottom: none; border-radius: var(--radius, 12px) var(--radius, 12px) 0 0; padding: 16px var(--pad-card) 0; }
    /* ── THE BAND'S HAIRLINES — every row underlines ITSELF ─────────────────
       EVERY ROW OF THE BAND DRAWS ITS OWN BOTTOM EDGE, AND THE CARD UNDER A BAND
       DRAWS NO TOP EDGE. One sentence, and it is the whole rule.

       It used to be the opposite: no row drew anything, and each line was drawn by
       whatever happened to sit BELOW it — a hub's tab strip and a drill-down's crumb
       with a border-TOP, a title-only band with a border-BOTTOM on the head itself
       (the old .ruled / .solo variants), and the band's closing edge by the CARD's
       border-top. Four mechanisms for one 1px line, and they didn't agree:

         • the head's border sits on the BORDER box, so a tabless band's rule ran 2px
           wider than a tab strip's and crossed the card's side borders at each end;
         • worse, on a tabless band that rule landed at y=231 and the card's own
           border-top at y=232 — TWO stacked hairlines, so the line under "Add-Ons"
           rendered 2px thick while the identical line under "Site Content" was 1px.
           A tab strip between them is the only reason the hubs looked right; that is
           why the difference tracked exactly with having tabs.

       Now: the title row draws the line under the title, the second row (tabs or
       trail) draws the band's closing edge, and the card contributes nothing to a
       seam that is inside one object. Nothing stacks, and a band with no second row
       is the same band with one fewer line.

       All of them bleed to the padding box (the same -pad-card), so they are the
       card-divider standard — equal distance from both walls, by construction.
       content-box on purpose: --hub-intro-h names the TITLE box, not the box plus its
       gap, and every other rule that reads it (.accent's height:auto, the phone's
       min-height:0) still means the same thing. */
    .page-head-row { display: flex; align-items: center; gap: 16px; box-sizing: content-box; height: var(--hub-intro-h);
      margin: 0 calc(-1 * var(--pad-card)); padding: 0 var(--pad-card) var(--hub-head-gap); border-bottom: 1px solid var(--border); }
    .page-head > .page-tabs, .page-head > .page-crumb { border-bottom: 1px solid var(--border); }
    /* (A band with no title row has nothing to underline: the Designer's trail-only
       band, .page-head.bare, where the crumb draws the one edge there is.) */
    /* h2 is the standard tag — the band holds the SCREEN's heading, and on a framed
       page there is no other. Site Config's bands still say h3 (they sit under a
       "Site Config" h2 on the grey); both are accepted so the two can't drift apart
       visually while that's true. */
    /* --fs-2xl, not the bespoke 17px this used to be. A screen's name is the
       largest thing on it and it was sitting one pixel BELOW the ladder's
       "section headings" rung, which is a rung named for something smaller than
       a screen. Retires an off-ladder literal (one fewer for type Stage C). */
    .page-head-row h2,
    .page-head-row h3 { margin: 0; font-size: var(--fs-2xl); font-weight: var(--fw-display); letter-spacing: -.01em; color: var(--text); white-space: nowrap; flex-shrink: 0; }
    /* A band that names a RECORD rather than a screen (a request's subject, a content
       page's title). A screen's name is short and fixed and must never wrap; a subject
       is whatever the member typed, so this one is allowed to shrink and ellipsise —
       and only then, when the row is genuinely full. */
    .page-head-row .page-head-title { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
    .page-head-row .page-head-meta { flex: 0 0 auto; margin-left: auto; color: var(--muted); font-size: var(--fs-sm); white-space: nowrap; }
    /* Back, in the band, beside the title it returns from. It was a full-width
       .back-btn row above the card — ~50px of empty band before any data, and a third
       header treatment inside the first 200px of the screen. Moving it here is the win;
       the box it briefly wore was not.

       IT KEEPS ITS LABEL, and it is a text link, not a bordered icon button. Two
       reasons, and the first is written four rules up: "Back stays the app's SUBTLE
       TEXT LINK — leaving is not an action you want to advertise with a box… a bordered
       control in the band would compete with the screen's real primary action in the
       bar below." That rule was about .page-crumb, but the reasoning is about the band,
       and this control is in the band.
       The second is a failure mode. Icon-only, it renders as a blank rounded box the
       moment the glyph doesn't resolve — which is exactly what a cached app.js without
       the new chevronLeft key does. A control whose whole job is "get me out of here"
       must not be able to become a mystery box; with the word beside it, a missing icon
       costs a chevron rather than the affordance.
       It is .crumb-back — the app's one Back — not a class of its own. See that rule
       above; it is written unscoped precisely so this can mount in the title row. */
    .page-head-row > .crumb-back { flex: none; }
    /* …and the third mount: the BAR, for a sub-view that lives inside one panel of a
       hub rather than on a screen of its own (Content Pages → the template chooser).
       There is no band of its own to put a crumb row in — the band above belongs to
       Site Content and carries its sub-tabs — so Back leads the bar it applies to.
       flex:none for the same reason as the title row: it is a label, not a column,
       and the lede beside it is the flexible one. */
    .page-bar > .crumb-back { flex: none; }
    /* (`.cms-preview-title` is gone. It existed because the Designer's preview had no
       frame to put a band on — it hand-built an approximation of the page instead. The
       preview now CLONES the real #section-cms-page (see dsgnShellHTML), band and all,
       so the title renders where a visitor actually sees it and this was the last place
       in the app rendering a page title inline. Closes Page-Frame-Rollout Phase 5.) */
    /* The strip starts where the title row's hairline ends — it does not draw a line
       of its own, and it does not re-state the gap above one (both live on the row now;
       see THE BAND'S HAIRLINE). */
    .page-tabs { display: flex; gap: 0; min-height: var(--page-tab-h); padding: 0; margin: 0 calc(-1 * var(--pad-card)); background: none; border: none; border-radius: 0; padding-left: 4px; }
    /* ── THE Site Config header ────────────────────────────────────────────
       ONE header per screen, always here: the .page-head zone fused to the top
       of the card, title left, FULL-BLEED divider under it. There is deliberately no
       second style, and no variant class: every band is ruled, by the title row, and
       what follows the line (a tab strip, a crumb trail, or the card) is the only thing
       that differs. Panels never carry a headline of their own — that is what produced
       two stacked "Members & Roles" bars, one full-bleed and one inset.
       (.panel-head still exists for the Platform console, which is its own screen.)

       There used to be a .ruled variant here for a band with no tab strip, drawing the
       same line a second way. It is gone: a class that means "draw the line you always
       draw" is a class that can be forgotten, and it was — #section-groupme's band asked
       for `.solo`, a rule that never existed, and spent its life with no divider at all. */
    /* Right-aligned panel actions (+ Add …, stats) — what used to hang off the panel's
       own h4 in .panel-sub-head. The buttons stay; only the redundant headline went. */
    .panel-tools { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-bottom: 10px; }
    .panel-tools.has-stats { justify-content: space-between; }
    /* In-card SECTION label — a genuinely different level: several sections inside ONE
       screen (Logos & Graphics / Welcome Splash, Password / Two-Factor / Passkeys).
       Never used to restate the screen's own name. */
    .panel-sub-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
    .panel-sub-head .sec-sub-title { margin: 0; }
    /* Centered for the same reason as .page-head-row above — a one-line
       description must not leave the title floating at the top of the band. */
    .panel-head { display: flex; align-items: center; gap: 16px; padding-bottom: 12px; margin-bottom: 14px; border-bottom: 1px solid var(--border); }
    .panel-head .set-desc { margin: 0; font-size: var(--fs-md); line-height: var(--lh-body); color: var(--muted); }
    .panel-head .panel-title { margin: 0; font-size: var(--fs-2xl); font-weight: var(--fw-display); letter-spacing: -.01em; color: var(--text); white-space: nowrap; flex-shrink: 0; }
    .page-tab { display: inline-flex; align-items: center; gap: 8px; border: none; background: none; border-radius: 0; padding: 10px 16px; font-family: inherit; font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--muted); cursor: pointer; white-space: nowrap; transition: color .12s; position: relative; }
    .page-tab:hover { color: var(--text); }
    .page-tab .ptab-ico { display: grid; place-items: center; color: currentColor; }
    /* Off the same glyph token the config rows use — a tab strip isn't a row, but the
       icons sit inches apart on the same screen and were drawn at two different sizes. */
    .page-tab .ptab-ico .lic { width: var(--list-glyph); height: var(--list-glyph); }
    /* Selected state reads from BOTH the class Site Config's hubs toggle and the ARIA
       state a tablist has to set anyway. That is not two conventions — it is one style
       hanging off the thing that is already true: a strip built as a real `role=tablist`
       (Help's Guide/Feedback) lights up without a second class to keep in sync. */
    .page-tab.active,
    .page-tab[aria-selected="true"] { color: var(--accent-dark); }
    .page-tab.active::after,
    .page-tab[aria-selected="true"]::after { content: ''; position: absolute; bottom: 0; left: 12px; right: 12px; height: 2px; background: var(--accent-dark); border-radius: 1px; }
    /* Grouped sub-tabs (App Branding): three clusters — how it looks · on the phone ·
       print & email — separated by a hairline and nothing else. Hubs without
       .ptab-group wrappers keep the flat row unchanged.

       The clusters used to carry uppercase captions (LOOK & WORDING · …). They said
       out loud what the hairline already showed, and cost enough width that they had
       to drop out below 1400px anyway. The grouping is the hairline now, and the row
       is a plain one-line tab strip like every other hub's. */
    .ptab-group { display: flex; flex-direction: row; align-items: center; }
    .ptab-group + .ptab-group { margin-left: 8px; padding-left: 8px; border-left: 1px solid var(--border); }
    .ptab-btns { display: flex; }
    /* Organization → Features: the optional-module switchboard */
    /* Member detail → Role & Responsibilities: the four "who is this person to the local"
       answers (access level, committee seat, rep flag, title) read as one set, so they sit
       in one section that uses the width instead of four stacked rows you scroll past. */
    /* The ROW gap is the grid's own, not .mi-field's stacking margin. Those fields carry
       `.mi-field + .mi-field { margin-top: 16px }` for the stacked forms they were built
       for — and a sibling margin does not know it is now a grid item in the NEXT COLUMN,
       so every field after the first sat 16px lower than the one beside it and the two
       columns read as two different rows. Zero it here and let row-gap do the spacing it
       was always doing, so a heading and its control line up across the row. */
    .ud-cols { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px 32px; align-items: start; }
    .ud-cols > .mi-field + .mi-field { margin-top: 0; }
    /* A .list-row that happens to be tall — see THE CONFIG ROW. It used to bring its own
       everything: a 34px accent-filled icon tile, 15px/2px padding, a border-bottom
       separator instead of the shared inset hairline, and no hover at all.
       Its end slot holds a switch (46px) rather than a 26px button, so the list widens
       --list-act-slot. That override MUST land on the element that declares --list-end
       (the .page-stacked), not on the row: a custom property resolves its var()s where it
       is declared, so --list-end had already computed to 30+16 by the time a row tried
       to change the input. Setting it deeper looks right and does nothing. */
    .ss-panel[data-panel="features"] .page-stacked { --list-act-slot: calc(46px * var(--fs-scale, 1)); }
    .feat-row { padding-top: 11px; padding-bottom: 11px; }
    .feat-meta { flex: 1 1 auto; min-width: 0; }
    .feat-name { font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--text); }
    .feat-desc { font-size: var(--fs-sm); line-height: var(--lh-body); color: var(--muted); margin-top: 2px; }
    .feat-manage { display: inline-block; margin-top: 6px; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--accent-dark); text-decoration: none; }
    .feat-manage:hover { text-decoration: underline; }
    /* Sits on the same line the Manage link would occupy — it answers the same question
       ("what can I do from here"), so it belongs in the same place. */
    .feat-locked { display: inline-flex; align-items: center; gap: 5px; margin-top: 6px; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--muted); }
    .feat-locked .lic { width: 13px; height: 13px; }
    /* Contract — locked/encrypted summary once uploaded (write-only panel) */
    .contract-lock { border: 1px solid var(--border); border-radius: 12px; padding: 20px; background: var(--bg); }
    .contract-lock-badge { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-xs); font-weight: var(--fw-display); text-transform: uppercase; letter-spacing: .05em; color: var(--ok, #1a7f37); background: var(--ok-bg, #eaf6ee); border: 1px solid var(--ok, #1a7f37); border-radius: 999px; padding: 4px 11px; }
    .contract-lock-badge .lic { width: 13px; height: 13px; }
    .contract-lock-name { font-size: var(--fs-xl); font-weight: var(--fw-display); color: var(--text); margin-top: 12px; }
    .contract-lock-meta { font-size: var(--fs-sm); color: var(--muted); margin-top: 3px; }
    .contract-lock-note { margin: 12px 0 0; max-width: 66ch; }
    .contract-lock-actions { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 16px; }
    /* ── Contract upload ──────────────────────────────────────────────────
       Upload-only, by design: a leader hands over the document they already
       have. (There used to be a full-height "paste the contract text here"
       textarea — it dominated the screen, pushed the progress panel and the
       title below the fold, and pasting only ever threw the document's
       formatting away.) The screen is now two columns: the act on the left
       (drop zone → progress → title), the guidance on the right, so the whole
       thing fits the card without scrolling and the progress bar appears
       exactly where the file was dropped. */
    .contract-upload { display: grid; grid-template-columns: minmax(0, 1fr) minmax(240px, 320px); gap: 26px; align-items: start; }
    .contract-up-main { min-width: 0; }
    .contract-upload .bf-lbl { display: block; margin: 0 0 5px; font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); }
    .contract-upload .bf-opt { font-weight: var(--fw-body); color: var(--muted); }
    .contract-name-field { margin-top: 18px; }
    .contract-in { width: 100%; box-sizing: border-box; }
    /* The drop zone IS the label for its (visually hidden) file input, so the
       whole panel is one click target and keyboard-focusable through the input. */
    .contract-drop { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 6px; min-height: 186px; padding: 26px 22px; text-align: center; border: 2px dashed var(--border); border-radius: 12px; background: var(--bg); cursor: pointer; transition: border-color .12s, background .12s; }
    .contract-drop:hover, .contract-drop.over, .contract-drop:focus-within { border-color: var(--accent); background: rgba(var(--accent-rgb), .05); }
    .contract-drop-in { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
    .contract-drop-ic { color: var(--muted); }
    .contract-drop-ic svg { width: 26px; height: 26px; }
    .contract-drop:hover .contract-drop-ic, .contract-drop.over .contract-drop-ic { color: var(--accent); }
    .contract-drop-main { font-size: var(--fs-lg); font-weight: var(--fw-semi); color: var(--text); }
    .contract-drop-main b { color: var(--accent); }
    .contract-drop-sub { font-size: var(--fs-sm); color: var(--muted); }
    /* Guidance rail — the old version of this was one dense paragraph under the
       textarea, which nobody reached. */
    .contract-up-side { border-left: 1px solid var(--border); padding-left: 24px; }
    .contract-side-h { margin: 0 0 8px; font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--text); }
    .contract-side-list { margin: 0; padding-left: 17px; display: flex; flex-direction: column; gap: 7px; font-size: var(--fs-sm); line-height: var(--lh-body); color: var(--muted); }
    .contract-side-list b { color: var(--text); font-weight: var(--fw-semi); }
    @media (max-width: 900px) { .contract-upload { grid-template-columns: minmax(0, 1fr); gap: 20px; } .contract-up-side { border-left: none; border-top: 1px solid var(--border); padding: 16px 0 0; } .contract-drop, .contract-staged { min-height: 186px; } }   /* room for the whole progress panel — see .contract-prog */
    /* A chosen-but-not-yet-uploaded file. Reads as "held", not "done" — the save
       is still ahead of it, and the title below is still editable. It stands in
       the drop zone's place, so nothing shifts when a file is picked. */
    .contract-staged { display: flex; align-items: center; gap: 12px; min-height: 186px; padding: 16px 18px; border: 2px dashed var(--border); border-radius: 12px; background: var(--bg); }
    .contract-staged .ic { flex-shrink: 0; color: var(--muted); }
    .contract-staged-main { flex: 1; min-width: 0; }
    .contract-staged-name { font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .contract-staged-meta { margin-top: 2px; font-size: var(--fs-sm); color: var(--muted); }
    .contract-staged-x { flex-shrink: 0; padding: 5px 12px; font-size: var(--fs-sm); }
    /* Upload/index progress: determinate while the file is on the wire, then
       indeterminate for the indexing pass (the server can't stream a %).
       It is OVERLAID on the lower half of the drop box rather than stacked under
       it, so the box is exactly as tall busy as idle and the title field below
       never moves. `busy` fades the box's own prompt and pins a staged file's
       row to the top so the two can't collide. */
    .contract-up-box { position: relative; }
    .contract-prog { position: absolute; left: 16px; right: 16px; bottom: 14px; padding: 0; border: 0; background: transparent;
                     animation: contract-prog-in .28s cubic-bezier(.22,.61,.36,1) both; }
    @keyframes contract-prog-in { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
    /* Busy is a STATE the whole box wears: the border stops being a dashed invitation
       to drop a file (there is nothing left to drop) and the surface warms slightly,
       so the box reads as working rather than waiting. */
    .contract-up-box.busy .contract-drop,
    .contract-up-box.busy .contract-staged { border-style: solid; border-color: rgba(var(--accent-rgb), .45); background: rgba(var(--accent-rgb), .04); transition: border-color .25s, background .25s; }
    .contract-up-box.busy .contract-drop { cursor: default; }
    .contract-up-box.busy .contract-drop-ic,
    .contract-up-box.busy .contract-drop-main,
    .contract-up-box.busy .contract-drop-sub { opacity: 0; }
    .contract-up-box.busy .contract-staged { align-items: flex-start; }
    .contract-up-box.busy .contract-staged .ic { color: var(--accent); }
    .contract-up-box.busy .contract-staged-x { visibility: hidden; }   /* nothing to cancel to mid-upload */
    .contract-prog-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
    .contract-prog-phase { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--text); }
    .contract-prog-pct { font-variant-numeric: tabular-nums; color: var(--accent-dark, var(--accent)); font-size: var(--fs-sm); font-weight: var(--fw-semi); }
    .contract-prog-track { position: relative; overflow: hidden; height: 8px; margin-top: 8px; border-radius: 999px; background: rgba(var(--accent-rgb), .16); }
    .contract-prog-fill { height: 100%; width: 0; border-radius: 999px; background: var(--accent, #ff6600); transition: width .18s linear; }
    .contract-prog.indet .contract-prog-fill { width: 35%; animation: contract-prog-slide 1.2s ease-in-out infinite; transition: none; }
    @keyframes contract-prog-slide { 0% { transform: translateX(-100%); } 100% { transform: translateX(300%); } }
    .contract-prog-note { margin-top: 7px; font-size: var(--fs-sm); color: var(--muted); }
    /* The warning is the only part of this panel that never changes, so it is the
       only part styled to be read at a glance: warn colour, its own icon, and a
       weight the muted narration above it doesn't have. */
    .contract-prog-warn { display: flex; align-items: center; gap: 6px; margin-top: 7px; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--warn); }
    .contract-prog-warn .ic { flex-shrink: 0; display: inline-flex; }
    .contract-prog-warn .lic { width: 14px; height: 14px; }
    @media (prefers-reduced-motion: reduce) {
      .contract-prog { animation: none; }
      .contract-prog.indet .contract-prog-fill { animation-duration: 2.4s; }
    }
    /* ── Resources menu config ─────────────────────────────────────────── */
    .res-sys-list { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; }
    .res-sys-row { display: flex; align-items: center; gap: 10px; padding: 8px 14px; font-size: var(--fs-md); }
    .res-sys-row + .res-sys-row { border-top: 1px solid var(--border); }
    .res-sys-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--border); flex-shrink: 0; }
    .res-sys-dot.on { background: var(--ok, #1a7f37); }
    .res-sys-label { font-weight: var(--fw-semi); color: var(--text); }
    .res-sys-note { font-size: var(--fs-sm); color: var(--muted); margin-left: auto; }
    .res-type-sel { padding: 5px 8px; font-size: var(--fs-sm); border: 1px solid var(--border); border-radius: 6px; background: var(--card); color: var(--text); font-family: inherit; flex-shrink: 0; }
    /* ── Contract document viewer ──────────────────────────────────────── */
    /* The reader is a framed page like any other: band, then ONE card. Inside the card
       the contents rail and the agreement are two PANES split by a hairline — they used
       to be two separate floating cards with a gap between them, which meant the band
       above had nothing to fuse to and the screen read as three objects instead of one.
       Both panes scroll: a two-pane reader is the one shape where that is correct, and
       _frametest.mjs allows it explicitly (`scrollers: 2`). */
    .contract-viewer { display: flex; flex-direction: column; flex: 1; min-height: 0; width: 100%; }
    .contract-viewer-actions { display: flex; gap: 8px; flex-shrink: 0; margin-left: auto; }
    /* Icon-only buttons: the glyph is centred by .icon-btn's grid, so it must carry no
       nudges of its own — the old margin-right/vertical-align were left from when this
       row held icon+label buttons, and they pushed the print glyph up and to the left
       inside its box. And size the SVG, not the span (the house pattern): sizing the
       span left an 18px glyph in a 14px box, and .icon-btn's grid centres the BOX, so
       the glyph hung 2px out to the right. */
    .contract-viewer-actions .ic { display: block; }
    .contract-viewer-actions .ic svg { display: block; width: 17px; height: 17px; }
    /* Search / Document are peer states, so they get a segmented control rather than
       a ghost "Search" button that read as a filter and hid the whole experience. */
    /* (The .cv-mode pill switcher lived here. Both of its users — Contract's
       Document/Search and Help's Guide/Feedback — switch SCREENS, so both ride
       .page-tabs in their band now. Nothing is left to style.) */
    /* Search mode owns the full panel (it's a destination, not a drawer), and it is
       laid out as a CHAT: the transcript takes the height and scrolls, the composer
       sits on the floor of the panel with a full-bleed rule above it. The panel's
       gutter therefore lives on the two children, not on .cv-search — otherwise that
       rule stops 22px short of each edge and reads as a stray line inside the page. */
    .cv-search { display: flex; flex-direction: column; flex: 1; min-height: 0; margin-top: 12px; }
    /* Capped and centred: this is a conversation, and a 1400px-wide chat bubble is not
       readable. The cap applies to the composer too so the box lines up with the
       messages instead of running the width of the window under them. */
    .cv-search .cbr-msgs { flex: 1; min-height: 0; overflow-y: auto; padding: 14px var(--pad-card) 18px; display: flex; flex-direction: column; gap: 10px; }
    .cv-search .cbr-msgs, .cv-search .cbrc-form { width: 100%; max-width: 940px; margin-left: auto; margin-right: auto; }
    /* The conversation grows UP off the composer, so an empty panel isn't a greeting
       marooned at the top of a screen of grey. auto margin rather than
       justify-content:flex-end — flex-end on a scroller makes overflow at the top
       unreachable, and a long transcript is exactly when that would bite. */
    .cv-search .cbr-msgs > :first-child { margin-top: auto; }
    /* Starting points, sitting under the greeting like suggested replies. */
    .cv-hint-chips { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 2px; }
    .cv-hint-chip { background: var(--card); border: 1px solid var(--border); border-radius: 999px; padding: 7px 13px; font: inherit; font-size: var(--fs-sm); color: var(--text); cursor: pointer; transition: border-color .12s, color .12s; }
    .cv-hint-chip:hover { border-color: var(--accent); color: var(--accent); }
    .contract-viewer-body { display: flex; flex: 1; min-height: 0; margin-top: 12px; border-top: 1px solid var(--border); }
    /* The breadcrumb is the phone's collapsed table of contents — desktop keeps the
       persistent sidebar, so it never appears there. */
    .cv-crumb { display: none; }
    /* Arriving from search. Two cues, and only ever one at a time: when we pinpointed
       the clause we tint the WORDS (and leave a soft tint behind, so it's still findable
       after the flash fades); when we could only place the article, the whole section
       flashes as it always did. Washing the section AND marking the clause would hide
       the precise cue under the vague one for the whole animation. */
    @keyframes cvFlash { 0%, 55% { background: var(--accent-light); } 100% { background: transparent; } }
    .cv-section.cv-hit { animation: cvFlash 1.8s ease-out; border-radius: 8px; }
    @keyframes cvClauseFlash { 0%, 55% { background: rgba(var(--accent-rgb), .38); } 100% { background: rgba(var(--accent-rgb), .13); } }
    .cv-clause-hit { background: rgba(var(--accent-rgb), .13); color: inherit; border-radius: 3px; animation: cvClauseFlash 2.2s ease-out; }
    .cv-toc { width: 240px; flex-shrink: 0; border-right: 1px solid var(--border); overflow-y: auto; padding: 14px 0; }
    .cv-toc-item { display: block; width: 100%; background: none; border: none; text-align: left; font: inherit; font-size: var(--fs-sm); line-height: var(--lh-snug); padding: 5px 16px; color: var(--muted); cursor: pointer; border-left: 3px solid transparent; transition: color .12s, border-color .12s, background .12s; }
    .cv-toc-item:hover { color: var(--text); background: var(--bg); }
    .cv-toc-item.active { color: var(--accent); border-left-color: var(--accent); font-weight: var(--fw-semi); background: var(--accent-light); }
    .cv-doc { flex: 1; min-width: 0; overflow-y: auto; padding: 22px 28px 60px; }
    .cv-section { margin-bottom: 32px; scroll-margin-top: 12px; }
    .cv-section-head { font-size: var(--fs-xl); font-weight: var(--fw-display); color: var(--text); margin: 0 0 10px; padding-bottom: 6px; border-bottom: 1px solid var(--border); display: flex; flex-wrap: wrap; align-items: baseline; gap: 6px 10px; }
    .cv-section-head .cb-ref { font-size: var(--fs-2xs); position: relative; top: -1px; }
    .cv-sec-title { font-size: var(--fs-xl); font-weight: var(--fw-display); }
    .cv-toc-ref { font-weight: var(--fw-bold); color: var(--accent); margin-right: 3px; }
    .cv-section-body { font-size: var(--fs-lg); line-height: var(--lh-loose); color: var(--text); word-wrap: break-word; }
    /* Plain-text bodies (paste/PDF imports) keep the original pre-wrap rendering —
       their newlines are the only structure they have. */
    .cv-plain { white-space: pre-wrap; }
    /* ── The cover sheet ────────────────────────────────────────────────────
       An agreement opens on a cover, not on a heading that says "Title Page" — so the
       reader's first screen carries the local's letterhead masthead (mark, name,
       subtitle, accent rule) with the document's own title-page wording under it,
       exactly as the export prints it. Built by cvMastheadHtml(). */
    .cv-cover { text-align: center; margin-bottom: 40px; padding-bottom: 28px; border-bottom: 1px solid var(--border); }
    .cv-cover-head { padding: 10px 0 4px; }
    .cv-cover-mark { display: inline-flex; align-items: center; justify-content: center; margin-bottom: 16px; }
    .cv-cover-mark img { display: block; max-height: 128px; max-width: 320px; }
    /* Same three rescues the printed cover uses, for the same reason: this card is
       white, and the app's header mark is usually drawn for the dark nav strip. */
    .cv-cover-mark.onwhite { background: #fff; }
    .cv-cover-mark.ink img { filter: brightness(0); }
    .cv-cover-mark.plate { background: var(--primary, #161616); border-radius: 8px; padding: 14px 18px; }
    .cv-cover-mark.plate.ink img { filter: brightness(0) invert(1); }
    .cv-cover-mark.round { overflow: hidden; border-radius: 22px; }
    .cv-cover-org { font-size: var(--fs-3xl); font-weight: var(--fw-display); color: var(--text); margin: 0; line-height: var(--lh-tight); }
    .cv-cover-sub { font-size: var(--fs-lg); font-style: italic; color: var(--muted); margin-top: 5px; }
    .cv-cover-rule { height: 3px; width: 180px; background: var(--accent); margin: 16px auto 0; border-radius: 2px; }
    .cv-cover-body { margin-top: 20px; }
    /* The title page's own wording is centred to match the sheet — the imported
       markup's alignment, indent depth and heading sizes are overridden on purpose:
       a cover has no outline, so a clause indent here is Word's leftovers, not
       structure. Scoped through .cv-cover so these win over the .cv-rich rules
       below, which are the same specificity and come later in the file. */
    .cv-cover .cv-cover-body .cv-p, .cv-cover .cv-cover-body .cv-h,
    .cv-cover .cv-cover-body p, .cv-cover .cv-cover-body div { text-align: center; }
    .cv-cover .cv-cover-body .cv-h { font-size: inherit; margin: 0 0 8px; }
    .cv-cover .cv-cover-body .cv-in1, .cv-cover .cv-cover-body .cv-in2,
    .cv-cover .cv-cover-body .cv-in3, .cv-cover .cv-cover-body .cv-in4,
    .cv-cover .cv-cover-body .cv-blk1, .cv-cover .cv-cover-body .cv-blk2,
    .cv-cover .cv-cover-body .cv-blk3, .cv-cover .cv-cover-body .cv-blk4 { margin-left: 0; padding-left: 0; text-indent: 0; }
    /* The cover we SET, for an agreement that imported no title page of its own
       (cvOpenPageHtml). Same masthead, same centred sheet, so a member can't tell
       which of the two they're looking at — that's the point: every local's contract
       opens on a cover, not only the ones whose upload happened to carry one. */
    .cv-openpage { text-align: center; margin-bottom: 40px; padding-bottom: 28px; border-bottom: 1px solid var(--border); }
    .cv-cover-kicker { font-size: var(--fs-sm); font-weight: var(--fw-semi); letter-spacing: .14em; text-transform: uppercase; color: var(--muted); margin: 0; }
    .cv-cover-title { font-size: var(--fs-2xl); font-weight: var(--fw-display); color: var(--text); margin: 10px 0 0; line-height: var(--lh-snug); }
    .cv-cover-term { font-size: var(--fs-3xl); font-weight: var(--fw-display); color: var(--accent); margin: 8px 0 0; font-variant-numeric: tabular-nums; }
    /* ── What the agreement adds up to ──────────────────────────────────────
       Three counted figures under the cover (cvStatsHtml). This is the part that
       answers "how much work went into this" without the member having to scroll a
       contents list to the end and tally it themselves.

       Sized as DATA, not as a heading: the numbers carry, the labels sit quiet under
       them. Tabular figures so 38,412 and 148 line up on their digits instead of
       wobbling against each other. The row is capped and centred because on a desktop
       the document column is wide, and three numbers spread across 900px stop reading
       as one fact about one document. */
    .cv-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); gap: 0; max-width: 420px; margin: 26px auto 0; border-top: 1px solid var(--border); }
    .cv-stat { padding: 16px 6px 2px; }
    .cv-stat + .cv-stat { border-left: 1px solid var(--border); }
    .cv-stat-n { font-size: var(--fs-3xl); font-weight: var(--fw-display); color: var(--text); line-height: var(--lh-none); font-variant-numeric: tabular-nums; }
    .cv-stat-l { font-size: var(--fs-sm); color: var(--muted); margin-top: 3px; }
    /* ── Structured contract rendering (.docx imports) ──────────────────────
       Typeset like the printed agreement rather than a web page: a serif body at a
       comfortable measure, real numbered clauses at their true depth, and salary
       schedules as actual tables. A member should recognise this as THEIR contract. */
    .cv-rich { font-family: Georgia, 'Iowan Old Style', 'Times New Roman', serif; font-size: var(--fs-lg); line-height: var(--lh-loose); }
    .cv-rich .cv-p { margin: 0 0 12px; text-align: left; }
    .cv-rich .cv-center { text-align: center; }
    .cv-rich .cv-right { text-align: right; }
    /* Outline depth (cvIndentHtml infers it from each clause's leading marker —
       ARTICLE → A. → 1. → a.). The hanging indent puts the marker in the margin and
       squares the clause text up beside it, so the hierarchy is visible at a glance
       instead of being a wall of flush-left prose. Mobile halves the steps: full
       depth on a phone would squeeze the text column to nothing. */
    .cv-rich .cv-in1, .cv-rich .cv-in2, .cv-rich .cv-in3, .cv-rich .cv-in4 { text-indent: -1.6em; padding-left: 1.6em; }
    .cv-rich .cv-in1 { margin-left: 20px; }
    .cv-rich .cv-in2 { margin-left: 42px; }
    .cv-rich .cv-in3 { margin-left: 64px; }
    .cv-rich .cv-in4 { margin-left: 86px; }
    /* Tables and lists inherit the depth of the clause they follow (cvIndentHtml walks
       the section in order). Margin only — the hanging indent above is for text with a
       marker in it; on a table it would push the table back out past the clause. */
    .cv-rich .cv-blk1 { margin-left: 20px; }
    .cv-rich .cv-blk2 { margin-left: 42px; }
    .cv-rich .cv-blk3 { margin-left: 64px; }
    .cv-rich .cv-blk4 { margin-left: 86px; }
    .cv-rich b { font-weight: var(--fw-bold); }
    .cv-rich .cv-h { font-family: inherit; font-weight: var(--fw-bold); color: var(--text); margin: 22px 0 10px; line-height: var(--lh-snug); }
    .cv-rich .cv-h2 { font-size: var(--fs-xl); }
    .cv-rich .cv-h3, .cv-rich .cv-h4 { font-size: var(--fs-lg); }
    /* Word supplies the real marker text ("A.", "(a)", "iv."), so the list itself
       carries no CSS counter — the numbering matches the paper contract exactly. */
    .cv-rich .cv-list { list-style: none; margin: 0 0 12px; padding: 0 0 0 4px; }
    .cv-rich .cv-list .cv-list { margin: 8px 0 4px; padding-left: 22px; }
    .cv-rich .cv-list li { display: flex; gap: 10px; margin: 0 0 9px; align-items: baseline; }
    .cv-rich .cv-mark { flex-shrink: 0; min-width: 1.9em; font-weight: var(--fw-semi); color: var(--muted); font-variant-numeric: tabular-nums; }
    .cv-rich .cv-li-body { flex: 1; min-width: 0; }
    /* Tables scroll on their own rather than reflowing: a salary schedule that
       rewraps into stacked cells stops being readable as a schedule. */
    .cv-table-wrap { overflow-x: auto; margin: 14px 0 18px; border: 1px solid var(--border); border-radius: 8px; }
    .cv-table { border-collapse: collapse; width: 100%; font-family: system-ui, -apple-system, 'Segoe UI', sans-serif; font-size: var(--fs-md); line-height: var(--lh-snug); }
    .cv-table th, .cv-table td { padding: 7px 11px; text-align: left; border-bottom: 1px solid var(--border); vertical-align: top; }
    .cv-table th { background: var(--bg); font-weight: var(--fw-bold); color: var(--text); position: sticky; top: 0; white-space: nowrap; }
    .cv-table td { color: var(--text); }
    /* Scoped to tbody on purpose: the header row lives in a <thead> now (so it can
       reprint on every printed page), and unscoped these two would strip the header's
       underline and count it as a zebra stripe. */
    .cv-table tbody tr:last-child td { border-bottom: none; }
    .cv-table tbody tr:nth-child(even) td { background: var(--bg); }
    .cv-loading { padding: 40px 20px; text-align: center; font-size: var(--fs-lg); color: var(--muted); }
    .cv-empty { padding: 60px 20px; text-align: center; }
    .cv-empty p { font-size: var(--fs-lg); color: var(--muted); margin: 8px 0; }
    /* ── Desktop: two panels on a page, the same shape as Site Config ──────────
       The reader used to sit straight on the app background — a table of contents
       and a wall of text with one hairline between them, which read as "unstyled"
       next to every other page. Here the contents and the agreement are each their
       own card with their own scroll, and the Document ⇄ Search toggle rides up into
       the page header so there's ONE header on the page rather than a header with a
       control bar under it. Switching to Search swaps the whole two-panel row for the
       conversation (same grid cell), which is why both live on grid-row 2.
       Scoped to min-width:769 so the phone's overlay/scrub TOC below is untouched. */
    @media (min-width: 769px) {
      /* The page column: band on top, card filling the rest. (It used to be a two-cell
         grid so the mode toggle could sit beside the title; the toggle is in the band's
         tab strip now, which is where every other screen keeps its switcher.) */
      .contract-viewer { width: 100%; max-width: calc(var(--page-max, 1500px) + 44px); margin-inline: auto; padding: var(--page-top, 22px) 22px var(--page-bottom, 22px); }
      .contract-viewer > .page-frame { flex: 1 1 0; min-height: 0; }
      .cv-search, .contract-viewer-body { flex: 1 1 0; min-height: 0; margin-top: 0; }
      /* Two panes in ONE card: contents rail (label + list, two elements so the label
         can't scroll away), then the agreement. The hairline between them is the card's
         own divider — no gap, no second border, no second shadow. */
      .contract-viewer-body { display: grid; grid-template-columns: 264px minmax(0, 1fr); grid-template-rows: auto minmax(0, 1fr); border-top: none; }
      .cv-toc-head { grid-column: 1; grid-row: 1; display: flex; align-items: center; gap: 8px; border-right: 1px solid var(--border); border-bottom: 1px solid var(--border); padding: 13px 16px 11px; font-size: var(--fs-sm); font-weight: var(--fw-bold); letter-spacing: .04em; text-transform: uppercase; color: var(--muted); }
      .cv-toc-head .ic { width: 14px; height: 14px; }
      .cv-toc-count { margin-left: auto; font-weight: var(--fw-semi); letter-spacing: 0; text-transform: none; }
      .cv-toc { grid-column: 1; grid-row: 2; width: auto; border-right: 1px solid var(--border); padding: 8px 0 12px; }
      .cv-doc { grid-column: 2; grid-row: 1 / -1; padding: 30px 36px 60px; }
      /* The clause highlight is inset now that the document has an edge of its own. */
      .cv-section.cv-hit { border-radius: 8px; }
      /* Search swaps for the whole reading area inside the same card. */
      .cv-search { display: flex; flex-direction: column; overflow: hidden; }
    }
    @media (max-width: 768px) {
      .cv-toc-head { display: none; }
      /* The band and the card take the phone gutter, like every other framed page. */
      /* 16px 14px — the phone's page rhythm, the same one `.app-container` sets. The
         bottom used to be 14, which put the reader's card 2px below every other
         screen's. See THE CARD BOX. */
      .contract-viewer { padding: 16px 14px; display: flex; flex-direction: column; flex: 1; min-height: 0; }
      .contract-viewer > .page-frame { flex: 1 1 0; min-height: 0; }
      .cv-rich .cv-in1 { margin-left: 10px; }
      .cv-rich .cv-in2 { margin-left: 21px; }
      .cv-rich .cv-in3 { margin-left: 32px; }
      .cv-rich .cv-in4 { margin-left: 43px; }
      /* Halved on a phone for the same reason the clause steps are: a table pushed the
         full depth in has no measure left to be a table with. */
      .cv-rich .cv-blk1 { margin-left: 10px; }
      .cv-rich .cv-blk2 { margin-left: 21px; }
      .cv-rich .cv-blk3 { margin-left: 32px; }
      .cv-rich .cv-blk4 { margin-left: 43px; }
      /* The contents OVERLAY the document rather than sitting above it in the column.
         This is load-bearing, not cosmetic: as a flex sibling the TOC's height was part
         of the same box that supplies #cv-doc's scroll range, so rolling it up deleted
         the very scroll distance the scrub runs on — the browser then clamped scrollTop
         back down, which re-opened the TOC, and it settled into a half-open wobble
         instead of closing. Absolute keeps #cv-doc's height (and so its scroll range)
         constant, which makes the scrub a stable pure function of scrollTop.
         #cv-doc gets a matching padding-top from JS so nothing hides under the panel. */
      /* …and it starts where the band ends. The base rule floats this pane 12px below
         whatever is above it and draws its own top border — both from before the reader
         lived in a frame, when the pane was a card of its own. Desktop was corrected
         when the frame landed (margin-top:0, border-top:none up in the min-width block)
         and the phone was not, so the contents selector sat in a 14px strip of empty
         white under the tab row, with two hairlines 13px apart bracketing nothing. The
         frame's own top border is the divider; the selector fuses to the tabs like every
         other frame's first bar, and the 13px goes back to the contract. */
      .contract-viewer-body { flex-direction: column; position: relative; margin-top: 0; border-top: none; }
      /* ── TOC ⇄ breadcrumb: SCRUBBED BY SCROLL, not triggered ────────────────
         The contract's own scroll position IS the animation timeline. Scroll
         down and the contents roll up in direct proportion; stop halfway and it
         sits halfway; scroll back up and it unrolls. cvScrub() in app.js writes
         height/opacity every frame, so there must be NO transition/animation on
         these two — a transition here would lag the finger and reintroduce the
         "it just happens" feel this replaced. `flex: none` is load-bearing:
         under `flex: 1` the column container overrides the inline height and
         the scrub does nothing at all. */
      .cv-toc { position: absolute; top: 0; left: 0; right: 0; z-index: 3; width: auto; min-height: 0; background: var(--card); border-right: none; border-bottom: 1px solid var(--border); padding: 8px 0 14px; overflow-y: auto; overflow-x: hidden; }
      .cv-toc-item { padding: 9px 16px; font-size: var(--fs-md); }
      .cv-crumb { position: absolute; top: 0; left: 0; right: 0; z-index: 4; display: flex; align-items: center; gap: 9px; width: auto; background: var(--card); border: none; border-bottom: 1px solid var(--border); padding: 11px 14px; font: inherit; font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); text-align: left; cursor: pointer; overflow: hidden; }
      .cv-crumb .ic { width: 15px; height: 15px; flex-shrink: 0; color: var(--muted); }
      .cv-crumb-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      .cv-crumb-caret { flex-shrink: 0; color: var(--muted); font-size: var(--fs-xs); }
      .cv-crumb:active { background: var(--accent-light); }
      .cv-doc { padding: 16px 14px 40px; }
      /* ── The cover, on a phone ──────────────────────────────────────────
         This is the first screen a member sees (cvParkOpening parks them here), and
         it has one job the desktop cover doesn't: fit, and still show that there is
         more underneath. A cover that fills the viewport edge to edge reads as the
         whole screen and gets no scroll; leaving the counts near the fold — and the
         first article just past it — is what says "keep going".
         So the mark comes down off its print size and the vertical rhythm tightens.
         Nothing is removed: the same masthead, title, term and counts as desktop. */
      .cv-cover-mark img { max-height: 76px; max-width: 200px; }
      .cv-cover-mark { margin-bottom: 12px; }
      .cv-cover-head { padding: 4px 0 0; }
      .cv-cover-rule { margin-top: 13px; width: 132px; }
      .cv-cover, .cv-openpage { margin-bottom: 26px; padding-bottom: 20px; }
      .cv-stats { margin-top: 18px; max-width: none; }
      .cv-stat { padding: 13px 4px 2px; }
      /* The word count is the widest figure by far and the one that must not wrap —
         "38,412" breaking across two lines undoes the whole point of the row. */
      .cv-stat-n { font-size: var(--fs-2xl); }
      /* The gutter is on the children, not the panel — see .cv-search above. */
      .cv-search .cbr-msgs, .cv-search .cbrc-form { padding-left: 14px; padding-right: 14px; }
      /* This contract nests clauses five deep. At desktop indents that leaves a
         ~190px text column on a phone, so tighten the step and let the marker sit
         closer — the hierarchy still reads, but the prose keeps its measure. */
      .cv-rich { font-size: var(--fs-lg); }
      .cv-rich .cv-list .cv-list { padding-left: 12px; }
      .cv-rich .cv-list li { gap: 7px; }
      .cv-rich .cv-mark { min-width: 1.6em; }
    }
    /* ── Display panel: the text-size chooser ────────────────────────────────
       A row of equal cards, each previewing its own scale so the choice is visible
       before it's made. The preview text is sized off the CARD's own --fs-scale
       (--tso-scale, set inline per option), not the page's, so all four sizes show
       side by side at once — that's the whole point of showing rather than telling.
       It's a SEPARATE var from --fs-scale on purpose: overriding --fs-scale on the
       card would also resize the card's own "Default / Larger" labels, so the four
       labels would disagree in size and the card would fight its own preview. */
    .txt-scale-row { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-top: 4px; }
    @media (max-width: 700px) { .txt-scale-row { grid-template-columns: repeat(2, 1fr); } }
    .txt-scale-opt { position: relative; display: flex; flex-direction: column; align-items: center; justify-content: center;
      gap: 7px; min-height: 92px; padding: 13px 10px; background: var(--card); border: 1px solid var(--border);
      border-radius: 10px; cursor: pointer; font-family: inherit; color: var(--text); text-align: center;
      transition: border-color .15s, box-shadow .15s; }
    .txt-scale-opt:hover { border-color: var(--accent); }
    .txt-scale-opt[aria-pressed="true"] { border-color: var(--accent); box-shadow: 0 0 0 2px rgba(var(--accent-rgb), .28); }
    /* The live preview — reads the card's own scale, so each card renders its size. */
    .txt-scale-opt .tso-prev { font-size: calc(13px * var(--tso-scale, 1)); font-weight: var(--fw-semi); line-height: var(--lh-tight); }
    .txt-scale-opt .tso-lbl { font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase;
      letter-spacing: .04em; color: var(--muted); }
    /* Screen Fit reuses the text-size card shell, but its preview has to show the
       thing the setting actually does — fitting MORE onto one screen — so it draws
       n columns in a fixed-width frame instead of a letterform. */
    .screen-fit-row { grid-template-columns: repeat(3, 1fr); }
    @media (max-width: 700px) { .screen-fit-row { grid-template-columns: repeat(3, 1fr); } }
    .screen-fit-opt .sfo-prev { display: grid; grid-template-columns: repeat(var(--sfo-cols, 2), 1fr); gap: 3px;
      width: 54px; height: 22px; }
    .screen-fit-opt .sfo-prev i { background: var(--muted); opacity: .45; border-radius: 2px; }
    .screen-fit-opt[aria-pressed="true"] .sfo-prev i { background: var(--accent); opacity: 1; }
    .txt-scale-opt[aria-pressed="true"] .tso-lbl { color: var(--accent-dark); }
    .ss-panel { display: none; }
    .ss-panel.active { display: block; animation: ssfade .18s ease; }
    .ss-panel.hub-fused { animation: none; }
    /* Same seam as `.page-head + .page-frame`, reached differently: in Site Config the
       band is not adjacent to the card, it sits above a whole stack of switchable
       panels. Square corners AND no top border — the band above already drew the one
       line this seam gets (see THE BAND'S HAIRLINES). */
    .ss-panel.hub-fused > .card { border-top-left-radius: 0; border-top-right-radius: 0; border-top: none; }
    .ss-panel #audit-list { overflow-x: auto; }
    /* ── THE PAGE FRAME — one shape for every screen in the app ─────────────
       Site Config proved this shape; it is the whole site's now. A framed screen
       is a title band fused to the top of a card, then the card:

         .page-head    the band. This screen's name, and nothing else's — fused to
                       the card's top edge (squared corners where they meet), and
                       drawing the divider either with its own border-bottom
                       (.solo) or with a .page-tabs strip's border-top.
         .page-frame   the card. Fixed height, overflow hidden — a FRAME, not a
                       document.
         .page-body    the card's contents. On a form screen this IS the scroller;
                       on a list screen it is .page-stacked, a column holding:
             .page-bar     ONE fixed row — lede | search · count · actions
             .list-head    optional column labels
             .page-scroll  THE ONLY THING THAT SCROLLS
         .page-foot    optional pinned bar — destructive left · status · primary right

       Why a frame and not a document: the title, the actions and the Save bar are
       the controls you came for, and a 500-row list must never push them off the
       screen. It also gives the card a real bottom edge — one that ends because
       the frame ends, not because the content happened to stop, which is the
       difference between "that's the end of the list" and "is it still loading?".

       THE PAGE TITLE LIVES IN THE BAND. Not on the grey above it. The grey <h2>
       (.section-header) survives in exactly one place: a section that holds
       SEVERAL screens (Site Config → District Setup, Platform, Settings, State),
       where it names the section and the band names the screen. Anywhere else it
       is the same word twice and 45px of fold.

       .page-tabs switches SCREENS. A control that filters one list — Requests'
       Open/Complete — is a segmented control and belongs in .page-bar with the
       other controls. They look different because they are different.

       ── THE CARD BOX — the white rectangle is ONE rectangle ─────────────────
       Everything above describes what goes IN the card. This is the card itself,
       and it is the part that was drifting, because nothing named it: the band's
       internals were tokenised and asserted while its outer edges were left to
       whatever each page's wrapper happened to do. Four numbers, desktop:

         top     nav bottom + --page-top (22px)
         left    --gutter (22px) from the window
         right   --gutter (22px) from the window
         bottom  --page-bottom (22px) above the viewport edge
         card top → divider   57px  (16 pad + 26 title row + 13 gap + 1 rule + 1 border)

       An EVEN margin on all four sides, and one distance to the first line. Flip
       between two screens and the white rectangle must not move a pixel — that is
       the whole standard, and it is why a page may not invent its own column.
       Concretely: put the band and the frame inside `.app-container` and let THE
       FRAME'S PLUMBING below do the rest. Don't hand-roll a wrapper, don't set a
       top margin on the band, and don't leave `scrollbar-gutter: stable` on a
       container that has stopped scrolling — a reserved gutter comes off ONE side,
       which is an uneven margin by another name.

       All four were broken somewhere, each by a different mechanism, and each in a
       way you can only see by flipping between two screens:
         • Calendar's band carried `margin: 26px 22px 0` — a pre-token number that
           outlived --page-top's arrival, so its card started 4px low.
         • Member Chat had a page column of its own (`.compose-wrap`) with no gutter
           and no cap: card flush to both window edges, 330px short of the bottom.
         • Submit kept `scrollbar-gutter: stable` on a container that no longer
           scrolls: 22px left, 37px right.
         • The Supply Closet's `.accent` band let a 46px icon size the title row, so
           its divider sat 20px below every other screen's.
       Asserted per screen by scripts/_frametest.mjs, which measures the card's four
       edges and the band's rule against the numbers above. */
    .page-frame { display: flex; flex-direction: column; min-height: 0; overflow: hidden; padding: 0;
      background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); box-shadow: var(--shadow); }
    .card.padded.page-frame { padding: 0; display: flex; flex-direction: column; min-height: 0; overflow: hidden; }
    .page-frame .page-body { flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: var(--pad-card); }
    .page-frame .page-foot { flex: none; display: flex; align-items: center; justify-content: flex-end; gap: 12px; padding: 13px var(--pad-card); border-top: 1px solid var(--border); background: var(--card); border-radius: 0 0 var(--radius) var(--radius); }
    .page-foot .page-status { margin-right: auto; font-size: var(--fs-sm); color: var(--muted); }
    .page-foot .page-status.ok { color: var(--accent-dark, var(--accent)); font-weight: var(--fw-semi); }
    .page-foot .page-status.err { color: var(--danger); font-weight: var(--fw-semi); }
    .page-foot .page-save:disabled { opacity: .45; cursor: default; }

    /* ── THE CARD SECTION — what goes INSIDE the one card ───────────────────
       The page card is the only card. A thing inside it is a SECTION, not a
       second card: it separates with a hairline and a heading, never with a
       border, a radius, a fill and a gutter of its own.

       WHY THIS EXISTS. Before the frame, every screen was grey page → white
       cards floating on grey, and an inner panel's white fill, 1px border,
       12px radius and 22px gutter were all load-bearing — they were what made
       it a card at all. The frame replaced that with grey page → ONE white
       card, and nobody told the panels. They still paint background:var(--card)
       ON TOP OF a var(--card) card, so the fill separates nothing and what
       survives is pure cost: a border outlining white-on-white, and a second
       gutter stacked on the frame's first one. On the request detail that put
       68px between the card's wall and the first character of the description,
       twice over, at four padded levels deep.

       This is not a new idea — it is the Members panel's shape, which the
       plumbing note below already calls "the one that reads best on a phone
       and the reason every page is being brought to it". #users-list carries
       no border, no radius and no fill on EITHER breakpoint. List screens got
       brought to it; detail and composite screens did not. This is that.

       THE INVARIANT, and it is asserted (see the DENSE array in
       scripts/_frametest.mjs): between the card's wall and any line of body
       text there is exactly ONE gutter and ZERO borders.

       Deliberately NOT .page-section, which is a different thing on purpose —
       a boxed, page-coloured block, documented below as the last resort. This
       is the default; that is the exception. The name pairs with hr.card-sep,
       the hairline the detail screens already break sections with. */
    /* The reset IS the component: a section is what's left when a card stops being one.
       Doubled class on purpose — .contact-card / .ticket-detail-card / .comments-card
       are declared much later in this file at equal specificity, and a section has to
       out-rank the card chrome it is replacing without every screen having to delete
       that chrome first. It is opt-in per screen for exactly that reason: adding
       `card-sec` to a panel converts it, and a panel that hasn't been converted yet
       (the Supply Closet's "Posted By" reuses .contact-card) is untouched. */
    .card-sec.card-sec { background: none; border: 0; border-radius: 0; box-shadow: none; margin: 0; min-width: 0; }
    /* ── THE SECTION HEAD — a band, and ONE height ─────────────────────────
       ONE HEIGHT, because the hairline under a head is a rule the eye tracks
       ACROSS columns. Sized from the control slot, not from the text, exactly
       as .page-acts sizes the bar's cluster: a head carrying a print button, a
       head carrying a Reassign menu and a head carrying only a title are all
       the same height, so their hairlines land on the same pixel. They were
       48px, 49px and 67px — Notes & Activity's rule sat 19px below its
       neighbours' because a 38px icon button had stretched its head, and the
       whole top of the screen read as three panels that didn't agree.

       A BAND, because the head is what tells you a new section started. On a
       desktop the rails between columns say it too, but the phone stack has no
       rails — and it REORDERS with CSS `order`, so a `.card-sec + .card-sec`
       rule draws its boundary wherever the DOM happens to put the sibling,
       which on that screen is not where the seam is. (It put a rule above
       Assignee Details and Complete Request, and none above the three real
       seams.) A filled head is order-proof: it announces its own section from
       wherever the reflow lands it, and it needs no sibling to be correct.
       Same grey, and the same job, as .list-head over a column of rows. */
    /* ── THE HEAD IS SIZED BY ITS LABEL, NOT BY A CONTROL SLOT ───────────────
       The head must be a FIXED height — the frame suite asserts every head on a
       screen measures the same, because their rules are a line the eye tracks
       across columns (Notes & Activity's print button broke that once by growing
       its own head to 55 against its neighbours' 46). The mistake was deriving that
       height from --sec-act-h, the 32px control slot: it made every head 46px tall
       to hold a button that, in practice, no head has as a flex child (`.csh-end`
       has zero uses, and the one real head control — the case-file print button —
       is absolutely positioned). So 30 of those 46 pixels were reserve, spent as
       air around a 16px label.
       That reserve is what Eric kept reading as "every blue header carries padding
       beneath it", and it got louder when the heads lost their fill and rule: a
       banded head owns the space inside its fill, a bare label owns nothing but
       itself. Moving the slack ABOVE the label was the wrong repair — the label
       just fell down to meet the content and left a hole under the rail. The head
       SHRINKS instead: --pad-band above the label, one label line, a hairline of
       lead below. The label sits where a label belongs, at the top of the thing it
       names, and the content follows it up. Controls centre on the smaller slot. */
    .card-sec-hd { --sec-act-h: calc(24px * var(--fs-scale, 1));
      --sec-lbl-h: calc(16px * var(--fs-scale, 1));
      display: flex; align-items: center; gap: 8px;
      height: calc(var(--pad-band) + var(--sec-lbl-h) + 4px);
      padding: var(--pad-band) var(--pad-card) 4px;
      background: var(--band); border-bottom: 1px solid var(--band-rail);
      font-size: var(--fs-xs); font-weight: var(--fw-bold); color: var(--band-ink);
      letter-spacing: .1em; text-transform: uppercase; }
    .card-sec-hd .icon-btn, .card-sec-hd .btn,
    .card-sec-hd > .csh-end, .card-sec-hd .status-select-wrap { align-self: center; }
    /* A head LABELS the data under it; at --fs-xl/--fw-bold in --text it was reading
       louder than the data, three-across. The eyebrow treatment is what the app
       already uses for a label that names a column (.list-head) — the head is the
       same job at section scale, so it gets the same voice. */
    .card-sec-hd .ic { color: var(--band-ico); }
    /* ── THE REQUEST DETAIL'S HEADS ARE LABELS, NOT BANDS ────────────────────
       Same eyebrow — same size, weight, tracking and ink — with the fill traded for a
       rule underneath: the treatment the member detail already uses (.sec-sub-title).
       Eric's call, made twice with the two screens side by side. On a screen that
       stacks five sections, five filled bands read as five separate objects and the
       pale blue does the talking; a label over a rule says "a block starts here" just
       as clearly and lets the content be the loudest thing on the card.

       ── ONE LINE MEANS ONE THING, at section scale ─────────────────────────
       And the head draws NO rule of its own. It used to draw one underneath, which is
       the same defect the directory row and the Requests row each had and each fixed:
       a hairline between a label and the thing it labels, in the same grey and the
       same weight as the hairline that marks where a section ENDS. Two jobs, one look
       — so the screen read as an evenly spaced stack of rules and none of them said
       "a new section starts here". Measured on the phone stack: the seam between the
       member's contact line and Request Details had no line at all, while a line sat
       under every label.

       So the only hairlines on this screen are BETWEEN sections — the rails the
       containers already draw (.td-col-main, .td-v2, .td-top, .td-grid) — and the
       eyebrow does its own talking. It can: it is uppercase, tracked, in the band ink,
       against body copy at --fs-md. Nothing else on the card looks like it.

       WHAT THE SELF-DRAWN RULE WAS PROTECTING, and how that is protected now. A
       `.card-sec + .card-sec` seam once drew in the wrong places here: the phone stack
       reordered with CSS `order`, so a sibling selector landed wherever the DOM sibling
       happened to be — above Assignee Details and Complete Request, and above none of
       the three real seams. The fix then was a rule the head drew on itself, which
       needs no sibling. The fix NOW is to remove the reordering: renderDetail emits the
       phone stack in the order it is read (member → request → assignee → notes →
       complete), so `+` means what it says and the seam is the seam. Asserted by
       _frametest ("no section is reordered" + "every section boundary is marked").

       The height stays FIXED (see THE HEAD IS SIZED BY ITS LABEL above — it is
       derived from the label now, not from the control slot). Switching to padding
       + min-height let Notes & Activity — the one head carrying a button — stand
       55px against its neighbours' 46, which the suite caught. */
    #section-ticket .card-sec-hd {
      position: relative;
      background: none; border-bottom: 0;
      /* The lean. With the fill and rule gone, the label was still CENTRED in its
         46px slot — laid out for the band it no longer is. Centred, it sat ~15px
         from the card edge above and ~29px from its own content below (its slack
         plus the content's own lead), so proximity grouped every head with the
         EDGE instead of the thing it names — Eric read it as extra space under
         the heads, which is exactly what it was. Top padding moves the label's
         slack above it: the height is border-box so the slot stays 46px (the
         equal-heights assert), the label drops toward its content, and the air
         becomes the section's breathing room instead of a gap under the label. */
      padding-top: var(--pad-band); }
    /* Every control in a head takes the slot, so none of them can change the height.
       An ICON button also takes it as its WIDTH: .icon-btn is 38×38 everywhere else,
       and forcing only the height left a squat 38×28 box with 3px of air above and
       below it in the (now shorter) head. Square at the slot size, it reads as a mark
       in the header rather than a control wedged into it. */
    .card-sec-hd .icon-btn, .card-sec-hd .btn { height: var(--sec-act-h); min-height: 0; flex: none; }
    .card-sec-hd .icon-btn { width: var(--sec-act-h); border-radius: 7px; }
    /* …and none of them inherit the eyebrow. Uppercase + .1em tracking cascades, so a
       Reassign button in a .csh-end came out shouting in the label's colour. */
    .card-sec-hd .btn, .card-sec-hd .icon-btn {
      text-transform: none; letter-spacing: 0;
      font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--text);
      border-color: var(--band-rail); }
    .card-sec-hd .icon-btn .ic, .card-sec-hd .btn .ic { color: inherit; }
    /* A section with no head of its own still needs its seam drawn. Kept on DOM
       adjacency deliberately: a headless section has nothing to carry the band,
       so a container that reorders its children must give them heads. */
    .card-sec + .card-sec:not(:has(> .card-sec-hd)) { border-top: var(--rail); }
    /* The head's trailing controls (a Reassign menu, a print button) sit at the
       far end of its own row rather than in a body footer — same slot on every
       section, same corner the .page-bar puts its actions in. */
    .card-sec-hd > .csh-end { margin-left: auto; display: flex; align-items: center; gap: 8px; }
    .card-sec-body { padding: var(--pad-card); min-width: 0; }
    /* A section whose body IS a scroller keeps the gutter and bleeds its bar to
       the wall, exactly as .page-scroll does for the card. */
    .card-sec-body.card-sec-scroll { overflow-y: auto; min-height: 0; }

    /* THE SECTIONS CARRY THE GUTTER, so the card stops paying it. That is the whole
       trick: the hairlines then run wall to wall (the card-divider standard — full
       content width, equal distance from both edges) while the text still lands on
       the one gutter, exactly where it landed before.
       A stated class rather than :has(> .card-sec), because the sections are not
       always the body's own children — a screen that scrolls puts .page-scroll in
       between, and a :has() that reached through it would be a different selector
       for the same idea. One name, both shapes. .page-scroll's bleed comes off too:
       it exists to put the scrollbar on the card's wall, and with no gutter to bleed
       out of it would hang the scroller a gutter past that wall. */
    .page-frame .page-body.page-sections { padding: 0; }
    .page-frame .page-body.page-sections > .page-scroll { margin-right: 0; padding-right: 0; }
    /* A sectioned screen that ALSO has a bar: the body stopped paying the gutter, so
       the bar pays its own. Its rule already ran wall to wall (it is the body's full
       width) — that is the seam between the bar and the list and it stays. */
    .page-frame .page-body.page-sections > .page-bar { padding: 0 var(--pad-card) 13px; margin-bottom: 0; }
    /* NOTE for the next screen that puts a .page-bar on a sections body: it will need
       one, and it is not a one-liner. A bar's rule is its own border-bottom, which
       spans its padding box — on a padded body that lands a gutter short of each wall
       (the card-divider standard) and on a padding-free sections body it runs wall to
       wall. And --page-bar-h is a border-box floor written for a bar whose top gutter
       is paid by the BODY, so moving that gutter onto the bar spends the floor on
       padding and stands a one-line lede 21px closer to its rule than the identical
       lede one screen over. Both were written here once (Legal, before it moved to
       .set-cards) and both are removed rather than left dead: an untested rule that
       fires on a shape nothing has is worse than no rule. */

    /* ── SIDE-BY-SIDE SECTIONS — divided by a rail, not a gap ───────────────
       Three panels in a row used to cost 3×(border+gutter+gutter+border) plus
       two 16px gaps of dead grey between them. A rail is 1px and reads as ONE
       surface divided, which is what it is. Columns set --card-cols per screen,
       the same way --list-cols is declared once per list. */
    .card-cols { display: grid; grid-template-columns: var(--card-cols, 1fr); gap: 0; min-width: 0; }
    .card-cols > * { min-width: 0; }
    /* Doubled class, and it is load-bearing: the thing in a column is a .card-sec,
       whose own reset is written `.card-sec.card-sec { border: 0 }` so it can out-rank
       the card chrome it replaces. At a bare `.card-cols > * + *` the rail lost that
       fight and three columns rendered as one undivided field of white. */
    .card-cols.card-cols > * + * { border-left: var(--rail); }
    /* Stacked on a phone: the rail turns with the flow, so a column boundary is
       still one hairline and never a leftover vertical line down a stack. */
    @media (max-width: 760px) {
      .card-cols { grid-template-columns: minmax(0, 1fr); }
      .card-cols.card-cols > * + * { border-left: none; border-top: var(--rail); }
    }

    /* ── THE FRAME'S PLUMBING — how a page hands its height to its card ─────
       Any page container holding a .page-frame stops being the scroller and
       becomes a column: the band takes its natural height, the frame takes the
       rest, and .page-scroll inside does the scrolling. BOTH breakpoints — a
       phone gets the same fused band and internal scroller a desktop does. That
       is the Members panel's shape, which is the one that reads best on a phone
       and the reason every page is being brought to it.
       (Site Config has its own plumbing below — its frame sits two wrappers deep,
       inside .ss-panel inside .settings-shell, so :has() has to look further.) */
    .app-section.active > .app-container:has(> .page-frame) {
      display: flex; flex-direction: column; overflow: hidden;
      scrollbar-gutter: auto;   /* the container no longer scrolls — don't reserve a gutter it can't use */
    }
    .app-container > .page-frame { flex: 1 1 0; min-height: 0; width: 100%; }
    .page-head { flex: none; }
    /* Band and card are two elements that read as ONE object, so square the corners
       where they meet. Written on the ADJACENT SIBLING rather than on a child of the
       container: a page may wrap the pair in a layout column of its own (Submit does),
       and the seam is a fact about the two elements, not about their parent. (Site
       Config fuses the identical seam with .hub-fused — there the band is not adjacent
       to the card, it sits above a whole stack of switchable panels.) */
    .page-head + .page-frame { border-top-left-radius: 0; border-top-right-radius: 0; border-top: none; }
    /* The bar's search fill. A list page has no lede — the search box takes that slot
       and grows; the count and the actions keep the places they hold on every other
       screen, so the eye finds them in the same corner whatever page it is on. */
    .page-bar > .page-search { flex: 1 1 auto; min-width: 0; }
    .page-bar > .page-search input { width: 100%; box-sizing: border-box; }

    /* ── THE BAND'S SECOND ROW: tabs OR a trail, never both ─────────────────
       A band's second row answers one question, and which question depends on where
       you are:

         .page-tabs   "which screen?"  — you are CHOOSING. Branding's Logos/Text/Colors,
                      Help's Guide/Feedback, Contract's Document/Search.
         .page-crumb  "where am I, and how do I get back?" — you have DRILLED IN. You
                      are in a place now, not at a tab selector, so the tabs go away and
                      the trail takes their row.

       They occupy the SAME slot at the SAME height, so drilling into a member or a page
       doesn't shove the content below it down a row — and coming back doesn't pull it up.
       That symmetry is the whole point: the band is a fixed object whose second row
       changes meaning, not a stack that grows. (Before this, a drill-down kept the tab
       strip it had just left AND added a back row underneath — two rows spent telling you
       about somewhere you no longer were.)

       The trail itself is the Designer's, promoted: a bordered Back button, then
       ancestor › ancestor › where-you-are. */
    /* The border-top and the gap above it are for the ONE mount that is not in a band:
       .dtl-crumb, under the member detail's identity block on a phone, where there is no
       title row to draw a line for it. In a band the row above has already drawn it — see
       the rule under the trail, and THE BAND'S HAIRLINE. */
    .page-crumb { display: flex; align-items: center; gap: 12px; min-height: var(--page-tab-h, 41px);
      margin: var(--hub-head-gap) calc(-1 * var(--pad-card)) 0; padding: 0 var(--pad-card); border-top: 1px solid var(--border); }
    .page-head > .page-crumb { margin-top: 0; border-top: none; }
    /* A band that is ONLY a trail — the Designer, where the last crumb already names the
       page you're editing, so a title row above it would be a second name for one screen.
       No title means no gap to clear and no hairline at all. */
    .page-head.bare { padding-top: 0; padding-bottom: 0; }
    /* A back link that hides (Requests only shows one when you drilled in from the
       dashboard) must take its whole row with it — an empty 40px strip with a hairline
       reads as a broken band. */
    .page-crumb:not(:has(> :not(.hidden))) { display: none; }
    /* …and when it IS showing, it owns the band's bottom edge, exactly as .page-tabs
       does — no padding under it, so a crumb band is the same height as the tab band it
       is interchangeable with. (This used to need a rule: the head carried 13px of its
       own padding-bottom for the title-only case, which became slack under "← Back" —
       25px below the label against 12 above it, and a band 14px TALLER than the tab
       band. Drilling into a member shoved the whole list down 14px and coming back
       pulled it up, which is the exact shove the shared row height prevents. The gap
       belongs to the title row now, so there is nothing left to cancel.) */
    /* The member detail's own crumb row is OFF by default. It exists for exactly one
       case — the phone's quick Members visit, where the band that would carry this
       screen's crumb is hidden (see the members-quick rule in the phone block). Every
       other context gets its Back from the band itself, and two ways back on one screen
       is one too many. */
    .dtl-head > .dtl-crumb { display: none; }
    /* Back stays the app's SUBTLE TEXT LINK — deliberately not the Designer's bordered
       button. Leaving is not an action you want to advertise with a box; the trail beside
       it already says where you'd land, and a bordered control in the band would compete
       with the screen's real primary action in the bar below. */
    /* Written unscoped, not as `.page-crumb .crumb-back`: the request detail puts its
       Back in the band's TITLE row rather than in a crumb row, and one Back should not
       become two components over where it is mounted.

       Doubled class for the same reason .card-sec.card-sec is doubled: a promoted back
       BAR keeps its .back-btn class (other rules still key on it), and .back-btn is
       declared later in this file with `margin-bottom: 14px`. As a plain `.crumb-back`
       this tied and lost, which pushed the control 14px up its own row — 5px of space
       above it against 19 below. */
    .crumb-back.crumb-back { display: inline-flex; align-items: center; gap: 4px;
      border: none; background: none; padding: 0; margin: 0; font-family: inherit;
      font-size: var(--fs-md); color: var(--muted); font-weight: var(--fw-semi); cursor: pointer; }
    .crumb-back:hover { color: var(--text); }
    .crumb-back .ic { flex: none; }
    .crumb-back .ic svg { width: 15px; height: 15px; }
    .page-crumb .crumbs { min-width: 0; overflow: hidden; }

    /* ── THE STACKED BODY — a screen whose work is a LIST ───────────────────
       (The shape itself is documented once, above, under THE PAGE FRAME. What
       follows is the list variant and the three rules it encodes.)

       1. THE BAR IS ONE ROW. It used to be a description paragraph with a button
          row stacked above or below it. That cost ~40px of the fold on every
          single screen and put the primary action underneath copy nobody re-reads
          after the first visit. Lede left, actions right, one hairline.

       2. ONLY THE LIST SCROLLS. The lede, the "+ Add" button and the Save bar are
          all fixed, so a 60-building list never pushes its own controls off screen
          — you are never scrolling to find the button you came for.

       3. EVERY EDIT IS SAVE-GATED — in Site Config, where a screen has a
          `.page-foot`. No screen in there half-commits. See the staged-list engine
          (CFG_LISTS) in app.js: list rows edit a working copy and the footer's Save
          is the ONLY thing that writes. The old mix — rename saved instantly, a
          toggle below it saved instantly, but the form above it needed Save — is
          exactly the confusion this removes. A member-facing list (Requests, the
          Directory) commits nothing, so it has no footer and this rule doesn't
          apply to it: the card simply ends on its own bottom edge. */
    .page-frame .page-body.page-stacked { display: flex; flex-direction: column; overflow: hidden; }
    /* Same fixed-two-line box as .page-head-row, and CENTERED for the same
       reason: sized for the longest lede so the list below starts on the same
       pixel on every screen, with the contents centered so a one-line lede
       doesn't hang off the ceiling while its "+ Add" button floats mid-air. */
    /* The height is TWO LINES OF LEDE PLUS THE PADDING, written out of the type
       ladder rather than as a magic number — box-sizing is border-box app-wide,
       so a min-height that forgot the padding fit two lines on paper and only
       one in the browser. That is exactly what it did: a one-line screen
       (Contract) sat at 42px and a two-line screen (Request Types) grew to 52px,
       so the divider — and the list under it — moved as you changed tabs.
       Border-box counts the hairline too, hence the trailing 1px: leave it out
       and the screens that DO fill two lines sit exactly one pixel lower. */
    .page-bar { --page-bar-h: calc(2 * var(--fs-md) * var(--lh-body) + 13px + 1px);
      flex: none; display: flex; align-items: center; gap: 18px; min-height: var(--page-bar-h); padding-bottom: 13px; margin-bottom: 14px; border-bottom: 1px solid var(--border); }
    /* (The dead band between the bar's rule and the scroller is corrected on phones
       only — see THE LIST DISAPPEARS AT THE RULE in the max-width:760 block. Desktop
       keeps the margin outside the scroller on purpose: its lists are capped by a
       sticky <thead>, so content already slides behind the header instead of
       evaporating in open card, and moving the gap inside the scrollport there would
       leave a 14px strip ABOVE the pinned cap for rows to scroll through.) */
    /* Two lines, then ellipsis — the head can't grow without moving the list.
       The margin reset is written at .page-bar specificity on purpose: the
       generic paragraph rule in this section beats a bare `.page-lede { margin:0 }`
       and puts 20px under the copy, which reads as a head that isn't centered. */
    .page-bar .page-lede { flex: 1 1 auto; min-width: 0; margin: 0; color: var(--muted); font-size: var(--fs-md); line-height: var(--lh-body); display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; }
    .page-lede b { color: var(--text); font-weight: var(--fw-semi); }
    /* Count sits WITH the actions, not in the footer: it's a fact about what
       you're looking at, and the footer is reserved for what saving will do.
       Fixed width, right-aligned: "4 types" and "15 grade levels" differ by half
       an inch, and without the reserve the button beside them started somewhere
       new on every screen. It reads as a column of counts, not a jitter. */
    .page-count { flex: none; width: calc(96px * var(--fs-scale, 1)); text-align: right; color: var(--muted); font-size: var(--fs-sm); white-space: nowrap; }
    /* ── THE HEAD'S ACTION CLUSTER — one size, every screen ────────────────
       The rule: every control in .page-acts is --page-act-h tall and at least
       --page-act-w wide, whatever it says and whatever screen it's on.

       Both halves earned their way in. HEIGHT: the cluster mixes buttons with a
       text input (Class Subjects' filter), and padding-sized controls of
       different types never land on the same height — the filter sat a pixel
       proud of the button beside it. Height is set here and the padding gives
       way, so type of control stops mattering.

       WIDTH: the labels run from "+ Add Position" to "+ Add Request Type", so
       the primary action — the most-used control on the screen — landed in a
       different place on each of the five, and moved again the moment a label
       was reworded. A min-width sized to the longest label pins it, exactly as
       .page-foot does for Save. Wider labels still grow; nothing clips. */
    .page-acts { --page-act-h: calc(33px * var(--fs-scale, 1)); --page-act-w: calc(168px * var(--fs-scale, 1));
      flex: none; display: flex; align-items: center; gap: 8px; }
    .page-acts > .btn, .page-acts > input, .page-acts > select {
      height: var(--page-act-h); min-width: var(--page-act-w); padding-top: 0; padding-bottom: 0; box-sizing: border-box; }
    .page-acts > .btn { justify-content: center; }
    /* An input's min-width is its FULL width — a filter box has no label to size
       it, so it takes the same slot a button would. */
    .page-acts > input { width: var(--page-act-w); }
    /* The scroller bleeds to the card edge so the scrollbar rides the card's
       inner wall rather than floating 22px in from it.
       `scrollbar-gutter` is left at its default (auto) here so that non-table
       panels — Announcements, System Emails, etc., which have individual bordered
       items rather than a bordered outer table box — are full-width and align with
       the + Add button in .page-bar. Without this, the ~15px stable gutter
       reservation permanently narrows item borders below the button's right edge.
       Table-pattern panels (.list-head + .page-scroll) explicitly set
       `scrollbar-gutter: stable` in the rule below — they need it because .list-head
       is a SIBLING of the scroller and must reserve the same gutter or column labels
       slide off their columns when a real scrollbar appears (measured: 15px at the
       action slot, 12px at Phone). The table's outer border sits at the card content
       edge regardless, so the stable gutter is invisible at the card level. */
    /* The bleed is written against THE GUTTER, not against 22. It used to be a
       literal, and the phone gutter is 14 — so on a phone the scroller reached
       8px past the card's inner wall and .page-frame's overflow:hidden clipped
       that much of the scrollbar off. Same value on both sides now, by
       construction. */
    .page-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; margin-right: calc(-1 * var(--pad-card)); padding-right: var(--pad-card); }
    /* A GRID scroller must pin its rows to CONTENT — `auto` rows will not.
       .page-scroll is `flex: 1 1 auto; min-height: 0`, so whenever its content is
       taller than the space left on the line, the flex line squashes it to fit.
       A block scroller answers that by overflowing — which is the entire point of
       a scroller. A grid container answers it by re-resolving its `auto` rows
       against the definite height it was just handed: the Local Exchange's three
       356px rows became three 125px rows, every card's cover art was crushed to
       0, and the card's own `overflow: hidden` clipped the perk off the bottom.
       Worst of all it did NOT scroll — the rows shrank instead of overflowing, so
       scrollHeight equalled clientHeight and there was nothing left to scroll to.
       `max-content` says a row is as tall as its tallest card and nothing may
       argue; the overflow then lands on the scroller, where it belongs. Cards
       still stretch to the row, so a row stays visually even.
       Every grid that is also a scroller belongs on this line. */
    .biz-grid.page-scroll, .supply-grid.page-scroll { grid-auto-rows: max-content; }
    /* A titled block INSIDE a screen — the settings that belong to a list but
       aren't part of it (Rep Coverage Target under Hierarchy; the request
       options under Request Types). Boxed so it reads as its own card, but it
       shares the screen's ONE Save: two save bars on one screen would put us
       straight back to "which button saves which half?". */
    .page-section { margin-top: 16px; padding: 15px 17px; background: var(--bg); border: 1px solid var(--border); border-radius: 10px; }
    .page-section:first-child { margin-top: 0; }
    .page-sec-head { display: flex; align-items: center; gap: 12px; margin-bottom: 9px; }
    .page-sec-title { margin: 0; flex: none; font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--text); }
    .page-sec-head .page-sec-desc { margin: 0; flex: 1 1 auto; min-width: 0; color: var(--muted); font-size: var(--fs-sm); line-height: var(--lh-body); }
    .page-section > .page-sec-body > * + * { margin-top: 10px; }
    /* A single setting that belongs to the list rather than beside it: one line,
       sitting between the head and the column labels, so it reads as part of the
       screen instead of a second card competing with it. Never grows past a line
       — if a screen needs more than one, it earns a .page-section. */
    /* Air, not a rule: the head's hairline is directly above and the column labels'
       is directly below, so a third line here would box the checkbox in. It gets
       its space from the margins instead — near-equal above and below, so it reads
       as its own row rather than a caption on whichever hairline it sits nearer.
       Written at .page-stacked specificity because .mask-chk — the shared checkbox
       this borrows its control from — is declared LATER in this file with
       `margin: 11px 0 0`, and a bare `.list-opt` ties with it and loses. That is
       what put a wide gap above the row and none below it.
       The head already contributes 14px of its own margin below the hairline, so
       the top margin is the remainder: 14 + 2 above, 16 below. */
    /* It rides the ROW GRID, not the card edge. An option above a list is still a
       line in that list's table: its checkbox takes the lead slot — so it lands on
       the same centre as every icon below it — and its label starts at the content
       column, level with the column header and with the first field in every row.
       Left-aligned to the padding edge instead, the checkbox sat 9px left of the
       icon column and the label 11px left of the names, touching neither, which is
       the "it's not where I want it" feeling: it was indented to nothing. */
    .page-stacked .list-opt { display: grid; grid-template-columns: var(--list-lead) minmax(0, 1fr); gap: 0 14px;
      flex: none; align-items: center; margin: 2px 0 16px; padding: 0 12px; font-size: var(--fs-md); }
    .list-opt input { margin-top: 0; justify-self: center; }
    .list-opt b { color: var(--text); font-weight: var(--fw-semi); }
    .page-section .set-desc { margin: 6px 0 0; font-size: var(--fs-sm); }
    /* ONE empty state for the whole area (it used to be three: an inline-styled
       div, an .empty > p, and a .cms-empty). Dashed outline = "a thing goes
       here", and it names the button that fills it. */
    .list-empty { display: grid; place-items: center; gap: 5px; padding: 32px 18px; text-align: center; border: 1px dashed var(--border); border-radius: 10px; color: var(--muted); font-size: var(--fs-sm); line-height: var(--lh-body); }
    /* An empty list FILLS the space its card was given, so the message sits in the
       middle of the screen it belongs to instead of hugging the ceiling with a
       column of blank card under it. Gated on :has() to the case where the empty
       state is the ONLY thing in the scroller — a list with rows keeps the plain
       block flow, because flex items in a scroller can shrink and these rows have
       a fixed height to defend. */
    .page-scroll:has(> .list-empty:only-child),
    .page-scroll:has(> *:only-child > .list-empty:only-child) { display: flex; flex-direction: column; }
    .page-scroll > .list-empty:only-child,
    .page-scroll > *:only-child:has(> .list-empty:only-child) { flex: 1 1 auto; display: flex; flex-direction: column; }
    .page-scroll > * > .list-empty:only-child { flex: 1 1 auto; }
    /* Stretched, its two lines are two auto grid rows that split the free space
       between them — the heading floated a quarter down and the sentence sat three
       quarters down, at opposite ends of a tall dashed box. `align-content` packs
       the rows as ONE block in the middle, which is what "centered" meant. */
    .page-scroll > .list-empty:only-child,
    .page-scroll > * > .list-empty:only-child { align-content: center; }
    .list-empty b { display: block; color: var(--text); font-weight: var(--fw-semi); font-size: var(--fs-lg); }
    /* ── THE CONFIG ROW ─────────────────────────────────────────────────────
       One row component for every list in Site Config. Before this there were
       four, and you met all four walking one hub:

         Hierarchy      naked 34px flex row, no separator, no hover
         Request Types  same but 8px taller and wrapping
         Class Subjects a 46px row with hairline separators
         Buildings      a 62px bordered white card + 8px gap, with hover

       Four answers to "what does a row look like" inside two menus. Now: one
       height, one hairline, one hover, one focus ring, whatever the row holds.

       The separator is an inset box-shadow, not a border, so it costs zero
       layout height and the hover fill can round its corners over it.

       Buildings lost the most: 70px per row (card + gap) became ~45px, so a
       district's list shows about a third more schools before you scroll —
       which is the whole point of the fixed head and pinned footer above. */
    /* ONE height, set here and nowhere else. min-height alone wasn't enough: a
       row of text (Buildings) sat at the 38px floor while a row of inputs
       (Hierarchy, Request Types) was pushed past it by its own control, so the
       lists drifted a couple of pixels apart tab to tab. The control is sized to
       the row below (.list-in / .list-sel), not the other way round.
       (It stays min-height, not height: the Buildings row restacks to three lines
       on a phone and has to be allowed to grow there.) */
    /* 44px, up from 40 — the growth half of the space standard, spent here
       because a config list is the densest thing in the app and the first place
       reclaimed space is worth anything. The control inside stays 30px, so the
       row's own breathing goes 5px above and below to 7: the number that moved
       is the AIR, not the control. 44 is also the standard touch target. */
    .list-row { --list-row-h: calc(44px * var(--fs-scale, 1));
      display: flex; align-items: center; gap: 10px; min-height: var(--list-row-h); padding: 5px 12px; border-radius: 8px; background: none; transition: background .12s; }
    .list-row + .list-row { box-shadow: inset 0 1px 0 var(--border); }
    .list-row:hover { background: var(--bg); }
    .list-row:hover + .list-row, .list-row:hover { box-shadow: none; }
    /* Rows that OPEN something (a building's details) rather than edit in place. */
    .list-row-link { cursor: pointer; }
    .list-row-link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
    /* ── THE THREE ZONES, AND THE ONLY PLACE THEIR WIDTHS LIVE ───────────────
       Fixed-width slots so the eye tracks one column down a list, and down every
       list: the leading mark (grip · lock · icon · icon-picker), the content, then
       the row's end — actions, then a rail for "this row opens something".

         --list-lead      the leading mark
         --list-act-slot  the actions cluster (a 26px delete button + breathing room)
         --list-chev      the opens-something rail
         --list-end       slot + chev — what every screen's --list-cols ends with

       THE CHEVRON RAIL IS RESERVED ON EVERY LIST, used or not. Buildings is the
       only list today whose rows open a detail screen, and it used to pay for its
       chevron out of its own end column (16px) while the actions cluster inside
       needed 30 — so its delete button rendered 20px wide instead of 26 and 14px
       left of the delete button on every other tab. Reserving the rail everywhere
       costs the other lists 16px and buys the one thing this section is for: the
       same control in the same place on every screen. A list that grows a detail
       screen later fills the rail; nothing else moves.

       The markup for both slots is built in ONE place (cfgLead / cfgLeadCtl /
       cfgEnd / cfgDelBtn in app.js) — a screen that writes its own is the drift. */
    /* Declared on .list-cols as well as .page-stacked so a list that isn't inside a config
       card — the Members roster — pulls the same numbers from the same place rather
       than restating them. */
    /* --list-glyph is published on :root as well, because the config screens are not the
       only place that draws a glyph at this scale — the hub tab strips sit inches away
       from these rows and were drawing theirs at 16px against the rows' 19px. */
    :root { --list-glyph: calc(19px * var(--fs-scale, 1)); }
    .page-stacked, .list-cols { --list-lead: calc(38px * var(--fs-scale, 1)); --list-act-slot: calc(30px * var(--fs-scale, 1));
      --list-chev: calc(16px * var(--fs-scale, 1)); --list-end: calc(var(--list-act-slot) + var(--list-chev));
      --list-mark: calc(28px * var(--fs-scale, 1)); --list-glyph: calc(19px * var(--fs-scale, 1)); }
    .list-row-lead { flex: none; width: var(--list-lead); display: grid; place-items: center; color: var(--muted); }
    /* ONE glyph size for every lead mark, drawn or clickable. Class Subjects' icon
       picker is a 28px button around a 19px glyph, and a marker used to be a bare
       16px one — so walking from Buildings to Class Subjects, the icon column
       visibly changed weight. The picker is the bigger of the two and a hit target
       can't shrink to match a decoration, so the marker grew instead: the GLYPHS
       are now the same size on every list and only the button's chrome differs,
       which is the honest difference (one is pressable, the other isn't).
       (.lic is 1em, so without a size here each list's icon quietly took its own
       font-size — that was a further 15-vs-16px drift underneath.) */
    .list-row-lead > .lic,
    .list-row-lead > button > svg { width: var(--list-glyph); height: var(--list-glyph); }
    /* The control's box. Sized here so a lead control can't set its own — see
       .subj-icon-btn, which now brings only its border and hover. */
    .list-row-lead > button { width: var(--list-mark); height: var(--list-mark); }
    /* The drag grip is a TEXT character, so the token has to reach it as a
       font-size or it sits at its own --fs-xl (16px) next to 19px glyphs — the
       same mismatch, one list over. Scoped to the lead slot: the page designer
       uses this grip too and isn't on this scale. */
    .list-row-lead > .pg-blk-grip { font-size: var(--list-glyph); line-height: var(--lh-none); }
    .list-row-end { flex: none; width: var(--list-end); display: flex; align-items: center; }
    .list-row-acts { flex: 1 1 auto; display: flex; align-items: center; gap: 6px; justify-content: flex-end; }
    .list-chev { flex: none; width: var(--list-chev); display: grid; place-items: center; color: var(--muted); }
    .list-chev > .lic { width: calc(16px * var(--fs-scale, 1)); height: calc(16px * var(--fs-scale, 1)); }
    /* The chevron is the row's own affordance, so it lights with the row, not on
       its own hover — the whole row is the click target. */
    .list-row-link:hover .list-chev, .list-row-link:focus-visible .list-chev { color: var(--accent); }
    /* ONE field treatment for every editable cell in Site Config — same height,
       same border, same radius, and a focus RING (the app's existing "focused"
       vocabulary, borrowed from .subj-icon-btn) rather than four different
       border-colour-only focus states.
       The height is FIXED, not padding-derived, for the same reason .page-acts
       fixes its controls: an input and a select padded identically still land on
       different heights, and whichever was tallest set the height of the row it
       was in. 30px + the row's 5px padding either side = the 40px row above. */
    .list-in, .list-sel { height: calc(30px * var(--fs-scale, 1)); padding: 0 10px; border: 1px solid var(--border); border-radius: 7px; font-family: inherit; font-size: var(--fs-md); line-height: var(--lh-snug); background: var(--card); color: var(--text); }
    .list-in { flex: 1 1 auto; min-width: 0; }
    .list-sel { flex: none; max-width: 100%; }
    .list-in:focus, .list-sel:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
    .list-in:disabled, .list-sel:disabled { background: var(--surface-2, #f4f5f5); color: var(--muted); cursor: default; }
    /* ── COLUMNIZED ROWS ──────────────────────────────────────────────────
       THE RULE: a row carrying MORE THAN ONE data point is columnized across
       the row's full width, under a header that names the columns. A row with
       one data point (a grade level is just a name) stays a plain flex row and
       gets no header — a header over one column is noise.

       Header and rows share ONE grid definition, `--list-cols`, set once on the
       screen's .page-stacked. That's the whole point: a column can't be widened in
       the rows and left narrow in the header, because there is only one place
       to widen it. Screens declare their columns below; nothing else does.

       This is what replaced the prose that used to live INSIDE rows — Request
       Types read "Grievance [routes to →] Jane Rep" on every single row, which
       is a column label repeated once per row instead of printed once at the
       top. */
    /* (The zone tokens used to be declared here, on .list-cols only, and a second
       time inside .list-row-end as a min-width. They're on .page-stacked now — see THE
       THREE ZONES — so a flex list and a grid list read the same numbers.) */
    .list-cols .list-row, .list-cols .list-head { display: grid; grid-template-columns: var(--list-cols); gap: 0 14px; align-items: center; }
    /* Per-screen column definitions — the ONE place each list's columns live. */
    /* Rep Coverage is a real column even though exactly one row can fill it: the
       setting belongs to the Building Representative title, and a column under a
       named header is how this screen says "this value is about THIS row". The
       column only exists when that title does (a state HQ workspace has no
       buildings) — renderPositions sets .pos-has-cover. */
    .ss-panel[data-panel="positions"] .page-stacked { --list-cols: var(--list-lead) minmax(0, 1fr) calc(140px * var(--fs-scale, 1)) var(--list-end); }
    .ss-panel[data-panel="positions"] .page-stacked.pos-has-cover { --list-cols: var(--list-lead) minmax(0, 1fr) calc(140px * var(--fs-scale, 1)) calc(168px * var(--fs-scale, 1)) var(--list-end); }
    .ss-panel[data-panel="rtypes"]    .page-stacked { --list-cols: var(--list-lead) minmax(0, 1fr) minmax(0, 1.35fr) var(--list-end); }
    .ss-panel[data-panel="subjects"]  .page-stacked { --list-cols: var(--list-lead) minmax(0, 1fr) var(--list-end); }
    /* Same three slots as Class Subjects — the two option-list screens are one
       shape in the markup (renderOptList), so they are one shape here too. */
    .ss-panel[data-panel="grade-levels"] .page-stacked { --list-cols: var(--list-lead) minmax(0, 1fr) var(--list-end); }
    /* Automated Emails' mapping table: the email, then the design it uses. The one
       list in the section that ends WITHOUT --list-end — its rows carry no action
       and open no screen, and the column is 430px wide. Reasoning in full up in
       AUTOMATED EMAILS. */
    .ss-panel[data-panel="sysmail"] .page-stacked { --list-cols: var(--list-lead) minmax(0, 1fr) minmax(0, 1.1fr); }
    .ss-panel[data-panel="buildings"] .page-stacked { --list-cols: var(--list-lead) minmax(140px, 1.3fr) minmax(0, 2.2fr) minmax(110px, 0.9fr) var(--list-end); }
    /* Buildings is the one list with more columns than a phone can hold, so its
       three data cells stack between the two fixed slots. It restacks by REDEFINING
       --list-cols, not by overriding grid-template-columns, for two reasons: the
       column definition stays in the one place this block promises it lives, and
       the override actually lands. It used to sit up in the Buildings block, 1,500
       lines earlier, where `.page-stacked.list-cols .bl-row` tied on specificity with
       `.page-stacked.list-cols .list-row` above and LOST on source order — so the phone
       layout half-applied: the icon and the end slot spanned three rows while the
       grid underneath them was still the five desktop columns. */
    /* Every cell is placed EXPLICITLY. Spanning the two fixed slots down three rows
       and letting the data cells auto-place put them in rows 4-6 — the grid grew to
       six rows and the text sat under the icon instead of beside it. */
    @media (max-width: 640px) {
      .ss-panel[data-panel="buildings"] .page-stacked { --list-cols: var(--list-lead) minmax(0, 1fr) var(--list-end); }
      .page-stacked.list-cols .bl-row { gap: 2px 12px; align-items: start; }
      .bl-row .list-row-lead { grid-column: 1; grid-row: 1 / span 3; align-self: start; margin-top: 2px; }
      .bl-name  { grid-column: 2; grid-row: 1; }
      .bl-addr  { grid-column: 2; grid-row: 2; }
      .bl-phone { grid-column: 2; grid-row: 3; }
      .bl-row .list-row-end { grid-column: 3; grid-row: 1 / span 3; align-self: center; }
      .bl-cell { white-space: normal; }
    }
    /* Connections → Accounts — THE CONFIG ROW, same as Add-Ons: mark · what it is ·
       what's on the other end · the one control. It used to be three bespoke cards in a
       three-column grid, a component that appears on no other screen in the app, holding
       two thirds of a 1500px page empty below them.
       STATUS IS A COLUMN because status is what the screen is for: the question you open
       it with is "which of these is actually on", and a column is one place to run your
       eye down instead of three sentences written three ways.
       The end slot holds a text button, not the default 30px icon button, so
       --list-act-slot widens — on the element that declares --list-end (the .page-stacked),
       never on the row, because a custom property resolves its var()s where it is
       declared and by row time --list-end has already computed. See THE THREE ZONES. */
    /* Status is a FIXED column, not a share of the leftovers: sized to its longest
       reading and parked against the action slot, so it sits beside the control it
       explains instead of floating in the middle of the row. The account column takes
       whatever is left, which is where the long copy wants to go anyway. */
    .ss-panel[data-panel="connections"] .page-stacked {
      --list-act-slot: calc(120px * var(--fs-scale, 1));
      --list-cols: var(--list-lead) minmax(0, 1fr) calc(228px * var(--fs-scale, 1)) var(--list-end);
    }
    /* One control, one size. "Connect" and "Set Up" are the same act on two rows, and a
       button that sizes to its own label makes them look like two different kinds of
       thing. The slot sets the width; the label centres in it. */
    .ss-panel[data-panel="connections"] .list-row-end .btn { min-width: calc(112px * var(--fs-scale, 1)); justify-content: center; }
    /* ONE HEIGHT FOR ALL THREE. The config row's height is a variable for exactly this
       reason, so this is a taller value of the shared rule rather than a rule of our own:
       --list-row-h IS the min-height. Every row holds the same two lines (name + one-line
       description) and nothing else — there is no fine-print slot, so this height is a
       floor every row genuinely reaches instead of space reserved for a line most rows
       don't have. Descriptions are held to one line at desktop (see CONN_SOURCES) and
       clamped to two anywhere else, so nothing can outgrow it either. */
    .conn-row { --list-row-h: calc(58px * var(--fs-scale, 1)); }
    .conn-row .feat-desc { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
    /* A brand mark, not a glyph: these rows name other people's products, and their own
       marks are how you find the one you want. One box, whatever the artwork is. */
    .conn-row .conn-mark { display: grid; place-items: center; }
    .conn-row .conn-mark > img, .conn-row .conn-mark > svg { width: 22px; height: 22px; object-fit: contain; display: block; }
    .conn-state { display: inline-flex; align-items: center; gap: 8px; min-width: 0; font-size: var(--fs-md); color: var(--muted); line-height: var(--lh-body); }
    /* nowrap is what makes the ellipsis actually engage — and it is why connecting an
       account cannot change a row's height. The status is the one cell whose content is
       not ours: "Sending as someone@district.k12.oh.us" in a 228px column wraps to three
       lines and pushes the row past its floor. It stays on one line and truncates. */
    .conn-state > span:last-child { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .conn-state b { color: var(--text); font-weight: var(--fw-bold); }
    /* The dot is the state; colour alone would leave it unreadable to a good number of
       people. Live gets the halo, off is a plain neutral dot, unavailable is hollow —
       three different shapes before you get to three different colours. */
    .conn-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--neutral); flex: none; }
    .conn-on { color: var(--text); }
    .conn-on .conn-dot { background: var(--ok); box-shadow: 0 0 0 3px var(--ok-bg); }
    .conn-na .conn-dot { background: transparent; box-shadow: inset 0 0 0 1.5px var(--neutral); }
    .conn-load .conn-dot { background: var(--border); }
    /* Phone: the status drops under the name, between the two fixed slots — the same
       restack Buildings does, done the same way (redefine --list-cols, don't override
       grid-template-columns) so the column definition stays in one place. */
    @media (max-width: 700px) {
      .ss-panel[data-panel="connections"] .page-stacked { --list-cols: var(--list-lead) minmax(0, 1fr) var(--list-end); }
      .conn-row { gap: 4px 12px; align-items: start; }
      .conn-row .list-row-lead { grid-column: 1; grid-row: 1 / span 2; margin-top: 2px; }
      .conn-row .feat-meta { grid-column: 2; grid-row: 1; }
      .conn-row .conn-state { grid-column: 2; grid-row: 2; }
      .conn-row .list-row-end { grid-column: 3; grid-row: 1 / span 2; align-self: center; }
    }
    /* Communications Setup runs on the same standard — a calendar source is a
       label and an ID, so it columnizes exactly like a request type. */
    .ss-panel[data-panel="calendars"] .page-stacked { --list-cols: var(--list-lead) minmax(0, 1fr) minmax(0, 1.6fr) var(--list-end); }
    /* The Security Log. Read-only, so nothing lands in the end slot — but it is still
       reserved, so the log's right edge is the right edge of every other list. */
    .ss-panel[data-panel="audit"] .page-stacked { --list-cols: var(--list-lead) calc(158px * var(--fs-scale, 1)) minmax(0, 0.9fr) minmax(0, 1fr) calc(112px * var(--fs-scale, 1)) minmax(0, 1.5fr) var(--list-end); }
    /* A content page: kind mark, title + menu id, then THREE READERSHIP COLUMNS, then
       readers/open/delete. The numbers used to ride the title's meta line as one grey
       sentence — "Menu id: about-sea · 2 opens this week · 1 member · 100% read to the
       end" — which is true and unreadable: telling two pages apart meant reading two
       paragraphs and holding four figures in your head. As columns the digits line up
       down the list, which is the whole reason a table beats a sentence.
       Three buttons in the end slot now, so --list-act-slot widens again (on this
       element — see the Add-Ons note). */
    /* The tracks are sized to the WORDS, not just to the figures: .list-th ellipsises,
       so a column narrower than its own label silently becomes "READ TO E…" — a header
       you cannot read is worse than no header. Each width is the larger of its label
       and its content (a 52px trend line + its number; a 34px ring + its clearance). */
    /* THE SLACK GOES IN ITS OWN TRACK, between the data and the controls. With the
       title column set to 1fr it swallowed every spare pixel, so the three figures got
       pushed to the far right and ended up reading as part of the action cluster —
       a wide grey gap after the page name, then numbers jammed against the buttons.
       The title now takes what it needs up to a cap, the stats sit right after it, and
       an empty flexible track absorbs the rest. */
    .ss-panel[data-panel="pages"] .page-stacked { --list-act-slot: calc(88px * var(--fs-scale, 1));
      --pg-stat-col: calc(118px * var(--fs-scale, 1));
      --list-cols: var(--list-lead) minmax(170px, calc(400px * var(--fs-scale, 1)))
        var(--pg-stat-col) var(--pg-stat-col) var(--pg-stat-col)
        minmax(calc(28px * var(--fs-scale, 1)), 1fr) var(--list-end); }
    /* The template chooser is CARDS in the same frame — no columns to lay out, and no
       column labels above them. */
    .ss-panel[data-panel="pages"] .page-stacked:has(.pg-chooser) { --list-cols: minmax(0, 1fr); }
    @media (max-width: 1100px) {
      /* Narrow: "how many opened it" is the figure a leader scans for, so it is the one
         that stays. Read-to-end is a second-order question and the widest column. */
      .ss-panel[data-panel="pages"] .page-stacked { --list-cols: var(--list-lead) minmax(140px, 1fr) calc(106px * var(--fs-scale, 1)) var(--list-end); }
      .ss-panel[data-panel="pages"] .pg-col-members, .ss-panel[data-panel="pages"] .pg-col-end,
      .ss-panel[data-panel="pages"] .pg-gap,
      .ss-panel[data-panel="pages"] .list-head > .list-th-members, .ss-panel[data-panel="pages"] .list-head > .list-th-end { display: none; }
    }
    /* Add Page / Cancel are the same control in two states — one size, one shape,
       and enough room that neither reads as cramped next to the other. */
    .ss-panel[data-panel="pages"] .page-acts > .btn { padding: 8px 18px; min-width: calc(104px * var(--fs-scale, 1)); justify-content: center; }
    /* The Feedback inbox — the Security Log's table, with one cell that is prose.
       Five columns of data (when · category · who · where they were) under labels, then
       the message takes everything that's left. It used to be ONE 1fr cell holding all of
       it: a bold word, an accent pill, and an eight-item grey sentence of name · email ·
       date · page, wrapped at whatever width the card happened to be — which is a column
       header written out once per row, the exact thing this block exists to stop (see the
       Request Types note above).
       THERE IS NO LEAD TRACK. Category is the first column and the glyph is INSIDE it —
       see .fb-cat below for why two tracks could never be made to read as one thing.
       Everywhere else in the section the lead mark is decoration and the header opens with
       an empty cell; on this list the mark is the row's first piece of data.
       THE FIXED COLUMNS ARE MEASURED, NOT ESTIMATED. Category is 116px because the widest
       reading (glyph + 7px + "Thank-you", plus the lead slot's centring pad) lands at 112.
       When is 104px because the longest date this screen can print is "Dec 28, 12:34 PM" at
       100 — and it is fmtInboxDate's job to keep it that way: printing a year AND a time
       together needs 134 and quietly ellipsized every pre-2026 row when this column was 116.
       When was 158 to begin with, a copy of the Security Log's width, and the category pill
       it replaced was another 96 beside a 38px slot. Anything wider than its longest reading
       is a column charging the one cell that could use the room. */
    .ss-panel[data-panel="feedback"] .page-stacked {
      --list-cols: calc(116px * var(--fs-scale, 1)) calc(104px * var(--fs-scale, 1))
                   minmax(calc(150px * var(--fs-scale, 1)), 1fr) minmax(0, 2.8fr) var(--list-end);
    }
    /* The header label sits over the GLYPH, not over the cell's left edge, because the glyph
       is where the column's content starts (the pad below is the lead slot's centring
       offset, kept so this list's mark lands on the same x as every other list's). */
    .page-stacked .list-head .fb-th-cat { padding-left: calc((var(--list-lead) - var(--list-glyph)) / 2); }
    /* EVERY CELL RULE BELOW IS SCOPED TO .page-stacked, and has to be: .list-cell is
       declared further down this file with `white-space: nowrap; text-overflow: ellipsis`,
       and a bare `.fb-msg` ties with it on specificity and LOSES on source order. Written
       unscoped, the message cell renders as one nowrap line — the clamp measures no
       overflow, so no row is ever expandable and the table silently loses its whole point. */
    /* Cells align to the FIRST line, not to each other's middles: the message is three
       lines and the date is one, and centring the date against it floats a timestamp in
       the middle of a paragraph. Everything starts on the row's top line and reads across.
       Category is the exception — it is one compact mark rather than a line of text, so it
       centres on the row instead of hanging off the top of a three-line paragraph. */
    .page-stacked.list-cols .fb-item { align-items: start; cursor: pointer; }
    .page-stacked .fb-when { color: var(--muted); font-variant-numeric: tabular-nums; }
    /* ONE MARK IN TWO PARTS, IN ONE CELL. The glyph and the word take the same tone from the
       same custom property, set once on the row.
       THEY ARE NOT TWO TRACKS, and that is the fix rather than a tidy-up: as a lead slot
       plus a column beside it they read as two adjacent fields, because the slot centres a
       19px glyph in a 38px box and then the grid adds its own 14px gap — 24px of nothing
       between a mark and the word naming it. A negative margin on the label only pulled it
       back to 16px before it started overlapping the slot. In one cell the gap is 7px and
       chosen, and the padding-left reproduces the slot's centring offset so the glyph still
       lands on the exact x every other list in the section draws its mark on.
       The coloured pill that used to carry the category is gone with it: on every other list
       the lead mark is decoration and a status pill is the only colour going, but here the
       mark IS the category, so tinting it says everything the pill said and the word can
       then just be a word. */
    /* Semantic status tokens, NOT the tenant accent: a category is a fact about the message,
       so it must mean the same thing in every local. --accent-dark was the first try (it is
       what .ev-tag.warn paints with) and on a local whose accent is red it made "Problem"
       read as an alert, on an amber local it collided with the unread dot, and either way
       the meaning of the colour moved with the branding. */
    .page-stacked .fb-item { --fb-tone: var(--neutral); }
    .page-stacked .fb-item.tone-ok   { --fb-tone: var(--ok); }
    .page-stacked .fb-item.tone-warn { --fb-tone: var(--warn); }
    .page-stacked .fb-cat { display: flex; align-items: center; gap: 7px; align-self: center;
      padding-left: calc((var(--list-lead) - var(--list-glyph)) / 2);
      font-size: var(--fs-sm); font-weight: var(--fw-bold); color: var(--fb-tone); }
    .page-stacked .fb-cat-ic { flex: none; display: grid; place-items: center; }
    .page-stacked .fb-cat-ic > .lic { width: var(--list-glyph); height: var(--list-glyph); }
    /* Name over address. They are one fact about one person, so they are one column —
       two would have split a 38-character email away from the name it belongs to and paid
       for the gap out of the message. The address is a real mailto: replying is what an
       inbox is for, and it is the row's only link. */
    .page-stacked .fb-who { white-space: normal; line-height: var(--lh-snug); }
    .page-stacked .fb-who > b { display: block; font-weight: var(--fw-semi); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .page-stacked .fb-mail { display: block; font-size: var(--fs-xs); color: var(--accent-dark); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .page-stacked .fb-mail-none { color: var(--muted); font-style: italic; }
    /* THE ONE CELL WHOSE LENGTH ISN'T OURS. It clamps to three lines so a member who wrote
       an essay can't give one row the height of the whole table, and the row opens on click
       when there is more to see (fbMarkClamped measures which rows those actually are, so
       a message that already fits gets no pointer).
       THE CLAMP LIVES ON THE INNER SPAN, NOT THE CELL, and has to: the cell is a grid item
       (and a flex item once restacked), and a grid/flex item's display is BLOCKIFIED — the
       browser rewrites `-webkit-box` to a plain block box before line-clamp ever applies.
       Written on the cell it silently does nothing: the message wraps to its full height,
       no row overflows, and nothing is ever marked expandable. */
    /* ── UNREAD IS THE ONE STATE THIS INBOX HAS, SO IT IS SAID PROPERLY ────────
       Three signals at once, the way mail does it: a tinted row, a bold name, and the
       message in full ink instead of the softened grey a read one gets. It used to be a
       7px accent dot against a name that was --fw-semi either way, which at a glance is no
       signal — you had to hunt for a circle to find the rows that were the entire point of
       opening the screen.
       EVERY SIGNAL IS A CLASS ON THE ROW, never an element inside it. That is what lets
       markFeedbackRead clear the whole state by swapping one class — the dot was markup, so
       clicking a row dropped the tint and left the dot sitting there next to the date,
       still claiming unread, until something re-rendered the list.
       A BACKGROUND, not the 3px accent spine this had once before: the spine was an inset
       box-shadow, so it REPLACED the row divider, and the rule that put the divider back
       then out-ranked the next unread row's own spine — in any run of consecutive unread
       messages only the first was ever marked. A background composes with the divider
       instead of competing for the same property. Hover still has to win, so it restates
       the tint one step deeper rather than being out-specified into doing nothing. */
    .page-stacked .fb-item.is-new { background: var(--wash-orange, #fff4ec); }
    .page-stacked .fb-item.is-new:hover { background: color-mix(in srgb, var(--accent) 12%, var(--card)); }
    .page-stacked .fb-item.is-new .fb-who > b { font-weight: var(--fw-bold); }
    .page-stacked .fb-item.is-new .fb-msg { color: var(--text); }
    .page-stacked .fb-msg { white-space: pre-wrap; line-height: var(--lh-body); color: var(--ink-soft, #2b2b2b); text-overflow: clip; overflow: hidden; }
    .page-stacked .fb-msg > span { display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
    .page-stacked .fb-msg.is-clamped { cursor: pointer; }
    .page-stacked .fb-msg.is-clamped:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
    .page-stacked .fb-item.is-open .fb-msg > span { display: block; overflow: visible; }
    /* Narrow: the columns can't hold, so the row restacks to two lines — who sent it, then
       what they said — by REDEFINING --list-cols and placing every cell explicitly, the same
       way Buildings and Connections restack (never by overriding grid-template-columns, so
       the column definition stays in one place).
       EXPLICIT placement, not flow: the first attempt at this was flex-wrap + `order`, and
       it put every cell on ONE line, because a flex line only breaks when it is FULL —
       "these belong on their own row" is not something `order` can say.
       The category column keeps its measured width rather than going `auto`: a track sized
       to its own content is sized PER ROW (each .list-row is its own grid), so an "Idea" and
       a "Thank-you" would start the name at two different x's and the list would read ragged
       down the left.
       The column labels go with the columns they named; the box needs its cap back, or the
       table is left open at the top.
       1400px is a VIEWPORT number buying ~1040px of PANEL: this screen sits behind Site
       Config's sidebar, which takes ~360px before the card starts. Read every breakpoint on
       a config panel that way — the naive 1080 this started at left the message column 184px
       wide on a 1280px laptop, which is a table in name only. */
    @media (max-width: 1400px) {
      .ss-panel[data-panel="feedback"] #fb-thead { display: none; }
      .ss-panel[data-panel="feedback"] #fb-thead + .page-scroll { border-top: 1px solid var(--border); border-radius: 10px; }
      .ss-panel[data-panel="feedback"] .page-stacked {
        --list-cols: calc(116px * var(--fs-scale, 1)) minmax(0, 1fr)
                     calc(104px * var(--fs-scale, 1)) var(--list-end);
      }
      .page-stacked.list-cols .fb-item { gap: 3px 12px; align-items: start; }
      .page-stacked .fb-cat  { grid-column: 1; grid-row: 1; align-self: start; }
      .page-stacked .fb-who  { grid-column: 2; grid-row: 1; }
      .page-stacked .fb-when { grid-column: 3; grid-row: 1; justify-self: end; }
      .page-stacked .fb-item .list-row-end { grid-column: 4; grid-row: 1 / span 2; align-self: center; }
      .page-stacked .fb-msg  { grid-column: 1 / span 3; grid-row: 2; margin-top: 3px; }
    }
    /* Phone: ONE content column, three lines — [category … date] / name+address / message.
       The category's fixed column goes away here: at 375px it plus the end slot leave the
       name 67px, so the name and the message each take the full content width instead, and
       the category shares its line with the date at the two ends of one area.
       (This is why the phone case is its own breakpoint rather than more of the one above:
       it isn't a narrower version of that layout, it's a different one.) */
    @media (max-width: 700px) {
      .ss-panel[data-panel="feedback"] .page-stacked { --list-cols: minmax(0, 1fr) var(--list-end); }
      .page-stacked .fb-cat  { grid-column: 1; grid-row: 1; justify-self: start; padding-left: 0; }
      .page-stacked .fb-when { grid-column: 1; grid-row: 1; justify-self: end; }
      .page-stacked .fb-who  { grid-column: 1; grid-row: 2; }
      .page-stacked .fb-msg  { grid-column: 1; grid-row: 3; }
      .page-stacked .fb-item .list-row-end { grid-column: 2; grid-row: 1 / span 3; align-self: center; }
    }
    /* Fill the scrollbar-gutter strip with the row's tint. The bounded table is
       intact — .list-head caps the top, the scroller carries its own border and
       bottom radius, together they close a box (see THE LIST IS A BOUNDED TABLE
       below). What the row's tint stopped 15px short of is the strip
       `.list-head + .page-scroll` reserves at ~line 5301 with `scrollbar-gutter:
       stable`, kept so column labels don't jump when the list grows past its
       scroller. We can't drop the reservation without breaking that alignment,
       but we CAN paint into it: negative margin-right pulls the row's border-box
       out into the gutter, and matching extra padding-right puts the content-box
       back where it was — so grid tracks are unchanged and every cell lands on
       the same x it did before, while the tint (and the row divider inset shadow,
       and hover) all follow the box to the table's right border. Scoped to the
       bounded-table breakpoint: narrower widths hide the head, re-cap the
       scroller as a self-contained box, and the row already sits flush against
       its right border.
       Hardcoded 15px matches what `scrollbar-gutter: stable` reserves for a
       classic scrollbar (there is no CSS var for it). When a scrollbar actually
       renders it will sit over the last 15px of tint — the same fate every
       browser scrollbar accepts on a coloured row. */
    @media (min-width: 1401px) {
      /* overflow-x: hidden is required — .page-scroll has `overflow-y: auto` and
         per CSS spec that promotes the other axis to `auto` too, so the row's
         15px extension into the gutter area counts as scrollable overflow and
         triggers a horizontal scrollbar. Clipping x-overflow instead leaves the
         extension visible (overflow clips at the padding-box edge, and the
         gutter reservation lives inside the padding-box, so nothing here has
         anywhere to go). */
      .ss-panel[data-panel="feedback"] #fb-thead + .page-scroll { overflow-x: hidden; }
      .ss-panel[data-panel="feedback"] #fb-thead + .page-scroll .list-row {
        margin-right: -15px;
        padding-right: calc(12px + 15px);
      }
    }
    /* Column labels sit OUTSIDE the scroller so they can't scroll away from the
       rows they name (same reason the roster's header is a sibling of its list,
       not its first row). Aligned to the row grid, so the label sits over its
       data — and right-aligned labels track right-aligned cells. */
    /* ── THE LIST IS A BOUNDED TABLE ────────────────────────────────────────
       The header and the rows under it are ONE surface: a filled cap with the
       column labels, then the rows inside a box that closes on all four sides.

       It used to be neither. Each row drew a single hairline along its own top
       edge and nothing else — a quarter of a border, four times per screen —
       and the list's only other rule was the one under the column labels. The
       eye read those top edges as unfinished boxes rather than as dividers,
       because a divider needs something to divide INSIDE. Now it has one: the
       hairlines are interior rules of a table, the cap says where it starts,
       and the box says where it ends.

       The head keeps a FIXED height for the same reason .list-row does — Class
       Subjects puts a sort button in its header and that button's 2px of padding
       used to make this band 4px taller on that one screen, which moved the
       whole list down with it. The band's height is set here; a control inside
       it gives way (see .subj-hd-btn). */
    .list-head { --list-head-h: calc(30px * var(--fs-scale, 1));
      flex: none; display: flex; align-items: center; gap: 10px; min-height: var(--list-head-h); padding: 0 12px; overflow: hidden; scrollbar-gutter: stable; background: var(--band); border: 1px solid var(--band-rail); border-radius: 10px 10px 0 0; margin-bottom: 0; font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .1em; color: var(--band-ink); }
    /* The rows' box. `+ *` rather than `+ .page-scroll` because the Members roster
       is the one list whose rows don't live in a .page-scroll — and it is the same
       table, so it gets the same box.

       TWO WAYS INTO THE BOX, one look: a list with column labels (the labels ARE
       the cap), or a headerless list that opts in with .list-boxed. Add-Ons is the
       second kind — six switches need no column header, and a header naming one
       column is noise (see COLUMNIZED ROWS) — but it is still a list of rows and
       was the one screen left drawing them on open card.

       `.list-boxed` is written bare rather than as `.page-scroll.list-boxed` so a
       screen can box a short list that ISN'T the scroller: the one or two settings
       that sit above a table (Request Types' "Needed by" dates, Event Reminders)
       are the same object as an Add-Ons row — a named thing with a switch — and
       were the last places still drawing that as a loose checkbox on open card. */
    .list-head + *, .list-boxed { border: 1px solid var(--border); background: var(--card); }
    .list-head + * { border-top: none; border-radius: 0 0 10px 10px; }
    .list-boxed { border-radius: 10px; }
    /* Boxed, the scroller no longer bleeds to the card wall: the scrollbar rides
       the table's own inner edge. The reserved gutter stays on BOTH elements, so
       the column labels still land on the columns they name (see .page-scroll).

       And it HUGS ITS ROWS instead of growing to fill the card (flex-shrink only).
       An unbounded list has no bottom to draw, so filling was free; a bounded one
       drew its closing edge at the bottom of the card no matter how few rows were
       above it — one calendar source in a box eight rows tall. It still shrinks
       and scrolls the moment the rows outrun the space, which is the case the
       pinned head and footer exist for. */
    .list-head + .page-scroll, .page-scroll.list-boxed { flex: 0 1 auto; margin-right: 0; padding-right: 0; scrollbar-gutter: stable; }
    /* A footnote under the rows is part of the table's box now, so it takes the
       rows' gutter rather than starting flush against the border. */
    .list-head + .page-scroll > .hint, .page-scroll.list-boxed > .hint { display: block; padding: 9px 12px 3px; }
    /* Inside the box a hover is a full-width BAND, not a floating pill — the pill
       was shaped for rows sitting on open card; a row in a table spans it. */
    .list-head + * .list-row, .list-boxed .list-row { border-radius: 0; }
    /* A settings box that is NOT the scroller sits above the list it modifies, so it
       takes the bar's rhythm below it and never grows. */
    .list-boxed.opt-boxed { flex: none; margin-bottom: 12px; }
    /* …and it is NOT part of that list, so it does not take that list's columns.
       `.list-cols .list-row` is set on the PANEL BODY, so every .list-row inside the
       panel inherits the table's tracks — which laid this row out on Request Types'
       `type | routes to | actions` grid: the description stopped at the end of the
       "Request Type" column and the switch parked at the start of "Routes To", with
       half the box empty to its right. Back to the flex row Add-Ons draws, where the
       description takes the width and the switch is pinned to the far edge. */
    .list-cols .opt-boxed .list-row { display: flex; grid-template-columns: none; }
    .list-head .list-row-lead, .list-head .list-row-end { color: inherit; }
    .list-head .list-th { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    /* A plain TEXT cell — the row equivalent of .list-th. Lists whose cells are fields
       don't need it (an input fills its column); lists that display text do, and every
       one of them was writing its own min-width/ellipsis pair. */
    .list-cell { min-width: 0; font-size: var(--fs-md); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    /* Discard anchors left with the other negative actions (see the action-bar
       convention); it only appears while there is something to discard. */
    .page-foot .page-discard { margin-right: 2px; }
    .page-foot .page-discard.hidden { display: none; }
    /* A staged row that Save will CREATE or DELETE, marked while it's pending so
       "nothing happened when I clicked delete" is never the read. */
    .list-staged-new { box-shadow: inset 3px 0 0 var(--ok, var(--accent)); }
    /* Save is waiting on this row for a name. Amber, not red: nothing is wrong
       yet — it just isn't finished, and the placeholder says what to type. */
    .list-row-needs-name { box-shadow: inset 3px 0 0 var(--warn); }
    .list-row-needs-name .list-in { border-color: var(--warn); }
    .bl-unnamed { color: var(--warn); font-style: italic; }
    .list-staged-del { opacity: .5; }
    .list-staged-del .pos-name, .list-staged-del .rt-name, .list-staged-del .bl-nm { text-decoration: line-through; }
    /* ── THE LIST DISAPPEARS AT THE RULE, NOT 14px BELOW IT (phone) ───────────
       .page-bar's 14px of bottom margin sits BETWEEN its rule and the scroller, which
       puts it outside the scrollport — so rows scrolling up vanished at the scroller's
       top edge, leaving a 14px band of empty card between the rule and the place the
       content evaporates. Standing still that reads as breathing room; in motion it
       reads as the list quitting early, a gap short of the line that is supposed to
       stop it.

       Same 14px, moved inside the scroller as padding. At rest nothing moves — the
       first row still sits 14px under the rule. Scrolling, the padding scrolls away
       with the content and rows travel all the way to the rule and disappear against
       it. Measured on both lists: dead band 14px → 0, first row still at 14px.

       PHONE ONLY. The desktop lists are capped by a sticky <thead>, so content already
       slides behind a header rather than into open card, and moving the gap inside the
       scrollport there would leave a 14px strip above the pinned cap for rows to scroll
       through — a worse artifact than the one being fixed.

       And written as `+ .page-scroll`, not on .page-bar alone: the config screens put a
       .list-head between the bar and its scroller, and zeroing the margin there would
       jam the column labels against the rule. This pair only fires where the scroller
       is the bar's immediate next sibling, which is the only shape with the dead band. */
    @media (max-width: 760px) {
      .page-bar:has(+ .page-scroll) { margin-bottom: 0; }
      .page-bar + .page-scroll { padding-top: 14px; }
    }
    @media (max-width: 720px) {
      .page-bar { flex-wrap: wrap; gap: 10px; }
      /* The uniform width is a desktop alignment device — two 168px controls plus a
         gap overflow a phone. Here they share the row instead, which lands them on a
         consistent width again by a different route. */
      .page-acts { width: 100%; justify-content: flex-end; --page-act-w: 0px; }
      .page-acts > .btn, .page-acts > input { flex: 1 1 0; min-width: 0; }
      .page-count { width: auto; }
      /* …and a cluster whose ONLY control is tucked away collapses here. Reserving the
         slot is a DESKTOP device — it stops the count sliding across a single-line bar
         — but this bar wraps, so the cluster is a row of its own, and a tucked button
         bought 43px of blank strip under the count for an alignment nothing can see.
         (Content Pages, while the template chooser is open.) */
      .page-acts:has(> .tuck-hidden:only-child) { display: none; }
    }

    /* ── "There is something to save" ───────────────────────────────────────
       A pinned footer is easy to stop seeing: it looks identical whether or not
       you have unsaved work, so an edit could sit there indefinitely with no
       cue. Two changes, both fired once per clean→dirty transition:

         · the status text names the state ("Unsaved changes"), and
         · the Save button takes a single breath — the same one-beat lift the
           composer plays when you click a block in the preview (.blk-focus).
           Same vocabulary on purpose: in this app, that motion means LOOK HERE.

       Deliberately a single beat, not a loop. A pulsing button is a siren; it
       stops meaning anything by the third repeat and it's exhausting to edit
       next to. The button's own enabled/disabled contrast is what carries the
       state afterward — the breath only marks the moment it changed. */
    .page-status.pending { color: var(--warn); font-weight: var(--fw-semi); display: inline-flex; align-items: center; gap: 7px; }
    .page-status.pending::before { content: ''; width: 7px; height: 7px; border-radius: 50%; background: var(--warn); flex: none; }
    .page-save.page-armed { animation: cformArm 1.05s cubic-bezier(.22,.9,.3,1) both; }
    @keyframes cformArm {
      0%   { transform: scale(1)     translateY(0);    box-shadow: 0 0 0 0 rgba(var(--accent-rgb),0); }
      20%  { transform: scale(1.045) translateY(-2px); box-shadow: 0 0 0 5px rgba(var(--accent-rgb),.22); }
      48%  { transform: scale(.994)  translateY(0);    box-shadow: 0 0 0 8px rgba(var(--accent-rgb),0); }
      68%  { transform: scale(1.008) translateY(-1px); }
      100% { transform: scale(1)     translateY(0);    box-shadow: 0 0 0 0 rgba(var(--accent-rgb),0); }
    }
    /* The cue is decoration; the enabled button and the footer text carry it. */
    @media (prefers-reduced-motion: reduce) { .page-save.page-armed { animation: none; } }
    /* Uniform footer action-button width (text centered) so Save / Send Test Email /
       Revert line up across panels — mirrors the My Profile convention. Destructive
       actions (Remove Contract) share their own wider uniform width. */
    .page-foot > .btn { min-width: 132px; justify-content: center; }
    .page-foot > .btn.ghost-danger { min-width: 160px; }
    /* Outlined buttons (Remove Contract, Revert…) carry a 1px border the filled
       ones don't, so any footer holding a destructive action stood ~3px taller
       than its neighbours — a bar that changes height as you move between
       screens. A transparent border on the filled ones equalizes the box at
       every text scale, without touching either button's look. */
    .page-foot > .btn:not(.ghost):not(.ghost-danger) { border: 1.5px solid transparent; }   /* 1.5px = .btn.ghost-danger's border width */
    /* Platform footers: a destructive/reset action (e.g. Revert to .env) anchors
       LEFT, positive Save stays right — the left-negative / right-positive convention. */
    #section-it .page-foot > .btn.ghost-danger { margin-right: auto; }
    /* Invite footer: same pinned strip, but it holds a short hint + an input row
       (Full name / Email / Role) with Send Invite anchored right — mirroring the
       Save footers. Hidden while a member's detail view is open. */
    .page-foot.inv-foot { flex-direction: column; align-items: stretch; gap: 9px; }
    /* Requests' footer is the Members roster's, minus the collapse — there is only ever
       one control in it. The button takes the full width on a phone, where it is the
       screen's one verb, and sits at the right on a desktop like every other primary
       action in a .page-foot. */
    .page-foot.req-foot { justify-content: flex-end; }
    @media (max-width: 760px) { .page-foot.req-foot > .btn { width: 100%; justify-content: center; padding-top: 11px; padding-bottom: 11px; } }
    /* The phone's stand-in for this whole footer — see #inv-open in index.html. Off at
       every other width, where the form is simply always open. */
    .inv-open { display: none; }
    /* Its counterpart — only meaningful where the form can be collapsed at all. */
    .inv-close { display: none; }
    .inv-foot .inv-foot-hint { font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); }
    .inv-foot .inv-row { display: flex; flex-wrap: wrap; gap: 8px 10px; align-items: flex-end; }
    /* Each control is a labeled field so the two look-alike dropdowns are self-
       evident: "Access Level" (a site permission level) vs "Directory Title" (a
       configured position). Without the captions, "Building Rep" (the writer tier)
       and a "Building Representative" position sat side by side with no cue which
       was which. */
    .inv-foot .inv-field { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
    .inv-foot .inv-field.inv-grow { flex: 1; min-width: 150px; }
    .inv-foot .inv-cap { font-size: var(--fs-xs); font-weight: var(--fw-semi); letter-spacing: .03em; text-transform: uppercase; color: var(--muted); }
    .inv-foot .inv-opt { font-weight: var(--fw-medium); text-transform: none; letter-spacing: 0; opacity: .8; }
    .inv-foot .inv-row input,
    .inv-foot .inv-row select { padding: 8px 11px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-md); font-family: inherit; background: #fff; color: var(--text); }
    .inv-foot .inv-row > .btn { min-width: 132px; justify-content: center; margin-left: auto; }
    .inv-foot .inv-row > .btn:disabled { opacity: .45; cursor: default; }
    .inv-foot #inv-msg { margin: 0; }
    .ud-hint { display: block; font-size: var(--fs-sm); color: var(--muted); margin-top: 4px; line-height: var(--lh-body); }
    /* Pending sign-up, on their detail screen. This was a bare red button wedged 4px
       under the identity header with nothing saying what it did — and what it does is
       exactly what picking a Role below does (both PATCH role, and the server emails
       them on any move out of pending, see routes/user-admin.js). So the block says so:
       what approving means, and that the dropdown is the way to bring them in higher.
       Accent tint, matching the PENDING chip that names this state everywhere else. */
    .ud-pending { margin: 16px 0 4px; padding: 14px 15px; border-radius: 10px;
      background: var(--accent-light); border: 1px solid rgba(var(--accent-rgb), .30); }
    .ud-pending-t { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--accent-dark); }
    /* Both paragraphs are written at .ud-pending .x specificity ON PURPOSE. `.card p`
       (line ~276) sets margin-bottom: 20px and, at (0,1,1), beats a bare single-class
       rule — which left 20px of dead space under the last line and a lopsided gap above
       the button. Same trap the .page-lede comment warns about. */
    .ud-pending .ud-pending-b { margin: 5px 0 0; font-size: var(--fs-sm); line-height: var(--lh-body); color: var(--text); }
    .ud-pending .ud-pending-go { display: flex; width: fit-content; margin: 14px 0 0; align-items: center; gap: 7px; }
    .ud-pending-go .lic { width: 14px; height: 14px; }
    .ud-pending .ud-pending-alt { margin: 14px 0 0; font-size: var(--fs-xs); line-height: var(--lh-body); color: var(--muted); }
    /* My Profile's checkbox (.mask-chk) is the one this app uses, so the member
       detail rows use it too — they used to carry a bare native box on a nowrap
       line, which is why the same control read as two different things on two
       screens. Its 11px top margin is tuned for sitting under an input; under a
       .kv-k caption it only has that caption's rhythm to clear. */
    .mi-fbody > .mask-chk { margin-top: 5px; }
    /* The icon gutter centres on the CONTROL. That is My Profile's rule and the
       reason .mi-fic carries a 10px top margin: its .mi-fbody starts with the input,
       so 10px lands the glyph dead centre on it. The member detail rows put a .kv-k
       caption first, which pushes the control ~21px down while the icon stayed
       where it was — centred on neither the caption nor the field it labels.
       Re-centre past the caption's block. A checkbox row needs less: its control is
       a 16px box on one line, not a 41px input. Gated on the caption actually being
       there — a detail row can open with a hint or a control instead (the building's
       Grades Served and Location rows do), and pushing the glyph past a caption that
       doesn't exist is the same misalignment in the other direction. */
    .dtl-body .mi-field:has(> .mi-fbody > .dtl-lbl) > .mi-fic { margin-top: 31px; }
    .dtl-body .mi-field:has(> .mi-fbody > .dtl-lbl):has(.mask-chk) > .mi-fic { margin-top: 27px; }
    /* Invite title <select>: fixed width so the field never resizes with the chosen title.
       Grey (placeholder look) until a title is actually picked; dark once chosen. The open
       option list stays dark regardless. */
    .inv-foot #inv-position { width: 190px; color: var(--muted); }
    .inv-foot #inv-position.touched { color: var(--text); }
    .inv-foot #inv-position option { color: var(--text); }
    /* When a footer panel is active, hand the viewport height to its card so the
       footer sits at the page bottom and the body becomes the scroller. */
    :is(#section-content, #section-settings, #section-it, #section-state) .app-container:has(.ss-panel.page-panel.active) { display: flex; flex-direction: column; overflow: hidden; }
    :is(#section-content, #section-settings, #section-it, #section-state) .app-container:has(.ss-panel.page-panel.active) .settings-shell { flex: 1 1 0; min-height: 0; grid-template-rows: minmax(0, 1fr); }
    :is(#section-content, #section-settings, #section-it, #section-state) .app-container:has(.ss-panel.page-panel.active) .settings-sidebar { align-self: start; }
    :is(#section-content, #section-settings, #section-it, #section-state) .app-container:has(.ss-panel.page-panel.active) .settings-content { align-self: stretch; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
    :is(#section-content, #section-settings, #section-it, #section-state) .ss-panel.page-panel.active { flex: 1 1 0; min-height: 0; display: flex; }
    :is(#section-content, #section-settings, #section-it, #section-state) .ss-panel.page-panel.active > .page-frame { flex: 1 1 0; min-height: 0; width: 100%; }
    /* ── Shared config-row editors (Home Page Buttons + Navigation Menus) ──
       Both lists use one visual language: a grey, bordered row holding white
       fields, with a vertical ↑ ↓ ✕ action cluster (.blk-btn) on the right and
       a dashed inline "+ add" affordance. Keeps the two sections — which began
       life as separate components — looking and behaving the same. */
    .page-actions { display: flex; flex-direction: column; gap: 4px; flex-shrink: 0; }
    .list-fields { flex: 1; display: flex; flex-wrap: wrap; gap: 8px; min-width: 0; }
    .list-fields input, .list-fields select { padding: 7px 9px; border: 1px solid var(--border); border-radius: 7px; font-size: var(--fs-md); font-family: inherit; background: #fff; color: var(--text); }
    .list-fields input:focus, .list-fields select:focus { outline: none; border-color: var(--accent); }
    .page-add { margin-top: 8px; padding: 7px 12px; border: 1px dashed var(--border); border-radius: 8px; background: transparent; color: var(--accent); cursor: pointer; font-size: var(--fs-md); font-weight: var(--fw-semi); font-family: inherit; }
    .page-add:hover { border-color: var(--accent); background: rgba(var(--accent-rgb),.06); }

    /* Menus & Buttons — each item is a compact preview row that expands to an
       editor in place (same pattern as the Pages block builder). The collapsed
       row shows a live icon + label + destination/link-count, so the whole list
       reads at a glance and stays short; the editor reuses the .list-fields /
       .df-* / .nl-* field styles below. */
    .mb-sec-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 6px; }
    .mb-sec-head .sec-sub-title { margin: 0; }
    .mb-hint { margin: 0 0 10px; }
    .mb-grid .prev-head { margin-bottom: 10px; }
    .mb-list { display: flex; flex-direction: column; gap: 5px; }
    /* ── Move-to: how a locked destination crosses containers ──
       Sits where a delete would be on an editable row, because it answers the question
       the missing delete raises ("then what CAN I do with this?"). Drag still handles
       reordering inside a container; this handles leaving one. */
    .dest-move { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-sm); color: var(--muted); }
    .dest-move-l { white-space: nowrap; }
    .dest-move select { padding: 4px 7px; border: 1px solid var(--border); border-radius: 7px;
                        font-family: inherit; font-size: var(--fs-sm); background: var(--card); color: var(--text); max-width: 160px; }
    .dest-move select:focus { outline: none; border-color: var(--accent); }
    .mb-acts .dest-move { margin-right: 8px; }
    .navlink-dest .dest-fields { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
    .navlink-dest .mb-label { font-weight: var(--fw-semi); }
    @media (max-width: 640px) {
      .dest-move-l { display: none; }            /* the select's own options say it */
      .mb-acts .dest-move { margin-right: 4px; }
    }
    .mb-item { border: 1px solid var(--border); border-radius: 10px; background: #fff; overflow: hidden; transition: border-color .12s, box-shadow .12s, transform .12s; }
    .mb-item.open { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
    /* A collapsed row lifts on hover so the list feels like a stack of cards you
       can pick up, not a table. Open rows already carry the accent ring. */
    .mb-item:not(.open):hover { border-color: rgba(var(--accent-rgb),.45); box-shadow: 0 2px 8px rgba(22,22,22,.07); }
    /* Denser rows: this screen exists to REORDER, and a drag you have to scroll during
       is a drag you get wrong. Six rows at ~70px put half the bar below the fold. */
    .mb-head { display: flex; align-items: center; gap: 8px; padding: 4px 8px; }
    /* The order rail. This screen exists to arrange a bar left-to-right; without a
       position the vertical list was just a table you happened to be able to drag. */
    .mb-pos { flex: none; width: 19px; text-align: center; font-size: var(--fs-xs); font-weight: var(--fw-bold);
              color: var(--muted); font-variant-numeric: tabular-nums; opacity: .75; }
    .mb-item.open .mb-pos, .mb-item:hover .mb-pos { color: var(--accent); opacity: 1; }
    /* What a menu HOLDS, listed rather than counted — "5 items · 1 custom" described
       something the reader could not see. Three names and an overflow count is enough to
       recognise a menu without opening it, and it makes a dropdown look different from a
       leaf header at a glance, which was the other thing this list never showed. */
    .mb-kids { display: flex; align-items: center; gap: 4px; flex-wrap: nowrap; overflow: hidden; margin-top: 2px; }
    .mb-kid { flex: none; max-width: 128px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
              font-size: var(--fs-xs); color: var(--muted); background: rgba(127,127,127,.10);
              border-radius: 999px; padding: 1px 8px; }
    .mb-kid.more { background: none; padding-left: 2px; }
    .mb-empty-note { font-size: var(--fs-xs); color: var(--muted); font-style: italic; }
    @media (max-width: 640px) { .mb-kid:nth-child(n+3) { display: none; } }
    .mb-main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; background: none; border: none; padding: 0; margin: 0; font: inherit; color: inherit; text-align: left; cursor: pointer; }
    .mb-main.mb-static { cursor: default; }   /* order rows: draggable via the grip, but the row itself isn't a button */
    .mb-ico { width: 28px; height: 28px; flex: none; border-radius: 7px; background: var(--accent-light); color: var(--accent); display: grid; place-items: center; font-size: var(--fs-xl); overflow: hidden; }
    .mb-ico.gm { background: none; }
    .mb-ico img { width: 100%; height: 100%; object-fit: cover; }
    .mb-ico svg { width: 19px; height: 19px; display: block; }
    .mb-ico.gm svg { width: 28px; height: 28px; }
    .mb-ico.menu { background: rgba(127,127,127,.1); color: var(--muted); }
    /* Header tiles: built-ins read as solid app furniture (neutral tile, full-strength
       glyph), inherited reads as the state's (accent tint) — so kind is legible from
       the icon column alone before you read a badge. */
    .mb-ico.hdr { background: linear-gradient(180deg, rgba(127,127,127,.13), rgba(127,127,127,.07)); color: var(--text); box-shadow: inset 0 0 0 1px rgba(127,127,127,.10); }
    .mb-ico.inherited { background: linear-gradient(180deg, rgba(var(--accent-rgb),.18), rgba(var(--accent-rgb),.08)); color: var(--accent-dark, var(--accent)); box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb),.18); }
    .mb-item:hover .mb-ico.hdr { color: var(--accent); }
    .mb-txt { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
    .mb-label { font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .mb-label.empty { color: var(--muted); font-weight: var(--fw-medium); font-style: italic; }
    .mb-meta { font-size: var(--fs-xs); color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .mb-acts { display: flex; align-items: center; gap: 3px; flex: none; }
    .mb-edit { padding: 11px 11px 12px; border-top: 1px solid var(--border); background: rgba(127,127,127,.03); }
    .mb-field-label { display: block; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--muted); margin: 0 0 5px; }
    .mb-menu-links { display: flex; flex-direction: column; gap: 6px; margin: 8px 0 10px; }
    /* Unified Menu Bar editor — the same controls-beside-framed-stage layout as
       App Icon / App Header: the row list left, the live replica bar right. */
    .mb-grid { display: grid; grid-template-columns: minmax(0, 620px) minmax(280px, 1fr); gap: 26px; align-items: start; }
    .mb-col { min-width: 0; }
    /* .prev-stage carries the recessed fill + border now, like every other preview
       in the app. It was a white card matching the rows it sits beside, which is
       what made a read-only replica of the bar look like more rows to edit. */
    .mb-stage { position: sticky; top: 12px; min-width: 0; padding: 12px 14px; }
    @media (max-width: 1150px) { .mb-grid { display: block; } .mb-stage { position: static; margin-top: 18px; } }
    /* ── Stacked: the preview leads, the list runs full width under it ───────
       Keeps .prev-stage — the SAME recessed chrome every other preview in the app
       wears — and only changes where it sits and how wide it runs. */
    .mb-stacked { display: block; }
    /* A THIN strip. Leading the panel cost too much page height and pushed the list —
       the thing you actually work in — below the fold. Trimmed to the bar plus enough
       room beneath it for an open sub-menu, which is a large part of what is worth
       previewing at all. The Home-buttons half is the expensive part and now appears
       only while the Home row is open, i.e. only while it is the thing being edited. */
    .mb-body > .mb-stage { position: static; padding: 9px 14px 10px; margin: 0 0 12px; flex: none; }
    .mb-body > .mb-stage .prev-head { margin-bottom: 7px; }
    /* The preview strip is the BAR. Home's buttons are rendered as real tiles inside the
       expanded Home row now, so previewing them up here was the same thing twice — and it
       was the expensive half of the strip's height. */
    .mb-body > .mb-stage .mbp-sub { display: none; }
    .mb-body > .mb-stage .mbp-item { padding: 8px 13px; }
    .mb-stacked .mb-col { min-width: 0; }
    /* Full width means the bar fits, so the replica stops needing a scrollbar to show
       a strip the member takes in at a glance. */
    .mb-body > .mb-stage .mbp-bar { overflow-x: visible; flex-wrap: wrap; }
    .mb-body > .mb-stage .mbp-btns { grid-template-columns: repeat(3, minmax(0, 1fr)); }
    @media (max-width: 900px) { .mb-body > .mb-stage .mbp-btns { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
    @media (max-width: 620px) { .mb-body > .mb-stage .mbp-btns { grid-template-columns: minmax(0, 1fr); } }
    /* The hint rides the preview's own head, right-aligned, instead of sitting under
       the stage as a paragraph competing with the list for attention. */
    .prev-note { margin-left: auto; font-weight: var(--fw-body); letter-spacing: 0; text-transform: none; font-size: var(--fs-xs); color: var(--muted); }
    /* The replica bar. A COPY of #site-nav's numbers, not a reuse of it (same deal
       as .hdrp-bar): bar #232323, items 13.5px @500 .6-white, 11/15 padding, 4px
       active underline. If one changes there, change it here. */
    .mbp-bar { background: var(--nav-bg, #232323); border-radius: 10px; padding: 0 6px; display: flex; align-items: center; overflow-x: auto; }
    /* The Home shortcuts, under the bar. A replica like .mbp-bar — same source list, so
       the whole stage shows everything this screen actually controls. */
    .mbp-sub { margin-top: 11px; }
    .mbp-cap { display: block; font-size: var(--fs-2xs, 10px); font-weight: var(--fw-bold); letter-spacing: .05em;
               text-transform: uppercase; color: var(--muted); margin-bottom: 6px; }
    .mbp-btns { display: grid; grid-template-columns: repeat(auto-fill, minmax(158px, 1fr)); gap: 7px; }
    /* The preview holds REAL .big-btn markup (see renderMbBarPreview) so it can never drift
       from the home page. All this does is shrink it to stage size and take the interactive
       affordances off — it is a picture of a button, not a button. */
    .mbp-btns .big-btn { padding: 9px 11px; gap: 9px; min-width: 0; cursor: default; box-shadow: none; }
    .mbp-btns .big-btn:hover { border-color: var(--border); box-shadow: none; }
    .mbp-btns .big-btn > div { min-width: 0; }
    .mbp-btns .big-btn h4 { font-size: var(--fs-sm); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .mbp-btns .big-btn p { font-size: var(--fs-2xs, 10px); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .mbp-btns .big-btn .bb-ico { width: 28px; height: 28px; border-radius: 8px; font-size: var(--fs-lg); }
    .mbp-btns .big-btn .bb-ico.lucide .bb-ico-svg { width: 17px; height: 17px; }
    .mbp-btns .big-btn .bb-ico.gm svg,
    .mbp-btns .big-btn .bb-ico.img img { width: 28px; height: 28px; }
    .mbp-sub.hl .big-btn { border-color: var(--accent); }
    .mbp-item { color: rgba(255,255,255,.6); border-bottom: 4px solid transparent; padding: 11px 15px; font-size: var(--fs-md); font-weight: var(--fw-medium); white-space: nowrap; display: inline-flex; align-items: center; gap: 4px; transition: color .15s, border-color .15s; }
    .mbp-item.on { color: #fff; border-bottom-color: var(--accent); }
    .mbp-item.hl { color: #fff; border-bottom-color: rgba(var(--accent-rgb), .55); }
    .mbp-chev { font-size: var(--fs-2xs); opacity: .5; }
    /* Expandable rows (Home / Resources / custom menus) wear the caret; it flips open. */
    .mb-caret { flex: none; color: var(--muted); font-size: var(--fs-2xs); padding: 0 4px; transition: transform .2s; }
    .mb-item.open > .mb-head .mb-caret { transform: rotate(180deg); color: var(--accent-dark); }
    /* Only the EXCEPTIONS wear chips now (Inherited / System) — the Built-in and
       Menu chips are gone, so the outline variant below has no remaining user
       except the nested "System" rows inside Resources. */
    /* ── The status COLUMN ───────────────────────────────────────────────────
       Every row ends in the same fixed-width slot, whether or not it has anything
       to say. Badges used to be shrink-wrapped and butted against whatever came
       before them, so "Built in", "Inherited" and "Pinned" landed at three
       different x-positions down a single list and read as decoration on
       individual rows rather than as one column you can scan for "what can't I
       touch?". An empty slot on a free row is the point: the column stays true. */
    .mb-acts { --mb-lockw: 92px; }
    .mb-badge, .mb-badge-slot { flex: none; width: var(--mb-lockw); box-sizing: border-box; text-align: center; }
    .mb-badge-slot { display: inline-block; }
    .mb-badge { font-size: var(--fs-2xs); font-weight: var(--fw-bold); letter-spacing: .04em; text-transform: uppercase; color: var(--muted); background: none; box-shadow: inset 0 0 0 1px rgba(127,127,127,.22); border-radius: 999px; padding: 2px 8px; }
    @media (max-width: 760px) { .mb-acts { --mb-lockw: auto; } .mb-badge-slot { display: none; } }
    .mb-badge.inherited { box-shadow: none; color: var(--accent-dark, var(--accent)); background: rgba(var(--accent-rgb),.12); }
    /* Inherited rows read as "movable, but not yours to edit here": a soft left accent. */
    /* No left bar on the inherited row. The "Inherited" badge already says the one thing
       this row has to say, and a 3px accent stripe made it read as SELECTED — the same
       signal the open row uses — on a row that is merely shared by the state. */
    /* (.res-disabled is gone: a destination whose add-on is off is no longer greyed out
       here, it is not listed at all — see navDestHidden.) */
    /* The Home row's nested Home-Page-Buttons editor header (a smaller .mb-sec-head). */
    .mb-sub-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 4px; }
    /* A bare sub-head: the action, and a hint only while the list is empty. It used to
       carry a title restating the row you had just expanded, over a paragraph explaining
       what the rows underneath already demonstrate. */
    .mb-sub-bare { justify-content: flex-end; margin-bottom: 8px; }
    .mb-sub-bare .set-desc { margin: 0; margin-right: auto; }
    /* Names the tray, so "+ Add Button" isn't a control floating alone on a grey band. */
    /* The held slot on a fixed row: same width as a grip, no handle, no cursor. */
    /* Same glyph, same box, invisible. A bespoke fixed-width spacer was ~10px narrower
       than a real grip, so the fixed rows' numbers and icons sat right of everyone
       else's — the column broke exactly where it most needed to hold. */
    /* Tracks the grip's BOX, not just its glyph — the grip is a 24px chip now, so a
       ghost sized only by font-size would be ~12px and put the fixed rows' numbers
       back out of column, which is the exact bug this element exists to prevent. */
    .pg-blk-grip-ghost { color: transparent; font-size: var(--fs-xl); line-height: var(--lh-none); user-select: none;
                         width: var(--grip-size); height: var(--grip-size); flex: none; pointer-events: none; }
    /* Reserved caret gutter: a row that doesn't expand still holds the space, so the
       status column lands at one x-position down the whole list. */
    .mb-caret-slot { display: inline-block; width: 14px; flex: none; }
    /* Reserved delete column, so the trash lands at ONE x-position across the list
       whether or not a given row can be deleted. Right-hand order is fixed everywhere:
       tag, delete, chevron. */
    .mb-del-slot { display: inline-block; width: 28px; flex: none; }
    .mb-acts > .blk-btn.del { width: 28px; flex: none; }
    /* ── Which rows are TREES you can drop into ──────────────────────────────
       Communications and Quick Links looked identical, but one holds children and one
       cannot — and nothing on the row said so, which is why the answer had to be a
       "Move to" select instead of a gesture. A container wears a soft accent edge and
       says what it holds; during a drag it lights up as a real target. */
    /* The ICON says it, not a coloured bar down the edge: a menu tree wears the network
       glyph, everything else wears the glyph of the thing it actually is. */
    .mb-item.mb-can-hold .mb-ico { color: var(--accent); background: var(--accent-light); }
    body.dnd-active .mb-item.mb-can-hold { background: rgba(var(--accent-rgb), .05); }
    .mb-item.dnd-nest { border-color: var(--accent) !important; background: rgba(var(--accent-rgb), .11) !important;
                        box-shadow: 0 0 0 3px rgba(var(--accent-rgb), .22); }
    .mb-item.dnd-nest .mb-label::after { content: ' 4 drop in here'; color: var(--accent); font-size: var(--fs-sm); font-weight: var(--fw-bold); }
    /* Nested rows obey the SAME right-hand column as the top-level list: tag, delete,
       chevron, each with reserved space. They used to end in whatever that particular
       row happened to have, so a menu's contents read as a different kind of list from
       the one holding it. */
    .navlink-row { display: flex; align-items: center; gap: 8px; }
    .navlink-row > .list-fields { flex: 1; min-width: 0; }
    .navlink-row > .mb-acts { flex: none; }
    /* Children fill the row they were being squeezed out of. */
    .mb-kids { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin-top: 3px; }
    .mb-kid { display: inline-flex; align-items: center; gap: 5px; }
    .mb-kid-ic { display: inline-flex; color: var(--muted); }
    .mb-kid-ic svg { width: 12px; height: 12px; }
    .mb-tray-lbl { margin-right: auto; font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
    /* "3 of 4" beside the tray label — a counter, not a heading, so it drops the caps and
       the tracking. It turns amber at the ceiling and carries the reason, because the
       + button greys out there and `.btn:disabled` has pointer-events:none — a tip on the
       button itself could never be opened. */
    .mb-tray-count { margin-left: 8px; font-weight: var(--fw-medium); letter-spacing: 0; text-transform: none; }
    .mb-tray-count.full { color: var(--warn); }
    .mb-sub-title { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--text); }

    /* Field widths reused inside an open editor. */
    .df-label { flex: 1 1 150px; } .df-sub { flex: 1 1 190px; } .df-icon { flex: 0 0 60px; text-align: center; } .df-type { flex: 0 0 130px; } .df-val { flex: 1 1 190px; }
    /* ── The nested tray: ONE list, not N cards inside a card ────────────────
       The children of an open row (Home's buttons, the Resources links) used to be
       .mb-item cards — the same white, bordered, shadowed box as a TOP-LEVEL header
       — floating inside the open row's own card. A card inside a card inside a row
       inside a list, with the nesting communicated only by the parent's orange
       border. That is the single thing that made this control read as archaic.
       They are hairline rows in one bordered container instead. Both trays share
       this, so the two can never diverge. */
    /* ── Home's tray IS Home ─────────────────────────────────────────────────
       The buttons render as the buttons, in the grid they land in, so arranging
       Home looks like Home instead of like a table describing it. The grip and the
       trash ride on top and surface on hover, so the thing you are judging is the
       button and not its handles. The open one takes the full row and becomes the
       builder. */
    /* Reflows on width, and DELIBERATELY not Home's one-row grid. Home is capped at four
       tiles across a 1120px column; this list sits in a config panel roughly half that,
       carries a grip and a trash on every tile, and expands one row into the full-width
       builder. Four columns here would be ~140px each — narrower than the tile's own
       horizontal layout survives. It mirrors the tile, not the row: same .big-btn, same
       icon, same type, reflowing to whatever this panel's width actually is. */
    #dash-list > .mb-list.db-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px;
                            border: 0; background: none; overflow: visible; padding: 2px; }
    #dash-list > .mb-list.db-grid > .mb-item { border: 0 !important; background: none !important; box-shadow: none !important; }
    .db-tile { position: relative; }
    .db-tile-btn { width: 100%; text-align: left; cursor: pointer; }
    .db-tile-btn h4.empty { color: var(--muted); font-style: italic; }
    /* INSIDE the button's own bounds, on its right edge, where a tile has empty space.
       The corners were tried first and both are taken: top-left is the icon, and hanging
       them outside made handles float in the grid gutter belonging to no tile. */
    /* Top corners, just inside the radius, floating OVER the content. Reserving room
       for them instead re-wrapped the label the moment you pointed at a tile — the
       button changed shape under the cursor, which is worse than a glyph overlapping
       a description for the second you are reaching for it. */
    .db-tile > .pg-blk-grip, .db-tile > .blk-btn.del {
      position: absolute; top: 6px; z-index: 3; opacity: 0; transition: opacity .12s;
      width: 24px; height: 24px; display: grid; place-items: center; padding: 0;
      background: var(--card); border: 1px solid var(--border); border-radius: 7px;
      box-shadow: 0 1px 4px rgba(0,0,0,.12);
    }
    .db-tile > .pg-blk-grip { left: 6px; }
    .db-tile > .blk-btn.del { right: 6px; }
    /* The confirm is the app's STANDARD banner strip — same one every other list uses.
       A tile is a third of a row wide, so an arming tile takes the whole row for the
       length of the question; the strip then reads exactly like it does everywhere else
       instead of wrapping into a narrow column. */
    /* IN PLACE. Spanning the row tore the tile out of the grid and shoved every other
       button sideways to ask one question about one of them. */
    .db-tile.row-del-arming { grid-column: auto; }
    .db-tile .row-del-confirm { flex-wrap: wrap; justify-content: center; text-align: center; }
    .db-tile.row-del-arming > .pg-blk-grip, .db-tile.row-del-arming > .blk-btn.del { display: none; }
    .db-tile:hover > .pg-blk-grip, .db-tile:hover > .blk-btn.del,
    .db-tile:focus-within > .pg-blk-grip, .db-tile:focus-within > .blk-btn.del { opacity: 1; }
    .db-tile > .blk-btn.del:hover { color: var(--danger); }
    /* The one being edited takes the whole row. */
    /* The builder edits the tile WHERE IT SITS. It used to take the full row, which
       moved the button you were editing away from the place you clicked it. */
    #dash-list > .mb-list.db-grid > .mb-item.db-builder { grid-column: auto; }
    #dash-list > .mb-list, #res-list > .mb-list {
      border: 1px solid var(--tray-border); border-radius: 10px;
      background: var(--card); overflow: hidden; display: block;
    }
    #dash-list > .mb-list > .mb-item, #res-list > .mb-list > .mb-item {
      border: 0; border-radius: 0; box-shadow: none;
      border-bottom: 1px solid var(--tray-rule); background: transparent;
    }
    #dash-list > .mb-list > .mb-item:last-child, #res-list > .mb-list > .mb-item:last-child { border-bottom: 0; }
    /* The card hover/open chrome has to go with the card, or a hovered row still
       paints a border and shadow that no longer have a box to sit on. */
    #dash-list > .mb-list > .mb-item:not(.open):hover,
    #res-list  > .mb-list > .mb-item:not(.open):hover { border-color: var(--tray-rule); box-shadow: none; background: var(--tray-row-hover); }
    #dash-list > .mb-list > .mb-item.open, #res-list > .mb-list > .mb-item.open {
      border-color: var(--tray-rule); box-shadow: none; background: var(--tray-row-open);
    }
    #dash-list > .mb-list > .mb-item > .mb-head, #res-list > .mb-list > .mb-item > .mb-head { padding: 9px 12px; gap: 11px; align-items: center; }
    #dash-list .mb-head .pg-blk-grip, #res-list .mb-head .pg-blk-grip { opacity: .34; }
    #dash-list .mb-item.open .mb-head .pg-blk-grip, #res-list .mb-item.open .mb-head .pg-blk-grip { opacity: .5; }
    #dash-list .mb-ico { width: 32px; height: 32px; flex: none; border-radius: 9px; background: var(--accent-light); color: var(--accent); display: grid; place-items: center; }
    #dash-list .mb-ico svg { width: 17px; height: 17px; }
    #dash-list .mb-label { font-size: var(--fs-lg); font-weight: var(--fw-semi); color: var(--text); }
    #dash-list .mb-meta { font-size: var(--fs-sm); color: var(--muted); margin-top: 0; }
    /* Trash is ghosted until you point at the row — an outline per row was six boxes
       competing with the six icons opposite them. It gains its outline while editing,
       where it has to read as live. */
    #dash-list .mb-head .blk-btn.del, #res-list .mb-head .blk-btn.del { border-color: transparent; background: none; opacity: 0; transition: opacity .12s, border-color .12s; }
    #dash-list .mb-item:hover .blk-btn.del, #dash-list .mb-item.open .blk-btn.del,
    #res-list  .mb-item:hover .blk-btn.del, #res-list  .mb-item.open .blk-btn.del { opacity: 1; }
    #dash-list .blk-btn.del.outlined { border-color: var(--border); background: var(--card); opacity: 1; }
    #dash-list .blk-btn.del:hover, #res-list .blk-btn.del:hover { color: var(--danger); border-color: var(--danger); }

    /* ── An open child edits IN THE ROW ──────────────────────────────────────
       Same geometry as the collapsed row, just taller, so nothing jumps when it
       opens. The icon tile becomes the picker; the two lines become the fields
       they already looked like. */
    /* ── The button builder ──────────────────────────────────────────────────
       An open child stops being a row in a tree and becomes a workbench: a stage
       holding the real member-facing tile at the size a member taps, edited in place.
       Arranging things and MAKING one are different acts and should not look alike. */
    /* Contained, not full-bleed. The builder was a page-wide tinted band with the tile
       floating in the middle of it, so the workbench read as a gap in the list. */
    .db-builder { background: transparent; padding: 9px 10px; }
    .db-build-panel { max-width: none; margin: 0; background: var(--tray-row-open);
                      border: 1px solid rgba(var(--accent-rgb), .35); border-radius: 12px; }
    .db-build-bar { display: flex; align-items: center; gap: 8px; padding: 7px 10px 0; }
    .db-build-lbl { flex: 1; font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
    .db-stage { display: flex; flex-direction: column; align-items: stretch; gap: 6px; padding: 9px 10px 10px; }
    .db-stage .db-card { width: 100%; max-width: none; cursor: default; }
    .db-stage .db-card:hover { border-color: var(--border); box-shadow: var(--shadow); }
    .db-stage-note { font-size: var(--fs-xs); color: var(--muted); text-align: center; }
    /* The tile's own icon box is the picker here. */
    .db-stage .db-ico { position: relative; padding: 0; border: 1px dashed var(--accent); background: var(--accent-light); border-radius: 10px; flex: none; cursor: pointer; display: grid; place-items: center; }
    .db-stage .db-ico:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
    .db-icons { justify-content: center; flex-wrap: wrap; }
    .db-icons-or { font-size: var(--fs-sm); color: var(--muted); }
    .db-emoji-wrap { display: inline-flex; align-items: center; gap: 6px; }
    .db-emoji-l { font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
    .db-emoji { width: 62px; flex: none; text-align: center; font-size: var(--fs-lg); }
    /* NO page-wide dropdowns. A select stretched to the panel's full width reads as a
       search bar and puts its own value a screen-length away from its label. This one
       lines up with the tile it belongs to. */
    .db-builder .db-goes { justify-content: center; flex-wrap: wrap; padding: 0 10px 10px; margin-top: 0; gap: 7px; }
    .db-builder .db-dest { flex: 1 1 130px; min-width: 0; max-width: none; }
    .db-head-edit { align-items: flex-start !important; }
    .db-head-edit .db-ico { position: relative; width: 32px; height: 32px; flex: none; padding: 0; display: grid; place-items: center;
                            border: 1px dashed var(--accent); border-radius: 9px; background: var(--accent-light); color: var(--accent); cursor: pointer; }
    .db-head-edit .db-ico svg { width: 17px; height: 17px; }
    .db-head-edit .db-ico:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
    .db-ico-pencil { position: absolute; right: -5px; bottom: -5px; width: 17px; height: 17px; border-radius: 50%;
                     background: var(--accent); color: var(--on-accent, #fff); display: grid; place-items: center; box-shadow: 0 1px 3px rgba(0,0,0,.25); }
    /* Beats `.db-head-edit .db-ico svg` above, which is more specific than a bare
       `.db-ico-pencil svg` and was sizing the pencil to 17px inside a 17px circle —
       a solid orange blob sitting on the glyph. */
    .db-head-edit .db-ico .db-ico-pencil svg { width: 9px; height: 9px; stroke-width: 2.6; }
    .db-txt { min-width: 0; flex: 1; }
    /* Indented to the label's left edge: grip + gap + 32px tile + gap. */
    .db-edit { padding: 0; }
    .db-done { flex: none; height: 32px; padding: 0 14px; border: 0; border-radius: 8px; background: var(--text);
               font-family: inherit; font-size: var(--fs-sm); font-weight: var(--fw-bold); color: var(--card); cursor: pointer; }
    .db-done:hover { background: var(--accent); }
    /* Fields that look like the text they replace: no box until you point at them. */
    /* Dashed, always — that IS the affordance now that .db-hint is gone. A field that
       only reveals itself on hover cannot tell a teacher it is editable. */
    .db-title, .db-sub { display: block; width: 100%; padding: 0 0 2px; border: 0; border-bottom: 1px dashed var(--border);
                         border-radius: 0; background: none; font-family: inherit; color: inherit; }
    .db-title { font-size: var(--fs-lg); font-weight: var(--fw-semi); color: var(--text); }
    .db-sub { font-size: var(--fs-sm); color: var(--muted); margin-top: 2px; }
    .db-title:hover, .db-sub:hover { border-bottom-color: var(--accent-dark); }
    .db-title:focus, .db-sub:focus { outline: none; border-bottom-color: var(--accent); border-bottom-style: solid; }
    .db-icons { display: flex; align-items: center; gap: 8px; margin-top: 9px; }
    .db-emoji { flex: 1; min-width: 0; text-align: center; }
    .db-goes { display: flex; align-items: center; gap: 9px; margin-top: 10px; }
    .db-goes-l { flex: none; font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .08em; text-transform: uppercase; color: var(--muted); }
    .db-dest { flex: 1; min-width: 0; }
    .db-follow { width: 100%; margin-top: 8px; }
    @media (max-width: 560px) { .db-icons { flex-wrap: wrap; } .db-emoji { flex: 1 0 100%; order: -1; } }
    .df-iconcell { display: flex; align-items: center; gap: 6px; flex: 0 0 auto; }
    .df-iconbtn { width: 40px; height: 40px; flex: 0 0 40px; border: 1px solid var(--border); border-radius: 8px; background: #fff; cursor: pointer; display: grid; place-items: center; overflow: hidden; padding: 0; font-size: var(--fs-2xl); }
    .df-iconbtn:hover { border-color: var(--accent); }
    .df-iconbtn img { width: 100%; height: 100%; object-fit: cover; }
    .df-iconbtn svg { width: 30px; height: 30px; display: block; }
    .df-iconbtn.ghost { color: var(--muted); }
    .df-iconbtn.ghost svg { width: 20px; height: 20px; }
    .df-iconbtn.ghost:hover { color: var(--accent); }
    .nt-label { width: 100%; padding: 8px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-lg); font-weight: var(--fw-semi); font-family: inherit; background: #fff; color: var(--text); }
    .nt-label:focus { outline: none; border-color: var(--accent); }
    .navlink-row { display: flex; gap: 8px; align-items: center; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: #fff; }
    .nl-label { flex: 1 1 160px; min-width: 0; } .nl-url { flex: 2 1 200px; min-width: 0; } .nl-type { flex: 0 0 118px; }
    /* My info form + request submitter-contact block */
    .mi-label { display: block; font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); margin: 0 0 4px; }
    .mi-field-group { margin-bottom: 16px; }
    .mi-field-group:last-of-type { margin-bottom: 0; }
    .mi-input { width: 100%; padding: 8px 11px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-lg); font-family: inherit; background: #fff; color: var(--text); }
    .mi-chk { display: flex; align-items: center; gap: 7px; margin: 6px 0 4px; font-size: var(--fs-md); color: var(--text); cursor: pointer; }
    .mi-chk-sub { margin-top: 5px; margin-left: 2px; color: var(--muted); font-size: var(--fs-sm); }
    .mi-chk input { width: 15px; height: 15px; flex: none; accent-color: var(--accent); }
    .mi-chk-note { color: var(--muted); }
    /* Contextual privacy checkbox shown directly under the phone / address fields (profile + register) */
    .mask-chk { display: flex; align-items: flex-start; gap: 9px; margin: 11px 0 0; font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); cursor: pointer; text-align: left; }
    /* Custom checkbox so it renders identically across platforms (the native
       control is rounded+orange on iOS but a square black-check on desktop). */
    .mask-chk input { -webkit-appearance: none; appearance: none; width: 16px; height: 16px; flex: none; margin-top: 1px; border: 1.5px solid var(--border); border-radius: 5px; background: var(--card); cursor: pointer; position: relative; transition: background .12s, border-color .12s; }
    .mask-chk input:checked { background: var(--accent); border-color: var(--accent); }
    .mask-chk input:checked::after { content: ""; position: absolute; left: 3.5px; top: 0px; width: 4px; height: 8px; border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg); }
    .mask-chk input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
    .mask-chk .mask-strong { color: var(--text); font-weight: var(--fw-semi); }
    /* Orange/white custom checkmark everywhere in Site Config + Site Settings —
       same control as the profile privacy checkbox (.mask-chk above), so every
       checkbox in the admin/CMS layer renders identically across platforms
       (the native control is rounded+orange on iOS but a square black-check on
       desktop). Scoped to the two admin sections; covers JS-rendered checkboxes
       (.blk-check, review-committee, welcome-override, hide-on-mobile/dark, …)
       and beats the global .blk-check input width via id specificity. */
    #section-content input[type=checkbox],
    #section-settings input[type=checkbox] {
      -webkit-appearance: none; appearance: none; width: 16px; height: 16px; flex: none;
      margin: 0; border: 1.5px solid var(--border); border-radius: 5px; background: var(--card);
      cursor: pointer; position: relative; vertical-align: middle; transition: background .12s, border-color .12s;
    }
    #section-content input[type=checkbox]:checked,
    #section-settings input[type=checkbox]:checked { background: var(--accent); border-color: var(--accent); }
    #section-content input[type=checkbox]:checked::after,
    #section-settings input[type=checkbox]:checked::after {
      content: ""; position: absolute; left: 3.5px; top: 0px; width: 4px; height: 8px;
      border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg);
    }
    #section-content input[type=checkbox]:focus-visible,
    #section-settings input[type=checkbox]:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }
    .mi-input:focus { outline: none; border-color: var(--accent); }
    /* Icon-gutter field rows — shared by My Profile + Create Account. A muted
       Member-Directory glyph sits in a left gutter; the field's own placeholder
       carries the label, so there's no separate bold label line. */
    .mi-field { display: flex; gap: 12px; align-items: flex-start; }
    .mi-field + .mi-field { margin-top: 16px; }
    .mi-fic { flex: none; width: 20px; text-align: center; color: var(--muted); font-size: var(--fs-xl); line-height: var(--lh-none); margin-top: 10px; }
    .mi-fic .lic { width: 17px; height: 17px; }
    .mi-fbody { flex: 1; min-width: 0; }
    /* Create Account reuses the same rows; its auth inputs self-space by default,
       so drop that margin and let the row gap drive the vertical rhythm. */
    /* Match My Profile: adjacent fields space via .mi-field + .mi-field (16px);
       fields right after a section header sit close to it (no blanket top margin). */
    #form-register .mi-field input:not([type=checkbox]), #form-register .pw-wrap { margin-bottom: 0; }
    /* Consistent vertical rhythm across the whole create-account form: every gap
       between stacked controls is 16px — the same as .mi-field + .mi-field. That
       covers name→email and grade→subject already; these two align the outliers
       (the Password/Confirm pair and the address lines, which were 10px / 8px). */
    #form-register .pw-wrap + .pw-wrap { margin-top: 16px; }
    #form-register .addr-grp { gap: 16px; }
    /* Work-info field triggers (grades / subjects / building). Tapping one opens
       the shared .mf-* bottom-sheet chooser (same as the Member Directory filters). */
    .mi-picker-btn { display: flex; align-items: center; justify-content: space-between; gap: 8px; width: 100%; padding: 8px 11px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-lg); font-family: inherit; background: var(--card); color: var(--text); cursor: pointer; text-align: left; }
    .mi-picker-btn:focus { outline: none; border-color: var(--accent); }
    button.place-bldg { display: flex; align-items: center; justify-content: space-between; gap: 8px; text-align: left; cursor: pointer; }
    .mi-picker-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    /* Building / grade / subject picker placeholders use the same grey as the
       dropdowns' empty state (#9ca3af), not the darker --muted. */
    .mi-picker-label.ph { color: #9ca3af; }
    .mi-picker-caret { font-size: var(--fs-2xs); opacity: .5; flex: none; }
    /* Contact-the-member: its own card (like Notes) — header bar + padded grid */
    .contact-card { border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 14px; background: var(--card); overflow: hidden; }
    /* :not(.card-sec-hd) is load-bearing, not tidiness. An element+class selector
       out-ranks a bare .card-sec-hd, so on a CONVERTED screen this rule would win
       every property it names and hand the head its old height back. The Supply
       Closet still uses .contact-card as a real card, so this stays for it — the
       guard is what lets both live until Supply's turn in the rollout. */
    .contact-card h4:not(.card-sec-hd) { font-size: var(--fs-lg); font-weight: var(--fw-semi); margin: 0; padding: var(--pad-band) var(--pad-card); border-bottom: var(--rail); color: var(--text); display: flex; align-items: center; gap: 8px; }
    .contact-card h4.card-sec-hd { margin: 0; }
    .contact-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 12px 18px; padding: 16px var(--pad-card); }
    .contact-item label { display: block; font-size: var(--fs-2xs); font-weight: var(--fw-semi); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 1px; white-space: nowrap; }
    .contact-item span, .contact-item a { font-size: var(--fs-md); color: var(--text); }
    .contact-item a { color: var(--accent); font-weight: var(--fw-semi); text-decoration: none; }
    .contact-item a:hover { text-decoration: underline; }
    /* ══ ONE MEMBER RECORD, ONE LAYOUT — .mem-rec ═══════════════════════════
       Three surfaces print the same person from the same markup: the Member
       Directory's phone row, and the Member/Assignee cards on the request detail
       at BOTH widths. They had drifted into three layouts. The directory's rules —
       the fixed contact lane, the two-line name clamp, the floored footer height —
       stopped at the directory, so the request detail truncated in different places,
       stacked the name on a phone and ran it inline on a desktop, and left-aligned a
       contact column the directory right-aligns. Same person, three looks.

       This block IS the layout, written once; every surface that prints a member
       record wears `.mem-rec` and gets all of it. The one shape that is NOT a record
       is the directory's DESKTOP table — that is eight columns, not a card — and it
       stays correct without exception rules here because every override it needs is
       already `#members-list …`, which out-ranks everything below by an id.

       Reasoning for the individual rules lives with them; the two that carry the most
       history are the clamp (ONE RECORD, ONE HEIGHT) and the lane (THE CONTACT COLUMN
       IS A FIXED LANE), both first written for the phone directory. */
    .mem-rec .mem-row { display: flex; flex-wrap: nowrap; align-items: flex-start; gap: 10px; }
    .mem-rec .mem-name { flex: 1 1 auto; width: auto; min-width: 0; align-items: flex-start; }
    /* ── THE CIRCLE BELONGS TO THE NAME ────────────────────────────────────
       The avatar was centred on the whole identity block — first name, last name AND
       the title line. That line is usually an empty &nbsp; placeholder (drawn whether
       or not a member has a title, so every record is the same height), so on most
       records the circle was centred on nothing: 16px below the centre of the line it
       labels. Top-aligned and nudged 1px, the 34px circle centres on the 36px of the
       two name lines and ignores the title line entirely. */
    .mem-rec .mem-ava { margin-top: calc(1px * var(--fs-scale, 1)); }
    /* The building/grade slot is a FIXED column (--mem-assign-w, measured across the
       whole list by alignAssignColumn()) and the subject stack holds its own slot, so
       the three parts land at the same x on every record — which is the only reason
       two cards side by side on the request detail line up with each other and with
       the directory. Sized to its own content it would be a different width per row. */
    .mem-rec .mem-assign { flex: 0 0 auto; min-width: 0; width: var(--mem-assign-w, 140px); margin-top: 0; }
    .mem-rec .mem-subjects { flex: 0 0 auto; margin-top: 0; }
    /* ══ ONE RECORD, ONE HEIGHT ════════════════════════════════════════════
       The height varied two ways, both per-member: the name renders one <b class=
       "mem-nm"> PER WORD, stacked, so a third word or a wrap made that record 66px
       against everyone else's 50; and the contact footer drops a line when a member
       has no email. So the name is clamped to exactly two lines — each word its own
       line, its own ellipsis, anything past the second dropped — and the footer is
       floored at the height it has when full. A member missing a field holds the
       field's SPACE, which is what an empty state is for. */
    .mem-rec .mem-id { overflow: hidden; }
    .mem-rec .mem-nm { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .mem-rec .mem-nm:nth-of-type(n+3) { display: none; }
    /* THE GAP IS THE GROUPING — see ONE LINE MEANS ONE THING further down. There is no
       rule between a record's identity line and its contact line; 5px inside a record
       against a full gutter of padding around it is the separation a reader needs, and
       a second hairline in the same grey doing a different job is what made these
       lists read as an evenly spaced stack of rules with nothing marking where a
       person ended. */
    .mem-rec .mem-c-footer { padding: 5px 0 0; }
    .mem-rec .memf { min-height: calc(41px * var(--fs-scale, 1)); }
    /* THE CONTACT COLUMN IS A FIXED LANE. Phone and email used to take whatever the
       address left over, which is a different width on every record — so the ellipsis
       landed at a different x each time and the column read as ragged. A set width puts
       every truncation in the same place, and truncation is the right answer here:
       nobody reads an address or an email out of a directory, they tap it. The address
       takes the slack beside it, and clips in ITS lane the same way (it was `nowrap`
       with nothing to clip it, so a long enough street painted over the phone column). */
    .mem-rec .memf-l { flex: 0 0 auto; width: calc(168px * var(--fs-scale, 1)); }
    .mem-rec .memf-addr { flex: 1 1 auto; min-width: 0; max-width: none; }
    .mem-rec .memf-addr-tx { overflow: hidden; text-overflow: ellipsis; }
    /* The request detail's Member/Assignee card is a record in a section rather than a
       row in a list, so all it owns is its own gutter. Everything else is .mem-rec. */
    .mem-single-card { padding: 11px var(--pad-card); }
    .mem-single-card .mem-c-footer { display: block !important; }
    @keyframes ssfade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: none; } }
    /* Desktop My Profile action alignment: positive actions (Save, Enable, Connect,
       Send a Test) sit at the right edge; destructive/negative actions (Reset Password,
       Disable Notifications, Disable Two-Factor) anchor at the left. A row is treated as
       negative when it contains a .ghost-danger button. The mobile block below packs every
       action into a single right-edge column, so this is desktop-only by override order. */
    #section-profile .btn-row,
    #section-profile .ir-actions,
    #section-profile #notif-actions,
    #section-profile #twofa-actions { display: flex; justify-content: flex-end; }
    #section-profile .btn-row:has(.ghost-danger),
    #section-profile .ir-actions:has(.ghost-danger),
    #section-profile #notif-actions:has(.ghost-danger),
    #section-profile #twofa-actions:has(.ghost-danger) { justify-content: flex-start; }
    /* Stacked connect rows (GroupMe: full-width token input + Connect button) are a
       column, so justify-content doesn't move the button — align-self does. Pull the
       Connect button to the right edge to match every other positive profile action. */
    #section-profile .ir-actions.ir-stack > .btn { align-self: flex-end; }
    /* Uniform action-button width (text centered) so Save / Connect / Upload Photo /
       Enable / Send a Test line up regardless of label length — at every viewport,
       not just mobile. Destructive actions share their own wider uniform width. */
    #section-profile .btn-row > .btn,
    #section-profile .ir-actions > .btn,
    #section-profile #notif-actions > .btn,
    #section-profile #twofa-actions > .btn,
    #section-profile .profile-head .btn { min-width: 132px; justify-content: center; }
    #section-profile .btn-row > .btn.ghost-danger,
    #section-profile #notif-actions > .btn.ghost-danger,
    #section-profile #twofa-actions > .btn.ghost-danger { min-width: 160px; }
    @media (max-width: 820px) {
      /* The Member/Assignee card used to re-declare the directory's phone layout here,
         and inherit the directory's DESKTOP layout above — which is exactly how the two
         cards drifted from the row they are supposed to match. Both are .mem-rec now,
         at every width, and there is nothing left to say at this breakpoint. */
      .settings-shell { grid-template-columns: 1fr; }
      .settings-sidebar { position: static; display: flex; gap: 6px; overflow-x: auto; padding: 7px; }
      .ss-group { display: none; }
      .ss-nav { width: auto; white-space: nowrap; padding: 8px 12px; }
      .ss-nav .ss-chev { display: none; }
      /* App Branding hub: full-width, equal segments on phones. */
      /* A panel head still stacks its title over its description here; the hub band
         is a lone title now, so it has nothing to stack and keeps its one-line box. */
      .panel-head { flex-direction: column; gap: 4px; }
      .page-head-row { height: auto; min-height: 0; }
      .panel-head .set-desc { font-size: var(--fs-sm); }
      /* ── The band on a phone ────────────────────────────────────────────────
         Symmetric padding, so the title reads centred in its band rather than
         bottom-heavy, and the card's phone gutter instead of the desktop 22.
         This is the treatment the Members panel had all to itself (as
         `#section-content.members-quick`); it belongs to every framed page now.

         The SIDES are --pad-card, not a literal 14px, because this block starts
         at 820px and the token switches at 760: written literally, the band went
         to 14 sixty pixels before the body under it did, so a tablet-width window
         showed a title inset 8px LEFT of the text it titles. The token makes the
         band's wall the body's wall by construction, at every width. */
      .page-head { padding: 13px var(--pad-card) 0; border-radius: var(--radius) var(--radius) 0 0; }
      .page-head > .page-tabs { margin-left: calc(-1 * var(--pad-card)); margin-right: calc(-1 * var(--pad-card)); }
      .page-tabs { display: flex; width: 100%; overflow-x: auto; }
      .page-tab { flex: 1; justify-content: center; padding: 9px 8px; gap: 6px; }
      /* Grouped rows: hairline dividers only, tighter. */
      .ptab-group { flex: 1; }
      .ptab-btns { flex: 1; }
      .ptab-group + .ptab-group { margin-left: 4px; padding-left: 4px; }
      /* My Profile on mobile: drop the section selector and show every section
         stacked as one scrollable list — the user's whole profile in one view.
         (Site Settings / Content keep their selector — too many panels to stack.)
         A .hidden panel still wins via !important, so gating is preserved. */
      #section-profile .settings-sidebar { display: none; }
      #section-profile .ss-panel { display: block; }
      #section-profile .ss-panel + .ss-panel { margin-top: 14px; }
      #section-profile .btn-row,
      #section-profile .ir-actions,
      #section-profile #notif-actions,
      #section-profile #twofa-actions { justify-content: flex-end; }
      #section-profile #notif-actions,
      #section-profile #twofa-actions { display: flex; }
      /* Uniform action-button widths (132px / 160px danger) now live in the base
         block above so desktop and mobile match — see #section-profile .btn-row > .btn. */
      /* Belt-and-suspenders: clip any residual horizontal overflow so the page
         can't drift left/right while scrolling vertically on touch. */
      #section-profile.active > .app-container { overflow-x: hidden; }
    }
    /* Role-gate divider — mobile only, shows between the universal profile panels
       and the gated Integrations section. Hidden on desktop where the sidebar
       Connections group label already communicates this. */
    /* A detail screen's section break — "Responsibilities", "Work Info", "Personal Info"
       on a member, and the same shape on Profile and a building. It is the third thing
       in the app that names the block under it, after .card-sec-hd (a section of a card)
       and .list-head (a column of rows), and it was the only one still rendering as
       plain bold body text. Same eyebrow voice and the same brand-derived ink as the
       other two, so a detail screen reads like the rest of the app.

       Type and ink, deliberately NOT the full band fill: this one sits in flow inside a
       padded column (Profile's cards, the member detail's field groups), so a fill would
       have to bleed to a wall whose distance differs per host. The rule under it is what
       carries the "a block starts here" job that the fill does elsewhere. If it ever
       wants the fill, it needs the bleed plumbing first — see .page-sections. */
    .sec-sub-title { font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .1em;
      text-transform: uppercase; color: var(--band-ink); margin: 0 0 12px;
      padding-bottom: 7px; border-bottom: 1px solid var(--band-rail); }
    .card-sep { border: none; border-top: 1px solid var(--border); margin: 18px 0; }
    /* Grade Levels: two columns so 14 grades fit without scrolling (order runs down
       the first column, then the second — like a printed list). */
    /* Grade Levels: as many columns as the card can hold, not a hard two. A fixed
       2 was written when the card was narrow; on a wide screen it split 15 grades
       8/7 and left the entire bottom half of the card empty while stretching a
       three-character grade across a 700px field. Sizing by COLUMN WIDTH instead
       fills the card at any width and keeps each field sized to what it holds.
       Order still runs down a column then across, like a printed list. */
    /* Grade Levels used to be a CSS multi-column list — it snaked down one column
       and back up the next, so it was the one screen in Site Config where the row
       under a row wasn't the next item, and reordering read as nonsense. It is a
       list like every other list now; see the column definitions below. */
    #section-content .card-sep { margin: 14px 0; }
    .profile-role-gate { display: none; align-items: center; gap: 10px; padding: 14px; background: var(--accent-light); border: 1px solid rgba(var(--accent-rgb),.18); border-radius: 11px; margin-top: 14px; margin-bottom: 14px; }
    .profile-role-gate .ic { font-size: var(--fs-lg); color: var(--accent); flex-shrink: 0; }
    .prg-label { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--accent-dark); }
    .prg-note { font-size: var(--fs-sm); color: var(--muted); margin-top: 1px; }
    @media (max-width: 820px) { .profile-role-gate { display: flex; } }
    .intg-top { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; }
    .intg-top .intg-badge { margin-left: auto; }   /* push the status badge to the right edge */
    .intg-name { font-size: var(--fs-xl); font-weight: var(--fw-bold); }
    .intg-desc { font-size: var(--fs-md); color: var(--muted); margin: 0 0 10px; line-height: var(--lh-body); }
    /* The badge is the status LIGHT of a connection card, so it carries a dot as well as
       a colour — state you can read at arm's length, and read at all if green and grey
       look the same to you. The dot is currentColor, so it is simply whatever the badge
       already is. */
    .intg-badge { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .4px; padding: 3px 10px; border-radius: 999px; }
    .intg-badge::before { content: ''; width: 6px; height: 6px; border-radius: 50%; background: currentColor; flex: none; }
    .badge-on { background: var(--ok-bg); color: var(--ok); }
    .badge-off { background: var(--neutral-bg); color: var(--neutral); }   /* was a pair of hardcoded greys — the status ladder owns these two values */
    /* A live connection should be visible without reading every badge on the screen.
       .is-on (set by setIntgBadge, never at a call site) tints the card's own edge and
       ground with the SAME green the badge uses. This is My Profile's two personal
       integration cards now — Site Config's Accounts screen is a config list, and its
       equivalent is the .conn-on dot. */
    .intg-card.is-on { border-color: color-mix(in srgb, var(--ok) 34%, var(--border)); background: color-mix(in srgb, var(--ok-bg) 22%, var(--card)); }

    /* Canva design picker */
    .canva-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; }
    .canva-thumb { border: 1px solid var(--border); border-radius: 10px; overflow: hidden; cursor: pointer; background: #fff; transition: border-color .15s, box-shadow .15s; text-align: left; padding: 0; font-family: inherit; }
    .canva-thumb:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(var(--accent-rgb),.12); }
    .canva-thumb img { width: 100%; aspect-ratio: 4/3; object-fit: cover; display: block; background: #f3f4f6; }
    .canva-thumb .ct-title { padding: 8px 10px; font-size: var(--fs-md); font-weight: var(--fw-semi); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

    /* ── HOME ON A PHONE: THE PAGE DOES NOT SCROLL ───────────────────────────
       The frame fills the space between the header and the tab bar, and the events list
       inside it is the only thing that moves. This block used to be a careful column of
       flex bases and floors, all of it arithmetic about how to divide a page that was
       always slightly too tall (a 380px chat rail, a calendar block with a two-event
       floor, a shortcut row fighting the tab bar). None of that survives the frame:
       there is one scroller, so nothing has to be traded against anything.

       .hm-body and .hm-rail go display:contents, which hands their children to the
       frame's own flex column — that is what lets ONE set of quick-link tiles be the
       rail's 2×2 grid on desktop and the pinned footer row here, with no second DOM.
       `order` then puts the regions in the phone's reading order. */
    @media (max-width: 880px) {
      .home-wrap { padding: 12px 14px; }   /* 14px horizontal matches the app-container gutter */
      .home-announce { margin-bottom: 0; }
      .hm-body, .hm-rail { display: contents; }
      .home-frame > .home-announce { order: 1; }
      .home-frame > .onboard-panel { order: 2; }
      .home-frame > #home-advocacy { order: 3; padding: 10px 15px; }
      .hm-setup { order: 4; padding: 12px 15px; }
      .home-frame > #home-myreq { order: 5; padding: 10px 15px; }
      .submit-hero { order: 6; padding: 12px 15px; }
      /* THE EVENTS REGION HAS A FLOOR, and it is the one number this layout still needs.
         Everything above it (announcement, onboarding, setup callout, request card, hero)
         is flex:none, so with enough of them on screen at once this region is what gets
         squeezed — and `min-height: 0` let it go to literally zero, which is the events
         list disappearing rather than scrolling. The floor is its head plus two condensed
         rows: the least this can be and still be an "Upcoming Events". Past that the card
         clips like any other frame, and the list scrolls inside its floor. */
      .hm-main { order: 7; flex: 1 1 auto; min-height: calc(38px + 2 * 42px);
        display: flex; flex-direction: column; }
      /* The tiles are the PINNED FOOTER: one row, four across, above the tab bar. */
      .hm-quick { order: 8; flex: none; min-height: 0; }
      .hm-quick .hm-sechead { display: none; }
      .hm-tiles { flex: none; display: flex; gap: 8px; padding: 10px 15px;
        border-top: 1px solid var(--border); }
      .hm-tile { flex: 1 1 0; min-width: 0; gap: 5px; padding: 9px 4px; }
      .hm-tile-l { font-size: var(--fs-2xs); line-height: var(--lh-tight); overflow-wrap: anywhere; }
      .hm-tile-ico .bb-ico { width: 30px; height: 30px; }
      .hm-tile-ico .bb-ico.lucide .bb-ico-svg { width: 17px; height: 17px; }
      .hm-tile-ico .bb-ico.gm svg, .hm-tile-ico .bb-ico.img img { width: 30px; height: 30px; }
      .hm-sechead { padding: 10px 15px 8px; }
      .submit-hero .sh-cta { padding: 11px 16px; font-size: var(--fs-md); }
      /* Four tiles across 375px is ~79px each: the label has to give way rather than
         print "The DCEA Sideline Swag Sto…". data-cols comes from renderShortcuts. */
      .hm-tiles[data-cols="4"] .hm-tile { padding: 9px 3px; }
    }

    /* Stats */
    .stats-row { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 28px; }
    .stat-chip { background: var(--card); border: 1px solid var(--border); border-radius: 9px; padding: 9px 16px; display: flex; align-items: center; gap: 8px; font-size: var(--fs-md); }
    .stat-chip .dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }

    /* ── THE SECTION HEADER — the grey title above the card ─────────────────
       LEGAL IN EXACTLY ONE CASE: a section that holds SEVERAL screens — Site Config,
       Site Settings, Platform, State, Profile — where this names the SECTION and the
       band inside each card names the SCREEN. That is a real hierarchy signal.

       On a single-screen page it is the same word twice and ~45px of fold, and it
       leaves the card below it anonymous: a white box that opens with a search input
       instead of saying what it is. Every such page has been converted — the title
       lives in `.page-head` now (see THE PAGE FRAME). Do not add one back.
       `_frametest.mjs` fails any framed screen that grows one. */
    .section-header { display: flex; align-items: center; gap: 12px; margin-bottom: 18px; flex-wrap: wrap; }
    .section-header h2 { font-size: var(--fs-3xl); font-weight: var(--fw-bold); flex: 1; align-self: flex-start; }   /* pin the title to the header's top so it sits at the SAME spot on every page, even when a taller action button (New Request / Post an item) shares the row */
    .back-btn { background: none; border: none; font-family: inherit; font-size: var(--fs-md); color: var(--muted); cursor: pointer; display: flex; align-items: center; gap: 5px; padding: 0; margin-bottom: 14px; transition: color .15s; }
    .back-btn:hover { color: var(--text); }

    /* Ticket list */
    .ticket-list { display: flex; flex-direction: column; gap: 6px; }
    .ticket-row { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); padding: 15px 18px; cursor: pointer; display: flex; align-items: center; gap: 12px; box-shadow: var(--shadow); transition: border-color .15s; }
    .ticket-row:hover { border-color: var(--accent); }
    .tr-main { flex: 1; min-width: 0; }
    .tr-title { font-size: var(--fs-lg); font-weight: var(--fw-semi); margin-bottom: 4px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .tr-meta { font-size: var(--fs-sm); color: var(--muted); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
    .tr-right { display: flex; align-items: center; gap: 8px; flex-shrink: 0; }
    .tr-date { font-size: var(--fs-sm); color: var(--muted); white-space: nowrap; }
    .ticket-row.unread .tr-title { font-weight: var(--fw-display); }
    .unread-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
    /* Inbox triage extras */
    .inbox-stats { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 12px; }
    .inbox-search { width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 9px; font-size: var(--fs-md); font-family: inherit; background: #fff; color: var(--text); margin-bottom: 12px; }
    .inbox-search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    .tr-avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0; background: var(--accent-light); color: var(--accent-dark); display: grid; place-items: center; font-size: var(--fs-xs); font-weight: var(--fw-bold); }
    .tr-assign { max-width: 150px; padding: 4px 7px; border: 1px solid var(--border); border-radius: 7px; font-size: var(--fs-sm); font-family: inherit; background: #fff; color: var(--muted); cursor: pointer; }
    .tr-assign.on { border-color: var(--accent); color: var(--accent-dark); }
    .tr-assign:focus { outline: none; border-color: var(--accent); }
    .tr-assignee { font-size: var(--fs-sm); color: var(--muted); white-space: nowrap; }

    /* Security audit log */
    /* (.audit-table lived here — a bordered <table> with its own head, cell padding and
       hover. The Security Log is .list-row rows under a .list-head now, like every other
       list in the section; see THE CONFIG ROW.)
       The Member Directory used to render as `.audit-table .mem-table`, borrowing an
       admin log's chrome for a member-facing screen. It owns it outright now — these
       four rules are what it was inheriting, and nothing else reads them. */
    .mem-table { width: 100%; border-collapse: collapse; background: #fff; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
    .mem-table th { text-align: left; font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: var(--fw-bold); padding: 9px 12px; background: var(--bg); border-bottom: 1px solid var(--border); }
    .mem-table td { padding: 9px 12px; font-size: var(--fs-sm); border-bottom: 1px solid var(--border); vertical-align: top; }
    .mem-table tr:last-child td { border-bottom: none; }
    /* (.mem-c-addr's `word-break: break-word` lived here, from the days when the
       address was the one cell allowed to wrap. Every cell in the table is one line
       now — a row that grows bends the lane the eye is following — so the address
       ellipsizes with the rest of them.) */
    /* Directory rows — name cell with avatar; "hero" rows (people with a photo) sit taller with a larger portrait. */
    .mem-name { display: flex; align-items: center; gap: 10px; }
    .mem-ava { flex-shrink: 0; width: 34px; height: 34px; border-radius: 50%; overflow: hidden; display: grid; place-items: center; background: var(--accent); color: var(--on-accent); font-size: var(--fs-sm); font-weight: var(--fw-bold); }
    .mem-ava img { width: 100%; height: 100%; object-fit: cover; }
    .mem-id { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
    .mem-nm { font-size: var(--fs-lg); line-height: var(--lh-tight); }
    .mem-pos { font-size: var(--fs-2xs); color: var(--muted); }
    /* Building / Grade — small labelled columns under the name; subjects show as icons only. */
    .mem-foot { display: flex; flex-wrap: wrap; align-items: flex-end; gap: 3px 16px; margin-top: 4px; }
    .mf-col { display: flex; flex-direction: column; line-height: var(--lh-tight); }
    .mf-lbl { font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
    .mf-val { font-size: var(--fs-sm); color: var(--text); }
    .mf-col-subj { flex-direction: row; align-items: center; gap: 5px; }
    .mf-subj-ic { display: inline-flex; }
    .mf-subj-ic svg { width: 16px; height: 16px; }
    /* Class subjects: a 2-wide icon grid, fills left column first (top→bottom),
       then spills rightward. Capped at 2 rows (more than 4 subjects add columns).
       1 subject: centered in the column. 2 subjects: side-by-side, single row.
       3+: 2-row grid (default). */
    .mem-subjects { flex: 0 0 auto; width: 42px; display: grid; grid-auto-flow: column; grid-template-rows: auto auto; justify-content: start; gap: 5px 6px; align-content: start; }
    .mem-subj-n1 { grid-template-rows: auto; justify-content: center; align-self: center; }
    .mem-subj-n2 { grid-template-rows: auto; align-self: center; }
    .mem-subj-ic { display: inline-flex; }
    .mem-subj-ic svg { width: 18px; height: 18px; }
    /* Contact footer (mobile card only) — address left, phone/email column right. */
    .memf { display: flex; justify-content: space-between; gap: 14px; align-items: center; }
    .memf-l { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 6px; align-items: flex-end; }
    .memf-row { display: inline-flex; align-items: center; gap: 7px; font-size: var(--fs-md); color: var(--accent); text-decoration: none; min-width: 0; max-width: 100%; }
    .memf-l .memf-row { flex-direction: row-reverse; }
    .memf-row.memf-muted { color: var(--text); }
    .memf-empty { color: var(--muted) !important; }
    .memf-row > span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .memf-ic { flex: 0 0 auto; display: inline-flex; color: var(--muted); }
    .memf-ic .lic { width: 14px; height: 14px; }
    /* The map-pin glyph carries ~2 more units of internal side-whitespace (3u vs ~1u)
       than the phone/mail icons on the right, so its left optical gap reads ~1.2px wider
       at 14px. Pull the pin left to match, making the left/right margins symmetrical. */
    .memf-addr .memf-ic { margin-left: -1.2px; }
    .memf-addr { flex: 0 0 auto; max-width: 52%; display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-sm); line-height: var(--lh-snug); color: var(--accent); text-decoration: none; }
    /* Keep the address to exactly two rows — street, then city/state/zip — never
       wrapping the street onto a third line or truncating the city/state/zip. */
    .memf-addr-tx { min-width: 0; white-space: nowrap; }
    /* "Kept private" contact fields: a lock + an unreadable blurred sample, so the
       field is clearly present but marked private (real value never sent here). */
    .priv-field { display: inline-flex; align-items: center; gap: 5px; color: var(--muted); cursor: default; }
    .memf-row.priv-field { gap: 7px; }
    .memf-addr.priv-field { gap: 6px; }
    .priv-blur { user-select: none; -webkit-user-select: none; pointer-events: none; white-space: nowrap; color: var(--muted); opacity: .7; letter-spacing: .5px; font-size: .92em; }
    .priv-peek { background: none; border: none; padding: 0; cursor: pointer; color: var(--muted); display: grid; place-items: center; flex-shrink: 0; line-height: var(--lh-none); }
    .priv-peek svg.lic { width: 14px; height: 14px; }
    .priv-peek:hover { color: var(--accent); }
    /* Name + assignment share one row as THREE columns: name/role · building/grade
       · class-subject icon stack. The flex row lives on an inner .mem-row div, NOT
       the <td> (display:flex on a table cell lets the cell's intrinsic table width
       ghost the flex sizing). On desktop the columns are FIXED width so they line
       up member-to-member: a narrow-ish name (scooted left), a building/grade column
       sized to the widest one via --mem-assign-w (set by alignAssignColumn() in JS,
       since flexbox can't share a width across rows), and the reserved subject
       quadrant. Mobile overrides these back to shrink-to-fit (see the card block). */
    .mem-row { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 8px 18px; }
    .mem-name { flex: 0 0 auto; width: 160px; }
    .mem-assign { flex: 0 0 auto; width: var(--mem-assign-w, 140px); display: flex; flex-direction: column; align-items: flex-end; gap: 6px; margin-top: 0; }
    /* Right-align grade/school the way phone/email are: icon on the right, value hugging it. */
    .mem-assign .memf-row { flex-direction: row-reverse; }
    /* Multi-building members collapse to one line: truncated short-name list + (N) count.
       Cap the list so a member with many buildings never blows out the shared column width. */
    .mem-pop-trigger { cursor: pointer; }
    .mem-pop-trigger:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
    /* Popover listing the full building/grade set, anchored to the tapped row. */
    .mem-pop { position: fixed; z-index: 1000; min-width: 150px; max-width: 260px; background: var(--card); border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 14px 34px rgba(16,24,40,.18); padding: 7px 0; font-size: var(--fs-md); animation: ssfade .12s ease; }
    .mem-pop-h { font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .5px; color: var(--muted); padding: 3px 13px 6px; text-align: right; }
    /* Icon on the right, names right-aligned hugging it — mirrors the directory rows. */
    .mem-pop-item { display: flex; flex-direction: row-reverse; align-items: center; gap: 8px; padding: 6px 13px; color: var(--text); line-height: var(--lh-snug); text-decoration: none; }
    .mem-pop-item .memf-ic { flex: 0 0 auto; color: var(--muted); }
    .mem-pop-lbl { flex: 1 1 auto; min-width: 0; text-align: right; overflow-wrap: anywhere; }
    .mem-pop-sub { color: var(--muted); font-size: var(--fs-2xs); }
    .mem-pop-link { cursor: pointer; color: var(--accent); text-decoration: none; }
    .mem-pop-link:active { color: var(--accent-dark); }
    /* Subject popover: word on the left, class icon in one tight column just past
       the longest word — mirrors the subjects filter. Every label is pinned to the
       widest one's width (--mem-pop-lbl-w, measured in openMemberPop) and the popover
       hugs its content (min-width:0) instead of floating the icons to the far edge. */
    .mem-pop-subj { min-width: 0; }
    .mem-pop-subj .mem-pop-h { text-align: left; }
    .mem-pop-subj .mem-pop-item { flex-direction: row; }
    .mem-pop-subj .mem-pop-item .memf-ic { order: 1; }
    .mem-pop-subj .mem-pop-lbl { flex: 0 1 var(--mem-pop-lbl-w, auto); text-align: left; }
    .mem-pop-item .subj-ic { width: 16px; height: 16px; }
    .mem-bldg-count { flex: 0 0 auto; }
    /* "needed" variant (Create Account validation): left-aligned with the icon in
       an aligned left column, and a red title so it reads as a validation error. */
    .mem-pop--needed .mem-pop-h { text-align: left; color: var(--danger); }
    .mem-pop--needed .mem-pop-item { flex-direction: row; }
    .mem-pop--needed .mem-pop-lbl { text-align: left; }
    .mem-bldg-room { color: var(--muted); font-size: var(--fs-2xs); }

    /* ── Hover tooltips (custom, replacing native title=) ──────────────────
       [data-tip] renders a styled bubble on hover/focus — the brand-black card
       language of .mem-pop, minus the arrow (single ::after so it never collides
       with a target's own pseudo — see the .mfilter dot below, moved to ::before).
       HOVER-ONLY: the whole block is gated behind a fine-pointer + hover query, so
       touch devices show nothing — they keep the element's aria-label for AT, and
       the deliberate TAP popovers (.mem-pop, the validation popover) are untouched.
       CONTRACT: every [data-tip] must also carry an aria-label — a CSS bubble is
       invisible to screen readers. Default bubble sits ABOVE; add data-tip-pos="bottom"
       for anchors near the top edge (top toolbars) so it doesn't clip. */
    @media (hover: hover) and (pointer: fine) {
      /* The bubble now lives in ONE body-level fixed layer that initTooltips (app.js)
         positions on hover/focus — so NO overflow:hidden card, toolbar, or scroller can
         clip it (the old ::after bubble sliced off at whatever box contained it, which is
         the "black half-tooltip" bug). It auto-flips above/below and clamps to the
         viewport; data-tip-pos="bottom" forces the below-anchor placement. */
      .tip-bubble {
        position: fixed; top: 0; left: 0; z-index: 1200; pointer-events: none;
        background: #161616; color: #fff;
        font-size: var(--fs-sm); font-weight: var(--fw-medium); line-height: var(--lh-snug); text-align: center; letter-spacing: .1px;
        white-space: normal; width: max-content; max-width: 240px;
        padding: 6px 9px; border-radius: 7px; box-shadow: 0 8px 22px rgba(16,24,40,.24);
        opacity: 0; transition: opacity .12s ease;
      }
      .tip-bubble.tip-on { opacity: 1; }
    }
    .mem-bldg-list { min-width: 0; max-width: 62px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    /* Building value as a clickable maps link with a location pin */
    .mf-bldg { display: inline-flex; align-items: center; gap: 3px; color: var(--accent); text-decoration: none; cursor: pointer; }
    .mf-bldg:hover { text-decoration: underline; }
    .mf-pin { display: inline-flex; }
    .mf-pin .lic { width: 11px; height: 11px; }
    .mem-link { color: var(--accent); }
    .mem-link-muted { color: var(--text); }
    /* TWO SHAPES, ONE ROW. Every member renders both: the columnized cells the desktop
       table shows, and the name block + combined contact footer the phone card shows.
       Each set is off by default and turned on by exactly one breakpoint, so neither
       layout has to hide the other's cells one at a time — and the row is never rebuilt
       on resize. */
    .mem-c-footer { display: none; }   /* the phone card (≤760px) re-shows it */
    .mem-c-desk   { display: none; }   /* the desktop table (≥761px) re-shows it */
    /* A field this member simply doesn't have. Muted, and the same mark in every column. */
    .mem-dash { color: var(--muted); }

    /* ════════════════════════════════════════════════════════════════════
       DESKTOP MEMBER DIRECTORY — centered card grid (≥761px)
       The desktop directory is no longer a stretched table: above the mobile
       breakpoint the SAME .mem-table rows reflow (CSS-only) into a centered grid
       of equal, FIXED-HEIGHT person tiles. The ≤760px gold-standard card block
       (@media max-width:760px) is untouched, so mobile cannot regress.
       This also sets the app-wide desktop WIDTH baseline: centre the content,
       cap it, never stretch a single element to monitor width. Other pages opt
       in by setting --page-max on their own .app-container.
       ════════════════════════════════════════════════════════════════════ */
    @media (min-width: 761px) {
      /* ── Desktop width baseline + "edge back-bar" layering ──────────────────
         Convention (chosen via the layering mockup, 2026-06-25): the back button
         hugs the page gutter in a full-width strip like the chrome; the title,
         toolbar and content centre in a --page-max column. Pages with no back
         button simply centre the whole capped container. Each page sets its own
         --page-max; the desktop baseline is 1500px (calendar-width) across every page. */
      #section-members { --page-w: 1500px; }
      #section-requests, #section-profile, #section-calendar, #section-submit,
      #section-cms-page, #section-ticket, #section-content, #section-settings,
      #section-voting, #section-esign, #section-state, #section-documents,
      #section-it, #section-supply, #section-help, #section-state-page,
      #section-volunteer, #section-advocacy, #section-laws { --page-w: 1500px; }
      /* Laws used to opt OUT of the 1500px standard (a 960px column) because it is
         the one READING page: one card per row at 1500px runs ~150 characters to the
         line. It is back on the standard column — the measure is protected instead by
         breaking the cards into TWO columns once the reading area is wide enough
         (≥1400px viewport, see .law-main), which lands each card at ~75 characters,
         inside the 65–95 band. Width standard kept, measure kept. */

      /* ── THE page-width standard (single source of truth) ───────────────────
         --page-col is the ONE desktop content column every primary page uses:
         capped at --page-max, centred, with a 22px gutter on each side (the +44
         is 2×22, so the INNER content lands on the exact same left/right edge on
         every page). Anything that wants "the standard width" sets
         `width: var(--page-col); margin-inline: auto;` — never a bespoke min()/max().
         Change the column here, once, and Home, Requests, Members, the settings
         shell (Site Config / Settings / Platform / State / Profile) and the
         dashboard all move together. Do NOT reintroduce per-page widths. */
      /* --page-w is the width standard in PHYSICAL pixels (what the member actually
         measures on the glass). --page-max is that same standard expressed in the
         CSS pixels the page lays out in, which is why it divides by --ui-zoom.
         WHY: Screen Fit (Profile → Display) works by `body { zoom: z }`, so at
         z=0.8 the shell lays out in 1/0.8 as many CSS pixels and every CSS px
         renders at 0.8 physical. A cap written as a flat `1500px` is therefore a
         cap on CSS pixels — the column stayed 1500 CSS px and rendered 1200 px
         wide, so on any screen ALREADY past the cap "More"/"Most" just shrank the
         page and grew the side margins: strictly worse, which is the opposite of
         what the control promises. Dividing pins the column to 1500 PHYSICAL px at
         every setting. Text is already pinned the same way (--fs-scale ÷ z in
         setDisplayFit), so what actually changes at z<1 is the stuff measured in
         raw px — card padding, gaps, grid minmax() tracks — which shrinks and lets
         MORE fit inside the same physical column. That is the feature. Under-cap
         screens are unaffected: they were never at the cap, they hit `100% - 44px`. */
      .app-section { --page-max: calc(var(--page-w, 1500px) / var(--ui-zoom, 1)); }
      .app-section { --page-col: min(calc(var(--page-max) + 44px), 100% - 44px); }

      /* ── THE page-frame standard (vertical sibling of --page-col) ───────────
         The width war is won; this is the same treatment for the TOP and BOTTOM.
         Three tokens, defined ONCE, drive every page's vertical rhythm so nothing
         bounces:
           --page-top     nav → page header (the top rail)
           --page-bottom  last content → viewport edge (the ONE bottom line)
           --header-gap   page header → content (the gap under the H2)
         22px top/bottom matches the 22px side gutter, so the capped content column
         floats in an EVEN margin on all four sides. Rulesets that follow:
           • header present (default): title at --page-top, content --header-gap below.
           • no header (Home): content begins at --page-top — no header+gap to honor.
           • back button present: the back-bar takes the top rail and pushes the
             header + body DOWN by its height; the bottom line stays put (we eat the
             loss in the content body).
         Change a number here, once, and every page moves together. Do NOT
         reintroduce per-page 26/14/22 top/bottom padding. Desktop-only (min-width:761);
         the mobile shell keeps its own 16/14 rhythm and is intentionally untouched. */
      .app-section { --page-top: 22px; --page-bottom: 22px; --header-gap: 18px; --gutter: 22px; }

      /* Apply the frame to every page container in one place. Sides stay whatever
         each container already sets (22px gutter, or 0 on the full-height column
         pages that cap their card instead) — this rule governs ONLY top + bottom. */
      .app-section.active > .app-container,
      #section-home .home-wrap { padding-top: var(--page-top); padding-bottom: var(--page-bottom); }
      /* Header → content gap is a token too, so it can never drift per-page. */
      .app-section .section-header { margin-bottom: var(--header-gap); }
      /* (Contract's old .chat-wrap header rule lived here. That wrapper went away with
         the full-page chat; the reader carries its own .contract-viewer-head now.) */

      /* ONE identical column for every primary page. The gutter (the .app-container /
         .cal-wrap padding) sits OUTSIDE the cap, and the capped children centre inside
         it, so card borders + header titles land on the EXACT same left/right edges on
         Members, Submit, Requests, Profile, Calendar and the CMS content pages — no
         per-page wiggle. width:100% so flex-column children fill the cap instead of
         shrinking to content. */
      #section-members .app-container > :not(.back-btn),
      #section-submit  .app-container > :not(.back-btn),
      #section-cms-page .app-container > :not(.back-btn),
      #section-ticket .app-container > :not(.back-btn),
      #section-content .app-container > :not(.back-btn),
      #section-settings .app-container > :not(.back-btn),
      #section-it .app-container > :not(.back-btn),
      #section-state .app-container > :not(.back-btn),
      #section-documents .app-container > :not(.back-btn),
      #section-voting .app-container > :not(.back-btn),
      #section-esign .app-container > :not(.back-btn),
      #section-supply .app-container > :not(.back-btn),
      #section-volunteer .app-container > :not(.back-btn),
      #section-advocacy .app-container > :not(.back-btn),
      #section-laws .app-container > :not(.back-btn),
      #section-help .app-container > :not(.back-btn),
      #section-state-page .app-container > :not(.back-btn),
      #section-designer .app-container > :not(.back-btn),
      #section-compose .app-container > :not(.back-btn),
      /* The Local Exchange joined the standard here instead of keeping its own
         `#section-businesses .app-container { width: var(--page-col) }`, which was
         written outside every media query and so evaluated to `width: auto` on a phone —
         see the note at the top of the Member Businesses block. */
      #section-businesses .app-container > :not(.back-btn),
      #section-profile .app-container > *,
      #section-requests > .app-container > *,
      /* Home and the leader Dashboard join the standard here. Each used to cap ITSELF —
         its own 22px padding on top of .app-container's, plus --page-col narrowing the
         container — which put their card on a 44px side gutter against every other
         screen's 22. The container keeps the gutter; the band and the card take the cap. */
      #section-home .home-wrap > *,
      #section-dashboard.active > .app-container > *,
      #section-calendar .cal-wrap > * { max-width: var(--page-max); width: 100%; margin-inline: auto; }

      /* Put the remaining back-button / detail pages on the SAME --page-col as the
         column pages above, so their content (and the back-bar) lands on the exact
         same left edge at every width — no more 22-vs-44 drift on laptop-width
         screens. (Members/Requests/Content/Settings/Platform/State already carry the
         column via their own container rules; these are the stragglers.) The child
         cap above still limits inner content to --page-max, so wide screens are
         unchanged — this only fixes the narrow-desktop gutter.
         Done with PADDING, not width: .app-container IS the scroller
         (overflow-y:auto), so narrowing it pulls the scrollbar 22px in off the
         window edge — a floating bar mid-page that the full-width pages don't
         have. Padding leaves the scroller full-bleed and lands the inner content
         on the identical 44px edge.
         Set on the SECTION, not the container: the back-bar is a sibling of the
         container (see below) and has to land on the identical gutter.

         ── AND IT IS DELIBERATELY NOT APPLIED ANY MORE ────────────────────────
         The paragraph above closed its comment one line early — it ended with a
         comment terminator after "44px edge." — so everything after it, and the
         twelve-selector rule that followed it, parsed as one invalid selector and
         was dropped. Same defect killed two other rules in this file, including
         the one that makes form controls follow Text Size; all three are fixed,
         and _frametest now asserts the file has no unbalanced comment. Note the
         third one closed early on a `--fs-*` glob inside its own prose, so the
         hazard is not only forgetting a terminator — it is writing one by accident.

         So the rule has not been in effect since that typo, which is long enough
         that every one of these pages was tuned at 22.

         Reviving it was tried and reverted, because it now does the OPPOSITE of
         what it says: measured at 1500, Requests and the Directory sit at 22 while
         a revived ticket sat at 44 — it creates the 22-vs-44 drift it was written
         to remove. Its premise has been overtaken twice over. The back-bar it was
         aligning to is gone (the trail lives in the band now), and the page frame
         put every page's header inside the column, so the "stragglers" it names
         are no longer stragglers.

         Kept as a comment rather than deleted: the reasoning is sound for the
         layout it was written against, and someone will eventually wonder why the
         detail pages don't carry a 44px gutter. This is why. If it is ever wanted
         back, it needs re-deriving against the frame, not un-commenting. */

      /* ── The back-bar (desktop) — same object as the phone's ────────────────
         One bar, one behaviour, both breakpoints: full-bleed chrome pinned to the
         top of the scroller, hugging the nav bar above it, with the page content
         tucked underneath. Desktop only differs in being SLIMMER (the pointer
         doesn't need a 48px tap target) and in sitting at the desktop gutter.
         --gutter is the container's side padding; the bar cancels it with equal
         negative margins so it bleeds edge-to-edge, then re-applies it as its own
         padding so the "← Back" label still lands on the page's left edge.
         Applies to EVERY back-button page via one generic selector — the old rule
         named six sections and the rest were left with a bare underlined button.
         (GroupMe carries its own band now, so it has no back button in the
         .app-container for this rule to reach — deliberately out of scope.)

         THE BAR SITS OUTSIDE THE SCROLLER, as a direct child of the .app-section.
         It used to be the scroller's first child, sticky at top:0 — which looks the
         same until you notice the right edge: .app-container is the scrollport AND
         carries `scrollbar-gutter: stable`, so its padding box stops ~15px short of
         the window and everything inside is clipped there. No width or negative
         margin can reach past it: the bar faded out early and the scrollbar ran down
         the strip where the chrome should have been. As a section child (the section
         is already a flex column) the bar spans the full width and the scroller —
         gutter, scrollbar and all — begins BENEATH it. Sticky is no longer needed;
         a bar above the scrollport can't scroll away. */
      .app-section.active > .app-container { padding-inline: var(--gutter); }
      /* The bar owns the top rail. The gap under it is the scroller's OWN padding,
         not a margin on the bar: a scroller's padding-top scrolls away with the
         content, so the page still slides right up under the divider — the same
         read the sticky version had, without a dead band pinned under the bar. */
      .app-section.active:has(> .back-btn:not(.hidden)) > .app-container { padding-top: var(--header-gap); }
      /* Members/Requests are the other container family — the container ITSELF is
         the capped, centred column (padding 0, margin auto) and does NOT scroll (the
         list inside the card does). Their bar is a SECTION child like everyone
         else's, so it lands on the page gutter rather than the column's left edge;
         only their own ID-level padding-top rule has to be answered here. */
      #section-members.active:has(> .back-btn:not(.hidden)) > .app-container,
      #section-requests.active:has(> .back-btn:not(.hidden)) > .app-container { padding-top: var(--header-gap); }
      .app-section.active > .back-btn {
        z-index: 5;
        justify-content: flex-start;
        background: var(--bg);
        margin: 0;
        padding: 10px var(--gutter) 9px;
        border-bottom: 1px solid var(--border);
        box-shadow: 0 6px 10px -6px rgba(16,24,40,.20);
      }

      /* (Members/Requests used to keep the bar INSIDE their column and break it back
         out to the window with a 100vw + negative-margin trick, which landed the
         "← Back" label on the COLUMN's left edge — ~190px in on a wide monitor,
         while every other page's label sat on the page gutter. The bar is a section
         child on those two pages now, so the generic rule above covers them and the
         label lines up with the request detail page at every width.) */

      /* Site Config + Site Settings action rows: positive buttons right-align with
         uniform width, mirroring My Profile. (Inline add-forms use .flex-gap, not
         .btn-row, so they keep input-grows; the secret-field Replace/Cancel aren't
         .btn-row either, so they're untouched.) Desktop-only — mobile is unchanged.
         #teamgmail-actions used to be grafted on here BY ID, which is why one card on
         Connections → Accounts right-aligned its button at 132px while the two beside
         it sat left at their natural width. That convention is for a FORM's action row
         — a row that ends in Save. A connection card has no form and no footer, so its
         action belongs at the start edge under its own copy, like every other card's
         (.intg-act). Don't re-add an ID here; if a row needs this shape, give it
         .btn-row. */
      #section-content .btn-row,
      #section-settings .btn-row { display: flex; justify-content: flex-end; gap: 8px; flex-wrap: wrap; }
      #section-content .btn-row > .btn,
      #section-settings .btn-row > .btn { min-width: 132px; justify-content: center; }
      /* Negative/neutral actions (secondary: Revert, Send Test, + Add group) anchor
         LEFT; the primary Save anchors RIGHT. A lone primary stays right (flex-end
         above). The :not(.hidden) guard keeps Save right when Revert is hidden.
         Mirrors My Profile's :has(.ghost-danger) precedent. */
      #section-settings .btn-row:has(.secondary:not(.hidden)) { justify-content: space-between; }

      /* SPA convention: the page fills the viewport and does NOT scroll — the right
         content panel scrolls inside itself (the sidebar nav stays put). Mirrors the
         request-detail page. The header (back bar, title, intro) sits above a flex:1
         shell whose content column is the only scroller. Desktop-only. */
      #section-content .app-container,
      #section-settings .app-container,
      #section-it .app-container,
      #section-state .app-container,
      #section-profile .app-container { display: flex; flex-direction: column; overflow: hidden;
        width: var(--page-col); margin-inline: auto; }   /* same column as Home/Requests/Members so the sidebar's left edge lines up */
      #section-content .settings-shell,
      #section-settings .settings-shell,
      #section-it .settings-shell,
      #section-state .settings-shell,
      #section-profile .settings-shell { flex: 1 1 0; min-height: 0; grid-template-rows: minmax(0, 1fr); }
      /* The content COLUMN fills the height but doesn't scroll itself — its single
         active card is the scroller, so the scrollbar sits INSIDE the card's border
         (not floating out in the column). scrollbar-gutter keeps the gutter reserved
         so content never shifts when the bar appears/disappears. */
      #section-content .settings-content,
      #section-settings .settings-content,
      #section-it .settings-content,
      #section-state .settings-content { align-self: stretch; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
      /* The sidebar nav fills the full page height and scrolls INSIDE itself with a
         permanently-reserved gutter, so a long nav (Site Config / Platform / State
         carry many rows) never overflows the page or gets clipped — it stays put while
         the content card on the right scrolls independently. The :has() selector
         re-asserts full height even when a footer (cform) panel is active, overriding
         the align-self:start set for that case earlier. */
      #section-content .settings-sidebar,
      #section-settings .settings-sidebar,
      #section-it .settings-sidebar,
      #section-state .settings-sidebar,
      :is(#section-content, #section-settings, #section-it, #section-state) .app-container:has(.ss-panel.page-panel.active) .settings-sidebar {
        position: static; align-self: stretch; min-height: 0;
        overflow-y: auto; scrollbar-gutter: stable;
        scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
      :is(#section-content, #section-settings, #section-it, #section-state) .settings-sidebar::-webkit-scrollbar { width: 12px; }
      :is(#section-content, #section-settings, #section-it, #section-state) .settings-sidebar::-webkit-scrollbar-track { background: transparent; }
      :is(#section-content, #section-settings, #section-it, #section-state) .settings-sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; border: 3px solid transparent; background-clip: padding-box; }
      :is(#section-content, #section-settings, #section-it, #section-state) .settings-sidebar::-webkit-scrollbar-thumb:hover { background: var(--muted); background-clip: padding-box; }
      #section-content .ss-panel.active,
      #section-settings .ss-panel.active,
      #section-it .ss-panel.active,
      #section-state .ss-panel.active { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
      /* A panel's single card ALWAYS fills the full page height — a short form no
         longer leaves a stub card floating in a tall column — and scrolls inside
         itself with a permanently-reserved gutter. cform (footer) cards are excluded:
         their own body is the scroller and the Save footer stays pinned (see above). */
      #section-content .ss-panel.active > .card.padded:not(.page-frame):only-child,
      #section-content .ss-panel.active > .intg-card:only-child,
      #section-settings .ss-panel.active > .card.padded:not(.page-frame):only-child,
      #section-settings .ss-panel.active > .intg-card:only-child,
      #section-it .ss-panel.active > .card.padded:not(.page-frame):only-child,
      #section-it .ss-panel.active > .intg-card:only-child,
      #section-state .ss-panel.active > .card.padded:not(.page-frame):only-child,
      #section-state .ss-panel.active > .intg-card:only-child { flex: 1 1 0; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; }
      /* Multi-card panels (e.g. Buildings) keep their cards at natural height and
         scroll as a group instead of stretching each card. */
      #section-content .ss-panel.active:has(> .card.padded ~ .card.padded),
      #section-settings .ss-panel.active:has(> .card.padded ~ .card.padded),
      #section-it .ss-panel.active:has(> .card.padded ~ .card.padded),
      #section-state .ss-panel.active:has(> .card.padded ~ .card.padded) { overflow-y: auto; scrollbar-gutter: stable; }
      /* Footer-card panels keep the body-scrolls / footer-pinned model on mobile
         too — the card itself must NOT become the scroller, or the Save footer
         would scroll off. The inner .page-body is the scroller (rule above). */
      :is(#section-content, #section-settings, #section-it, #section-state) .ss-panel.page-panel.active > .card.padded.page-frame { max-height: none; overflow: hidden; }
      /* My Profile stacks several cards per panel, so its column stays the scroller
         (gutter reserved the same way). */
      #section-profile .settings-content { align-self: stretch; min-height: 0; overflow-y: auto; scrollbar-gutter: stable; }

      /* The internal scrollers above paint their scrollbar INSIDE the card's rounded
         border. A default chunky bar fills an opaque grey square into the top/bottom-right
         corners, so the rounded corner reads as square. Make the bar thin with a
         transparent track + inset rounded thumb so the corner shows through. */
      #section-content .ss-panel.active > .card.padded,
      #section-content .ss-panel.active > .intg-card,
      #section-settings .ss-panel.active > .card.padded,
      #section-settings .ss-panel.active > .intg-card,
      #section-it .ss-panel.active > .card.padded,
      #section-it .ss-panel.active > .intg-card,
      #section-state .ss-panel.active > .card.padded,
      #section-state .ss-panel.active > .intg-card,
      #section-profile .settings-content { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
      #section-content .ss-panel.active > .card.padded::-webkit-scrollbar,
      #section-content .ss-panel.active > .intg-card::-webkit-scrollbar,
      #section-settings .ss-panel.active > .card.padded::-webkit-scrollbar,
      #section-settings .ss-panel.active > .intg-card::-webkit-scrollbar,
      #section-it .ss-panel.active > .card.padded::-webkit-scrollbar,
      #section-it .ss-panel.active > .intg-card::-webkit-scrollbar,
      #section-state .ss-panel.active > .card.padded::-webkit-scrollbar,
      #section-state .ss-panel.active > .intg-card::-webkit-scrollbar,
      #section-profile .settings-content::-webkit-scrollbar { width: 12px; }
      #section-content .ss-panel.active > .card.padded::-webkit-scrollbar-track,
      #section-content .ss-panel.active > .intg-card::-webkit-scrollbar-track,
      #section-settings .ss-panel.active > .card.padded::-webkit-scrollbar-track,
      #section-settings .ss-panel.active > .intg-card::-webkit-scrollbar-track,
      #section-profile .settings-content::-webkit-scrollbar-track { background: transparent; }
      #section-content .ss-panel.active > .card.padded::-webkit-scrollbar-thumb,
      #section-content .ss-panel.active > .intg-card::-webkit-scrollbar-thumb,
      #section-settings .ss-panel.active > .card.padded::-webkit-scrollbar-thumb,
      #section-settings .ss-panel.active > .intg-card::-webkit-scrollbar-thumb,
      #section-it .ss-panel.active > .card.padded::-webkit-scrollbar-thumb,
      #section-it .ss-panel.active > .intg-card::-webkit-scrollbar-thumb,
      #section-state .ss-panel.active > .card.padded::-webkit-scrollbar-thumb,
      #section-state .ss-panel.active > .intg-card::-webkit-scrollbar-thumb,
      #section-profile .settings-content::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; border: 3px solid transparent; background-clip: padding-box; }
      #section-content .ss-panel.active > .card.padded::-webkit-scrollbar-thumb:hover,
      #section-content .ss-panel.active > .intg-card::-webkit-scrollbar-thumb:hover,
      #section-settings .ss-panel.active > .card.padded::-webkit-scrollbar-thumb:hover,
      #section-settings .ss-panel.active > .intg-card::-webkit-scrollbar-thumb:hover,
      #section-profile .settings-content::-webkit-scrollbar-thumb:hover { background: var(--muted); background-clip: padding-box; }

      /* ── The page column (Calendar / Members / Requests) ────────────────────
         The container is just the layout column: capped + centred on the SAME
         --page-col as every other page, plain (no card chrome). On Members and
         Requests the title no longer sits on it — it moved into the frame's own
         band (see THE PAGE FRAME) — so what this rule does now is hold the column
         and hand its height down. Calendar still carries a header here until it is
         converted. */
      #section-calendar .cal-wrap,
      #section-members .app-container,
      #section-requests > .app-container {
        width: var(--page-col); margin-inline: auto; padding-left: 0; padding-right: 0;
        padding-bottom: var(--page-bottom); display: flex; flex-direction: column; overflow: hidden;
        scrollbar-gutter: auto;   /* the container no longer scrolls (the list does), so don't reserve a right gutter — keeps header + card edges symmetric */
        background: transparent; border: 0; box-shadow: none; }
      #section-members .app-container,
      #section-calendar .cal-wrap,
      #section-requests > .app-container { padding-top: var(--page-top); }
      /* (The back-bar used to live inside this column and break back out to the
         window, which meant `overflow: hidden` here clipped it — hence an old
         overflow:visible opt-out. The bar is a section child now, above the column
         entirely, so the clip is harmless and stays.) */
      /* Calendar carries the top rail on its header (the cal-wrap has no top padding),
         so give the header the same --page-top + --header-gap every other page uses. */
      #section-calendar .cal-wrap .section-header { padding-left: 0; padding-right: 0; padding-top: var(--page-top); margin-bottom: var(--header-gap); }
      /* (The white working surface used to be built here, per page: `.page-card`,
         14/18/16 padding, its own border and radius. It is `.page-frame` now — one
         card, defined once, the same object Site Config uses. Nothing page-specific
         is left to declare.) */

      /* Calendar's frame IS the card. It holds the custom calendar (toolbar +
         month/week/schedule body), so give it the same inner padding as the Requests
         card so the toolbar doesn't hug the card's top/side edges. */
      /* The card chrome comes from .page-frame now; only the inner padding the
         calendar toolbar needs is the calendar's own. */
      #section-calendar .cal-frame-wrap { flex: 1 1 0; min-height: 0; padding: 14px 18px 16px; }
      #section-calendar .cal-frame-wrap iframe { border: 0; border-radius: 12px; }
      /* Home + Dashboard used to take --page-col on the CONTAINER here, which narrowed the
         scroller by 44px and (with no `margin-inline: auto`) pinned it left. That was
         invisible while both screens were stacks of floating cards. Now each is ONE card
         whose box is measured, and the container must behave like every other page's: keep
         .app-container's 22px gutter at full bleed and let the CHILDREN carry the cap (the
         child-cap rule above). Narrowing here as well is what produced a 44px gutter. */
      /* ══ THE DIRECTORY IS A TABLE ═══════════════════════════════════════════
         A directory is a COLUMN OF PEOPLE and the eye reads DOWN it: one row per
         member, every fact at the same x on every row. It used to be a 3-wide
         grid of person tiles — a card per member inside the one card the page
         already is — which meant nine facts packed into a paragraph shape at
         three different x positions per screen row. Looking for who teaches 3rd
         grade at Southgate was a zig-zag across three tiles and then down; it is
         one lane now, and the fields that were buried in that paragraph (title,
         building, grade, subjects) are columns with names.

         Same table the config screens draw — see THE LIST IS A BOUNDED TABLE and
         COLUMNIZED ROWS: a filled cap naming the columns, rows inside a box that
         closes on all four sides, one hairline between people. It stays <table>
         markup instead of .list-head + .list-row divs for one reason: the SAME
         rows are the phone's card stack (the @media max-width:760 block near the
         end of this file), which is finished work and is not to be disturbed. So
         the house grid is applied HERE, inside the one query only desktop reads.

         ONE grid definition — --list-cols, exactly as the standard requires —
         shared by the header row and every member row, so a column cannot be
         widened in one and left narrow in the other.

         Name · Title · Building · Grade · Subjects · Email · Phone · Address.
         The two fixed-width numbers are the ones with a known longest value
         (a grade, a formatted phone); everything else is proportional. */
      #section-members .page-stacked {
        --mem-cols: minmax(0, 1.45fr) minmax(0, 1fr) minmax(0, 1.05fr)
                    calc(96px * var(--fs-scale, 1)) calc(70px * var(--fs-scale, 1))
                    minmax(0, 1.7fr) calc(112px * var(--fs-scale, 1)) minmax(0, 1.9fr);
        --list-cols: var(--mem-cols); }
      /* Members below rep rank get a "Contact" button. It is a real column with a real
         header slot, not a control floated over the address — so the grid grows a track
         when the row grows a cell. The eight are written once, above. */
      #section-members .page-stacked:has(.mem-c-action) {
        --list-cols: var(--mem-cols) calc(94px * var(--fs-scale, 1)); }
      /* THE HEAD IS A SIBLING, THE SCROLLER IS THE BODY, TOGETHER THEY'RE ONE BOX.
         The border, radius and background used to live on .mem-table, and with 570
         rows that box was 26,250px tall — its rounded corners and bottom border
         scrolled off screen. First iteration moved the box onto #members-list (the
         scroller) and pinned the head sticky inside it; second iteration split them
         into the canonical .list-head + scroller pair Site Config already uses, so
         the head sits above the scroll instead of on top of it.
         #members-head owns the top edge (radius + top border, blue band chrome, no
         bottom radius); #members-list owns the bottom edge (radius + bottom border,
         no top border because the head's bottom border draws the seam). Both share
         --list-cols and scrollbar-gutter: stable so their columns line up whether
         or not the scrollbar is present. Head hidden at phone widths — the rows
         reflow into cards there (see the mobile block near line 9120). */
      #members-head { flex: none; display: grid; grid-template-columns: var(--list-cols);
        gap: 0 14px; align-items: center;
        min-height: calc(30px * var(--fs-scale, 1)); padding: 0 12px;
        background: var(--band); border: 1px solid var(--border); border-bottom: 1px solid var(--band-rail);
        border-radius: 10px 10px 0 0;
        /* `overflow: hidden` + `scrollbar-gutter: stable` + `scrollbar-width: thin`
           reserves the SAME 12px the scroller below spends on its actual bar (see the
           #members-list ::-webkit-scrollbar rule below), so head content and list
           content start and end at the same x. `scrollbar-gutter` is only honored on
           SCROLL CONTAINERS — bare `visible` overflow makes it a no-op, which is why
           columns drifted 0 → 7 across eight tracks the last time this was tried. */
        overflow: hidden; scrollbar-gutter: stable; scrollbar-width: thin;
        font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .1em;
        color: var(--band-ink); }
      #members-head .mem-hd-cell { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      #members-list { padding: 0; margin: 0;
        border: 1px solid var(--border); border-top: 0;
        border-radius: 0 0 10px 10px; background: var(--card);
        scrollbar-gutter: stable;
        scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
      #members-list::-webkit-scrollbar { width: 10px; height: 10px; }
      #members-list::-webkit-scrollbar-track { background: transparent; }
      #members-list::-webkit-scrollbar-thumb { background: var(--border); background-clip: padding-box;
        border: 2px solid transparent; border-radius: 8px; }
      #members-list::-webkit-scrollbar-thumb:hover { background: var(--muted); background-clip: padding-box; }
      /* Content inside no longer carries the box: the head+scroller pair owns it now.
         NO overflow:hidden on the table — the scroller is the scrollport. */
      /* A FLOOR under the columns, not a crush. Eight columns need room; on a narrow
         desktop window the proportional tracks would keep dividing until an email was
         nine characters wide. Below the floor the table scrolls sideways inside the
         card (.page-scroll's overflow-y already computes overflow-x to auto) — the
         columns keep their proportions and you move the view, which is what a table
         does. The phone doesn't reach this rule: it has the card stack. */
      #members-list .mem-table { display: block; min-width: calc(1040px * var(--fs-scale, 1)); border: 0; border-radius: 0; background: transparent; overflow: visible; }
      #members-list .mem-table > tbody { display: block; }
      #members-list .mem-table > tbody > tr { display: grid; grid-template-columns: var(--list-cols); gap: 0 14px; align-items: center; }
      /* The head is not a <thead> any more — see #members-head above; the old sticky-
         inside-scroller rules used to live here and stopped applying when renderMembers
         moved the column labels into their own sibling div. */
      /* A row is the height of the house row, and every cell is ONE line — a member
         with four buildings must not stand taller than a member with one, or the lane
         the eye is following bends. What doesn't fit ellipsizes; the popover has the
         rest. */
      #members-list .mem-table > tbody > tr { min-height: calc(46px * var(--fs-scale, 1)); padding: 5px 12px; border-top: 1px solid var(--border); }
      #members-list .mem-table > tbody > tr:first-child { border-top: 0; }
      /* No `display` here on purpose: a grid item is blockified anyway, and setting it
         would out-rank .mem-c-footer's `display: none` and paint the phone's contact
         footer at the end of every desktop row. */
      #members-list .mem-table td { padding: 0; border: 0; min-width: 0;
        font-size: var(--fs-md); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      #members-list .mem-c-desk { display: block; }
      /* The Name column carries the NAME. The card stacks the words and packs the
         building/grade/subject block beside them because a card has one line to work
         with; here each of those is its own column, so the name cell holds an avatar
         and one line of text — the same lead mark every other list in the app draws
         (--list-mark). */
      #members-list .mem-c-name .mem-row { display: block; }
      #members-list .mem-c-name .mem-name { display: flex; align-items: center; gap: 9px; width: auto; min-width: 0; }
      #members-list .mem-c-name .mem-ava { width: var(--list-mark); height: var(--list-mark); font-size: var(--fs-xs); margin-top: 0; }
      #members-list .mem-c-name .mem-id { flex-direction: row; align-items: baseline; gap: 4px; min-width: 0; overflow: hidden; }
      #members-list .mem-c-name .mem-nm { font-size: var(--fs-md); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
      #members-list .mem-c-name .mem-nm:first-child { flex: none; }
      /* …and the record's two-line clamp is undone: a column is ONE line, so the words
         run inline and a third word is data, not overflow. (.mem-rec drops it because a
         stacked name is what makes every phone row the same height.) */
      #members-list .mem-c-name .mem-nm:nth-of-type(n+3) { display: inline; }
      #members-list .mem-c-name .mem-pos,
      #members-list .mem-c-name .mem-assign,
      #members-list .mem-c-name .mem-subjects { display: none; }
      /* Subjects: the same icon stack, laid out as ONE row inside its column (the
         card's 2-row quadrant is a card shape). Past what fits, the column clips and
         the popover — the whole point of the stack being tappable — lists them all. */
      #members-list .mem-c-subj .mem-subjects { display: flex; width: auto; gap: 5px; align-items: center; }
      /* "(4) Wo, Sm, Ha…" — the count leads so the number is readable even when the
         list itself is cut off by the column. */
      #members-list .mem-many { display: inline; cursor: pointer; }
      #members-list .mem-many-n { color: var(--muted); }
      #members-list .mem-c-action { overflow: visible; }
      #members-list .mem-c-action .btn { width: 100%; justify-content: center; }
    }

    /* "Directing this to <person>" banner on the submit form. */
    .submit-directed { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; padding: 10px 13px; border-radius: 10px; background: rgba(var(--accent-rgb),.08); border: 1px solid rgba(var(--accent-rgb),.25); font-size: var(--fs-md); color: var(--text); }
    .submit-directed .ic { color: var(--accent-dark); }
    .submit-directed .link-btn { margin-left: auto; }
    /* A failure or a block earns the one thing a log is scanned for — colour on the
       lead mark. The row itself stays neutral: tinting whole rows in a list where a
       third of the events are "warn" turns the page into wallpaper. */
    .audit-row.is-bad .list-row-lead { color: var(--warn); }
    .audit-when { color: var(--muted); font-variant-numeric: tabular-nums; }
    .audit-actor { font-weight: var(--fw-semi); }
    .audit-ip { color: var(--muted); font-family: ui-monospace, Menlo, Consolas, monospace; font-size: var(--fs-xs); }
    .audit-detail { color: var(--muted); }
    .list-th.sortable { cursor: pointer; user-select: none; }
    .list-th.sortable:hover { color: var(--text); }
    .list-th.sort-active { color: var(--accent-dark); }
    .page-panel .page-acts .mfilter { margin-right: 0; }
    .ev-tag { display: inline-block; font-size: var(--fs-xs); font-weight: var(--fw-bold); padding: 2px 8px; border-radius: 999px; white-space: nowrap; background: var(--neutral-bg); color: var(--neutral); }
    .ev-tag.ok { background: var(--ok-bg); color: var(--ok); }
    .ev-tag.warn { background: var(--accent-light); color: var(--accent-dark); }
    .ev-tag.bad { background: var(--danger-bg); color: var(--danger); }
    .ev-tag.info { background: var(--neutral-bg); color: var(--neutral); }

    /* Ticket detail */
    .ticket-detail-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); overflow: hidden; margin-bottom: 14px; }
    .td-header { padding: var(--pad-card); border-bottom: var(--rail); }
    .td-header h2 { font-size: var(--fs-2xl); font-weight: var(--fw-bold); margin: 10px 0 14px; }
    .td-meta-row { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
    .td-body { padding: var(--pad-card); }
    /* The member's own words, and the reason anyone opened this screen — so this is
       where the reclaimed width gets spent first. --fs-xl, one rung up, on the
       ladder's own loose line-height rather than a bespoke 1.75. */
    .td-desc { font-size: var(--fs-xl); line-height: var(--lh-loose); white-space: pre-wrap; word-break: break-word; }
    .td-info-row { display: flex; gap: 24px; flex-wrap: wrap; margin-top: 20px; padding-top: 18px; border-top: 1px solid var(--border); font-size: var(--fs-md); }
    .td-info-item label { display: block; color: var(--muted); font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .5px; margin-bottom: 2px; }
    .td-info-item span { font-weight: var(--fw-medium); }
    .leader-controls { padding: var(--pad-band) var(--pad-card); display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; }
    .ctrl-group label { display: block; font-size: var(--fs-2xs); font-weight: var(--fw-semi); color: var(--muted); text-transform: uppercase; letter-spacing: .04em; margin-bottom: 4px; white-space: nowrap; }
    .select-ctrl { padding: 7px 10px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-md); font-family: inherit; background: #fff; color: var(--text); cursor: pointer; }
    .select-ctrl:focus { outline: none; border-color: var(--accent); }
    .status-select-wrap { position:relative; display:inline-block; line-height:inherit; }
    .status-select-label { pointer-events:none; user-select:none; }
    .status-select-label .chev { font-size:var(--fs-lg); opacity:.6; }
    .status-select-native { position:absolute; left:0; top:0; width:100%; height:100%; opacity:0; cursor:pointer; }

    /* Comments */
    .comments-card { background: var(--card); border: 1px solid var(--border); border-radius: var(--radius); margin-bottom: 14px; }
    /* .comments-hdr and .td-card-hdr were byte-identical to each other and differed
       from .contact-card h4 only by the h4's margin reset — three names for one
       object, which is how the request detail ended up with three panel components
       too. The declarations are gone: .card-sec-hd is the object now, and leaving a
       second copy here would have quietly out-ranked it by source order and put the
       old height back. The names survive only as the hooks the screen's own rules
       key on. */
    .comments-hdr, .td-card-hdr { margin: 0; }
    /* Who can read a note. It has now been THREE things, and the third is the one that
       looks right on the built screen.
       (Copy keeps "assigned building representative": "rep" is the app's shorthand,
       never its member-facing wording.)

       1. A tinted band under the head — which read as a SECOND HEADER stacked on the
          real one, so the column opened with two pieces of chrome before any content.
       2. The log's first ROW: .act-item geometry, its own hairline, and a stub of the
          thread rail running down out of its icon so the thread visibly began at the
          notice. That fixed the tint and created a worse problem — the sentence is not
          an entry in the chronology. Threading it onto the rail said it was, and it
          then owned a third divider that lined up with nothing across the split.
       3. What it is now: the last LINE OF THE HEAD. It lives inside .td-meta (it is a
          full-width row of that grid, see `grid-column: 1 / -1`), so it wears the head's
          padding and the head's one bottom divider instead of carrying any of its own.
          No hairline, no rail stub, no gutter — the band already pays all three. It
          describes the section, and it now sits in the section's header, under the facts.

       Type stays --fs-xs/--lh-snug and --muted: it is a footnote to the head, not a
       peer of the STATUS row above it. */
    .td-notes-hint { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px;
      margin: 0; padding: 0;
      font-size: var(--fs-xs); line-height: var(--lh-snug); color: var(--muted); }
    .td-notes-hint .ic { flex: none; display: grid; place-items: center; color: var(--band-ico); }
    .td-notes-hint .ic svg { width: 14px; height: 14px; }

    /* ── #7 · Status is a fact, not an action ───────────────────────────────
       The list colour-codes status because you are scanning many rows there. Here
       there is one, its label already says "Status", and the accent is this app's
       ACTION colour — a read-only value wearing it reads as something you can press.
       --neutral is grey ON PURPOSE (see the token comment: the brand is no-blue-tint);
       an "info blue" here would be a second brand. */
    /* Status reads as a VALUE, not a chip — see the statusKV comment in app.js for why
       the grey pill went. Colour only where it means something: a request that is over
       is worth spotting, so the two terminal states take the semantic tokens (never a
       literal hex — see the status-colour tokens in :root) and the live ones stay body
       text. --neutral, not --muted, for Withdrawn: it is a real end state, not a
       de-emphasised one. */
    .td-status { font-weight: var(--fw-semi); }
    .td-status-resolved { color: var(--ok); }
    .td-status-closed { color: var(--neutral); }
    /* The category BADGE — the request exhibit's facts row, under the headline.
       Shaped like the record-header badges on the Site Config sub-screens
       ("MEMBER" / "TEST" / "EAGLE LANDING"): uppercase micro type in a soft
       rounded rect — a fact stamped on the record, not a pill you might press.
       Status stays neutral for the reason #7 gives (orange is the ACTION colour);
       category colour is different ink: the sanctioned ticket-category palette
       (CAT/catMeta in app.js) exists to tell categories apart, and none of it sits
       near the accent. Colours arrive inline from that one palette; this rule owns
       only the shape. */
    .td-cat-pill { display: inline-flex; align-items: center; padding: 3px 9px; border-radius: 6px;
      font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase;
      letter-spacing: .04em; white-space: nowrap; }

    /* ══ THE REQUEST IS AN EXHIBIT ═══════════════════════════════════════════
       The request panel holds exactly one thing — what the member actually wrote,
       the only thing on this screen nobody else can tell you — so it is composed
       as a document rather than as fields: headline → facts row → the words as a
       PULL-QUOTE → signature. BOTH layouts, one composition; the wide layout adds
       an indent, a measure cap and bigger type (see the .td-v2 block below), and
       that is the whole difference.

       The quote dressing borrows the CMS pull-quote's language — the Georgia
       flourish on --fs-quote-mark, a cite line — because that block and this one
       are the same object: somebody's words, presented. It does NOT borrow the
       4px accent bar. Two drafts carried it and it read as interface chrome, a
       blockquote rail, rather than editorial dressing; without it the words sit
       back on the gutter where the space standard wants them anyway. The mark
       hangs above the first line at the text's own edge (the -4px eats Georgia's
       left bearing so the INK aligns, not the glyph box) and runs a shade stronger
       than the CMS block's .18, because it is now the only thing saying "verbatim".
       There is no closing mark on purpose — the mark is ornament, not punctuation;
       the signature is what closes the quote.

       Drafts worth not repeating: the facts were a labelled Category/Submitted row
       (as a footer with a border-top hanging over the empty run, then as a band
       under the head with a rule of its own) — a labelled fact row anywhere in this
       panel needs a rule to close it, and every such rule divided nothing. The
       category was a lone kicker ABOVE the headline and looked parked. Facts belong
       under the name, which is the grammar the Site Config record headers already
       use ("Raja Al-Battani" then MEMBER · TEST). The panel's interior now carries
       ZERO rules: the only lines are the head above it and the footer below.

       (The headline keeps the size it has always had — `.td-body .td-title`,
       --fs-3xl — which is already the right phone scale; only .td-v2 steps it up.) */
    .td-facts { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin: 0 0 30px; }
    .td-fact-when { font-size: var(--fs-sm); color: var(--muted); }
    .td-quote { margin: 0; position: relative; padding: 30px 0 0; }
    .td-quote::before { content: '\201C'; position: absolute; top: -8px; left: -4px;
      font-size: var(--fs-quote-mark); line-height: var(--lh-none); color: rgba(var(--accent-rgb), .28);
      font-family: Georgia, serif; pointer-events: none; }
    /* The signature and NOTHING else — the filing time lives in the facts row,
       because a byline carrying both a name and a clock read as neither. */
    .td-cite { margin-top: 18px; font-size: var(--fs-md); font-weight: var(--fw-semi); }
    /* The on-behalf clause is the quieter half of the signature — who wrote it stays
       bold, who it was written for rides after it in body weight. */
    .td-cite-for { font-weight: var(--fw-body); color: var(--muted); }

    /* ── ASSIGNING A REQUEST ─────────────────────────────────────────────────
       See openAssignPicker in app.js for why this replaced a native <select>.
       Everything here is sized for a THUMB: full-width rows at 56px (well over the
       44px minimum), the whole row is the target rather than a word inside it, and
       there is no dropdown to hold open. The three groups are ranked by how often
       triage actually wants them, and the rarest one starts closed. */
    .td-reassign { margin-left: auto; background: none; border: 0; padding: 0; cursor: pointer;
      font-family: inherit; font-size: var(--fs-md); font-weight: var(--fw-semi);
      color: var(--accent-dark); text-transform: none; letter-spacing: 0; }
    .td-reassign .chev { font-size: var(--fs-lg); opacity: .6; }
    .assign-loading { padding: 22px 4px; color: var(--muted); font-size: var(--fs-md); text-align: center; }
    .assign-group { margin-bottom: 14px; }
    .assign-group-hd { font-size: var(--fs-2xs); font-weight: var(--fw-bold); color: var(--muted);
      text-transform: uppercase; letter-spacing: .08em; margin: 0 0 6px 2px; }
    .assign-row { width: 100%; display: flex; align-items: center; gap: 12px; min-height: 56px;
      padding: 8px 10px; border: 1px solid transparent; border-radius: 10px; background: none;
      font-family: inherit; text-align: left; cursor: pointer; }
    .assign-row:hover { background: var(--bg); }
    /* The one already chosen is stated, not just ticked — a tick alone is easy to miss
       on a phone held at arm's length over a staffroom table. */
    .assign-row.is-current { border-color: var(--accent); background: var(--accent-light); }
    .assign-face { width: 34px; height: 34px; border-radius: 50%; flex: none; object-fit: cover; }
    .assign-face-init { display: grid; place-items: center; background: var(--accent-light);
      color: var(--accent); font-size: var(--fs-sm); font-weight: var(--fw-bold); }
    .assign-row.is-current .assign-face-init { background: var(--card); }
    .assign-who { display: flex; flex-direction: column; min-width: 0; flex: 1; }
    .assign-name { font-size: var(--fs-lg); font-weight: var(--fw-semi); color: var(--text);
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .assign-where { font-size: var(--fs-sm); color: var(--muted);
      overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .assign-check { flex: none; color: var(--accent); display: grid; place-items: center; }
    .assign-check .lic { width: 18px; height: 18px; }
    /* The member ASKED for this person. That is a request, not a routing hint, so it
       gets the top of the sheet and a tint that says "start here". */
    .assign-asked .assign-row { border-color: var(--border); background: var(--card); }
    .assign-asked .assign-group-hd { color: var(--accent-dark); }
    /* Everyone else, closed by default. */
    .assign-more { border-top: var(--rail); padding-top: 10px; }
    .assign-more-sum { display: flex; align-items: center; gap: 8px; min-height: 44px; cursor: pointer;
      font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--accent-dark);
      list-style: none; padding: 0 2px; }
    .assign-more-sum::-webkit-details-marker { display: none; }
    .assign-more-sum::after { content: '▾'; margin-left: auto; opacity: .6; transition: transform .15s; }
    .assign-more[open] .assign-more-sum::after { transform: rotate(180deg); }
    .assign-more-n { font-size: var(--fs-2xs); font-weight: var(--fw-bold); color: var(--muted);
      background: var(--neutral-bg); border-radius: 999px; padding: 1px 7px; }
    .assign-clear { width: 100%; display: flex; align-items: center; justify-content: center; gap: 8px;
      min-height: 48px; margin-top: 10px; border: 1px solid var(--border); border-radius: 10px;
      background: none; font-family: inherit; font-size: var(--fs-md); font-weight: var(--fw-semi);
      color: var(--muted); cursor: pointer; }
    .assign-clear:hover { border-color: var(--danger); color: var(--danger); }
    /* NO RULE BETWEEN ENTRIES. The log used to draw a hairline under every row, which
       chopped one continuous record into a stack of unrelated slabs — and it was the
       same hairline the screen uses to mean "a new section starts here", so it said the
       wrong thing twice. The thread's rail and the typed marks already group the log;
       that is their whole job. (Eric, looking at the built panel: "its the notes and
       activity section, why are we dividing it in half".) */
    .comment-item { padding: var(--pad-band) var(--pad-card) 10px; display: flex; gap: 12px; }
    .cmt-avatar { width: 30px; height: 30px; border-radius: 50%; flex-shrink: 0; object-fit: cover; }
    .cmt-avatar.init { background: var(--accent-light); color: var(--accent); display: grid; place-items: center; font-size: var(--fs-xs); font-weight: var(--fw-bold); }
    .cmt-body { flex: 1; min-width: 0; }
    /* WRAPS, and the pill never does. On a phone this line is a wrapping name, a pill and
       a timestamp in ~280px, and a nowrap flex resolved that by squeezing the pill until
       its own text broke across two lines inside the rounded shape. The pill is a label,
       so it holds its size (nowrap + no shrink) and the LINE gives way instead: the name
       wraps as prose, and the stamp drops to its own row when there is no space left. */
    .cmt-author { font-size: var(--fs-md); font-weight: var(--fw-semi); display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin-bottom: 3px; }
    .role-tag { font-size: var(--fs-2xs); font-weight: var(--fw-bold); padding: 1px 7px; border-radius: 999px; background: var(--accent-light); color: var(--accent-dark); text-transform: uppercase; white-space: nowrap; flex-shrink: 0; }
    .cmt-text { font-size: var(--fs-md); line-height: var(--lh-loose); white-space: pre-wrap; word-break: break-word; }
    .cmt-time { font-size: var(--fs-2xs); color: var(--muted); opacity:.6; font-weight:var(--fw-body); white-space:nowrap; flex-shrink:0; margin-left:auto; min-width:94px; text-align:right; }
    /* TOP-ALIGNED, not centred. An event sentence can run to three lines — "Submitted by
       AG Apollonius Geller · filed on their behalf by SH Selene Herschel" does — and
       centring hung the mark and the timestamp against the middle of the block, so the
       mark drifted off the line it belongs to and the time floated in the middle of
       nowhere. Both belong to the FIRST line, which is where the entry starts. The 6px
       is what centres an 18px line of --fs-sm inside the 30px mark slot, so a one-line
       entry looks exactly as it did before. */
    .act-item { display:flex; align-items:flex-start; gap:12px; padding:10px var(--pad-card); font-size:var(--fs-sm); color:var(--muted); }
    .act-item > .act-text, .act-item > .act-time { padding-top: 6px; }
    /* A person is one object: the avatar never wraps away from the name it belongs to.
       The sentence still breaks between chips, which is where a break reads naturally. */
    .tl-person { white-space: nowrap; }
    /* ── #9 · THE LOG IS ONE THREAD ─────────────────────────────────────────
       The entries were separate blocks stacked in a scroller, so a note, a system
       event and a filed meeting read as three unrelated things that happened to be
       near each other. One rail down the avatar column ties them into a single
       chronology. Drawn as a background on the LIST, not as a border on each entry:
       a per-entry border would break at every gap and stop at the last one, and the
       thread has to look continuous through both. Positioned off the gutter token so
       it stays on the avatars' centres at either breakpoint.

       INSET at both ends. At a flat 100% the rail ran wall to wall inside the scroller,
       so it butted into the head's divider above and the composer's below and welded
       itself to both — a vertical line growing out of a horizontal one reads as a join,
       and the thread isn't joined to either of them. 14px is --pad-band, the same air
       the first entry already pays above its own mark, so the rail starts and stops
       clear of the rules without ever falling short of the first or last mark's centre
       (the shallowest entry, .act-item, centres its dot 19px down).

       `local` is what makes that safe on a SCROLLER. At the default `scroll` the
       background is pinned to the visible box, so a percentage height measures the
       list's WINDOW, not the thread: with more entries than fit, the rail stopped short
       of the bottom edge while entries kept scrolling past it — measured, a mark at 794
       with the rail ending at 787, a dot floating off the end of its own thread. With
       `local` the positioning area is the scrollable content, so the rail travels with
       the entries.

       THE THREAD IS MEASURED, NOT INHERITED — the four --tl-* lengths are set by
       tdDrawThread() in app.js from the first and last MARK. A height derived from the
       container was wrong at both ends and in opposite directions: on desktop the list
       is a flex-filled column, so a request with one event drew a rail the full height
       of an empty panel, a thread implying a chronology that isn't there; on the phone
       the list shrink-wraps, so the same request's rail came out ~10px — entirely hidden
       behind the dot's own ring, i.e. no thread at all. Neither is a fact about the log.

       IT IS TWO LINES, AND THEY MEAN DIFFERENT THINGS.
       • The SPINE (this background) is solid, and runs first mark to last: what happened.
       • The TRAIL (::after) is dotted and fades out, and runs down into the empty room
         under the last event: what hasn't happened yet. It is drawn only while the case
         is OPEN — tdDrawThread zeroes it on a completed or withdrawn request, so a
         finished thread ends on its last mark. That hard stop is the whole distinction
         between "nothing has happened yet" and "nothing more will", and it is why the
         empty space under a new request now reads as waiting rather than as a bug. */
    #cmt-list { position: relative;
      background-image: linear-gradient(var(--border), var(--border));
      background-size: 1px var(--tl-spine-h, 0px);
      background-repeat: no-repeat; background-attachment: local;
      background-position: calc(var(--pad-card) + 15px) var(--tl-spine-top, 0px);
      /* A FLOOR for the log, so a new request's thread has somewhere to be. Where the
         list shrink-wraps (the phone; desktop flex-fills its column and overrides this),
         one system event made it 38px tall — the whole log thinner than the composer
         under it. Three entries' worth of room: .act-item is 38px, so ~114 rounded to
         120. A floor, not a height — the log grows past it with the second note and
         never reserves phone screen it isn't using. */
      min-height: 120px; }
    /* The trail. Dotted AND fading: the dots say "not continuous, nothing here", the
       fade says "and it just tapers out" — either alone reads as a styling accident,
       together they read as a deliberate open end. Absolutely positioned rather than a
       second background layer because a repeating gradient cannot fade its own alpha
       over distance; a mask can, and a mask on the element's background would have taken
       the spine with it. It scrolls with the content (an abs child of a scroll container
       does), which is the same coordinate space the `local` background uses. */
    #cmt-list::after { content: ''; position: absolute; width: 1px; pointer-events: none;
      left: calc(var(--pad-card) + 15px);
      top: var(--tl-trail-top, 0px); height: var(--tl-trail-h, 0px);
      /* MATCHED TO THE SPINE BY AVERAGE INK, which is the only way solid → dotted reads
         as one line changing character rather than two lines of different weight.
         Drawn in --border like the spine, the dots were invisible: a 1px dotted line is
         half holes before the fade touches it, and --border is #d6d4d0, the palest grey
         in the app. Drawn in --muted (#6b7280) they were visible and WRONG the other way
         — the future darker than the past. So: --muted dots at 50% duty, and the mask
         carries a starting alpha of .55 rather than 1. That puts the ink around #a8adb2,
         which over 3-on-3-off averages out to roughly #d3d6d8 — the spine's own value.
         Same apparent weight, different texture, then it tapers. */
      background-image: repeating-linear-gradient(var(--muted) 0 3px, transparent 3px 6px);
      /* Held at that strength for the first half, THEN taper. A straight fade from the
         last mark was invisible by its own midpoint, so the dots died a few pixels in
         and left the room they were supposed to be filling looking blank again. */
      -webkit-mask-image: linear-gradient(rgba(0,0,0,.55) 0, rgba(0,0,0,.55) 52%, transparent 100%);
      mask-image: linear-gradient(rgba(0,0,0,.55) 0, rgba(0,0,0,.55) 52%, transparent 100%); }
    /* …and the marks sit ON the rail rather than beside it: an opaque ring lets the
       line pass behind each one instead of through it.
       `.mlog-eyebrow .ic` is NOT in this list. It was, from when the meeting record's
       mark was meant to be that icon — but the mark is `.mlog-eyebrow::before` on the
       rail now, and this ring is drawn from the CARD colour, so on the eyebrow's own
       tint it painted a white square around the glyph. A ring only works where the
       thing behind it is the card. */
    .cmt-avatar, .tl-mark { box-shadow: 0 0 0 3px var(--card); }

    /* ── THE TYPED MARK ──────────────────────────────────────────────────────
       One mark per entry, saying WHAT it is: a request opening, a rep being assigned,
       a meeting filed, a note, a request finishing. Sized to the 30px column every
       entry's text already clears, so the rail runs dead through their centres.
       Colour is carried by the two events worth spotting from across a log — opening
       and completing — and everything else stays neutral, so the colour means
       something when it appears. Status tokens, never literals. */
    /* 26 in a 30px slot (2px of margin all round): the slot is what every entry's text
       clears and what the rail is centred on — --pad-card + 15 — so the mark has to
       occupy 30 even though it draws 26, or it sits 2px off its own thread. */
    .tl-mark { width: 26px; height: 26px; margin: 2px; border-radius: 50%; flex: none;
      display: grid; place-items: center;
      background: var(--card); border: 1px solid var(--border); color: var(--muted); }
    /* The glyph is centred by the MARK's grid, so the .ic wrapper must not add a line
       box of its own — inline, it carries the parent's line-height and pushes the svg
       down and left inside the ring. `display:grid` + zero line-height makes the span
       exactly its icon. */
    .tl-mark .ic { display: grid; place-items: center; line-height: 0; }
    .tl-mark .lic { width: 13px; height: 13px; display: block; }
    .tl-open { border-color: var(--accent); color: var(--accent); background: var(--accent-light); }
    .tl-ok   { border-color: var(--ok); color: var(--ok); background: var(--ok-bg); }
    .tl-end  { border-color: var(--neutral); color: var(--neutral); background: var(--neutral-bg); }
    /* A note is the log's ordinary weather — quieter than any state change. */
    .tl-note { color: var(--muted); }
    /* THE PERSON, INLINE. The mark column carries the event type, so a face rides beside
       the name it belongs to instead: small enough to sit in a line of text without
       changing its height, and it inherits the line's own colour for the name. */
    .tl-person { display: inline-flex; align-items: center; gap: 5px; vertical-align: baseline; }
    .tl-face { width: 18px; height: 18px; border-radius: 50%; flex: none; object-fit: cover; }
    .tl-face-init { display: grid; place-items: center; background: var(--accent-light);
      color: var(--accent); font-size: var(--fs-2xs); font-weight: var(--fw-bold); letter-spacing: 0; line-height: var(--lh-none); }
    .cmt-author .tl-face { margin-right: 1px; }
    /* The record's MARK sits on the rail (see .mlog-mark); the card itself starts clear
       of it. At +30 the card's edge began exactly where the 30px mark ends, so the mark's
       3px ring — the thing that lets the rail pass behind it — landed ON the card and the
       two overlapped.
       +42 is not just "enough room": it is where every other entry's TEXT starts (the
       gutter, the 30px mark column, then .comment-item's own 12px gap), so the card's
       left edge lines up with the notes above and below it and the column reads as one. */
    .mlog-item { margin-left: calc(var(--pad-card) + 42px); margin-right: var(--pad-card); }
    /* The scroller's own bar is the loudest object in that column at default width. */
    #cmt-list { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
    #cmt-list::-webkit-scrollbar { width: 8px; }
    #cmt-list::-webkit-scrollbar-track { background: transparent; }
    #cmt-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; }
    .act-text { flex:1; min-width:0; line-height:var(--lh-body); }
    .act-time { font-size: var(--fs-2xs); opacity:.6; flex-shrink:0; white-space:nowrap; margin-left:auto; min-width:94px; text-align:right; }
    .comment-form { padding: var(--pad-band) var(--pad-card); border-top: 1px solid var(--border); display: flex; gap: 10px; align-items: center; }
    .comment-form textarea { flex: 1; padding: 11px 12px; border: 1px solid var(--border); border-radius: 9px; font-size: var(--fs-md); font-family: inherit; resize: none; min-height: 44px; height: 44px; background: #fff; color: var(--text); }
    .comment-form textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    /* Mobile: a single-row note box that lines up with the 44px send button. */
    @media (max-width: 760px) {
      .comment-form { align-items: center; }
      .comment-form textarea { min-height: 44px; height: 44px; resize: none; }
    }
    .cmt-send { background: var(--accent); color: var(--on-accent); border: none; border-radius: 9px; width: 44px; height: 44px; cursor: pointer; font-size: var(--fs-xl); flex-shrink: 0; }
    .cmt-send:hover { background: var(--accent-hover); }
    /* Phone-sheet courtesy callout — mirrors the "Private by Design" (.submit-privacy) style. */
    .phone-courtesy { display: flex; gap: 10px; align-items: flex-start; text-align: left; margin: 2px 0 12px; padding: 11px 14px; border-radius: 10px; background: var(--accent-light); border: 1px solid rgba(var(--accent-rgb),.18); }
    .phone-courtesy .pc-ic { color: var(--accent); flex: 0 0 auto; margin-top: 1px; display: inline-flex; }
    .phone-courtesy .pc-ic svg { width: 15px; height: 15px; }
    .phone-courtesy .pc-label { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--accent-dark); }
    .phone-courtesy .pc-note { font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); margin-top: 1px; }
    /* ── THE COMPOSER IS ONE ROW, AT EVERY WIDTH ─────────────────────────────
       The note composer is the Notes panel's footer, so it matches the request card's
       footer: 14px of padding around one row of controls. The tools ride IN that row
       as square buttons (their labels stay in the DOM for screen readers and for the
       toggle code, which rewrites them) — a bordered, filled square reads as a click
       target in a way the old flat pills on their own line did not.

       This lived in the min-width:761 block, under a comment that said "Mobile keeps
       the stacked original". It shouldn't have: the phone is where the stacked version
       cost the most, three rows deep — a note field, then a row of pill buttons, then
       the panel's own footer — on the screen with the least room to spend. The DOM
       order is untouched; this is flex `order` only, so the phone and the desktop are
       the same composer at two sizes.

       --cmt-ctl is that size: 44px here (the app's own tap-target floor, and what the
       note field has always been on a phone), 48px from 761 up where it lines up with
       the slide-to-complete control beside it. Nothing else differs. */
    #section-ticket { --cmt-ctl: 44px; }
    .cmt-compose { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; padding: var(--pad-band) var(--pad-card); border-top: 1px solid var(--border); }
    .cmt-compose .comment-form { order: 2; flex: 1 1 auto; min-width: 0; padding: 0; border-top: 0; gap: 8px; }
    .cmt-compose .cmt-tools { order: 1; flex: 0 0 auto; padding: 0; gap: 8px; flex-wrap: nowrap; }
    .cmt-compose .cmt-attach-row:not(.hidden) { order: 0; flex: 1 0 100%; padding: 0 0 10px; }
    .cmt-compose .comment-form textarea,
    .cmt-compose .cmt-send { height: var(--cmt-ctl); min-height: var(--cmt-ctl); }
    /* Padding is DERIVED from the control height so exactly one line of text fits and
       the field is not a scroller. A flat 13px is desktop's number: at 44px it leaves
       an 18px content box for a 20px line, so the textarea overflowed its own single
       row and the phone had a scrollable box inside a scrolling card (caught by
       _frametest's "at most 1 scroller" rule, which is the one that would have let a
       scroll-inside-a-scroll ship). 20px is the line box at --fs-md; the padding is
       whatever is left of --cmt-ctl around it.
       MINUS THE BORDER, and that 2px is the whole reason this had to be fixed twice.
       Everything here is border-box, so the height budget pays for the 1px hairline top
       and bottom before any text — leaving 18px again, for the same 20px line, and an
       empty "Add a note…" field that shipped with a scrollbar already in it. Subtract
       the border and the content box lands exactly on the line at both sizes: 13px of
       padding at desktop's 48, 11px at the phone's 44. */
    .cmt-compose .comment-form textarea { --cmt-line: calc(20px * var(--fs-scale, 1));
      line-height: var(--cmt-line); padding: calc((var(--cmt-ctl) - var(--cmt-line) - 2px) / 2) 12px; }
    .cmt-compose .cmt-send { width: var(--cmt-ctl); border-radius: 12px; }
    .cmt-compose .cmt-tool { width: var(--cmt-ctl); height: var(--cmt-ctl); padding: 0; justify-content: center; border-radius: 12px; background: var(--bg); color: var(--text); transition: background .12s, border-color .12s, color .12s; }
    .cmt-compose .cmt-tool:hover { background: var(--accent-light); border-color: var(--accent); color: var(--accent); }
    .cmt-compose .cmt-tool .ic svg { width: 18px; height: 18px; }
    /* Active/attention states have to be restated here — the square-button rules above
       are more specific than the plain .cmt-tool.on / .cmt-tool-resume ones. */
    .cmt-compose .cmt-tool.on { background: rgba(var(--accent-rgb),.12); border-color: var(--accent); color: var(--accent); }
    .cmt-compose .cmt-tool.cmt-scope.on { background: var(--warn-bg); border-color: var(--warn); color: var(--warn); }
    .cmt-compose .cmt-tool.cmt-tool-resume { border-color: var(--warn); color: var(--warn); }
    /* Labels stay readable to assistive tech; the icon carries it visually, and on a
       phone the amber banner up in the thread is the wordy way back into a draft. */
    .cmt-compose .cmt-tool > span:not(.ic) { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
    /* ── Note composer extras: link-attach row + tool buttons (rep/leader). ── */
    /* (`.cmt-compose .comment-form { border-top: 1px }` lived here and is gone: the
       composer draws that edge itself now, and the form is a flex item inside it. It
       used to be harmless only because the rule that cancelled it sat in a later media
       query — at equal specificity, moved up here, it would have won and drawn a second
       hairline across the middle of the row.) */
    /* The staged-link panel. It reads as a small labelled tray so it's obvious the
       boxes belong to the note being written, not to some separate "upload" action. */
    .cmt-attach-row { display: block; padding: 12px var(--pad-card) 4px; }
    .cmt-attach-row.hidden { display: none; }
    .cmt-attach-hd { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 7px; }
    .cmt-attach-hd-t { font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .05em; text-transform: uppercase; color: var(--muted); }
    .cmt-attach-x { border: 0; background: none; padding: 0; color: var(--muted); font-size: var(--fs-xs); font-weight: var(--fw-semi); font-family: inherit; cursor: pointer; }
    .cmt-attach-x:hover { color: var(--danger); }
    .cmt-attach-fields { display: flex; gap: 8px; }
    .cmt-attach-hint { margin-top: 6px; font-size: var(--fs-xs); color: var(--muted); }
    .cmt-attach-row input { flex: 1 1 auto; min-width: 0; padding: 9px 11px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-md); font-family: inherit; background: #fff; color: var(--text); }
    .cmt-attach-row input#cmt-attach-label { flex: 0 1 170px; }
    .cmt-tool.on { background: rgba(var(--accent-rgb),.1); border-color: var(--accent); color: var(--accent); }
    .cmt-attach-row input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    .cmt-tools { display: flex; gap: 8px; padding: 10px var(--pad-card) 14px; flex-wrap: wrap; }
    .cmt-tool { display: inline-flex; align-items: center; gap: 6px; padding: 6px 11px; border: 1px solid var(--border); border-radius: 999px; background: var(--card); color: var(--muted); font-size: var(--fs-sm); font-weight: var(--fw-semi); cursor: pointer; }
    .cmt-tool:hover { border-color: var(--accent); color: var(--accent); }
    .cmt-tool .ic svg { width: 14px; height: 14px; }
    /* Attached external link chip (on a note or a meeting log). */
    .cmt-attach { display: inline-flex; align-items: center; gap: 8px; margin-top: 8px; max-width: 100%; padding: 7px 11px; border: 1px solid var(--border); border-radius: 8px; text-decoration: none; color: var(--accent); font-size: var(--fs-sm); font-weight: var(--fw-semi); }
    .cmt-attach:hover { border-color: var(--accent); }
    .cmt-attach .ic svg { width: 15px; height: 15px; flex-shrink: 0; }
    .cmt-attach-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .cmt-attach-ext { flex-shrink: 0; font-size: var(--fs-2xs); font-weight: var(--fw-bold); color: var(--muted); text-transform: uppercase; letter-spacing: .03em; }
    /* ── Meeting-log timeline event — a distinct, accent-edged card. ── */
    /* ── #10 · A STRUCTURED record inside the thread ────────────────────────
       It used to mark itself with a 3px accent left edge — the one shape the app
       doesn't otherwise use, and at 14px from the thread's own rail the two read as
       a rendering fault rather than two different things. It is a panel now, with a
       tinted eyebrow across its top: the record's TYPE is the eyebrow, which also
       retires the bold title that repeated those exact words underneath it.
       The shape is generic on purpose — a grievance step or an escalation reuses it
       with its own icon and its own eyebrow label, and nothing here is meeting-specific. */
    /* ── Two things stolen from the PRINTED case file (lib/case-file-doc.js) ──
       The printed log and this one are the same record, so where they disagreed one
       of them was wrong. The print doc's own rules settled both, and the PDF is NOT
       changing — the screen is what moves.

       1. IT SITS ON THE SPINE. Print's stated rule is "ONE timeline. Every entry,
          whatever kind it is, hangs off a single spine… the KIND is carried by the
          marker, the spine colour and a kicker." On screen this panel was the one
          entry that fell OFF the rail — and it is the heaviest thing the log can
          hold, so the thread broke exactly where the record mattered most. The old
          reason ("a panel can't sit on the rail") wasn't a constraint: print puts the
          marker at the panel's top-left, on the spine, and carries on. The giveaway
          that this was always half-intended is the ring on `.mlog-eyebrow .ic` a few
          rules up — the treatment for a mark that sits on the rail, on a panel that
          was indented past it, so it had nothing to do. The marker is a rounded
          SQUARE, the shape print already reserves for a structured record; a note is
          an avatar and a system event a hollow dot, so all three stay distinct.

       2. NEUTRAL TINT, NOT THE ACCENT. Print says it outright: "Neutral grey tint,
          never the accent: a local's brand can be any colour, and a wash of it behind
          ten lines of type is a coin flip." This panel was a full accent wash —
          `--accent-light` behind the body, `rgba(accent,.09)` behind the eyebrow, an
          accent border round the lot. That is the same argument the app already made
          for `--on-accent` and `readableOn`, applied everywhere except here. The
          accent still carries the distinction, just the way print carries it: on the
          MARKER and the left EDGE, where it is a 2px rule and not a field.

       Deliberately NOT stolen: print's fixed left date gutter. This screen keeps its
       quiet right-aligned stamps (`.mlog-when`, `.cmt-time`) — Eric's call, and the
       right one for a column this narrow. */
    /* A SECTION of the log, not a card floating in it — the same rule the rest of this
       screen follows. It was a bordered, filled, 8px-radius box sitting inside the one
       card the page already is, which is the exact nesting the space standard removes,
       and it was the last of it left on this screen.
       What distinguishes it is what print uses: the SPINE and the marker. A 2px accent
       edge and a whisper of neutral tint say "a structured record happened here"
       without drawing an object. The tint is 3% black, not --neutral-bg (#ececea):
       that token is for a footer bar against white, and across a block this tall it
       read as a grey field with the log's text sitting in a hole. Radius only on the
       outer corners, so the spine stays a clean edge rather than a rounded tab.

       Written at .comment-item.mlog-item specificity ON PURPOSE. The markup is always
       both classes, and the PHONE rule `.comment-item { padding: 12px var(--pad-card)
       8px }` is declared later in this file — so a bare `.mlog-item { padding: 0 }`
       ties on specificity and LOSES on a phone. The panel then re-inherited a 14px
       inset, which pushed its text 14px right of every other entry's and dragged the
       rail marker off the rail with it, because the marker is positioned off the
       eyebrow's padding edge. Desktop looked perfect the whole time. */
    /* No accent bar down the left edge. It was print's "spine" idea carried over, but
       print has no rail of its own — on screen the thread rail already runs 15px to the
       left of this card, so the bar was a second vertical line beside the first, and two
       parallel rules read as a rendering fault rather than as emphasis. The mark on the
       rail carries the accent instead, which is where the eye already is. */
    .comment-item.mlog-item { display: block; padding: 0; background: var(--card);
      border: 1px solid var(--border); border-radius: 10px; position: relative;
      margin-top: 10px; margin-bottom: 12px; box-shadow: 0 1px 3px rgba(16,24,40,.06); }
    /* IT IS AN EVENT, so it looks like one. A calendar tile leads — the one shape every
       reader already knows means "this happened on a day" — then the kind, then the full
       weekday-and-time line beneath it.
       THE TILE IS THE SHARED .ev-date (see THE EVENT DATE BLOCK). It used to be a block of
       its own, tinted in --accent — the fourth rendition of "this happened on a day" in an
       app that had settled on one. A meeting record is not more urgent than a calendar
       event; it is the same fact on a different screen, so it wears the same object as the
       dashboard footer, Home's Upcoming Events and the agenda. */
    .mlog-head { display: flex; align-items: center; gap: 12px; padding: var(--pad-row); }
    /* A meeting with no date yet — the log's one state the calendar surfaces don't have.
       Same 44px box, an icon where the numeral goes. */
    .ev-date-none { display: grid; place-items: center; min-height: 44px; }
    .ev-date-none .ic { color: var(--band-ico); }
    .mlog-headtxt { min-width: 0; }
    .mlog-item > .cmt-body { padding: 0 var(--pad-row) var(--pad-row);
      border-top: 1px solid var(--border); padding-top: var(--pad-row); }
    /* The action lives in a FOOTER, right-aligned, with the attribution on the left —
       the app's action-bar convention, the same shape as every other pinned footer. It
       used to be a lone button loose in the body above a line of grey text, which is the
       one place the convention says an action never goes. */
    .mlog-foot-row { display: flex; align-items: center; justify-content: space-between;
      gap: 12px; flex-wrap: wrap; padding: 9px var(--pad-row); border-top: 1px solid var(--border);
      background: rgba(0, 0, 0, .018); border-radius: 0 0 9px 9px; }
    /* The marker has to escape the panel to reach the rail, so neither this rule nor
       the eyebrow can take `overflow: hidden` — it was here to clip the eyebrow's fill
       to the top corners, which the eyebrow now does itself with its own radius.

       It hangs off the EYEBROW, not off the panel, so it centres on that row at any
       Text Size: a fixed `top` measured 3.8px high the moment the row grew, and the
       row grows with --fs-scale. Horizontally it is exact — absolute offsets are
       measured from the containing block's PADDING edge, which here is the panel's
       border box (--pad-card + 30) plus the 2px accent border plus the eyebrow's own
       14px inset = --pad-card + 46, while the rail is at --pad-card + 15. So a 9px
       mark whose centre lands on the rail sits at -(31 + 4.5). Both ends are written
       off the same token the rail is, so they track the gutter at either breakpoint. */
    /* ONE tint for the block, not two. The eyebrow carried its own fill on top of the
       panel's, and the two greys were close enough that the result read as muddy
       rather than as structure.

       The rail marker hangs off .mlog-head (which is why that box is positioned): the
       head is the row it must line up with, and hanging it off the CARD would centre it
       on the whole card's height instead. Offsets are from .mlog-head's padding edge —
       the card's border box at --pad-card + 30, plus its 3px accent border, plus the
       head's own --pad-row inset — while the rail is at --pad-card + 15. Written in one
       calc off the same tokens so it cannot drift from the rail at any breakpoint or
       text size, which a hardcoded pixel offset did twice. */
    /* THE MARK — the same object a person gets, carrying an icon instead of initials.
       A note's author sits on the rail as a 30px .cmt-avatar.init circle: accent-light
       fill, accent glyph, and a 3px ring in the card colour so the rail passes BEHIND
       it rather than through it. A meeting is an entry in the same thread, so it takes
       the same circle and swaps the initials for its own icon (people for a
       representation meeting, notes for a plain log). A square pip was a third vocabulary
       for "something is here" on a rail that already had exactly one.

       It sits ON the rail, not beside it. The card's border box is at --pad-card + 42
       and the rail at --pad-card + 15, so a 30px circle centres on the rail at -43px:
       27 (card-to-rail) + 1 (the card's border) + 15 (half the circle).
       The head's own --pad-row is NOT in that term, and putting it there is the mistake
       to avoid — an absolutely positioned box is offset from its containing block's
       PADDING BOX, which is inside the border but BEFORE the padding, so the head's
       inset never applies. Including it put the mark 12px (exactly --pad-row) left of
       the rail: adjacent to the thread instead of on it. */
    .mlog-head { position: relative; }
    .mlog-mark { position: absolute; top: 50%; transform: translateY(-50%);
      left: -43px;
      width: 30px; height: 30px; border-radius: 50%; display: grid; place-items: center;
      background: var(--accent-light); color: var(--accent);
      box-shadow: 0 0 0 3px var(--card); }
    .mlog-mark svg { width: 15px; height: 15px; }
    .mlog-kind { font-size: var(--fs-2xs); font-weight: var(--fw-bold); letter-spacing: .1em;
      text-transform: uppercase; color: var(--accent-dark); }
    .mlog-when { margin-top: 3px; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--text); }
    .mlog-parts { font-size: var(--fs-md); color: var(--text); margin-top: 4px; }
    .mlog-parts strong { color: var(--muted); font-weight: var(--fw-semi); }
    .mlog-parts:first-child { margin-top: 0; }
    .mlog-item .cmt-text { margin-top: 5px; }
    .mlog-foot { font-size: var(--fs-xs); color: var(--muted); min-width: 0; }
    /* ── The live meeting composer (openMeetingLog) ──────────────────────────
       This is open on the table DURING a representation meeting, so it is laid out for
       a person typing fast under pressure and not looking at the screen much: one field
       per line (never two side by side — you lose which box you're in), a generous
       target, and boxes that grow rather than scroll internally. Wide, because the
       summary fields are where a whole meeting lands. */
    /* .is-full is on every meeting card; the doubled class is what beats the generic
       .confirm-card max-width defined further down the sheet. */
    /* Width: the SAME column as every primary desktop page (--page-col's formula —
       --page-max + 2×22px gutter, capped to the viewport). The sheet is a dialog, not
       an .app-section, so it can't inherit the token — but it must land on the same
       edges, because taking notes is a full-attention screen and the real estate is
       the point. */
    .mlog-card.is-full { max-width: none; width: min(calc(1500px / var(--ui-zoom, 1) + 44px), 100vw - 44px); }
    .mlog-card { text-align: left; }
    .mlog-form { display: flex; flex-direction: column; margin: 4px 0; }
    .mlog-row { display: flex; flex-direction: column; gap: 5px; margin-top: 14px; }
    .mlog-lbl { font-size: var(--fs-sm); font-weight: var(--fw-bold); color: var(--text); }
    .mlog-opt { color: var(--muted); font-weight: var(--fw-body); }
    .mlog-form input, .mlog-form textarea { width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-lg); line-height: var(--lh-body); font-family: inherit; background: #fff; color: var(--text); }
    .mlog-form textarea { resize: vertical; overflow: hidden; min-height: 40px; }
    .mlog-sub-input { margin-top: 6px; }
    .mlog-form input:focus, .mlog-form textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    .mlog-row-when input { max-width: 260px; }
    .mlog-card .confirm-title .ic svg { width: 18px; height: 18px; vertical-align: -3px; }
    /* THE CARD IS CAPPED, NOT THE FORM. This used to give the form `max-height: 68vh`
       and nothing to the card, which only works if the rest of the card is free — and
       it isn't: measured on a 812px phone the head came to 308px and the actions 77, so
       the card totalled 989 inside an 812 viewport with `max-height: none` and an
       overlay that doesn't scroll. The bottom ~180px, Save included, was simply
       unreachable. Capping the CARD and letting the form take whatever is left over is
       the same intent expressed against the right box, and it can't drift when the head
       grows. dvh first for iOS, where 100vh is the tallest the chrome ever gets rather
       than what you can actually see; the vh line above it is the fallback. */
    .mlog-card.is-full { display: flex; flex-direction: column;
      max-height: calc(100vh - 40px); max-height: calc(100dvh - 40px); }
    .mlog-card.is-full > .mlog-head, .mlog-card.is-full > .mlog-actions { flex: 0 0 auto; }
    .mlog-card.is-full .mlog-form { flex: 1 1 auto; min-height: 0; max-height: none; overflow-y: auto; padding-right: 10px; }
    /* ON A PHONE THE HEAD STACKS. Beside the title sits the save pill — "Draft — saving
       as you type", 171px and deliberately unshrinkable (flex-shrink: 0, nowrap) so the
       one thing a rep checks mid-meeting never wraps to something ambiguous. In a 287px
       head that left the TEXT column 76px, and a two-sentence explainer wrapped to TEN
       lines: 195px of the 308px head, on the screen with the least of it to spare. The
       pill keeps its shape and moves under the title instead, which costs one row and
       gives the text the full width — the head comes back to about a third of what it
       was, and every pixel of that goes to the form. */
    @media (max-width: 760px) {
      .mlog-card.is-full > .mlog-head { flex-direction: column; align-items: flex-start; gap: 8px; }
    }
    .mlog-sec { margin: 26px 0 2px; padding-bottom: 6px; border-bottom: 1px solid var(--border); font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
    .mlog-sec:first-child, .mlog-row + .mlog-sec { margin-top: 26px; }
    .mlog-sec-hint { display: block; margin-top: 3px; font-size: var(--fs-xs); font-weight: var(--fw-body); letter-spacing: 0; text-transform: none; color: var(--muted); }
    .mlog-hint { display: block; font-size: var(--fs-xs); font-weight: var(--fw-body); font-style: italic; color: var(--muted); margin-top: 1px; }
    /* The save indicator. It sits in the header rather than by the buttons because it
       answers the only question that matters mid-meeting — "is what I just typed safe?" —
       and that question is asked while looking at the top of the sheet, not the bottom. */
    .mlog-head { display: flex; align-items: flex-start; gap: 16px; }
    .mlog-head-txt { flex: 1; min-width: 0; }
    .mlog-status { display: flex; align-items: center; gap: 7px; flex-shrink: 0; margin-top: 4px; padding: 5px 11px; border-radius: 999px; background: var(--neutral-bg); font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--muted); white-space: nowrap; }
    .mlog-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--neutral); flex-shrink: 0; }
    .mlog-status.is-saving { background: var(--warn-bg); color: var(--warn); }
    .mlog-status.is-saving .mlog-dot { background: var(--warn); animation: mlog-pulse 1s ease-in-out infinite; }
    .mlog-status.is-saved { background: var(--ok-bg); color: var(--ok); }
    .mlog-status.is-saved .mlog-dot { background: var(--ok); }
    .mlog-status.is-failed { background: var(--danger-bg); color: var(--danger); }
    .mlog-status.is-failed .mlog-dot { background: var(--danger); }
    @keyframes mlog-pulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
    /* Discard sits left and red, saves right — the app-wide action-bar rule. */
    .mlog-actions { display: flex; align-items: center; gap: 8px; justify-content: flex-end; }
    .mlog-actions .ghost-danger, .mlog-actions [data-c="history"] { margin-right: auto; }
    /* "You left notes open" — the way back into an interrupted meeting. */
    /* Lives at the top of #cmt-list (the scroller) on purpose: as a fixed sibling of
       the list it stole ~80px from a fixed-height panel and pushed the compose row —
       Attach link / Meeting notes — out the bottom of the card, where the page's
       overflow:hidden clipped it. */
    /* A DRAFT MEETING IS A TIMELINE ENTRY, not a banner. It used to be a tinted box
       pinned above the whole log — full width, its own margins, no mark — which put the
       one thing that hasn't happened yet at the TOP of a chronology that runs oldest
       first, above events that did happen. It is a meeting that is being written now, so
       it sits where that meeting will land (last) and wears the log's own entry shape:
       same mark column, same gutter, same body.
       What marks it as UNFINISHED is the mark, not a slab of colour — dashed ring, amber
       ink, the same users glyph a filed meeting uses. Same object, one state earlier.
       The tint moved from a fill to a hairline for the same reason: at full-width fill
       it shouted louder than the notes it sat above, and it is a reminder to its author,
       invisible to everyone else. */
    /* A meeting STILL BEING WRITTEN. Same card, one state earlier: the rail mark goes
       dashed and a small tag says so, and nothing else moves — a reader who has seen a
       filed meeting recognises this instantly, which a separate banner never achieved.
       Amber, not accent: this is "not finished", not "needs you". */
    .mlog-live .mlog-mark { border-style: dashed; border-color: var(--warn); color: var(--warn); background: var(--warn-bg); }
    .mlog-livetag { display: inline-block; margin-left: 8px; padding: 1px 8px; border-radius: 999px;
      background: var(--warn-bg); color: var(--warn);
      font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .04em;
      vertical-align: 1px; }
    .cmt-tool.cmt-tool-resume { border-color: var(--warn); color: var(--warn); }
    .mlog-edited { color: var(--muted); font-style: italic; }
    /* The edit trail on a filed record. */
    .mnote-edited { border: 0; background: none; padding: 0; font: inherit; color: var(--accent); text-decoration: underline; cursor: pointer; }
    .mhist-card { max-width: 640px; }
    .mhist-item { border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; margin-bottom: 10px; }
    .mhist-hdr { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-bottom: 8px; }
    .mhist-f { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--text); }
    .mhist-who { font-size: var(--fs-xs); color: var(--muted); }
    .mhist-was, .mhist-now { font-size: var(--fs-md); line-height: var(--lh-body); white-space: pre-wrap; padding: 7px 10px; border-radius: 7px; }
    .mhist-was { background: var(--danger-bg); color: var(--text); text-decoration: line-through; text-decoration-color: rgba(0,0,0,.25); margin-bottom: 5px; }
    .mhist-now { background: var(--ok-bg); color: var(--text); }
    .mhist-k { display: block; font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); margin-bottom: 2px; }
    .mlog-preview { color: var(--muted); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
    /* A LINK, not a bordered button. It was an outlined pill inside a tinted panel
       inside the page card — three nested boxes for a tertiary action that opens more
       of the entry you are already reading. The screen's real actions live in the bar
       and the footer; this one belongs to the log line it sits on, and the app already
       spells that "underlined accent text". */
    /* It is a real .btn sm secondary now, in the card's footer where the action-bar
       convention puts a positive action. It briefly became a bare text link — right
       call while it was floating loose in the body, wrong once it had a footer to sit
       in, because a footer's action is the app's standard button everywhere else.
       Nothing to declare but the flex behaviour; the .btn classes carry the rest. */
    .mlog-open { flex: none; }
    /* Read-only meeting-notes sheet (openMeetingNotes). */
    .mnote-card { max-width: 620px; text-align: left; }
    .mnote-when { font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--accent); margin-top: 2px; }
    .mnote-body { max-height: min(58vh, 560px); overflow-y: auto; margin: 4px 0 2px; padding-right: 6px; }
    .mnote-sec { margin: 16px 0 6px; padding-bottom: 5px; border-bottom: 1px solid var(--border); font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .06em; text-transform: uppercase; color: var(--accent); }
    .mnote-row { margin-bottom: 10px; }
    /* What each side said, set as a passage on its own rail — the same shape the printed
       record uses, so the screen and the paper are recognisably one document. */
    .mnote-pass { padding-left: 10px; border-left: 2px solid var(--border); }
    .mnote-pass .mnote-v { line-height: var(--lh-loose); }
    .mnote-k { font-size: var(--fs-sm); font-weight: var(--fw-bold); color: var(--text); }
    .mnote-v { font-size: var(--fs-md); color: var(--text); line-height: var(--lh-body); white-space: pre-wrap; margin-top: 2px; }
    .mnote-empty { font-size: var(--fs-md); color: var(--muted); padding: 10px 0; }
    .mnote-foot { font-size: var(--fs-xs); color: var(--muted); margin-top: 10px; }
    /* Mobile share-as-PDF prints the app screen directly; the inline-flex person chips
       drop below the text baseline in print engines that compute a grid child's baseline
       from the bottom of the grid rather than from its content. Force middle alignment
       and pin the avatar to the top of each comment row so the circles stay put. */
    @media print {
      .tl-person { vertical-align: middle; }
      .comment-item { align-items: flex-start; }
    }
    /* ── Page Text editor (Site Config → App Branding → Wording) ──
       ~75 editable strings, so the shape is a TABLE OF CONTENTS, not a form: one
       collapsed <details> per screen, opened one at a time. Inside a section the fields
       run ONE PER ROW. The old auto-fit grid put two and three boxes across a wide
       screen, which read as a paragraph of boxes and put unrelated strings of different
       heights side by side. Height now carries meaning instead: a textarea here means
       the member sees a multi-line field there.

       AND THE TABLE OF CONTENTS IS THE HOUSE TABLE. Everything below this line used to
       describe a stack of sixteen cards: each `<details>` carried its own 1px border,
       12px radius, white fill and 9px of grey between it and the next. That is the
       pre-frame language — white cards floating on grey — and it hasn't been true here
       since the frame made the panel ONE white card. Painting card-on-card, the border
       outlined nothing, the radius rounded nothing, and the 9px gaps were fifteen
       stripes of page colour running down the middle of a card (see THE CARD SECTION).
       The rows are `.list-row` on `--list-cols` under a `.list-head` now — one box, one
       cap, hairlines between — which is not a new idea for this screen so much as the
       one Buildings, Request Types, Calendars and Automated Emails all already use.

       What that bought, beyond matching: the row got COLUMNS, and a column had to be
       filled with something true. The group's description was already written (it is in
       lib/site-strings.js and in the `data-desc` of every hand-written group) and it was
       printed inside the body, where you only reach it after opening the section — i.e.
       after the moment it was for. It is the second column now.

       The row heights, the lead slot, the hover band and the hairlines are all inherited
       from THE CONFIG ROW; this block only says what is particular to Page Text. */
    /* THE COLUMNS. A string is a ROW: the wireframe that says where it lands, the SCREEN
       it belongs to, what the string is, and the box you type it in. No --list-end —
       these rows carry no action and open nothing, so reserving 46px of chevron rail
       would take the width out of the wording column, which is the one that matters here
       (same call, same reason, as Automated Emails' mapping table). */
    .ss-panel[data-panel="pagetext"] .page-stacked {
      --list-cols: var(--list-lead) calc(224px * var(--fs-scale, 1)) minmax(0, 1fr) minmax(0, 2.2fr); }
    .pt-group { display: block; }
    /* ── THE SCREEN COLUMN ─────────────────────────────────────────────────
       This is the third shape this list has had, and the first two both failed the same
       way. Sixteen bordered cards: card-on-card, borders outlining nothing. Sixteen
       filled bands: a fill that never stops separates nothing, and folded shut the whole
       table was stripes. What both were really doing was hiding the strings behind a
       heading — and the strings are what you came for.
       So every row is expanded, and the heading became a COLUMN. A heading scrolls away;
       by row forty you no longer know which screen you are editing. A column is on every
       row at every scroll position, which is the question ("which screen is this on?")
       actually answered rather than answered once at the top.
       NOTHING HERE IS FILLED. --band belongs to the .list-head and to nothing else: the
       moment a second thing wears it, it stops reading as "this is the header". */
    .pt-screen { grid-column: 2; grid-row: 1; min-width: 0; display: flex; align-items: center; gap: 7px;
      font-size: var(--fs-sm); color: var(--text); overflow: hidden; }
    /* The repeats say the same word in a quieter ink — the column is the answer to
       "which screen", and an answer printed once per run stops being one the moment you
       scroll past it. Quiet enough to read as a continuation, not as sixteen new facts. */
    .pt-screen-rpt { color: var(--muted); padding-left: calc(15px * var(--fs-scale, 1) + 7px); }
    .pt-screen-rpt, .pt-screen .pt-fold-t { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    /* The fold control, on the row that starts each screen's run: the thing you click is
       the thing that names what disappears. A real <button> — it is the only control in
       the row and has to be reachable by keyboard like every other one in the section. */
    .pt-fold { display: inline-flex; align-items: center; gap: 7px; min-width: 0; padding: 0;
      border: 0; background: none; font: inherit; font-size: var(--fs-sm); font-weight: var(--fw-bold);
      color: inherit; text-align: left; cursor: pointer; }
    .pt-fold:hover { color: var(--accent-dark, var(--accent)); }
    .pt-fold:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
    .pt-fold-car { flex: none; display: grid; place-items: center; color: var(--muted); }
    .pt-fold-car > .lic { width: calc(15px * var(--fs-scale, 1)); height: calc(15px * var(--fs-scale, 1));
      transform: rotate(90deg); transition: transform .15s ease; }
    .pt-fold:hover .pt-fold-car { color: var(--accent); }
    .pt-folded .pt-fold-car > .lic { transform: none; }
    /* Folded: the strings go, the row that names them stays — NOT `display:none` on the
       group, because the screen has to keep a line in the table or there is no way back.
       The surviving row is emptied by hiding .pt-field-main outright rather than by
       `visibility: hidden` on its cells: a hidden cell still takes its space, so a screen
       whose first string is a textarea folded down to a 100px blank stripe. Hiding the
       wrapper (display:contents, so this takes its whole subtree with it) leaves a plain
       44px row carrying the screen's name and what it is holding. */
    .pt-folded .pt-field:not(.pt-first), .pt-folded .pt-sub,
    .pt-folded .pt-off-note { display: none; }
    .pt-folded .pt-first > .pt-field-main,
    .pt-folded .pt-first > .pt-skel { display: none; }
    /* The screen's row-0 cell also carries the add-on chip, rather than a column of its
       own: eleven of the sixteen screens are not add-ons and would leave that column
       empty, and under the Add-Ons rule every row would carry the same value. */
    .pt-state { flex: none; }
    /* Folded, this is the only thing standing in for the rows that went away, so it says
       what they were ("7 strings"). Open, the rows speak for themselves and it is empty
       unless a search is narrowing them. */
    .pt-count { flex: none; margin-left: auto; padding-left: 8px; font-size: var(--fs-2xs); color: var(--muted); font-variant-numeric: tabular-nums; }
    .pt-off-note { margin: 0; padding: 9px 12px; border-bottom: 1px solid var(--border); }
    /* No gap and no padding: these are table rows now, and .list-row brings its own
       44px, its own 12px gutter and the hairline between it and the next. */
    .pt-fields { display: flex; flex-direction: column; }
    /* A quiet rule inside a screen — "Before Submitting", "The Form", "After Submitting".
       Only drawn where the subsection changes, so a single-subject screen has none.
       It sits in the WHAT-IT-IS column, not across the table: it qualifies that column
       ("Heading" of what?), and a label spanning the full width would read as a second
       header competing with the one real one. */
    .pt-sub { padding: 10px 12px 4px calc(var(--list-lead) + 224px * var(--fs-scale, 1) + 26px + 12px);
      font-size: var(--fs-2xs); font-weight: var(--fw-bold);
      letter-spacing: .1em; text-transform: uppercase; color: var(--muted); }
    /* ── The typeface picker, above the strings ──────────────────────────────
       Site Config → Branding → Wording opens with the local's typeface, then the
       words it renders. It sits ABOVE .list-head on purpose: it is not a string
       in the list, it is the thing every string on this screen is drawn in, and
       a row inside the bounded table would read as one more piece of copy.

       The box borrows the segmented control's visual language (--bg fill, 1px
       --border, 9px radius — see .cp-toggle) because it does the same job: pick
       one of a few, not type a value. It is a real <select> rather than a strip
       of tabs because the options carry sample text and a strip of six would
       wrap on a phone.

       .fp-select sets no font-family of its own — renderFontPicker writes the
       previewed stack inline, so the closed box always shows the chosen face and
       each option shows its own. A picker that names fonts in the UI font tells
       you nothing, and here it would be actively misleading: these are SYSTEM
       stacks, so the same choice resolves to a different face on Windows, macOS
       and Android. The sample IS the label. */
    .font-pick { display: grid; grid-template-columns: auto minmax(0, 260px) minmax(0, 1fr);
      align-items: center; gap: 10px 12px; padding: 0 var(--pad-card) var(--pad-row); }
    .fp-lbl { font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); }
    /* The trigger is .mi-picker-btn — the SAME field the profile's grade/subject/building
       pickers use, opening the same anchored dropdown. A native <select> would be the one
       control on the screen wearing the OS's 1995 list instead of the app's. */
    .fp-trigger { min-width: 0; }
    /* The sample rides the site-config preview component (.prev-stage + previewHead),
       like Brand Colors and App Icon — a typeface is a brand choice and previews the
       same way the other brand choices do. Real chrome, not one word: a heading weight
       over a body line, because "does this read at 13px" is the actual question. */
    /* Inline with the field, and ONE line tall. previewHead already lays its tag and
       body out as a flex row, so the stage is a single row: [PREVIEW] sample text.
       The head's bottom margin exists to separate it from a block of preview under
       it; here it IS the preview, so it goes. */
    .fp-stage { min-width: 0; padding: 6px 11px; align-self: center; }
    .fp-stage .prev-head { margin: 0; min-width: 0; }
    .fp-sample { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
      font-size: var(--fs-md); font-weight: var(--fw-body); color: var(--text);
      text-transform: none; letter-spacing: normal; }
    @media (max-width: 760px) {
      /* Stacked: the field on its own row, the preview under it. */
      .font-pick { grid-template-columns: auto minmax(0, 1fr); }
      .fp-stage { grid-column: 1 / -1; }
    }

    /* Wireframe + field, side by side. The thumbnail is a fixed 44px column so every
       label starts on the same line regardless of skeleton shape. */
    /* One row per string: the label sits BESIDE its box, not stacked above it. Stacked,
       every field cost two lines and a screen that is nothing but fields read as twice
       as long as it is — and the label column gives the eye a single edge to scan down
       instead of a zigzag. The label is a fixed column so the boxes line up with each
       other, which is what makes a list of them scannable at all. */
    /* THE STRING'S ROW. `list-row` in the markup brings the 44px height, the 12px
       gutter, the hover band and the hairline to the next row; `display:grid` on
       --list-cols comes from `.list-cols .list-row`. The 780px cap that used to sit
       here is gone with the form it belonged to: a cell fills its column.

       .pt-field-main is display:contents — it has no layout job left. It exists because
       every load and save path in this file, and ptDecorateStatic's normalising of the
       five hand-written groups, addresses it; dissolving it here lets the label, the
       note and the box be direct cells of the row's grid without any of that moving. */
    .pt-field-main { display: contents; }
    /* The wireframe takes the LEAD SLOT — the column every list in this section puts its
       marker in, so a string's "where does this land" thumbnail tracks down the same
       edge as Buildings' icons and Positions' locks. Scaled to the slot (38px) from the
       44px it drew when it sat outside a table. */
    .pt-skel { width: var(--list-lead); line-height: 0; display: grid; place-items: center; }
    .pt-skel svg { width: calc(34px * var(--fs-scale, 1)); height: calc(25px * var(--fs-scale, 1)); }
    .pt-skel-bg { fill: var(--card); stroke: var(--border); stroke-width: 1; }
    .pt-skel-bar { fill: var(--border); opacity: .55; }
    /* The lit band — the whole point. Accent so it reads instantly against the greys. */
    .pt-skel-hit { fill: var(--accent); }
    .pt-group-off .pt-skel-hit { fill: var(--muted); }
    /* Column 2 — what the string IS. Left-aligned: it is a table column now, and a
       column reads down its left edge. (It was right-aligned when it was a form label
       hugging the box beside it, which is a different object entirely.) */
    .pt-lbl { grid-column: 3; grid-row: 1; min-width: 0; align-self: center;
      font-size: var(--fs-md); color: var(--text); line-height: var(--lh-snug); }
    /* Column 3 — the wording, in the box you type it in. EVERY CELL IS PLACED
       EXPLICITLY, and this is why: `.pt-desc` was the only child that named a column,
       so the three fields that carry a note (the two required confirmations and the
       privacy banner) laid out as label · note — and then the INPUT, having no
       placement of its own, auto-flowed into the next free cell, which was column ONE
       of the next grid row. The box for the string you came to edit rendered under its
       own label, an entire column left of every other box on the screen, with the note
       sitting where the box belonged. It looked like a rendering bug because it was one.
       The note stays ABOVE the box: "keep the 'or it wouldn't be safe or appropriate to'
       half" is a thing to read BEFORE you rewrite the sentence, not after. */
    .pt-desc { grid-column: 4; grid-row: 1; margin: 0 0 5px; }
    .pt-field input, .pt-field textarea { grid-column: 4; grid-row: 1; }
    .pt-field:has(.pt-desc) input, .pt-field:has(.pt-desc) textarea { grid-row: 2; }
    /* A row carrying a note is two grid rows tall, so the label names the pair from the
       middle of it rather than sitting against the note's first line. */
    .pt-field:has(.pt-desc) .pt-lbl { grid-row: 1 / 3; }
    @media (max-width: 700px) {
      /* One column, and the wireframe steps aside: it would eat the width a long
         placeholder needs. Every explicit placement above is unwound so DOM order
         stacks them — label · note · box, which is the order they are written in. */
      .ss-panel[data-panel="pagetext"] .page-stacked { --list-cols: minmax(0, 1fr); }
      .pt-skel { display: none; }
      .pt-screen, .pt-lbl, .pt-desc, .pt-field input, .pt-field textarea,
      .pt-field:has(.pt-desc) .pt-lbl,
      .pt-field:has(.pt-desc) input, .pt-field:has(.pt-desc) textarea { grid-column: 1; grid-row: auto; }
      .pt-screen-rpt { display: none; }   /* the run's first row names it; a phone has no width to repeat it */
      /* …and the subsection rule loses the indent that aligned it to the WHAT-IT-IS
         column, because on one column there is no such column. Left in, its 300px of
         padding was wider than the table and gave the box 80px of sideways scroll —
         invisible until you drag it, which is the worst kind of broken. */
      .pt-sub { padding-left: 12px; }
      .pt-field { padding-top: 9px; padding-bottom: 9px; }
      .pt-lbl { font-weight: var(--fw-semi); }
    }
    /* The box is the section's ONE field treatment (.list-in) — same 30px height, same
       7px radius, same focus ring as every editable cell in Site Config — written out
       here rather than by adding the class in three render paths. A textarea keeps its
       own height, because a tall box here is what says the member sees more than one
       line there; the row grows with it, which .list-row's min-height allows for. */
    .pt-field input, .pt-field textarea { width: 100%; min-width: 0; margin-top: 0; height: calc(30px * var(--fs-scale, 1)); padding: 0 10px; border: 1px solid var(--border); border-radius: 7px; font-size: var(--fs-md); font-family: inherit; line-height: var(--lh-snug); background: var(--card); color: var(--text); }
    /* A multi-line box is THREE lines before anything is typed in it, whatever the spec's
       `rows` says. Two of them shipped at rows="2", which on a screen whose whole job is
       wording read as a single-line box that happened to wrap — and the promise this
       panel makes in its own lede is that a tall box here means a multi-line field there.
       Above that floor ptGrow() sizes it to its content (see app.js), so `overflow-y` can
       stay hidden: an inner scrollbar on a box that already fits its text is a scrollbar
       that only ever appears mid-keystroke. `resize` stays — the cap is 420px and someone
       rewriting a long passage should be able to overrule it. */
    .pt-field textarea { height: auto; resize: vertical; overflow-y: hidden;
      min-height: calc(3 * var(--fs-md) * var(--lh-body) + 16px); padding: 7px 10px; line-height: var(--lh-body); }
    .pt-field input:focus, .pt-field textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
    /* An untouched field shows the shipped copy as its placeholder — dim it a little
       more than a normal hint so "inherited default" reads differently from "empty". */
    .pt-field input::placeholder, .pt-field textarea::placeholder { color: var(--muted); opacity: .75; }
    /* A group whose optional module is switched off: legible but plainly inert, so it
       reads as "parked" rather than broken. Inputs are also `disabled` in the markup —
       this is only the visual half. */
    /* Dimmed to say "members can't see this yet", NOT to say "you can't type here" — these
       fields are editable while the add-on is off, so no not-allowed cursor and no dead-input
       background. .7 rather than .5: it has to stay comfortably readable to write in. */
    .pt-group-off .pt-fields { opacity: .7; }
    /* On/off chip on every add-on group — an absent chip is not a state, so both are said.
       No margin of its own: it is a flex child of .pt-group-t now, and the cell's gap is
       the spacing. `flex: none` so a long screen name shrinks before the chip does — the
       name has an ellipsis to fall back on and the chip is two letters that mean nothing
       clipped. */
    .pt-state { flex: none; padding: 2px 8px; border-radius: 999px; font-size: var(--fs-2xs); font-weight: var(--fw-bold); letter-spacing: .04em; text-transform: uppercase; }
    .pt-state.on  { background: var(--ok-bg); color: var(--ok); }
    .pt-state.off { background: var(--neutral-bg); color: var(--neutral); }
    /* The rule that separates "every local has this" from "only if you switched it on".
       NOT filled: --band is the header's colour and the header's alone, and a second
       filled strip halfway down the table competes with the one that names the columns.
       A rule and a label is enough — there is exactly one of these, so it does not need
       to shout to be found. */
    .pt-band { display: flex; flex-wrap: wrap; align-items: baseline; gap: 2px 10px;
      margin: 0; padding: 14px 12px 7px; border-top: 1px solid var(--border); }
    .pt-band-t { font-size: var(--fs-2xs); font-weight: var(--fw-bold); letter-spacing: .1em; text-transform: uppercase; color: var(--text); }
    .pt-band-d { font-size: var(--fs-sm); color: var(--muted); }
    .pt-off-tag { margin-left: 8px; padding: 2px 8px; border-radius: 999px; background: var(--neutral-bg); color: var(--neutral); font-size: var(--fs-2xs); font-weight: var(--fw-bold); letter-spacing: .04em; text-transform: uppercase; vertical-align: middle; }
    /* ── Letterhead editor (Site Config → App Branding → Letterhead) ── */
    /* The form column is the one carrying paired fields and two-up toggles now, so it
       gets the wider half; the preview is page-shaped and reads fine smaller. */
        /* Weighted toward the form: the preview is now sized from its own height, so a
       wide column just padded grey around a centred page, while Address and Contact
       had to share 230px between them. */
    /* The grid takes the scroller's leftover height DIRECTLY, via flex, rather than
       min-height:100% — a percentage that never resolved, because .page-scroll's own
       height is indefinite as far as a percentage child is concerned. Without it the grid
       was only ever as tall as the form (339px), so the preview column had nothing spare
       to grow into and the page stayed the same size on a short laptop and a tall
       monitor. Scoped to this panel: .page-scroll is shared with every other Site Config
       screen and none of them want a flex column. */
    .ss-panel[data-panel="letterhead"] .page-scroll,
    .ss-panel[data-panel="letterhead"] #letterhead-editor { display: flex; flex-direction: column; min-height: 0; }
    .ss-panel[data-panel="letterhead"] #letterhead-editor { flex: 1 1 auto; }
    /* THE ROW IS 1fr, NOT CONTENT. This grid already gets a definite height from the
       scroller via flex — but its single row defaulted to `auto`, i.e. content-sized,
       so the row asked its tallest child how tall to be and the preview answered with
       a number derived from its own WIDTH (height:100% of an indefinite parent falls
       back to aspect-ratio). Measured before this line: the scroller and the grid were
       both 425px while the row was 596px and the preview column overflowed them by
       171px — which is the scrollbar, the overflow past the viewport, and the
       width→height feedback that made it shake, all from one `auto`.
       `minmax(0, 1fr)` makes the row take the grid's OWN height and allows it below
       content, so the height flows one way only — scroller → grid → row → wrap → fill
       → the sheet, which sizes its width from the height it is given. Nothing upstream
       ever asks the sheet how big it is, so there is no loop left to close and nothing
       to overflow. */
    /* The row stays DEFINITE and this grid stays shrinkable. Round 3 briefly tried
       `auto` + `flex: 1 0 auto`, on the reasoning that a width-derived sheet no longer
       needs a definite row — true, but it misses what the row is also doing: holding
       the column inside `.page-scroll`. That scroller is a flex item with
       `min-height: 0`, so a child that refuses to shrink does not make it scroll, it
       makes it COLLAPSE (measured: 8px tall), and the panel spills straight through
       its own pinned footer. The sheet is kept in bounds by its cap instead — see
       `.lh-preview` below. */
    /* 1.6fr, not 2fr. The form is three short cards; the sheet is the thing this screen
       exists to show, and at 2fr the viewport cap above never bound — the column was the
       narrower limit at every window size, so a tall monitor bought nothing. */
    .lh-grid { display: grid; grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); gap: 14px; align-items: stretch; flex: 1 1 auto; min-height: 0; }
    /* Two columns of cards, so the whole letterhead fits one view instead of a tall
       stack beside a tall page. The mark and the footer are short; the masthead is
       the only meaty one, so it takes the second column on its own. */
    /* align-CONTENT:start as well as align-items — the form column is now as tall as
       the panel, and without it the grid's rows spread down the whole height, which
       floated "Bottom Of The Page" away from the card above it. */
    .lh-form { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; min-width: 0; align-items: start; align-content: start; }
    /* The mark takes the left column outright; the two short text cards stack in the
       right one. Left to auto-flow, the footer landed under the mark and made the
       form as tall as both of them added together. */
    .lh-form > .lh-mark { grid-column: 1; grid-row: 1 / span 2; }
    @media (max-width: 1150px) { .lh-form { grid-template-columns: minmax(0, 1fr); } }
    /* Each group is one part of the sheet — masthead, contact block, footer — headed by
       a hairline rule so the column reads as three short forms, not fifteen stacked rows. */
    .lh-group { min-width: 0; border: 1px solid var(--border); border-radius: 12px;
                background: var(--card); padding: 10px 12px; }
    .lh-gh { display: flex; align-items: baseline; gap: 8px; margin: 0 0 10px; padding-bottom: 6px;
             border-bottom: 1px solid var(--border); font-size: var(--fs-xs); font-weight: var(--fw-bold);
             letter-spacing: .05em; text-transform: uppercase; color: var(--muted); }
    .lh-gh-note { font-size: var(--fs-xs); font-weight: var(--fw-body);
                  letter-spacing: 0; text-transform: none; color: var(--muted); opacity: .85; }
    .lh-rows { display: grid; grid-template-columns: minmax(0, 1fr); gap: 9px; }
    .lh-rows.two { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    /* A full-width field inside a two-up group: the name and subtitle run the width
       of the printed page, so they run the width of the group. */
    .lh-rows.two > .lh-wide { grid-column: 1 / -1; }
    /* The per-field explanation rides on the label as a hover tip: it was five paragraphs
       of prose competing with the live preview that already shows the answer. */
    .lh-lbl { display: inline-block; margin-bottom: 5px; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--text); }
    .lh-lbl.has-tip { cursor: help; text-decoration: underline dotted color-mix(in srgb, var(--muted) 60%, transparent); text-underline-offset: 3px; }
    .lh-field input, .lh-field textarea { width: 100%; padding: 9px 11px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-md); font-family: inherit; background: var(--card); color: var(--text); }
    .lh-field textarea { resize: vertical; line-height: var(--lh-body); }
    .lh-field input:focus, .lh-field textarea:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    /* Framed like .ai-stage / .hdrp-stage — every branding preview lives in the
       same bordered stage with the same uppercase caption. */
        /* Recessed like every other preview in Branding — it was a white card matching
       the editors beside it, which is what made a read-only page look editable. */
    /* align-self:start — THE STAGE HUGS ITS SHEET. Stretched to the grid row (which
       fills the panel) it was a grey box up to 312px taller than the page inside it,
       measured at 1700x1300 with the warning away: the sheet is sized from the column's
       WIDTH and the stage was sized from the panel's HEIGHT, so the two agreed only by
       coincidence. Nothing here reads the sheet's size back, so hugging costs nothing —
       it just stops the stage claiming room the page never fills. Sticky still works:
       the item's grid area is the whole row, it simply sits at the top of it. */
    .lh-preview-wrap { position: sticky; top: 12px; align-self: start; min-width: 0; padding: 11px 13px; display: flex; flex-direction: column; min-height: 0; }
    /* (.lh-prev-head lived here — a fourth spelling of .prev-head's four declarations,
       already unused: the letterhead stage renders previewHead() like everything else.) */
    /* 8.5:11 keeps the preview honestly page-shaped, so a masthead that crowds the
       page reads as crowded here too. */
        /* Page-proportioned, but sized from HEIGHT so the sticky preview fits the viewport.
       At width:100% an 8.5x11 came out ~880px tall — taller than the panel, so the thing
       it exists to show was never fully on screen at once. */
    /* Sized from the VIEWPORT, not a fixed cap. A fixed size is wrong at both ends:
       big enough to read on a laptop made the panel scroll, and small enough never to
       scroll made it a postage stamp on a tall monitor. The 652px subtrahend is the
       chrome around the scroller (nav, tabs, lede, footer) plus this stage's own head
       and warning box — measured, not guessed, at 820px and 1100px. aspect-ratio +
       align-self:center keeps page proportions: width derives from the final height
       rather than stretching to the column. */
    /* The page takes whatever height the stage has left after its heading and warning,
       so it is as large as the panel allows instead of a viewport-derived guess that was
       a postage stamp on a tall monitor. min-height:0 lets it SHRINK inside the flex
       column — without it a flex item refuses to go below its content and pushes the
       panel into a scrollbar. aspect-ratio + align-self:center keep page proportions:
       the width derives from the height it ends up with. */
    /* Two elements, because aspect-ratio derives HEIGHT FROM WIDTH — on its own it made
       the page as tall as the column was wide and pushed the panel into a scrollbar,
       whatever flex asked for. So: the filler takes the leftover space (flex-basis 0, so
       the flex algorithm hands it a definite height), and the page inside is height:100%
       of that, letting aspect-ratio derive the WIDTH instead. Now the page is as large as
       the panel allows and never larger. */
    /* ── The shake ────────────────────────────────────────────────────────────
       `height: 100%` + `aspect-ratio` derives the WIDTH from the height, and
       `max-width: 100%` clamps that width, which wants to re-derive the height.
       Two constraints pushing the same box in opposite directions.

       That pair is stable ON ITS OWN — swept across 13 container heights from 200
       to 1400px it never oscillated. What made it shake was an outside nudge:
       when this column sits at a width where the preview's height straddles the
       scroller's threshold, the vertical scrollbar appears, takes ~15px of width,
       the aspect-ratio makes the box taller, a horizontal scrollbar is then needed,
       that steals the height back, the vertical one is no longer required — and
       round it goes. Frame-differencing a screen recording showed it as full
       repaints of one identical region (`x[106-580] y[122-736]`, ~63,500px) with
       long still gaps between, and a ResizeObserver logged `fill resized` with NO
       re-render alongside it: a pure size loop, not a redraw.
       Two things close it off. `overflow: hidden` here means this box can never
       grow a scrollbar of its own, so it clips rather than joining the argument;
       and `scrollbar-gutter: stable` on `.page-scroll` — the actual scroller above
       it — reserves the bar's width whether or not it is drawn, so its appearance
       can no longer change the available width. (The gutter property only applies
       to a real scroll container, which is why it goes there and not here.)
       `min-height: 0` lets the box shrink to the clamp instead of holding a floor
       that keeps it over the threshold. */
    /* ── ROUND 2: kill the width→height path, not the scrollbar ───────────────
       The first fix stabilised one scrollbar and the shake survived, which proves
       the scrollbar was a symptom rather than the cause. The cause is that this
       box's height was CONTENT-DERIVED.

       The comment above explains the intent: flex-basis 0 hands the filler a
       definite height, and the page inside is height:100% of that, so aspect-ratio
       derives the WIDTH. That reasoning holds only while the grid row is sized by
       the FORM column. When the preview would be the taller of the two, the row is
       sized by the preview instead — whose height, via `height:100%` of an
       indefinite parent, falls back to aspect-ratio deriving height FROM WIDTH. The
       column's width becomes an input to its own height, and then ANY scrollbar
       anywhere in the chain closes the loop. That is why the regime is
       width-dependent, why shrinking the window escapes it, and why stabilising one
       scrollbar only moved the threshold instead of removing it.

       So the height is definite and VIEWPORT-derived now. `vh` cannot be changed by
       a vertical scrollbar (only `vw` can), so nothing the layout does can feed back
       into it: the iframe's `height:100%` resolves against a real number, width
       derives from that height, and if `max-width:100%` clamps the width on a narrow
       column the height does NOT move, because it was never computed from the width.
       The loop has no path left to close. `clamp()` keeps it sensible from a laptop
       to a tall monitor, which was the original objection to a viewport-derived
       guess — a fixed vh was "a postage stamp on a tall monitor"; a clamped one
       isn't. */
    /* ── ROUND 3: the sheet stops being sized by the column at all ────────────
       Rounds 1 and 2 both kept `height:100%` + `aspect-ratio` — width derived from
       height — and both were correct ONLY while the FORM column was the taller of
       the two, because that is what makes the row's height definite. The moment the
       preview column is the taller one, `height:100%` resolves against an indefinite
       parent, falls back to aspect-ratio deriving height FROM width, and `max-width:
       100%` pushes back the other way: the loop is closed again.

       And the thing that flips which column is taller is the warning box, which is
       IN the preview column and is ~120px tall. So "the yellow warning disappeared"
       and "the preview started shaking" are not two symptoms — they are one event.
       Switching the mark to App icon / Name only, or putting a backing behind it,
       all clear the warning (lhCheckLogo), shorten the preview column past the form
       column, and hand the sheet's height back to its own width.

       So: the sheet is sized from its WIDTH, full stop. `width:100%` of the column,
       `height:auto`, aspect-ratio derives the height. Nothing reads the height back
       — no `height:100%`, no `max-width` clamp fighting it, no dependence on which
       column is taller, no scrollbar that can change the answer. The cap stays
       viewport-derived so a tall monitor still gets a big sheet (the original
       objection to a fixed size), and `vh` cannot be moved by a vertical scrollbar,
       so it is an input that nothing downstream can perturb.

       The cap has to be sized for the case where the WARNING IS SHOWING, not for the
       space that happens to be free right now. Sizing it to the free space is how the
       height came back into the calculation through the side door: the warning is the
       one thing in this column that comes and goes, so a sheet that grows into the
       room the warning vacates is a sheet whose size depends on the warning again.
       Priced at the worst case it is one number in every state, and the filler simply
       carries more slack when the warning is away. The subtrahend is the chrome above
       the stage plus the stage's own head plus a warning — measured, not guessed.

       Two things this must NOT do, both learned the hard way:
       · the grid row stays `minmax(0, 1fr)` and the grid stays shrinkable. An `auto`
         row lets the column grow past `.page-scroll`, and because that scroller is
         itself a flex item with `min-height: 0`, it does not scroll — it COLLAPSES
         (measured at 8px) and the whole panel spills out through the pinned footer.
       · the sheet keeps `height: auto`. Clamping its height with `max-height` does
         not shrink its width to match — aspect-ratio yields to a definite `width` —
         so the page comes out squashed rather than smaller. */
    /* ── ROUND 4: the filler stops being a filler ─────────────────────────────
       Round 3 made the sheet width-derived and left this box stretching (`flex: 1 1 0`)
       to whatever height the panel had spare. Two rules sizing the same box off two
       different axes, and they never agreed in either direction — both measured:
       · TOO TALL — 1700x1300, warning away: fill 774px around a 462px sheet, i.e. 312px
         of empty grey under the page. That is the "large gap".
       · TOO SHORT — 1000x900: fill 132px around a 209px sheet, and `overflow: hidden`
         then CUT THE BOTTOM OFF the preview. Worse at 900px wide, where the one-column
         media query hands the sheet the full width: 228px of page clipped away.
       So the filler fills nothing now — it is exactly its sheet. The height flows one
       way and one way only: column width → sheet width → sheet height → this box →
       the stage. `overflow: hidden` stays as the guard it was added to be (this box can
       never grow a scrollbar and join the argument); with a content-derived height there
       is nothing left for it to clip. */
    .lh-preview-fill { flex: none; min-width: 0;
                       display: flex; justify-content: center; align-items: flex-start;
                       overflow: hidden; }
    /* TWO limits, and whichever bites is exact — that is what leaves no gap in either
       direction. The column's width caps it (and then the stage hugs, above); the
       viewport term caps it on a short window, and 525px is EXACTLY what sits above and
       below this sheet, measured: 474 of chrome outside `.page-scroll`, 40 of stage
       padding + head, 11 of stage bottom padding. So when height is the binding limit
       the column fills the scroller to the pixel — no gap AND no scrollbar — instead of
       being priced for a warning box that no longer lives in this column at all (it
       moved to The Mark; see lhMarkBlock). `vh` cannot be moved by a scrollbar, so it
       stays an input nothing downstream can perturb, which is what keeps the width →
       height flow one-directional and the panel still.
       The floor is 180px so a very short window gets a small page and an inch of scroll
       rather than the old 80px postage stamp (at 1280x800 the previous formula rendered
       a 114px-wide "page" — an answer nobody can read to the question this panel exists
       to answer). The ceiling is 520px: past that the sheet stops being a preview beside
       a form and starts being a document viewer. */
    .lh-preview { width: 100%; max-width: min(100%, max(180px, calc((100vh - 531px) * 0.7727)), 520px);
                  height: auto; aspect-ratio: 8.5 / 11; margin: 0 auto;
                  border: 1px solid var(--border); border-radius: 8px; background: #fff; display: block; }
    /* Opted in PER PANEL, not globally. `.page-scroll` deliberately keeps
       `scrollbar-gutter: auto` — see the note on that rule: a blanket `stable`
       permanently narrows the non-table panels by ~15px and slides their items off
       the + Add button's right edge. Table-pattern panels already opt in one by one
       for the same reason column labels would drift; Letterhead opts in because its
       preview is an aspect-ratio box whose height is derived from this column's
       width, so a scrollbar arriving or leaving is not a cosmetic 15px here — it is
       the input to a size calculation, and flipping it is what made the panel shake. */
    .ss-panel[data-panel="letterhead"] .page-scroll { scrollbar-gutter: stable; }
    .lh-logo-note { margin-top: 9px; }
    /* Measured verdict on the letterhead mark (lhCheckLogo). Sits between the preview
       and the prose note: the note explains the rule, this says whether THIS logo breaks it. */
    .lh-warn { display: flex; align-items: flex-start; gap: 8px; margin-top: 9px; padding: 8px 10px;
               background: var(--warn-bg); border: 1px solid color-mix(in srgb, var(--warn) 32%, transparent);
               border-radius: 9px; font-size: var(--fs-sm); line-height: var(--lh-body); color: var(--warn); }
    .lh-warn b { display: block; margin-bottom: 2px; }
    .lh-warn .ic { flex-shrink: 0; display: flex; margin-top: 1px; }
    .lh-warn .ic svg { width: 15px; height: 15px; }
    /* The warning names a fix that lives two inches away — so it carries the button. */
    .lh-warn .lh-fix { margin-top: 8px; }
    /* ── Printed Mark block: which logo prints, and how it survives white paper ──
       Source cards and print options are one-line controls with their explanation on
       a hover tip. They used to carry it inline, which made three cards and four
       checkboxes taller than the five text fields underneath them combined. */
    /* Only the LAYOUT of these two rows lives here now — how many across, and the
       spacing. What a pick-one chip and a toggle chip actually LOOK like is defined
       once, up in the Branding block (.opt-pick / .opt-chip, which these share their
       declarations with). Don't restate the appearance here; that is precisely the
       drift that made this tab and the Logos tab look like different products. */
    .lh-srcs { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 7px; margin-bottom: 10px; }
    .lh-src-name { display: block; }
    .lh-opts { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 7px; margin-bottom: 10px; }
    /* "Name only" prints no mark, so the knobs that shape one have nothing to act on. */
    .lh-opts.off { display: none; }
    @media (max-width: 900px) { .lh-grid { grid-template-columns: minmax(0, 1fr); } .lh-preview-wrap { position: static; } }
    @media (max-width: 640px) { .lh-srcs, .lh-opts, .lh-rows.two { grid-template-columns: minmax(0, 1fr); } }
    /* Notes locked once a request is done (reopen to add more). */
    .comment-locked { padding: 18px var(--pad-card); border-top: 1px solid var(--border); font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--muted); display: flex; align-items: center; justify-content: center; gap: 7px; }
    .comment-locked .ic { display: inline-flex; opacity: .65; }
    .comment-locked .ic svg { width: 15px; height: 15px; }
    /* Complete-this-request card — its own section at the very bottom. Uses the
       same header as the other cards; the orange slide-to-confirm is its accent
       (a stray tap/scroll can't finish a ticket). */
    .complete-card .td-card-hdr .ic { color: #059669; }
    /* The Complete card's body. Its gutter was a literal 22, so on a phone — where the
       gutter is 14 — its copy sat 8px inside every other line on the screen. Caught by
       the one-gutter assertion, which only saw it because the fixture's description is
       a single character and this became the leftmost text in the card. */
    .cc-body { padding: 16px var(--pad-card); }
    .cc-sub { font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); margin: 0 0 14px; }
    /* Reopen button fills the slot the slide control held — full width, same height. */
    .cc-reopen { width: 100%; height: 48px; justify-content: center; }
    .slide-confirm { position: relative; height: 48px; border-radius: 999px; background: var(--accent-light); border: 1px solid rgba(var(--accent-rgb),.30); overflow: hidden; touch-action: none; user-select: none; -webkit-user-select: none; transition: background .25s ease, border-color .25s ease; }
    .slide-fill { position: absolute; left: 4px; top: 3px; height: 40px; width: 40px; background: rgba(var(--accent-rgb),.18); border-radius: 999px; pointer-events: none; }
    .slide-label { position: absolute; inset: 0; display: grid; place-items: center; padding-left: 30px; font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--accent-dark); pointer-events: none; }
    .slide-handle { position: absolute; left: 4px; top: 3px; width: 40px; height: 40px; border-radius: 50%; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; cursor: grab; touch-action: none; }
    .slide-handle:active { cursor: grabbing; }
    .slide-confirm.done { background: #059669; border-color: #059669; }
    .slide-confirm.done .slide-fill { display: none; }
    .slide-confirm.done .slide-handle { opacity: 0; transition: opacity .2s; }
    .slide-confirm.done .slide-label { color: #fff; padding-left: 0; }
    /* The escalate track runs the OTHER WAY: handle parked right, dragged left. Mirroring
       is purely presentational — wireSlide works in distance-along-track for both — but the
       opposite gesture is what makes the two impossible to confuse with a thumb. Amber, not
       green: sending a case up is a different KIND of act from finishing one. */
    .slide-confirm.slide-rtl { background: var(--warn-bg); border-color: rgba(0,0,0,.10); }
    .slide-confirm.slide-rtl .slide-fill { left: auto; right: 4px; background: rgba(0,0,0,.06); }
    .slide-confirm.slide-rtl .slide-handle { left: auto; right: 4px; background: var(--warn); color: #fff; }
    .slide-confirm.slide-rtl .slide-label { padding-left: 0; padding-right: 30px; color: var(--warn); }
    .slide-confirm.slide-rtl.armed { background: var(--warn); border-color: var(--warn); }
    .slide-confirm.slide-rtl.armed .slide-label { color: #fff; padding-right: 0; }
    .slide-confirm.slide-rtl.armed .slide-fill { display: none; }
    /* Escalation shares the completion card, below a full-content-width divider. */
    .cc-esc { border-top: 1px solid var(--border); margin-top: 16px; padding-top: 14px; }
    .cc-body > .cc-esc:first-child { border-top: 0; margin-top: 0; padding-top: 0; }
    .cc-esc-sub { margin-bottom: 12px; }
    .esc-strip { display: flex; align-items: center; gap: 10px; }
    .esc-strip .lic { width: 18px; height: 18px; flex: 0 0 18px; color: var(--muted); }
    .cc-esc-on.is-active .esc-strip .lic { color: var(--warn); }
    .esc-strip-txt { min-width: 0; flex: 1; }
    .esc-strip-t { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--text); }
    .esc-strip-s { font-size: var(--fs-xs); color: var(--muted); margin-top: 1px; }
    .esc-strip-reason { margin-top: 10px; font-size: var(--fs-md); line-height: var(--lh-body); white-space: pre-wrap; color: var(--text); }
    .esc-strip-note { margin-top: 8px; font-size: var(--fs-xs); color: var(--muted); line-height: var(--lh-body); }
    /* One-shot confetti burst from the button center on completion. */
    .confetti-layer { position: fixed; inset: 0; pointer-events: none; z-index: 2000; }
    .confetti-bit { position: fixed; width: 8px; height: 8px; border-radius: 2px; animation: confetti-fly .9s ease-out forwards; }
    @keyframes confetti-fly {
      0%   { transform: translate(-50%, -50%) rotate(0); opacity: 1; }
      100% { transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy) + 70px)) rotate(var(--rot)); opacity: 0; }
    }

    /* ── Site-config delete standard: trash icon + inline "type delete" + red explode ── */
    /* The destructive button's icon (replaces the old ✕ glyph). */
    .blk-btn.del .lic { width: 15px; height: 15px; }
    .danger-del-btn { display: inline-flex; align-items: center; gap: 7px; }
    .danger-del-btn .lic { width: 15px; height: 15px; }
    .danger-del-btn:hover { border-color: #ef4444; color: #b91c1c; }
    /* Stretch the wrapper to the card width so the inline confirm bar (absolute,
       inset:0) has room to lay out; without this it collapses to the button's
       width and the bar spills off the card's left edge. The button inside keeps
       its natural size (left-aligned), so nothing else changes visually. */
    .danger-del { align-self: stretch; }

    /* The rows that host an inline confirm must anchor the absolute overlay.
       .list-row covers the whole section at once — Buildings' rows were missing
       from this list, so their confirm bar anchored to whatever ancestor happened
       to be positioned instead of to the row it belongs to. */
    .list-row, .pos-row, .rtype-row, .subj-cfg, .danger-del, .pg-row, .mb-head, .navlink-row { position: relative; }
    /* Page delete trigger — trash icon + label; muted until hovered, then red. */
    .pg-del-btn { display: inline-flex; align-items: center; gap: 6px; background: none; border: 1px solid var(--border); border-radius: 8px; padding: 6px 11px; font-size: var(--fs-md); font-weight: var(--fw-semi); font-family: inherit; color: var(--muted); cursor: pointer; line-height: var(--lh-none); }
    .pg-del-btn:hover { border-color: var(--danger); color: var(--danger); }
    .pg-del-btn .lic { width: 14px; height: 14px; }

    /* Armed: the whole row glows red while the confirm bar sits over it. */
    .row-del-arming { outline: 2px solid #ef4444; outline-offset: 1px; border-radius: 8px; }
    /* ── ARMED INSIDE THE TABLE ──────────────────────────────────────────────
       A row in a bounded list (see THE LIST IS A BOUNDED TABLE) is square and
       lives in a clipping scroller, so the standalone treatment was wrong twice
       over: an outline drawn 3px OUTSIDE the row is cut off by the box's left
       wall — you got three sides of a rectangle — and both the outline and the
       bar kept 8px corners over a row that has none.

       So the bar IS the highlight here: it fills the row wall to wall, square,
       with a rule above and below and no side borders to be clipped. A warning
       band across the table, which is what a table row can actually be. */
    .list-head + * .row-del-arming, .page-scroll.list-boxed .row-del-arming { outline: none; border-radius: 0; }
    .list-head + * .row-del-confirm, .page-scroll.list-boxed .row-del-confirm {
      border-radius: 0; border-left-width: 0; border-right-width: 0;
      border-top-color: #ef4444; border-bottom-color: #ef4444; box-shadow: none;
    }

    /* The confirm bar covers the row: warning copy + "type delete" box + actions. */
    .row-del-confirm {
      position: absolute; inset: 0; z-index: 6; display: flex; align-items: center;
      justify-content: center; gap: 8px; padding: 4px 8px; border-radius: 8px; background: #fef2f2;
      border: 1px solid #fca5a5; box-shadow: 0 2px 8px rgba(220,38,38,.12);
      animation: rdc-in .14s ease-out;
    }
    @keyframes rdc-in { from { opacity: 0; transform: translateY(-2px); } to { opacity: 1; transform: none; } }
    .rdc-msg { flex: 0 1 auto; min-width: 0; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: #b91c1c; line-height: var(--lh-tight); }
    .rdc-input {
      width: 116px; flex-shrink: 0; padding: 6px 9px; border: 1px solid #f0a3a3;
      border-radius: 6px; font-size: var(--fs-md); font-family: inherit; background: #fff; color: var(--text);
    }
    .rdc-input:focus { outline: none; border-color: #dc2626; box-shadow: 0 0 0 2px rgba(220,38,38,.16); }
    .row-del-confirm.ready .rdc-input { border-color: #dc2626; }
    .rdc-cancel, .rdc-go {
      flex-shrink: 0; display: inline-flex; align-items: center; gap: 5px; padding: 6px 11px;
      border-radius: 6px; font-size: var(--fs-sm); font-weight: var(--fw-semi); font-family: inherit; cursor: pointer;
      border: 1px solid transparent; line-height: var(--lh-none);
    }
    .rdc-cancel { background: #fff; border-color: #e5d4d4; color: #6b7280; }
    .rdc-cancel:hover { border-color: #c9b8b8; color: var(--text); }
    .rdc-go { background: #dc2626; color: #fff; }
    .rdc-go .lic { width: 14px; height: 14px; }
    .rdc-go:hover:not(:disabled) { background: #b91c1c; }
    .rdc-go:disabled { opacity: .4; cursor: default; }

    /* ── THE DANGER ZONE'S BUTTONS, ON A PHONE ───────────────────────────────
       All three zones (member, building, request) render the same `.ud-danger >
       .danger-del` row, and on a phone it was the weakest thing on the card: `sm`
       pills hugging the left wall under a full-width paragraph, two of them at two
       different widths on the member screen, one small grey one on the request
       screen. Everything else the phone stack offers — the note composer, Slide to
       Complete — spans the card. The most destructive control in the app should not
       be the only thing that looks incidental.

       So they span it too, splitting the row evenly when there are two, at a real
       44px touch target. And the destructive one is RED: `secondary` painted Delete
       and Disable the same grey, which is backwards — one is reversible and the
       other takes the account, its requests and its notes. A Delete that is still
       gated (disable them first) keeps the grey, and now that greyness means
       something instead of matching its neighbour. */
    @media (max-width: 760px) {
      .ud-danger .danger-del { display: flex; gap: 10px; }
      .ud-danger .danger-del > .btn { flex: 1 1 0; min-width: 0; justify-content: center; min-height: 44px; }
      /* Red INK and a red edge, not a red fill — the .btn.ghost-danger treatment the
         action-bar convention already names for destructive controls. Deliberately no
         background: the Users panel carries an id-scoped .btn.secondary fill that wins
         over anything written here, so setting one gave the request zone a pink wash
         and the member zone a grey one — the same button, two colours, on two screens
         that are supposed to be one pattern. Ink and border land on both. */
      .ud-danger .danger-del > .danger-del-btn:not(:disabled) {
        border-color: var(--danger); color: var(--danger);
      }
      /* THE INLINE CONFIRM STOPS BEING AN OVERLAY HERE. It is absolutely positioned
         over the row it guards, which works on a desktop where the sentence, the
         "type delete" box and two buttons fit on one line. They cannot fit on 346px,
         and an overlay cannot grow — so on a phone it takes the row's place instead:
         the buttons hide, the bar goes static and wraps, and the zone grows by
         exactly what the confirm needs. Same idea as the dashboard tiles' wrap. */
      .ud-danger .row-del-arming > .btn { display: none; }
      .ud-danger .row-del-confirm {
        position: static; flex: 1 1 100%; flex-wrap: wrap;
        justify-content: center; text-align: center; gap: 8px; padding: 10px;
      }
      .ud-danger .rdc-msg { flex: 1 1 100%; }
      .ud-danger .rdc-input { flex: 1 1 100%; width: auto; }
      .ud-danger .rdc-cancel, .ud-danger .rdc-go { flex: 1 1 0; justify-content: center; min-height: 40px; }
    }

    /* Explode: red flash + tiny shudder, then a height-collapse that slides the next row up. */
    .row-exploding { animation: row-explode .26s ease-out; }
    @keyframes row-explode {
      0%   { background: transparent; }
      18%  { background: rgba(220,38,38,.18); transform: translateX(-2px); }
      40%  { transform: translateX(2px); }
      60%  { transform: translateX(-1px); }
      100% { background: rgba(220,38,38,.06); transform: none; }
    }
    .row-collapsing {
      overflow: hidden;
      transition: height .32s ease, opacity .32s ease, margin .32s ease, padding .32s ease;
      opacity: 0;
      margin-top: 0 !important; margin-bottom: 0 !important;
      padding-top: 0 !important; padding-bottom: 0 !important;
    }

    @media (prefers-reduced-motion: reduce) {
      .row-del-confirm, .row-exploding { animation: none; }
      .row-collapsing { transition: none; }
    }

    /* Request-flow polish: completed rows, archive group, own-submission tag, controls */
    .ticket-row.done { opacity: .62; }
    .ticket-row.done:hover { opacity: 1; }
    .ticket-row.done .tr-title { text-decoration: line-through; text-decoration-color: var(--muted); }
    .ticket-row.mine { border-left: 3px solid var(--accent-light); }
    .tr-youtag { font-size: var(--fs-2xs); font-weight: var(--fw-display); text-transform: uppercase; letter-spacing: .04em; color: var(--accent-dark); background: var(--accent-light); padding: 1px 7px; border-radius: 999px; }
    .archive-toggle { width: 100%; margin-top: 10px; background: none; border: none; cursor: pointer; font-family: inherit; font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--muted); display: flex; align-items: center; gap: 7px; padding: 8px 4px; }
    .archive-toggle:hover { color: var(--text); }
    .archive-chev { display: inline-block; transition: transform .15s; }
    .archive-toggle.open .archive-chev { transform: rotate(90deg); }
    .archive-count { background: var(--bg); border-radius: 999px; padding: 0 8px; font-size: var(--fs-xs); }
    .archive-list { display: flex; flex-direction: column; gap: 6px; margin-top: 4px; }
    .ctrl-note { font-size: var(--fs-sm); color: var(--muted); align-self: center; }
    .td-main { padding: 0; }
    .td-main .td-body { border-bottom: 0; }
    /* Off its bespoke 20px onto --fs-3xl, which retires one of the ~565 literals
       Stage C hasn't reached and makes the request's subject the biggest thing in
       the card, which is what it is. */
    .td-body .td-title { font-size: var(--fs-3xl); font-weight: var(--fw-bold); line-height: var(--lh-tight); margin: 0 0 14px; }
    /* contact panel sits full-width between the summary and the description */
    .ticket-detail-card > .contact-card { margin: 16px var(--pad-card) 2px; }

    /* ── Request detail layout ─────────────────────────────────────────────
       Desktop is a full-height, non-scrolling page: a full-width top row of
       people & actions (member · assignee · complete), then a 60/40 split below —
       Request Details on the left, Notes & Activity on the right. The PAGE never
       scrolls; each lower panel scrolls inside itself (the note list is the
       primary scroller; a long request description scrolls too). On mobile every
       wrapper collapses to display:contents and `order` rebuilds the original
       single stack, so the phone layout is unchanged. */
    @media (min-width: 761px) {
      /* The page fills the viewport and doesn't scroll; inner panels do. */
      #section-ticket .app-container { display: flex; flex-direction: column; overflow: hidden; }
      #section-ticket #ticket-content { flex: 1; min-height: 0; display: flex; flex-direction: column; }
      /* ── THE SPACE STANDARD, applied ─────────────────────────────────────
         Every gap on this screen was 16px of dead grey between two panels that
         each also paid a border and a 22px gutter of their own — inside a card
         that had already paid both. The gaps are RAILS now: one hairline, and
         the panels read as one surface divided, which is what they are. The
         card stops paying the gutter too (.page-sections on the body), so the
         rails run wall to wall and the text lands on the one gutter it always
         should have had. Measured: the description used to start 45px inside
         the card's wall on a desktop and 29px on a phone; it starts at the
         gutter now. Asserted by the `dense: true` entry in _frametest.mjs. */
      #section-ticket .td-grid { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 0; }

      /* Top row: member / assignee / complete share the full width as equal columns,
         stretched to a common height so the three bottoms line up. */
      #section-ticket .td-top { flex: 0 0 auto; display: flex; align-items: stretch; gap: 0; }
      #section-ticket .td-top > * { flex: 1 1 0; min-width: 0; margin-bottom: 0; }
      #section-ticket .td-top > * + * { border-left: var(--rail); }

      /* Lower split: Request Details 60% | Notes & Activity 40%, filling the rest
         of the height. minmax(0,…) lets the panels shrink so their inner content
         can scroll. */
      #section-ticket .td-body-cols { flex: 1 1 0; min-height: 0; display: grid; grid-template-columns: minmax(0, 3fr) minmax(0, 2fr); grid-template-rows: minmax(0, 1fr); gap: 0; }
      #section-ticket .td-body-cols > * + * { border-left: var(--rail); }
      #section-ticket .td-col-request, #section-ticket .td-col-notes { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
      #section-ticket .td-col-request > *, #section-ticket .td-col-notes > * { margin-bottom: 0; }

      /* Request Details fills the left column; a long description scrolls inside. */
      #section-ticket .td-card-request { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
      #section-ticket .td-card-request .td-card-hdr,
      #section-ticket .td-card-request .leader-controls { flex: 0 0 auto; }
      #section-ticket .td-card-request .td-main { flex: 1 1 auto; min-height: 0; overflow-y: auto; }

      /* Notes & Activity fills the right column; header/meta/hint/form stay put and
         the note list is the internal scroller. */
      #section-ticket .comments-card { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
      #section-ticket .comments-card .comments-hdr,
      #section-ticket .comments-card .td-meta,
      #section-ticket .comments-card .form-hint,
      #section-ticket .comments-card .cmt-compose,
      #section-ticket .comments-card .comment-locked { flex: 0 0 auto; }
      #section-ticket .comments-card #cmt-list { flex: 1 1 0; min-height: 0; overflow-y: auto; }

      /* The Notes meta row (Status/Updated) sits right below its panel header. It used
         to be paired here with a matching summary row (Category/Submitted) in the
         request panel, given an identical height so their bottom dividers lined up
         across the two panels; the request panel composes those facts into its exhibit
         now and has no band, so only this one is left. The 76px floor stays as its own
         minimum — the taller crossing height is set below. */
      #section-ticket .comments-card .td-meta { padding: var(--pad-band) var(--pad-card); gap: 8px 26px; min-height: 76px; align-content: center; }
      /* The Notes meta is the one band STRETCHED to a height it did not ask for —
         it matches the people cards (--td-people-body) so the two rules cross the
         column rail as one line. Centring its rows in that borrowed height pushed
         the first fact 40px below the head's label, against the member card's 18
         for the same head treatment: the two panels sit side by side under heads
         that line up, so their first lines have to as well.

         WHERE THE SURPLUS GOES. This band wants ~79px and is held at ~123, so ~44px
         has to live somewhere, and both ends of the range are wrong: centred, the
         facts start 40px under the head's label while the member card's start 18, and
         the two panels open at visibly different heights; packed to the top, the whole
         44 collects under the notice as a hole and the band's closing rule looks
         detached from everything above it. Both were shipped and both were called out.
         space-between spends it on the one gap that can carry it — the facts stay put,
         level with the member card across the rail, and the visibility notice drops to
         the band's floor, which is where a footnote belongs anyway. The gap between
         them is wide, deliberately: they are not two facts, they are the facts and a
         note about who can read them. (The notice's own air is the unscoped
         `.td-meta > .td-notes-hint` margin, so the phone keeps the same rhythm at its
         natural height.) */
      #section-ticket .comments-card .td-meta { align-content: space-between; }
    }
    /* ── V2 (opt-in trial): Notes as a full-height right-hand panel ───────────
       The whole left column (people cards + Request Details) is one scroll-free
       stack; Notes & Activity spans the entire viewport height beside it, so the
       log is always tall. The Complete/Escalate controls move into the request
       card's footer next to Delete and Print. Desktop only — renderDetail forces
       V1 below 761px, so none of this ever reaches the protected phone stack. */
    /* Print the case file — lives at the right edge of the Notes header. */
    .td-print-hdr { margin-left: auto; }
    /* Absolute, so the head's own height (asserted equal across panels by the frame
       suite) never grows to accommodate it; the head is already position:relative in
       this section. It CENTRES on the head rather than pinning to a corner: the head
       is sized by its label now (THE HEAD IS SIZED BY ITS LABEL, above), so a fixed
       top inset would hang the button out of a box barely taller than it is.
       `inset-block: 0 + margin: auto` centres it whatever the head measures, so the
       two never need re-tuning together. */
    /* Centred on the LABEL, not on the head. The head is not symmetric — it is
       --pad-band above the label and a hairline below (see THE HEAD IS SIZED BY ITS
       LABEL), so its box centre sits ~5px above its label's centre. A control centred
       on the box therefore floats above the words it shares a row with, which is
       exactly what Eric saw. This lands the button's middle on the label's middle:
       the label's line box starts at --pad-band and is --sec-lbl-h tall, so half the
       difference between that line and the control is the offset. Written from the
       same three tokens the head is, so it can't drift out of alignment when the head
       is retuned. */
    #section-ticket .card-sec-hd .td-print-hdr {
      position: absolute;
      top: calc(var(--pad-band) + (var(--sec-lbl-h) - var(--sec-act-h)) / 2);
      right: var(--pad-band);
    }
    /* …and its TAP TARGET is bigger than its box. The visual control is sized to the
       head (24px), which is well under the ~44px a thumb wants — and on the phone this
       is now the only way to print, so it is a thumb that presses it. The overlay
       extends the hit area past the button on every side without moving a pixel of it
       or growing the head; it reaches to the card's right wall, which is dead space. */
    #section-ticket .card-sec-hd .td-print-hdr::after {
      content: ''; position: absolute; inset: -10px calc(var(--pad-band) * -1) -10px -10px;
    }
    @media (min-width: 761px) {
      /* The split is derived, not chosen: --td-card is exactly what a top-row card
         measures in V1 (three cards + two gaps across the page), so the main column
         is sized to hold two of them plus the gap between. Member/Assignee therefore
         come out the same width in both layouts and only Notes changes — it takes
         whatever is left, which is narrower than the 2fr it used to get. */
      /* The two gaps this arithmetic subtracts are rails now, so the 32 (2 × 16)
         becomes 2 (2 × 1) and the main column's "plus the gap between" becomes
         plus the rail. Member/Assignee still measure the same in both layouts —
         that is what the whole calc() is for — they are just 15px wider each,
         which is the gap that stopped being grey. */
      #section-ticket .td-v2 { --td-card: calc((100% - 2px) / 3); display: grid; grid-template-columns: minmax(0, calc(var(--td-card) * 2 + 1px)) minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); gap: 0; }
      #section-ticket .td-v2 > * + * { border-left: var(--rail); }
      #section-ticket .td-v2 .td-col-main { min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 0; }
      #section-ticket .td-v2 .td-col-main > * { margin-bottom: 0; }
      #section-ticket .td-v2 .td-col-main > * + * { border-top: var(--rail); }
      /* Notes fills the full column height — the point of V2. */
      #section-ticket .td-v2 .td-col-notes { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
      #section-ticket .td-v2 .td-col-notes > * { margin-bottom: 0; }

      /* (THE CROSSING IS GONE, and the story is worth keeping. The people row's closing
         rule and the Notes band's used to land 9px apart across the column rail — two
         T-junctions where there should have been one +. Fixing it meant stretching the
         Notes band to the people cards' height, which could not be a constant (a wrapping
         name grows the card), so renderDetail measured both sides after mount and pinned
         them to the taller. It worked. What it cost was 44px of surplus inside a band that
         wanted ~79: packed to the top that pooled under the visibility notice as a hole,
         centred it pushed the facts 40px below their own head. Eric called both, and then
         called the premise: the notes panel shouldn't be divided by a rule at all. With no
         rule there is nothing to cross, no stretch, no surplus — the band is as tall as
         what's in it. Three problems left with one deletion, which is usually the sign the
         rule was the mistake, not its tuning.) */
      /* The DESKTOP half of the exhibit — see THE REQUEST IS AN EXHIBIT (unscoped,
         further down) for the composition itself, which both layouts share.
         What only the wide layout gets: the reading indent, the measure cap, and
         the display sizes. The panel here is a ~1,000px room with one thing in it,
         so the block steps in by --pad-exhibit and the type goes up a rung; the
         phone has neither the width to indent nor the room for 28px type. */
      #section-ticket .td-v2 .td-card-request .td-main { display: flex; flex-direction: column; }
      /* CONTENT SIZE follows a typographic rule, not a pixel one: line length is
         measured in characters, the comfortable band is ~45–90ch, and the MEASURE
         lives on the text itself (max-width in ch, see .td-desc) so how many pixels
         a line buys is decided by the type size — want wider lines, use bigger type,
         never longer ones. Exhibit prose reads at --fs-2xl for the same reason the
         email standard reads at 16px: it is read once, as a document, not scanned as
         UI. The block's own cap is just the measure plus the indents, so the headline
         and facts row stop where the prose does. */
      #section-ticket .td-v2 .td-card-request .td-body { width: 100%; max-width: 820px;
        padding-top: var(--pad-band);
        padding-left: calc(var(--pad-card) + var(--pad-exhibit));
        padding-right: calc(var(--pad-card) + var(--pad-exhibit)); }
      #section-ticket .td-v2 .td-desc { font-size: var(--fs-2xl); max-width: 72ch; }
      #section-ticket .td-v2 .td-title { font-size: var(--fs-4xl); }

      /* The request card's footer: destructive/utility icons left, the positive
         Complete control right. */
      .td-foot { flex: 0 0 auto; border-top: 1px solid var(--border); }
      .td-foot > .cc-esc { border-top: 0; margin: 0; padding: var(--pad-band) var(--pad-card); }
      /* min-height, not just padding: the row's tallest control is the 48px slider,
         and without it (a request nobody can complete) the footer would shrink out
         of line with the note composer opposite it. Both are 76px + 1px border. */
      .td-foot-row { display: flex; align-items: center; gap: 16px; padding: var(--pad-band) var(--pad-card); min-height: 76px; }
      .td-foot > .cc-esc + .td-foot-row { border-top: 1px solid var(--border); }
      /* 0 1 auto, not 0 0 auto: the slot holds the danger-zone unlock now, and a track
         that cannot shrink pushes Slide to Complete off the end of a narrow column. */
      .td-foot-left { display: flex; align-items: center; gap: 8px; flex: 0 1 auto; min-width: 0; }
      .td-foot-right { flex: 1 1 auto; min-width: 0; display: flex; justify-content: flex-end; }
      .td-foot-right .slide-confirm { flex: 1 1 auto; max-width: 340px; }
      /* The danger zone rides in the same footer, under the row — "under Slide to
         Complete" reads the same here as it does on the phone stack. Its own rule,
         because the footer draws one at its top and this is a second band inside it. */
      .td-foot > .td-dz { border-top: 1px solid var(--border); }

      /* ── #6 · The identity blocks ──────────────────────────────────────────
         There is nothing here any more, and that IS the rule. This screen used to
         re-lay-out the shared member record for its own width: the name ran inline
         instead of stacking (`.mem-id { flex-direction: row }`), and the contact lane
         was left-aligned with its icons leading instead of right-aligned with them
         trailing. Each was defensible on its own and together they meant the same
         person looked like two different people depending on which screen you were on
         — which is the thing Eric asked to stop. The Member/Assignee cards are
         .mem-rec, whole, at every width; see ONE MEMBER RECORD, ONE LAYOUT, and
         _frametest's "the directory row and the request-detail card are ONE layout". */
      /* BOTH CARDS ARE THE SAME CARD. The assignee's grade/building group used to be
         hidden here, because it repeated a building the identity sub-line was already
         printing ("1 · Eagle Landing") in a 200px row. That sub-line no longer carries
         the building, so the rule was hiding real information — and, worse, hiding a
         FIXED-WIDTH column: with .mem-assign out of the flex row the subject icon fell
         to the card's right edge on the assignee and sat in its own lane on the member,
         so two cards side by side, built from the same markup, didn't line up.

         They are one card now: name grows, the subject icon holds its 42px slot, the
         grade/building group is 140px hard against the right. Same three-part row the
         directory draws. If the building ever reads twice again, the fix is the
         sub-line — not deleting the column that keeps the two cards square. */

      /* ── #11 · One footer height across both columns ───────────────────────
         The number lived in two rules that had to be kept equal by hand. It is a
         token now, and it stays 76 rather than the mock's 64 for the reason already
         written above: the row's tallest control is the 48px slider, and 48 + 2×14
         is where that lands. Sized from the slot, not from the contents — the same
         rule --sec-act-h applies to the head. */
      #section-ticket { --card-foot-h: 76px; }
      .td-foot-row, .cmt-compose { min-height: var(--card-foot-h); }

      /* The composer's own layout moved OUT of this query — the phone gets the same
         one row now (see THE COMPOSER IS ONE ROW). All that is desktop's is the size
         of the controls in it, and that is one token. */
      #section-ticket { --cmt-ctl: 48px; }
      /* Locked (terminal request) — the panel's footer keeps the same height. */
      #section-ticket .comments-card .comment-locked { min-height: 76px; display: flex; align-items: center; justify-content: center; gap: 8px; }
    }
    @media (max-width: 760px) {
      /* A plain column of sections, in the order renderDetail emits them: member →
         request → assignee → notes → complete → danger zone (last, under Slide to
         Complete, which is the whole point of moving Delete here). This block used to
         collapse V2's wrappers with `display: contents` and put the stack back in order
         with CSS `order` — six rules whose only job was to undo the markup. It is gone
         because the markup is right now; see THE PHONE STACK IS EMITTED IN THE ORDER IT
         IS READ in app.js, and what it buys is that the seam below is a real seam. */
      #section-ticket .td-grid { display: flex; flex-direction: column; }
    }
    /* ── THE ONLY HAIRLINES ON THIS SCREEN ARE BETWEEN SECTIONS ──────────────
       V1 stacks, so its boundary is horizontal, and it is drawn once per seam by the
       container — the heads draw nothing (see THE REQUEST DETAIL'S HEADS ARE LABELS).
       Unscoped by width on purpose: V1 is the phone's layout AND the ≥761 fallback if
       a window is resized under a rendered screen, and a stack with no seams at all is
       the failure this replaced. V2 is a two-column grid and draws its own vertical
       rails, so it is excluded — otherwise the notes column gets a stray line across
       its top. */
    #section-ticket .td-grid:not(.td-v2) > * + * { border-top: var(--rail); }

    /* ── THE REQUEST DANGER ZONE ─────────────────────────────────────────────
       Delete is not in the footer any more; it lives at the very end of the screen
       behind the same unlock the member and building screens use. Locked, all that
       is on screen is the lock itself — a slide track anywhere with a pointer, the
       three-pip pull strip on a touch phone (the pull is a thumb gesture; a mouse
       cannot perform it, so a narrow desktop window would otherwise be stuck).
       The class rides on .td-grid because that is the one element both layouts have
       in common, and because it survives the 15s poll's re-render. */
    #section-ticket .td-grid:not(.dz-open) .ud-section-label,
    #section-ticket .td-grid:not(.dz-open) .ud-danger { display: none; }
    #section-ticket .td-dz { padding: var(--pad-band) var(--pad-card); }
    #section-ticket .td-dz .ud-section-label { margin: 0 0 8px; padding: 0; border-top: 0; }
    #section-ticket .td-dz .ud-danger { margin: 0; padding: 0; border-top: 0; gap: 12px; }
    /* THE PHONE CARD DRAWS THE BAND DIVIDER the member screen's footer already had. There,
       the lock rides in one band and the zone opens as a second one, with the heading's own
       rule between them; here both are in one card and there was nothing marking the seam.
       Full-bleed, because it separates two bands of the card — that is the card-divider
       standard, not a section title's inset rule. Desktop needs none: its copy of the zone
       is already a second band inside .td-foot, under that footer's own rule.
       Declared AFTER the reset above on purpose — same specificity, so order is what wins,
       and this is the exception to it rather than a competing default. */
    #section-ticket .td-dz-card .ud-section-label {
      margin: 0 calc(-1 * var(--pad-card)) 8px;
      padding: 14px var(--pad-card) 0;
      border-top: 1px solid var(--border);
    }
    /* The lock is visible only while the zone is shut — that is the whole rule here.
       Its WIDTH belongs to the mount, not to this rule: the track has two homes with
       opposite sizing needs, and one shared `width: 100%` collapsed the footer copy to
       1px (the slot around it is content-sized and every child of a .slide-confirm is
       absolutely positioned, so a percentage against an indefinite parent resolves to
       nothing). Two mounts, two stated widths, no specificity fight. */
    #section-ticket .td-grid:not(.dz-open) .td-dz-slide {
      display: block; height: 34px;
    }
    /* The phone card: the zone is the whole section, so the track spans it. Capped
       rather than full-bleed — a slide as wide as the card reads as the screen's main
       action, which is the opposite of true. */
    .td-dz-card .td-dz-slide { width: 100%; max-width: 320px; }
    /* The footer row: it is a SLOT sharing the bar with Slide to Complete, so it takes
       a stated width and shrinks with the column rather than crowding it off. */
    .td-foot-left > .td-dz-slide { flex: 0 1 auto; width: 300px; max-width: 100%; min-width: 0; }
    /* Locked, the panel's own band is empty — hide the wrapper so the footer doesn't
       carry a hairline and a gutter's worth of nothing under the row. (The phone card
       keeps its wrapper: the lock lives inside it there.) */
    #section-ticket .td-grid:not(.dz-open) .td-foot > .td-dz { display: none; }
    @media (max-width: 760px) and (hover: none) {
      #section-ticket .td-grid:not(.dz-open) .td-dz-slide { display: none; }
      #section-ticket .td-grid:not(.dz-open) .td-dz-lock {
        display: flex; align-items: center; gap: 7px;
        padding: 7px 9px; border: 1px dashed var(--border); border-radius: 9px;
        color: var(--muted); font-size: var(--fs-xs); user-select: none;
      }
    }

    /* ── Requests landing: compact toolbar + unified full-height table ───── */
    /* The active .app-container already gets flex:1/min-height:0 from the shell;
       making this one a flex column (overflow hidden) lets the table-wrap be the
       internal scroller so the toolbar stays put and the list fills to the bottom. */
    #section-requests > .app-container { display: flex; flex-direction: column; overflow: hidden; }   /* full-width flex column; the desktop card treatment is in the min-width:761 block */
    /* Row 1: full-width search. Row 2: filter icons (left) + count (right). */
    /* The toolbar IS the page bar now (.page-bar owns the row: height, hairline,
       margins, the action slot). All that's left here is the tighter gap the filter
       cluster wants — 18px between three icon buttons reads as three separate
       things instead of one control group. Search used to be `flex: 1 1 100%`, which
       is what forced the filters onto a second row and cost the fold a whole line;
       it takes the lede's growing slot now (.page-search). */
    .req-toolbar { column-gap: 8px; row-gap: 10px; }
    .req-search { position: relative; display: flex; align-items: center; }
    .req-search .mss-ic { position: absolute; left: 11px; width: 17px; height: 17px; color: var(--muted); display: flex; pointer-events: none; }
    .req-search .mss-ic .lic { width: 17px; height: 17px; }
    #req-search { width: 100%; padding: 8px 36px 8px 34px; border: 1px solid var(--border); border-radius: 9px; font-size: var(--fs-md); font-family: inherit; background: #fff; color: var(--text); margin: 0; }
    #req-search:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    .req-new-btn { display: inline-flex; align-items: center; gap: 6px; padding: 7px 14px; background: var(--accent); color: var(--on-accent); border: none; border-radius: 9px; font-size: var(--fs-md); font-weight: var(--fw-semi); cursor: pointer; white-space: nowrap; flex-shrink: 0; line-height: var(--lh-none); font-family: inherit; transition: background .15s; }
    .req-new-btn:hover { background: var(--accent-hover); }
    .req-new-btn:active { background: var(--accent-dark); }
    /* Icon filter buttons + count stay on one line; the flexing search box pushes
       them to the right edge so the count sits in line, mirroring the directory. */
    .req-toolbar .mfilter { flex: 0 0 auto; }
    .req-toolbar #req-stats { margin-bottom: 0; margin-left: auto; flex: 0 0 auto; white-space: nowrap; }

    .req-table-wrap { flex: 1 1 auto; min-height: 0; overflow: auto; }   /* scroller only — the page card carries the border/bg/shadow now */
    .req-table { width: 100%; border-collapse: separate; border-spacing: 0; font-size: var(--fs-md); }
    .req-table thead th { position: sticky; top: 0; z-index: 2; background: var(--bg); text-align: left; font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); font-weight: var(--fw-bold); padding: 10px 14px; border-bottom: 1px solid var(--border); white-space: nowrap; }
    .req-table tbody td { padding: 11px 14px; border-bottom: 1px solid var(--border); vertical-align: middle; color: var(--text); }
    .req-table tbody tr.req-row:last-child td { border-bottom: none; }
    .req-row { cursor: pointer; transition: background .12s; }
    .req-row:hover > td { background: #fafafa; }
    /* Orange tint = a request the current user submitted */
    .req-row.mine > td { background: rgba(var(--accent-rgb),.06); }
    .req-row.mine:hover > td { background: rgba(var(--accent-rgb),.10); }
    .rq-c-title { width: 40%; max-width: 1px; }
    .rq-title { font-weight: var(--fw-semi); display: inline-block; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; vertical-align: middle; }
    .req-row.unread .rq-title { font-weight: var(--fw-display); }
    .req-row.unread .unread-dot { display: inline-block; margin-right: 6px; vertical-align: middle; }
    .req-row.done > td { opacity: .6; }
    .req-row.done:hover > td { opacity: 1; }
    .req-row.done .rq-title { text-decoration: line-through; text-decoration-color: var(--muted); }
    .rq-c-member { white-space: nowrap; color: var(--muted); }
    .rq-c-member .tr-avatar { display: inline-grid; vertical-align: middle; }
    .rq-member-name { margin-left: 7px; color: var(--text); vertical-align: middle; }
    .rq-me-chip { display: inline-block; font-size: var(--fs-2xs); font-weight: var(--fw-display); text-transform: uppercase; letter-spacing: .04em; color: var(--accent-dark); background: var(--accent-light); padding: 1px 7px; border-radius: 999px; }
    .rq-c-date { white-space: nowrap; color: var(--muted); font-variant-numeric: tabular-nums; }
    .rq-c-assigned.rq-muted { color: var(--muted); opacity: .55; }
    .rq-c-assignee { white-space: nowrap; color: var(--muted); }
    .rq-c-assignee .rq-muted { color: var(--muted); font-style: italic; }
    .rq-c-assignee .tr-assign { max-width: 150px; }
    /* Empty state centers in the whole list area (works on mobile too, where the
       wrap is normally flex:0 0 auto / page-scroll). */
    #req-table-wrap.is-empty { flex: 1 1 auto; display: flex; align-items: center; justify-content: center; }
    .req-table .rq-empty td { text-align: center; padding: 24px 20px; color: var(--muted); border-bottom: none; }   /* muted text + no row-separator line under the standalone empty state */
    .rq-empty .ico { font-size: var(--fs-4xl); opacity: .45; display: block; margin-bottom: 8px; }
    .rq-archive-row > td { padding: 0; background: var(--bg); }
    .rq-archive-row .archive-toggle { margin: 0; width: 100%; padding: 10px 14px; }
    /* Active-list section labels: "New · N" / "In progress · N" with a status dot. */
    .rq-sec td { padding: 14px 4px 6px; }
    .rq-sec { font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
    .rq-sec-dot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; margin-right: 7px; vertical-align: middle; }
    .rq-sec-new { background: var(--accent); }
    .rq-sec-prog { background: #1d9e75; }
    .rq-sec-n { color: var(--muted); font-weight: var(--fw-semi); }
    /* Open / Complete toggle: the selected half is a solid (neutral, not orange)
       thumb that slides; the other half is a vacant slot. The CONTAINER owns the
       perimeter outline (one clean rounded pill), the THUMB owns the single centre
       divider — so there's no doubled line where two half-outlines used to meet. */
    .req-viewtabs { position: relative; flex: 0 0 auto; display: inline-flex; height: 38px; border-radius: 9px; background: var(--bg); box-shadow: inset 0 0 0 1px #aba79f; overflow: hidden; touch-action: pan-y; user-select: none; }
    .rvt-slider { position: absolute; top: 0; bottom: 0; left: 0; width: 50%; background: var(--card); box-shadow: inset 0 0 0 1px #aba79f; transition: transform .22s cubic-bezier(.4, 0, .2, 1); pointer-events: none; }   /* white thumb: outer edges sit on the container outline (still reads as 1px), inner edge is the divider */
    .req-viewtabs[data-view="completed"] .rvt-slider { transform: translateX(100%); }
    /* Match the thumb's outer corners to the half it occupies so its inset outline
       follows the 9px arc (square corners get clipped by the rounded container,
       leaving the corner un-outlined). Inner (centre) corner stays square = divider. */
    .req-viewtabs[data-view="open"]      .rvt-slider { border-radius: 9px 0 0 9px; }
    .req-viewtabs[data-view="completed"] .rvt-slider { border-radius: 0 9px 9px 0; }
    .rvt { position: relative; z-index: 1; flex: 1 1 0; min-width: 84px; padding: 0 16px; border: none; background: none; color: #9c988f; font-family: inherit; font-size: var(--fs-md); font-weight: var(--fw-semi); cursor: pointer; transition: color .18s; }
    /* ── ONE ROW ON A PHONE: search · category · building — gutter — open/complete ──
       This bar was four rows deep before the list started: a full-width search, then
       the toggle and two filters, then the count on a line of its own, then New
       Request. Half the fold was chrome. It is the Member Directory's bar now, which
       is the point — the two screens are the same object and were arguing about it.

       The search field is what gives way (that is why its placeholder is "Search…"),
       the filters keep their 38px targets, and the toggle shrinks to fit rather than
       taking a row: smaller type, tighter padding, and a 64px floor per segment
       instead of 84. The 12px margin before it is the "clear gutter" — the toggle
       CHOOSES what the list contains, the two icons narrow it, and the gap says they
       are different jobs.

       `order` only; the DOM is untouched, so the tab order still runs search → toggle
       → filters, which is the order they matter in for a keyboard. */
    @media (max-width: 760px) {
      /* A FLOOR under the field, not just a growth rule. With bare `flex: 1 1 0` the
         field is whatever four other controls leave — measured 50px, five of which
         were text room, so the box couldn't show its own placeholder. The floor makes
         the TOGGLE the thing that gives way, which is right: it has two fixed words in
         it and the field has to hold whatever gets typed. */
      /* basis 0 AND a min-width, not `auto`: with an auto basis the field asks for an
         <input>'s intrinsic ~200px, wins it, and pushes the toggle onto a second row —
         the exact bug this rule exists to fix, one row lower down. */
      .page-bar.req-toolbar > .req-search { order: 0; flex: 1 1 0; min-width: calc(91px * var(--fs-scale, 1)); }
      .req-toolbar > #rf-cat      { order: 1; }
      .req-toolbar > #rf-building { order: 2; }
      /* The reserved (invisible) Clear slot IS the gutter, exactly as it is on the
         directory — the filters narrow the list, the toggle decides what the list is,
         and the empty slot between them says so without drawing anything. */
      .req-toolbar > #rf-clear    { order: 3; }
      .req-toolbar > #req-viewtabs { order: 4; }
      .req-toolbar > #req-stats   { order: 5; }
      .req-toolbar > .page-acts   { order: 6; }
      .req-toolbar .req-search input { padding-left: 30px; }
      .req-search:has(> .search-x.hidden) #req-search { padding-right: 11px; }
      /* THE TOGGLE IS ICONS HERE, NOT WORDS. The row is 332px and every part of it is
         spoken for — the field needs 91 (a 48px "Search…" plus the magnifier's 30px
         lane and 11 of right padding), two 38px filters, the 36px reserved Clear, four
         6px gaps — which leaves ~105 for the toggle. Two fixed words do not fit that
         at any size worth reading: shrunk to the micro size they cleared it by 9px on
         a 390px phone and still clipped "Complete" mid-word on a 430px one, because
         the label doesn't scale with the screen the way the field does.
         A glyph is the same width in every language and at every width, so the control
         stops being the thing that decides whether the row fits. The words stay in the
         DOM for assistive tech, and the desktop still shows them. */
      /* ── THE TWO BARS ARE ONE BAR ────────────────────────────────────────────
         3 filters + copy (directory) == 2 filters + the toggle (here). Eric's rule, and
         it resolves to exact numbers rather than "about the same". At 430px both bars
         have 372px of content column, and the directory spends it:

             search 142 · 10 · [38 6 38 6 38 6 36] = 168 · 14 · copy 38   = 372

         So this bar has to spend 138 on its two filters and Clear (38 6 38 6 36) plus
         the same 10 before them and 14 after — which leaves the toggle exactly 82, the
         width of two icon buttons and the gap between them. Set the toggle to 82 and
         the search field lands on 142 on its own: the two screens' fields, Clear slots
         and right edges all line up, because they are now the same arithmetic.

         The gaps are what carry it. This bar's were a flat 6px, which is why the field
         came out 154 — 12px of it borrowed from gaps the directory pays. The 4px on the
         first filter and the 8px on the toggle are the difference between the two
         structures (the directory nests its filters in .members-tools with its own 6px
         gaps, then pays 10 + a 4px margin before Copy). */
      .req-toolbar .req-viewtabs { width: calc(82px * var(--fs-scale, 1)); margin-left: 8px; }
      .req-toolbar > #rf-cat { margin-left: 4px; }
      .req-toolbar .rvt { min-width: 0; padding: 0; flex: 1 1 0; display: flex; align-items: center; justify-content: center; }
      .req-toolbar .rvt-lbl { position: absolute; width: 1px; height: 1px; margin: -1px; padding: 0; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
      .req-toolbar .rvt-ic { display: inline-flex; }
      .req-toolbar .rvt-ic svg { width: 18px; height: 18px; }
      /* The count had a ROW OF ITS OWN under the bar — 41px of card to say a number the
         list itself is showing. It is a desktop device (it rides beside the actions in
         a bar with room to spare); on a phone it was the third of four rows standing
         between the toolbar and the first request. */
      #req-stats { display: none; }
    }
    .rvt.on { color: var(--text); }
    /* The glyph is the phone's; a bar with room shows the word. Off by default so the
       desktop needs no rule to suppress it — the same "hidden until a breakpoint asks
       for it" shape as .mem-c-desk on the directory. */
    .rvt-ic { display: none; }
    .rvt:not(.on):hover { color: var(--text); }

    /* ── Ticket detail: Jira/ADO key:value summary grid ─────────────────── */
    .td-id { font: 600 12px ui-monospace, Menlo, Consolas, monospace; color: var(--muted); background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 2px 8px; }
    /* (`.td-summary` — the request's Category/Submitted footer — lived here. Its whole
       history was about WHERE a labelled fact row belongs: first between the head and
       the description, which pushed the member's own words three rows down; then a
       meta footer with its rule on top, which left that rule hanging over the panel's
       empty run. The answer turned out to be neither — the facts fold into the
       exhibit's own facts row (THE REQUEST IS AN EXHIBIT), so no row, and no rule.) */
    /* The Notes head's fact band. The 8px row gap is for the FACTS' own rows (Status ·
       Updated · Needed by), which are peers on a grid; the visibility notice that ends
       the band is not a peer — it is a footnote to the whole thing — so it gets a full
       band of air above it and none of its own. Without that it sat 8px under Status
       and 15px off the band's floor, reading as a crowded fourth fact. The rule was
       desktop-only for a while and the phone kept the crowded version; there is nothing
       width-dependent about it. */
    /* No bottom rule. It divided the one panel whose contents are all the same subject —
       the head, its facts, and the log they describe — and the log's own thread already
       marks where entries begin. It also had to exist for the rail-crossing to have
       anything to cross, which is why --td-people-body stretched this band to the people
       row's height; with the rule gone that whole apparatus goes too, and the band is
       simply as tall as what is in it. */
    .td-meta { display: grid; grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr); gap: 8px 26px; padding: var(--pad-band) var(--pad-card); }
    .td-meta > .td-notes-hint { margin-top: calc(var(--pad-band) - 8px); }
    .kv { display: grid; grid-template-columns: auto 1fr; column-gap: 7px; align-items: baseline; font-size: var(--fs-md); min-width: 0; }   /* label hugs its text so the value sits right beside it */
    .kv-k { font-size: var(--fs-2xs); font-weight: var(--fw-semi); color: var(--muted); text-transform: uppercase; letter-spacing: .04em; white-space: nowrap; }
    .kv-v { min-width: 0; color: var(--text); font-weight: var(--fw-medium); overflow-wrap: anywhere; }
    .kv-v .badge { vertical-align: baseline; }
    .kv-r { grid-template-columns: auto auto; justify-content: end; }   /* label hugs value, pair sits at the right edge */
    .kv-r .kv-v { text-align: right; }
    .kv-mono { font-family: ui-monospace, Menlo, Consolas, monospace; }
    .kv-empty { color: var(--muted); font-style: italic; }
    .assign-empty { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 22px; margin: 0; color: var(--muted); text-align: center; }
    .assign-empty .lic { width: 28px; height: 28px; stroke-width: 1.5; opacity: .65; }
    .assign-empty .assign-empty-t { font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); }
    .assign-empty .assign-empty-s { font-size: var(--fs-sm); }

    /* Security card — "Managed by Google" empty state (Google-only accounts). */
    .sec-managed { display: flex; flex-direction: column; align-items: center; gap: 10px; padding: 34px 20px 16px; text-align: center; }
    .sec-managed-icons { display: flex; align-items: center; gap: 14px; }
    .sec-managed-g { width: 30px; height: 30px; display: block; }
    .sec-managed-div { width: 1px; height: 20px; background: var(--border); }
    .sec-managed-lock { display: inline-flex; color: var(--muted); opacity: .7; }
    .sec-managed-lock .lic { width: 26px; height: 26px; stroke-width: 1.6; }
    .sec-managed-t { font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--text); }
    /* .sec-managed scope wins over the global ".card p { margin-bottom:20px }" rule */
    .sec-managed .sec-managed-s { font-size: var(--fs-md); line-height: var(--lh-body); color: var(--muted); max-width: 360px; margin: 0; }
    /* SUMMARY / DESCRIPTION. Was an off-ladder 9px — below --fs-2xs, the smallest
       rung the ladder admits, and the smallest type anywhere on the screen. */
    .td-section-label { font-size: var(--fs-2xs); font-weight: var(--fw-semi); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 8px; white-space: nowrap; }

    /* ── Submit a Request: privacy strip + single-column form ─────────────── */
    /* Cards run ~2× the old width and sit centered in the page; the back-btn +
       header keep their left-aligned position (the container is not centered).
       The white form card flexes to fill down to the page bottom, with the
       Description textarea absorbing the slack — same as the phone layout. */
    /* overflow:hidden for the same reason every framed page has it: the band and the
       footer are fixed, so the PAGE must not scroll — only .submit-body inside the card.
       (Submit wraps its frame in .submit-layout, so the generic `:has(> .page-frame)` rule
       in THE FRAME'S PLUMBING does not reach this container.) */
    /* scrollbar-gutter:auto for the same reason THE FRAME'S PLUMBING gives it to every
       other framed container: this one no longer scrolls, so the 15px it was reserving
       from the base .app-container rule was 15px it could never use — and it came off
       ONE side, so Submit's card sat on a 22px left gutter and a 37px right one. The
       generic `:has(> .page-frame)` rule can't reach here (the frame is inside
       .submit-layout), so it is restated. */
    #section-submit > .app-container { display: flex; flex-direction: column; overflow: hidden; scrollbar-gutter: auto; }
    .submit-layout { max-width: 1280px; width: 100%; margin: 0 auto; flex: 1; min-height: 0; display: flex; flex-direction: column; }
    /* The scroller gets its own right-hand gutter (pad in, pull back out with a
       matching negative margin) so the scrollbar sits clear of the card's inner
       edge instead of hugging it, and a thin custom bar to match the gutter's
       scale — same treatment as the Content/Settings/IT/State scrollers. */
    #section-submit .submit-form-card { flex: 1; min-height: 0; display: flex; flex-direction: column; }
    #section-submit #ticket-form { flex: 1; min-height: 0; display: flex; flex-direction: column; }
    #section-submit .submit-desc { flex: 0 0 auto; display: flex; flex-direction: column; }
    #section-submit .submit-desc #t-desc { min-height: 120px; resize: vertical; }
    /* The work, and the only thing that scrolls (on desktop). A plain column so the
       fields keep the form's own rhythm; the card's padding lives HERE, not on the
       card, so the footer below can span the card edge to edge. */
    /* The body carries the card's padding at every width — the card itself is a
       .page-frame now (padding 0), so if the body didn't, a phone would render the form
       flush against the card's border. */
    .submit-body { display: flex; flex-direction: column; min-width: 0; padding: 14px; }
    .submit-foot { display: flex; align-items: center; justify-content: flex-end; gap: 12px; margin-top: 14px; }
    /* ── The pinned Submit footer — the SAME bar Branding and every Site Config
       panel use (.page-foot) ──────────────────────────────────────────────
       One hairline across the whole card, a card-coloured strip beneath it, the
       positive action right-aligned in it. It earns the full bleed by living
       OUTSIDE the scroller: an earlier version was sticky INSIDE the scrolling
       form, which meant it could only span the content column (bleeding it by the
       card's padding put the left edge flush and left the right edge short by the
       scrollbar gutter — the one-sided divider the card standard forbids). Moving
       the scroll to .submit-body removes that constraint entirely: the scrollbar
       now sits inside the body, above the footer, exactly as it does on Branding.
       DESKTOP ONLY: on phones the button stays in the flow at the end of the form —
       a pinned bar there would cost a row of height on the viewport that has the
       least of it, on top of the tab bar already pinned below it. */
    @media (min-width: 761px) {
      #section-submit .submit-form-card { padding: 0; overflow: hidden; }
      #section-submit .submit-body {
        flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: var(--pad-card);
        scrollbar-width: thin; scrollbar-color: var(--border) transparent;
      }
      #section-submit .submit-body::-webkit-scrollbar { width: 12px; }
      #section-submit .submit-body::-webkit-scrollbar-track { background: transparent; }
      #section-submit .submit-body::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; border: 3px solid transparent; background-clip: padding-box; }
      #section-submit .submit-body::-webkit-scrollbar-thumb:hover { background: var(--muted); background-clip: padding-box; }
      #section-submit .submit-foot {
        flex: none; margin-top: 0; padding: 13px var(--pad-card);
        border-top: 1px solid var(--border); background: var(--card);
        border-radius: 0 0 var(--radius) var(--radius);
      }
    }
    /* Category + Subject share ONE line. Category is NOT an equal column — a select
       whose options are two or three words has no business owning half the row, so it
       gets width:auto and sizes itself to its own widest option (the browser measures
       the option list, not the current value). Subject takes every pixel that's left.
       max-width keeps one absurdly long category name from swallowing the subject. */
    .submit-top-row { display: flex; gap: 16px; align-items: flex-end; }
    .submit-top-row > .form-field { min-width: 0; }
    .submit-top-row > .submit-cat { flex: 0 0 auto; }
    .submit-top-row > .submit-cat select { width: auto; min-width: 180px; max-width: 340px; }
    .submit-top-row > .submit-subject { flex: 1 1 auto; }
    .submit-top-row > .form-field.hidden { display: none; }
    /* Optional "preferred building rep" disclosure — a quiet link until opened, so the
       form reads the same length for the members who don't care who picks it up.
       It sits on its own row below the toggle, not in the flex row above — cap it
       to roughly one column's width so it reads as consistent with the row instead
       of stretching full-bleed. */
    /* ONE vertical rhythm for the whole form: 18px from a control to the next
       label, matching .form-field. These two optional blocks are a .form-field
       nested inside a .submit-pref, so both were paying a bottom margin and the
       gap stacked to 32px (and 42px with the reveal's margin-top) against 18px
       everywhere else. The wrapper owns the spacing; the field inside owns none.
       No margin-top on the revealed field either — the toggle it used to clear is
       display:none the whole time that field is on screen. */
    /* The wrapper owns NO margin of its own: collapsed, all three of these are a
       single line of link text, and three stacked 18px margins turned that line into
       a block of dead space. The ROW pays the form's 18px rhythm once, for all of
       them, open or closed. */
    .submit-pref { margin: 0; }
    /* The three optional disclosures (needed-by date · preferred rep · on behalf of)
       share ONE row — stacked they cost three full rows of height for three link
       buttons. flex-wrap + a 240px basis so they wrap to a second line on a narrow
       desktop instead of squeezing; align-items:flex-start so an opened field doesn't
       stretch its still-collapsed neighbours. */
    .submit-optional-row { display: flex; flex-wrap: wrap; gap: 14px 16px; align-items: flex-start; margin: 0 0 18px; }
    .submit-optional-row > .submit-pref { flex: 1 1 240px; min-width: 0; }
    .submit-optional-row > .submit-pref.hidden { display: none; }
    /* Inside the row the field already has a column to fill — the 420px cap that
       kept it from stretching full-bleed would now just make it look arbitrary. */
    .submit-optional-row .submit-pref-field { max-width: none; }
    .submit-pref > .form-field { margin-bottom: 0; }
    .submit-pref-toggle { display: inline-flex; align-items: center; gap: 7px; font-size: var(--fs-md); }
    .submit-pref-toggle .ic .lic { width: 14px; height: 14px; }
    .submit-pref-field { margin-top: 0; max-width: 420px; }
    .submit-pref-note { margin-top: 6px; font-size: var(--fs-sm); line-height: var(--lh-body); color: var(--muted); }
    /* The note is filled in by prepRepPref only when there are reps to explain. Empty,
       it must not still pay its top margin — that quietly widened this one row past the
       form's 18px rhythm. */
    .submit-pref-note:empty { margin-top: 0; }
    /* Label row: label on the left, Remove link on the right — used by both optional
       reveal fields (date + preferred rep). Overrides the default block label margin
       so the flex row controls the spacing instead. */
    .submit-optional-label-row { display: flex; justify-content: space-between; align-items: baseline; margin-bottom: 6px; }
    .submit-optional-label-row label { margin-bottom: 0; }
    .submit-optional-clear { font-size: var(--fs-sm); color: var(--muted); }
    .submit-optional-clear:hover { color: var(--danger); }
    /* Compact "private by design" reassurance, shown at the top of the scrolling form. */
    .submit-privacy { display: flex; gap: 10px; align-items: flex-start; margin: 0 0 16px; padding: 11px 14px; border-radius: 10px; background: var(--accent-light); border: 1px solid rgba(var(--accent-rgb),.18); }
    .submit-privacy .ic { color: var(--accent); flex: 0 0 auto; margin-top: 1px; font-size: var(--fs-lg); }
    .submit-privacy .ic .lic { width: 15px; height: 15px; }
    .submit-privacy .prg-note { line-height: var(--lh-body); }
    /* The two filing-floor callouts stack by default; on desktop they sit side by
       side so the pair reads as one wide step instead of a tall double-stack. */
    @media (min-width: 761px) {
      .submit-callouts { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; align-items: stretch; }
      .submit-callouts .pii-card { margin-bottom: 0; display: flex; flex-direction: column; }
      .submit-callouts .pii-card-check { margin-top: auto; }
    }
    /* ── Unified "how-to" popup (post-submit next steps, iOS install, iOS save-
       to-contacts). One card, numbered icon steps, one primary button. ─────── */
    /* In-app confirm dialog (site UI in place of the native confirm()). */
    .confirm-card { background: var(--card); border-radius: 16px; width: 100%; max-width: 380px; padding: 22px; box-shadow: 0 20px 60px rgba(0,0,0,.3); animation: ssfade .14s ease; }
    .confirm-title { font-size: var(--fs-xl); font-weight: var(--fw-bold); margin: 0 0 8px; }
    .confirm-msg { font-size: var(--fs-md); color: var(--muted); line-height: var(--lh-body); margin: 0 0 18px; }
    .confirm-actions { display: flex; gap: 10px; justify-content: flex-end; }
    .howto-card { background: var(--card); border-radius: 16px; width: 100%; max-width: 400px; padding: 22px; box-shadow: 0 20px 60px rgba(0,0,0,.3); position: relative; animation: ssfade .14s ease; }
    .howto-x { position: absolute; top: 11px; right: 11px; background: none; border: 0; font-size: var(--fs-3xl); line-height: var(--lh-none); color: var(--muted); cursor: pointer; padding: 2px 7px; }
    .howto-lead { width: 52px; height: 52px; border-radius: 13px; margin: 2px auto 12px; display: block; }
    .howto-title { font-size: var(--fs-xl); font-weight: var(--fw-bold); margin: 0 0 6px; text-align: center; }
    .howto-intro { font-size: var(--fs-md); color: var(--muted); line-height: var(--lh-body); margin: 0 0 17px; text-align: center; }
    .howto-steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 15px; }
    .howto-step { display: flex; gap: 13px; align-items: flex-start; text-align: left; }
    .howto-ico { flex: 0 0 auto; width: 32px; height: 32px; border-radius: 50%; background: var(--accent-light); color: var(--accent-dark); display: grid; place-items: center; font-size: var(--fs-md); font-weight: var(--fw-bold); }
    .howto-ico svg { width: 17px; height: 17px; }
    .howto-step > div { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
    .howto-step b { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--text); }
    .howto-step span { font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); }
    .howto-primary { width: 100%; margin-top: 20px; justify-content: center; }
    /* "Finish Setting Up" — the standing install/notifications checklist. Shown on the
       dashboard and on the pending-approval screen; removes itself when both are done. */
    .setup-card { background: var(--accent-light); border: 1px solid var(--accent); border-radius: var(--radius); box-shadow: var(--shadow); padding: 22px; margin-bottom: 18px; }
    .setup-card h4 { margin: 0 0 12px; font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--text); }
    .setup-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
    .setup-row { display: flex; align-items: center; gap: 12px; }
    .setup-mark { display: inline-flex; align-items: center; justify-content: center; width: 30px; height: 30px; flex: none; border-radius: 9px; background: var(--warn-bg); color: var(--warn); }
    .setup-mark svg { width: 16px; height: 16px; }
    .setup-row.done .setup-mark { background: var(--ok-bg); color: var(--ok); }
    /* Step 2 before step 1 is done: visible so the order is obvious, but plainly not actionable yet. */
    .setup-row.locked { opacity: .62; }
    .setup-row.locked .setup-mark { background: var(--neutral-bg); color: var(--neutral); }
    .setup-copy { display: flex; flex-direction: column; gap: 1px; min-width: 0; flex: 1; }
    .setup-copy b { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--text); }
    .setup-copy span { font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); }
    .setup-row.done .setup-copy b { color: var(--muted); font-weight: var(--fw-semi); }
    /* The Building Rep setup gate — the Dashboard's stand-in until a rep on a phone has
       installed the app and turned notifications on (see repGateBlocked). One centered
       column and nothing else on the screen: it is the only thing to do here, so competing
       with it would only make it skippable. The rest of the app is untouched — this takes
       ONE section, never the shell, so the tab bar underneath still works. */
    .dash-gate { display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 100%; padding: 32px 18px 40px; }
    .dash-gate > .setup-card { width: 100%; max-width: 440px; margin-bottom: 20px; }
    .dash-gate-inner { width: 100%; max-width: 440px; text-align: center; }
    .dash-gate-ico { display: inline-flex; align-items: center; justify-content: center; width: 52px; height: 52px; border-radius: 15px; background: var(--accent-light); color: var(--accent); margin-bottom: 14px; }
    .dash-gate-ico svg { width: 26px; height: 26px; }
    .dash-gate-inner h2 { margin: 0 0 8px; font-size: var(--fs-2xl); font-weight: var(--fw-bold); color: var(--text); }
    .dash-gate-lead { margin: 0 0 20px; font-size: var(--fs-md); line-height: var(--lh-body); color: var(--muted); }
    /* Quiet opt-out under the primary action. Deliberately understated — it must be
       findable by someone who means it, and easy to skip past for everyone else. */
    .howto-secondary { display: block; width: 100%; margin: 10px 0 0; background: none; border: 0; padding: 4px; font: inherit; font-size: var(--fs-sm); color: var(--muted); cursor: pointer; }
    .howto-secondary:hover { color: var(--text); text-decoration: underline; }
    .ss-link { color: var(--accent); font-weight: var(--fw-semi); cursor: pointer; }
    .ss-link:hover { text-decoration: underline; }
    .an-list { display: flex; flex-direction: column; gap: 12px; text-align: left; }
    .an-item { border: 1px solid var(--border); border-radius: var(--radius); padding: 12px; }
    .an-title { font-weight: var(--fw-bold); font-size: var(--fs-md); margin-bottom: 4px; }
    .an-body { font-size: var(--fs-sm); color: var(--muted); margin-bottom: 8px; line-height: var(--lh-body); }

    /* Forms */
    .form-field { margin-bottom: 18px; }
    .form-field label { display: block; font-size: var(--fs-md); font-weight: var(--fw-semi); margin-bottom: 6px; }
    .form-field input, .form-field textarea, .form-field select { width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 9px; font-size: var(--fs-lg); font-family: inherit; background: #fff; color: var(--text); }
    .form-field textarea { resize: vertical; min-height: 110px; }
    .form-field select { appearance: none; -webkit-appearance: none; padding-right: 32px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%236b7280' stroke-width='1.5' fill='none' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 11px center; }
    /* Help text + the category placeholder share one grey. The select uses --muted
       while inputs fell back to the (lighter) browser/iOS default, so they looked
       different; pin both to the same value. */
    .form-field input::placeholder, .form-field textarea::placeholder { color: #9ca3af; }
    .form-field select:required:invalid { color: #9ca3af; }
    /* App-wide: any dropdown showing its empty placeholder option ("Select…",
       "State", …) uses that same grey — matching the category dropdown — instead
       of the darker text/muted color. Reverts to normal text once a value is
       picked. !important so it wins over the various id-scoped select colors. */
    select:has(option[value=""]:checked) { color: #9ca3af !important; }
    .form-field input:focus, .form-field textarea:focus, .form-field select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    .form-hint { font-size: var(--fs-sm); color: var(--muted); margin-top: 5px; }

    /* Settings */
    .field { margin-bottom: 16px; }
    .field > label, .field > summary { display: block; font-size: var(--fs-md); font-weight: var(--fw-semi); margin-bottom: 6px; color: var(--text); }
    .field input { width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 9px; font-size: var(--fs-lg); font-family: inherit; background: #fff; color: var(--text); }
    .field input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    details.field summary { cursor: pointer; }
    /* Saved-secret field: a locked grey box when a credential is stored, an input
       (with Cancel) once you choose to Replace it. State classes set by secretSetState. */
    .secret-locked { display: none; align-items: center; flex-wrap: wrap; gap: 10px; row-gap: 6px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 9px; background: var(--bg); }
    .secret-input { display: flex; align-items: center; gap: 8px; }
    .secret-field.is-locked .secret-locked { display: flex; }
    .secret-field.is-locked .secret-input { display: none; }
    .secret-locked-ic { display: inline-flex; color: var(--muted); flex: none; }
    .secret-locked-ic .lic { width: 16px; height: 16px; }
    .secret-dots { flex: 1; min-width: 24px; letter-spacing: 2px; color: var(--muted); overflow: hidden; line-height: var(--lh-none); }
    .secret-src { flex: none; font-size: var(--fs-xs); color: var(--muted); }   /* provenance: "stored in app" / "from .env file" */
    .secret-saved-tag { flex: none; font-size: var(--fs-xs); font-weight: var(--fw-bold); color: var(--ok); background: var(--ok-bg); border-radius: 999px; padding: 2px 9px; display: inline-flex; align-items: center; gap: 4px; }
    .secret-saved-tag .lic { width: 12px; height: 12px; }
    .secret-edit-btn { flex: none; }
    .secret-input input { flex: 1; min-width: 0; }
    /* Admin secrets (API keys / client secrets) are NOT login credentials, so we keep
       them out of the browser/PWA password manager by rendering them as masked TEXT
       inputs (no <input type=password> → no "save password?" prompt). JS upgrades
       type=password → type=text only where text-security is supported (Chromium,
       WebKit/iOS — the PWA targets); elsewhere they stay native password fields. */
    input.secret-mask[type="text"] { -webkit-text-security: disc; text-security: disc; }
    input.secret-mask[type="text"].revealed { -webkit-text-security: none; text-security: none; }
    .secret-reveal { flex: none; background: none; border: none; color: var(--muted); cursor: pointer; padding: 6px; display: grid; place-items: center; border-radius: 7px; }
    .secret-reveal:hover { color: var(--text); background: var(--bg); }
    .secret-reveal .lic { width: 16px; height: 16px; }
    .secret-cancel-btn { flex: none; white-space: nowrap; }
    .secret-field:not(.has-saved) .secret-cancel-btn { display: none; }   /* no saved value to fall back to */
    .hint { display: block; margin-top: 6px; color: var(--muted); font-size: var(--fs-sm); line-height: var(--lh-body); overflow-wrap: anywhere; }
    .code { background: #0f172a; color: #e2e8f0; padding: 10px 12px; border-radius: 8px; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: var(--fs-sm); word-break: break-all; }
    .status-blk { font-size: var(--fs-md); line-height: var(--lh-loose); padding: 12px 14px; border-radius: 9px; background: #f1f5f9; color: var(--text); margin-bottom: 20px; }
    .status-blk.status-ok { background: var(--ok-bg); color: var(--ok); }
    .btn-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 6px; }
    /* Three-choice confirm (uiConfirm's opts.alt): the destructive option sits to the LEFT
       of the Cancel/primary pair. Narrower than the three fit, the row wraps — and left to
       itself it wrapped the PRIMARY onto the second line, stranding "Save & Leave" under
       a Cancel it was supposed to sit beside. Keep the pair together and drop the third
       option below them instead. */
    @media (max-width: 480px) {
      #uiconfirm-modal #uiconfirm-alt:not(.hidden) { order: 1; width: 100%; margin-right: 0; }
    }
    .msg { padding: 10px 12px; border-radius: 9px; font-size: var(--fs-md); margin-bottom: 14px; }
    .msg.ok  { background: var(--ok-bg); color: var(--ok); }
    .msg.err { background: var(--danger-bg); color: var(--danger); }
    /* Pending-approval screen: a warm holding state that auto-advances on approval. */
    .pending-badge { width: 56px; height: 56px; border-radius: 50%; margin: 0 auto 14px; background: var(--accent-light); color: var(--accent-dark); display: grid; place-items: center; }
    .pending-badge .ic, .pending-badge .lic { width: 26px; height: 26px; }
    .pending-wait { display: flex; align-items: center; gap: 9px; text-align: left; font-size: var(--fs-md); line-height: var(--lh-body); color: var(--muted); background: var(--bg); border: 1px solid var(--border); border-radius: 10px; padding: 11px 13px; margin: 0 0 18px; }
    /* Shared marker column so the dot (status box) and the icon (install box) put
       their text at the same left position even though the markers differ in size. */
    .pending-mark { flex: none; width: 18px; display: flex; align-items: center; justify-content: center; }
    .pending-dot { flex: none; width: 9px; height: 9px; border-radius: 50%; background: var(--accent); animation: pendingPulse 1.4s ease-in-out infinite; }
    @keyframes pendingPulse { 0%, 100% { opacity: 1; transform: scale(1); } 50% { opacity: .35; transform: scale(.7); } }
    @media (prefers-reduced-motion: reduce) { .pending-dot { animation: none; } }
    /* "While you wait, install the app" card on the pending screen. */
    .pending-install-card { text-align: left; border: 1px solid var(--border); border-radius: 12px; padding: 14px 13px; margin: 0 0 18px; background: var(--bg); }
    .pending-install-card p { display: flex; align-items: center; gap: 9px; font-size: var(--fs-md); line-height: var(--lh-body); color: var(--muted); margin: 0 0 12px; }
    .pending-install-card p .ic, .pending-install-card p .lic { flex: none; width: 18px; height: 18px; color: var(--accent); }
    .pending-install-card .btn { width: 100%; text-align: center; justify-content: center; }
    .guide { margin-top: 22px; font-size: var(--fs-md); color: var(--muted); border-top: 1px solid var(--border); padding-top: 18px; }
    .guide summary { cursor: pointer; font-weight: var(--fw-semi); color: var(--text); }
    .guide ol { margin: 12px 0 0 18px; line-height: var(--lh-loose); }
    .guide p { margin: 10px 0 0; }
    .guide.guide-tight { margin-top: 8px; border-top: none; padding-top: 0; }   /* mid-card guides (GroupMe) — no top divider */
    .guide a, .field a { color: var(--accent); }

    /* GroupMe admin rows reuse the bordered-row shell (the Menus & Buttons editor
       moved to the .mb-* preview rows, but GroupMe still uses .dash-row). */
    .dash-row { display: flex; gap: 10px; align-items: flex-start; padding: 11px; border: 1px solid var(--border); border-radius: 9px; margin-bottom: 8px; background: var(--bg); }
    .dash-row.gm { align-items: center; }
    .dash-row.gm .dash-row-actions { flex-direction: row; align-items: center; }

    /* Auth (login/register) card */
    .auth-card { max-width: 400px; width: 100%; text-align: center; }
    .auth-logo { height: 60px; object-fit: contain; margin-bottom: 22px; }
    /* Slightly larger logo on the create-account view (has-login-footer = register panel). */
    #view-login.has-login-footer .auth-logo { height: 72px; }
    /* Top "Back" affordance for create-account. Lives above the logo but only on
       that panel (has-login-footer); the other auth panels keep their own links. */
    .auth-back { display: none; }
    #view-login.has-login-footer .auth-back { display: flex; }
    /* Direct-signup deeplink (/join, ?register): no sign-in origin, so drop the
       grey back banner entirely — there's nowhere to go "back" to. */
    #view-login.deeplink-signup .auth-back { display: none !important; }
    /* Header block = logo + heading/subtext. The heading/subtext render ONLY on
       the create-account panel (has-login-footer); other panels supply their own
       inside #auth-panels, so here the head is just the centered logo. */
    .auth-head { display: flex; flex-direction: column; align-items: center; }
    .auth-head-text { display: none; text-align: center; }
    #view-login.has-login-footer .auth-head-text { display: block; }
    #view-login.has-login-footer .auth-head .auth-logo { margin-bottom: 12px; }
    #view-login.has-login-footer .auth-head { margin-bottom: 20px; }
    #view-login.has-login-footer .auth-head-text h2 { font-size: var(--fs-3xl); margin: 0 0 4px; }
    #view-login.has-login-footer .auth-head-text p { font-size: var(--fs-lg); color: var(--muted); margin: 0; }
    /* Scroll cue at the base of the create-account form (mobile only, see media
       query): a soft fade to the card + a bobbing chevron that says "more below",
       fading out (.off) once you reach the bottom. */
    .scroll-cue { display: none; }
    @keyframes cueBounce { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(4px); } }
    @media (prefers-reduced-motion: reduce) { .scroll-cue .cue-arrow { animation: none; } }
    /* Create-account primary action: a footer bar. On desktop it sits centered
       below the card; on phones the card scrolls and this pins to the bottom in
       NORMAL FLOW (like the app's bottom tab bar — never position:fixed, which
       anchors to the large viewport and breaks iOS 100vh). Shown only on the
       create-account panel; the button greys out (.btn:disabled) until valid. */
    .login-footer { width: 100%; max-width: 400px; margin: 14px auto 0; }
    .login-footer .auth-btn { margin: 0; }
    @media (max-width: 760px) {
      /* Create-account fills the screen: a white sheet (square corners, flush to
         the top), its header (logo + title + subtext) pinned OUTSIDE the scroll,
         only the fields scrolling, and the action bar as a flush black footer
         (no page padding, so nothing squares off / gaps below the card). */
      #view-login.has-login-footer { display: flex; flex-direction: column; align-items: stretch; background: #161616; padding: 0; }
      #view-login.has-login-footer > .auth-card { flex: 1 1 auto; min-height: 0; max-width: none; margin: 0; display: flex; flex-direction: column; overflow: hidden; border-radius: 0; box-shadow: none; }
      /* Grey chrome bar up top (like the app's section back-bars); bleeds full
         width over the card's 22px padding, and the card's overflow:hidden +
         radius rounds its top corners to match the sheet. */
      #view-login.has-login-footer .auth-back { flex: none; width: calc(100% + 44px); margin: -22px -22px 16px; padding: 15px 22px; justify-content: flex-start; font-size: var(--fs-lg); background: var(--bg); border-bottom: 1px solid var(--border); }
      #view-login.has-login-footer .auth-logo { flex: none; }
      /* The header lockup shrinks CONTINUOUSLY as the fields scroll: --hp (header
         progress, 0 at top → 1 collapsed) is set by JS from the scroll offset, so
         every size/gap interpolates frame-by-frame (real motion, no snap). At the
         top it's stacked + large (the wide full logo needs its own line); near the
         end JS adds .hdr-row and it settles into a compact, page-CENTERED
         logo+text row that hands ~2/3 of the header height back to the form. */
      #view-login.has-login-footer .auth-head {
        flex: none; display: flex; flex-flow: column nowrap; align-items: center; justify-content: center;
        gap: calc(10px - 3px * var(--hp, 0));
        margin-bottom: calc(20px - 8px * var(--hp, 0));
      }
      /* The runtime collapse morphs these two via transform (translate+scale) on
         their own compositor layers, so it stays smooth at 120Hz. --hp is only
         toggled during the one-time keyframe measurement; at runtime it's 0. */
      #view-login.has-login-footer .auth-head .auth-logo { margin: 0; flex: none; height: calc(72px - 30px * var(--hp, 0)); transform-origin: center; will-change: transform; }
      #view-login.has-login-footer .auth-head-text { text-align: center; min-width: 0; transform-origin: center; will-change: transform; }
      #view-login.has-login-footer .auth-head-text h2 { font-size: calc(20px - 5px * var(--hp, 0)); margin: 0 0 calc(4px - 2px * var(--hp, 0)); line-height: var(--lh-tight); }
      #view-login.has-login-footer .auth-head-text p { font-size: calc(13px - 1.5px * var(--hp, 0)); line-height: var(--lh-snug); }
      /* Compact end-state (used to MEASURE the collapsed keyframe): logo beside the
         text, the pair centered on the page, text kept center-aligned to match the
         runtime transform so the measured centers line up. */
      #view-login.hdr-row.has-login-footer .auth-head { flex-flow: row nowrap; gap: 12px; }
      #view-login.has-login-footer #auth-panels { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
      #view-login.has-login-footer [data-panel="register"] { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
      /* Header (h2 + subtext) stays put; only the form scrolls. */
      #view-login.has-login-footer #form-register { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
      #view-login.has-login-footer > .login-footer { flex: none; max-width: none; margin: 0; background: #161616; border-top: none; box-shadow: none; display: flex; align-items: center; min-height: calc(var(--footer-bar-h) + env(safe-area-inset-bottom, 0px)); padding: 0 16px env(safe-area-inset-bottom, 0px); }
      #view-login.has-login-footer > .login-footer .auth-btn { width: 100%; }
      /* Scroll cue: fade + bobbing chevron pinned to the base of the scrolling
         form; JS adds .off (fade out) when you've reached the bottom. */
      #view-login.has-login-footer > .auth-card { position: relative; }
      #view-login.has-login-footer #reg-scroll-cue {
        display: flex; align-items: flex-end; justify-content: center;
        position: absolute; left: 0; right: 0; bottom: 0; height: 54px; pointer-events: none;
        padding-bottom: 6px; background: linear-gradient(to top, var(--card) 34%, rgba(255, 255, 255, 0));
        opacity: 1; transition: opacity .25s ease;
      }
      #view-login.has-login-footer #reg-scroll-cue.off { opacity: 0; }
      #view-login.has-login-footer .cue-arrow { color: var(--muted); animation: cueBounce 1.6s ease-in-out infinite; }
    }
    #auth-panels h2 { font-size: var(--fs-3xl); margin-bottom: 6px; }
    #auth-panels > div > p { margin-bottom: 20px; }
    #auth-panels form { text-align: left; }
    /* :not([type=checkbox]) so this full-width field styling never hits the privacy
       mask checkboxes — the ID selector here would otherwise beat .mask-chk input and
       blow each checkbox up to a full-width padded box (breaking the register form). */
    #auth-panels form input:not([type=checkbox]), #auth-panels form select { width: 100%; padding: 12px 13px; border: 1px solid var(--border); border-radius: 9px; font-size: var(--fs-xl); font-family: inherit; background: #fff; color: var(--text); margin-bottom: 10px; }
    #auth-panels form input:not([type=checkbox]):focus, #auth-panels form select:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px rgba(var(--accent-rgb),.1); }
    /* The room # sits in a placement row (compact widget) — keep it sized to the
       building selector button, not blown up to the big touch-field padding above.
       margin:0 is essential: the inherited margin-bottom:10px would otherwise eat
       into the flex row's align-items:stretch and leave the room box short. */
    #auth-panels form input.place-room { padding: 8px 11px; font-size: var(--fs-lg); flex: 0 0 78px; margin: 0; border-radius: 8px; }
    /* Small helper line under the password fields (a little air after the
       Confirm password field so it doesn't crowd the input). */
    #auth-panels .pw-hint { text-align: left; font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); margin: 9px 0 0; }
    /* Password show/hide toggle */
    .pw-wrap { position: relative; margin-bottom: 10px; }
    .pw-wrap input { margin-bottom: 0 !important; padding-right: 46px !important; }
    .pw-toggle { position: absolute; right: 6px; top: 50%; transform: translateY(-50%); background: none; border: none; cursor: pointer; font-size: var(--fs-xl); line-height: var(--lh-none); color: var(--muted); padding: 6px; margin: 0; }
    .pw-toggle:hover { color: var(--text); }
    .pw-toggle svg { width: 18px; height: 18px; display: block; }
    .auth-hint { text-align: left; font-size: var(--fs-md); color: var(--muted); line-height: var(--lh-body); background: var(--accent-light); border: 1px solid rgba(var(--accent-rgb),.35); border-radius: 9px; padding: 9px 12px; margin-bottom: 14px; }
    .auth-hint b { color: var(--accent-dark); }
    .auth-btn { width: 100%; justify-content: center; margin-top: 4px; }
    .auth-links { display: flex; justify-content: space-between; gap: 10px; margin-top: 12px; font-size: var(--fs-md); }
    .auth-links a { color: var(--accent); text-decoration: none; }
    .auth-links a:hover { text-decoration: underline; }
    .auth-or { display: flex; align-items: center; text-align: center; color: var(--muted); font-size: var(--fs-sm); margin: 16px 0; }
    .auth-or::before, .auth-or::after { content: ""; flex: 1; border-top: 1px solid var(--border); }
    .auth-or span { padding: 0 12px; }
    /* "Not <local>? Choose a different local" — the remembered-local escape. Muted and
       small: it sits below every real action because 99% of members never need it. */
    .auth-local-switch { margin: 18px 0 0; text-align: center; font-size: var(--fs-sm); color: var(--muted); }
    .auth-local-switch b { font-weight: var(--fw-semi); color: var(--text); }
    .auth-local-switch a { color: var(--muted); text-decoration: underline; text-underline-offset: 2px; }
    .auth-local-switch a:hover { color: var(--accent); }
    .auth-msg { padding: 9px 12px; border-radius: 8px; font-size: var(--fs-md); margin-bottom: 10px; }
    .auth-msg.ok  { background: #ecfdf5; color: #065f46; }
    .auth-msg.err { background: #fef2f2; color: #991b1b; }

    /* Filters */
    .filter-bar { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 16px; }
    /* RADIUS ENCODES PRESSABILITY — see .biz-chip. This is a CONTROL, so it takes the
       control box: 38px tall, 9px corners, a 1px border, sized to sit level with the
       .icon-btn, .btn and search field it shares a .page-bar with. It was a 999px capsule
       with a 1.5px border, which is the shape this app uses for a LABEL (the card's
       Pending mark, a category chip, a count) — so on the Supply Closet bar an inert
       count and a live filter were the same object, and the funnel that replaces this
       strip on a phone was a third shape again. */
    .filter-btn { height: 38px; padding: 0 14px; border: 1px solid var(--border); border-radius: 9px; background: var(--card); font-size: var(--fs-md); cursor: pointer; font-family: inherit; color: var(--muted); transition: background .15s, border-color .15s, color .15s; }
    .filter-btn:hover { border-color: var(--accent); color: var(--accent); }
    .filter-btn.on { border-color: var(--accent); background: var(--accent-light); color: var(--accent-dark); font-weight: var(--fw-semi); }

    /* Misc */
    /* margin:auto centers the empty state both ways inside any flex-column scroller
       (e.g. #members-list, #users-list) instead of letting it sit pinned to the top. */
    .empty { text-align: center; padding: 48px 20px; color: var(--muted); margin: auto; }
    .empty .ico { font-size: var(--fs-4xl); margin-bottom: 10px; opacity: .4; }
    /* Any scroller holding only an empty state becomes a centering flex column. */
    :has(> .empty:only-child) { display: flex; flex-direction: column; }
    footer { text-align: center; padding: 16px; color: var(--muted); font-size: var(--fs-sm); border-top: 1px solid var(--border); flex-shrink: 0; }
    .hidden { display: none !important; }
    .flex-gap { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
    .mt-3 { margin-top: 12px; }
    .mt-4 { margin-top: 16px; }
    .toast { position: fixed; bottom: 24px; right: 24px; background: #161616; color: #fff; padding: 12px 20px; border-radius: 10px; font-size: var(--fs-md); opacity: 0; transition: opacity .3s; pointer-events: none; z-index: 999; }
    .toast.show { opacity: 1; }
    .spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid rgba(255,255,255,.35); border-top-color: #fff; border-radius: 50%; animation: spin .7s linear infinite; }
    @keyframes spin { to { transform: rotate(360deg); } }

    /* ── Mobile app shell: bottom tab bar + More sheet (phones only) ── */
    #mobile-tabs { display: none; }
    .mtab { position: relative; flex: 1; background: none; border: none; border-top: 2px solid transparent; color: rgba(255,255,255,.55); font-family: inherit; font-size: var(--fs-2xs); font-weight: var(--fw-semi); padding: 7px 2px 7px; cursor: pointer; display: flex; flex-direction: column; align-items: center; gap: 3px; }
    /* Count badge sitting over the top-right of a tab's icon */
    .mtab-badge { position: absolute; top: 3px; left: 50%; transform: translateX(2px); min-width: 16px; height: 16px; padding: 0 4px; box-sizing: border-box; border-radius: 9px; background: var(--accent); color: var(--on-accent); font-size: var(--fs-2xs); font-weight: var(--fw-display); line-height: 16px; text-align: center; box-shadow: 0 0 0 2px var(--primary, #232323); }
    .mtab .mt-ico { font-size: var(--fs-2xl); line-height: var(--lh-none); }
    .mtab.on { color: #fff; border-top-color: var(--accent); }
    .more-sheet { display: none; position: fixed; inset: 0; z-index: 90; background: rgba(0,0,0,.45); }
    .more-panel { position: absolute; left: 0; right: 0; bottom: 0; background: var(--card); border-radius: 16px 16px 0 0; padding: 8px 14px calc(18px + env(safe-area-inset-bottom,0)); max-height: 82vh; overflow-y: auto; animation: sheetUp .18s ease; }
    @keyframes sheetUp { from { transform: translateY(100%); } to { transform: translateY(0); } }
    .more-grab { width: 40px; height: 5px; border-radius: 3px; background: var(--border); margin: 4px auto 10px; }
    .more-title { font-size: var(--fs-sm); font-weight: var(--fw-display); text-transform: uppercase; letter-spacing: .5px; color: var(--muted); margin: 0 4px 4px; }
    .more-grp { font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .4px; color: var(--muted); margin: 12px 4px 4px; }
    .more-item { display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; background: none; border: none; border-radius: 10px; padding: 12px; font-size: var(--fs-lg); font-family: inherit; color: var(--text); cursor: pointer; }
    .more-item:hover, .more-item:active { background: var(--bg); }
    .more-item .mi-ico { font-size: var(--fs-2xl); width: 24px; text-align: center; flex-shrink: 0; }
    .more-item.danger { color: #b91c1c; }
    /* ── The quick-access row at the foot of the sheet ────────────────────────
       Divided off with a real rule, not just a gap: everything above is a place to
       READ something, and this is the one place to DO something. The divider is the
       whole reason it reads as an exception rather than the last resource in the
       list, so it spans the panel's full content width (see the card-divider rule)
       and the row keeps a little air on both sides of it. */
    /* Sticky to the foot of the sheet's scroller, so it is on screen the moment the sheet
       opens however long the Resources above it run. The negative margins let it own the
       panel's own bottom padding (including the iPhone home-indicator inset), so it reads
       as the sheet's floor rather than a card floating above one. */
    .more-foot { position: sticky; bottom: calc(-18px - env(safe-area-inset-bottom, 0px));
      margin: 10px -14px calc(-18px - env(safe-area-inset-bottom, 0px));
      padding: 6px 14px calc(18px + env(safe-area-inset-bottom, 0px));
      background: var(--card); border-top: 1px solid var(--border); }
    .more-quick { align-items: flex-start; padding-top: 11px; padding-bottom: 11px; }
    .more-quick .mi-ico { margin-top: 1px; }
    .mq-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; flex: 1; }
    .mq-lbl { font-weight: var(--fw-semi); }
    /* The errand, spelled out. "Members" alone reads as another directory — the line
       underneath is what tells a leader this is where the invite and the approval are. */
    .mq-text small { font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-tight); }
    /* Count of sign-ups waiting. Accent, not amber: it's a thing to do, not a fault —
       and it's the only badge on this sheet, so it doesn't need to shout to be found. */
    .mq-badge { flex-shrink: 0; align-self: center; min-width: 20px; height: 20px; padding: 0 6px; box-sizing: border-box; border-radius: 10px; background: var(--accent); color: var(--on-accent); font-size: var(--fs-2xs); font-weight: var(--fw-display); line-height: 20px; text-align: center; }

    /* ── Requests landing on desktop → let the cards float on the grey page ──
       The request rows are already cards at every width (see the data-sentence
       block further down). On mobile the table-wrap is transparent so they float
       on the grey --bg; on desktop it was still a white panel, so the cards read
       as one flat white block. Strip the wrap's white chrome on desktop too — the
       toolbar stays put and the list still scrolls inside the wrap. (Mobile keeps
       its own copy of this in the max-width:760px block below.) */
    @media (min-width: 761px) {
      .req-table-wrap { border: none; background: transparent; box-shadow: none; }
    }

    @media (max-width: 760px) {
      #site-nav { display: none !important; }                 /* bottom tabs replace the top strip */
      /* Signed-OUT chrome: mirror the app's bottom tab bar as an empty black
         footer (no nav buttons), so the sign-in screen matches the rest of the
         app. It's a normal-flow row of the body column, so it takes height from
         the centered view above — the login card re-centers in the space that's
         left, i.e. shifts up on its own. Hidden on create-account (that panel
         has its own action-bar footer). In the signed-in app the footer is
         inline display:none (set in showView), so this only affects login/pending. */
      footer { display: flex; align-items: center; justify-content: center;
        min-height: calc(var(--footer-bar-h) + env(safe-area-inset-bottom, 0px));
        padding: 0 16px env(safe-area-inset-bottom, 0px); background: var(--primary, #232323);
        color: rgba(255,255,255,.5); font-size: var(--fs-sm); border: none; border-top: 1px solid rgba(255,255,255,.08);
        box-shadow: 0 -4px 18px rgba(0,0,0,.18); }
      main:has(#view-login.has-login-footer) ~ footer { display: none; }
      #userbox .uname, #userbox .ub-caret { display: none; }  /* just the avatar in the header; tap opens the profile menu */
      header { padding: calc(11px + env(safe-area-inset-top, 0px)) calc(16px + env(safe-area-inset-right, 0px)) 11px calc(16px + env(safe-area-inset-left, 0px)); }
      /* Per-tenant header logo height, PHONES ONLY (Site Config → App Branding →
         Header; the var is set on <html> by applyHeaderLogoHeight, same channel as
         the brand palette). Deliberately not a desktop knob: at 1500px a 36px mark
         was never the problem — the squeeze is a wide wordmark on a ~390px bar,
         where every pixel of logo width is a pixel the union's name and tagline
         don't get. Desktop keeps the fixed 36px above, untouched.
         :has(img) scopes BOTH rules to an uploaded logo on purpose — with no logo
         .brand-logo holds the 24px text wordmark, whose fixed 36px box the type
         sits in; letting the slider shrink that would clip the fallback. */
      #brand-logo:has(img) { height: var(--hdr-logo-h-mobile, 36px); }
      #brand-logo:has(img) img { height: var(--hdr-logo-h-mobile, 36px); max-height: var(--hdr-logo-h-mobile, 36px); }
      /* No bottom reserve needed: the tab bar is in normal flow (below), not an overlay. */
      #ql-groupme { display: none; }                           /* GroupMe broadcast is desktop-only — Communications isn't used on mobile */
      #ql-compose { display: none; }                           /* Email composer is desktop-only on phones */
      /* (#ql-members and #chatbot-rail are gone with the Home redesign — the directory is
         the first quick-link tile, and Contract search is its own destination.) */
      /* In NORMAL FLOW (not position:fixed): it's the last row of the body's
         flex column, so it always sits at the true bottom and nothing can render
         behind it. (Fixed bottom bars anchor to the large viewport on mobile
         Chrome, which is what put content behind the bar.) safe-area padding
         lifts the buttons above the iPhone home indicator in the PWA.
         Colour is --primary, NOT --nav-bg: on desktop the nav strip is a second
         band under the header and reads as its own thing, but on phones the tab
         bar is the only chrome besides the header — the 8% tint just looks like
         the header failed to match itself. Top and bottom bar are one colour. */
      #mobile-tabs:not(.hidden) { display: flex; flex-shrink: 0; min-height: calc(var(--footer-bar-h) + env(safe-area-inset-bottom, 0px)); background: var(--primary, #232323); border-top: 1px solid rgba(255,255,255,.08); padding-bottom: env(safe-area-inset-bottom, 0); box-shadow: 0 -4px 18px rgba(0,0,0,.18); }
      .more-sheet:not(.hidden) { display: block; }
      /* ── "Your request" on a phone ──────────────────────────────────────────
         The four-dot pipeline does not survive 390px: the labels have to sit UNDER their
         dots, and four of them at a readable size is wider than the screen. So the dots
         become four equal bars with the labels spread beneath them, and the action stack
         becomes one full-width button under the card rather than a column beside it. */
      .yr-body { flex-direction: column; gap: 10px; padding: 12px 14px; }
      .yr-rail { width: auto; height: 3px; align-self: stretch; }
      .yr-subj { font-size: var(--fs-lg); }
      .yr-pipe { gap: 5px; flex-wrap: wrap; }
      .yr-step { flex: 1 1 0; width: auto; gap: 0; }
      .yr-step:last-child { flex: 1 1 0; width: auto; }
      .yr-marker { display: block; }
      .yr-dot { width: 100%; height: 4px; border-radius: 2px; border: none; background: var(--neutral-bg); }
      .yr-bar, .yr-check { display: none; }
      .yr-step-done .yr-dot { background: var(--ok, #15803d); }
      .yr-step-now .yr-dot { background: var(--accent); box-shadow: none; }
      .yr-lbl { font-size: var(--fs-2xs); margin-top: 5px; }
      .yr-acts { flex-direction: row; }
      /* ≥44px tap targets — the tab bar's own buttons are 51px and set the floor. */
      .yr-acts .ld-btn { flex: 1 1 auto; padding: 13px 12px; }
      /* Same 44px floor for the row's dismiss ✕ — a thumb target sitting INSIDE another
         thumb target (the row itself opens the request), so it is the one control here
         that has to be easy to hit on purpose. Grown with a ::after overlay rather than
         width/height: sizing the button up pushed its row 2px taller than its neighbours
         (a 44px box does not fit a 41px row), and a list of rows that don't line up is a
         worse bug than a small ✕. The overlay adds hit area and no geometry.
         Asymmetric on purpose: full 44px tall, but it only grows left into the row's own
         10px flex gap, never across the status word beside it. A symmetric box reached
         9px over "Resolved", so a thumb aimed at the status would have dismissed the
         notice instead of opening the request — the one misfire this control must not
         have, because the request is what she was reaching for. */
      .yr-x::after { content: ''; position: absolute; inset: -9px -9px -9px -5px; }
      /* (Home's shortcut tiles are .hm-tile now — icon above label at every width, since
         a label is the destination and the old sub-line never survived four across. Their
         phone sizing lives with the rest of the phone Home block above.) */
      /* ── Request flow on phones: reclaim width + comfortable tap targets ── */
      .app-section.active > .app-container { padding: 16px 14px; }
      /* The Back button is a fixed-looking header row ABOVE the scroller — it is a
         direct child of the .app-section, not of the .app-container (see the desktop
         rule for why: the container is the scrollport, and anything inside it is
         clipped at the scrollbar gutter). Drop the container's TOP padding when the
         section has a Back button so the bar owns the top edge; its own top padding
         supplies the breathing room, and the divider + soft drop-shadow make it read
         as chrome with the scrollable content tucked beneath. The 14px under the bar
         is the scroller's padding (it scrolls away with the content) so the page
         still slides up under the divider. */
      .app-section.active:has(> .back-btn:not(.hidden)) > .app-container { padding-top: 14px; }
      .app-section.active > .back-btn {
        z-index: 5;
        justify-content: flex-start;
        background: var(--bg);
        margin: 0;
        padding: 16px 14px 14px;
        border-bottom: 1px solid var(--border);
        box-shadow: 0 6px 10px -6px rgba(16,24,40,.20);
      }
      /* (Members/Requests used to keep their bar inside the container and cancel its
         side padding to bleed edge-to-edge. Their bar is a section child like every
         other page's now, so the one rule above covers all of them.) */
      /* ── Submit a Request on phones: scrollable form ──
         The form card scrolls internally; the Description textarea has a
         sensible fixed height instead of flex-filling the viewport. */
      #section-submit > .app-container { display: flex; flex-direction: column; overflow: hidden; }
      #section-submit .submit-layout,
      #section-submit .submit-form-card,
      #section-submit #ticket-form { flex: 1; min-height: 0; display: flex; flex-direction: column; }
      #section-submit #ticket-form { overflow-y: auto; overflow-x: hidden; }
      #section-submit .submit-desc { flex: 0 0 auto; display: flex; flex-direction: column; }
      #section-submit .submit-desc #t-desc { min-height: 100px; resize: none; }
      .submit-top-row { flex-direction: column; gap: 0; align-items: stretch; }
      /* Stacked, Category has the whole width — the desktop shrink-to-fit sizing
         would leave a stubby select floating in a full-width column. */
      .submit-top-row > .submit-cat select { width: 100%; min-width: 0; max-width: none; }
      /* These are flex items of the column-flex #ticket-form. NEVER give them
         overflow:hidden — that drops a flex item's automatic minimum size to 0,
         so the container shrinks the whole block to height:0 and clips the
         "Add a Needed-By Date" / rep-preference links out of existence. */
      .submit-pref { min-width: 0; max-width: 100%; flex-shrink: 0; }
      /* Phones keep the three disclosures stacked — side by side they'd each be ~30%
         of a narrow screen. The row owns the spacing (the wrappers have none), so the
         gap has to be a real value here, not 0. */
      .submit-optional-row { flex-direction: column; gap: 14px; align-items: stretch; }
      /* And the children go back to intrinsic size. The desktop row gives them
         `flex: 1 1 240px` to share the width three ways; once the row is a COLUMN
         that 240px basis is a HEIGHT, so each collapsed one-line link became a
         240px-tall block (and grew on top of that) — ~750px of white space between
         three links. flex-basis is main-axis, and the main axis just changed. */
      .submit-optional-row > .submit-pref { flex: 0 0 auto; }
      /* ── The "needed by" date field, fixed THREE ways on purpose ────────────
         Every browser on iOS (Chrome included) is WebKit, and WebKit draws
         input[type=date] as a native control with its own intrinsic width and
         CENTERED value text. Desktop Chrome is Blink and draws something else
         entirely, which is why this reads perfect here and spills on a phone.
         Do not "simplify" any of the three — each blocks a different escape:

         1. A grid cage. minmax(0, 1fr) is a track that is hard-capped at the
            container width AND has no min-content floor, so a child with a big
            intrinsic width cannot push past it. This is spec-level layout math,
            not a WebKit courtesy — it holds even if the control ignores width. */
      #submit-due-wrap { display: grid; grid-template-columns: minmax(0, 1fr); }
      /*  2. Drop the native widget, so it sizes like any other input of ours. */
      #t-due { -webkit-appearance: none; appearance: none;
               display: block; box-sizing: border-box;
               width: 100%; max-width: 100%; min-width: 0; }
      /*  3. Left-align the value. WebKit centers it, which is what makes the
             field read as a big stretched button instead of a text field. The
             min-height keeps the row from collapsing while the date is empty. */
      #t-due::-webkit-date-and-time-value { text-align: left; margin: 0; min-width: 0; min-height: 1.2em; }
      #t-due::-webkit-calendar-picker-indicator { margin-left: auto; flex-shrink: 0; }
      /* Only the VERTICAL rhythm is a phone decision now. The horizontal value in
         each of these was the gutter written out a seventh time; --pad-card is
         already 14 down here, so .td-summary / .td-meta / .act-item / the
         .leader-controls padding said nothing the token doesn't and are gone. */
      .td-header, .td-body, .td-main .td-body { padding: 16px var(--pad-card); }
      .ticket-detail-card > .contact-card { margin: 14px var(--pad-card) 2px; }
      .td-info-row { gap: 12px 18px; }
      .leader-controls { flex-direction: column; align-items: stretch; }
      .leader-controls .ctrl-group { width: 100%; }
      .select-ctrl { width: 100%; padding: 11px 12px; font-size: var(--fs-xl); }   /* full-width, ~44px, 15px avoids iOS zoom-on-focus */
      .leader-controls .btn.sm { width: 100%; justify-content: center; padding: 12px 16px; font-size: var(--fs-lg); }
      .ctrl-note { text-align: center; }
      /* The heads are out of this list: .card-sec-hd owns one derived height at both
         widths now, and a phone padding override would only put the drift back. */
      .contact-card h4:not(.card-sec-hd), .comment-form { padding: 12px var(--pad-card); }
      .comment-item { padding: 12px var(--pad-card) 8px; }
      /* Reflow ticket rows: title on top, status/date/assignee on a second line */
      .ticket-row { flex-wrap: wrap; }
      .tr-main { flex: 1 1 100%; }
      .tr-right { flex: 1 1 100%; justify-content: flex-start; flex-wrap: wrap; margin-top: 2px; }
      .tr-assign { display: none; }   /* the hub assigns from the detail view on phones */
      /* ── Requests table → compact, columnized cards on phones ──
         Two tidy lines per card: line 1 = title + status pill; line 2 = category ·
         who · date. The title flex-grows to fill line 1, which forces the secondary
         fields onto a second line; because every row uses identical rules, the same
         data lands in the same spot down the list — scannable like a dashboard. */
      /* (The phone used to scroll the PAGE here and let the table run at its natural
         height. It doesn't any more: a framed page keeps its band and its bar fixed at
         every width, and .page-scroll does the scrolling — the Members panel's shape.) */
      /* Stays one line on phones too: search shrinks, icons + count hold the right. */
      .req-toolbar { column-gap: 6px; row-gap: 12px; }
      /* No 7em floor under the name here — see .rq-main. On a 301px card that floor is
         a third of the row, and it clipped the category to make room for white space. */
      .rq-main { --rq-who-min: 0px; }
      .req-table-wrap { border: none; background: transparent; box-shadow: none; }
      #req-table-wrap.is-empty { min-height: 50dvh; }
      .req-table, .req-table tbody, .req-row { display: block; width: 100%; }
      .req-table thead { display: none; }
      .req-row { position: relative; display: flex; flex-wrap: wrap; align-items: center; gap: 5px 8px;
                 background: var(--card); border: 1px solid var(--border); border-radius: var(--radius);
                 box-shadow: var(--shadow); margin-bottom: 8px; padding: 10px 12px 10px 16px; }
      .req-row > td { border: none !important; box-shadow: none !important; padding: 0; min-width: 0; }
      .req-row > td[data-label]::before { content: none; }   /* drop the stacked field labels */
      /* Status + flag are pinned to the top corners (out of flow) and the title takes
         its own full-width first line. That makes line 1 identical on every row no
         matter how long the title is — so the columns line up down the list. */
      .req-row > td.rq-c-status  { position: absolute; top: 9px; right: 12px; }
      .req-row > td.rq-c-title   { order: 0; flex: 1 1 100%; max-width: none; padding-right: 88px;   /* clear the status pill */
                                   display: flex; align-items: center; gap: 6px; }
      .req-row .rq-title         { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: var(--fs-lg); font-weight: var(--fw-bold); }
      /* line 2: compact meta strip; date pinned to the right edge */
      .req-row > td.rq-c-member   { order: 1; font-size: var(--fs-sm); }
      .req-row > td.rq-c-category { order: 2; }
      .req-row > td.rq-c-assignee { order: 3; font-size: var(--fs-sm); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 50%; }
      .req-row > td.rq-c-created  { order: 4; margin-left: auto; font-size: var(--fs-xs); white-space: nowrap; color: var(--muted); }
      /* trimmed on phones to keep cards tight */
      .req-row > td.rq-c-updated, .req-row > td.rq-c-assigned,
      .req-row > td.rq-c-assignee-ctl, .req-row .tr-assign { display: none; }
      .req-row::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; background: var(--req-edge, transparent); border-radius: var(--radius) 0 0 var(--radius); }
      /* "mine" = tint the WHOLE card, not each label. On mobile the row is a flex
         card, so the desktop per-cell tint (.req-row.mine > td) would leave the
         flex gaps showing through between separately-tinted cells. */
      .req-row.mine { background: var(--accent-light); box-shadow: inset 3px 0 0 var(--accent), var(--shadow); }   /* accent edge as inset shadow → no content shift, columns stay aligned */
      .req-row.mine > td, .req-row.mine:hover > td { background: transparent; }
      .req-row.mine::before { display: none; }
      /* ── Member directory → rows on phones (no horizontal scroll) ── */
      /* The scroller bleeds LEFT for the same reason the Requests one does — see THE
         SCROLLER HAS TO BLEED TOO. The rows below bleed out by --pad-card to reach the
         card wall, and this is the box that would otherwise clip that bleed off at the
         gutter. `overflow-x: visible` does NOT save it: with overflow-y auto the other
         axis computes to auto and clips anyway, which is exactly the trap this hit. */
      #members-list { overflow-x: visible !important;
        margin-left: calc(-1 * var(--pad-card)); padding-left: var(--pad-card); }
      /* Empty state: the list grows to fill the frame so the message centres in the
         card instead of hugging the toolbar. (The container and the frame are already
         a flex column at every width now — see THE FRAME'S PLUMBING — so this is all
         that's left of the old three-rule phone special case.) */
      #members-list:has(> .empty:only-child) { flex: 1 1 auto; min-height: 0; }
      /* Kill the desktop's bounded-box chrome on #members-list itself: at phone width
         the rows read as their own stack, not "content inside a table box." The head
         goes away for the same reason — the column labels don't survive the reflow. */
      #members-head { display: none; }
      #members-list { border: 0; border-radius: 0; background: transparent; padding: 0; scrollbar-gutter: auto; }
      /* Restore the bar-to-scroller shape the shared `.page-bar + .page-scroll` pair
         gives every other phone list. That rule no longer matches on this screen
         because #members-head sits BETWEEN them in the DOM (display:none doesn't
         change what `+` looks at). Written specifically for #members-list to preserve
         the config-screens exception the shared rule protects (a real .list-head
         between bar and scroller must NOT get the margin zeroed, or column labels
         jam against the bar's rule). */
      #section-members .page-bar { margin-bottom: 0; }
      #members-list { padding-top: 14px; }
      .mem-table { background: transparent; border: none; border-radius: 0; box-shadow: none; overflow: visible; }
      .mem-table, .mem-table > tbody, .mem-table > tbody > tr, .mem-table > tbody > tr > td { display: block; width: 100%; }
      .mem-table thead { display: none; }
      /* A ROW, not a card — the same conversion Requests made (see A ROW, NOT A CARD
         and THE ROW REACHES THE WALL). This one kept the v1 treatment longest: a
         border, a 12px radius, a shadow and an 8px gap per member, which made the
         directory a stack of pill cards floating inside the one card the page already
         is. Three edges to the left of a name — the card's wall, the pill's border,
         then the text — where the standard allows exactly one gutter and zero borders.

         What survives is the ROW's own layout, which is the part that works: identity
         over contact, split by the .mem-c-name::after rule. Only the container changes
         — one hairline between members, the card's edge around all of them, and the
         fill spanning the full width of the table.

         And the space the chrome was using is RECLAIMED, which is the whole point of
         removing it. A name used to start 30px inside the wall — 1px frame + 14px
         gutter + 1px pill border + 14px pill padding, three edges deep. It starts at
         one gutter now, the same x as the search box directly above it, which hands
         30px of width back to every row on a 375px screen. Taking the chrome out and
         leaving the text where it was would have been redecorating, not converting. */
      /* THE ROW NO LONGER BLEEDS ITSELF — the scroller does it. This carried
         `margin: 0 -14px` from when #members-list still started one gutter inside the
         card and the row had to break out to reach the wall. The scroller bleeds both
         sides now (see the row-fill fix on .page-scroll), so the negative margin is
         counted twice: the row's box measured 1 → 389 against a card whose inner edge
         is 15 → 375, its fill was saved only by the scroller clipping it, and its
         PADDING moved with it — putting the name at the card's wall with no gutter at
         all. Width auto inside a scroller that already spans wall-to-wall lands the
         row on 15 → 375 with its text one gutter in, which is the whole rule. */
      .mem-table > tbody > tr { position: relative; width: auto; background: var(--card);
        border: 0; border-bottom: var(--rail); border-radius: 0; box-shadow: none;
        margin: 0;
        padding: 11px var(--pad-card); }
      .mem-table > tbody > tr:last-child { border-bottom: 0; }
      .mem-table > tbody > tr > td { border: none !important; box-shadow: none !important; padding: 0; }
      .mem-c-name { margin-bottom: 0; }
      /* ── ONE LINE MEANS ONE THING ──────────────────────────────────────────
         A full-width hairline used to sit here, between a member's identity line and
         their contact line — and the row below draws one too (tr's border-bottom).
         Same 1px, same grey, two different jobs, so the list read as an evenly spaced
         stack of rules with nothing marking where a person ended. It is the reason
         this reads as harder to scan than the old cards did, and the cards were never
         the thing that made it work: the GAP between them was.
         So the rule is deleted and proximity does the grouping instead — see the
         footer's indent below. Reasoning in full at .rq-sub, which had the identical
         defect on the Requests list. */
      /* The desktop table's columns are off; the card's combined contact footer is on.
         Written as `.mem-table td.…` rather than leaning on .mem-c-desk's default,
         because the rule two lines up blockifies EVERY td by element+class and would
         out-rank a bare class selector — which is a phone showing all eight desktop
         columns stacked, one per line. */
      .mem-table td.mem-c-desk { display: none; }
      /* THE GAP IS THE GROUPING — no indent. This carried `margin-left: 44px` for one
         release, to hang the contact line off the name. Two things were wrong with it.
         The rule two lines up sets every cell to `width: 100%`, so a left margin does
         not narrow the box, it SHIFTS it: the contact column ran 44px past the card's
         wall and the phone number lost its last digit and its icon off the right edge.
         And it was solving a problem the 5px gap had already solved — 5px inside a
         record against 22px of row padding and a hairline between records is the
         separation a reader needs. So the record starts at the wall, like every other
         row in the app. */
      .mem-table td.mem-c-footer { display: block; }
      /* Everything else this row needs — the three columns kept as a row, the fixed
         building/grade slot, the avatar's top-align, the two-line name clamp, the
         floored footer height, the fixed contact lane and the address that takes the
         slack and clips in it — is ONE MEMBER RECORD, ONE LAYOUT (.mem-rec, unscoped,
         further up). It was written here first and then copied, badly, onto the request
         detail's cards; the row wears the class now and the copy is gone. The <tr>
         carries `.mem-rec` at every width and the desktop table's own `#members-list`
         overrides out-rank it by an id, so the columns are unaffected. */
      .mem-table td.mem-c-action { margin-top: 9px; }
      /* hero rows: drop the desktop gradient/rail; use a clean left accent border */

      /* ── Dashboard gate: setup card top-aligned (matches Home), empty state centered below ── */
      .dash-gate { align-items: stretch; justify-content: flex-start; min-height: 100%; padding: 0; }
      .dash-gate > .setup-card { max-width: none; }
      .dash-gate-inner { max-width: none; flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 18px 0; }
      /* ── Leader dashboard, phones — only what CANNOT collide ────────────────
         THE REST OF THIS SCREEN'S PHONE RULES ARE NOT HERE, and that is deliberate:
         every `.ld-*` override lives in the phone block at the END of the file,
         AFTER the `.ld-*` base. Same class = same specificity, so source order
         decides — and rules written here silently LOST to the base further down.
         Measured on a real iPhone: the desktop stats band and the whole 440px-rail +
         place-card map rendered on a 390px screen, because `.ld-mapzone { display:
         none }` sat here and `display: flex` sat 1,600 lines later.
         Only container/gate rules that nothing downstream re-declares belong here. */
      #section-dashboard.active > .app-container { display: flex; flex-direction: column; padding-bottom: 16px; overflow: hidden; }
    }

    /* ── Welcome animation splash ── */
    #boot-cover { position: fixed; inset: 0; z-index: 150; background: var(--bg); transition: opacity .35s ease; }
    #boot-cover.gone { opacity: 0; pointer-events: none; }
    /* Portrait lock. The manifest's "orientation":"portrait" handles Android/Chromium
       PWAs, but iOS/WebKit ignores that field outright — so on iPhone the only way to
       keep the app upright is to cover the screen when the phone is turned sideways.
       Scoped to phones (coarse pointer + short landscape height) so tablets and desktop,
       which are legitimately used in landscape, are never blocked. z-index sits above the
       boot cover so a cold launch in landscape shows the prompt, not the grey splash. */
    #rotate-lock { display: none; }
    @media (orientation: landscape) and (max-height: 500px) and (pointer: coarse) {
      #rotate-lock { position: fixed; inset: 0; z-index: 200; background: var(--bg); color: var(--text);
        display: flex; flex-direction: column; align-items: center; justify-content: center;
        gap: 14px; text-align: center; padding: 24px; }
      #rotate-lock svg { width: 52px; height: 52px; color: var(--accent); }
      #rotate-lock h2 { margin: 0; font-size: var(--fs-2xl); font-weight: var(--fw-bold); }
      #rotate-lock p { margin: 0; font-size: var(--fs-lg); color: var(--muted); line-height: var(--lh-tight); white-space: nowrap; }
    }
    /* Height mirrors the app shell (body) so the splash centers within the VISIBLE
       area, not the taller layout viewport — otherwise the flex-centered content
       lands below the true middle behind mobile browser chrome. */
    #welcome-splash { position: fixed; top: 0; left: 0; right: 0; height: var(--app-height, 100vh); z-index: 200; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--bg); padding: 28px; text-align: center; opacity: 0; pointer-events: none; visibility: hidden; }
    @media (display-mode: standalone) { #welcome-splash { height: 100vh; } }   /* installed PWA: true full screen, same as the shell */
    #welcome-splash.show { opacity: 1; pointer-events: auto; visibility: visible; }                 /* instant cover (no transition) */
    #welcome-splash.leaving { opacity: 0; visibility: visible; transition: opacity .6s ease; }        /* smooth dissolve out — layout kept intact */
    .ws-inner { display: flex; flex-direction: column; align-items: center; gap: 20px; max-width: 90vw; animation: wsIn .5s cubic-bezier(.2,.7,.3,1) backwards; }
    #welcome-splash.leaving .ws-inner { transform: scale(1.05); transition: transform .6s cubic-bezier(.4,0,.2,1); }   /* gentle lift-away as it fades */
    #ws-img { max-width: min(440px, 82vw); max-height: 58vh; object-fit: contain; }
    .ws-skip { color: var(--muted); font-size: var(--fs-sm); letter-spacing: .4px; }
    @keyframes wsIn { from { opacity: 0; transform: scale(.97); } to { opacity: 1; transform: scale(1); } }
    @media (prefers-reduced-motion: reduce) { .ws-inner { animation: none; } #welcome-splash.leaving { transition: opacity .2s; } #welcome-splash.leaving .ws-inner { transition: none; transform: none; } }

    /* ════════════════════════════════════════════════════════════════════
       CLICK-AFFORDANCE SYSTEM — make "clickable vs static" unmistakable,
       especially on TOUCH (every cue is persistent or :active-driven, never
       hover-only). Appended last so it wins on source order. Two buckets:
         ACT-IN-PLACE  → the solid .btn (look unchanged) + a press
         NAVIGATE/OPEN → .ql-btn/.quick-card/.submit-hero/.req-row: a SOLID
                         chevron (› in-app, ↗ external) + elevation + press
         STATIC        → flat, no chevron, no press, default cursor
       ════════════════════════════════════════════════════════════════════ */

    /* 0 — kill the grey OS tap flash only where we supply our own :active */
    .btn, .ql-btn, .big-btn, .quick-card, .submit-hero,
    .req-row, .filter-btn, .icon-btn, .subj-chip, .theme-chip, .imc-card,
    .more-item, .mtab, .mem-link, .mem-link-muted,
    .cev-desc a { -webkit-tap-highlight-color: transparent; }

    /* 1 — ONE keyboard focus ring for everything (opt-OUT, never opt-in) */
    @supports selector(:focus-visible) { :focus:not(:focus-visible) { outline: none; } }
    :focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
    /* dark ring on solid-accent surfaces (orange-on-orange is invisible) */
    .btn:not(.secondary):not(.ghost):focus-visible,
    .submit-hero:focus-visible,
    .quick-card.accent-card:focus-visible { outline-color: var(--primary); }
    /* light ring on the dark header / bottom tab bar */
    .nav-item:focus-visible, .mtab:focus-visible { outline: 2px solid #fff; outline-offset: -3px; }

    /* 2 — BUCKET 1: act-in-place. Keep .btn's look; add the touch press. */
    .btn { transition: background .15s, transform .06s ease; }
    .btn:active:not(:disabled)           { transform: translateY(1px); background: var(--accent-dark); }
    .btn.secondary:active:not(:disabled) { transform: translateY(1px); background: #e5e7eb; }
    .btn.ghost:active:not(:disabled)     { transform: translateY(1px); background: var(--accent-light); }
    .btn:disabled { transform: none; }

    /* 3 — BUCKET 2: navigate/open = persistent SOLID chevron + press */
    .ql-btn, .quick-card { position: relative; }
    .ql-btn { padding-right: 34px; }
    .quick-card { padding-right: 38px; }
    .ql-btn::after, .quick-card::after {
      content: "\203A"; position: absolute; top: 50%; right: 14px; transform: translateY(-50%);
      font-size: var(--fs-2xl); font-weight: var(--fw-bold); line-height: var(--lh-none); color: var(--accent-dark);
      pointer-events: none; transition: transform .12s; }
    .quick-card.accent-card::after { color: #fff; }
    /* never stamp a chevron over an existing right-pinned count badge */
    .ql-btn:has(.ql-count)::after, .quick-card:has(.qc-count)::after { display: none; }
    .ql-btn:active, .quick-card:active { background: var(--accent-light); transform: translateY(1px); }
    .quick-card.accent-card:active { background: var(--accent-hover); }
    .ql-btn:active::after, .quick-card:active::after { transform: translateY(-50%) translateX(3px); }
    .big-btn:active { background: var(--accent-light); transform: translateY(1px); }

    /* (The submit hero is no longer a giant div-as-button: it is a region of Home's card
       with a real primary BUTTON in it, so the whole-block chevron and press came off —
       .btn's own affordances cover the thing that is actually clickable now.) */
    /* Home's quick-link tiles are navigate/open too, but a centred tile carries no room
       for a right-pinned chevron; the press is the cue. */
    .hm-tile:active { background: var(--accent-light); transform: translateY(1px); }
    .hm-tile { -webkit-tap-highlight-color: transparent; transition: border-color .15s, box-shadow .15s, background .12s, transform .06s ease; }

    /* 3b — clickable ROWS. Desktop keeps its hover tint (hover exists there). */
    @media (hover: hover) { .req-row:hover > td { background: #fafafa; } }
    .req-row:active > td { background: var(--accent-light); }
    .req-row.mine:active > td { background: rgba(var(--accent-rgb),.12); }
    /* THE fix: on phones the request card keeps elevation + gains a chevron in a
       reserved right gutter (status pill moved left of it), while the look-alike
       member card goes flat (below) — they now read oppositely, no tap-test. */
    @media (max-width: 760px) {
      .req-row { padding-right: 34px; }
      .req-row > td.rq-c-status { right: 34px; }
      .req-row > td.rq-c-title  { padding-right: 110px; }
      .req-row::after {
        content: "\203A"; position: absolute; right: 13px; top: 50%; transform: translateY(-50%);
        font-size: var(--fs-3xl); font-weight: var(--fw-bold); line-height: var(--lh-none); color: var(--accent-dark);
        pointer-events: none; transition: transform .12s; }
      .req-row:active { background: var(--accent-light); }
      .req-row.mine:active { background: rgba(var(--accent-rgb),.10); }
      .req-row:active::after { transform: translateY(-50%) translateX(3px); } }

    /* 4 — STATIC: strip the borrowed affordances */
    .mem-table tbody tr { cursor: default; }
    .mem-table tbody tr:hover td { background: transparent; }   /* kill the audit-table hover tint */
    @media (max-width: 760px) {
      .mem-table > tbody > tr { box-shadow: none; }             /* flat = not a button */
      .mem-table td.mem-c-action .btn { width: 100%; justify-content: center; } }
    /* the real targets INSIDE the static member row get clear link cues */
    .mem-link, .mem-link-muted, .map-link { color: var(--accent-dark); text-decoration: underline; text-underline-offset: 2px; }
    .mem-link:active, .mem-link-muted:active, .map-link:active { color: var(--accent); }
    .map-link { cursor: pointer; -webkit-tap-highlight-color: transparent; }
    /* Address → Maps chooser (device-appropriate apps + copy) */
    .maps-sheet { position: fixed; inset: 0; z-index: 130; background: rgba(0,0,0,.45); display: flex; align-items: flex-end; justify-content: center; }
    .maps-pop { background: var(--card); width: 100%; max-width: 440px; border-radius: 16px 16px 0 0; padding: 8px 10px calc(12px + env(safe-area-inset-bottom, 0)); box-shadow: 0 -4px 18px rgba(0,0,0,.18); animation: sheetUp .18s ease; }
    .maps-addr { font-size: var(--fs-sm); color: var(--muted); padding: 10px 12px 8px; word-break: break-word; }
    .maps-opt { display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; background: none; border: none; border-radius: 10px; padding: 13px 12px; font-size: var(--fs-lg); font-family: inherit; color: var(--text); cursor: pointer; }
    .maps-opt:active { background: var(--accent-light); }
    .maps-opt .lic { width: 18px; height: 18px; flex-shrink: 0; color: var(--accent-dark); }
    .maps-opt.cancel { justify-content: center; color: var(--muted); margin-top: 4px; border-top: 1px solid var(--border); border-radius: 0; }
    /* iOS "Save to Contacts" heads-up dialog */
    @media (min-width: 600px) { .maps-sheet { align-items: center; } .maps-pop { border-radius: 14px; margin: 0 16px; } }
    /* calendar cards are static — neutralize the accent rail */
    .cev { border-left: 1px solid var(--border); box-shadow: none; }
    .cev-desc a { text-decoration: underline; text-underline-offset: 2px; font-weight: var(--fw-semi); }
    /* The event row's date block KEEPS its tint. It used to be greyed here, on the reasoning
       that an accent on a static row reads as a click affordance — but the tinted date block
       is the calendar's identity, not a button: it is exactly what the meeting-log entry in a
       request's timeline wears, and greying it is most of why the Upcoming Events list read
       as a column of bare strings on a phone. The row still has no chevron and no press. */
    /* read-only pills must not look like clickable filter pills */
    .badge, .pill, .um-stat, .rq-me-chip, .ev-tag { cursor: default; }

    /* 5 — chips / icon tools / filters: rest cue stays; add a touch press */
    .filter-btn:active { background: var(--accent-light); border-color: var(--accent); }
    .icon-btn { transition: background .12s, border-color .12s, color .12s; }
    .icon-btn:active { background: var(--accent-light); border-color: var(--accent); color: var(--accent-dark); }
    .subj-chip:not(.on):active, .theme-chip:not(.on):active,
    .imc-card:active, .pg-addblk:active { background: var(--accent-light); border-color: var(--accent); }

    /* 6 — nav / bottom tabs / more sheet: press + external (↗) cue */
    .mtab { transition: color .12s, background .12s; }
    .mtab:active { background: rgba(255,255,255,.08); }
    .mtab:active .mt-ico { transform: scale(.9); transition: transform .08s; }
    .more-item:active { background: var(--accent-light); }
    .nav-item[data-link]::after, .nav-sub-item[data-link]::after,
    .more-item[target="_blank"]::after { content: " \2197"; font-size: .82em; opacity: .7; margin-left: 3px; }

    /* 7 — TOUCH: neutralize hover states so they can't stick after a tap */
    @media (hover: none) {
      .req-row:hover > td { background: transparent; }
      .leader-card:hover { transform: none; box-shadow: var(--shadow); }
      .quick-card:hover, .big-btn:hover, .ql-btn:hover { box-shadow: var(--shadow); border-color: var(--border); }
      .filter-btn:hover { border-color: var(--border); color: var(--muted); }
      .subj-chip:hover, .theme-chip:hover, .imc-card:hover, .pg-addblk:hover { border-color: var(--border); }
      /* Icon buttons (e.g. directory filters): a sticky :hover after a tap/navigation
         must not light up the glyph — reset color too, not just the border. Active
         (.on) filters keep their accent. */
      .icon-btn:not(.on):hover { border-color: var(--border); color: var(--text); }
      .nav-sub-item:hover, .more-item:hover { background: transparent; color: inherit; }
      /* The userbox menu sits inside the (white-text) top bar, so `color: inherit`
         would render its items white-on-white. Reset to the menu's own resting
         color instead — a sticky hover after opening leaves no fake "pressed" look. */
      .userbox-menu a:hover { background: transparent; color: var(--text); }
      .userbox-menu a.danger:hover { background: transparent; color: #b91c1c; } }

    /* ════════ Requests landing — data-sentence rows (replaces the old column table)
       "{who} submitted a {category} {when}."  +  divider  +  "Assigned to … | updated …"
       A clean card list on every viewport; overrides the old table/column CSS. */
    #req-table, #req-body, #req-body > tr, #req-body > tr > td { display: block; width: 100%; box-sizing: border-box; }
    #req-head { display: none; }
    /* The desktop column cells ride in the same <tr> as the phone's sentence cell and
       are off until the desktop query turns them on — see THE REQUEST QUEUE IS A TABLE.
       Written here (not as a phone-query rule) so the default is OFF: a cell that has to
       be switched off per breakpoint is a cell that shows up on some width nobody tested.
       Written at #req-body > tr > td strength ON PURPOSE: the rule directly above sets
       every td in this body to `display: block` at id+2-element specificity, which a bare
       `.rq-c-desk` loses to — so the columns rendered stacked under the sentence on the
       phone. Match the selector that would otherwise win. */
    #req-body > tr > td.rq-c-desk { display: none; }
    /* A ROW, not a card. It carried a border, a 12px radius, a shadow and an 8px gap —
       so the Requests landing was a stack of pill cards floating inside the one card the
       page already is, which is the nesting THE CARD SECTION exists to remove. The list
       reads as a list now: one hairline between rows, and the card's own edge around all
       of them. The state tint below stays — a fill is a fact about the row, and it is
       the border, the radius, the shadow and the gap that were making it an object. */
    .req-row { display: block; width: 100%; position: relative; cursor: pointer; padding: 0;
      background: var(--card); border: 0; border-bottom: var(--rail); border-radius: 0;
      box-shadow: none; margin-bottom: 0; transition: background .12s, transform .06s ease; }
    #req-body > .req-row:last-child { border-bottom: 0; }
    /* State at a glance (replaces the old "New / In progress" section headers):
       new/unassigned = orange tint; in progress (assigned) = plain white. Just the
       background — no left bar. */
    .req-row.rq-st-new { background: var(--accent-light); }
    /* (The tinted-row variants of .rq-sub's border went with the border itself — see
       ONE LINE MEANS ONE THING. They existed to keep a grey hairline from reading cold
       on an orange fill; there is no hairline inside a row any more.) */
    .req-row.done { opacity: .68; }
    .req-row::before { display: none; }                              /* drop the old status rail */
    .req-row::after { content: "\203A"; position: absolute; top: 50%; right: 13px; transform: translateY(-50%);
      font-size: var(--fs-3xl); font-weight: var(--fw-bold); line-height: var(--lh-none); color: var(--accent-dark); pointer-events: none; transition: transform .12s; }
    .req-row:hover > td, .req-row.mine:hover > td, .req-row:active > td { background: transparent; }   /* kill the old per-cell tints */
    /* Hover is a FILL now, not a border colour — there is no border left to tint, and a
       row's hover has always been a fill everywhere else in the app (.list-row). */
    @media (hover: hover) { .req-row:hover { background: var(--band); } }
    .req-row:active { transform: translateY(1px); }
    .req-row:active::after { transform: translateY(-50%) translateX(3px); }
    .req-table td.rq-sentence { display: block; border: 0; padding: 12px 38px 12px 15px; }   /* 38px right clears the chevron lane */
    /* Columnized sentence: [who] [submitted a] [category] [slack] [created-time, right].
       The footer mirrors the right column so "updated" sits directly under the time.

       WHO IS A FLOOR, NOT A CAGE. It used to be a flat `7em`, which bought column
       alignment at the price of ellipsising "Member 1 · Collins K-8" down to
       "Member 1 · C…" while 60% of a 1,500px row sat empty — the single most visible
       thing on the page, and it read as broken rather than tidy. minmax(7em,
       max-content) keeps every ordinary row landing on the same column AND lets a long
       name take the width it needs out of the slack lane beside it. Only a row that is
       genuinely full clips now, which is the only time an ellipsis means anything.
       (Asserted in _frametest.mjs: nothing may truncate while a fifth of its row is
       empty.) The floor is dropped on phones — 7em is a third of a 301px row. */
    .rq-main { display: grid; grid-template-columns: minmax(var(--rq-who-min, 7em), max-content) auto minmax(0, max-content) 1fr auto; align-items: baseline; column-gap: 6px;
      font-size: var(--fs-md); line-height: var(--lh-snug); color: var(--text); }
    .rq-who { font-weight: var(--fw-semi); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .rq-verb { color: var(--muted); white-space: nowrap; }
    .rq-cat { font-weight: var(--fw-bold); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    /* Explicit tracks: the due chip is emitted AFTER the date in the markup, but it
       belongs to the left of it — track 4 (the 1fr slack lane), date always last. */
    .rq-when { grid-column: 5; grid-row: 1; justify-self: end; white-space: nowrap; color: var(--muted); font-size: var(--fs-sm); }
    .rq-main .unread-dot { margin-right: 4px; }
    /* ── ONE LINE MEANS ONE THING ────────────────────────────────────────────
       This row used to draw a hairline between its two lines, and the row below it
       draws one too (.req-row's border-bottom). Same 1px, same grey, two entirely
       different jobs — so a list read as an evenly spaced stack of rules with nothing
       saying which gaps were boundaries. Kristin, on a real phone: it is harder to
       tell one record from the next than it was when they were cards.

       The fix is a DELETION. The inner rule is gone and the gap it sat in is closed to
       3px, so the two lines of one request are nearly touching while 24px of padding
       and the row's own hairline separate it from the next one. Proximity does the
       grouping, which is what it is for; the one surviving rule can now only mean
       "next record". (The cards worked for the same reason — not because they were
       cards, but because the gap between them was obviously bigger than anything
       inside them.) */
    .rq-sub { display: grid; grid-template-columns: 1fr auto; align-items: baseline; column-gap: 10px;
      margin-top: 3px; font-size: var(--fs-sm); color: var(--muted); }
    .rq-assigned { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .rq-assign-label { font-size: var(--fs-2xs); font-weight: var(--fw-medium); color: var(--muted); text-transform: uppercase; letter-spacing: .04em; white-space: nowrap; opacity: .7; }
    .rq-assign-val  { color: var(--text); font-weight: var(--fw-medium); }
    .rq-assign-none { color: var(--muted); font-style: italic; }
    .rq-updated { justify-self: end; white-space: nowrap; }
    .rq-sub .tr-assign { font-size: var(--fs-sm); padding: 2px 6px; max-width: 100%; }

    /* ══ THE ROW REACHES THE WALL (phone) ═══════════════════════════════════
       A fill is a fact about the ROW, so it has to span the row — and the row is the
       table's full width, which is the card's wall. It didn't. The gutter was paid by
       .page-body, so the row BOX started a gutter in and its background could only
       paint inside that: the state tint stopped 14px short of a card whose own band
       hairline runs edge to edge, and a fill that stops short doesn't read as a row,
       it reads as a block floating inside the table. (Measured on a 430pt phone: band
       rule 4px inside the wall, row tint 14px. That gap is the whole complaint.)

       So the gutter moves off the container and onto the row — bleed out by exactly
       --pad-card each side and let the cell pay the gutter itself. `width: auto` is
       load-bearing: the row is `width: 100%` above, and with a fixed width the negative
       margins only SHIFT the box (left edge to the wall, right edge 28px short of it)
       instead of stretching it.

       …and the space that frees up is RECLAIMED rather than handed back as padding.
       The sentence used to start 29px inside the wall (14px body gutter + 15px cell)
       and clear a 52px chevron lane on the right, on a row 347px wide. It starts at ONE
       gutter now — the same x as the search box directly above it — and the chevron
       sits one gutter off the wall instead of 27px in. That is ~29px of width back on
       every row, which on this screen is the difference between "Contract questi…" and
       the whole category. Removing the chrome and then re-paying it as padding would
       have been redecorating; the point of the conversion is the width.

       PHONE ONLY — and this time for a reason that isn't caution. Desktop is a bounded
       table now (see THE REQUEST QUEUE IS A TABLE below): the table draws its own box,
       and a row's fill runs to THAT box's edge, exactly as the directory's does. A row
       bleeding to the card wall underneath a table that has its own border would put
       the fill outside the box that contains it.

       Scoped to the LANDING by id, not to .req-row. The state triage list builds the
       same .req-row / .rq-sentence markup in its own table (stateReqRow), inside a
       screen with different plumbing and a Resolve button sitting where this chevron
       goes — so a bare `.req-row::after` would quietly move furniture on a page nobody
       asked about. The bleed belongs to this table, so it says this table. */
    @media (max-width: 760px) {
    #req-body > .req-row { width: auto;
      margin-left: calc(-1 * var(--pad-card));
      margin-right: calc(-1 * var(--pad-card)); }
    #req-table td.rq-sentence { padding-left: var(--pad-card); padding-right: 38px; }
    /* The chevron is chrome at the row's right edge, so it rides one gutter off the
       wall like everything else — not a gutter PLUS 13px, which is where a row that had
       already paid the gutter used to leave it. 38px of cell padding is what clears it. */
    #req-body > .req-row::after { right: var(--pad-card); }
    /* THE SCROLLER HAS TO BLEED TOO — the bit that makes the row's bleed actually
       PAINT. A negative margin moves a box; it does not exempt it from an ancestor's
       clip. .page-scroll is `overflow-y: auto`, which per spec makes the other axis
       clip as well, and its box starts one gutter inside the card on the LEFT — so the
       row bled out to the wall and was then cut off at the gutter, painting from
       exactly where the text starts. That is why this read as "the fill is indented"
       for three rounds while getBoundingClientRect insisted it was flush: a rect is
       the box you asked for, not the pixels that survive the clip. elementFromPoint is
       what settles it — at 2px inside the card wall it returned .page-frame's white,
       not the row's tint.

       The RIGHT side never had the bug because .page-scroll already bleeds right (it
       parks the scrollbar on the wall). This gives the left the mirror image, so the
       scroller's padding box spans wall to wall and there is nothing left to clip.
       Content does not move: the padding puts the content box back exactly where it
       was. */
    #req-table-wrap { margin-left: calc(-1 * var(--pad-card)); padding-left: var(--pad-card);
      scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
    }

    /* ══ THE REQUEST QUEUE IS A TABLE (desktop) ═════════════════════════════
       Same conversion, same standard, same rules as THE DIRECTORY IS A TABLE — a work
       queue is a COLUMN OF CASES and the eye reads DOWN it. The landing rendered one
       data sentence per row, which is the right shape for a 375px phone and the wrong
       one for a 1500px desk: to compare two requests you had to read two sentences,
       because the assignee sat at a different x on every line depending on how long the
       name before it was. Every fact is a column with a name now.

       The row markup carries BOTH shapes (see reqRow) and each breakpoint shows one, so
       there is no resize handler and no second renderer — the directory's arrangement.

       ONE grid definition, --list-cols, shared by the cap and every row, so a column
       cannot be wide in the header and narrow in the body. The two fixed tracks are the
       ones with a known longest value (a date, a status word); the rest are
       proportional. */
    @media (min-width: 761px) {
      #section-requests .page-stacked {
        --req-cols: minmax(0, 1.15fr) minmax(0, 1.9fr) minmax(0, 1.1fr) minmax(0, 1.1fr)
                    calc(104px * var(--fs-scale, 1)) calc(104px * var(--fs-scale, 1))
                    calc(92px * var(--fs-scale, 1));
        --list-cols: var(--req-cols); }
      /* The phone's sentence cell and the chevron are the phone's; the cap is the
         desktop's. (The chevron was a whole-row affordance — in a table the row's
         hover and cursor say the same thing without spending a column on it.) */
      #req-table td.rq-sentence, #req-body > .req-row::after { display: none; }
      #req-head { display: block; }
      /* Selector matches the phone default's specificity — `#req-body > tr > td.rq-c-desk`
         is 1 ID + 2 elements + 1 class, and a bare `.rq-c-desk` (1 class) loses to it
         even inside this media query, so the columns stayed hidden and every row rendered
         as an empty white bar under a visible cap. */
      #req-body > tr > td.rq-c-desk { display: block; }
      /* Padding-right is the scrollbar gutter .page-scroll bleeds out with a matching
         negative margin — written long-hand, because a padding SHORTHAND would zero it
         and drop the scrollbar back on top of the table. */
      #req-table-wrap { padding: 0 22px 14px 0; margin-left: 0; padding-left: 0; }
      /* The table is the box. No overflow:hidden — that would make the table its own
         scrollport and the sticky cap would stop sticking. A floor under the columns so
         seven tracks can't divide down to nothing on a narrow desktop window; below it
         the table scrolls sideways inside the card, which is what a table does. */
      #req-table { display: block; min-width: calc(980px * var(--fs-scale, 1));
        border: 1px solid var(--border); border-radius: 10px; background: var(--card); overflow: visible; }
      /* Sticky lives on the THEAD, not on its row: a sticky box travels only inside its
         parent, and a row's parent is the 30px thead — which scrolls away immediately. */
      #req-head { position: sticky; top: 0; z-index: 2; }
      #req-body { display: block; }
      #req-head > tr, #req-body > .req-row {
        display: grid; grid-template-columns: var(--list-cols); gap: 0 14px; align-items: center; }
      #req-head > tr { min-height: calc(30px * var(--fs-scale, 1)); padding: 0 12px;
        background: var(--band); border-bottom: 1px solid var(--band-rail); border-radius: 10px 10px 0 0; }
      #req-head th { padding: 0; background: none; border: 0; text-align: left;
        font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .1em;
        color: var(--band-ink); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      /* Every cell is ONE line: a request with a long title must not stand taller than
         one with a short title, or the lane the eye is following bends. */
      #req-body > .req-row { min-height: calc(46px * var(--fs-scale, 1)); padding: 5px 12px;
        border-top: 1px solid var(--border); border-bottom: 0; }
      #req-body > .req-row:first-child { border-top: 0; }
      #req-table td.rq-c-desk { padding: 0; border: 0; min-width: 0;
        font-size: var(--fs-md); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
      /* Reset the legacy `.rq-c-title { width: 40%; max-width: 1px }` (line ~8232) —
         that was a `table-layout: fixed` trick to force ellipsis inside a real <table>,
         and in this grid layout `max-width: 1px` literally clips the title cell to 1px.
         The phone rule at line ~9096 resets the same pair; the desktop needs it too. */
      #req-body > .req-row .rq-c-title { font-weight: var(--fw-semi); max-width: none; width: auto; }
      /* State is a WORD in its own column now, so the row keeps no fill of its own —
         the same reason the directory tints no rows. Two signals for one fact is what
         made the orange read as noise. */
      #req-body > .req-row.rq-st-new, #req-body > .req-row.mine { background: var(--card); box-shadow: none; }
      .rq-state { font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase;
        letter-spacing: .04em; padding: 2px 8px; border-radius: 999px; white-space: nowrap; }
      .rq-state-new  { background: var(--accent-light); color: var(--accent-dark); }
      .rq-state-prog { background: var(--band); color: var(--band-ink); }
      .rq-state-done { background: var(--ok-bg, var(--band)); color: var(--ok, var(--band-ink)); }
      /* The empty state is not a row of columns — it spans the whole grid. */
      #req-body > .rq-empty { display: block; padding: 0; border: 0; }
    }

    /* Soft "needed by" date (requests #18) — a quiet chip, never a red alarm.
       It rides INLINE in the sentence row (its own grid track between the category
       and the date), so a row with a needed-by date is exactly as tall as one
       without it — every card in the list is one consistent height. On a phone
       there isn't room beside the category, so there it drops to its own line.
       Future = brand tint; once the day passes = a calm amber. */
    /* It lives in the meta row now, in the slot "updated …" would have used — so it is
       always end-aligned on one line and never adds height to the card. */
    .rq-due { justify-self: end; align-self: baseline; display: inline-flex; align-items: center; gap: 4px;
      margin-top: 0; font-size: var(--fs-xs); font-weight: var(--fw-medium); padding: 1px 9px 1px 7px; border-radius: 999px;
      background: var(--accent-light); color: var(--accent-dark); white-space: nowrap; }
    .rq-due svg { flex: none; opacity: .85; }
    .rq-due.past { background: var(--warn-bg); color: var(--warn); }
    /* (The phone rule that dropped it to its own row is gone with the reason for it.) */
    /* Ticket detail "Needed by" row — an inline date input for actors, text for others. */
    /* "Needed by" rides the right column in line with Status (see kv-r) and stays
       inside the card — the minmax(0,…) tracks let it shrink instead of spilling. */
    .kv-due .kv-v { display: inline-flex; align-items: center; min-width: 0; justify-content: flex-end; }
    .td-due.past { color: var(--warn); font-weight: var(--fw-semi); }
    /* Request Types panel: the opt-in toggle sits below the list, divided off. */
    .rtypes-duedates { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }

    /* Roster import wizard (#4) */
    .um-import-btn { flex: none; }
    #roster-import-view .ri-drop { display: flex; gap: 10px; flex-wrap: wrap; align-items: center; }
    .ri-table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 8px; }
    .ri-preview-wrap { max-height: 340px; overflow-y: auto; }
    .ri-table { width: 100%; border-collapse: collapse; font-size: var(--fs-md); }
    .ri-table th { text-align: left; font-size: var(--fs-xs); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: 8px 10px; border-bottom: 1px solid var(--border); background: var(--bg); position: sticky; top: 0; }
    .ri-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); vertical-align: middle; }
    .ri-table tbody tr:last-child td { border-bottom: none; }
    .ri-col-h { font-weight: var(--fw-semi); white-space: nowrap; }
    .ri-map { padding: 5px 8px; border: 1px solid var(--border); border-radius: 6px; font-family: inherit; font-size: var(--fs-md); background: #fff; color: var(--text); }
    .ri-samp { display: inline-block; background: var(--bg); border-radius: 4px; padding: 1px 6px; margin: 0 3px 3px 0; color: var(--muted); font-size: var(--fs-sm); }
    .ri-empty { color: var(--border); }
    .ri-collide { color: var(--danger); font-size: var(--fs-xs); font-weight: var(--fw-semi); }
    .ri-req { display: flex; gap: 16px; margin: 12px 0; font-size: var(--fs-md); }
    /* "First row is column headers" — an answer the importer states rather than assumes. */
    .ri-hdr-row { display: flex; align-items: center; gap: 8px; margin: 0 0 10px; font-size: var(--fs-md); cursor: pointer; }
    .ri-hdr-note { color: var(--muted); }
    .ri-chk.ok { color: var(--ok); font-weight: var(--fw-semi); }
    .ri-chk.no { color: var(--muted); }
    /* .ri-actions is now only for actions INSIDE a step's content (the Done screen's
       invite pair). Moving between steps is the pinned footer's job — see .ri-foot. */
    .ri-actions { display: flex; gap: 10px; justify-content: flex-end; margin-top: 14px; flex-wrap: wrap; }
    /* The wizard footer, on the standard .page-foot geometry: Back anchors LEFT, the
       step's primary stays RIGHT — the app's left-negative / right-positive convention,
       where "back" is the negative move. The requirement chips ride in the footer's
       status slot, beside the button they gate, instead of above the content that
       explains them: a disabled Continue and the reason it's disabled are one thought. */
    .page-foot.ri-foot > .ri-back { margin-right: auto; }
    .page-foot.ri-foot .ri-req { margin: 0; }
    .ri-summary { display: flex; gap: 10px; margin: 8px 0 10px; flex-wrap: wrap; }
    .ri-badge { padding: 3px 12px; border-radius: 999px; font-size: var(--fs-md); font-weight: var(--fw-semi); }
    .ri-badge.create { background: var(--accent-light); color: var(--accent-dark); }
    .ri-badge.update { background: var(--neutral-bg); color: var(--neutral); }
    .ri-badge.skip { background: var(--bg); color: var(--muted); }
    .ri-warn-note { color: var(--warn); font-size: var(--fs-md); margin: 4px 0; }
    .ri-act { display: inline-block; font-size: var(--fs-2xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .03em; padding: 1px 7px; border-radius: 4px; }
    .ri-act.create { background: var(--accent-light); color: var(--accent-dark); }
    .ri-act.update { background: var(--neutral-bg); color: var(--neutral); }
    .ri-act.skip { background: var(--bg); color: var(--muted); }
    .ri-r-skip { opacity: .6; }
    .ri-notes { color: var(--muted); font-size: var(--fs-sm); }
    .ri-result-big { font-size: var(--fs-2xl); font-weight: var(--fw-bold); margin: 6px 0 2px; color: var(--ok); }
    .ri-invite-card { border: 1px solid var(--border); border-radius: 10px; padding: 14px; margin: 16px 0; background: var(--bg); }
    .ri-invite-hd { font-weight: var(--fw-bold); margin-bottom: 2px; }
    .wiz-steps { display: flex; align-items: center; gap: 10px; margin: 0 0 22px; flex-wrap: wrap; }
    .wiz-step { display: flex; align-items: center; gap: 7px; color: var(--muted); font-size: var(--fs-sm); font-weight: var(--fw-semi); }
    .wiz-step-dot { width: 22px; height: 22px; border-radius: 50%; display: grid; place-items: center; font-size: var(--fs-sm); font-weight: var(--fw-bold); background: var(--bg); color: var(--muted); border: 1px solid var(--border); flex: none; }
    .wiz-step.done .wiz-step-dot { background: var(--accent-light); color: var(--accent-dark); border-color: transparent; }
    .wiz-step.active .wiz-step-dot { background: var(--accent); color: var(--on-accent); border-color: transparent; }
    .wiz-step.active { color: var(--text); }

    /* 8 — reduced motion: keep colour/elevation cues, drop the movement */
    @media (prefers-reduced-motion: reduce) {
      .btn:active, .ql-btn:active, .quick-card:active, .big-btn:active,
      .submit-hero:active, .req-row:active, .mtab:active .mt-ico { transform: none; }
      .ql-btn:active::after, .quick-card:active::after,
      .submit-hero:active::after, .req-row:active::after { transform: translateY(-50%); } }

    /* ── Shared icon picker (Class subjects · page builder · email composer) ── */
    .ip-overlay { position: fixed; inset: 0; z-index: 1200; background: rgba(17,24,39,.5); display: flex; align-items: center; justify-content: center; padding: 16px; }
    .ip-overlay.hidden { display: none; }
    .ip-dialog { background: var(--card,#fff); border-radius: 16px; width: 100%; max-width: 460px; max-height: 86vh; display: flex; flex-direction: column; box-shadow: 0 20px 60px rgba(0,0,0,.3); overflow: hidden; }
    .ip-head { display: flex; align-items: center; justify-content: space-between; padding: 14px 16px; border-bottom: 1px solid var(--border); }
    .ip-title { font-weight: var(--fw-bold); font-size: var(--fs-xl); color: var(--text); }
    .ip-x { background: none; border: none; font-size: var(--fs-xl); cursor: pointer; color: var(--muted); width: 30px; height: 30px; border-radius: 8px; }
    .ip-x:hover { background: var(--accent-light); color: var(--accent); }
    .ip-search { margin: 12px 16px 4px; padding: 9px 12px; border: 1px solid var(--border); border-radius: 9px; font-size: var(--fs-lg); font-family: inherit; background: #fff; color: var(--text); }
    .ip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(44px,1fr)); gap: 6px; padding: 10px 16px; overflow-y: auto; flex: 1; }
    .ip-opt { display: grid; place-items: center; aspect-ratio: 1; border: 1px solid var(--border); border-radius: 10px; background: #fff; cursor: pointer; padding: 0; transition: border-color .12s, box-shadow .12s, background .12s; }
    .ip-opt:hover { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
    .ip-opt.on { border-color: var(--accent); background: var(--accent-light); box-shadow: 0 0 0 2px var(--accent); }
    .ip-opt-svg { width: 22px; height: 22px; }
    .ip-empty { grid-column: 1/-1; text-align: center; color: var(--muted); font-size: var(--fs-md); padding: 24px 0; }
    .ip-cat { grid-column: 1/-1; font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); padding: 10px 2px 2px; border-top: 1px solid var(--border); margin-top: 4px; }
    .ip-cat:first-child { border-top: none; margin-top: 0; padding-top: 2px; }

    /* "A new version is available" banner — shown when a deploy lands while a
       client is open (see liveReloadInit). A small fixed toast, not the shell. */
    .update-banner { position: fixed; left: 50%; transform: translateX(-50%); bottom: 22px; z-index: 4000;
      display: flex; align-items: center; gap: 12px; max-width: calc(100vw - 28px);
      background: var(--text); color: #fff; padding: 9px 11px 9px 16px; border-radius: 12px;
      box-shadow: 0 12px 32px rgba(16,24,40,.30); font-size: var(--fs-md); animation: ub-fade .18s ease; }
    .update-banner .ub-tx { font-weight: var(--fw-semi); white-space: nowrap; }
    .update-banner .ub-btn { background: var(--accent); color: var(--on-accent); border: none; border-radius: 8px; padding: 7px 14px; font-size: var(--fs-md); font-weight: var(--fw-bold); font-family: inherit; cursor: pointer; flex: none; }
    .update-banner .ub-btn:hover { background: var(--accent-hover); }
    .update-banner .ub-x { background: none; border: none; color: rgba(255,255,255,.7); font-size: var(--fs-2xl); line-height: var(--lh-none); cursor: pointer; padding: 0 2px; flex: none; }
    .update-banner .ub-x:hover { color: #fff; }
    @keyframes ub-fade { from { opacity: 0; } to { opacity: 1; } }
    /* Mobile: full-width above the bottom tab bar + safe area, not centered. */
    @media (max-width: 760px) {
      .update-banner { left: 14px; right: 14px; transform: none; max-width: none; bottom: calc(env(safe-area-inset-bottom, 0px) + 72px); }
      /* Full-width on mobile: let the text grow so Reload + X sit flush at the
         right edge instead of leaving a gap after the X. */
      .update-banner .ub-tx { white-space: normal; flex: 1; }
    }
    .ip-colors { padding: 4px 16px 8px; border-top: 1px solid var(--border); }
    .ip-color-row { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
    .ip-color-lbl { font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--muted); width: 46px; flex: none; }
    .ip-color-sws { display: flex; flex-wrap: wrap; gap: 6px; }
    .ip-foot { display: flex; justify-content: flex-end; gap: 8px; padding: 12px 16px; border-top: 1px solid var(--border); }

    /* ── Site content pages (rendered cms-page views) ── */
    .cms-hero { border-radius: 14px; padding: 48px 28px; text-align: center; background: var(--accent-light); color: var(--text); }
    .cms-hero.has-img { color: #fff; background-size: cover; background-position: center; }
    .cms-hero-title { font-size: var(--fs-4xl); font-weight: var(--fw-display); margin: 0 0 10px; line-height: var(--lh-tight); }
    .cms-hero-sub { font-size: var(--fs-xl); opacity: .95; margin: 0 auto; max-width: 640px; line-height: var(--lh-body); }
    .cms-hero-cta { display: inline-block; margin-top: 20px; background: var(--accent); color: var(--on-accent); text-decoration: none; padding: 11px 24px; border-radius: 10px; font-weight: var(--fw-bold); font-size: var(--fs-xl); transition: filter .15s; }
    .cms-hero-cta:hover { filter: brightness(.92); }
    .cms-hero.has-img .cms-hero-cta { background: #fff; color: var(--accent); }
    .cms-hero.has-img .cms-hero-cta:hover { filter: brightness(.95); }
    .cms-body { font-size: var(--fs-xl); line-height: var(--lh-loose); color: var(--text); max-width: 760px; }
    .cms-body p { margin: 0 0 12px; } .cms-body a { color: var(--accent); }
    /* Rich-text lists: the global `* { padding:0 }` reset would clip bullets/numbers,
       so re-indent lists in every rich-text block (text, callout, image+text, hero
       body, intros, FAQ answers, timeline, features). */
    .cms-body ul, .cms-body ol, .cms-intro ul, .cms-intro ol, .cms-faq-a ul, .cms-faq-a ol,
    .cms-tl-tx ul, .cms-tl-tx ol, .cms-feat-tx ul, .cms-feat-tx ol { margin: 0 0 12px; padding-left: 1.45em; }
    .cms-body li, .cms-intro li, .cms-faq-a li, .cms-tl-tx li, .cms-feat-tx li { margin-bottom: 4px; }
    .cms-body ul ul, .cms-body ol ol, .cms-body ul ol, .cms-body ol ul { margin: 4px 0 0; }
    .cms-intro { font-size: var(--fs-xl); line-height: var(--lh-loose); color: var(--muted); max-width: 760px; margin: -2px 0 18px; }
    .cms-faq { display: flex; flex-direction: column; gap: 10px; max-width: 760px; }
    .cms-faq-item { border: 1px solid var(--border); border-radius: 14px; background: var(--card,#fff); overflow: hidden; transition: border-color .15s, box-shadow .15s; }
    .cms-faq-item:hover { border-color: rgba(var(--accent-rgb),.35); }
    .cms-faq-item > summary { cursor: pointer; padding: 14px 18px; font-weight: var(--fw-semi); font-size: var(--fs-xl); color: var(--text); list-style: none; display: flex; justify-content: space-between; align-items: center; gap: 12px; }
    .cms-faq-item > summary::-webkit-details-marker { display: none; }
    .cms-faq-item > summary::after { content: '+'; color: var(--accent); font-size: var(--fs-2xl); font-weight: var(--fw-body); line-height: var(--lh-none); transition: transform .2s ease; }
    .cms-faq-item[open] > summary::after { content: '\2013'; transform: rotate(90deg); }
    .cms-faq-a { padding: 0 18px 16px; font-size: var(--fs-lg); line-height: var(--lh-loose); color: var(--muted); }
    .cms-faq-a a { color: var(--accent); }
    .cms-cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px,1fr)); gap: 16px; }
    .cms-card { border: 1px solid var(--border); border-radius: 14px; background: var(--card,#fff); padding: 20px; text-align: center; transition: border-color .15s, box-shadow .15s; }
    .cms-card:hover { border-color: rgba(var(--accent-rgb),.3); box-shadow: 0 4px 16px rgba(0,0,0,.06); }
    .cms-card-photo img { width: 84px; height: 84px; border-radius: 50%; object-fit: cover; }
    .cms-card-ic { width: 64px; height: 64px; border-radius: 50%; background: var(--accent-light); display: grid; place-items: center; margin: 0 auto; }
    .cms-card-ic-svg { width: 32px; height: 32px; }
    .cms-card-name { font-weight: var(--fw-bold); font-size: var(--fs-xl); color: var(--text); margin-top: 12px; }
    .cms-card-role { font-size: var(--fs-md); color: var(--muted); margin-top: 2px; }
    .cms-card-link { display: block; font-size: var(--fs-md); color: var(--accent); text-decoration: none; margin-top: 6px; word-break: break-word; }
    .cms-resgrp { margin-bottom: 22px; max-width: 760px; }
    .cms-resgrp-h { font-size: var(--fs-xl); font-weight: var(--fw-bold); color: var(--text); margin: 0 0 10px; }
    .cms-reslist { display: flex; flex-direction: column; gap: 8px; }
    .cms-res { display: flex; align-items: center; gap: 14px; padding: 14px 16px; border: 1px solid var(--border); border-radius: 14px; background: var(--card,#fff); text-decoration: none; color: var(--text); transition: border-color .15s, box-shadow .15s, transform .15s; }
    .cms-res:hover { border-color: var(--accent); box-shadow: 0 4px 14px rgba(0,0,0,.06); transform: translateY(-1px); }
    .cms-res-ic { width: 40px; height: 40px; flex: none; border-radius: 10px; background: linear-gradient(180deg, rgba(var(--accent-rgb),.16), rgba(var(--accent-rgb),.07)); display: grid; place-items: center; color: var(--accent); }
    .cms-res-ic .lic, .cms-res-ic-svg { width: 20px; height: 20px; }
    .cms-res-tx { flex: 1; display: flex; flex-direction: column; min-width: 0; }
    .cms-res-lbl { font-weight: var(--fw-semi); font-size: var(--fs-lg); }
    .cms-res-desc { font-size: var(--fs-sm); color: var(--muted); margin-top: 2px; }
    .cms-res-arrow { color: var(--muted); font-size: var(--fs-2xl); flex: none; }
    .cms-timeline { list-style: none; margin: 0; padding: 0; position: relative; max-width: 760px; }
    .cms-timeline::before { content: ''; position: absolute; left: 19px; top: 6px; bottom: 6px; width: 2px; background: var(--border); }
    .cms-tl-item { position: relative; padding: 0 0 22px 56px; }
    .cms-tl-marker { position: absolute; left: 0; top: 0; min-width: 40px; height: 40px; border-radius: 50%; background: var(--accent); color: var(--on-accent); display: grid; place-items: center; font-weight: var(--fw-bold); font-size: var(--fs-sm); padding: 0 4px; z-index: 1; box-shadow: 0 0 0 4px rgba(var(--accent-rgb),.15); }
    .cms-tl-h { font-weight: var(--fw-bold); font-size: var(--fs-xl); color: var(--text); padding-top: 9px; }
    .cms-tl-tx { font-size: var(--fs-lg); line-height: var(--lh-loose); color: var(--muted); margin-top: 4px; }
    .cms-tl-tx a { color: var(--accent); }
    .cms-features { display: grid; gap: 18px; }
    .cms-features.cols-3 { grid-template-columns: repeat(3,1fr); }
    .cms-features.cols-2 { grid-template-columns: repeat(2,1fr); }
    .cms-feat { border: 1px solid var(--border); border-radius: 14px; background: var(--card,#fff); padding: 22px; transition: border-color .15s, box-shadow .15s, transform .15s; }
    .cms-feat:hover { border-color: rgba(var(--accent-rgb),.3); box-shadow: 0 4px 16px rgba(0,0,0,.06); transform: translateY(-2px); }
    .cms-feat-ic { width: 48px; height: 48px; border-radius: 12px; background: linear-gradient(180deg, rgba(var(--accent-rgb),.16), rgba(var(--accent-rgb),.07)); display: grid; place-items: center; margin-bottom: 12px; color: var(--accent); }
    .cms-feat-ic-svg { width: 26px; height: 26px; }
    .cms-feat-h { font-weight: var(--fw-bold); font-size: var(--fs-xl); color: var(--text); }
    .cms-feat-tx { font-size: var(--fs-lg); line-height: var(--lh-loose); color: var(--muted); margin-top: 6px; }
    .cms-feat-tx a { color: var(--accent); }
    .cms-empty { color: var(--muted); font-size: var(--fs-lg); }
    @media (max-width: 640px) {
      .cms-features.cols-3, .cms-features.cols-2 { grid-template-columns: 1fr; }
      .cms-cards { grid-template-columns: 1fr 1fr; }
      .cms-hero { padding: 36px 20px; } .cms-hero-title { font-size: var(--fs-3xl); } .cms-hero-sub { font-size: var(--fs-xl); }
    }
    @media (max-width: 420px) { .cms-cards { grid-template-columns: 1fr; } }

    /* ── Pages admin editor (Site Config → Pages) ── */
    /* Rows are margin-spaced (not flex-gap) so the shared delete animation's
       collapse — which zeroes margins — closes the gap cleanly as siblings slide up. */
    /* (.pg-list / .pg-row's card chrome lived here — a bordered 12px-radius card with a
       10px gap and a lift-on-hover shadow, plus a 36px gradient-filled icon tile. Content
       Pages is a .list-row list now: click the row to edit, exactly as Buildings and Members
       work, with "open the live page" and delete as icon buttons in the shared end slot.) */
    .pg-row { padding-top: 10px; padding-bottom: 10px; }
    /* The page-kind glyph moved out of the badge into a leading tile — same icon
       column the Menus & Buttons list uses, so both tabs of this hub scan alike. */
    .pg-row-ico { width: 36px; height: 36px; flex: none; border-radius: 9px; display: grid; place-items: center; color: var(--accent); background: linear-gradient(180deg, rgba(var(--accent-rgb),.16), rgba(var(--accent-rgb),.07)); box-shadow: inset 0 0 0 1px rgba(var(--accent-rgb),.16); }
    .pg-row-ico svg, .pg-row-ico .lic { width: 20px; height: 20px; display: block; }
    .pg-list .pg-row:last-child { margin-bottom: 0; }
    .pg-row-main { flex: 1; min-width: 0; }
    .pg-row-title { font-weight: var(--fw-semi); font-size: var(--fs-lg); color: var(--text); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
    .pg-row-meta { font-size: var(--fs-sm); color: var(--muted); margin-top: 3px; }
    /* ── The Pages list is a TABLE, because the numbers on it are comparable ─────
       Readership used to run as one grey sentence per row — "Menu id: about-sea ·
       2 opens this week · 1 member · 100% read to the end" — which is true, and
       unreadable: telling two pages apart meant reading two paragraphs and holding
       four figures in your head. Right-aligned columns line the digits up down the
       list, which is the whole reason a table beats a sentence. */
    /* ── The three readership columns, DRAWN ──────────────────────────────────
       A number alone answers "how many" and nothing else: "31 opens" cannot say
       whether a page is finding its audience or losing it, and "29%" reads as a
       statistic rather than as most-of-them-left. So each column carries the shape
       that answers its own question — a trend line, a face, a filled ring — and the
       figure sits beside it. Everything here is drawn from data the row already has.

       They END well clear of the action buttons: the numbers were running right up
       against the Readers/open/delete cluster, so the eye read them as part of it. */
    .pg-col { display: flex; align-items: center; justify-content: flex-end; gap: 8px;
      font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); font-variant-numeric: tabular-nums; }
    .pg-col b { font-weight: var(--fw-bold); }
    /* (No trailing padding here — the flexible gap TRACK does that job now, and did it
       properly: padding could only ever buy a fixed 18px against a column that was
       already pinned to the wrong edge.) */
    /* An em-dash, not a zero. "0 opens" and "nothing has been counted yet" are different
       claims, and a column of zeros reads as failure where nothing has happened. */
    .pg-col-none { font-style: normal; color: var(--muted); font-weight: var(--fw-body); }
    /* One word, right-aligned over its figures. What each one actually counts is a
       tooltip rather than a second line — the distinction between "opened 84 times"
       and "84 different people" matters, but not enough to spend a row of chrome on
       every time the screen is opened. */
    .pg-th { text-align: right; cursor: help; }

    /* Eight weeks of opens. Green rising, muted falling — the DIRECTION is the fact
       worth colouring; the magnitude is already the number beside it. */
    .pg-spark { flex: none; width: calc(52px * var(--fs-scale, 1)); height: 16px; color: var(--neutral); opacity: .9; }
    .pg-spark.up { color: var(--ok); }
    .pg-spark.down { color: var(--warn); }
    .pg-people { flex: none; display: inline-flex; color: var(--muted); }
    .pg-people .lic { width: 15px; height: 15px; }

    /* A gauge, then the figure. The ring shows the part AND the whole, which a bare
       percentage does not; the number beside it stays legible, which a number crammed
       into a table-sized ring does not. Drawn with a conic-gradient masked by a centre
       disc — no SVG, no library. */
    .pg-gauge { display: inline-flex; align-items: center; gap: 7px; --ring-track: var(--border); }
    .pg-gauge > b { color: var(--ring-c); font-weight: var(--fw-bold); }
    .pg-ring { flex: none; display: grid; place-items: center; width: 20px; height: 20px; border-radius: 50%;
      background: conic-gradient(var(--ring-c) calc(var(--p) * 1%), var(--ring-track) 0); }
    .pg-ring::after { content: ""; grid-area: 1 / 1; width: 12px; height: 12px; border-radius: 50%; background: var(--card); }
    .pg-gauge-good { --ring-c: var(--ok); }
    .pg-gauge-warn { --ring-c: var(--warn); }
    .pg-gauge-low  { --ring-c: var(--neutral); }
    /* The row's hover fill would show through the ring's hole, so the disc follows it. */
    .list-row:hover .pg-ring::after { background: var(--accent-light); }
    .pg-badge { font-size: var(--fs-xs); font-weight: var(--fw-semi); text-transform: capitalize; color: var(--accent); background: var(--accent-light); border-radius: 20px; padding: 3px 9px; }
    .pg-badge .lic { width: 13px; height: 13px; vertical-align: -2px; margin-right: 1px; }
    .pg-row-actions { display: flex; gap: 6px; flex: none; }
    /* ── Page template chooser ──────────────────────────────────────────────
       A gallery of LAYOUTS, so every card leads with a wireframe of the stack it
       builds (see pgWireHTML) and only then names it. Eight templates on a FIXED
       4-column grid — two even rows of four, never a 5+3 orphan row — and each
       card reads sideways: wireframe well on the left, words on the right. The
       sideways card is what buys the fit; stacked, eight cards ran ~700px tall
       and the whole chooser scrolled. Equal-height cards on one grid: the
       thumbnail stretches to the card, the body flexes, and the "N blocks" line
       is pushed to the bottom so it lines up across the row however long the
       descriptions run. */
    /* The gallery is NOT a table, so it doesn't get the table's box. The column-label
       strip goes `display:none` when it's empty, but `.list-head + *` is adjacency —
       it doesn't care — so the scroller kept its border, radius and card fill and drew
       a lidless box around the cards: a hairline down the left of the gallery with no
       header on top of it to explain itself. */
    .page-scroll:has(.pg-chooser-wrap) { border: none; border-radius: 0; background: transparent; }
    .pg-chooser-wrap { container-type: inline-size; }
    /* grid-auto-rows: 1fr — both rows the same height. Without it each row sizes to
       its own tallest card and the two rows sit at different heights, which reads as
       a mistake in a gallery of eight equal choices. */
    /* THE TOP ROW NEEDS HEADROOM. .pg-choice:hover lifts the card 2px and lays a
       22px glow around it, and :focus-visible draws a 3px ring — all of which the
       scroller's top edge cut clean off the first row, so hovering a card in row 1
       looked like a rendering fault while row 2 lit up correctly. A scroll container
       clips at its padding box and nothing above the scroll origin is reachable, so
       this has to be real padding: negative margins only push the cards INTO the
       clip. The bar gives back what the padding takes (see below) so the gap under
       the divider still measures the standard 14px — the gallery sits exactly where
       the list's first row does. */
    .pg-chooser { display: grid; grid-template-columns: repeat(4, 1fr); grid-auto-rows: 1fr; gap: 12px;
                  align-content: start; padding-top: 8px; padding-bottom: 8px; }
    .page-body:has(.pg-chooser-wrap) .page-bar { margin-bottom: 6px; }
    .pg-choice { display: flex; flex-direction: row; align-items: stretch; border: 1px solid var(--border); border-radius: 14px; background: var(--card,#fff);
                 padding: 0; overflow: hidden; text-align: left; cursor: pointer; font-family: inherit; color: inherit;
                 transition: border-color .14s, box-shadow .14s, transform .14s; }
    .pg-choice:hover { border-color: var(--accent); box-shadow: 0 8px 22px rgba(var(--accent-rgb),.14); transform: translateY(-2px); }
    .pg-choice:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
    /* Narrow on purpose. A quarter of this panel is ~280px, and the words are what
       the reader is choosing between — the wireframe only has to say "hero, then
       three cards, then a button", which it does at 66px across. */
    /* The wireframe is ABSOLUTE inside the well so a tall stack can't set the card's
       height. Sideways, the thumb is a stretched flex item: left in flow, a 5-block
       template made its card 30px taller than its neighbours and the row ragged —
       the well is meant to CLIP a long stack, not grow for it. */
    .pg-choice-thumb { position: relative; display: flex; flex: none;
                       width: calc(88px * var(--fs-scale, 1)); min-height: calc(88px * var(--fs-scale, 1));
                       padding: 0; overflow: hidden;
                       background: rgba(127,127,127,.06); border-right: 1px solid var(--border); }
    .pg-choice-thumb .pgw { position: absolute; inset: 10px; }
    .pg-choice:hover .pg-choice-thumb { background: rgba(var(--accent-rgb),.05); }
    .pg-choice-body { display: flex; flex-direction: column; flex: 1 1 auto; min-width: 0; gap: 3px; padding: 12px 14px 12px; }
    .pg-choice-name { display: block; font-weight: var(--fw-bold); font-size: var(--fs-lg); color: var(--text); }
    /* Clamped, not clipped: the grid sizes a row to its tallest card, so one long
       description used to run off the bottom of every OTHER card in its row and
       take the block count with it. Five lines is the sideways column's version of
       the old three — narrower column, same sentences, so every one of the eight
       still reads in full (the longest, Survey, needs exactly five). */
    .pg-choice-desc { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 5; overflow: hidden;
                      font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); }
    .pg-choice-meta { display: block; margin-top: auto; padding-top: 8px; font-size: var(--fs-xs); font-weight: var(--fw-semi);
                      letter-spacing: .03em; text-transform: uppercase; color: var(--muted); opacity: .8; }

    /* The wireframe itself. Every shape is a grey bar except the two things a
       reader scans a layout for — the hero band and the buttons — which take the
       accent, so the eye lands on where the page starts and where it asks. The
       mask fades a stack that runs past the well instead of hard-cutting it. */
    .pgw { display: flex; flex-direction: column; gap: 6px; flex: 1 1 auto; min-width: 0; }
    .pgw-clip { -webkit-mask-image: linear-gradient(#000 72%, transparent 100%); mask-image: linear-gradient(#000 72%, transparent 100%); }
    .pgw > * { flex: none; }
    .pgw i, .pgw b { display: block; height: 3px; border-radius: 2px; background: rgba(127,127,127,.36); }
    .pgw .pgw-short { width: 58%; }
    .pgw-text i, .pgw-quote i { margin-bottom: 4px; }
    .pgw-text i:last-child, .pgw-quote i:last-child { margin-bottom: 0; }
    .pgw-hero { border-radius: 5px; padding: 8px 9px 9px; background: rgba(var(--accent-rgb),.14); }
    .pgw-hero i { height: 6px; width: 66%; border-radius: 3px; background: rgba(var(--accent-rgb),.7); margin-bottom: 6px; }
    .pgw-hero b { margin-bottom: 4px; }
    .pgw-hero b:last-child { margin-bottom: 0; }
    .pgw-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
    .pgw-stats i { height: 17px; border-radius: 4px; background: rgba(127,127,127,.2); }
    .pgw-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
    .pgw-grid i { height: 14px; border-radius: 4px; background: rgba(127,127,127,.2); }
    .pgw-cards { display: grid; grid-template-columns: repeat(3, 1fr); gap: 5px; }
    .pgw-cards i { height: 22px; border-radius: 4px; background: rgba(127,127,127,.2); }
    .pgw-res { border-radius: 4px; padding: 6px 7px; background: rgba(127,127,127,.11); }
    .pgw-res b { width: 44%; height: 4px; background: rgba(127,127,127,.5); margin-bottom: 6px; }
    .pgw-res i { margin-bottom: 4px; }
    .pgw-res i:last-child { width: 72%; margin-bottom: 0; }
    .pgw-faq i { height: 9px; border-radius: 3px; background: rgba(127,127,127,.16); margin-bottom: 4px; }
    .pgw-faq i:last-child { margin-bottom: 0; }
    .pgw-time { padding-left: 12px; border-left: 2px solid rgba(127,127,127,.3); }
    .pgw-time i { width: 78%; margin-bottom: 6px; }
    .pgw-time i:nth-child(2) { width: 62%; }
    .pgw-time i:last-child { width: 70%; margin-bottom: 0; }
    .pgw-quote { padding-left: 9px; border-left: 3px solid rgba(var(--accent-rgb),.4); }
    .pgw-callout { border-radius: 4px; padding: 7px 8px; background: rgba(var(--accent-rgb),.1); border-left: 3px solid rgba(var(--accent-rgb),.45); }
    .pgw-callout i { width: 80%; background: rgba(127,127,127,.42); }
    /* ── The email side of the wireframe vocabulary ────────────────────────────
       The Composer's chooser draws from the same function now, so its starters show
       the shape they build. Everything here is tinted from the tenant's accent, so a
       local's gallery is in its own colours rather than in a stock palette. */
    .pgw-head b { width: 62%; height: 7px; border-radius: 3px; background: rgba(127,127,127,.55); }
    .pgw-cta { display: flex; justify-content: center; }
    .pgw-cta i { width: 48%; height: 12px; border-radius: 6px; background: rgba(var(--accent-rgb),.75); }
    .pgw-qr { width: 30px; height: 30px; margin: 0 auto; border-radius: 3px;
      background: repeating-conic-gradient(rgba(127,127,127,.62) 0% 25%, transparent 0% 50%) 0 0 / 7px 7px; }
    .pgw-code { display: grid; grid-template-columns: repeat(6, 1fr); gap: 3px; }
    .pgw-code i { height: 14px; border-radius: 3px; background: rgba(var(--accent-rgb),.28); }
    .pgw-img { height: 26px; border-radius: 4px; background: rgba(127,127,127,.22); }
    .pgw-gal { display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; }
    .pgw-gal i { height: 18px; border-radius: 3px; background: rgba(127,127,127,.22); }
    .pgw-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 7px; }
    .pgw-cols span { display: block; }
    .pgw-cols i { display: block; height: 4px; border-radius: 2px; background: rgba(127,127,127,.3); margin-bottom: 4px; }
    .pgw-cols i:last-child { width: 70%; margin-bottom: 0; }
    .pgw-social { display: flex; justify-content: center; gap: 6px; }
    .pgw-social i { width: 11px; height: 11px; border-radius: 50%; background: rgba(var(--accent-rgb),.5); }
    /* Bars of different heights — a chart is the one block whose whole point is that
       its values differ, so a row of equal blocks would be the wrong picture. */
    .pgw-chart { display: flex; align-items: flex-end; gap: 4px; height: 26px; }
    .pgw-chart i { flex: 1; border-radius: 2px 2px 0 0; background: rgba(var(--accent-rgb),.55); }
    .pgw-chart i:nth-child(1) { height: 45%; } .pgw-chart i:nth-child(2) { height: 80%; }
    .pgw-chart i:nth-child(3) { height: 60%; } .pgw-chart i:nth-child(4) { height: 100%; }
    .pgw-event { border-radius: 4px; padding: 6px 7px; background: rgba(127,127,127,.1); border-left: 3px solid rgba(var(--accent-rgb),.5); }
    .pgw-event b { width: 50%; height: 5px; background: rgba(127,127,127,.5); margin-bottom: 5px; }
    .pgw-event i { margin-bottom: 3px; }
    .pgw-count { text-align: center; }
    .pgw-count b { display: inline-block; width: 34%; height: 13px; border-radius: 3px; background: rgba(var(--accent-rgb),.6); margin-bottom: 4px; }
    .pgw-count i { margin: 0 auto; }
    .pgw-therm { height: 10px; border-radius: 5px; background: rgba(127,127,127,.2); overflow: hidden; }
    .pgw-therm i { display: block; width: 62%; height: 100%; border-radius: 5px; background: rgba(var(--accent-rgb),.7); }
    .pgw-video { height: 28px; border-radius: 4px; background: rgba(127,127,127,.24); position: relative; }
    .pgw-video::after { content: ""; position: absolute; inset: 0; margin: auto; width: 0; height: 0;
      border-style: solid; border-width: 5px 0 5px 8px; border-color: transparent transparent transparent rgba(255,255,255,.85); }
    .pgw-sig i { width: 46%; margin-bottom: 3px; background: rgba(127,127,127,.24); }
    .pgw-sig i:last-child { width: 34%; margin-bottom: 0; }
    /* Deliberate emptiness has to LOOK deliberate. A plain 12px gap drew nothing at
       all, so a spacer was invisible in a template thumbnail and invisible again on
       its own palette tile — the one tile where "nothing" is indistinguishable from
       "broken". The dashed band says the gap is the content. */
    .pgw-space { height: 14px; border-radius: 4px; border: 1px dashed rgba(127,127,127,.34); }
    .pgw-btns { display: flex; gap: 6px; }
    .pgw-btns i { height: 11px; width: 46px; border-radius: 6px; background: rgba(var(--accent-rgb),.55); }
    .pgw-btns i:last-child { background: rgba(127,127,127,.26); }
    .pgw-imgtext { display: flex; gap: 7px; align-items: flex-start; }
    .pgw-imgtext b { width: 38%; height: 30px; border-radius: 4px; background: rgba(127,127,127,.22); }
    .pgw-imgtext span { flex: 1; display: flex; flex-direction: column; gap: 4px; padding-top: 2px; }
    .pgw-survey i { height: 7px; border-radius: 4px; background: rgba(127,127,127,.18); margin-bottom: 5px; }
    .pgw-survey i:last-child { width: 66%; margin-bottom: 0; }
    .pgw-rule { height: 1px; background: rgba(127,127,127,.3); margin: 2px 0; }
    /* A ballot reads as a choice: a ring and a line, twice. */
    .pgw-vote { display: flex; flex-direction: column; gap: 5px; }
    .pgw-vote span { display: flex; align-items: center; gap: 5px; }
    .pgw-vote em { flex: none; width: 7px; height: 7px; border-radius: 50%;
      border: 1.5px solid rgba(var(--accent-rgb),.6); }
    .pgw-vote i { flex: 1; }
    /* A table reads as a grid — the one shape no other block in the set has. */
    .pgw-table { display: grid; grid-template-columns: repeat(3, 1fr); gap: 3px; }
    .pgw-table i { height: 6px; border-radius: 2px; }
    .pgw-table i:nth-child(-n+3) { background: rgba(var(--accent-rgb),.45); }
    /* Custom page: nothing to draw a picture of, so it draws the invitation instead. */
    .pgw-blank { display: flex; align-items: center; justify-content: center;
                 border: 2px dashed rgba(127,127,127,.28); border-radius: 8px; -webkit-mask-image: none; mask-image: none; }
    .pgw-blank span { font-size: var(--fs-3xl); font-weight: var(--fw-semi); line-height: var(--lh-none); color: rgba(127,127,127,.5); }
    .pg-choice:hover .pgw-blank { border-color: rgba(var(--accent-rgb),.45); }
    .pg-choice:hover .pgw-blank span { color: var(--accent); }
    /* Four sideways cards need ~1040px of PANEL — measured on the wrapper, not the
       window, because the sidebar and the 1500px page cap spend ~700px of a wide
       window before this grid sees any of it. Below that it drops straight to TWO,
       never three: eight cards over three columns is a 3+3+2 orphan row, which is
       the shape this layout exists to get rid of. */
    @container (max-width: 1040px) {
      .pg-chooser { grid-template-columns: 1fr 1fr; }
    }
    @container (max-width: 520px) {
      .pg-chooser { grid-template-columns: 1fr; gap: 12px; }
      .pg-choice-thumb { width: calc(96px * var(--fs-scale, 1)); min-height: calc(96px * var(--fs-scale, 1)); }
    }
    .pg-field { margin-bottom: 14px; }
    .pg-field > label { display: block; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--text); margin-bottom: 5px; }
    .pg-input, .pg-select { width: 100%; padding: 8px 11px; border: 1px solid var(--border); border-radius: 8px; font-size: var(--fs-md); font-family: inherit; background: #fff; color: var(--text); }
    /* ── ONE list row ─────────────────────────────────────────────────────────
       A button, a stat, a comparison line, a file. Eight of them in eight
       identical grey boxes is a wall, so the row leads with its NUMBER in a chip —
       which is what a person says out loud about it ("the third one is wrong").
       The last row loses its bottom margin so the "+ Add" button below sits at the
       list's own rhythm rather than one gap further out than everything else. */
    .pg-item { border: 1px solid var(--border); border-radius: 10px; padding: 12px; margin-bottom: 8px; background: rgba(127,127,127,.045); }
    .pg-item:last-of-type { margin-bottom: 10px; }
    .pg-item-head { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 10px; }
    .pg-item-lbl { display: flex; align-items: center; gap: 7px; min-width: 0;
      font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--muted); }
    .pg-item-n { display: grid; place-items: center; flex: none; width: 19px; height: 19px; border-radius: 50%;
      background: rgba(var(--accent-rgb),.13); color: var(--accent-dark, var(--accent));
      font-size: var(--fs-xs); font-weight: var(--fw-bold); line-height: var(--lh-none); font-variant-numeric: tabular-nums; }
    /* A row's own last field shouldn't push the row's floor down by a full gap. */
    .pg-item > .pg-field:last-child, .pg-item > .pg-pair:last-child { margin-bottom: 0; }
    .pg-iconbtn { display: inline-flex; align-items: center; gap: 8px; border: 1px solid var(--border); border-radius: 9px; background: #fff; padding: 6px 12px; cursor: pointer; font-size: var(--fs-md); color: var(--text); }
    .pg-iconbtn:hover { border-color: var(--accent); }
    .pg-iconbtn svg { width: 22px; height: 22px; }
    .pg-ic-img { width: 22px; height: 22px; object-fit: contain; border-radius: 4px; }
    .pg-imgrow { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
    .pg-imgprev { width: 54px; height: 54px; border-radius: 8px; object-fit: cover; border: 1px solid var(--border); }
    .pg-rt-bar { display: flex; gap: 4px; margin-bottom: 6px; }
    .pg-rt-bar button { border: 1px solid var(--border); background: #fff; border-radius: 6px; min-width: 30px; height: 28px; cursor: pointer; font-size: var(--fs-md); color: var(--text); }
    .pg-rt-bar button:hover { border-color: var(--accent); color: var(--accent); }
    .pg-rt-edit { min-height: 70px; border: 1px solid var(--border); border-radius: 8px; padding: 9px 11px; font-size: var(--fs-md); line-height: var(--lh-body); background: #fff; color: var(--text); }
    .pg-rt-edit:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
    .pg-del-x { background: none; border: none; color: var(--muted); cursor: pointer; font-size: var(--fs-xl); line-height: var(--lh-none); }
    .pg-del-x:hover { color: #dc2626; }
    /* Page-list trash button: icon variant of pg-del-x, hover gets a soft red chip. */
    .pg-trash { display: inline-flex; align-items: center; justify-content: center; padding: 5px; border-radius: 7px; }
    .pg-trash:hover { background: rgba(220,38,38,.08); }
    .pg-trash .lic { width: 16px; height: 16px; }
    .pg-editor-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 16px; padding-top: 14px; border-top: 1px solid var(--border); position: relative; }
    /* ── THE reorder grip. ONE look, everywhere something can be dragged ──────
       Shared by every drag-to-reorder list (Menus & Buttons, Org Structure, the
       shortcut tiles on Home, the page/email block editors).

       This rule set colour, size and cursor and NEVER RESET THE BUTTON CHROME —
       and `.pg-blk-grip` is a <button>. So outside the few places that restyled it,
       the handle rendered with the USER AGENT's defaults: a #f0f0f0 fill and a
       `2px outset` ridge with square corners. Measured on Menus & Buttons before this
       fix: `16x20, background rgb(240,240,240), border 2px outset, radius 0px`. That
       is a raw browser button sitting in the UI, which is why the row's handle read as
       worse than the tile's — the tile's rule happens to declare its own background,
       border and radius, so it was the only one that ever looked designed.
       The tile's treatment IS the design, so it moves here and everything inherits it:
       a 24px chip in the card colour with the app's border and radius. A drag handle is
       the same affordance wherever it appears, so it is the same object. */
    .pg-blk-grip { cursor: grab; flex: none; user-select: none;
      width: var(--grip-size); height: var(--grip-size); padding: 0; display: grid; place-items: center;
      background: var(--card); border: 1px solid var(--border); border-radius: 7px;
      box-shadow: 0 1px 4px rgba(0,0,0,.12);
      color: var(--muted); font: inherit; font-size: var(--fs-xl); line-height: var(--lh-none); }
    .pg-blk-grip:active { cursor: grabbing; }
    .pg-blk-grip:hover { color: var(--accent); border-color: var(--accent); }
    /* ── The ONE drag treatment (public/js/dnd.js) ────────────────────────────
       Replaced `.grip-lifted` + the unscoped `.dz-above`/`.dz-below` pair. Those
       bare class selectors matched any element anywhere, so a Designer row got
       BOTH an inset edge line and a floating bar at once — while an *expanded*
       row got neither, because `.dsgn-blk.open`'s own box-shadow outranked them.
       One insertion line element now replaces every row-class variant. */
    /* The drop line has to answer one question at a glance: is THAT where it lands?
       A 3px hairline lost that argument against a list of bordered rows. It reads as a
       real gap now — end caps that sit proud of the rows, so the eye catches the slot
       rather than hunting for a coloured edge among other coloured edges. */
    .dnd-line { position: absolute; left: 2px; right: 2px; height: 4px; margin-top: -2px; border-radius: 4px; background: var(--accent);
                box-shadow: 0 0 0 4px rgba(var(--accent-rgb),.18), 0 2px 10px rgba(var(--accent-rgb),.45);
                opacity: 0; transform: scaleX(.97); transition: opacity .1s ease, transform .1s ease; pointer-events: none; z-index: 8; }
    .dnd-line.on { opacity: 1; transform: scaleX(1); }
    /* In a GRID the caret is vertical — end caps top and bottom, not left and right. */
    .dnd-line.vert { transform: scaleY(.97); }
    .dnd-line.vert.on { transform: scaleY(1); }
    .dnd-line.vert::before, .dnd-line.vert::after { left: 50%; right: auto; margin-left: -4.5px; top: auto; margin-top: 0; }
    .dnd-line.vert::before { top: -3px; }
    .dnd-line.vert::after { bottom: -3px; }
    .dnd-line::before, .dnd-line::after { content: ''; position: absolute; top: 50%; width: 9px; height: 9px; margin-top: -4.5px;
                                          border-radius: 50%; background: var(--accent); }
    .dnd-line::before { left: -3px; } .dnd-line::after { right: -3px; }
    /* The row left behind: a dashed slot, not a faded copy. .4 opacity read as "this is
       still here and broken", which is why the drag looked like it had duplicated the row. */
    .dnd-lift { opacity: .28; filter: grayscale(1); }
    .dnd-ghost { position: fixed; z-index: 400; pointer-events: none; margin: 0; opacity: .97; border-radius: 10px;
                 box-shadow: 0 18px 42px rgba(0,0,0,.26), 0 0 0 1px rgba(var(--accent-rgb),.55);
                 transform: translate3d(0,0,0); will-change: transform; cursor: grabbing; }
    .dnd-ghost .dnd-line { display: none; }
    body.dnd-active { cursor: grabbing; user-select: none; }
    /* Keyboard lift — the visible twin of aria-pressed on a grip. */
    .dnd-kb { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 8px; }
    /* Grips are real buttons now (focusable, keyboard-operable), so they need the
       app's font and no default button chrome. touch-action keeps a tablet drag
       from being stolen by the scroller. */
    .dnd-grip { touch-action: none; }
    .sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
    .pg-blk-main { flex: 1; min-width: 0; cursor: pointer; }
    .pg-blk-type { font-weight: var(--fw-bold); font-size: var(--fs-md); color: var(--text); }
    .pg-blk-sum { font-size: var(--fs-sm); color: var(--muted); margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .pg-blk-acts { display: flex; align-items: center; gap: 4px; flex: none; }
    .pg-blk-btn { display: inline-flex; align-items: center; justify-content: center; border: 1px solid var(--border); background: #fff; border-radius: 7px; min-width: 28px; height: 28px; cursor: pointer; font-size: var(--fs-md); color: var(--text); }
    .pg-blk-btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
    .pg-blk-btn:disabled { opacity: .35; cursor: default; }
    .pg-blk-btn.danger:hover:not(:disabled) { border-color: #ef4444; color: #ef4444; }
    .pg-blk-btn .lic { width: 15px; height: 15px; }
    .pg-palette { display: grid; grid-template-columns: repeat(auto-fill, minmax(94px, 1fr)); gap: 8px; }
    /* font-family: inherit because this is a <button> — without it the palette's
       labels rendered in the UA's button font while every other label in the app
       used the site stack, in the one panel that exists to look like a set. */
    .pg-addblk { display: flex; flex-direction: column; align-items: center; justify-content: space-between; gap: 6px; aspect-ratio: 1 / 1; border: 1px dashed var(--border); background: rgba(127,127,127,.04); border-radius: 12px; padding: 11px 7px 9px; cursor: pointer; font-family: inherit; font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--text); text-align: center; transition: border-color .12s, color .12s, background .12s, box-shadow .12s; }
    .pg-addblk:hover { border-color: var(--accent); border-style: solid; color: var(--accent); background: var(--accent-light); box-shadow: 0 2px 9px rgba(0,0,0,.07); }
    /* The tile's picture is the SAME skeleton the starter gallery draws (.pgw-*),
       in the local's own accent — see pgBlockGlyph. It sits in a well, like the
       chooser's thumb does, so the miniature reads as a little page rather than as
       loose bars floating in a dashed square. Centred vertically: a two-bar block
       and a six-bar block have to look like the same KIND of object. */
    .pg-addblk .pgw-tile { justify-content: center; align-self: stretch; gap: 4px;
      flex: 1 1 auto; min-height: 0; padding: 8px 9px; border-radius: 8px;
      background: rgba(127,127,127,.07); transition: background .12s; }
    .pg-addblk:hover .pgw-tile { background: rgba(var(--accent-rgb),.09); }
    .pg-addblk-name { display: block; line-height: var(--lh-tight); }
    /* Stack page render (visitor) — vertical rhythm + block heading + divider */
    .cms-stack { display: flex; flex-direction: column; }
    .cms-stack .cms-blk + .cms-blk { margin-top: 26px; }
    .cms-stack .cms-blk-text, .cms-stack .cms-blk-quote, .cms-stack .cms-blk-callout { max-width: 760px; }
    /* The text half of a two-part block. A flex/grid child defaults to min-width:auto,
       which means it refuses to shrink below its LONGEST WORD — so one pasted URL in a
       member quote pushed the whole block past the page column instead of wrapping.
       Measured at a 700px column before this rule: testimonial 808px, event card 916px,
       timeline 936px. min-width:0 lets them shrink; overflow-wrap breaks the URL itself,
       which nothing else can do. Same failure family as the half-width text block. */
    .cms-tm-body, .cms-ev-what, .cms-tl-body { min-width: 0; flex: 1 1 auto; overflow-wrap: anywhere; }
    .cms-block-h { font-size: var(--fs-2xl); font-weight: var(--fw-bold); color: var(--text); margin: 0 0 12px; }
    .cms-divider { border: none; height: 1px; background: linear-gradient(90deg, transparent, var(--border) 20%, var(--border) 80%, transparent); margin: 26px 0; }
    /* Image block — single image at a chosen size + alignment, optional caption */
    .cms-img { display: block; max-width: 100%; height: auto; border-radius: 12px; }
    .cms-imgfig { margin: 0; }
    .cms-imgfig.size-small  { max-width: 240px; }
    .cms-imgfig.size-medium { max-width: 440px; }
    .cms-imgfig.size-large  { max-width: 680px; }
    .cms-imgfig.size-full   { max-width: 100%; }
    .cms-imgfig.align-center { margin-left: auto; margin-right: auto; }
    .cms-imgfig.align-right  { margin-left: auto; margin-right: 0; }
    .cms-imgfig.align-left   { margin-left: 0; margin-right: auto; }
    .cms-imgfig.align-center .cms-img-cap { text-align: center; }
    .cms-imgfig.align-right .cms-img-cap  { text-align: right; }
    .cms-img-cap { font-size: var(--fs-md); color: var(--muted); margin-top: 8px; line-height: var(--lh-body); }
    /* Image + text block — image floats to one side, text wraps around it */
    .cms-blk-imagetext::after { content: ""; display: block; clear: both; }
    .cms-it-fig { margin: 0 0 10px; }
    .cms-it-fig.width-narrow { width: 33%; }
    .cms-it-fig.width-medium { width: 50%; }
    .cms-it-fig.width-wide   { width: 66%; }
    .cms-it-fig.side-left  { float: left;  margin-right: 22px; }
    .cms-it-fig.side-right { float: right; margin-left: 22px; }
    /* On narrow screens the float would crush the text — stack image over text. */
    @media (max-width: 560px) {
      .cms-it-fig.width-narrow, .cms-it-fig.width-medium, .cms-it-fig.width-wide { width: 100%; }
      .cms-it-fig.side-left, .cms-it-fig.side-right { float: none; margin: 0 0 14px; }
    }
    /* Callout block — tinted notice box with a tone-colored icon square. */
    .cms-callout { display: flex; gap: 13px; align-items: center; padding: 13px 16px; border: 1px solid; border-radius: var(--radius); box-shadow: var(--shadow); }
    .cms-callout .cms-callout-ic { flex: none; display: grid; place-items: center; width: 40px; height: 40px; border-radius: 10px; color: #fff; flex-shrink: 0; }
    .cms-callout .cms-callout-ic .lic, .cms-callout .cms-callout-ic-svg { width: 20px; height: 20px; }
    .cms-callout .cms-body { min-width: 0; color: var(--text); }
    .cms-callout.tone-info    { background: var(--neutral-bg); border-color: var(--neutral); }
    .cms-callout.tone-info    .cms-callout-ic { background: var(--neutral); }
    .cms-callout.tone-success { background: var(--ok-bg);      border-color: var(--ok); }
    .cms-callout.tone-success .cms-callout-ic { background: var(--ok); }
    .cms-callout.tone-warn    { background: var(--warn-bg);    border-color: var(--warn); }
    .cms-callout.tone-warn    .cms-callout-ic { background: var(--warn); }
    .cms-callout.tone-danger  { background: var(--danger-bg);  border-color: var(--danger); }
    .cms-callout.tone-danger  .cms-callout-ic { background: var(--danger); }
    /* Buttons block — a row of CTA links, wrapping, aligned. */
    .cms-btnrow { display: flex; flex-wrap: wrap; gap: 10px; }
    .cms-btnrow.align-center { justify-content: center; }
    .cms-btnrow.align-right  { justify-content: flex-end; }
    .cms-btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 18px; border-radius: 9px; font-size: var(--fs-lg); font-weight: var(--fw-semi); text-decoration: none; border: 1.5px solid transparent; transition: filter .12s, background .12s; }
    .cms-btn-primary   { background: var(--accent); color: var(--on-accent); }
    .cms-btn-primary:hover { filter: brightness(.95); }
    .cms-btn-secondary { background: var(--accent-light, #fff7ed); color: var(--accent-dark, #c2410c); }
    .cms-btn-outline   { background: transparent; color: var(--accent-dark, #c2410c); border-color: var(--accent); }
    .cms-btn-outline:hover { background: var(--accent-light, #fff7ed); }
    /* Stats block — big numbers + labels in an auto-fit grid. */
    .cms-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 16px; }
    .cms-stat { text-align: center; padding: 20px 14px; background: linear-gradient(180deg, rgba(var(--accent-rgb),.08), rgba(var(--accent-rgb),.02)); border: 1px solid rgba(var(--accent-rgb),.15); border-radius: 14px; }
    .cms-stat-n { font-size: var(--fs-4xl); font-weight: var(--fw-display); line-height: var(--lh-tight); color: var(--accent-dark, var(--accent)); }
    .cms-stat-l { font-size: var(--fs-md); color: var(--muted); margin-top: 5px; line-height: var(--lh-snug); }

    /* ── In-app Survey block (Page Designer editor + member view) ─────── */
    .pg-optrow { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; }
    .pg-check { display: flex; gap: 8px; align-items: center; font-size: var(--fs-md); color: var(--text); margin: 4px 0 10px; cursor: pointer; }
    .pg-check input { width: 16px; height: 16px; flex: 0 0 auto; }
    /* A hint explains the field ABOVE it, so it sits under that field — 4px down,
       flush with its label.

       This rule used to be written for one caller: the survey's anonymity note,
       which hangs off a CHECKBOX and therefore wanted pulling up tight and
       indenting past the box. Those two values (`margin-top:-6px`,
       `padding-left:24px`) then applied to every hint in the app. When the shared
       block editor started emitting help text inside .pg-field, every one of those
       hints came out indented 24px past its own label and lifted 6px INTO the
       input it was explaining — which is why pgSchemaFieldsHTML carried an inline
       `padding-left:0;margin-top:4px` to undo it at one of the two call sites and
       the other one just looked broken. The checkbox case is now what it always
       was — a special case — and says so by selector. */
    .pg-hint { font-size: var(--fs-sm); line-height: var(--lh-body); margin: 4px 0 0; color: var(--muted); }
    .pg-check + .pg-hint { margin: -6px 0 12px; padding-left: 24px; }
    .pg-svy-qs { margin-top: 6px; }
    .cms-blk-survey { border: 1px solid var(--border); border-radius: 14px; padding: 18px 18px 16px; background: var(--card); }

    /* ── Registry-native page blocks (see public/js/block-renderers.js) ──────
       Each of these has an email twin built from tables in the same file. Page
       side uses the type scale + brand tokens; the email side cannot (mail
       clients do not resolve var()), so the two are deliberately not shared. */
    /* Readership on a page row. Quiet by default — it is context, not a score. */
    .pg-stat { color: var(--accent); }
    .pg-stat::before { content: "· "; color: var(--muted); }
    .pg-stat-none { color: var(--muted); }
    /* Unsaved edits waiting in this browser. Warn-coloured because unlike readership
       it is a state the leader has to DO something about. */
    .pg-stat-draft { color: var(--warn); font-weight: var(--fw-semi); }

    .cms-h2 { font-size: var(--fs-2xl); font-weight: var(--fw-bold); color: var(--text); margin: 0; line-height: var(--lh-tight); }
    .cms-emoji { font-style: normal; }
    .cms-ic-svg { width: 22px; height: 22px; }
    /* An uploaded image standing in for an icon. Every icon slot (card, resource,
       feature, callout) is a fixed tinted box, so the image sizes to the box it
       lands in rather than each slot needing its own image rule. */
    .cms-ic-img { max-width: 72%; max-height: 72%; object-fit: contain; display: block; }

    /* Alert bar — ONE urgent line. Deliberately loud and deliberately singular:
       a page with three alert bars has none. */
    .cms-alert { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; padding: 14px 18px; border-radius: 10px; font-size: var(--fs-lg); font-weight: var(--fw-bold); line-height: var(--lh-body); }
    .cms-alert-tx { flex: 1 1 240px; }
    .cms-alert-cta { flex: 0 0 auto; border: 1.5px solid; border-radius: 8px; padding: 7px 14px; font-size: var(--fs-md); text-decoration: none; }
    .cms-alert-cta:hover { text-decoration: underline; }

    /* Countdown — the number is the point, so it gets the size. */
    .cms-count { text-align: center; border: 1px solid var(--border); border-radius: 14px; padding: 22px 18px; background: var(--card); }
    .cms-count-t { font-size: var(--fs-md); color: var(--muted); }
    .cms-count-n { font-size: var(--fs-count-hero); font-weight: var(--fw-bold); color: var(--accent); line-height: var(--lh-none); margin-top: 4px; }
    .cms-count-u { font-size: var(--fs-md); color: var(--muted); margin-top: 2px; }
    .cms-count-note { font-size: var(--fs-sm); color: var(--muted); margin-top: 8px; }

    /* Goal thermometer */
    .cms-thermo { height: 20px; border-radius: 10px; background: var(--accent-light, #f3f4f6); overflow: hidden; }
    .cms-thermo-fill { height: 100%; background: var(--accent); border-radius: 10px; transition: width .5s ease; }
    .cms-thermo-legend { font-size: var(--fs-md); color: var(--muted); margin-top: 8px; }
    .cms-thermo-legend b { color: var(--accent); }
    .cms-thermo-note { font-size: var(--fs-sm); color: var(--muted); margin-top: 2px; }

    /* Comparison table — current vs proposed. Scrolls inside itself on a narrow
       screen rather than making the whole page scroll sideways. */
    .cms-tablewrap { overflow-x: auto; }
    .cms-cmp { width: 100%; border-collapse: collapse; font-size: var(--fs-md); }
    .cms-cmp th, .cms-cmp td { padding: 10px 12px; text-align: center; border-bottom: 1px solid var(--border); }
    .cms-cmp thead th { font-size: var(--fs-sm); text-transform: uppercase; letter-spacing: .4px; color: var(--muted); border-bottom-width: 2px; }
    .cms-cmp tbody th[scope="row"] { text-align: left; font-weight: var(--fw-bold); color: var(--text); }
    .cms-cmp .cmp-win { background: var(--accent-light); color: var(--accent-dark); font-weight: var(--fw-bold); }

    /* ── TABLE ─────────────────────────────────────────────────────────────
       Shares the comparison table's wrapper (which is what makes a wide grid
       scroll inside itself instead of pushing the page sideways on a phone).
       Numbers ride right and tabular-nums, because a salary schedule read down a
       column is the entire reason this block exists — proportional digits make
       "52,300" and "9,140" fail to line up on the comma. The first cell stays
       left: it is the row's name, not one of its numbers. */
    .cms-table { width: 100%; border-collapse: collapse; font-size: var(--fs-md); }
    .cms-table th, .cms-table td { padding: 9px 12px; border-bottom: 1px solid var(--border);
      text-align: right; font-variant-numeric: tabular-nums; }
    .cms-table th:first-child, .cms-table td:first-child { text-align: left; font-variant-numeric: normal; }
    .cms-table thead th { font-size: var(--fs-sm); text-transform: uppercase; letter-spacing: .4px;
      color: var(--muted); border-bottom-width: 2px; white-space: nowrap; }
    .cms-table tbody th[scope="row"] { font-weight: var(--fw-semi); color: var(--text); }
    .cms-table tbody tr:last-child th, .cms-table tbody tr:last-child td { border-bottom: 0; }
    /* Zebra, faintly. Fifteen rows of a step-and-column grid is exactly the shape
       an eye loses its place in halfway across. */
    .cms-table tbody tr:nth-child(even) { background: rgba(127,127,127,.045); }

    /* ── THE LIVE BLOCKS ───────────────────────────────────────────────────
       reps · events · ballot · esigndoc. Each renders a frame immediately and
       fills it when its request lands (see cmsHydrate* in app.js), so these are
       the styles for all three states: waiting, filled, and nothing-to-show. */
    .cms-live-loading { color: var(--muted); font-style: italic; }
    .cms-live-note { font-size: var(--fs-sm); color: var(--muted); margin: 0 0 10px; line-height: var(--lh-body); }
    .cms-live-note a { color: var(--accent); }
    .cms-live-done { font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--ok); white-space: nowrap; }

    /* People — reps and officers, read live from the directory. */
    .cms-people { list-style: none; margin: 0; padding: 0; display: grid;
      grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 12px; }
    .cms-person { display: flex; align-items: flex-start; gap: 12px; min-width: 0;
      padding: 13px 15px; border: 1px solid var(--border); border-radius: 12px; background: var(--card); }
    .cms-person-face { flex: none; width: 44px; height: 44px; border-radius: 50%; object-fit: cover;
      border: 1px solid var(--border); background: var(--bg); }
    /* No photo: initials, in the local's own accent. A grey silhouette reads as a
       missing person; initials read as a person we simply have no picture of. */
    .cms-person-mono { display: grid; place-items: center; border: 0;
      background: var(--accent-light); color: var(--accent-dark);
      font-size: var(--fs-lg); font-weight: var(--fw-bold); letter-spacing: .02em; }
    .cms-person-tx { min-width: 0; display: flex; flex-direction: column; gap: 1px; }
    .cms-person-name { font-size: var(--fs-lg); font-weight: var(--fw-semi); color: var(--text); }
    .cms-person-role { font-size: var(--fs-md); color: var(--accent-dark); }
    .cms-person-where { font-size: var(--fs-sm); color: var(--muted); }
    .cms-person-reach { font-size: var(--fs-sm); margin-top: 4px; overflow-wrap: anywhere; }
    .cms-person-reach a { color: var(--accent); }
    .cms-person-dot { color: var(--muted); margin: 0 6px; }

    /* Events — the next few, from the real calendar. */
    .cms-evlist { list-style: none; margin: 0; padding: 0; }
    .cms-evrow { display: flex; gap: 14px; padding: 13px 0; border-bottom: 1px solid var(--border); }
    .cms-evrow:last-child { border-bottom: 0; padding-bottom: 0; }
    .cms-evrow:first-child { padding-top: 0; }
    /* A fixed date column so the titles line up down the list — the thing that
       makes it read as a schedule rather than as a stack of paragraphs. */
    .cms-evrow-when { flex: none; width: 62px; display: flex; flex-direction: column; text-align: right; }
    .cms-evrow-when b { font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--accent-dark); }
    .cms-evrow-when span { font-size: var(--fs-sm); color: var(--muted); }
    .cms-evrow-what { flex: 1; min-width: 0; }
    .cms-evrow-t { display: block; font-size: var(--fs-lg); font-weight: var(--fw-semi); color: var(--text); }
    .cms-evrow-loc { display: inline-flex; align-items: center; gap: 5px; margin-top: 3px;
      font-size: var(--fs-sm); color: var(--muted); text-decoration: none; }
    .cms-evrow-loc:hover { color: var(--accent); }
    .cms-evrow-loc .lic { width: 14px; height: 14px; }
    .cms-evrow .cev-rsvp { margin-top: 9px; }

    /* Ballots and documents — a row, what it is, and the one thing to do about it. */
    .cms-liverows { list-style: none; margin: 0; padding: 0; }
    .cms-liverow { display: flex; align-items: center; gap: 14px; padding: 13px 0;
      border-bottom: 1px solid var(--border); }
    .cms-liverow:last-child { border-bottom: 0; padding-bottom: 0; }
    .cms-liverow:first-child { padding-top: 0; }
    .cms-liverow-tx { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
    .cms-liverow-t { font-size: var(--fs-lg); font-weight: var(--fw-semi); color: var(--text); }
    .cms-liverow-sub { font-size: var(--fs-sm); color: var(--muted); }
    .cms-liverow-act { flex: none; }
    @media (max-width: 480px) {
      .cms-liverow { flex-direction: column; align-items: stretch; gap: 9px; }
      .cms-liverow-act .btn { width: 100%; }
    }

    /* Chart — drawn from divs, never fetched. */
    .cms-ch-rows { display: flex; flex-direction: column; gap: 8px; }
    .cms-ch-row { display: grid; grid-template-columns: minmax(70px, 22%) 1fr auto; align-items: center; gap: 10px; }
    .cms-ch-lbl { font-size: var(--fs-md); color: var(--muted); }
    .cms-ch-track { background: var(--accent-light); border-radius: 8px; height: 16px; }
    .cms-ch-bar { height: 100%; border-radius: 8px; min-width: 2px; transition: width .5s ease; }
    .cms-ch-val { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--accent); }
    .cms-ch-cols { display: flex; align-items: flex-end; gap: 12px; height: 200px; }
    .cms-ch-col { flex: 1 1 0; display: flex; flex-direction: column; justify-content: flex-end; height: 100%; text-align: center; }
    .cms-ch-colbar { border-radius: 8px 8px 0 0; min-height: 3px; position: relative; transition: height .5s ease; }
    .cms-ch-colbar span { position: absolute; top: -20px; left: 0; right: 0; font-size: var(--fs-sm); font-weight: var(--fw-bold); color: var(--accent); }
    .cms-ch-collbl { font-size: var(--fs-sm); color: var(--muted); margin-top: 6px; }

    /* Member voice */
    .cms-tm { display: flex; gap: 16px; align-items: flex-start; background: var(--accent-light); border-radius: 14px; padding: 18px; }
    .cms-tm-img { width: 60px; height: 60px; border-radius: 30px; object-fit: cover; flex: 0 0 auto; }
    .cms-tm-q { font-size: var(--fs-lg); font-style: italic; line-height: var(--lh-loose); color: var(--text); }
    .cms-tm-who { font-size: var(--fs-md); color: var(--muted); margin-top: 8px; }
    .cms-tm-who span::before { content: " · "; }

    /* Downloads */
    .cms-dl { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
    .cms-dl a { display: flex; align-items: center; gap: 12px; justify-content: space-between; border: 1px solid var(--border); border-radius: 10px; padding: 12px 14px; text-decoration: none; color: var(--text); transition: border-color .12s, background .12s; }
    .cms-dl a:hover { border-color: var(--accent); background: var(--accent-light); }
    .cms-dl-main { display: flex; flex-direction: column; gap: 2px; }
    .cms-dl-main b { color: var(--accent); }
    .cms-dl-desc, .cms-dl-meta { font-size: var(--fs-sm); color: var(--muted); }

    /* Expandable sections — <details>, so it opens with no script at all. */
    .cms-acc { border: 1px solid var(--border); border-radius: 10px; margin-bottom: 8px; overflow: hidden; }
    .cms-acc-h { cursor: pointer; padding: 13px 16px; font-weight: var(--fw-bold); font-size: var(--fs-lg); color: var(--text); list-style: none; }
    .cms-acc-h::-webkit-details-marker { display: none; }
    .cms-acc-h::after { content: "＋"; float: right; color: var(--muted); font-weight: var(--fw-body); }
    .cms-acc[open] .cms-acc-h::after { content: "－"; }
    .cms-acc-b { padding: 0 16px 14px; font-size: var(--fs-md); line-height: var(--lh-loose); color: var(--text); }

    /* On this page */
    .cms-toc { border-left: 3px solid var(--accent); padding: 4px 0 4px 16px; }
    .cms-toc-t { font-size: var(--fs-sm); text-transform: uppercase; letter-spacing: .5px; color: var(--muted); margin-bottom: 8px; }
    .cms-toc-l { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 5px; font-size: var(--fs-md); }
    .cms-toc-l a { color: var(--accent); text-decoration: none; }
    .cms-toc-l a:hover { text-decoration: underline; }
    .cms-toc:empty, .cms-toc-l:empty { display: none; }

    /* Gallery / QR / social / event / columns — the Composer-only blocks that
       now also render on a page. */
    .cms-gal { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; }
    .cms-gal img { width: 100%; border-radius: 10px; display: block; }
    .cms-qr { text-align: center; }
    .cms-qr img { display: inline-block; }
    .cms-qr-cap { font-size: var(--fs-md); color: var(--muted); margin-top: 8px; }
    .cms-social { display: flex; flex-wrap: wrap; gap: 8px 18px; justify-content: center; font-size: var(--fs-md); }
    .cms-social a { color: var(--accent); text-decoration: none; }
    .cms-ev { display: flex; gap: 16px; align-items: stretch; border: 1px solid var(--border); border-radius: 12px; padding: 14px; }
    .cms-ev-when { flex: 0 0 92px; display: flex; flex-direction: column; align-items: center; justify-content: center; background: var(--accent); color: var(--on-accent, #fff); border-radius: 8px; padding: 10px 6px; }
    .cms-ev-when b { font-size: var(--fs-xl); line-height: var(--lh-tight); }
    .cms-ev-when span { font-size: var(--fs-sm); opacity: .92; margin-top: 3px; }
    .cms-ev-what { display: flex; flex-direction: column; justify-content: center; gap: 4px; }
    .cms-ev-t { font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--text); }
    .cms-ev-loc { font-size: var(--fs-md); color: var(--muted); }
    .cms-ev-cta { align-self: flex-start; margin-top: 6px; background: var(--accent); color: var(--on-accent, #fff); border-radius: 6px; padding: 7px 16px; font-size: var(--fs-md); font-weight: var(--fw-bold); text-decoration: none; }
    .cms-cols { display: grid; grid-template-columns: 1fr 1fr; gap: 22px; }
    @media (max-width: 640px) {
      .cms-cols { grid-template-columns: 1fr; }
      .cms-tm { flex-direction: column; }
      .cms-ch-row { grid-template-columns: 1fr; gap: 3px; }
      .cms-ev { flex-direction: column; }
      .cms-ev-when { flex-direction: row; gap: 8px; }
    }
    /* What the MEMBER is told is recorded, shown above the questions — before they
       answer, not after. The schema keeps this promise (survey_responses.user_id is
       NULL on an anonymous survey), so it is stated plainly rather than hedged. */
    .svy-privacy { font-size: var(--fs-sm); line-height: var(--lh-body); margin: 0 0 14px; padding-bottom: 12px; border-bottom: 1px solid var(--border); color: var(--muted); }
    .svy-deadline { margin: -6px 0 14px; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--warn); }
    /* Progress: a survey is the one block that asks the reader for work, so it says
       how much is left. Counted through svyGather, so it can't disagree with submit. */
    .svy-progress { display: flex; align-items: center; gap: 10px; margin: 0 0 16px; }
    .svy-progress-t { font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--muted); flex: 0 0 auto; font-variant-numeric: tabular-nums; }
    .svy-progress-track { flex: 1; height: 5px; background: rgba(127,127,127,.15); border-radius: 3px; overflow: hidden; }
    .svy-progress-fill { height: 100%; width: 0; background: var(--accent); border-radius: 3px; transition: width .3s ease; }
    .svy-fs { border: 0; padding: 0; margin: 0; min-width: 0; }
    .svy-fs[disabled] { opacity: .7; }
    /* Each question is a numbered card, so a long survey reads as a sequence of small
       asks rather than one wall. The number chip is also the answered indicator. */
    .svy-q { border: 0; padding: 0; margin: 0 0 20px; min-width: 0; }
    .svy-q-legend { display: flex; align-items: flex-start; gap: 10px; padding: 0; margin-bottom: 10px; width: 100%; }
    .svy-q-n { position: relative; flex: 0 0 auto; display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: var(--accent-light); color: var(--accent-dark); font-size: var(--fs-sm); font-weight: var(--fw-bold); line-height: 24px; transition: background .18s, color .18s; }
    /* Answered: the chip fills and the number gives way to a check. The digit is made
       transparent rather than resized, so the chip never reflows mid-survey. */
    .svy-q.done .svy-q-n { background: var(--accent); color: transparent; }
    .svy-q.done .svy-q-n::after { content: '✓'; position: absolute; inset: 0; display: grid; place-items: center; color: var(--on-accent); }
    .svy-q-t { font-weight: var(--fw-bold); color: var(--text); font-size: var(--fs-xl); line-height: var(--lh-tight); }
    .svy-req { color: var(--danger); }
    /* The native control is hidden but PRESENT — svyGather and the e2e suite both read
       `input:checked`, and keyboard/AT behaviour comes free. The ring beside it is drawn. */
    .svy-opt { display: flex; align-items: center; gap: 11px; min-height: 44px; padding: 10px 13px; border: 1px solid var(--border); border-radius: 10px; margin-bottom: 7px; cursor: pointer; transition: border-color .12s, background .12s; }
    .svy-opt:hover { border-color: var(--accent); }
    .svy-opt input { position: absolute; opacity: 0; width: 0; height: 0; }
    .svy-opt-ind { flex: 0 0 auto; position: relative; width: 20px; height: 20px; border: 2px solid var(--border); border-radius: 50%; transition: border-color .14s, background .14s; }
    .svy-q[data-kind="multi"] .svy-opt-ind { border-radius: 6px; }
    .svy-opt-tx { font-size: var(--fs-lg); }
    .svy-opt:has(input:checked) { border-color: var(--accent); background: rgba(var(--accent-rgb),.07); }
    .svy-opt:has(input:checked) .svy-opt-ind { border-color: var(--accent); background: var(--accent); }
    .svy-opt:has(input:checked) .svy-opt-ind::after { content: ''; position: absolute; inset: 0; margin: auto; width: 7px; height: 7px; border-radius: 50%; background: var(--on-accent); }
    .svy-q[data-kind="multi"] .svy-opt:has(input:checked) .svy-opt-ind::after { width: 5px; height: 9px; margin-top: 2px; border: solid var(--on-accent); border-width: 0 2px 2px 0; border-radius: 0; background: none; transform: rotate(45deg); }
    .svy-opt:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
    .svy-rating { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
    .svy-star { position: relative; display: grid; place-items: center; width: 44px; height: 44px; border: 1px solid var(--border); border-radius: 10px; cursor: pointer; color: var(--muted); transition: color .14s, border-color .14s, background .14s; }
    .svy-star input { position: absolute; opacity: 0; width: 0; height: 0; }
    /* Fill up TO the chosen star: the checked one, plus any it precedes. */
    .svy-star:has(input:checked), .svy-star:has(~ .svy-star input:checked) { color: var(--accent); border-color: var(--accent); background: rgba(var(--accent-rgb),.08); }
    .svy-star:has(input:checked) .ic, .svy-star:has(~ .svy-star input:checked) .ic { fill: currentColor; }
    .svy-star:has(input:focus-visible) { outline: 2px solid var(--accent); outline-offset: 2px; }
    .svy-rating-val { font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--muted); font-variant-numeric: tabular-nums; }
    .svy-textin { width: 100%; padding: 10px 12px; border: 1px solid var(--border); border-radius: 10px; font: inherit; font-size: var(--fs-lg); line-height: var(--lh-body); box-sizing: border-box; resize: vertical; }
    .svy-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 6px; }
    .svy-status { font-size: var(--fs-md); color: var(--muted); }
    .svy-status.ok { color: var(--ok); }
    .svy-status.err { color: var(--danger); }
    /* The one moment this screen gets to feel like something happened. */
    /* Same rule as the bars: visible by default, the entrance is the animation, no
       fill-mode — "thank you" must never be the thing that fails to appear. */
    .svy-thanks { display: flex; align-items: center; gap: 14px; padding: 14px 16px; margin-bottom: 16px; border-radius: 12px; background: rgba(var(--accent-rgb),.08); border: 1px solid rgba(var(--accent-rgb),.28); animation: svyRise .35s ease; }
    @keyframes svyRise { from { opacity: 0; transform: translateY(6px); } }
    .svy-thanks b { display: block; font-size: var(--fs-lg); color: var(--accent-dark); }
    .svy-thanks span { font-size: var(--fs-md); color: var(--muted); }
    .svy-check { flex: 0 0 auto; width: 34px; height: 34px; }
    .svy-check circle { fill: none; stroke: var(--accent); stroke-width: 2; opacity: .35; }
    .svy-check path { fill: none; stroke: var(--accent); stroke-width: 4; stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 40; stroke-dashoffset: 40; }
    .svy-check path { animation: svyDraw .5s .12s ease forwards; }
    @keyframes svyDraw { to { stroke-dashoffset: 0; } }
    .svy-results { margin-top: 18px; border-top: 1px solid var(--border); padding-top: 14px; }
    .svy-results-h { display: flex; align-items: center; justify-content: space-between; gap: 10px; font-size: var(--fs-sm); font-weight: var(--fw-bold); letter-spacing: .04em; text-transform: uppercase; color: var(--muted); margin-bottom: 12px; }
    .svy-results-h b { color: var(--text); }
    .svy-rq { margin-bottom: 18px; }
    .svy-rq-h { font-weight: var(--fw-bold); font-size: var(--fs-lg); margin-bottom: 8px; }
    .svy-bar { margin-bottom: 8px; }
    .svy-bar-top { display: flex; justify-content: space-between; gap: 10px; font-size: var(--fs-md); margin-bottom: 3px; }
    .svy-bar-lbl { display: flex; align-items: center; gap: 7px; min-width: 0; }
    .svy-bar.win .svy-bar-lbl { font-weight: var(--fw-semi); }
    .svy-win-tag { flex: 0 0 auto; font-size: var(--fs-2xs); font-weight: var(--fw-bold); letter-spacing: .05em; text-transform: uppercase; color: var(--accent-dark); background: var(--accent-light); border-radius: 999px; padding: 2px 7px; }
    .svy-bar-n { color: var(--muted); font-variant-numeric: tabular-nums; flex: 0 0 auto; }
    .svy-bar.win .svy-bar-n { color: var(--text); font-weight: var(--fw-semi); }
    .svy-bar-track { height: 9px; background: rgba(127,127,127,.15); border-radius: 5px; overflow: hidden; }
    .svy-bar-fill { height: 100%; background: rgba(var(--accent-rgb),.55); border-radius: 5px; min-width: 2px; }
    .svy-bar.win .svy-bar-fill { background: var(--accent); }
    /* The bar is drawn at its true width, immediately, and is NOT animated.
       Every pretty version of this — JS adds a class, or an animation/transition from
       width 0 — begins at zero and depends on something later to arrive at the number.
       When that something doesn't happen (a frame that never ran, an animation clock
       that isn't advancing because the tab isn't being painted) the bars sit empty and
       the chart silently reports "no responses" for a survey that has 41 of them.
       Decoration may animate in from nothing; DATA may not. */
    .svy-results .svy-bar-fill { width: var(--w); }
    .svy-split { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
    .svy-split-bars { flex: 1 1 240px; min-width: 0; }
    /* A conic-gradient ring — no chart library, and the slices are opacities of the
       tenant's own accent so any local's palette stays legible. */
    .svy-donut { flex: 0 0 auto; position: relative; width: 116px; height: 116px; border-radius: 50%; display: grid; place-items: center; }
    .svy-donut-hole { position: absolute; inset: 22px; border-radius: 50%; background: var(--card); display: grid; place-items: center; text-align: center; padding: 4px; }
    .svy-donut-hole b { font-size: var(--fs-xl); font-weight: var(--fw-bold); line-height: var(--lh-none); }
    .svy-donut-hole span { font-size: var(--fs-2xs); color: var(--muted); line-height: var(--lh-tight); overflow: hidden; }
    .svy-avg-tile { display: flex; align-items: center; gap: 12px; margin-bottom: 12px; }
    .svy-avg-n { font-size: var(--fs-3xl); font-weight: var(--fw-bold); line-height: var(--lh-none); }
    .svy-avg-stars { position: relative; display: inline-block; color: rgba(127,127,127,.3); font-size: var(--fs-xl); line-height: var(--lh-none); letter-spacing: 2px; }
    .svy-avg-on { position: absolute; inset: 0; width: var(--fill, 0); overflow: hidden; color: var(--accent); white-space: nowrap; }
    .svy-avg-c { font-size: var(--fs-md); color: var(--muted); }
    .svy-avg { font-size: var(--fs-md); color: var(--muted); margin-top: 4px; }
    .svy-texts { margin: 0; padding: 0; list-style: none; font-size: var(--fs-md); max-height: 340px; overflow-y: auto; }
    .svy-texts li { margin-bottom: 7px; padding: 8px 12px; border-left: 3px solid rgba(var(--accent-rgb),.4); background: rgba(127,127,127,.06); border-radius: 0 8px 8px 0; line-height: var(--lh-body); }
    .svy-texts-more { font-size: var(--fs-sm); color: var(--muted); margin: 6px 0 0; }
    .svy-count { font-size: var(--fs-md); color: var(--muted); }
    /* Leader survey card, third view of the Designer drawer. */
    .dsgn-svy h4 { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
    .dsgn-svy-badge { font-size: var(--fs-2xs); font-weight: var(--fw-bold); letter-spacing: .05em; text-transform: uppercase; color: var(--muted); background: rgba(127,127,127,.14); border-radius: 999px; padding: 2px 8px; }
    .dsgn-svy-part { margin: 8px 0 14px; font-size: var(--fs-md); color: var(--muted); }
    .dsgn-svy-part b { color: var(--text); }
    .dsgn-svy-part .svy-bar-track { margin-top: 5px; }
    .dsgn-svy-part .svy-bar-fill { width: auto; transition: none; background: var(--accent); }
    .dsgn-svy-acts { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }
    @media (max-width: 480px) {
      .svy-split { gap: 14px; }
      .svy-donut { margin: 0 auto; }
      .svy-split-bars { flex-basis: 100%; }
    }
    @media (prefers-reduced-motion: reduce) {
      .svy-progress-fill, .svy-q-n { transition: none; }
      /* The two decorative entrances stop; the check ends drawn rather than unstarted. */
      .svy-thanks, .svy-check path { animation: none; }
      .svy-check path { stroke-dashoffset: 0; }
    }
    /* Quote block — pull-quote with an accent bar and optional attribution. */
    .cms-quote { margin: 0; padding: 4px 0 4px 24px; border-left: 4px solid var(--accent); position: relative; }
    .cms-quote::before { content: '\201C'; position: absolute; top: -14px; left: 16px; font-size: var(--fs-quote-mark); line-height: var(--lh-none); color: rgba(var(--accent-rgb),.18); font-family: Georgia, serif; pointer-events: none; }
    .cms-quote blockquote { margin: 0; font-size: var(--fs-2xl); line-height: var(--lh-body); font-style: italic; color: var(--text); }
    .cms-quote-cite { margin-top: 10px; font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--text); font-style: normal; }
    .cms-quote-role { font-weight: var(--fw-body); color: var(--muted); }
    /* Video block — responsive 16:9 embed. */
    .cms-videofig { margin: 0; }
    .cms-video { position: relative; width: 100%; padding-top: 56.25%; border-radius: 12px; overflow: hidden; background: #000; }
    .cms-video iframe { position: absolute; inset: 0; width: 100%; height: 100%; border: 0; }

    /* ── Secure Voting (elections + ratification) ───────────────────────── */
    /* Width is handled by the shared child-cap rule (min-width:761px block) — the
       full-width container centres a --page-max column, matching Site Config. */
    .vot-tabs { display: inline-flex; border: 1.5px solid var(--border); border-radius: 999px; overflow: hidden; }
    .vot-tab { border: 0; background: transparent; padding: 7px 18px; font: inherit; font-weight: var(--fw-semi); color: var(--muted); cursor: pointer; }
    .vot-tab.active { background: var(--accent); color: var(--on-accent); }
    .vot-card { margin-bottom: 14px; }
    .vot-card-h { display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap; }
    .vot-card-t { margin: 0; font-size: var(--fs-2xl); }
    .vot-card-sub { margin-top: 4px; font-size: var(--fs-md); color: var(--muted); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
    .vot-card-act { flex: 0 0 auto; display: flex; gap: 8px; align-items: center; }
    .vot-badge { display: inline-block; padding: 2px 9px; border-radius: 999px; font-size: var(--fs-xs); font-weight: var(--fw-bold); color: #fff; letter-spacing: .2px; }
    .vot-voted { color: var(--ok); font-weight: var(--fw-semi); font-size: var(--fs-lg); }
    .vot-manage-top { margin-bottom: 14px; }
    .vot-ballot-h { margin-bottom: 8px; }
    .vot-secret { background: var(--accent-light); border: 1px solid var(--border); border-radius: 10px; padding: 10px 12px; font-size: var(--fs-md); margin: 12px 0 16px; }
    .vot-contest { border: 0; margin: 0 0 18px; padding: 0; }
    .vot-contest legend { font-weight: var(--fw-bold); font-size: var(--fs-xl); padding: 0; margin-bottom: 6px; }
    .vot-hint, .vot-mini { font-size: var(--fs-sm); color: var(--muted); }
    .vot-hint { margin-bottom: 8px; }
    .vot-blurb { color: var(--muted); }
    .vot-abstain { opacity: .85; }
    .vot-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-top: 12px; }
    .vot-result { margin: 8px 0 0; font-size: var(--fs-md); color: var(--text); }
    .vot-receipt-card { text-align: center; }
    .vot-check { width: 52px; height: 52px; margin: 0 auto 8px; border-radius: 50%; background: var(--ok); color: #fff; font-size: calc(30px * var(--fs-scale, 1)); line-height: 52px; }
    .vot-receipt { font-family: ui-monospace, Consolas, monospace; font-size: var(--fs-4xl); letter-spacing: 2px; font-weight: var(--fw-bold); background: var(--card); border: 2px dashed var(--border); border-radius: 12px; padding: 14px; margin: 14px auto; max-width: 320px; }
    .vot-h4 { margin: 18px 0 8px; font-size: var(--fs-lg); text-transform: uppercase; letter-spacing: .4px; color: var(--muted); }
    .vot-edit-contest { border: 1.5px solid var(--border); border-radius: 12px; padding: 12px; margin-bottom: 12px; }
    .vot-inline { display: flex; align-items: center; gap: 8px; margin: 6px 0; flex-wrap: wrap; }
    .vot-inline input[type=text], .vot-inline input:not([type]), .vot-c-title, .vot-choice input { flex: 1 1 200px; }
    .vot-c-title { font-weight: var(--fw-bold); }
    .vot-choices { margin: 8px 0; }
    .vot-x { border: 0; background: var(--danger-bg); color: var(--danger); width: 26px; height: 26px; border-radius: 7px; cursor: pointer; font-size: var(--fs-xl); flex: 0 0 auto; }
    .vot-mono { font-family: ui-monospace, Consolas, monospace; font-size: var(--fs-sm); color: var(--muted); word-break: break-all; }
    .vot-ro-c { margin-bottom: 10px; }
    .vot-ro-c ul { margin: 4px 0 0; padding-left: 20px; }
    .vot-scrut { margin-top: 14px; }
    .vot-chip { display: inline-flex; align-items: center; gap: 5px; background: var(--card); border: 1px solid var(--border); border-radius: 999px; padding: 3px 6px 3px 11px; font-size: var(--fs-md); margin: 4px 6px 0 0; }
    .vot-chip button { border: 0; background: transparent; color: var(--muted); cursor: pointer; font-size: var(--fs-lg); }
    .vot-table-wrap { overflow-x: auto; }
    .vot-table { width: 100%; border-collapse: collapse; font-size: var(--fs-md); }
    .vot-table th, .vot-table td { text-align: left; padding: 5px 8px; border-bottom: 1px solid var(--border); }
    /* On/off toggle switch (reused by the Site Settings Secure Voting panel). */
    .switch { position: relative; display: inline-block; width: 46px; height: 26px; flex: 0 0 auto; }
    .switch input { opacity: 0; width: 0; height: 0; }
    .switch-slider { position: absolute; inset: 0; background: var(--border); border-radius: 999px; transition: .2s; cursor: pointer; }
    .switch-slider::before { content: ""; position: absolute; height: 20px; width: 20px; left: 3px; top: 3px; background: #fff; border-radius: 50%; transition: .2s; }
    .switch input:checked + .switch-slider { background: var(--ok); }
    .switch input:checked + .switch-slider::before { transform: translateX(20px); }

    /* ── E-signatures ───────────────────────────────────────────────────── */
    /* Width handled by the shared child-cap rule (see Site Config). */
    .esign-doc { white-space: normal; background: var(--card); border: 1px solid var(--border); border-radius: 10px; padding: 16px 18px; margin: 12px 0 16px; font-size: var(--fs-lg); line-height: var(--lh-loose); max-height: 340px; overflow-y: auto; }
    .esign-sign-wrap { margin: 16px 0; }
    .esign-method { display: inline-flex; border: 1.5px solid var(--border); border-radius: 999px; overflow: hidden; margin-bottom: 10px; }
    .esign-name-in { font-family: 'Segoe Script', 'Bradley Hand', cursive; font-size: var(--fs-signature); padding: 6px 10px; width: 100%; max-width: 360px; border: 0; border-bottom: 2px solid var(--text); background: transparent; }
    .esign-pad-wrap { border: 2px dashed var(--border); border-radius: 12px; background: #fff; display: inline-block; max-width: 100%; }
    #esign-pad { display: block; width: 100%; max-width: 600px; height: auto; touch-action: none; cursor: crosshair; }
    .esign-consent { align-items: flex-start; margin: 14px 0; font-size: var(--fs-md); }
    .esign-reauth-code { margin: 12px 0; }
    .esign-import-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
    .esign-doc .esd-h, .esign-doc .esd-h1, .esign-doc .esd-h2, .esign-doc .esd-h3 { margin: 12px 0 4px; font-weight: var(--fw-bold); }
    .esign-doc .esd-h1 { font-size: var(--fs-xl); }
    .esign-doc .esd-h2 { font-size: var(--fs-lg); }
    .esign-doc .esd-p { margin: 0 0 8px; }
    .esign-doc .esd-list { margin: 0 0 8px 20px; }
    .esign-doc .esd-table { border-collapse: collapse; margin: 8px 0; width: 100%; }
    .esign-doc .esd-table th, .esign-doc .esd-table td { border: 1px solid var(--border); padding: 4px 8px; text-align: left; font-size: var(--fs-sm); }
    /* Pending-documents badge in the profile menu */
    .ubm-badge { display: inline-block; min-width: 18px; height: 18px; padding: 0 5px; border-radius: 999px; background: var(--accent); color: var(--on-accent); font-size: var(--fs-xs); font-weight: var(--fw-bold); line-height: 18px; text-align: center; vertical-align: middle; }

    /* ── State / OEA overview (stateadmin) ──────────────────────────────── */
    /* Platform (#section-it) + State share Site Config's exact width model: the
       container stays FULL-WIDTH (so scrollbar-gutter doesn't shave the column) and
       the settings-shell is centred + capped at --page-max by the shared child-cap
       rule in the min-width:761px block. Capping the container here instead cost 15px
       to the reserved scrollbar gutter, leaving State/IT narrower than Site Config. */
    .state-wrap { margin: 0 auto; }

    /* ── State coverage map (district choropleth) ── */
    .state-map-wrap { margin: 6px 0 26px; }
    .state-map-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
    .state-map-title { font-size: var(--fs-xl); font-weight: var(--fw-bold); color: var(--text); }
    .state-map-counts { display: flex; gap: 18px; }
    .state-map-counts .smc { font-size: var(--fs-sm); color: var(--muted); }
    .state-map-counts .smc b { color: var(--text); font-size: var(--fs-xl); font-weight: var(--fw-bold); margin-right: 3px; }
    .state-map-stage { position: relative; display: flex; justify-content: center; background: var(--card); border: 1.5px solid var(--border); border-radius: 16px; padding: 12px; min-height: 200px; }
    .state-map-loading { padding: 60px 0; text-align: center; }
    .state-map-svg { height: min(62vh, 560px); width: auto; max-width: 100%; display: block; }
    .sm-d { stroke: var(--card); stroke-width: 0.5; cursor: pointer; transition: filter .1s ease; }
    .sm-d.sm-empty { fill: var(--neutral-bg); stroke: var(--border); stroke-width: 0.6; }
    .sm-d.sm-healthy { fill: var(--ok); }
    .sm-d.sm-watch   { fill: var(--warn); }
    .sm-d.sm-help    { fill: var(--danger); }
    .sm-d:hover { filter: brightness(1.12); }
    .state-map-tip { position: absolute; pointer-events: none; opacity: 0; transform: translate(-50%, -120%); background: var(--text); color: var(--card); font-size: var(--fs-sm); line-height: var(--lh-snug); padding: 6px 9px; border-radius: 8px; white-space: nowrap; transition: opacity .1s; z-index: 4; box-shadow: 0 4px 16px rgba(0,0,0,.2); }
    .state-map-tip b { font-weight: var(--fw-bold); }
    .sms.sm-healthy { background: var(--ok); } .sms.sm-watch { background: var(--warn); } .sms.sm-help { background: var(--danger); }
    .sms.sm-empty { background: var(--neutral-bg); border: 1px dashed var(--border); }
    .state-map-offmap { margin-top: 10px; display: flex; flex-wrap: wrap; gap: 6px; align-items: center; font-size: var(--fs-sm); }
    .state-map-offmap:empty { display: none; }
    .sm-offchip { border: 1px solid var(--border); background: var(--card); border-radius: 999px; padding: 3px 11px; font-size: var(--fs-sm); color: var(--text); cursor: pointer; }
    .sm-offchip:hover { border-color: var(--accent); color: var(--accent); }
    @media (max-width: 520px) { .state-map-svg { height: auto; width: 100%; } }

    /* ── State overview: full-width header, then map left + data right ── */
    .page-frame .state-overview-body { overflow-y: hidden; display: flex; flex-direction: column; }
    /* Full-height chain: app-container (flex-col) → card → overview body → cols */
    #section-state .card { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }
    #section-state .state-overview-body { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
    #section-state .state-map-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; flex-wrap: wrap; padding-bottom: 12px; border-bottom: 1px solid var(--border); margin-bottom: 14px; flex-shrink: 0; }
    .state-overview-cols { flex: 1 1 0; min-height: 0; display: flex; flex-direction: row; gap: 24px; align-items: stretch; }
    .state-map-col { flex: 0 0 55%; min-height: 0; display: flex; flex-direction: column; }
    #section-state .state-map-wrap { flex: 1 1 0; min-height: 0; margin: 0; display: flex; flex-direction: column; }
    #section-state .state-map-stage { flex: 1 1 0; min-height: 0; }
    #section-state .state-map-svg { height: 100%; max-height: 100%; width: auto; }
    .state-data-col { flex: 1 1 0; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; padding-right: 2px; }

    .state-totals { display: grid; grid-template-columns: repeat(5, 1fr); gap: 8px; margin: 0 0 12px; flex-shrink: 0; }
    .state-stat { background: var(--card); border: 1.5px solid var(--border); border-radius: var(--radius); padding: 10px 8px; text-align: center; }
    .state-stat-n { font-size: var(--fs-3xl); font-weight: var(--fw-bold); color: var(--accent); line-height: var(--lh-tight); }
    .state-stat-l { font-size: var(--fs-2xs); color: var(--muted); margin-top: 2px; }
    .state-locals { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; }
    .state-locals .sd-table-wrap { flex: 1 1 0; min-height: 0; overflow-y: auto; }
    .state-map-legend { position: absolute; bottom: 12px; left: 12px; right: 12px; display: flex; flex-wrap: nowrap; align-items: center; gap: 0; font-size: var(--fs-xs); }
    .state-map-legend .sml { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; color: var(--text); padding: 0 12px 0 0; }
    .state-map-legend .sml + .sml { border-left: 1px solid var(--border); padding-left: 12px; }
    .state-map-legend .sml-empty { color: var(--muted); font-style: italic; }
    .state-map-legend .sms { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
    .state-badge { font-size: var(--fs-xs); font-weight: var(--fw-medium); color: var(--accent-dark); background: var(--accent-light); border-radius: 999px; padding: 2px 9px; }
    .state-sub-head { display: flex; align-items: baseline; gap: 10px; margin-top: 30px; }
    .state-sub-head .muted { font-size: var(--fs-md); }
    .state-cats-help { font-size: var(--fs-md); margin: 2px 0 12px; max-width: 640px; }
    /* OEA triage */
    .state-triage-row { display: flex; gap: 14px; align-items: flex-start; background: var(--card); border: 1.5px solid var(--border); border-radius: var(--radius); padding: 14px 16px; margin-bottom: 10px; }
    .state-triage-row.is-done { opacity: .62; }
    .state-triage-main { flex: 1; min-width: 0; }
    .state-triage-top { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
    .state-triage-top strong { font-size: var(--fs-lg); color: var(--text); }
    .state-triage-cat { font-size: var(--fs-sm); color: var(--muted); }
    .state-triage-body { font-size: var(--fs-md); color: var(--text); margin: 6px 0; white-space: pre-wrap; word-break: break-word; }
    .state-triage-meta { font-size: var(--fs-sm); color: var(--muted); }
    .state-triage-act { flex-shrink: 0; }
    /* OEA categories */
    .state-cats-new { display: flex; flex-wrap: wrap; gap: 10px; margin-bottom: 16px; max-width: 640px; }
    .state-cats-new input { flex: 1; padding: 10px 12px; border: 1.5px solid var(--border); border-radius: 9px; font-family: inherit; font-size: var(--fs-lg); color: var(--text); background: var(--card); }
    .state-cat { background: var(--card); border: 1.5px solid var(--border); border-radius: var(--radius); padding: 16px; margin-bottom: 12px; }
    .state-cat-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 10px; }
    .state-cat-head strong { font-size: var(--fs-xl); color: var(--text); }
    .state-cat-actions { display: flex; gap: 8px; flex-shrink: 0; }
    .state-cat-head { margin-bottom: 0; }
    /* Escalation routing: one row per local -> the staff member who owns its cases.
       (Replaces the retired per-local "minimum role" gate matrix.) */
    .state-routing { margin-top: 22px; padding-top: 16px; border-top: 1px solid var(--border); }
    .state-route-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 7px 0; font-size: var(--fs-md); color: var(--text); border-top: 1px solid var(--border); }
    .state-route-row:first-child { border-top: 0; }
    .state-route { padding: 6px 10px; border: 1.5px solid var(--border); border-radius: 8px; font-family: inherit; font-size: var(--fs-md); color: var(--text); background: var(--card); max-width: 60%; }
    /* Escalated Cases inbox on the state dashboard. */
    .state-esc-card { margin-top: 16px; }
    .state-esc-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
    .state-esc-cog .lic { width: 17px; height: 17px; }
    /* Merged OEA Requests panel — hover-revealed config gear */
    .state-req-head { display: flex; align-items: center; gap: 10px; }
    .state-req-head h4 { margin: 0; }
    .state-req-gear { margin-left: auto; display: inline-flex; align-items: center; justify-content: center;
      width: 30px; height: 30px; padding: 0; border: none; background: transparent; color: var(--muted);
      border-radius: 8px; cursor: pointer; opacity: 0; transition: opacity .14s ease, background .12s ease; }
    .state-req-gear .ss-ico { width: 18px; height: 18px; }
    .state-req-head:hover .state-req-gear,
    .state-req-gear:focus-visible { opacity: 1; }               /* keyboard-focusable + reveals on focus */
    .state-req-gear:hover { background: var(--bg); color: var(--text); }
    .state-req-gear:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
    @media (hover: none) { .state-req-gear { opacity: 1; } }     /* touch has no hover → always show */
    .state-req-act { margin-top: 8px; }
    .state-cat-panel { max-width: 560px; }
    /* Add Local modal */
    .state-add-local-panel { max-width: 480px; width: 100%; }
    .state-add-local-hint { font-size: var(--fs-md); margin: 2px 0 16px; }
    .state-add-local-form { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
    .state-add-local-form input { padding: 9px 12px; border: 1.5px solid var(--border); border-radius: 9px; font-family: inherit; font-size: var(--fs-lg); color: var(--text); background: var(--card); }
    .state-add-local-form input:focus { outline: none; border-color: var(--accent); }
    .state-add-local-form .set-label { font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--muted); margin-top: 6px; }
    .state-add-local-foot { display: flex; justify-content: flex-end; margin-top: 4px; }
    /* + Add Local button in the map header */
    .state-add-local-btn { margin-left: auto; flex-shrink: 0; }
    /* ── THE LEADER DASHBOARD IS A PAGE FRAME ───────────────────────────────
       Grey page → ONE white card, fixed height, overflow hidden, and exactly one
       internal scroller. It is the app's page-frame shape (see THE PAGE FRAME
       above), with the dashboard's own regions inside it:

         .page-head      the app's own title band, fused above the card (a SIBLING —
                         see THE PAGE FRAME; the date/role line is its .page-head-meta)
         .ld-strip       phones only — full-bleed 150px pins-only map strip
         .ld-cells       desktop only — full-width stats band (hairline below)
         .ld-frame-body  440px work rail | the town map, split by ONE rail hairline
             .ld-rail        fixed section head + .ld-scroll (THE scroller)
             .ld-mapzone     head, map, legend — desktop only
         .ld-foot        pinned footer: the next calendar event, every screen

       Inner regions separate with HAIRLINES, never nested card chrome — the
       card-sec invariant. The one exception is a stat tile, which keeps tile
       chrome because it is a control. ── */
    /* Flex column so the frame can fill the height. The WIDTH cap lives on the children,
       through the shared child-cap rule — not here (see the note there). */
    #section-dashboard.active > .app-container { display: flex; flex-direction: column; }
    .leader-dash { margin: 0; flex: 1 1 auto; min-height: 0; }
    .state-stat-link { cursor: pointer; transition: border-color .15s, box-shadow .15s; }
    .state-stat-link:hover, .state-stat-link:focus-visible { border-color: var(--accent); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 18%, transparent); }
    /* The map zone fills the leftover height beside the rail; the legend stays natural size. */
    .ld-mapzone { min-width: 0; min-height: 0; display: flex; flex-direction: column; }
    .ld-buildings-head { flex: none; font-weight: var(--fw-bold); font-size: var(--fs-xs); text-transform: uppercase;
      letter-spacing: .06em; color: var(--muted); padding: 10px 15px; border-bottom: 1px solid var(--border); }
    .ld-map { position: relative; width: 100%; flex: 1 1 auto; min-height: 180px; background: var(--card); overflow: hidden; }
    .ld-map-svg { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
    .ld-mb-minor { fill: none; stroke: var(--border); stroke-width: 1; opacity: .55; }
    .ld-mb-major { fill: none; stroke: var(--muted); stroke-width: 2.5; opacity: .18; stroke-linecap: round; }
    /* Real OSM map features — muted, faded fills (green land, blue water) under a grey
       road network, so it reads like a map without competing with the cards. rgba tints
       sit on either light or dark cleanly. */
    .ld-f-green { fill: rgba(126, 178, 111, .22); stroke: none; }
    .ld-f-water { fill: rgba(120, 165, 214, .30); stroke: none; }
    .ld-wl { fill: none; stroke: rgba(120, 165, 214, .55); stroke-width: 1.4; stroke-linecap: round; stroke-linejoin: round; }
    .ld-rd-minor { fill: none; stroke: var(--muted); stroke-width: 1; opacity: .3; stroke-linecap: round; stroke-linejoin: round; }
    .ld-rd-major { fill: none; stroke: var(--muted); stroke-width: 2.2; opacity: .48; stroke-linecap: round; stroke-linejoin: round; }
    .ld-map-attr { position: absolute; right: 6px; bottom: 5px; font-size: var(--fs-2xs); color: var(--muted);
      background: var(--bg); padding: 1px 6px; border-radius: 6px; opacity: .8; pointer-events: none; }
    /* Lunar basemap (Luna test local) — 1960s USGS retro cartography. Hachured crater rims
       (radial tick marks), sinuous dashed rilles, highland stipple dots. Cool grey family
       distinct from the warm page --bg. Pins + cards read clearly on top. */
    .ld-map-svg.ld-map-lunar { background: #c2c8d2; }
    .ld-lunar-fill { fill: #cdd3dc; }
    .ld-lunar-rille { fill: none; stroke: rgba(45, 55, 72, .38); stroke-width: 1.5;
      stroke-linecap: round; stroke-linejoin: round; stroke-dasharray: 5 3; }
    .ld-lunar-crater circle { fill: none; stroke: rgba(45, 55, 72, .35); stroke-width: 0.8; }
    .ld-lunar-crater line { stroke: rgba(45, 55, 72, .45); stroke-width: 0.7; stroke-linecap: butt; }
    .ld-lunar-stipple { fill: rgba(45, 55, 72, .18); stroke: none; }
    /* Tranquility Base vignette — lander, footprints, and the flag we left behind */
    .ld-lunar-lander path { fill: none; stroke: rgba(45, 55, 72, .55); stroke-width: 1; stroke-linejoin: round; stroke-linecap: round; }
    .ld-lunar-prints rect { fill: rgba(45, 55, 72, .38); stroke: none; }
    .ld-lunar-flag line { stroke: rgba(45, 55, 72, .6); stroke-width: 1; stroke-linecap: round; }
    .ld-lunar-flag-cloth { fill: rgba(255, 255, 255, .85); stroke: rgba(45, 55, 72, .5); stroke-width: .7; stroke-linejoin: round; }
    .ld-lunar-flag-stripes { fill: none; stroke: rgba(178, 58, 44, .6); stroke-width: 1; }
    .ld-lunar-flag-canton { fill: rgba(56, 71, 122, .65); stroke: none; }
    /* State map lunar decoration */
    /* Lunar drill-in: regolith ground under translucent district fills, shaded craters above */
    .state-map-lunar { border-radius: 8px; }
    .state-map-lunar .sm-d { fill-opacity: .5; stroke: rgba(38, 42, 50, .4); }
    .state-map-lunar .sm-d.sm-empty { fill: rgba(238, 241, 246, .3); }
    .sm-crater-rim { fill: none; stroke: rgba(38, 42, 50, .2); stroke-width: .8; }
    .sm-lunar-deco { pointer-events: none; }
    .ld-map-lines line { stroke: var(--muted); stroke-width: 1.5; opacity: .5; stroke-dasharray: 3 3; }
    .ld-pin { fill: var(--muted); }
    /* Pins colour by rep-coverage health, matching the shield stat. */
    .ld-pin-green { fill: var(--ok, #15803d); }
    .ld-pin-yellow { fill: var(--warn, #b45309); }
    .ld-pin-red { fill: var(--danger, #b91c1c); }
    .ld-pin-none { fill: var(--muted); }
    .ld-map-empty { padding: 28px 16px; text-align: center; font-size: var(--fs-md); }
    .ld-card { position: absolute; transform: translate(-50%, -50%); background: var(--card);
      border: 1px solid var(--border); border-radius: 12px; box-sizing: border-box; overflow: hidden;
      box-shadow: 0 2px 12px rgba(0,0,0,.14); }   /* lift off the white map surface (Google-style place card) */
    .ld-card-norep { border-color: var(--danger, #b91c1c); }
    .ld-card-inner, .ld-chip { padding: 10px 0 0; }
    /* On-map cards size to their content (name may wrap to 2 lines) so the footer sits right
       under the address — no fixed height, no stranded gap. The collision packer reserves a
       fixed CH per card, so heights stay close enough that cards don't crowd. */
    .ld-card-inner { display: flex; flex-direction: column; box-sizing: border-box; }
    /* Header reserves 2 lines and centers, so the icon holds one fixed position (vertically
       centered in the reserved height): a 2-line name centers the icon between both lines; a
       1-line name centers the single line next to that same icon. Keeps every card equal height. */
    .ld-card-name { font-weight: var(--fw-semi); font-size: var(--fs-lg); line-height: var(--lh-tight); margin: 0 13px 6px;
      display: flex; align-items: center; gap: 7px; min-height: 2.4em; }
    .ld-card-ico { flex: none; display: inline-flex; color: var(--muted); }
    .ld-card-ico .lic { width: 17px; height: 17px; }
    /* Full building name always shows — wrap to at most two lines rather than truncate. */
    .ld-card-title { flex: 1 1 auto; min-width: 0; display: -webkit-box; -webkit-line-clamp: 2;
      -webkit-box-orient: vertical; overflow: hidden; }
    .ld-card-name .it-chip { flex: none; }
    /* Meta row: address (left, multiline, click → maps) + grade range (right, icon on the
       outer edge). Vertically centered so the grade + pin sit on the address block's midline. */
    .ld-card-meta { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin: 0 13px 9px; }
    .ld-card-meta-noaddr { justify-content: flex-start; }
    .ld-card-addr { display: inline-flex; align-items: center; gap: 6px; min-width: 0;
      font-size: var(--fs-2xs); line-height: var(--lh-snug); color: var(--accent); text-decoration: none; }
    .ld-card-addr:hover .ld-card-addr-tx { text-decoration: underline; }
    .ld-card-addr-ic { flex: none; display: inline-flex; color: var(--muted); }
    .ld-card-addr-ic .lic { width: 14px; height: 14px; }
    .ld-card-addr-tx { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .ld-card-grade { display: inline-flex; align-items: center; gap: 5px; flex: none;
      font-size: var(--fs-sm); color: var(--text); white-space: nowrap; }
    .ld-card-grade .lic { width: 14px; height: 14px; color: var(--muted); flex: none; }
    /* Footer: a full content-width divider, then members / reps / requests as three equal
       columns split by pipe dividers. */
    .ld-card-foot { display: flex; border-top: 1px solid var(--border); margin: 0 13px; }
    .ld-foot-col { flex: 1 1 0; min-width: 0; display: inline-flex; align-items: center; justify-content: center; gap: 5px;
      font-size: var(--fs-md); color: var(--muted); padding: 8px 4px; border-left: 1px solid var(--border); }
    .ld-foot-col:first-child { border-left: none; }
    .ld-foot-col .lic { width: 15px; height: 15px; }
    /* Members + Requests columns jump into the filtered directory / requests; the reps
       column opens the hover tooltip. Both get a pointer + subtle hover. */
    .ld-foot-link, .ld-foot-col.mem-pop-trigger { cursor: pointer; }
    .ld-foot-link:hover, .ld-foot-col.mem-pop-trigger:hover { background: var(--bg); }
    .ld-foot-col:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 6px; }
    /* Building Rep view: every other building's request slot is held EMPTY rather than
       removed, so all cards on the map keep the same three-column shape and identical
       height. A rep never sees another building's request load — that's the whole reason
       /api/rep/dashboard exists as its own route. */
    .ld-foot-empty { flex: 1 1 0; border-left: 1px solid var(--border); padding: 8px 4px; }
    /* Their own building, marked so it's findable at a glance in a town of cards. */
    .ld-card-mine { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
    /* Rep-coverage health on the shield column: green covered, yellow short, red undercovered. */
    .ld-cov-green { color: var(--ok, #15803d); font-weight: var(--fw-bold); }
    .ld-cov-yellow { color: var(--warn, #b45309); font-weight: var(--fw-bold); }
    .ld-cov-red { color: var(--danger, #b91c1c); font-weight: var(--fw-bold); }
    .ld-cov-none { color: var(--muted); }
    .ld-stat-bad { color: var(--danger, #b91c1c); font-weight: var(--fw-bold); }
    .ld-stat-open { color: var(--accent); font-weight: var(--fw-semi); }
    /* Legend = a thin footer sliver inside the map card. */
    .ld-map-legend { flex: none; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px;
      margin: 0; padding: 6px 12px; border-top: 1px solid var(--border); background: var(--card); font-size: var(--fs-xs); color: var(--muted); }
    .ld-map-legend.hidden { display: none; }
    .ld-map-legend span { display: inline-flex; align-items: center; gap: 5px; }
    .ld-map-legend .lic { width: 13px; height: 13px; }
    .ld-legend-sep { color: var(--muted); opacity: .8; margin-left: 4px; }
    .ld-legend-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
    .ld-dot-green { background: var(--ok, #15803d); }
    .ld-dot-yellow { background: var(--warn, #b45309); }
    .ld-dot-red { background: var(--danger, #b91c1c); }
    /* Buildings with no address on file — chips in a hairline-separated strip under the
       map, inside the map zone (never a floating card on the grey). */
    .ld-map-missing { flex: none; display: flex; flex-wrap: wrap; gap: 8px; align-items: stretch; }
    .ld-map-missing:not(:empty) { padding: 10px 15px; border-top: 1px solid var(--border); }
    .ld-missing-head { flex: 1 0 100%; font-size: var(--fs-sm); color: var(--muted); margin: 0 0 -2px; }
    .ld-chip { background: var(--card); border: 1px solid var(--border); border-radius: 12px; min-width: 188px; }
    .ld-spark { display: block; margin-top: 6px; }
    .ld-sr-list { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }

    /* ══ Leader dashboards — brief, callouts, work rail ═══════════════════════
       Three screens share this shell (Review Committee / Building Rep / Local Leader
       off the committee). See the block comment above loadLeaderDashboard() in app.js.

       THE COLOUR RULE, and it is the whole point of the redesign: --accent is for ACTION
       and URGENCY ONLY. A resting number renders in --text. Exactly one element per screen
       wears the accent, so the orange thing on screen IS the thing to do. Adding a second
       accented tile doesn't add emphasis, it removes it from the first. */
    /* ── The desktop stats band ─────────────────────────────────────────────
       Six cells for the committee, four for a leader. A cell is a tile without
       chrome — hairline-divided, no border, no radius, no fill (the card-sec
       invariant). The committee's first two cells sit in a request-tinted block
       EXACTLY as wide as the work rail below, so the block's right hairline and the
       rail divider are one continuous line down the card. */
    .ld-cells { flex: none; display: flex; align-items: stretch; border-bottom: 1px solid var(--border); }
    .ld-cells:empty { display: none; }
    .ld-cell { flex: 1 1 0; min-width: 0; padding: 14px 15px; border-left: 1px solid var(--border); overflow: hidden; }
    .ld-cell:first-child { border-left: none; }
    .ld-cell .ld-tile-n { font-size: var(--fs-4xl); }
    .ld-cell .ld-tile-l { font-size: var(--fs-xs); }
    .ld-cell-accent .ld-tile-n { color: var(--accent); }
    /* The committee's two request numbers are a GROUP, not a highlight. The block exists for
       one reason: it is exactly as wide as the work rail below it, so the hairline down its
       right edge continues into the rail divider and the band reads as two columns rather than
       six cells. It used to carry an --accent-light tint with --accent-dark labels — the
       handoff's "request-themed block" — which made two ordinary averages the loudest thing in
       a band of six, and pink is not what "avg to resolve" means. Same card, same muted labels
       as every other cell; the hairline does the grouping. */
    .ld-req-block { flex: none; width: var(--ld-rail-w, 440px); display: flex;
      border-right: 1px solid var(--border); }

    /* Callout tiles — left-aligned, resting number in --text. */
    .ld-tile { background: var(--card); border: 1.5px solid var(--border); border-radius: var(--radius);
      padding: 9px 12px; overflow: hidden; }
    .ld-tile-n { font-size: var(--fs-3xl); font-weight: var(--fw-bold); line-height: var(--lh-tight); color: var(--text); }
    .ld-tile-l { font-size: var(--fs-xs); color: var(--muted); margin-top: 1px; line-height: var(--lh-snug); }
    .ld-tile-suffix { font-size: var(--fs-md); font-weight: var(--fw-semi); margin-left: 1px; }
    .ld-tile-delta { font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--ok); margin-left: 5px; }
    /* The one accented tile: the thing to do. */
    .ld-tile-accent { border-color: var(--accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 12%, transparent); }
    .ld-tile-accent .ld-tile-n { color: var(--accent); }
    .ld-n-danger { color: var(--danger, #b91c1c); }
    .ld-n-warn { color: var(--warn, #8a5a12); }
    .ld-n-ok { color: var(--ok, #15803d); }
    .ld-tile-track { display: block; height: 7px; border-radius: 99px; background: var(--neutral-bg); margin-top: 7px; overflow: hidden; }
    .ld-tile-fill { display: block; height: 100%; background: var(--accent); border-radius: 99px; }
    .ld-spark path { fill: none; stroke: var(--muted); stroke-width: 1.6; opacity: .7; stroke-linecap: round; stroke-linejoin: round; }
    .ld-spark-ok path { stroke: var(--ok, #15803d); opacity: .8; }
    /* Tick bars — a small count of discrete things, where a trend line would invent a shape. */
    .ld-ticks { display: flex; align-items: flex-end; gap: 3px; height: 16px; margin-top: 6px; }
    .ld-ticks i { display: block; width: 6px; height: 100%; border-radius: 2px; background: var(--warn); opacity: .55; }

    /* ── The frame body: work rail | town map ────────────────────────────────
       One vertical hairline between them, and it is the ONLY divider — the rail is a
       region of the card, not a card of its own. --ld-rail-w is shared with the stats
       band's request block so the two hairlines line up. */
    .ld-frame-body { --ld-rail-w: 440px; flex: 1 1 auto; min-height: 0; display: flex; }
    .ld-rail { min-width: 0; min-height: 0; flex: 1 1 auto; display: flex; flex-direction: column; }
    @media (min-width: 761px) {
      .ld-rail { flex: none; width: var(--ld-rail-w); border-right: 1px solid var(--border); }
      .ld-mapzone { flex: 1 1 auto; }
    }
    /* The rail's fixed head: section name, count pill, a right-aligned callout, and (phones,
       committee only) the queue/stats toggle. Everything below it scrolls; this does not. */
    .ld-railhead { flex: none; display: flex; align-items: center; gap: 8px; padding: 10px 15px;
      border-bottom: 1px solid var(--border); }
    .ld-sec-t, .ld-sechead .ld-sec-t { font-size: var(--fs-xs); font-weight: var(--fw-bold);
      text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
    .ld-sec-sub { font-weight: var(--fw-body); letter-spacing: 0; text-transform: none; }
    .ld-sec-note { margin-left: auto; font-size: var(--fs-xs); font-weight: var(--fw-semi); }
    .ld-sec-danger { color: var(--danger); }
    .ld-sec-warn { color: var(--warn); }
    .ld-sec-ok { color: var(--ok); text-transform: none; letter-spacing: 0; }
    .ld-pill { background: var(--accent); color: var(--on-accent); border-radius: 999px;
      padding: 2px 8px; font-size: var(--fs-xs); font-weight: var(--fw-bold); }
    /* A section INSIDE the scroller — a hairline and a heading, never a border and a fill. */
    .ld-sechead { display: flex; align-items: baseline; gap: 6px; margin: 0 0 9px; }
    /* THE ONE SCROLLER. Shaped for FULL-BLEED rows: .ld-q-row carries its own padding and a
       divider that must span the rail, so padding here would inset the lines. Bare content
       (tiles, bars, bullets) takes .ld-scroll-inset for the gutter instead. */
    .ld-scroll { flex: 1 1 auto; min-height: 0; overflow-y: auto; }
    .ld-scroll-inset { padding: 14px 15px; }
    .ld-empty { padding: 22px 16px; text-align: center; font-size: var(--fs-sm); }
    /* The mobile 2×2 tile grid — and the desktop rail's, at the same shape. */
    .ld-tiles { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
    /* Reading bars + This Week: sections of the scroller, hairline-separated. */
    .ld-reading, .ld-week { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border);
      display: flex; flex-direction: column; gap: 9px; }
    .ld-reading .ld-sechead, .ld-week .ld-sechead { margin: 0; }
    .ld-bullet { display: flex; align-items: baseline; gap: 8px; font-size: var(--fs-md); line-height: var(--lh-snug); }
    .ld-bdot { flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
    .ld-bdot-ok { background: var(--ok); }
    .ld-bdot-primary { background: var(--primary); }
    .ld-bdot-accent { background: var(--accent); }
    /* The rep's personal strip — their own three numbers, pinned under their plate. */
    .ld-pstrip { flex: none; display: flex; flex-wrap: wrap; gap: 4px 16px; padding: 10px 15px;
      border-top: 1px solid var(--border); font-size: var(--fs-xs); color: var(--muted); }
    .ld-pstrip-cell b { color: var(--text); font-size: var(--fs-md); font-weight: var(--fw-bold); margin-right: 3px; }
    /* THE TOGGLE IS PHONE-ONLY. Desktop shows the queue AND the stats at once (the rail
       beside the stats band), so a control that switches between them there offers to hide
       half of what is already on screen.
       WRITTEN AT TWO-CLASS STRENGTH ON PURPOSE. As a bare `.ld-hub-toggle` this lost to
       `.ld-seg-ctl { display: inline-flex }` — the shared segmented-control rule, declared
       80 lines further down at the same specificity — and the toggle appeared on desktop.
       Third time this file's source order has decided a `.ld-*` rule; the scope makes it
       a fact about the rail's head rather than a race. */
    /* THE TOGGLE APPEARS WHERE THERE IS A CHOICE. It used to be phone-only, because the only
       two views were the queue (rail) and the stats (band) and desktop showed both at once.
       A leader who also covers a building has a THIRD view, and their plate and the committee
       queue both want the rail — so on desktop the control appears exactly when someone holds
       both hats. The stats segment is a phone thing: up there the stats are the band. */
    .ld-railhead .ld-hub-toggle { display: inline-flex; margin-left: auto; flex: none;
      height: 34px; border-radius: 9px; background: var(--bg); box-shadow: inset 0 0 0 1px #aba79f;
      overflow: hidden; }
    .ld-railhead .ld-hub-toggle .ld-seg-btn { display: inline-flex; align-items: center;
      justify-content: center; min-width: 44px; padding: 0 12px; }
    .ld-railhead .ld-hub-toggle .lic, .ld-railhead .ld-hub-toggle svg { width: 17px; height: 17px; display: block; }
    /* Desktop: the stats segment stands down (the band above IS the stats), which also means a
       plain committee member — review + stats and nothing else — keeps no toggle at all here. */
    .ld-railhead .ld-hub-toggle .ld-seg-btn[data-view="stats"] { display: none; }
    .ld-railhead .ld-hub-toggle:not(:has(.ld-seg-btn:nth-of-type(2):not([data-view="stats"]))) { display: none; }
    /* (There is no pair of titles to hide any more: the rail renders the ONE name of whichever
       view is up — ldRenderRail picks it, including naming "Local Stats" when a work view is
       empty and the stats stand in its place. Two titles in the DOM and CSS choosing between
       them is what printed "NEEDS ASSIGNING LOCAL STATS 3" on desktop for a release.) */
    @media (max-width: 760px) {
      /* THE TOGGLE IS REQUESTS' TOGGLE (.req-viewtabs) — a rounded RECTANGLE with an inset
         outline and a white active segment, not the pill the map's mode switcher wears.
         Two segmented controls doing the same job on two screens should be the same object;
         the pill also read as a filter chip rather than a view switch. Same 9px radius,
         same 38px height, same outline colour, same muted-to-text colour change.
         It is the only control in this head — a red callout and a sort-order label used to
         share the row and its icons rendered clipped and off-centre on a real iPhone. */
      /* Every view the person has is a segment here — including stats, which on a phone has
         no band to live in. */
      .ld-railhead .ld-hub-toggle { display: inline-flex !important; height: 38px; }
      .ld-railhead .ld-hub-toggle .ld-seg-btn[data-view="stats"] { display: inline-flex; }
      /* Colour comes from the base .ld-seg-* rules now (they ARE Requests' treatment), so all
         this adds is the icon-only sizing: a 46px segment and an 18px glyph. */
      .ld-hub-toggle .ld-seg-btn { display: inline-flex; align-items: center; justify-content: center;
        min-width: 46px; padding: 0 14px; }
      .ld-hub-toggle .lic, .ld-hub-toggle svg { width: 18px; height: 18px; display: block; }
      /* (One body per render — .ld-view-queue / .ld-view-stats are gone with the two-view
         model; ldRenderRail draws whichever view is up.) */
      /* (Titles and the count are rendered per view now, not shown/hidden here.) */
      /* ── THE STATS VIEW FITS, IT DOES NOT SCROLL ─────────────────────────────
         The committee is the only role whose phone screen pays for this bar, so its stats
         view has ~38px less room than a plain leader's — and at the leader's sizes it
         overflowed into a scroll. Everything here is one rung down the ladder or a few px
         of padding; no bespoke sizes (the type test would fail one), and the desktop rail
         is untouched because this is scoped to the toggled view. */
      .ld-hubview-stats .ld-scroll-inset { padding: 11px 15px; }
      .ld-hubview-stats .ld-tiles { gap: 8px; }
      .ld-hubview-stats .ld-tile { padding: 8px 10px; }
      .ld-hubview-stats .ld-tile-n { font-size: var(--fs-2xl); }
      .ld-hubview-stats .ld-tile-track { height: 6px; margin-top: 5px; }
      .ld-hubview-stats .ld-ticks { height: 13px; margin-top: 4px; }
      .ld-hubview-stats .ld-spark { margin-top: 4px; }
      .ld-hubview-stats .ld-reading { margin-top: 11px; padding-top: 11px; gap: 7px; }
      /* FOUR READING ROWS, NOT SIX, on a phone. Six bars plus four tiles is more than the
         space holds on a real local's data (570 members, every screen read this week) — which
         is what put this view back into a scroll. The rows are a ranking, and the tail of a
         ranking is the part nobody acts on. */
      .ld-hubview-stats .ld-reading .ld-load-row:nth-child(n+6) { display: none; }
    }

    /* Queue rows — triage (hub) and my-plate (rep) share the shape. */
    .ld-q-row { padding: 12px 13px; border-bottom: 1px solid var(--border); }
    .ld-q-row:last-child { border-bottom: none; }
    .ld-q-eyebrow { font-size: var(--fs-2xs); font-weight: var(--fw-bold); letter-spacing: .06em; text-transform: uppercase; }
    .ld-q-meta { font-weight: var(--fw-body); letter-spacing: 0; text-transform: none; color: var(--muted); font-size: var(--fs-2xs); }
    .ld-age-danger { color: var(--danger, #b91c1c); }
    .ld-age-warn { color: var(--warn, #8a5a12); }
    .ld-age-neutral { color: var(--neutral, #5b6763); }
    .ld-q-subj { font-size: var(--fs-lg); font-weight: var(--fw-semi); line-height: var(--lh-snug); margin: 4px 0 2px; }
    .ld-q-ctx { font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-snug); }
    /* The context line turns red only when it names a real obstacle to routing. */
    .ld-q-ctx-bad { color: var(--danger, #b91c1c); font-weight: var(--fw-semi); }
    .ld-q-acts { display: flex; gap: 7px; margin-top: 9px; }
    /* Soft due dates: a chip and a colour, never an escalation. */
    .ld-due { display: inline-block; border-radius: 5px; padding: 2px 6px; font-size: var(--fs-2xs);
      font-weight: var(--fw-bold); letter-spacing: .06em; text-transform: uppercase; }
    .ld-due-danger { background: var(--danger, #b91c1c); color: #fff; }
    .ld-due-warn { background: var(--warn, #8a5a12); color: #fff; }
    .ld-due-none { background: var(--neutral-bg); color: var(--neutral); }

    .ld-btn { font-family: inherit; font-size: var(--fs-sm); font-weight: var(--fw-semi); border-radius: 9px;
      padding: 8px 10px; cursor: pointer; border: 1px solid var(--border); background: var(--card); color: var(--text);
      white-space: nowrap; }
    .ld-btn-primary { background: var(--accent); color: var(--on-accent); border-color: var(--accent); }
    .ld-btn-primary:hover { background: var(--accent-hover); border-color: var(--accent-hover); }
    .ld-btn-secondary:hover { background: var(--bg); }
    .ld-q-acts .ld-btn:first-child { flex: 1 1 auto; }
    /* A row with ONE action gives it the full width — a half-width lone button reads as if
       its partner failed to render (the rep's plate row, since Mark Resolved came off). */
    .ld-btn-wide { width: 100%; }
    /* Native select laid transparently over a styled button — the same trick the ticket
       screen's status/assign controls use, so the OS picker does the picking. */
    /* (The invisible-native-<select>-over-a-button trick is gone from this screen: assigning
       from the queue opens the app's own picker, the same sheet the request screen's Reassign
       control uses. See ldOpenAssign.) */

    /* Load bars — rep workload, and section reads on the health screen. */
    .ld-load-row { display: flex; align-items: center; gap: 11px; }
    .ld-load-name { flex: none; width: 96px; font-size: var(--fs-md); font-weight: var(--fw-semi);
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .ld-load-track { flex: 1 1 auto; height: 8px; border-radius: 99px; background: var(--neutral-bg); overflow: hidden; }
    .ld-load-fill { display: block; height: 100%; border-radius: 99px; }
    .ld-load-danger { background: var(--danger, #b91c1c); }
    .ld-load-warn { background: var(--warn, #8a5a12); }
    .ld-load-ok { background: var(--ok, #15803d); }
    .ld-load-none { background: transparent; }
    .ld-load-read { background: var(--primary); }
    .ld-load-n { flex: none; width: 18px; text-align: right; font-size: var(--fs-md); font-weight: var(--fw-bold); }
    .ld-load-read.muted, .ld-load-row + .ld-load-read { font-size: var(--fs-xs); line-height: var(--lh-snug); }
    div.ld-load-read { background: none; margin-top: 3px; }

    /* ── The pinned footer: what's next on the calendar ─────────────────────── */
    .ld-foot { flex: none; padding: 10px 15px; border-top: 1px solid var(--border); background: var(--card);
      border-radius: 0 0 var(--radius) var(--radius); }
    .ld-foot .ld-ev { cursor: pointer; }
    .ld-foot .ld-ev:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 8px; }
    .ld-foot-empty { font-size: var(--fs-sm); }

    /* THE FOOTER IS A ROW OF COLUMNS, up to three. It held one event and ~1,100px of white on
       a desktop; the width now buys the next three things on the calendar, divided by the same
       hairline the stats band uses. A phone gets the first one only (see the phone block). */
    .ld-foot { display: flex; align-items: stretch; }
    .ld-foot > .ld-ev { flex: 1 1 0; min-width: 0; padding-right: 14px; }
    .ld-foot > .ld-ev + .ld-ev { border-left: 1px solid var(--border); padding-left: 14px; }
    /* Event rows. The date block is the SHARED .ev-date (see THE EVENT DATE BLOCK). */
    .ld-ev { display: flex; align-items: center; gap: 11px; }
    .ld-ev-body { min-width: 0; display: flex; flex-direction: column; }
    .ld-ev-t { font-size: var(--fs-lg); font-weight: var(--fw-semi); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .ld-ev-m { font-size: var(--fs-sm); color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

    /* ── Map header: mode toggle ────────────────────────────────────────────── */
    .ld-buildings-head { display: flex; align-items: center; gap: 10px; }
    .ld-bh-title { flex: 1 1 auto; min-width: 0; }
    .ld-bh-sub { font-weight: var(--fw-body); }
    .ld-bh-note { font-size: var(--fs-xs); font-weight: var(--fw-body); text-transform: none; letter-spacing: 0; }
    /* THE MAP'S MODE SWITCHER IS A SEGMENTED CONTROL, SO IT IS REQUESTS' SEGMENTED CONTROL
       (.req-viewtabs): a rounded RECTANGLE — 9px, an inset outline, the track a shade darker
       than the card — not a 999px pill. Same object, same job, same shape, wherever it
       appears: this one and the committee's queue/stats toggle both come here.
       The ACTIVE segment keeps --primary rather than Requests' white thumb, and that is not
       drift: this control sits on a white card header, so a white active segment would be
       invisible against it, and the filled state is what makes the current map layer
       readable at a glance from across the card. Shape is the standard; the fill answers to
       the surface it sits on. */
    .ld-seg-ctl { display: inline-flex; border: none; border-radius: 9px; overflow: hidden;
      background: var(--bg); box-shadow: inset 0 0 0 1px #aba79f; }
    .ld-seg-btn { font-family: inherit; font-size: var(--fs-xs); font-weight: var(--fw-semi); border: none;
      background: none; color: #9c988f; padding: 6px 13px; cursor: pointer; transition: color .18s; }
    .ld-seg-btn:not(.on):hover { color: var(--text); }
    /* THE ACTIVE SEGMENT IS REQUESTS' WHITE THUMB, not a --primary fill. It was filled, on the
       reasoning that a white segment would vanish against the white card header this sits on —
       wrong, and Requests proves it: the TRACK is what supplies the contrast (a shade darker
       than the card, with the control's outline around it), so the white thumb reads as lifted
       out of the track rather than painted onto the card. Now all three segmented controls in
       the app — Requests' Open/Complete, the committee's queue/stats, this — are one object in
       shape AND colour, so switching a view looks the same wherever you do it. */
    .ld-seg-btn.on { background: var(--card); color: var(--text); box-shadow: inset 0 0 0 1px #aba79f; }
    /* The thumb squares off against its neighbour and rounds on the outer edge, so it lands
       inside the control's own corners instead of clipping across them. */
    .ld-seg-btn.on:first-child { border-radius: 9px 0 0 9px; }
    .ld-seg-btn.on:last-child { border-radius: 0 9px 9px 0; }
    .ld-seg-btn.on:first-child:last-child { border-radius: 9px; }
    .ld-legend-target { margin-left: auto; opacity: .9; }
    .ld-legend-target:empty { display: none; }

    /* ── Map place card ─────────────────────────────────────────────────────── */
    /* A 4px left rail carries the coverage colour; the address and grade-cap icon are gone. */
    .ld-mc { position: relative; border-left: 4px solid var(--border); padding: 9px 0 0; }
    .ld-mc-green { border-left-color: var(--ok, #15803d); }
    .ld-mc-yellow { border-left-color: var(--warn, #8a5a12); }
    .ld-mc-red { border-left-color: var(--danger, #b91c1c); }
    .ld-mc-none { border-left-color: var(--neutral, #5b6763); }
    .ld-mc-name { font-size: var(--fs-lg); font-weight: var(--fw-semi); line-height: var(--lh-tight); margin: 0 11px 3px;
      display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
    .ld-mc-meta { font-size: var(--fs-xs); color: var(--muted); margin: 0 11px 8px;
      white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
    .ld-mc-foot { display: flex; border-top: 1px solid var(--border); margin: 0 11px; }
    .ld-mc-cell { flex: 1 1 0; min-width: 0; display: flex; align-items: center; justify-content: center; gap: 4px;
      font-size: var(--fs-xs); color: var(--muted); padding: 7px 4px; border-left: 1px solid var(--border); }
    .ld-mc-cell:first-child { border-left: none; }
    .ld-mc-cell b { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--text); }
    /* An open count is only orange when there IS one — a zero is not urgent. */
    .ld-mc-open { color: var(--accent) !important; }
    .ld-mc-link { cursor: pointer; }
    .ld-mc-link:hover { background: var(--bg); }
    .ld-mc-link:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; border-radius: 6px; }
    .ld-mc-you { position: absolute; top: -4px; right: 7px; background: var(--accent); color: var(--on-accent);
      font-size: var(--fs-2xs); font-weight: var(--fw-bold); border-radius: 999px; padding: 1px 7px; }
    /* (.ld-mc-blank needs no rule of its own — it is a plain .ld-mc-cell held empty on a
       rep's map so every card keeps the same shape without carrying another building's
       request load. The class exists to say so in the markup.) */
    /* A rep sees the vacancy stated; appointing is a personnel action they cannot take. */
    .ld-mc-norep { color: var(--danger); font-weight: var(--fw-semi); }
    .ld-mc-eng { flex-direction: column; gap: 3px; }
    .ld-mc-track { display: block; width: 100%; height: 6px; border-radius: 99px; background: var(--neutral-bg); overflow: hidden; }
    .ld-mc-fill { display: block; height: 100%; background: var(--accent); border-radius: 99px; }
    .ld-mc-engl { font-size: var(--fs-2xs); }
    /* No rep is a vacancy, not a statistic — so the cell becomes the way to fill it. */
    .ld-appoint { font-family: inherit; font-size: var(--fs-xs); font-weight: var(--fw-bold); border: none;
      background: var(--accent); color: var(--on-accent); border-radius: 7px; padding: 4px 9px; cursor: pointer; }
    .ld-appoint:hover { background: var(--accent-hover); }
    /* Mode halo behind a pin — scaled to the share the current mode is about. */
    .ld-halo { fill: var(--accent); }
    .ld-halo-engagement { fill: var(--accent); }
    .ld-halo-plain { fill: var(--muted); }
    .ld-pin-mine { fill: var(--accent); }
    /* The pulse halo is INVISIBLE by default and only animates under
       prefers-reduced-motion: no-preference — a reader who asked for less motion gets a
       plain accent pin, not a static blob sitting under it. */
    .ld-pin-pulse { fill: var(--accent); opacity: 0; }

    /* ── The map strip (phones only) ─────────────────────────────────────────
       Full-bleed, 150px, hairline below, pins only — no place cards (a desktop card
       is ~200px wide and cannot survive 390px). Two overlay pills: what is
       undercovered on the left, how big the town is on the right. */
    .ld-strip { display: none; position: relative; flex: none; height: 150px;
      border-bottom: 1px solid var(--border); overflow: hidden; background: var(--card); }
    .ld-strip:empty { display: none; }
    .ld-strip-svg { display: block; width: 100%; height: 100%; }
    .ld-strip-pill { position: absolute; bottom: 8px; background: color-mix(in srgb, var(--card) 93%, transparent);
      border: 1px solid var(--border); border-radius: 999px; padding: 2px 9px; font-size: var(--fs-xs); color: var(--muted); }
    .ld-strip-under { left: 9px; color: var(--danger); font-weight: var(--fw-bold); }
    .ld-strip-counts { right: 9px; }
    /* The "You" flag, anchored right of the viewer's own pin. */
    .ld-strip-flag { position: absolute; transform: translate(10px, -50%); max-width: 62%;
      background: var(--accent); color: var(--on-accent); border-radius: 999px; padding: 2px 9px;
      font-size: var(--fs-2xs); font-weight: var(--fw-bold); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

    /* ── Animation ──────────────────────────────────────────────────────────────
       Runs once per load, and ONLY when the reader has not asked for less motion.
       Everything below is inside the no-preference query on purpose. */
    @media (prefers-reduced-motion: no-preference) {
      @keyframes ld-rise { from { opacity: 0; transform: translateY(9px); } }
      @keyframes ld-pindrop {
        0%   { opacity: 0; transform: translateY(-14px) scale(.5); }
        70%  { opacity: 1; transform: translateY(1px) scale(1.15); }
        100% { opacity: 1; transform: none; }
      }
      @keyframes ld-sweep { from { width: 0; } }
      @keyframes ld-draw { from { stroke-dashoffset: 240; } }
      @keyframes ld-fade { from { opacity: 0; } }
      /* The viewer's own pin breathes — ONE moving thing on the screen, and it answers
         "where am I". Every other pin is static (see ldPinSVG). */
      @keyframes ld-breathe { 0%, 100% { r: 4.2px; } 50% { r: 5.4px; } }
      @keyframes ld-breathe-mine { 0%, 100% { r: 5.6px; } 50% { r: 4.4px; } }
      @keyframes ld-pulse { from { r: 5px; opacity: .3; } to { r: 16px; opacity: 0; } }
      @keyframes ld-pulse-quiet { from { r: 4px; opacity: .16; } to { r: 13px; opacity: 0; } }
      .ld-anim .ld-tile, .ld-anim .ld-cell, .ld-anim .ld-reading, .ld-anim .ld-week {
        animation: ld-rise .45s cubic-bezier(.4, 0, .2, 1) both; }
      /* Motion is on .ld-pin-live, which the RENDERER decides per view (ldPinSVG): every
         building on a district-wide view, only the reader's own in the rep/plate view. Colour
         stays separate — .ld-pin-mine is identity, not animation. */
      .ld-pin-live { animation: ld-breathe 2.8s ease-in-out infinite; }
      .ld-pin-mine.ld-pin-live { animation: ld-breathe-mine 2.8s ease-in-out infinite; }
      .ld-pin-pulse { fill: var(--accent); animation: ld-pulse 2.8s ease-out infinite; }
      /* A district of pulsing halos has to be quieter than one — the same move at a tenth of
         the opacity, so six of them read as ambient rather than as six alarms. */
      .ld-pin-pulse-quiet { fill: var(--muted); animation: ld-pulse-quiet 3.2s ease-out infinite; }
      /* Map place cards fade ONLY — they must never be given an animated `transform`.
         `.ld-card` carries `translate(-50%, -50%)`, which is what centres it on its
         computed position; a keyframe that sets transform replaces that centering
         wholesale and throws the card off the map frame. (It did exactly that once.) */
      .ld-anim .ld-card { animation: ld-fade .45s cubic-bezier(.4, 0, .2, 1) both; }
      .ld-anim .ld-pin { transform-box: fill-box; transform-origin: center;
        animation: ld-pindrop .5s cubic-bezier(.4, 0, .2, 1) both; }
      .ld-anim .ld-load-fill, .ld-anim .ld-tile-fill, .ld-anim .ld-mc-fill {
        animation: ld-sweep .8s cubic-bezier(.4, 0, .2, 1) both; }
      .ld-anim .ld-spark path { stroke-dasharray: 240; animation: ld-draw 1.4s ease both; }
    }

    /* ── Leader dashboard, phones ────────────────────────────────────────────
       EVERY `.ld-*` PHONE OVERRIDE LIVES HERE, after the base block above — never with
       the other phone overrides earlier in the file. They target the same selectors at
       the same specificity, so source order decides, and rules written upstream silently
       LOSE. That is not hypothetical: shipped to a real iPhone, this screen rendered the
       desktop stats band and the full 440px-rail-plus-place-cards map on a 390px screen,
       because the `display: none` that was supposed to hide them sat 1,600 lines above
       the `display: flex` in the base.
       The phone shape: band, a 150px map strip, one section head, ONE scroller, a pinned
       event footer. No stats band (the committee's toggle switches the body to the health
       view instead), and no card map — a place card is ~200px wide and cannot survive 390. */
    @media (max-width: 760px) {
      .leader-dash { flex: 1 1 auto; min-height: 0; }
      /* The strip is the town on a phone; the card map and its off-map chips are desktop. */
      .ld-strip { display: block; }
      .ld-mapzone { display: none; }
      /* The desktop stats band is desktop-only: at 390px six cells is six columns of
         nothing. Its numbers are the stats VIEW of the mobile toggle instead. */
      .ld-cells { display: none; }
      /* One column, and the rail is the whole body — it must drop the desktop width and
         the divider that separated it from a map that is no longer there. */
      .ld-frame-body { flex-direction: column; }
      .ld-rail { width: auto; flex: 1 1 auto; border-right: none; }
      .ld-railhead { padding: 10px 15px; }
      .ld-scroll-inset { padding: 14px 15px; }
      .ld-tile-n { font-size: var(--fs-3xl); }
      /* Every in-card tap target clears 44px; the tab bar's buttons are 51px, the floor. */
      .ld-q-acts .ld-btn, .ld-q-acts .ld-assign-wrap .ld-btn { padding: 13px 10px; font-size: var(--fs-md); }
      .ld-btn { padding: 13px 10px; }
      /* NO safe-area padding here. The bottom tab bar is in normal flow BELOW this footer
         and already carries the inset (see the tab-bar rules) — adding it again padded the
         footer by the home-indicator's height a second time, which read as the event row
         sitting high in a too-tall bar. The footer keeps the same 10/15 as the rail head, so
         the date block lines up with the section head's text above it. */
      .ld-foot { padding: 10px 15px; }
      /* One event on a phone: there is room for exactly one above the tab bar, and a second
         would push it. The rest are one tap away on the Calendar tab. */
      .ld-foot > .ld-ev-more { display: none; }
      .ld-foot > .ld-ev { padding-right: 0; }
      /* The date/role line drops a rung at 375 so "Fri, Aug 7 · Review Committee" stays on
         the title's row instead of pushing the band to two lines. */
      #ld-sub { font-size: var(--fs-xs); }
      /* A phone has the width to spare here (the bar is the least precise thing in the
         row), so give the label enough room that "Member Directory" stops truncating. */
      .ld-load-name { width: 104px; }
      /* One scroller, and the page itself never moves — so this stays overflow-y:auto,
         NOT visible. A nested scroller was the old trap; here the frame's body IS the
         only scroller on the screen. */
      .ld-scroll { overflow-y: auto; -webkit-overflow-scrolling: touch; }
      /* THE PHONE DOES NOT SCROLL THIS LIST EITHER. It used to (it was the frame's one
         scroller here), and the result was the last row clipped in half behind the pinned
         quick-links row — which reads as broken, not as "there is more". Same rule as
         desktop now: hmTrimEvents cuts the row that does not fit and the hint names the door. */
      .hm-events { overflow: hidden; }
      .hm-events .cev { padding: 9px 15px; }
      .hm-events-more::after { padding: 8px 15px 10px; }
    }

    /* ── Event RSVP (calendar agenda) ───────────────────────────────────── */
    /* ONE actions row for everything an event offers. "Collect RSVPs" is a lone
       button, so it joins the row directly (display:contents) instead of holding a
       block of its own; a LIVE RSVP block is a whole control and takes the full line,
       pushing Meeting Notes to the next. */
    .cev-acts { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px; }
    .cev-acts > .cev-rsvp { flex: 1 1 100%; margin-top: 0; }
    .cev-acts > .cev-rsvp-off { display: contents; }
    .cev-rsvp { display: flex; align-items: center; flex-wrap: wrap; gap: 8px 12px; margin-top: 8px; }
    .rsvp-btns { display: inline-flex; border: 1.5px solid var(--border); border-radius: 999px; overflow: hidden; }
    .rsvp-btn { border: none; background: var(--card); color: var(--text); font-family: inherit; font-size: var(--fs-sm); font-weight: var(--fw-medium); padding: 5px 14px; cursor: pointer; border-left: 1px solid var(--border); transition: background .12s, color .12s; }
    .rsvp-btn:first-child { border-left: none; }
    .rsvp-btn:hover { background: var(--accent-light); }
    .rsvp-btn.is-on.r-yes { background: var(--ok); color: #fff; }
    .rsvp-btn.is-on.r-maybe { background: var(--warn); color: #fff; }
    .rsvp-btn.is-on.r-no { background: var(--danger); color: #fff; }
    .rsvp-count { font-size: var(--fs-sm); color: var(--muted); }
    .rsvp-lead { display: inline-flex; gap: 10px; margin-left: auto; }
    .rsvp-link { background: none; border: none; color: var(--accent-dark); font-family: inherit; font-size: var(--fs-sm); font-weight: var(--fw-medium); cursor: pointer; padding: 0; }
    .rsvp-link:hover { text-decoration: underline; }
    .rsvp-link.rsvp-off { color: var(--muted); }
    .rsvp-enable { display: inline-flex; align-items: center; gap: 6px; background: none; border: 1.5px dashed var(--border); border-radius: 999px; color: var(--accent-dark); font-family: inherit; font-size: var(--fs-sm); font-weight: var(--fw-medium); padding: 4px 12px; cursor: pointer; }
    .rsvp-enable:hover { border-color: var(--accent); background: var(--accent-light); }
    /* Roster modal */
    .roster-summary { display: flex; flex-wrap: wrap; gap: 8px 18px; padding-bottom: 12px; margin-bottom: 6px; border-bottom: 1px solid var(--border); }
    .roster-stat { font-size: var(--fs-md); color: var(--muted); }
    .roster-stat b { color: var(--text); font-weight: var(--fw-semi); font-size: var(--fs-xl); margin-right: 3px; }
    .roster-stat-att b { color: var(--ok); }
    .roster-list { max-height: 52vh; overflow-y: auto; }
    .roster-row { display: flex; align-items: center; gap: 10px; padding: 9px 2px; border-bottom: 1px solid var(--border); cursor: pointer; }
    .roster-person { display: flex; align-items: center; gap: 10px; flex: 1; min-width: 0; }
    .roster-av { width: 30px; height: 30px; border-radius: 50%; object-fit: cover; flex-shrink: 0; }
    .roster-initials { display: inline-flex; align-items: center; justify-content: center; background: var(--accent-light); color: var(--accent-dark); font-size: var(--fs-sm); font-weight: var(--fw-semi); }
    .roster-name { font-size: var(--fs-lg); color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .rsvp-tag { font-size: var(--fs-xs); font-weight: var(--fw-medium); border-radius: 999px; padding: 2px 9px; flex-shrink: 0; }
    .rsvp-tag.t-yes { background: var(--ok-bg); color: var(--ok); }
    .rsvp-tag.t-maybe { background: var(--warn-bg); color: var(--warn); }
    .rsvp-tag.t-no { background: var(--danger-bg); color: var(--danger); }
    .rsvp-tag.t-none { background: var(--bg); color: var(--muted); }
    .roster-att { display: inline-flex; align-items: center; gap: 5px; font-size: var(--fs-sm); color: var(--muted); flex-shrink: 0; }
    .roster-att input { width: 16px; height: 16px; accent-color: var(--ok); }
    /* Per-event reminder toggle (roster modal) + org defaults (Site Config) */
    .roster-reminder { display: flex; align-items: center; flex-wrap: wrap; gap: 4px 8px; padding: 10px 12px; margin-bottom: 12px; background: var(--accent-light); border-radius: var(--radius); font-size: var(--fs-md); color: var(--text); }
    .roster-rem-on { display: inline-flex; align-items: center; gap: 7px; cursor: pointer; }
    .roster-rem-on input { width: 16px; height: 16px; accent-color: var(--accent); }
    .roster-rem-when input { width: 54px; padding: 4px 6px; border: 1.5px solid var(--border); border-radius: 6px; font-family: inherit; font-size: var(--fs-md); text-align: center; }
    .evr-row { display: flex; align-items: center; gap: 8px; font-size: var(--fs-lg); color: var(--text); margin: 8px 0; }
    .evr-hours { width: 56px; padding: 5px 6px; border: 1.5px solid var(--border); border-radius: 6px; font-family: inherit; font-size: var(--fs-md); text-align: center; }

    /* ── Documents Library (Drive-backed file browser) ──────────────────── */
    /* cms-blk-documents: Drive folder block on content pages */
    .cms-docs-loading, .cms-docs-empty { padding: 20px 0; color: var(--muted); font-size: var(--fs-lg); text-align: center; }
    .cms-docs-list { display: flex; flex-direction: column; gap: 0; }
    /* Documents, Supply, Help and the State-page viewer join the SAME desktop 1500px
       column as every primary page via the shared child-cap rule (min-width:761px
       block): the full-width scrolling container centres a --page-max column with the
       page-edge scrollbar gutter reserved. Previously these had no cap and stretched
       edge-to-edge on wide monitors. */

    /* ── Volunteer Sign-Up Sheets / Advocacy / Onboarding (2026-07 features) ── */
    .section-sub { color: var(--muted); font-size: var(--fs-lg); margin: -8px 0 18px; }
    .section-header .sh-ic { font-size: var(--fs-3xl); margin-right: 6px; }

    .vol-list { display: flex; flex-direction: column; gap: 16px; }
    /* Advocacy composes SECTIONS, not cards — see THE CARD SECTION. Its rows run wall
       to wall inside the one card and separate with rails, so the list reads as a list
       rather than as a stack of boxes floating on a box. (.adv-card is gone with the
       card-inside-the-card it drew.) */
    .adv-list { display: flex; flex-direction: column; }
    .vol-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
    .vol-title { font-size: var(--fs-xl); font-weight: var(--fw-bold); }
    .vol-chip { display: inline-block; font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); background: var(--neutral-bg); border-radius: 999px; padding: 2px 9px; vertical-align: 2px; margin-left: 6px; }
    .vol-meta { color: var(--muted); font-size: var(--fs-md); margin-top: 4px; display: flex; align-items: center; gap: 6px; }
    .vol-fill { flex-shrink: 0; font-size: var(--fs-md); font-weight: var(--fw-semi); color: var(--muted); background: var(--neutral-bg); border-radius: 999px; padding: 4px 12px; }
    .vol-desc { color: var(--text); font-size: var(--fs-lg); margin: 10px 0 0; white-space: pre-line; }
    .vol-slots { margin-top: 14px; display: flex; flex-direction: column; }
    .vol-slot { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--border); }
    .vol-slot-main { flex: 1; min-width: 0; }
    .vol-slot-label { font-size: var(--fs-lg); font-weight: var(--fw-semi); }
    .vol-count { font-weight: var(--fw-medium); color: var(--muted); font-size: var(--fs-sm); margin-left: 6px; }
    .vol-names { margin-top: 5px; display: flex; flex-wrap: wrap; gap: 6px; }
    .vol-names-empty { color: var(--muted); font-size: var(--fs-sm); font-style: italic; }
    .vol-name { display: inline-flex; align-items: center; gap: 4px; font-size: var(--fs-sm); background: var(--neutral-bg); border-radius: 999px; padding: 2px 10px; }
    .vol-name.is-me { background: var(--accent); color: var(--on-accent, #fff); font-weight: var(--fw-semi); }
    .vol-kick { border: none; background: none; cursor: pointer; color: inherit; opacity: .6; font-size: var(--fs-lg); line-height: var(--lh-none); padding: 0 0 0 2px; }
    .vol-kick:hover { opacity: 1; }
    .vol-full { font-size: var(--fs-sm); font-weight: var(--fw-bold); color: var(--muted); }
    .vol-slot-cta { flex-shrink: 0; }
    .vol-by { margin-top: 12px; font-size: var(--fs-sm); color: var(--muted); }
    .vol-admin, .adv-admin { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); display: flex; gap: 8px; flex-wrap: wrap; }
    .vol-admin .ghost-danger, .adv-admin .ghost-danger { margin-left: auto; }

    /* ── The board: one compact row per sheet ─────────────────────────────
       Every sheet used to sit open on this page. A row states the four things
       you scan a list of twenty for — when, what, how full, are you on it —
       and everything else lives one click in. */
    .vol-list.vol-view-board { gap: 10px; }
    .vol-row { width: 100%; display: flex; align-items: center; gap: 14px; text-align: left;
               background: var(--card); border: 1px solid var(--border); border-radius: 14px;
               padding: 12px 16px; cursor: pointer; color: inherit; font: inherit;
               transition: border-color .12s, box-shadow .12s, transform .12s; }
    .vol-row:hover { border-color: var(--accent); box-shadow: 0 2px 10px rgba(0,0,0,.06); }
    .vol-row:active { transform: translateY(1px); }
    .vol-row.is-closed { opacity: .68; }
    /* (The shift's date block is the shared .ev-date now — see THE EVENT DATE BLOCK. It was
       the last surface with a month-over-day mark of its own, a grey pill on --neutral-bg.) */
    .ev-date-none svg { width: 18px; height: 18px; }
    .vol-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
    .vol-row-title { font-size: var(--fs-xl); font-weight: var(--fw-bold); }
    .vol-row-meta { color: var(--muted); font-size: var(--fs-md); }
    /* Fill bar: how staffed the sheet is, readable without reading the numbers. */
    .vol-row-bar { margin-top: 3px; height: 4px; border-radius: 999px; background: var(--neutral-bg); overflow: hidden; }
    .vol-row-bar i { display: block; height: 100%; background: var(--accent); border-radius: 999px; }
    .vol-row-fill { flex-shrink: 0; text-align: right; font-size: var(--fs-xl); font-weight: var(--fw-bold); }
    .vol-row-fill span { display: block; font-size: var(--fs-xs); font-weight: var(--fw-body); color: var(--muted); }
    .vol-row-go { flex-shrink: 0; font-size: var(--fs-2xl); color: var(--muted); line-height: var(--lh-none); }
    .vol-chip-mine { background: var(--ok-bg); color: var(--ok); display: inline-flex; align-items: center; gap: 4px; }
    .vol-chip-mine svg { width: 11px; height: 11px; }
    @media (max-width: 560px) {
      .vol-row { gap: 10px; padding: 11px 12px; }
      .vol-row-go { display: none; }
      .vol-row-fill { font-size: var(--fs-lg); }
    }

    /* ── One sheet ───────────────────────────────────────────────────────── */
    .vol-back { background: none; border: none; padding: 0; margin: 0 0 12px; cursor: pointer;
                color: var(--muted); font: inherit; font-size: var(--fs-md); font-weight: var(--fw-semi); }
    .vol-back:hover { color: var(--accent); }
    .vol-back span { font-size: var(--fs-lg); }
    .vol-detail, .vol-ci { position: relative; background: var(--card); border: 1px solid var(--border);
                           border-radius: 14px; padding: 18px 20px; }
    .vol-detail.is-closed { opacity: .8; }
    .vol-dt-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
    .vol-dt-title { font-size: var(--fs-3xl); font-weight: var(--fw-bold); margin: 0; }
    .vol-facts { margin-top: 7px; display: flex; flex-wrap: wrap; gap: 6px 16px; }
    .vol-fact { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: var(--fs-md); }
    .vol-fact svg { width: 14px; height: 14px; }
    /* Leader tools sit BELOW the sheet, in their own bar — running the event is a
       different job from signing up for it. */
    .vol-lead-tools { margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border);
                      display: flex; gap: 8px; flex-wrap: wrap; }
    .vol-lead-tools .btn svg, .vol-admin .btn svg { width: 14px; height: 14px; }
    /* Destructive action is an icon, left of nothing else, with the app's inline
       confirm — never a modal, and never a word-shaped button beside "Edit". */
    .vol-trash { margin-left: auto; display: inline-flex; align-items: center; justify-content: center;
                 width: 32px; height: 32px; border: 1px solid var(--border); border-radius: 9px;
                 background: none; color: var(--muted); cursor: pointer; }
    .vol-trash:hover { border-color: var(--danger); color: var(--danger); background: var(--danger-bg); }
    .vol-trash svg { width: 15px; height: 15px; }

    /* ── Check-in ("Run This Sheet") ──────────────────────────────────────
       The paper sign-in sheet, digital. Big targets, thumb-first: this is used
       standing at a table, on a phone, one-handed. */
    .vol-ci-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; }
    .vol-ci-title { font-size: var(--fs-2xl); font-weight: var(--fw-bold); margin: 0; }
    .vol-ci-sub { color: var(--muted); font-size: var(--fs-md); margin-top: 3px; }
    .vol-ci-tally { flex-shrink: 0; text-align: right; }
    .vol-ci-tally b { display: block; font-size: var(--fs-4xl); font-weight: var(--fw-bold); color: var(--ok); line-height: var(--lh-none); }
    .vol-ci-tally span { font-size: var(--fs-xs); color: var(--muted); }
    .vol-ci-tools { margin: 14px 0 4px; display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
    .vol-ci-search { flex: 1; min-width: 180px; display: flex; align-items: center; gap: 8px;
                     border: 1px solid var(--border); border-radius: 10px; padding: 0 10px; color: var(--muted); }
    .vol-ci-search svg { width: 15px; height: 15px; flex-shrink: 0; }
    /* 16px floor on purpose: anything smaller makes iOS zoom the page on focus, which
       on a check-in screen means the list jumps away mid-tap. */
    .vol-ci-search input { flex: 1; border: none; background: none; outline: none; color: var(--text);
                           font: inherit; font-size: var(--fs-xl); padding: 9px 0; min-width: 0; }
    .vol-ci-group { margin-top: 14px; }
    .vol-ci-glabel { font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase;
                     letter-spacing: .06em; color: var(--muted); padding: 0 2px 6px; }
    .vol-ci-glabel span { font-weight: var(--fw-body); text-transform: none; letter-spacing: 0; }
    .vol-ci-row { width: 100%; display: flex; align-items: center; gap: 12px; text-align: left;
                  background: none; border: 1px solid var(--border); border-radius: 12px;
                  padding: 11px 14px; margin-bottom: 7px; cursor: pointer; color: inherit; font: inherit; }
    .vol-ci-row:hover { border-color: var(--ok); }
    .vol-ci-row.is-here { background: var(--ok-bg); border-color: var(--ok); }
    .vol-ci-box { flex-shrink: 0; width: 26px; height: 26px; border: 2px solid var(--border);
                  border-radius: 7px; display: flex; align-items: center; justify-content: center; color: var(--on-accent, #fff); }
    .vol-ci-row.is-here .vol-ci-box { background: var(--ok); border-color: var(--ok); }
    .vol-ci-box svg { width: 16px; height: 16px; }
    .vol-ci-who { flex: 1; min-width: 0; display: flex; flex-direction: column; }
    .vol-ci-name { font-size: var(--fs-xl); font-weight: var(--fw-semi); }
    .vol-ci-meta { font-size: var(--fs-sm); color: var(--muted); }
    .vol-ci-state { flex-shrink: 0; font-size: var(--fs-xs); font-weight: var(--fw-bold);
                    text-transform: uppercase; letter-spacing: .05em; color: var(--muted); }
    .vol-ci-row.is-here .vol-ci-state { color: var(--ok); }
    .vol-ci-note { margin: 16px 0 0; padding-top: 12px; border-top: 1px solid var(--border);
                   font-size: var(--fs-sm); color: var(--muted); }
    .vol-ci-empty { padding: 18px 0; }
    @media (max-width: 560px) {
      .vol-detail, .vol-ci { padding: 15px 14px; }
      .vol-ci-state { display: none; }        /* the box already says it; the name needs the room */
      .vol-ci-row { padding: 13px 12px; }     /* taller instead: a thumb target, not a mouse one */
    }
    .vol-name.is-here { background: var(--ok-bg); color: var(--ok); font-weight: var(--fw-semi); }
    .vol-name.is-me.is-here { background: var(--ok); color: var(--on-accent, #fff); }
    /* Audience chip — only rendered when a sheet ISN'T open to everyone, so it reads
       as a warning rather than decoration. Distinct from the grey "Closed" chip. */
    .vol-chip-aud { background: var(--warn-bg); color: var(--warn); }
    .vol-add { margin-top: 10px; }
    /* The two print buttons, in the roster modal and on a sheet card alike. */
    .roster-actions:empty { display: none; }
    .roster-print { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); display: flex; gap: 8px; flex-wrap: wrap; }
    .roster-print .btn-ghost { display: inline-flex; align-items: center; gap: 6px; font-size: var(--fs-md); }
    .roster-print svg { width: 15px; height: 15px; }
    /* "Add To My Calendar" next to the RSVP buttons. Wraps to its own line on a
       phone rather than squeezing the three response buttons. */
    .rsvp-add { display: inline-flex; align-items: center; gap: 5px; }
    .rsvp-add svg { width: 14px; height: 14px; }
    @media (max-width: 560px) { .rsvp-add { flex-basis: 100%; margin-top: 6px; } }

    /* Slot editor rows in the sheet modal */
    .vs-slot-row { display: flex; gap: 8px; align-items: center; margin-bottom: 8px; }
    .vs-slot-row .vs-slot-label { flex: 1; }
    .vs-slot-row .vs-slot-cap { width: 72px; }
    .vs-slot-x { flex-shrink: 0; }

    /* ── ADVOCACY — "Take Action" ─────────────────────────────────────────
       Two states in one card: the LIST of campaigns, and one campaign drilled
       into. Both compose .card-sec sections (see THE CARD SECTION), so nothing
       in here draws a box: rows are full-bleed and separate with rails, and the
       campaign's parts are sections under section heads. The one thing that DOES
       get a fill is the letter body — it is an exhibit, a quotation of something
       the member is about to send, and a fill says that without a border. */
    .adv-rows { display: flex; flex-direction: column; }
    .adv-row { width: 100%; display: flex; align-items: center; gap: 14px; text-align: left;
               background: none; border: 0; padding: var(--pad-row) var(--pad-card); cursor: pointer;
               color: inherit; font: inherit; transition: background .12s; }
    .adv-row + .adv-row { border-top: var(--rail); }
    .adv-row:hover { background: var(--neutral-bg); }
    .adv-row.is-quiet { opacity: .7; }
    .adv-row-ic { flex: none; width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center;
                  background: var(--accent-light); color: var(--accent-dark); }
    .adv-row-ic svg { width: 17px; height: 17px; }
    .adv-row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
    .adv-row-title { font-size: var(--fs-xl); font-weight: var(--fw-bold); }
    .adv-row-meta { color: var(--muted); font-size: var(--fs-md); }
    .adv-row-bar { margin-top: 3px; height: 4px; border-radius: 999px; background: var(--neutral-bg); overflow: hidden; }
    .adv-row-bar i { display: block; height: 100%; background: var(--accent); border-radius: 999px; }
    .adv-row-n { flex: none; text-align: right; font-size: var(--fs-xl); font-weight: var(--fw-bold); }
    .adv-row-n span { display: block; font-size: var(--fs-xs); font-weight: var(--fw-body); color: var(--muted); }
    .adv-row-go { flex: none; font-size: var(--fs-2xl); color: var(--muted); line-height: var(--lh-none); }
    @media (max-width: 560px) { .adv-row { gap: 10px; } .adv-row-go { display: none; } }

    /* One campaign */
    .adv-summary { color: var(--text); font-size: var(--fs-lg); margin: 0; white-space: pre-line; }
    .adv-summary + .adv-thermo, .adv-summary + .adv-tally, .adv-count + .adv-summary { margin-top: 14px; }
    /* The countdown. Loud on purpose and only where a date exists — it is the one
       fact that changes the shape of the ask ("I Pledge To Vote" → "I Voted"). */
    .adv-count { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; margin: 0 0 4px; }
    .adv-count-n { font-size: var(--fs-4xl); font-weight: var(--fw-bold); line-height: var(--lh-none); color: var(--accent); }
    .adv-count-u { font-size: var(--fs-lg); font-weight: var(--fw-semi); }
    .adv-count i { font-style: normal; width: 100%; color: var(--muted); font-size: var(--fs-md); }
    .adv-count.is-soon .adv-count-n { color: var(--warn); }
    /* Momentum. A goal draws a thermometer; without one the count is a sentence. */
    .adv-thermo-track { height: 10px; border-radius: 999px; background: var(--neutral-bg); overflow: hidden; }
    .adv-thermo-track i { display: block; height: 100%; background: var(--accent); border-radius: 999px; transition: width .5s ease; }
    .adv-thermo.is-met .adv-thermo-track i { background: var(--ok); }
    .adv-thermo-legend { margin-top: 7px; font-size: var(--fs-md); color: var(--muted); }
    .adv-thermo-legend b { color: var(--text); font-weight: var(--fw-bold); }
    .adv-tally { margin: 0; font-size: var(--fs-md); color: var(--muted); }
    .adv-tally b { color: var(--text); font-size: var(--fs-lg); font-weight: var(--fw-bold); }
    .adv-tally span { display: block; margin-top: 3px; font-size: var(--fs-sm); }
    .adv-first { font-style: italic; }

    /* The letter — an exhibit, so a fill rather than a border (see the note above). */
    .adv-letter { margin: 0 0 14px; }
    .adv-letter-hdr { display: flex; align-items: center; justify-content: space-between; gap: 10px;
      margin-bottom: 8px; font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase;
      letter-spacing: .1em; color: var(--muted); }
    .adv-letter-body { padding: 14px 16px; border-radius: 10px; background: var(--neutral-bg);
      font-size: var(--fs-md); line-height: var(--lh-body); white-space: pre-wrap; max-height: 260px; overflow-y: auto; }
    .adv-hint { margin: 8px 0 0; font-size: var(--fs-sm); color: var(--muted); }
    .adv-btns { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
    .adv-btns + .adv-btns { margin-top: 10px; }
    .adv-btns-cta { margin-top: 16px; }
    /* The step numbers on a two-step ask (find yours → write to them). */
    .adv-btns .btn b { display: inline-grid; place-items: center; width: 18px; height: 18px; margin-right: 7px;
      border-radius: 50%; background: rgba(0,0,0,.14); font-size: var(--fs-xs); }
    .btn.is-mock { opacity: .75; cursor: default; }
    /* The disclosure sits WITH the button, because that is where the promise is made. */
    .adv-disclose { display: flex; align-items: flex-start; gap: 7px; margin: 14px 0 0;
      font-size: var(--fs-sm); color: var(--muted); }
    .adv-disclose svg { width: 14px; height: 14px; flex: none; margin-top: 2px; }
    /* After acting: what happens next, not a receipt. */
    .adv-done-line { display: flex; align-items: center; gap: 8px; margin: 0; font-size: var(--fs-lg); }
    .adv-done-line svg { width: 18px; height: 18px; color: var(--ok); }
    .adv-next { margin: 8px 0 14px; font-size: var(--fs-md); color: var(--muted); }

    /* Ballot measure: today vs. if it passes, side by side so they read as one fact. */
    .adv-vs { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
    .adv-vs-col { padding-right: var(--pad-card); }
    .adv-vs-col + .adv-vs-col { padding-left: var(--pad-card); padding-right: 0; border-left: var(--rail); }
    .adv-vs-col h4 { margin: 0 0 6px; font-size: var(--fs-xs); font-weight: var(--fw-bold);
      text-transform: uppercase; letter-spacing: .1em; color: var(--muted); }
    .adv-vs-col.is-new h4 { color: var(--accent-dark); }
    .adv-vs-col p { margin: 0; font-size: var(--fs-md); }
    @media (max-width: 620px) {
      .adv-vs { grid-template-columns: 1fr; }
      .adv-vs-col + .adv-vs-col { padding-left: 0; border-left: 0; border-top: var(--rail); padding-top: 12px; margin-top: 12px; }
    }
    .adv-stance b { display: block; font-size: var(--fs-2xl); font-weight: var(--fw-bold); color: var(--accent-dark); }
    .adv-stance p { margin: 6px 0 0; font-size: var(--fs-md); }
    .adv-fold { border-top: var(--rail); padding: 10px 0; }
    .adv-fold:first-child { border-top: 0; padding-top: 0; }
    .adv-fold summary { cursor: pointer; font-size: var(--fs-lg); font-weight: var(--fw-semi); }
    .adv-fold p { margin: 8px 0 0; font-size: var(--fs-md); color: var(--muted); white-space: pre-line; }
    .adv-official { font-size: var(--fs-sm) !important; }

    /* Endorsements: uniform rows, identical weight. Never a scorecard. */
    .adv-cands { display: flex; flex-direction: column; margin-top: 12px; }
    .adv-cand { padding: 11px 0; border-top: var(--rail); }
    .adv-cand:first-child { border-top: 0; padding-top: 0; }
    .adv-cand-office { display: block; font-size: var(--fs-xs); font-weight: var(--fw-bold);
      text-transform: uppercase; letter-spacing: .1em; color: var(--muted); }
    .adv-cand-name { display: block; font-size: var(--fs-xl); font-weight: var(--fw-bold); margin-top: 2px; }
    .adv-cand-why { display: block; font-size: var(--fs-md); color: var(--muted); margin-top: 3px; }
    .adv-fine { margin: 14px 0 0; font-size: var(--fs-sm); color: var(--muted); }

    /* Campaign HQ (leaders only) */
    .adv-tiles { display: flex; gap: 10px; flex-wrap: wrap; }
    .adv-tile { flex: 1 1 96px; min-width: 96px; padding: 10px 12px; border-radius: 10px; background: var(--neutral-bg); }
    .adv-tile b { display: block; font-size: var(--fs-3xl); font-weight: var(--fw-bold); line-height: var(--lh-tight); }
    .adv-tile span { display: block; font-size: var(--fs-xs); font-weight: var(--fw-semi);
      text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
    .adv-spark { display: flex; align-items: center; gap: 10px; margin-top: 14px; }
    .adv-spark-lbl { font-size: var(--fs-sm); color: var(--muted); }
    .adv-sent { margin: 12px 0 0; display: flex; align-items: center; gap: 7px; font-size: var(--fs-sm); color: var(--muted); }
    .adv-sent svg { width: 14px; height: 14px; }
    .adv-hq .adv-btns { margin-top: 16px; }
    .adv-who { margin-top: 14px; font-size: var(--fs-md); color: var(--muted); display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
    .adv-who-hdr { width: 100%; font-weight: var(--fw-semi); }

    /* One campaign on Home — a strip, not a card, because it is an interruption and
       an interruption should look like one line rather than a second screen.
       NO margin-bottom: this only ever renders into #home-advocacy, a child of the
       .home-main flex column, whose `gap: 14px` is the ONE thing that spaces the Home
       column. Its own 14px margin sat INSIDE the wrapper and stacked on top of that
       gap, so the strip alone had 28px under it while every other pair had 14 —
       the same trap .home-announce and .setup-card are cancelled for above. */
    .adv-strip { display: flex; align-items: center; gap: 12px; padding: 12px 14px;
      border-radius: 12px; background: var(--accent-light); }
    .adv-strip-ic { flex: none; width: 30px; height: 30px; border-radius: 8px; display: grid; place-items: center;
      background: var(--accent); color: var(--on-accent, #fff); }
    .adv-strip-ic svg { width: 15px; height: 15px; }
    .adv-strip-main { flex: 1; min-width: 0; }
    .adv-strip-eyebrow { font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase;
      letter-spacing: .08em; color: var(--accent-dark); }
    .adv-strip-title { font-size: var(--fs-lg); font-weight: var(--fw-semi); }
    .adv-strip-x { flex: none; border: 0; background: none; cursor: pointer; color: var(--muted);
      font-size: var(--fs-2xl); line-height: var(--lh-none); padding: 0 2px; }
    .adv-strip-x:hover { color: var(--text); }

    /* ── The campaign builder ─────────────────────────────────────────────
       Asks what the leader wants to ACCOMPLISH first, then shows them the
       member's own view of what they are writing, live, beside the form. */
    .advb { padding: var(--pad-card); }
    .advb-ask { margin: 0; font-size: var(--fs-2xl); font-weight: var(--fw-bold); }
    .advb-sub { margin: 6px 0 20px; font-size: var(--fs-md); color: var(--muted); }
    .advb-tiles { display: grid; grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); gap: 12px; }
    .advb-tile { display: flex; flex-direction: column; align-items: flex-start; gap: 7px; text-align: left;
      padding: 16px; border-radius: 12px; border: 1px solid var(--border); background: var(--card);
      cursor: pointer; color: inherit; font: inherit; transition: border-color .12s, box-shadow .12s; }
    .advb-tile:hover { border-color: var(--accent); box-shadow: 0 2px 10px rgba(0,0,0,.06); }
    .advb-tile-ic { width: 34px; height: 34px; border-radius: 9px; display: grid; place-items: center;
      background: var(--accent-light); color: var(--accent-dark); }
    .advb-tile-ic svg { width: 17px; height: 17px; }
    .advb-tile-name { font-size: var(--fs-xl); font-weight: var(--fw-bold); }
    .advb-tile-blurb { font-size: var(--fs-md); color: var(--muted); }
    .advb-cols { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 380px); gap: 26px; align-items: start; }
    @media (max-width: 1000px) { .advb-cols { grid-template-columns: minmax(0, 1fr); } .advb-prev { display: none; } }
    .advb-kind { margin: 0 0 16px; display: flex; align-items: center; gap: 7px;
      font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .1em; color: var(--muted); }
    .advb-kind svg { width: 14px; height: 14px; }
    .advb-f { display: block; margin-bottom: 16px; }
    .advb-lbl { display: block; margin-bottom: 5px; font-size: var(--fs-lg); font-weight: var(--fw-semi); }
    .advb-f input, .advb-f textarea { width: 100%; box-sizing: border-box; }
    .advb-hint { display: block; margin-top: 5px; font-size: var(--fs-sm); color: var(--muted); }
    .advb-tokens { display: flex; align-items: center; gap: 7px; flex-wrap: wrap;
      margin: -10px 0 16px; font-size: var(--fs-sm); color: var(--muted); }
    .advb-rep { position: relative; padding: 12px 40px 12px 0; border-top: var(--rail); }
    .advb-rep:first-child { border-top: 0; }
    .advb-rep input, .advb-rep textarea { width: 100%; box-sizing: border-box; margin-bottom: 6px; }
    .advb-rep .vol-trash { position: absolute; top: 12px; right: 0; }
    /* The preview is a picture of the member's screen, so it gets the one border in
       here — it is a device, not a panel, and it has to read as somewhere else. */
    .advb-prev { position: sticky; top: 0; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
    .advb-prev-hd { display: flex; align-items: center; gap: 7px; padding: 9px 14px; background: var(--band);
      color: var(--band-ink); font-size: var(--fs-xs); font-weight: var(--fw-bold);
      text-transform: uppercase; letter-spacing: .1em; border-bottom: 1px solid var(--band-rail); }
    .advb-prev-hd svg { width: 13px; height: 13px; }
    .advb-prev-body { max-height: 62vh; overflow-y: auto; }
    .advb-prev-body .card-sec-body { padding: 14px 16px; }
    .advb-prev-full { max-height: none; border: 1px solid var(--border); border-radius: 12px; overflow: hidden; }
    .advb-review { padding: 0; }
    .advb-gap { display: flex; align-items: flex-start; gap: 8px; margin: 0 0 16px; padding: 11px 14px;
      border-radius: 10px; background: var(--warn-bg); color: var(--warn); font-size: var(--fs-md); }
    .advb-gap svg { width: 15px; height: 15px; flex: none; margin-top: 2px; }

    /* Onboarding: the Home "Getting Started" card */
    .onboard-panel { padding-bottom: 6px; }
    .onb-progress-label { margin-left: auto; font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--muted); }
    .onb-bar { height: 6px; border-radius: 999px; background: var(--neutral-bg); margin: 12px 14px 4px; overflow: hidden; }
    .onb-bar-fill { height: 100%; border-radius: 999px; background: var(--accent); transition: width .3s ease; }
    .onb-items { padding: 6px 6px 8px; }
    .onb-item { display: flex; align-items: flex-start; gap: 10px; width: 100%; text-align: left; border: none; background: none; cursor: pointer; padding: 8px; border-radius: 10px; font: inherit; color: inherit; }
    .onb-item:hover:not(:disabled) { background: var(--neutral-bg); }
    .onb-item:disabled { cursor: default; }
    .onb-check { flex-shrink: 0; width: 20px; height: 20px; border: 1.5px solid var(--border); border-radius: 6px; display: inline-flex; align-items: center; justify-content: center; margin-top: 1px; color: var(--ok); }
    .onb-item.is-done .onb-check { border-color: var(--ok); }
    .onb-item.is-done .onb-label { text-decoration: line-through; color: var(--muted); }
    .onb-text { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
    .onb-label { font-size: var(--fs-md); font-weight: var(--fw-semi); }
    .onb-hint { font-size: var(--fs-sm); color: var(--muted); }
    /* A task can carry a "take me there" button. It sits BESIDE the check-off rather
       than inside it — a button in a button is invalid, and tapping "Open" must never
       tick the box on the way past. */
    .onb-row { display: flex; align-items: center; gap: 6px; }
    .onb-row .onb-item { flex: 1; min-width: 0; }
    .onb-go { flex-shrink: 0; border: 1px solid var(--border); background: none; color: var(--accent);
              font: inherit; font-size: var(--fs-sm); font-weight: var(--fw-semi); cursor: pointer;
              padding: 5px 11px; border-radius: 999px; margin-right: 8px; }
    .onb-go:hover { background: var(--neutral-bg); }

    /* Onboarding admin (Site Config → New-Member Onboarding): the checklist editor */
    .onb-tabs { display: flex; gap: 6px; margin: 4px 0 18px; border-bottom: 1px solid var(--border); }
    .onb-tab { border: none; background: none; font: inherit; font-size: var(--fs-md); font-weight: var(--fw-semi);
               color: var(--muted); cursor: pointer; padding: 8px 14px; border-bottom: 2px solid transparent; margin-bottom: -1px; }
    .onb-tab:hover { color: var(--text); }
    .onb-tab.is-active { color: var(--accent); border-bottom-color: var(--accent); }
    .onb-list-row { display: flex; align-items: center; gap: 10px; padding: 10px 0; border-top: 1px solid var(--border); }
    .onb-list-row:first-child { border-top: none; }
    .onb-list-row.is-off .onb-list-main { opacity: .5; }
    .onb-list-main { flex: 1; min-width: 0; }
    .onb-list-label { font-size: var(--fs-lg); font-weight: var(--fw-semi); display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
    .onb-list-tag { font-size: var(--fs-xs); font-weight: var(--fw-semi); text-transform: uppercase; letter-spacing: .04em;
                   color: var(--muted); background: var(--neutral-bg); border-radius: 999px; padding: 2px 8px; }
    .onb-page-hint { font-size: var(--fs-sm); color: var(--muted); margin-top: 2px; }
    .onb-list-link { font-size: var(--fs-sm); color: var(--muted); margin-top: 4px; display: flex; align-items: center; gap: 5px; }
    .onb-list-link svg { width: 13px; height: 13px; }
    .onb-page-acts { flex-shrink: 0; display: flex; align-items: center; gap: 6px; }
    .onb-page-acts .blk-btn.is-locked { opacity: .35; cursor: default; }
    /* Roll-up stat tiles */
    .onb-stats { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; }
    .onb-stat { border: 1px solid var(--border); border-radius: 12px; padding: 12px; text-align: center; }
    .onb-stat-n { font-size: var(--fs-2xl); font-weight: var(--fw-bold); }
    .onb-stat-l { font-size: var(--fs-sm); color: var(--muted); margin-top: 2px; }
    .onb-task-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--border); }
    .onb-task-row:first-child { border-top: none; }
    .onb-task-main { flex: 1; min-width: 0; }
    .onb-task-label { font-size: var(--fs-md); font-weight: var(--fw-semi); }
    .onb-task-row .onb-bar { margin: 8px 0 0; }
    @media (max-width: 640px) { .onb-stats { grid-template-columns: repeat(2, 1fr); } }

    /* Onboarding: the leader progress roll-up rows */
    .onb-prog-row { display: flex; align-items: center; gap: 12px; padding: 10px 0; border-top: 1px solid var(--border); }
    .onb-prog-row:first-child { border-top: none; }
    .onb-prog-main { flex: 1; min-width: 0; }
    .onb-prog-name { font-size: var(--fs-lg); font-weight: var(--fw-semi); }
    .onb-prog-left { font-size: var(--fs-sm); color: var(--muted); margin-top: 2px; }
    .onb-prog-row.is-complete .onb-prog-left { color: var(--ok); }
    .onb-prog-count { flex-shrink: 0; font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--muted); }

    /* ── Laws Impacting Unions (Local Leader+ reference) ──
       One jurisdiction on screen at a time (Federal is an option in the picker,
       not a permanent second list). Each card reads verdict → law → what it says
       → what the software does about it, that last part being the whole point
       of the page rather than a muted aside. */
    .law-disclaimer { display: flex; gap: 12px; align-items: flex-start; background: var(--neutral-bg); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; font-size: var(--fs-md); margin-bottom: 18px; }
    .law-disclaimer .ic { flex-shrink: 0; font-size: var(--fs-2xl); margin-top: 1px; }

    .law-picker { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-bottom: 18px; }
    .law-picker label { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--muted); text-transform: uppercase; letter-spacing: .04em; }
    .law-picker select { font-size: var(--fs-lg); font-weight: var(--fw-semi); padding: 8px 14px; border-radius: 8px; border: 1px solid var(--border); background: var(--card); color: var(--text); min-width: 260px; }
    .law-count { font-size: var(--fs-sm); color: var(--muted); }

    .law-intro { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 16px 18px; margin-bottom: 16px; }
    .law-intro-h { font-size: var(--fs-sm); font-weight: var(--fw-display); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 8px; }
    .law-intro p { font-size: var(--fs-lg); line-height: var(--lh-loose); margin: 0; }

    .law-legend { background: var(--neutral-bg); border: 1px solid var(--border); border-radius: 12px; padding: 14px 16px; margin-bottom: 18px; }
    .law-legend-h { font-size: var(--fs-sm); font-weight: var(--fw-display); text-transform: uppercase; letter-spacing: .06em; color: var(--muted); margin-bottom: 10px; }
    .law-legend-row { display: flex; align-items: baseline; gap: 10px; padding: 4px 0; }
    .law-legend-row .law-chip { flex-shrink: 0; min-width: 118px; text-align: center; }
    .law-legend-txt { font-size: var(--fs-md); color: var(--muted); line-height: var(--lh-body); }

    .law-card { background: var(--card); border: 1px solid var(--border); border-inline-start-width: 3px; border-radius: 12px; padding: 16px 18px; margin-bottom: 12px; }
    .law-card.law-t-ok { border-inline-start-color: var(--ok); }
    .law-card.law-t-danger { border-inline-start-color: var(--danger); }
    .law-card.law-t-warn { border-inline-start-color: var(--warn); }
    .law-card.law-t-neutral { border-inline-start-color: var(--muted); }
    .law-chip { display: inline-block; font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .04em; border-radius: 999px; padding: 3px 10px; }
    .law-chip.law-ok { color: var(--ok); background: var(--ok-bg); }
    .law-chip.law-danger { color: var(--danger); background: var(--danger-bg); }
    .law-chip.law-warn { color: var(--warn); background: var(--warn-bg); }
    .law-chip.law-neutral { color: var(--muted); background: var(--neutral-bg); }
    .law-name { font-size: var(--fs-xl); font-weight: var(--fw-bold); line-height: var(--lh-snug); margin: 10px 0 0; }
    .law-cite { font-size: var(--fs-sm); color: var(--muted); margin-top: 3px; }
    .law-what { font-size: var(--fs-lg); line-height: var(--lh-loose); margin: 10px 0 0; }
    /* The payoff. Divider spans the full content width, equally inset on both
       sides (card-divider standard) — a section of the card, not a callout. */
    .law-app { margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border); }
    .law-app-h { display: flex; align-items: center; gap: 7px; font-size: var(--fs-sm); font-weight: var(--fw-display); text-transform: uppercase; letter-spacing: .05em; color: var(--accent); margin-bottom: 7px; }
    .law-app-h svg { width: 15px; height: 15px; flex-shrink: 0; }
    .law-app p { font-size: var(--fs-lg); line-height: var(--lh-loose); margin: 0; }
    .law-sources { font-size: var(--fs-sm); color: var(--muted); margin-top: 18px; }

    @media (max-width: 640px) {
      .law-picker select { min-width: 0; width: 100%; }
      .law-legend-row { flex-direction: column; align-items: flex-start; gap: 3px; }
      .law-legend-row .law-chip { min-width: 0; }
    }

    /* The reading area draws the app's thin house bar (see the settings shell's
       internal scrollers) instead of the OS default. Desktop only; mobile already
       draws an overlay bar. Between 761–1099px the page is still a single stack and
       the CONTAINER is the scroller; at ≥1100px the shell splits and .law-main takes
       over (see below), so both selectors carry the house bar. */
    @media (min-width: 761px) {
      #section-laws.active > .app-container,
      #section-laws.active .law-main { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
      #section-laws.active > .app-container::-webkit-scrollbar,
      #section-laws.active .law-main::-webkit-scrollbar { width: 12px; }
      #section-laws.active > .app-container::-webkit-scrollbar-track,
      #section-laws.active .law-main::-webkit-scrollbar-track { background: transparent; }
      #section-laws.active > .app-container::-webkit-scrollbar-thumb,
      #section-laws.active .law-main::-webkit-scrollbar-thumb { background: var(--border); border-radius: 999px; border: 3px solid transparent; background-clip: padding-box; }
      #section-laws.active > .app-container::-webkit-scrollbar-thumb:hover,
      #section-laws.active .law-main::-webkit-scrollbar-thumb:hover { background: var(--muted); background-clip: padding-box; }
    }

    /* ── Desktop shell: fixed rail beside the ONLY scroller ────────────────
       The picker and the label key are reference furniture, not content: on a
       wide screen they move OUT of the vertical stack into a rail beside the
       reading column. SPA convention (same model as Site Config / Settings /
       the request detail): the PAGE does not scroll — the title, the disclaimer
       and the rail stay put, and .law-main (the laws themselves) is the single
       scroller. Below 1100px .law-shell / .law-rail / .law-main are plain blocks
       and the container scrolls, so the stack is exactly what it always was. */
    @media (min-width: 1100px) {
      /* Height chain, top to bottom: the container stops scrolling and becomes a
         flex column → #laws-content fills it → .law-shell fills that → .law-main
         is the one box with overflow. Every link needs min-height:0 or a flex/grid
         child refuses to shrink below its content and the scroll never engages. */
      /* scrollbar-gutter:auto because the container no longer scrolls — the base
         rule's `stable` would keep reserving ~10px here and pull the whole page 5px
         off the left edge every other desktop page lands on. */
      /* The frame already stopped the container scrolling (see THE FRAME'S PLUMBING), so
         all that's left is to hand the height down and STAND DOWN the frame's own
         scroller: at this width .law-main is the one box with overflow, and leaving
         #laws-content scrollable too would nest one scroller inside another. */
      #section-laws #laws-content { flex: 1 1 0; min-height: 0; display: flex; flex-direction: column; overflow: visible; }
      .law-shell { display: grid; grid-template-columns: 300px minmax(0, 1fr); grid-template-rows: minmax(0, 1fr);
        column-gap: 24px; align-items: start; flex: 1 1 0; min-height: 0; }
      /* The rail is static now — nothing under it moves, so there is nothing to
         stick to. It scrolls inside itself only if a short viewport can't hold the
         legend, rather than clipping it. */
      .law-rail { align-self: stretch; min-height: 0; overflow-y: auto; scrollbar-width: none; }
      .law-rail::-webkit-scrollbar { width: 0; }
      /* In a 300px rail the picker stacks (label → select → count) and the legend
         rows break the same way they do on a phone — the chip's 118px column
         would otherwise squeeze the text to a two-word ribbon. */
      .law-rail .law-picker { flex-direction: column; align-items: stretch; gap: 6px; margin-bottom: 14px; }
      .law-rail .law-picker select { min-width: 0; width: 100%; }
      .law-rail .law-legend { margin-bottom: 0; }
      .law-rail .law-legend-row { flex-direction: column; align-items: flex-start; gap: 3px; }
      .law-rail .law-legend-row .law-chip { min-width: 0; }
      /* The one scroller. scrollbar-gutter keeps the bar's lane reserved so the
         cards don't shift when a short jurisdiction doesn't overflow. */
      .law-main { align-self: stretch; min-height: 0; overflow-y: auto; overscroll-behavior: contain;
        scrollbar-gutter: stable; padding-inline-end: 4px; }
      .law-main > .law-card:last-child { margin-bottom: 0; }
    }

    /* At the full 1500px standard a single card runs ~150 characters to the line.
       The cards are self-contained units, so the reading column breaks into two
       once there's room — ~75 characters each, inside the 65–95 band — and the
       page finally fills the same column every other desktop page uses. The intro
       and the empty state stay full width: they're the header of the set. */
    /* Between the split (1100px) and the two-column break there is only room for ONE
       card per row, and on the full-width column that card runs ~120 characters.
       Cap the measure at the ~90 the page was designed around (660px card ≈ 611px of
       text at 14px) and leave the spare width as margin — the rail still sits on the
       page gutter, so it reads as a document column, not a short card. */
    @media (min-width: 1100px) and (max-width: 1399.98px) {
      .law-main > * { max-width: 660px; }
    }

    @media (min-width: 1400px) {
      .law-main { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-content: start; }
      .law-main > .law-intro, .law-main > .empty { grid-column: 1 / -1; margin-bottom: 0; }
      .law-main > .law-card { margin-bottom: 0; }
    }

    /* ── Terms/Privacy: registration checkbox, doc viewer, sign-in gate, admin ── */
    .legal-agree { display: flex; gap: 9px; align-items: flex-start; font-size: var(--fs-sm); color: var(--muted); margin: 12px 0 2px; cursor: pointer; }
    .legal-agree input { margin-top: 2px; flex-shrink: 0; }
    .legal-agree a { color: var(--accent); }
    .legal-doc-card { max-width: 720px; }
    .legal-doc-body { max-height: min(68vh, 640px); overflow-y: auto; font-size: var(--fs-md); line-height: var(--lh-loose); }
    .legal-doc-body h3 { font-size: var(--fs-lg); margin: 16px 0 6px; }
    .legal-doc-body h3:first-child { margin-top: 0; }
    .legal-doc-body p { margin: 0 0 10px; }
    .legal-doc-body ul { margin: 0 0 10px 18px; padding: 0; }
    .legal-doc-body li { margin-bottom: 5px; }
    .legal-gate { z-index: 190; }   /* above the app, below the doc-viewer modal it opens */
    #legal-doc-modal { z-index: 195; }
    .legal-gate-links { display: flex; gap: 10px; flex-wrap: wrap; margin-bottom: 16px; }

    /* ── The first-run legal gate ──────────────────────────────────────────
       This modal is, for most members, the first screen the app ever shows —
       so it gets a welcome's treatment, not a dialog's. Three parts: a tinted
       hero (crest + title + lede), the two documents as openable cards that
       tick off once read, and a real footer bar. Everything below is scoped to
       .lg-* so the generic .modal-card vocabulary is untouched. */
    .legal-gate { background: rgba(17, 24, 39, .62); backdrop-filter: blur(5px); -webkit-backdrop-filter: blur(5px); }
    .lg-card { max-width: 520px; position: relative; overflow: hidden;
      box-shadow: 0 24px 70px rgba(0, 0, 0, .34), 0 0 0 1px rgba(255, 255, 255, .06);
      animation: lgCardIn .42s cubic-bezier(.2, .75, .3, 1) backwards; }
    @keyframes lgCardIn { from { opacity: 0; transform: translateY(14px) scale(.975); } to { opacity: 1; transform: none; } }

    /* Hero. The wash is accent-tinted rather than solid accent: a full brand slab
       behind a legal notice reads as an ad, and a pale tenant accent would need
       --on-accent gymnastics for the body copy underneath. */
    .lg-hero { padding: 26px 26px 20px; text-align: center; border-bottom: 1px solid var(--border);
      background:
        radial-gradient(120% 100% at 50% 0%, rgba(var(--accent-rgb), .16) 0%, rgba(var(--accent-rgb), .04) 55%, transparent 100%),
        var(--card); }
    .lg-crest { width: 54px; height: 54px; border-radius: 50%; display: grid; place-items: center;
      margin: 0 auto 14px; background: var(--accent); color: var(--on-accent);
      box-shadow: 0 6px 18px rgba(var(--accent-rgb), .38);
      animation: lgCrestIn .5s cubic-bezier(.2, .8, .3, 1.25) .1s backwards; }
    .lg-crest svg { width: 27px; height: 27px; }
    @keyframes lgCrestIn { from { opacity: 0; transform: scale(.55); } to { opacity: 1; transform: scale(1); } }
    .lg-title { font-size: var(--fs-3xl); font-weight: var(--fw-display); line-height: var(--lh-tight); margin: 0 0 8px; }
    .lg-lede { font-size: var(--fs-lg); line-height: var(--lh-body); color: var(--muted); margin: 0 auto; max-width: 40ch; }

    .lg-body { padding: 18px 22px 4px; }
    .lg-docs { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }

    /* A document row. Card-shaped and obviously pressable on touch (the whole row
       is the target, no hover-only cues) — see the click-affordance system. */
    .lg-doc { display: flex; align-items: center; gap: 13px; width: 100%; text-align: left;
      padding: 13px 14px; border: 1.5px solid var(--border); border-radius: var(--radius);
      background: var(--card); color: var(--text); font-family: inherit; cursor: pointer;
      transition: border-color .16s, background .16s, transform .12s, box-shadow .16s;
      animation: lgRowIn .4s cubic-bezier(.2, .7, .3, 1) backwards; }
    .lg-docs .lg-doc:nth-child(1) { animation-delay: .16s; }
    .lg-docs .lg-doc:nth-child(2) { animation-delay: .24s; }
    @keyframes lgRowIn { from { opacity: 0; transform: translateY(8px); } to { opacity: 1; transform: none; } }
    .lg-doc:hover { border-color: var(--accent); background: var(--accent-light); }
    .lg-doc:active { transform: scale(.985); }
    .lg-doc:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
    .lg-doc-ico { width: 38px; height: 38px; border-radius: 10px; flex: none; display: grid; place-items: center;
      background: var(--accent-light); color: var(--accent); transition: background .16s, color .16s; }
    .lg-doc-ico svg { width: 19px; height: 19px; }
    .lg-doc-txt { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
    .lg-doc-name { font-size: var(--fs-lg); font-weight: var(--fw-bold); }
    .lg-doc-sub { font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-body); }
    .lg-doc-go { flex: none; width: 22px; height: 22px; display: grid; place-items: center; color: var(--muted); }
    .lg-doc-go svg { width: 18px; height: 18px; grid-area: 1 / 1; }
    .lg-doc-tick { opacity: 0; transform: scale(.5); }
    /* Read state — set by openLegalDoc(). Purely informational: reading is encouraged,
       never required, so this never gates the I Agree button. */
    .lg-doc.read { border-color: var(--ok); background: var(--ok-bg); }
    .lg-doc.read .lg-doc-ico { background: var(--ok); color: #fff; }
    .lg-doc.read .lg-doc-go { color: var(--ok); }
    .lg-doc.read .lg-doc-chev { opacity: 0; transform: scale(.5); }
    .lg-doc.read .lg-doc-tick { opacity: 1; transform: scale(1); }
    .lg-doc-chev, .lg-doc-tick { transition: opacity .2s, transform .28s cubic-bezier(.2, .8, .3, 1.4); }

    .lg-fine { font-size: var(--fs-sm); line-height: var(--lh-body); color: var(--muted); margin: 0 0 4px; }
    .lg-fine strong { color: var(--text); font-weight: var(--fw-semi); }

    /* Footer bar — positive action right, the way out left (action-bar convention). */
    .lg-foot { display: flex; align-items: center; justify-content: space-between; gap: 12px;
      padding: 16px 22px; border-top: 1px solid var(--border); background: var(--neutral-bg); }
    .lg-agree { min-width: 128px; justify-content: center; box-shadow: 0 4px 14px rgba(var(--accent-rgb), .32); }
    .lg-agree:disabled { opacity: .6; cursor: default; box-shadow: none; }
    .lg-signout { text-decoration: none; }

    /* Success beat. Covers the card so the gate closes on a "done" instead of just
       blinking out; legalGateAgree() shows it, waits, then hides the gate. */
    .lg-done { position: absolute; inset: 0; display: none; flex-direction: column; align-items: center;
      justify-content: center; gap: 16px; background: var(--card); }
    .lg-done.show { display: flex; animation: lgFade .22s ease backwards; }
    @keyframes lgFade { from { opacity: 0; } to { opacity: 1; } }
    .lg-done-ring { width: 68px; height: 68px; border-radius: 50%; display: grid; place-items: center;
      background: var(--ok); color: #fff; animation: lgPop .45s cubic-bezier(.2, .8, .3, 1.5) backwards; }
    .lg-done-ring svg { width: 34px; height: 34px; }
    @keyframes lgPop { from { opacity: 0; transform: scale(.4); } to { opacity: 1; transform: scale(1); } }
    .lg-done-txt { font-size: var(--fs-2xl); font-weight: var(--fw-bold); }

    @media (max-width: 480px) {
      .lg-hero { padding: 22px 18px 17px; }
      .lg-body { padding: 16px 16px 4px; }
      .lg-foot { padding: 14px 16px; }
    }
    @media (prefers-reduced-motion: reduce) {
      .lg-card, .lg-crest, .lg-doc, .lg-done, .lg-done-ring { animation: none; }
      .lg-doc, .lg-doc-chev, .lg-doc-tick { transition: none; }
      .lg-doc:active { transform: none; }
    }
    /* ── Legal & Privacy: the documents, as documents ───────────────────────
       Each tile is a real page — .lgd-paper-in renders the document's own text
       at a normal, tokenised size into a fixed 430px column, and the paper
       scales it down. That keeps the miniature honest (it IS the document, so
       it can't go stale) and keeps every size inside it on the type scale
       instead of inventing a set of 3px ones. Clicking the tile opens the
       editor popup; the raw markup never faces a teacher on the way in. */
    /* ── THREE BOXES, ONE SCREEN ───────────────────────────────────────────
       The three things this panel does are unrelated to each other, so they sit
       side by side rather than in a 962px stack inside a 426px scroller. They are
       .set-cards — the bordered, rounded box Branding → Images uses — because that
       is what the rest of Site Config looks like, and a full-bleed railed grid
       here made Legal the one screen in the section that didn't.
       The tracks are NOT equal — they are sized to what is in them. Documents needs
       two tiles side by side (a pair that wraps into a stack doubles the tallest
       box); retention carries three fields and two buttons, and its buttons
       wrapping to a second row costs 41px the screen doesn't have; the domain list
       needs least, so it gives.
       All three are FRACTIONS, not an `auto` measured off the tiles. `auto` never
       yields: at 1280 it held its 404px and left the other two on 202 and 315,
       which is a 158px-wide textarea. Fractions shrink everything together — and
       the tiles shrink with them (see .lgd-doc), so the pair still fits its box
       at any width instead of stacking the moment it runs 8px short. */
    /* The ratio is the one the railed version measured out to (404 / 287 / 450) — that
       balance was right; only the container was wrong. Boxes cost the row 72px the rails
       didn't (the body's two gutters plus two 14px gaps), and taking all 72 out of the
       narrowest box is what left the domain list at 225px under six lines of its own
       caption. Charged proportionally instead, so every box loses the same share. */
    .lgl-cards { grid-template-columns: minmax(0, 1.4fr) minmax(0, 1fr) minmax(0, 1.57fr);
                 min-height: 100%; }
    /* THE BOXES FILL THE CARD, they don't end where their content does. Three boxes of
       three different heights floating in the top half of a screen with an empty bottom
       half reads as a page still loading; three that reach the floor read as the whole
       screen, which is the point of getting it onto one. (Grid already stretches a row's
       items to each other — this is what stretches the ROW to the scroller.) */

    /* …and the domain list fills its box. It is the one field here with no natural size:
       `rows="4"` is a guess, and it was the only thing on the screen a leader had to drag
       to use — so it takes whatever height the box has left, and the drag handle goes
       with it. The floor is the old four rows, so it can never end up smaller than it
       shipped as on a short window. */
    .lgl-sec-signup { display: flex; flex-direction: column; }
    .lgl-sec-signup > .form-field { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; margin-bottom: 0; }
    .lgl-sec-signup > .form-field > #legal-domains { flex: 1 1 auto; min-height: calc(92px * var(--fs-scale, 1)); resize: none; }
    /* Three across stop paying for themselves before .set-cards' own auto-fit floor:
       at 1100 the domain box is 129px of textarea under ten lines of its own caption,
       and the two document tiles no longer fit beside each other. Stacked, it is the
       same screen it was — one column, scrolled — and that is the right trade at a
       width where side by side is only nominally side by side. Measured, not guessed:
       1280 still lands 287/202/361 with everything intact, so the line is below it. */
    @media (max-width: 1200px) {
      .lgl-cards { grid-template-columns: minmax(0, 1fr); }
    }
    /* Body copy in a COLUMN is narrow copy — the paragraph that used to run one
       line at 1,400px runs five here. What survives in a column is the sentence
       about the fields under it; the rest moved to the bar. */
    .ss-panel[data-panel="legal"] .set-desc { margin-bottom: 14px; }
    .lgd-docs { display: flex; flex-wrap: wrap; gap: 16px; align-items: stretch; }
    /* The tile is FLUID between two bounds, not a fixed 172px. It reaches its full
       size on a wide card and shrinks with its column on a narrow one — the paper
       is an aspect-ratio and the text inside it a scale factor, so the whole
       miniature comes down together and the pair never wraps into a stack. */
    .lgd-doc { display: flex; flex-direction: column; flex: 1 1 0;
               min-width: calc(96px * var(--fs-scale, 1)); max-width: calc(172px * var(--fs-scale, 1)); padding: 11px 11px 10px;
               border: 1px solid var(--border); border-radius: 12px; background: var(--card); text-align: left;
               font-family: inherit; color: inherit; cursor: pointer;
               transition: border-color .14s, box-shadow .14s, transform .14s; }
    .lgd-doc:hover { border-color: var(--accent); box-shadow: 0 8px 22px rgba(var(--accent-rgb),.14); transform: translateY(-2px); }
    .lgd-doc:focus-visible { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-light); }
    /* A page TOP, not a whole page: 8.5/11 spent 34px of the tallest column drawing
       paper the fade had already covered up. The gradient is what says "it continues",
       so the sheet only has to be tall enough to read as a sheet — and those 34px are
       the difference between this screen ending at the card and scrolling. */
    .lgd-paper { position: relative; display: block; aspect-ratio: 8.5 / 9; overflow: hidden; background: #fff;
                 border: 1px solid var(--border); border-radius: 5px; box-shadow: 0 1px 3px rgba(0,0,0,.07); }
    /* The page fades out rather than stopping mid-sentence — a document continues,
       and a hard cut reads as "this is all there is". */
    .lgd-paper::after { content: ''; position: absolute; inset: auto 0 0 0; height: 38%;
                        background: linear-gradient(rgba(255,255,255,0), #fff 88%); pointer-events: none; }
    /* The column is sized FROM the scale, not guessed alongside it: width 100%/S then
       scale(S) lands exactly on the paper's width whatever the tile is. A fixed px
       width here (the first attempt) rendered wider than the paper and every line
       got sliced mid-word at the clip edge — which reads as broken, not as a page. */
    .lgd-paper-in { --pgs: .34; position: absolute; top: 0; left: 0; width: calc(100% / var(--pgs));
                    padding: 18px 20px; color: #161616;
                    transform: scale(var(--pgs)); transform-origin: top left; pointer-events: none; }
    .lgd-paper-in h3 { font-size: var(--fs-lg); font-weight: var(--fw-bold); margin: 0 0 6px; }
    .lgd-paper-in h3:not(:first-child) { margin-top: 14px; }
    .lgd-paper-in p { font-size: var(--fs-md); line-height: var(--lh-body); margin: 0 0 8px; }
    .lgd-paper-in ul { margin: 0 0 8px; padding-left: 18px; }
    .lgd-paper-in li { font-size: var(--fs-md); line-height: var(--lh-body); margin-bottom: 3px; }
    .lgd-name { display: block; margin-top: 11px; font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--text); }
    .lgd-sub { display: block; margin-top: 2px; font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-snug); }
    .lgd-foot { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 6px 8px; margin-top: auto; padding-top: 11px; }
    .lgd-chip { font-size: var(--fs-xs); font-weight: var(--fw-semi); color: var(--muted);
                background: var(--neutral-bg); border-radius: 999px; padding: 3px 9px; white-space: nowrap; }
    /* "Your Wording" is the state worth spotting across a row of tiles — it means
       somebody here has already replaced the platform text and owns it now. */
    .lgd-chip-own { color: var(--accent-dark); background: var(--accent-light); }
    .lgd-edit { font-size: var(--fs-sm); font-weight: var(--fw-semi); color: var(--muted); }
    .lgd-doc:hover .lgd-edit { color: var(--accent); }
    /* Version + acceptance, UNDER the two tiles it describes — a caption on them,
       which is what it always was. It used to be a 250px grey box beside them: a
       filled, rounded box inside a white card is the second card the space standard
       exists to stop, and beside them it cost the column 266px of width it needed
       for the tiles themselves. Two lines, no fill, no padding of its own. */
    .lgd-state { display: flex; flex-direction: column; gap: 3px; margin-top: 13px; }
    .lgd-ver { font-weight: var(--fw-bold); color: var(--text); }
    .lgd-acc { font-size: var(--fs-md); color: var(--muted); line-height: var(--lh-body); }
    .lgd-behind { font-size: var(--fs-sm); color: var(--warn); font-weight: var(--fw-semi); }
    /* The loaded text still lives on the panel (legalAdminSave submits it, the popup
       reads and writes it) — it just isn't the interface any more. Not display:none:
       a hidden form control is skipped by some field walkers, and this one has to
       stay readable to fieldsSig and to the popup. */
    .lgd-src-store { position: absolute; width: 1px; height: 1px; padding: 0; border: 0; opacity: 0;
                     overflow: hidden; clip-path: inset(50%); pointer-events: none; }
    /* Retention's own tools, under the boxes they act on. */
    .ret-tools { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 14px; }
    .legal-src { font-weight: var(--fw-body); color: var(--muted); font-size: var(--fs-sm); }

    /* ── The document editor popup ──────────────────────────────────────────
       Wide and tall, because it is the one place on this screen where somebody
       is writing prose. Write/Preview because the light markup is only obvious
       once you've seen what it did. */
    .legal-edit-card { max-width: 780px; height: 88vh; }
    .lge-mode { display: inline-flex; height: 30px; margin-right: 12px; border-radius: 8px; background: var(--bg);
                box-shadow: inset 0 0 0 1px var(--border); overflow: hidden; }
    .lge-mode-btn { padding: 0 14px; border: none; background: none; color: var(--muted); font-family: inherit;
                    font-size: var(--fs-md); font-weight: var(--fw-semi); cursor: pointer; transition: color .14s, background .14s; }
    .lge-mode-btn + .lge-mode-btn { box-shadow: inset 1px 0 0 var(--border); }
    .lge-mode-btn:not(.on):hover { color: var(--text); }
    .lge-mode-btn.on { background: var(--card); color: var(--text); box-shadow: inset 0 0 0 1px var(--border); }
    .legal-edit-body { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; gap: 10px; }
    .lge-hint { margin: 0; }
    #legal-edit-text { flex: 1 1 auto; min-height: 0; resize: none; width: 100%;
                       font-family: ui-monospace, Consolas, monospace; font-size: var(--fs-md); line-height: var(--lh-body); }
    .lge-preview { flex: 1 1 auto; min-height: 0; overflow-y: auto; border: 1px solid var(--border); border-radius: 9px; padding: 16px 18px; }
    .lge-foot { flex: none; display: flex; flex-wrap: wrap; align-items: center; justify-content: flex-end; gap: 10px;
                padding: 12px 20px 14px; border-top: 1px solid var(--border); background: var(--card); }
    .lge-foot > .btn { white-space: nowrap; }
    #legal-edit-reset { margin-right: auto; }
    /* The one sentence that tells the two saves apart. On its own row above them —
       squeezed into the row it made the primary button wrap onto three lines. */
    .lge-note { flex: 1 0 100%; order: -1; margin: 0 0 2px; font-size: var(--fs-sm); color: var(--muted); line-height: var(--lh-snug); }
    @media (max-width: 760px) { .legal-edit-card { height: 92vh; } }

    /* ── Pre-submit confirmation callouts ──
       Two separate cards, each with heading + body + a required checkbox.
       .pii-card-warn = amber (student privacy), .pii-card-accent = brand accent
       (problem-solving step). Submit button stays disabled until both are ticked. */
    .pii-card { border-radius: 10px; padding: 14px; margin: 0 0 14px; }
    .pii-card-head { display: flex; gap: 10px; align-items: flex-start; }
    .pii-card-head .ic { flex: 0 0 auto; margin-top: 1px; font-size: var(--fs-lg); }
    .pii-card-head .ic .lic { width: 15px; height: 15px; }
    .pii-card-title { font-size: var(--fs-md); font-weight: var(--fw-bold); }
    .pii-card-body { font-size: var(--fs-sm); color: var(--text); opacity: .85; margin-top: 2px; line-height: var(--lh-body); }
    .pii-card-check { display: flex; align-items: flex-start; gap: 8px; margin: 10px 0 0; padding: 9px 11px; border-radius: 7px; background: rgba(255,255,255,.45); cursor: pointer; line-height: var(--lh-body); font-size: var(--fs-sm); }
    .pii-card-check input[type="checkbox"] { margin-top: 2px; flex-shrink: 0; }
    .pii-card-warn { background: var(--warn-bg); border: 1px solid rgba(var(--warn-rgb),.30); }
    .pii-card-warn .ic, .pii-card-warn .pii-card-title { color: var(--warn); }
    .pii-card-accent { background: var(--accent-light); border: 1px solid rgba(var(--accent-rgb),.25); }
    .pii-card-accent .ic, .pii-card-accent .pii-card-title { color: var(--accent); }
    .pii-card.pii-invalid { border-color: var(--danger); box-shadow: 0 0 0 2px var(--danger-bg); }
    .pii-card.pii-invalid .ic, .pii-card.pii-invalid .pii-card-title { color: var(--danger); }
    .ret-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 12px; }
    /* In a column the three fields stack, and a .form-field's own 18px bottom margin
       lands on top of the grid's gap — 30px between two number boxes, three times. */
    .ret-grid > .form-field { margin-bottom: 0; }
    .ret-preview { display: flex; flex-direction: column; gap: 4px; margin-top: 8px; font-size: var(--fs-md); }
    .ret-line { color: var(--danger); font-weight: var(--fw-semi); }
    .ret-none { color: var(--muted); font-style: italic; }

    /* ── Escalation to state (docs/Escalation-Plan.md) ──────────────────
       Three cards on the request detail: "we escalated this", "a local
       escalated this to us", and the offer to escalate. Uses the semantic
       status tokens (never a hardcoded status hex) so a local's arbitrary
       brand palette can't collide with them. */
    .esc-card { display: flex; flex-direction: column; gap: 10px; }
    .esc-card.esc-active { border-color: var(--warn); background: var(--warn-bg); }
    .esc-card.esc-origin { border-color: var(--accent); }
    .esc-head { display: flex; align-items: flex-start; gap: 10px; }
    .esc-head .lic { width: 20px; height: 20px; flex: 0 0 20px; margin-top: 1px; color: var(--accent); }
    .esc-card.esc-active .esc-head .lic { color: var(--warn); }
    .esc-title { font-weight: var(--fw-bold); font-size: var(--fs-lg); }
    .esc-sub { font-size: var(--fs-sm); color: var(--muted); margin-top: 1px; }
    /* Full-content-width divider, equal distance from both card edges. */
    .esc-reason { border-top: 1px solid var(--border); padding-top: 10px; font-size: var(--fs-lg); line-height: var(--lh-body); white-space: pre-wrap; }
    .esc-reason-k { display: block; font-size: var(--fs-xs); font-weight: var(--fw-bold); text-transform: uppercase; letter-spacing: .04em; color: var(--muted); margin-bottom: 3px; }
    .esc-note { font-size: var(--fs-sm); color: var(--muted); }
    .esc-packet { border-top: 1px solid var(--border); padding-top: 10px; display: flex; flex-direction: column; gap: 4px; }
    /* Positive action right, destructive left + red — the app-wide action bar rule. */
    .esc-actions { display: flex; justify-content: flex-end; }
    .esc-card .esc-actions .btn.ghost-danger { margin-right: auto; }

    /* Note badges: where a note came from, and who can see it. */
    .cmt-badge { font-size: var(--fs-2xs); font-weight: var(--fw-bold); padding: 1px 7px; border-radius: 999px; text-transform: uppercase; letter-spacing: .03em; margin-left: 6px; display: inline-flex; align-items: center; gap: 3px; white-space: nowrap; }
    .cmt-badge .lic { width: 10px; height: 10px; }
    .cmt-remote { background: var(--neutral-bg); color: var(--neutral); }
    .cmt-localonly { background: var(--warn-bg); color: var(--warn); }
    .comment-item.cmt-relayed { border-left: 3px solid var(--neutral); padding-left: 9px; }
    .mlog-foot .cmt-badge { margin-left: 8px; }

    /* The Local-only toggle in the note composer. */
    .cmt-tool.cmt-scope.on { background: var(--warn-bg); color: var(--warn); border-color: var(--warn); }

    /* Escalate dialog — confirm-card plus a required narrative. */
    .esc-dialog { text-align: left; }
    .esc-dlg-lbl { display: block; font-size: var(--fs-md); font-weight: var(--fw-semi); margin: 12px 0 5px; }
    .esc-dialog textarea { width: 100%; box-sizing: border-box; font: inherit; font-size: var(--fs-lg); padding: 9px 10px; border: 1.5px solid var(--border); border-radius: 8px; background: var(--card); color: var(--text); resize: vertical; }
    .esc-dialog textarea:focus { outline: none; border-color: var(--accent); }
    .esc-dlg-hint { font-size: var(--fs-sm); color: var(--muted); margin-top: 5px; }
    .esc-dlg-hint.esc-dlg-err { color: var(--danger); font-weight: var(--fw-semi); }

    /* ── Wordmark studio — the generated Welcome Splash (public/js/wordmark.js) ── */
    /* Fredoka, SIL OFL, shipped unmodified (subsetting would make a derivative, which
       can collide with a Reserved Font Name). Browsers fetch a font only when text
       actually renders in it, so this 159KB file is pulled ONLY when an admin opens
       the studio — members receive a finished PNG and never load the font at all. */
    @font-face {
      font-family: 'Fredoka Wordmark';
      src: url('/assets/fonts/Fredoka-VariableFont.ttf') format('truetype');
      font-weight: 300 700;
      font-display: swap;
    }
    .wm-card { max-width: 760px; }
    .wm-body { display: flex; gap: 20px; align-items: flex-start; }
    .wm-stage { flex: 0 0 auto; }
    .wm-cv { width: 248px; height: 310px; border-radius: 10px; border: 1px solid var(--border); display: block; }
    .wm-side { flex: 1 1 auto; min-width: 0; }
    .wm-intro { margin-bottom: 14px; }
    .wm-warn { background: var(--warn-bg); color: var(--warn); border-radius: 8px; padding: 9px 11px; font-size: var(--fs-md); font-weight: var(--fw-semi); margin-bottom: 12px; }
    .wm-lines-hd { font-size: var(--fs-md); font-weight: var(--fw-semi); margin-bottom: 6px; }
    .wm-line { display: flex; gap: 6px; align-items: center; margin-bottom: 6px; }
    .wm-line input { flex: 1 1 auto; min-width: 0; font: inherit; font-size: var(--fs-md); padding: 7px 9px; border: 1.5px solid var(--border); border-radius: 8px; background: var(--card); color: var(--text); }
    .wm-line select { flex: 0 0 auto; font: inherit; font-size: var(--fs-md); padding: 7px 6px; border: 1.5px solid var(--border); border-radius: 8px; background: var(--card); color: var(--text); }
    .wm-line input:focus, .wm-field input:focus { outline: none; border-color: var(--accent); }
    .wm-del { flex: 0 0 auto; background: none; border: none; color: var(--muted); font-size: var(--fs-2xl); line-height: var(--lh-none); cursor: pointer; padding: 0 4px; }
    .wm-del:hover { color: var(--danger); }
    .wm-add { margin-top: 2px; }
    .wm-field { margin-top: 14px; }
    .wm-field label { display: block; font-size: var(--fs-md); font-weight: var(--fw-semi); margin-bottom: 5px; }
    .wm-field input { width: 100%; box-sizing: border-box; font: inherit; font-size: var(--fs-md); padding: 7px 9px; border: 1.5px solid var(--border); border-radius: 8px; background: var(--card); color: var(--text); text-transform: uppercase; }
    .wm-actions { display: flex; gap: 8px; justify-content: flex-end; align-items: center; margin-top: 18px; }
    /* House action-bar rule: the resetting action sits left, the positive action right. */
    .wm-actions .ghost { margin-right: auto; }
    @media (max-width: 640px) { .wm-body { flex-direction: column; } .wm-stage { align-self: center; } }

    /* Monogram studio — the same generator, aimed at the App Icon. Shares the
       studio's field/action classes above; only the square stage differs. */
    .mg-card { max-width: 660px; }
    .mg-body { display: flex; gap: 20px; align-items: flex-start; }
    .mg-stage { flex: 0 0 auto; }
    .mg-tile { border: 1px solid var(--border); overflow: hidden; }
    /* Square wears the iOS corner so it reads as an app icon; wide is a logo plate. */
    .mg-tile-sq { width: 200px; height: 200px; border-radius: 44px; }
    .mg-tile-wide { width: 300px; height: 75px; border-radius: 10px; }
    .mg-cv { width: 100%; height: 100%; display: block; }
    .mg-side { flex: 1 1 auto; min-width: 0; }
    .mg-layouts { display: flex; gap: 8px; flex-wrap: wrap; }
    .mg-lay { font: inherit; font-size: var(--fs-md); font-weight: var(--fw-semi); padding: 7px 12px; border: 1.5px solid var(--border); border-radius: 8px; background: var(--card); color: var(--text); cursor: pointer; }
    .mg-lay:hover { border-color: var(--accent); }
    .mg-lay.on { border-color: var(--accent); background: var(--accent); color: var(--on-accent); }
    @media (max-width: 640px) { .mg-body { flex-direction: column; } .mg-stage { align-self: center; } }

    /* ── Platform → App Health ────────────────────────────────────────────
       Adoption per local + the grouped client-error feed. Type comes off the
       ladder (--fs-… / --fw-…); status colour comes off the semantic tokens, so
       an error count is --danger and never a bespoke hex. */
    .ah-sub { font-size: var(--fs-lg); font-weight: var(--fw-bold); line-height: var(--lh-snug); margin: 22px 0 8px; }
    .ah-table { width: 100%; border-collapse: collapse; font-size: var(--fs-md); }
    .ah-table th { text-align: left; font-size: var(--fs-xs); font-weight: var(--fw-semi); text-transform: uppercase; letter-spacing: .05em; color: var(--muted); padding: 6px 10px; border-bottom: 1px solid var(--border); }
    .ah-table td { padding: 8px 10px; border-bottom: 1px solid var(--border); }
    .ah-table .ah-num { text-align: right; font-variant-numeric: tabular-nums; }
    .ah-table .ah-bad { color: var(--danger); font-weight: var(--fw-semi); }
    .ah-table .ah-dim td { color: var(--muted); }
    .ah-table .ah-note { font-size: var(--fs-sm); color: var(--muted); font-style: italic; }
    .ah-err { border: 1px solid var(--border); border-radius: 10px; margin-bottom: 8px; background: var(--card); overflow: hidden; }
    .ah-err-head { display: flex; align-items: baseline; gap: 10px; padding: 10px 12px; cursor: pointer; }
    .ah-err-head:hover { background: var(--danger-bg); }
    .ah-err-n { font-size: var(--fs-xs); font-weight: var(--fw-bold); color: var(--danger); background: var(--danger-bg); border-radius: 999px; padding: 2px 8px; flex: none; font-variant-numeric: tabular-nums; }
    .ah-err-msg { font-size: var(--fs-md); font-weight: var(--fw-semi); flex: 1; word-break: break-word; }
    .ah-err-meta { font-size: var(--fs-2xs); color: var(--muted); flex: none; white-space: nowrap; }
    /* The stack is the one place raw member-browser text is rendered, so it scrolls in
       its own box and never widens the page. */
    .ah-err-stack { font-size: var(--fs-xs); line-height: var(--lh-body); white-space: pre-wrap; word-break: break-word; margin: 0; padding: 10px 12px; border-top: 1px solid var(--border); background: var(--bg); max-height: 300px; overflow: auto; }
    @media (max-width: 640px) {
      .ah-err-head { flex-wrap: wrap; }
      .ah-err-meta { width: 100%; white-space: normal; }
      .ah-table { display: block; overflow-x: auto; }
    }
