/* ============================================================
   CATALYST//SCRUM — design system v4
   Full-width app frame (sidebar + main), five user themes,
   big type hierarchy, avatars, unified escalation chips.
   Shared rules across every theme:
     · dark base, never pure black
     · brand/accent colors are decorative; AMBER/ORANGE/RED are
       reserved for warning semantics and nothing else
     · no blinking, no pulsing, minimal glow (sensory-friendly)
   ============================================================ */

/* ---------- Self-hosted typefaces ----------
   🔴 THESE USED TO COME FROM fonts.googleapis.com ON EVERY PAGE LOAD [fixed 2026-08-04]. Her rule:
   the app makes NO live network calls, because the school network may block them -- and a blocked
   font request meant every page fell back to system faces, losing Atkinson Hyperlegible, which is
   here for legibility rather than decoration. The browser harness found it by hanging 30 seconds on
   a page that had already rendered.

   Files are in public/assets/fonts/, 156KB total, latin + latin-ext only (this app is English-only
   and the full subset set is roughly ten times the bytes). Sora and JetBrains Mono are VARIABLE
   fonts, so one file covers their whole weight range; Atkinson Hyperlegible ships static 400/700.
   All three are SIL Open Font License 1.1, which permits self-hosting -- see fonts/LICENSES.txt.

   font-display:swap keeps text visible while they load, which is what the old Google link asked for
   too. Adding a weight the app does not already use means adding a file: nothing is fetched. */
@font-face{font-family:'Atkinson Hyperlegible';font-style:normal;font-weight:400;font-display:swap;
  src:url('/assets/fonts/AtkinsonHyperlegible-400-latin-ext.woff2') format('woff2');
  unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}
@font-face{font-family:'Atkinson Hyperlegible';font-style:normal;font-weight:400;font-display:swap;
  src:url('/assets/fonts/AtkinsonHyperlegible-400-latin.woff2') format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'Atkinson Hyperlegible';font-style:normal;font-weight:700;font-display:swap;
  src:url('/assets/fonts/AtkinsonHyperlegible-700-latin-ext.woff2') format('woff2');
  unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}
@font-face{font-family:'Atkinson Hyperlegible';font-style:normal;font-weight:700;font-display:swap;
  src:url('/assets/fonts/AtkinsonHyperlegible-700-latin.woff2') format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400 600;font-display:swap;
  src:url('/assets/fonts/JetBrainsMono-latin-ext.woff2') format('woff2');
  unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}
@font-face{font-family:'JetBrains Mono';font-style:normal;font-weight:400 600;font-display:swap;
  src:url('/assets/fonts/JetBrainsMono-latin.woff2') format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}
@font-face{font-family:'Sora';font-style:normal;font-weight:500 800;font-display:swap;
  src:url('/assets/fonts/Sora-latin-ext.woff2') format('woff2');
  unicode-range:U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF}
@font-face{font-family:'Sora';font-style:normal;font-weight:500 800;font-display:swap;
  src:url('/assets/fonts/Sora-latin.woff2') format('woff2');
  unicode-range:U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD}

/* ---------- Themes ---------- */
/* 🔴 THE PAGE IS DARK AND SAYS SO [2026-08-27]. Without this, a browser that re-colours pages it
   believes are light -- Chrome's Auto Dark Mode for Web Contents, chrome://flags/#enable-force-dark --
   treats every theme here as a light page and inverts it, which on an already-dark palette can paint
   text the same value as the panel behind it. That is per-BROWSER, so it survives switching themes in
   the app, and it leaves the markup alone, so the words are still in the DOM: exactly the report of a
   student seeing dialogs with buttons and no text while everyone else saw them fine.
   It also hands the browser the right defaults for scrollbars, form controls and UA dialog chrome, so
   the `input[type=date] { color-scheme:dark }` further down became a special case of this rather than
   the only place the app admitted what it looks like. */
:root { color-scheme: dark; }

:root {
  --bg:#0f1220; --bg-deep:#0b0e19; --panel:#161a2c; --panel-hi:#1c2138; --panel2:#11152500;
  --line:#262c47; --line-hi:#343c60;
  --text:#e7eaf6; --dim:#9aa3c4; --dimmer:#7b83a5;
  --brand:#f79ad3; --brand-ink:#2b1020; --brand-soft:rgba(247,154,211,.14);
  --accent-2:#9aaeff; --accent-2-soft:rgba(154,174,255,.13);
  --accent-3:#7ce8c4; --accent-3-soft:rgba(124,232,196,.12);
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(247,154,211,.09), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(154,174,255,.08), transparent 60%);
  /* --info, 2026-08-09: per-theme now, not a hardcoded blue — checked in OKLab against --amber/
     --red/livewire's --red (dE>=0.20), this theme's own --brand (dE>=0.10, lower chroma), and
     WCAG AA 4.5:1 on --panel/--bg-deep. Derived from --brand's own hue at a softer chroma. */
  --info:#ffcaff; --info-soft:rgba(255,202,255,.10);
}
/* ============================================================================
   Theme palettes — the complete 15-theme set for Catalyst//SCRUM.
   Consolidated 2026-07-25 from the parallel track's themes-append.css (now
   retired) into this single file. That file used to load second, after this
   one, specifically so its corrected values could win the cascade without
   anyone editing this file directly — a coordination seam between two Claude
   sessions working the repo at once, not a technical requirement. Now that
   the theme work has landed for good, one file is simpler and removes the
   two-source-of-truth risk (this file's old blocks had drifted stale under
   the append file's corrections).

   THE CONTRACT — every block defines the same 17 custom properties:
     --bg --bg-deep --panel --panel-hi --line --line-hi
     --text --dim --dimmer
     --brand --brand-ink --brand-soft
     --accent-2 --accent-2-soft --accent-3 --accent-3-soft
     --halo
   PLUS ONE OPTIONAL 18th: **--logomark**, the colour of the ninja mark in the wordmark. It is
   optional by design and NOT part of the contract above: `.logomark` reads
   `var(--logomark, var(--accent-2))`, so a theme that says nothing gets its own secondary accent
   and is already per-theme and already never --brand. A block only sets --logomark when
   --accent-2 fails one of two bars, both measured by tests/ui/audit_logomark.py:
     · dE >= 0.20 from the WORDMARK in OKLab (the file's own figure, from the --info note above;
       that note uses >=0.10 against --brand, but these two colours sit adjacent inside one word
       at 17px, which is the harder comparison), and
     · >= 3:1 against --bg AND --bg-deep, WCAG 2.2 non-text contrast. Both, because the mark's
       knockout band paints --bg-deep while the rest of it sits on --bg.
   ⚠ A pale TINT of the brand hue can clear dE and still read as one colour (measured: pumpkinspice
   and ultraviolet both passed on numbers and failed on the eye). Where the palette has no second
   hue to borrow, take the value vivid rather than light.

   Deliberately NOT redefined here: --amber/--red/--green/--bad/--info and
   their -soft pairs, plus --navdot-bg. Those come from the shared
   `:root, [data-theme] {…}` blocks below (~line 127, ~line 960) so severity
   colours are identical in every theme — the house rule that amber/orange/red
   mean warning and nothing else. Livewire is the one exception: its
   colorblind-safe remap of --green/--red/--bad/--navdot-bg lives in its own
   higher-specificity `html[data-theme="livewire"]` block further down (search
   "Bug H"), deliberately placed after everything else so it always wins —
   nothing needs to be repeated up here for it.

   ORDER = the theme-picker order (ruled by Mrs. Van Patten, 2026-07-24), and
   must keep matching app/config.php's THEMES array — both pickers iterate
   that registry, not this file's order.
   ============================================================================ */

/* 1 · CATALYSTCATS — the default theme and the app's identity palette: burnt
   orange on deep navy. Also the palette the blessed Celebrations report was
   designed on — every fallback literal in teacher/reports.css matches it, so
   a theme that omits a variable degrades to CatalystCats. */
[data-theme="catalystcats"] {
  --bg:#0d1524; --bg-deep:#0a101c; --panel:#14203a; --panel-hi:#192843;
  --line:#27385c; --line-hi:#364d7f;
  --text:#e9eef8; --dim:#9aabc9; --dimmer:#7e899c;
  --brand:#b45424; --brand-ink:#160a04; --brand-soft:rgba(180,84,36,.14);
  --accent-2:#5d84c4; --accent-2-soft:rgba(93,132,196,.13);
  --accent-3:#d9925c; --accent-3-soft:rgba(217,146,92,.12);
  --logomark:#2b7bfe;   /* her "bright neon blue". --accent-2's own hue, taken vivid */
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(180,84,36,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(93,132,196,.08), transparent 60%);
  /* --info, 2026-08-09: brand's own hue (44°) sits almost exactly between --red and --amber, so no
     softened variant of it clears the 0.20 safety margin from either — derived from --accent-2's
     hue instead (still this theme's own family), same brand-relative chroma/AA checks as every
     other theme. */
  --info:#5888d8; --info-soft:rgba(88,136,216,.10);
}

/* 2 · SCRUMNOIR — greyscale. The only hue-free theme in the roster, which
   makes it the strongest option for colour-vision differences: nothing in
   the decorative layer depends on hue at all. Severity colours still arrive
   from the shared semantic block, which is deliberate — a warning must stay
   a warning. */
[data-theme="scrumnoir"] {
  --bg:#101114; --bg-deep:#0c0d10; --panel:#17181c; --panel-hi:#1d1f24;
  --line:#2b2e35; --line-hi:#3b3f49;
  --text:#eceef2; --dim:#8f949e; --dimmer:#7d8189;
  --brand:#e8eaee; --brand-ink:#0c0d10; --brand-soft:rgba(232,234,238,.14);
  --accent-2:#8a8f99; --accent-2-soft:rgba(138,143,153,.13);
  --accent-3:#c2c7d0; --accent-3-soft:rgba(194,199,208,.12);
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(232,234,238,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(138,143,153,.08), transparent 60%);
  /* --info, 2026-08-09: this theme is hue-free by design, so --info stays a near-neutral grey too
     — distinguished from --amber/--red by lightness/chroma alone, same OKLab/AA checks. */
  --info:#84868a; --info-soft:rgba(132,134,138,.10);
}

/* 3 · ULTRAVIOLET — deep violet with a saturated purple brand. NOTE:
   --brand-ink is #f2edfc, a LIGHT ink, unlike every other theme's near-black
   — correct for this palette (light text on a strong violet fill). */
[data-theme="ultraviolet"] {
  --bg:#100b1f; --bg-deep:#0b0716; --panel:#191130; --panel-hi:#20163f;
  --line:#322459; --line-hi:#45327b;
  --text:#eee8fc; --dim:#a89ccf; --dimmer:#847d9d;
  --brand:#7a3ff2; --brand-ink:#f2edfc; --brand-soft:rgba(122,63,242,.14);
  --accent-2:#b39be8; --accent-2-soft:rgba(179,155,232,.13);
  --accent-3:#e0d0ff; --accent-3-soft:rgba(224,208,255,.12);
  --logomark:#00e5ff;   /* neon cyan. Every violet and magenta scored under the bar on a violet wordmark */
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(122,63,242,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(179,155,232,.08), transparent 60%);
  /* --info, 2026-08-09: brand's own hue, softened — same OKLab/AA checks as every theme. */
  --info:#9567ff; --info-soft:rgba(149,103,255,.10);
}

/* 4 · STORMFOREST: gem emerald on forest black — jungle at night, gold eyes
   in the dark. Brand mutes to #1C9E46 and accent-3 moves to sage #7fae9e,
   which also removes an amber decorative colour that competed with warning. */
[data-theme="stormforest"] {
  --bg:#080b09; --bg-deep:#070a08; --panel:#101511; --panel-hi:#151c16;
  --line:#22301f; --line-hi:#304237;
  --text:#e4efe6; --dim:#93a996; --dimmer:#6f8576;
  --brand:#1C9E46; --brand-ink:#04170a; --brand-soft:rgba(28,158,70,.13);
  --accent-2:#6f96ab; --accent-2-soft:rgba(111,150,171,.12);
  --accent-3:#7fae9e; --accent-3-soft:rgba(127,174,158,.10);
  --logomark:#16bdfc;   /* --accent-2's storm blue. A second green on a green wordmark did not read */
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(28,158,70,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(111,150,171,.08), transparent 60%);
  /* --info, 2026-08-09: brand's own hue, softened — same OKLab/AA checks as every theme. */
  --info:#98ffac; --info-soft:rgba(152,255,172,.10);
}

/* 5 · FROSTBYTE — accent-2 to a deeper blue #4a6fd4, accent-3 to pale ice
   #9fc9f2. */
[data-theme="frostbyte"] {
  --bg:#0c1322; --bg-deep:#090f1b; --panel:#121b2f; --panel-hi:#17233c;
  --line:#223252; --line-hi:#2f4470;
  --text:#e7edf8; --dim:#96a5c4; --dimmer:#7485a8;
  --brand:#6db8ff; --brand-ink:#041022; --brand-soft:rgba(109,184,255,.14);
  --accent-2:#4a6fd4; --accent-2-soft:rgba(74,111,212,.14);
  --accent-3:#9fc9f2; --accent-3-soft:rgba(159,201,242,.12);
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(109,184,255,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(74,111,212,.08), transparent 60%);
  /* --info, 2026-08-09: brand's own hue, softened — same OKLab/AA checks as every theme. */
  --info:#5197d9; --info-soft:rgba(81,151,217,.10);
}

/* 6 · BUBBLEGUM — replaces the old NeonBloom entirely. Base moved to a deep
   magenta #5c0d3d (navy read as a bug on a magenta page); --text/--dim lifted
   into the pink family rather than kept as cool greys. Chosen from seven
   contrast-measured candidates: #5c0d3d is the most saturated base that still
   lets the original bubblegum pink #f79ad3 read as the brand (text 11.9, dim
   7.8, brand 6.7, all comfortably AA). */
[data-theme="bubblegum"] {
  --bg:#5c0d3d; --bg-deep:#480a30; --panel:#671e4b; --panel-hi:#702a54;
  --line:#7d3d64; --line-hi:#8d5677;
  --text:#fdeef6; --dim:#e9b9d3; --dimmer:#c498b1;
  --brand:#f79ad3; --brand-ink:#2b1020; --brand-soft:rgba(247,154,211,.16);
  --accent-2:#c98aa8; --accent-2-soft:rgba(201,138,168,.14);
  --accent-3:#ffc9e0; --accent-3-soft:rgba(255,201,224,.13);
  --logomark:#fd13aa;   /* the whole palette is pink, so this separates on chroma, not hue */
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(247,154,211,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(201,138,168,.08), transparent 60%);
  /* --info, 2026-08-09: brand's own hue, softened — same OKLab/AA checks as every theme. */
  --info:#ffcaff; --info-soft:rgba(255,202,255,.10);
}

/* 7 · AURORA — brand #7fd4ad, and accent-2 swings from pink to violet
   #6a4fa8 with accent-3 #b39be0. */
[data-theme="aurora"] {
  --bg:#0d1518; --bg-deep:#0a1114; --panel:#121d21; --panel-hi:#16262b;
  --line:#22363c; --line-hi:#2f4750;
  --text:#e6f1ef; --dim:#94b0ab; --dimmer:#6f8a86;
  --brand:#7fd4ad; --brand-ink:#062318; --brand-soft:rgba(127,212,173,.13);
  --accent-2:#6a4fa8; --accent-2-soft:rgba(106,79,168,.13);
  --accent-3:#b39be0; --accent-3-soft:rgba(179,155,224,.12);
  --logomark:#9051fc;   /* violet against the mint wordmark; --accent-2 also failed 3:1 on --bg */
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(127,212,173,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(106,79,168,.08), transparent 60%);
  /* --info, 2026-08-09: brand's own hue, softened — same OKLab/AA checks as every theme. */
  --info:#5fae8b; --info-soft:rgba(95,174,139,.10);
}

/* 8 · SUMMERSORBET — warm mid-tone: soft pink brand with mint and butter
   accents on a plum-grey base. */
[data-theme="summersorbet"] {
  --bg:#282329; --bg-deep:#211d22; --panel:#332d34; --panel-hi:#3a333b;
  --line:#443c45; --line-hi:#5e535f;
  --text:#efe6ec; --dim:#a89aa2; --dimmer:#9d9599;
  --brand:#e88fb0; --brand-ink:#2a0f1c; --brand-soft:rgba(232,143,176,.14);
  --accent-2:#8fd0c8; --accent-2-soft:rgba(143,208,200,.13);
  --accent-3:#f0d59a; --accent-3-soft:rgba(240,213,154,.12);
  --logomark:#08f0e0;   /* --accent-2's teal against the pink wordmark */
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(232,143,176,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(143,208,200,.08), transparent 60%);
  /* --info, 2026-08-09: brand's own hue, softened — same OKLab/AA checks as every theme. */
  --info:#ffd0f0; --info-soft:rgba(255,208,240,.10);
}

/* 9 · PUMPKINSPICE — burnt orange on dark roasted brown, cream warmth, sage
   calm. */
[data-theme="pumpkinspice"] {
  --bg:#2f2a26; --bg-deep:#282320; --panel:#38322d; --panel-hi:#403933;
  --line:#4b423a; --line-hi:#5d5248;
  --text:#f4ece2; --dim:#b3a99b; --dimmer:#a29a91;
  --brand:#c85a1c; --brand-ink:#1c0f04; --brand-soft:rgba(200,90,28,.16);
  --accent-2:#6f7a6a; --accent-2-soft:rgba(111,122,106,.16);
  --accent-3:#f2d2b6; --accent-3-soft:rgba(242,210,182,.10);
  --logomark:#9ecf86;   /* --accent-2's sage, brightened. A neon green was too loud for autumn */
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(200,90,28,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(111,122,106,.08), transparent 60%);
  /* --info, 2026-08-09: brand's own hue (45°) sits almost exactly between --red and --amber, so no
     softened variant of it clears the 0.20 safety margin from either — derived from --accent-2's
     hue instead (sage, this theme's own "sage calm" per its design comment above). */
  --info:#63ae51; --info-soft:rgba(99,174,81,.10);
}

/* 10 · SUNSETTIDE — cool teal brand with a warm sand accent-2 on a slate
   base. */
[data-theme="sunsettide"] {
  --bg:#232a2e; --bg-deep:#1c2226; --panel:#2c353a; --panel-hi:#333d43;
  --line:#3c474d; --line-hi:#53626a;
  --text:#eaf1f3; --dim:#9aabb0; --dimmer:#929da0;
  --brand:#4aa8c8; --brand-ink:#0c1a1f; --brand-soft:rgba(74,168,200,.14);
  --accent-2:#e0a878; --accent-2-soft:rgba(224,168,120,.13);
  --accent-3:#c8e6f0; --accent-3-soft:rgba(200,230,240,.12);
  --logomark:#fc941a;   /* --accent-2's warm side against the blue wordmark */
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(74,168,200,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(224,168,120,.08), transparent 60%);
  /* --info, 2026-08-09: brand's own hue, softened — same OKLab/AA checks as every theme. */
  --info:#72c8e7; --info-soft:rgba(114,200,231,.10);
}

/* 11 · SYNTHWAVE — the largest correction of the set: brand flips from pink
   #ff7edb to cyan #00f5ff, with pink #ff2aa3 and orange #ff7a00 as accents,
   on a deeper violet base. */
[data-theme="synthwave"] {
  --bg:#120a20; --bg-deep:#0c0716; --panel:#1c1133; --panel-hi:#241540;
  --line:#312058; --line-hi:#453a7d;
  --text:#f2ecfc; --dim:#b8a3d9; --dimmer:#837baa;
  --brand:#00f5ff; --brand-ink:#031c1e; --brand-soft:rgba(0,245,255,.14);
  --accent-2:#ff2aa3; --accent-2-soft:rgba(255,42,163,.14);
  --accent-3:#ff7a00; --accent-3-soft:rgba(255,122,0,.12);
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(0,245,255,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(255,42,163,.08), transparent 60%);
  /* --info, 2026-08-09: brand's own hue, softened — same OKLab/AA checks as every theme. */
  --info:#00d2db; --info-soft:rgba(0,210,219,.10);
}

/* 12 · BIOHAZARD — toxic greens on near-black moss, hot pink warning spill. */
[data-theme="biohazard"] {
  --bg:#0c0f0a; --bg-deep:#090c08; --panel:#121608; --panel-hi:#171c0c;
  --line:#252d16; --line-hi:#37421f;
  --text:#eef5e4; --dim:#a4b48d; --dimmer:#768560;
  --brand:#b7ff2e; --brand-ink:#141c04; --brand-soft:rgba(183,255,46,.12);
  --accent-2:#ff4d6d; --accent-2-soft:rgba(255,77,109,.13);
  --accent-3:#00ff87; --accent-3-soft:rgba(0,255,135,.10);
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(183,255,46,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(255,77,109,.08), transparent 60%);
  /* --info, 2026-08-09: brand's own hue, softened — same OKLab/AA checks as every theme. */
  --info:#79b800; --info-soft:rgba(121,184,0,.10);
}

/* 13 · ARCADE — Pac-Man: near-black maze, saturated yellow lead, maze-blue
   accents, ghost red/green sparks. */
[data-theme="arcade"] {
  --bg:#0a0a10; --bg-deep:#08080d; --panel:#101018; --panel-hi:#15151f;
  --line:#1f1f38; --line-hi:#2d2d55;
  --text:#f2f2e9; --dim:#a3a3b8; --dimmer:#7c7c98;
  --brand:#ffe93d; --brand-ink:#1c1a04; --brand-soft:rgba(255,233,61,.13);
  --accent-2:#4a4ae8; --accent-2-soft:rgba(74,74,232,.18);
  --accent-3:#3dff7a; --accent-3-soft:rgba(61,255,122,.11);
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(255,233,61,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(74,74,232,.08), transparent 60%);
  /* --info, 2026-08-09: brand's own hue, softened — same OKLab/AA checks as every theme. */
  --info:#9f8a00; --info-soft:rgba(159,138,0,.10);
}

/* 14 · OVERCLOCK — brand to pure red #ff1c1c and accent-3 to amber #f5a800.
   Both sit in the severity hue family, so decorative brand elements and
   genuine warnings share a colour space in this theme specifically. */
[data-theme="overclock"] {
  --bg:#131114; --bg-deep:#0e0c0f; --panel:#1a171b; --panel-hi:#211d23;
  --line:#332d36; --line-hi:#453e47;
  --text:#efeaf0; --dim:#a89ba9; --dimmer:#8a7f8a;
  --brand:#ff1c1c; --brand-ink:#1c0404; --brand-soft:rgba(255,28,28,.14);
  --accent-2:#d4dae4; --accent-2-soft:rgba(212,218,228,.10);
  --accent-3:#f5a800; --accent-3-soft:rgba(245,168,0,.12);
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(255,28,28,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(212,218,228,.08), transparent 60%);
  /* --info, 2026-08-09: brand's own hue, softened — same OKLab/AA checks as every theme. Note
     brand ITSELF is --red's own hue family here (this theme's whole point); --info still clears
     the safety margin via lightness/chroma, same as it must in every theme. */
  --info:#ff9d88; --info-soft:rgba(255,157,136,.10);
}

/* 15 · LIVEWIRE — high visibility: near-black, electric blue lead, calmer
   --text/--dim than the original. Tagged colourblind-safe in the picker.
   Its severity-colour remap does NOT live here — see the file-header note
   above and the higher-specificity html[data-theme="livewire"] block later
   in this file ("Bug H"), which deliberately wins the cascade for
   --green/--red/--bad/--navdot-bg regardless of where this block sits. */
[data-theme="livewire"] {
  --bg:#0a0c10; --bg-deep:#07090c; --panel:#10141b; --panel-hi:#151b24;
  --line:#243040; --line-hi:#39424f;
  --text:#e8eef6; --dim:#94a4b8; --dimmer:#727f8f;
  --brand:#43b5ff; --brand-ink:#041220; --brand-soft:rgba(67,181,255,.14);
  --accent-2:#8ad3ff; --accent-2-soft:rgba(138,211,255,.12);
  --accent-3:#ffea8a; --accent-3-soft:rgba(255,234,138,.10);
  --logomark:#f9d70d;   /* --accent-3. --accent-2 IS a lighter shade of this theme's blue wordmark */
  --halo:radial-gradient(900px 480px at 18% -10%, rgba(67,181,255,.10), transparent 65%),
         radial-gradient(800px 420px at 92% 8%, rgba(138,211,255,.08), transparent 60%);
  /* --info, 2026-08-09: brand's own hue, softened — checked against livewire's OWN remapped --red
     (#ff5714, "Bug H" below) as well as the universal --amber/--red, same as every theme, PLUS one
     constraint no other theme needs: §23's colourblind rule that --info and --green (Bug H's own
     "Wong bluish-green" remap, #00b083) must separate on LUMINANCE, not just hue — the exact bug
     that made an earlier "good" #58b0ff indistinguishable from --info. A first candidate at this
     hue (#2395d9) cleared every OTHER check but only gave a 14.9 greyscale gap (needs >20) —
     caught by re-running this theme's own §23 math against the new value, not assumed safe because
     the general 4-constraint search passed it. This lighter candidate gives a 69.8 gap. */
  --info:#71d8ff; --info-soft:rgba(113,216,255,.10);
}

/* Warning semantics — IDENTICAL in every theme so alerts always read the same */
:root, [data-theme] {
  --amber:#f2b95c; --amber-soft:rgba(242,185,92,.14);
  --orange:#f59a6b; --orange-soft:rgba(245,154,107,.15);
  --red:#ff8290; --red-soft:rgba(255,130,144,.13);
  --sans:"Atkinson Hyperlegible", system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --disp:"Sora", var(--sans);
  --mono:"JetBrains Mono", "Cascadia Code", Consolas, Menlo, monospace;
  --r-sm:6px; --r-md:10px; --r-lg:16px;
  --sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:24px; --sp-6:32px;
  --shadow-1:0 1px 3px rgba(0,0,0,.35);
  --shadow-2:0 4px 16px rgba(0,0,0,.3);
}

/* ---------- Base ---------- */
* { box-sizing:border-box; }
html, body { margin:0; padding:0; }
body {
  background:var(--halo), var(--bg);
  background-attachment:fixed;   /* halo pinned to the viewport — horizontal board scroll can't outrun it */
  color:var(--text);
  font-family:var(--sans); font-size:15.5px; line-height:1.55;
  min-height:100vh; -webkit-font-smoothing:antialiased;
}
a { color:var(--brand); text-decoration-color:color-mix(in srgb, var(--brand) 40%, transparent); text-underline-offset:3px; }
::selection { background:var(--brand); color:var(--brand-ink); }
.mono { font-family:var(--mono); }
.dim { color:var(--dim); }
.small { font-size:13px; }
/* "Characters remaining" [her ruling 2026-08-14] — app/ui.php's shared `data-counter` listener paints
   this next to every genuinely free-text capped field. Quiet by default (--dimmer, no border/background
   of its own), because most of the time it is ambient information, not a warning; --amber only once the
   cap is genuinely close (<=10 left), which is the one legitimate use of a reserved warning colour here. */
/* 🔴 ALWAYS UNDERNEATH THE BOX, ON THE RIGHT [her ruling 2026-08-23, with two reference designs].
   Her words: *"You took away space at the end of the line or were inconsistent. It should always go
   underneath the box on the right side either way."* Two things were wrong and they had different
   causes:
     · `text-align` was never set, so the count sat LEFT under a field whose text it is counting from
       the right. Both of her references put it under the box's right edge.
     · In a FLEX row it was not underneath at all — see `.wiztrackrow` below. That is the half that
       "took away space at the end of the line". */
.charcount { display:block; text-align:right; font-size:12px; color:var(--dimmer); margin-top:3px; }
/* 🔴 A COUNTER MUST NEVER DRAG ITS FIELD OUT OF LINE WITH THE FIELD BESIDE IT [her ruling 2026-08-24:
   *"the 40 characters left marker pushes the field up so it's not in line with the date fields
   anymore. The character count should ALWAYS be beneath the form and not push it up in alignment"*].
   ⚠ MEASURED, because the effect is invisible at rest: the counter is painted on focus/input, not on
   load, so it has zero height until she clicks into the field — and then the field jumped **21px** out
   of line with its neighbours (Name top 505 → 493, Date top 503 → 512).
   ⚠ THE REAL FIX FOR THE PLACE SHE FOUND IT was structural and is hers: the holidays add form is now
   its own popup with the name on one line and the dates on the next, so there is no side-by-side field
   left to fall out of line with. This class is for any OTHER row-shaped form: opt a flex row into it
   and the counter is taken out of flow, so the row is measured by its inputs and the counter cannot
   move them. Deliberately opt-in rather than global — there are 58 `data-counter` fields in 21 files,
   and taking every one out of flow risks overlapping whatever sits below a field in a stacked form. */
/* TWO SHAPES, because a stacked field and a side-by-side row need OPPOSITE treatment and one class
   cannot do both:
   · `.ccfield` — a field on its OWN line. RESERVES the counter's 19px permanently, so the counter has
     somewhere to live and nothing below it moves when the count appears. Measured after the fix: the
     date row under the holidays name box moves 0px on focus, where an unreserved line moved it 22.
   · `.ccrow` — a field SHARING a row with others. Reserving space there is what caused her complaint
     in the first place (the wrapper grows, and a bottom-aligned row lifts the input above its
     neighbours), so here the counter comes OUT of flow entirely: the row is measured by its inputs and
     the counter cannot move them. It may overlap whatever sits directly beneath the row, which is why
     this is opt-in per row and not a global rule over all 58 `data-counter` fields. */
.ccfield { position:relative; padding-bottom:19px; }
.ccfield .charcount { position:absolute; left:0; right:0; bottom:0; margin-top:0; }
.ccrow, .ccrow > * { position:relative; }
.ccrow { align-items:flex-end; }
.ccrow .charcount { position:absolute; top:100%; right:0; margin-top:1px; white-space:nowrap; }
.charcount-warn { color:var(--amber); font-weight:600; }
:focus-visible { outline:2px solid var(--brand); outline-offset:2px; border-radius:4px; }
@media (prefers-reduced-motion: reduce) { * { transition:none !important; animation:none !important; } }

/* ---------- App frame ---------- */
/* A4 · 260px, up from 224px [measured, 2026-08-19]. Labelled sidebars normally run 240-280 and this
   one was under that with navigation set SMALLER than paragraph text (see `.nav a` below). The 36px
   it costs is absorbed by `.main`, which already caps at max-width:1560px. */
.app { display:grid; grid-template-columns:260px 1fr; min-height:100vh; }
/* 🔴 `z-index:30` IS NEW AND LOAD-BEARING. `.sidebar` is `position:sticky` with `z-index:auto`, so it
   shares a stacking context with `.main` and, being EARLIER in the DOM, painted underneath it. Two
   things now hang outside the sidebar's own box and would otherwise render behind the page: the
   account block's full-class-name hover (A7) and the collapsed rail's hover labels (A1). */
.sidebar {
  background:var(--bg-deep); border-right:1px solid var(--line);
  padding:22px 14px 16px; display:flex; flex-direction:column; gap:4px;
  position:sticky; top:0; height:100vh; z-index:30;
}
/* A3 · The header row: wordmark left, collapse glyph hard right. Replaces the bare `.logo` anchor,
   which had the row's padding on itself. */
.logorow { display:flex; align-items:center; gap:8px; padding:4px 12px 18px; }
.sbtoggle {
  flex:none; width:30px; height:30px; padding:0; border-radius:var(--r-sm);
  border:1px solid var(--line); background:transparent; color:var(--dimmer);
  display:inline-flex; align-items:center; justify-content:center; cursor:pointer;
  transition:color .15s, background .15s, border-color .15s;
}
.sbtoggle:hover, .sbtoggle:focus-visible {
  color:var(--text); background:var(--panel-hi); border-color:var(--line-hi);
}
/* 🔴 THE WORDMARK MIXES 20% TOWARD `--text` [2026-08-16, contrast pass — her ruling: *"I want them to
   pass accessibility but still be within 'theme'"*]. Pure `--brand` measured 3.62:1 in ultraviolet,
   3.65 in pumpkinspice and 3.84 in catalystcats (the DEFAULT theme) against the sidebar, all short of
   AA's 4.5. 80% is the computed answer, not a chosen one: the HIGHEST share of brand that still clears
   4.5 in all 15 themes, swept in 5% steps and measured on the rendered page with the alpha layers
   composited. Worst case after: 4.76:1, pumpkinspice.
   Staying on `--brand` rather than picking a new colour is what keeps it on theme — the hue is the
   theme's own, only the lightness moves, and 20% is a shift you have to look for. Same computed-mix
   technique, and the same reasoning, as `.b-info` (85%) and `.b-red` (70%). `--brand` itself is NOT
   moved: `.logoinit`, `.pillbtn` and everything else read it unchanged. */
/* A4 · 17px, up from 15px. It was the same size as the nav beneath it, and a wordmark should ANCHOR
   the column rather than tie with it. Padding moves to `.logorow`, which owns the row now.
   🔴 17px / 1.5px letter-spacing IS MEASURED, and the first attempt at 19px / 2px was a real defect
   caught by looking at the render. The header row's inner width is 207px and the toggle plus its gap
   take 38, so the wordmark's budget is 169px. At 19px/2px it needs 182px — and because `.logo` is a
   flex item, it did not visibly overflow the ROW: it silently SHRANK to 169px while its own
   `white-space:nowrap` text kept its full width, so the text ran straight under the toggle glyph.
   `getBoundingClientRect()` reports the shrunk box, which is why this had to be measured with
   `scrollWidth`. Natural widths, measured: 19/2 = 182, 18/1.5 = 171, 17/2 = 168, **17/1.5 = 163**.
   17/1.5 is the largest that clears 169 with real slack rather than by a pixel.
   `flex:none` is the structural half of the fix — it can no longer shrink-and-overflow at all, so a
   future size change fails visibly instead of quietly sliding under the glyph. tests/ui/t_sidebar.py
   also asserts the row does not overflow, because `--disp` ("Sora") is a webfont and the fallback
   metrics differ if it ever fails to load. */
.logo {
  font-family:var(--disp); font-weight:800; font-size:17px; letter-spacing:1.5px;
  color:color-mix(in srgb, var(--brand) 80%, var(--text));
  text-decoration:none; padding:0; white-space:nowrap;
  /* `baseline`, not `center`: centring aligned the mark's BOX to the line box, and the line box is
     taller than the caps (26.34px vs 13.00px at 17px) because it reserves descender room that an
     all-caps wordmark never uses. Baseline alignment ignores that dead space and anchors to the
     letters themselves, which is the thing the mark has to line up with. */
  display:inline-flex; align-items:baseline; flex:none;
}
.logomark { position:relative; top:.157em; }
/* A5 · The ninja mark stands where the `//` used to. `.logo span` (the spaced slashes) is gone with
   it — the mark IS the separator now, so the letter-spacing on the wordmark provides the air and the
   margin here only balances it against the mark's own optical width. */
/* 🔴 THE MARK IS ITS OWN COLOUR, PER THEME [her ruling 2026-08-23]. Her reasoning, verbatim:
   *"Because the rest of the scrum dojo text does change per theme right? Like the title text? Which
   is why the svg should change per theme too."* Correct on both halves — `.logo` above is
   `color-mix(in srgb, var(--brand) 80%, var(--text))`, so the wordmark is already per-theme, and the
   mark was inheriting that exact value through `currentColor`. That is why it read as one flat
   colour instead of a mark sitting inside a wordmark.
   ⚠ A LITERAL COLOUR IS THE ONE THING THAT CANNOT GO HERE. She asked for "that bright neon blue" for
   CatalystCats, but this rule paints the mark in all 15 themes, and a hardcoded blue would be wrong
   in 14 of them — a blue mark on scrumnoir's greyscale or arcade's neon-green wordmark is not "still
   in theme", which is the condition she attached to the request.
   So it takes a TOKEN: `--accent-2`, the theme's secondary accent. It is one of the 17 properties
   every theme block is contractually required to define (see the contract note at the top of this
   file), so this is per-theme for free in all 15 with no new property and no contract change, and it
   is by construction never `--brand` — the "not the same color as scrum and dojo" half.
   In CatalystCats `--accent-2` is #5d84c4, a steel blue against the burnt-orange wordmark. That is a
   blue, but it is not NEON, so `--logomark` is an optional override that falls back to it: one
   property in one theme block brightens CatalystCats alone, without touching the other 14 or
   disturbing `--accent-2`'s many other uses. See HTMLSnapshots/LIVE-LOGO/ for the comparison. */
.logomark {
  display:inline-flex; align-items:center; margin:0 .3em 0 .22em; flex:none;
  color:var(--logomark, var(--accent-2));
}
/* Size and seat, both in `em` so the one rule serves the 17px sidebar AND the 18px auth wordmark and
   neither call site carries a magic number.
     · `.94em` ink height against a `.7647em` cap height = the mark stands **1.5px proud at 17px**,
       her "slightly bigger so that it's slightly taller".
     · `1.253em` keeps the cropped 64:48 aspect. Do not set one without the other.
     · `top:.157em` is the seat, and the number is MEASURED, not derived. `align-items:baseline` on
       `.logo` was supposed to land the SVG's bottom edge exactly on the text baseline — her first
       option, "the bottoms should all line up" — and then this nudge would push it down by half the
       excess so it overhangs EQUALLY above and below, which is her second option and the better of
       the two on a round mark. It landed 1.19px high of that: `.logomark` is itself an inline-flex
       box, so what `.logo` aligns is ITS synthesized baseline and not the SVG's own bottom edge, and
       the difference is its strut. Rather than restructure the box to chase a clean derivation, the
       seat absorbs it — hence .157em and not the .088em half-the-excess figure. tests/ui/t_sidebar.py
       asserts the resulting overhang is symmetric, so if a font or box change ever moves it, that
       fails loudly instead of drifting. Measured after: 1.48px proud at the top, 1.49px below the
       baseline. `position:relative` moves paint only, so it cannot reflow the row or reopen the
       overflow this wordmark already had once. */
.logomark svg { height:.94em; width:1.253em; }
/* A4 · 15.5px and a 46px row, both measured rather than eyeballed. The nav was 14.5px while `body`
   is 15.5px — the app's PRIMARY navigation set smaller than its paragraph text, which is backwards —
   and its rows computed to ~42px, which clears WCAG 2.5.8's 24px floor but sits under the 44-48px
   that reads as comfortable primary nav. 12px of vertical padding on a 15.5px/1.35 line lands at 46.
   `position:relative` is new and load-bearing: it is the positioning context for the collapsed
   rail's hover bubble and for the dot that re-anchors onto the icon (see the collapse block). */
.nav a {
  display:flex; align-items:center; gap:12px; position:relative;
  padding:12px 12px; border-radius:var(--r-md);
  color:var(--dim); text-decoration:none; font-weight:700; font-size:15.5px;
  transition:background .15s, color .15s;
}
.nav a .lbl { min-width:0; }
.nav a:hover { background:var(--panel-hi); color:var(--text); }
/* 🔴 THE COLOUR-ON-A-TINT-OF-ITSELF PATTERN, APP-WIDE [swept 2026-08-14]. `.b-info`/`.b-pink` (below)
   explain the shape: painting a token as text on a ~10-14% tint of THAT SAME token is inherently low
   contrast whatever the hue, because the background is always close to the text's own colour. This
   selector — the active sidebar item, on EVERY page — was one of 12 real instances found by sweeping
   every `color:var(--X)` + `background:var(--X-soft)` pair in this file (2 more matched the same
   regex but set only border-color, not text colour, and are not contrast issues). All 12 get the
   same treatment: lighten the TEXT toward `--text` by the same computed share already solved for
   `.b-info`/`.b-pink`/`.b-aday`/`.b-bday` — 55% for `--brand`, 55% for `--accent-2` — rather than
   moving the tokens, which clear AA against `--panel`/`--bg-deep` and are not the problem. */
.nav a.active { background:var(--brand-soft); color:color-mix(in srgb, var(--brand) 55%, var(--text)); }
/* 🔴 THE NAV MIXES TEXT GLYPHS WITH SVG ICONS, AND THAT IS WHY THEY LOOKED UNEVEN
   [her report 2026-08-27: *"the calendar meeting one appears bigger than the others and I really
   think they all could be a little bigger"*].
   MEASURED with tests/ui/audit_nav_icons.py rather than guessed, because two icons can share a width
   and still read as different sizes:
     · the text glyphs (Dashboard, Board, Messages, Teams, Class Settings, Avatar library) rendered at
       font-size 15.5px;
     · the SVG icons (Meetings, Help Queue) render at a fixed 20x20 via the rule below.
   So the SVGs were ~30% larger than everything beside them. Raising the glyphs to 19px brings the
   painted height in line with Meetings' 18x19 ink, which is the icon she named as readable.
   ⚠ THIS DOES NOT FIX EVERYTHING, and the rest is logged rather than papered over. `▦` (Board) and
   `☻` (Avatar library) are a dense fill pattern and a filled dingbat: bigger, they are still muddier
   than line art, because the problem is the GLYPH and not its size. Converting those two to real SVGs
   is the actual fix and is logged in docs/POST-DEPLOY.md.
   ⚠ Help Queue is an SVG and still reads small: its ink measured 7x13 inside its 20x20 box, the
   smallest painted glyph in the nav. That is its artwork, not this rule -- also logged. */
.nav .ico { width:24px; font-size:19px; line-height:1; text-align:center; opacity:.9; flex:none; }
/* Meetings and Settings are inline SVG rather than a glyph [no-emoji sweep, 2026-08-02] — they were
   the sidebar's only two emoji. Sized here, not on the element, and stroked with `currentColor`, so
   they inherit the link's own colour in all three states and light up with the brand colour when
   active. The glow is deliberately active-only: a drop-shadow on a dim, inactive icon reads as blur,
   not neon. Same treatment `.bubble svg` already gets. */
/* A4 · 20px, up from 17px — sized for the wider rail and the taller row. */
/* 🔴 20px, AND IT WENT TO 22 AND BACK THE SAME DAY [2026-08-28]. Worth recording so nobody raises it
   a third time on the strength of the note that used to live here.
   Her 2026-08-27 note asked for two things: *"the board and avatar library ones are really hard to
   read"* (EVENNESS) and *"I really think they all could be a little bigger"* (SIZE). The
   glyph-to-SVG pass that same day fixed the evenness half — every nav item is an SVG now, Help Queue
   got its own icon, and the set measures identical 20x20 boxes with an identical 1.9px stroke
   (audit_nav_icons.py). She uploaded that and looked at it: *"The current nav bar looks really good
   though ... they look good."* Told that the 22px raise was a separate, not-yet-uploaded change, she
   ruled: *"revert it because it looks good as is."*
   ⚠ SO THE SIZE HALF OF HER ORIGINAL NOTE WAS ALREADY SATISFIED BY FIXING THE EVENNESS. That is the
   thing to remember: the icons read small BECAUSE two of them were mismatched text glyphs, not
   because 20px is small. Raising the number was solving a problem that had already stopped existing.
   ⚠ `.navcirc svg` stays at its own 21px for the same reason — see that rule. The two families being
   one pixel apart is deliberate now rather than accidental: 21 in a 40px circle is the ring of space
   that rule was tuned for, and 20 in the rail is what she has approved twice.
   ⚠ The remaining complaint, that the calendar "appears bigger", is NOT a size problem and no value
   here can fix it: the boxes are identical, the ARTWORK is not drawn to one scale. Painted extent in
   viewBox units out of 24x24 — Teams 18x14 (252 sq), Meetings 18x19 (342). A 36% ink difference.
   Evening that out means redrawing icons, which is hers. See POST-DEPLOY §2. */
.nav .ico svg { width:20px; height:20px; display:block; margin:0 auto; }
.nav a.active .ico svg { filter:drop-shadow(0 0 5px color-mix(in srgb, var(--brand) 60%, transparent)); }
.sidebar .spacer { flex:1; }
.devmode {
  font-family:var(--mono); font-size:11px; line-height:1.5; color:var(--amber);
  background:var(--amber-soft); border-radius:var(--r-sm); padding:7px 10px; margin-bottom:10px;
}
/* `.idblock` was `.me` with the interactivity removed [2026-08-13, and `.me` itself deleted
   2026-08-14 once nothing emitted it any more]: it STATES who and where, and the
   three `.navcirc` controls below it are the only things you can click. Deliberately no :hover and
   no cursor:pointer — it must not look like a control, because it is not one any more. */
/* padding-TOP is deliberately larger than the bottom: the divider needs air under it before the
   avatar, or the rule reads as attached to the name rather than separating the two boxes. */
/* 🔴 A6 · ITS OWN SHADOWED BOX [her ruling 2026-08-19, from SideBar-Collapse-Concept-WithAccount.webp:
   *"See also how they do the account in this? That's how I want it!"*]. `.acctbox` wraps the identity
   half and the three controls so they read as ONE card. The `border-top` divider that used to separate
   this from the nav is GONE — the card's own border and shadow do that job now, and keeping both read
   as a line stuck to the top of a box. */
.acctbox {
  margin:0 2px 2px; padding:14px 14px 14px; border-radius:var(--r-md);
  background:var(--panel); border:1px solid var(--line);
  box-shadow:0 8px 24px -10px rgba(0,0,0,.7);
}
.idblock {
  display:flex; align-items:center; gap:12px; padding:2px 2px 18px;
  color:var(--text); cursor:default;
}
.idblock .who { line-height:1.3; min-width:0; }
/* ⚠ DERIVED FROM THE PADDING ABOVE, never nudged by eye -- so it has to be recomputed whenever the
   card's padding changes or the name silently overflows its column. At the 260px sidebar:
   260 − 28 (sidebar padding) − 4 (box margin) − 28 (box padding) − 36 (avatar) − 12 (gap) = 152.
   It has moved twice in one day, both times because she asked for more room in this card: 162 when
   the box had 10px padding, 156 at 12px, and 152 now at 14px. The ellipsis stays as the backstop for
   the DISPLAY NAME, which has no server-side truncation of its own. */
.idblock .who b { font-size:14px; display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:152px; }
/* `.role` now carries WHERE YOU ARE — the current class for a teacher, the current team for a
   student [Option A, approved 2026-08-04]. It gets the same ellipsis cap `.who b` already had,
   because a real class title ("Game Development (Subclass)") is far longer than the role words
   this line used to hold and would otherwise push the sidebar wide. */
/* A7 · 12px, one line, and TRUNCATED AT 28 CHARACTERS IN PHP with the full name on hover via
   `.htip`/`.htiptext` — see app/ui.php. The CSS ellipsis stays underneath it as a second line of
   defence (a 28-character title in a wide glyph can still overrun 162px), but the ellipsis a teacher
   actually sees on a long class name is the PHP one, which is the half that can carry a tooltip. */
.idblock .who .role { font-size:12px; color:var(--dimmer); font-family:var(--mono);
  display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; max-width:152px; }
.idblock .who .role .rolet { display:block; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
/* 🔴 THE JAM'S PARENT, ON ITS OWN SECOND LINE [her design 2026-09-07, amending Option A]. Her words:
   *"While it's focused into the 'jam', I think that should be the main class title. So like Fall Game
   Jam (Game Development) and that would be really long vertically, maybe put the (Game Development)
   still in parenthesis on a second line?"* It renders ONLY while active_subclass() returns one, so
   this rule is dormant on every class that is not currently running a jam.
   ⚠ IT DELIBERATELY ADDS NOTHING BUT A LINE. Same size, same colour, same mono face and the same
   152px ellipsis cap as `.rolet` above -- her sketch writes both lines plainly, and the parentheses
   are what mark the second one as subordinate. `line-height` is trimmed only so a two-line scope does
   not push the three nav circles below it out of the card. */
.idblock .who .role .rolesub { line-height:1.25; }
/* The hover carries the UNtruncated title, so it must not be clamped to the sidebar's own width — and
   it is anchored LEFT rather than centred. `.htip .htiptext` centres on its trigger, which is right
   for an inline dot in a table row but wrong for a 162px block inside a 260px sidebar: a long title
   at `width:max-content` is wider than its trigger, so centring pushes half of it off the left edge of
   the screen. Anchored left it grows rightward over the page, which is where the room is.
   `.htip.role .htiptext` is three classes (0,3,0), so it beats the base `.htip .htiptext` (0,2,0)
   wherever either rule happens to sit — deliberate, because the base rule lives ~400 lines lower and
   an equal-specificity override up here would silently lose (that exact trap cost a round on
   `.driftchip`, see its note). */
.htip.role .htiptext {
  left:0; transform:none; white-space:nowrap; width:max-content; max-width:none;
}

/* The account-scope control row: switch / settings / log out [her design 2026-08-13].
   34px, comfortably past WCAG 2.5.8's 24px floor (the same minimum t_pulse_taptarget.py enforces
   elsewhere). Colour and glow come from theme tokens so these light up per theme exactly like the
   nav's own SVG glyphs rather than being a fixed accent. */
.navcircles { display:flex; gap:10px; padding:0; }
.navcirc {
  position:relative; width:40px; height:40px; border-radius:50%; padding:0;
  display:inline-flex; align-items:center; justify-content:center;
  background:var(--panel-hi); border:1px solid var(--line);
  cursor:pointer; text-decoration:none; transition:color .15s, border-color .15s, background .15s;
  /* NEON AT REST, not grey [her call 2026-08-13]. These read as live controls the way the nav's own
     glyphs do, rather than waiting for a hover to come alive. Hover/active deepen it rather than
     introducing the colour. --brand is per-theme, so each theme lights them in its own neon. */
  color:var(--brand);
  filter:drop-shadow(0 0 4px color-mix(in srgb, var(--brand) 30%, transparent));
}
/* 🔴 ONE SIZE FOR ALL THREE, SET HERE AND NOT ON THE ICONS. svg_nav_settings() ships with NO
   width/height attributes (it is sized by `.nav .ico svg` in its original home), so inside a flex
   circle it stretched to fill and the cog rendered visibly larger than the two icons that do carry
   explicit dimensions. A CSS rule beats the attribute, so this normalises all three regardless of
   how each helper was written. 20px in a 38px circle leaves an even 9px ring of breathing room.
   Grown twice on 2026-08-27, both times at her ask: 34/18 read as cramped in the one card every
   student uses daily, then 38/20 still did. 40px still clears the collapsed rail, which has 48px of
   usable width, and three 40px circles plus two 10px gaps = 140px inside a 204px content column. */
/* 21px. Briefly 22 on 2026-08-28 to match the rail, reverted with it — see `.nav .ico svg`. These
   three were grown twice at her ask on 2026-08-27 (34/18, then 38/20, then 40/21) and 21 in a 40px
   circle is the ring of space the sizing note above was tuned for. Being one pixel off the rail is
   fine: they are different containers, and both figures are ones she has looked at and approved. */
.navcirc svg { width:21px; height:21px; flex:none; }
.navcirc:hover, .navcirc:focus-visible, .navcirc.active {
  color:color-mix(in srgb, var(--brand) 55%, var(--text)); border-color:var(--brand); background:var(--brand-soft);
  filter:drop-shadow(0 0 7px color-mix(in srgb, var(--brand) 55%, transparent));
}
/* ⚠ HOVER ALONE WOULD EXCLUDE KEYBOARD USERS, so this fires on :focus-visible too — the same pairing
   `.htip:focus-visible .htiptext` already uses for the board's colour swatches. Each control also
   carries a real aria-label; this bubble is the sighted-user half, not the accessible name. */
/* ⚠ LEFT-ANCHORED, NOT CENTRED. Centring on the circle put "Switch or Create a Class" half outside
   the 224px sidebar, where it was clipped unreadable — caught in the first live capture. Growing
   rightward from each circle's own left edge keeps the longest label inside the rail. */
.navcirctip {
  position:absolute; bottom:calc(100% + 8px); left:0;
  white-space:nowrap; background:var(--bg-deep); color:var(--text);
  border:1px solid var(--line-hi); border-radius:var(--r-sm); padding:4px 8px;
  font-size:11.5px; opacity:0; pointer-events:none; transition:opacity .12s; z-index:60;
}
.navcirc:hover .navcirctip, .navcirc:focus-visible .navcirctip { opacity:1; }
/* The cross-class dot: something needs you in a class you are not standing in. Now that every page
   is single-class this is the only signal that exists for it. */
.navcircdot { position:absolute; top:1px; right:1px; }
.navlogout { display:block; color:var(--dimmer); font-size:12.5px; text-decoration:none; padding:6px 12px; }
.navlogout:hover { color:var(--text); }

/* ---------- Sidebar collapse [A1/A2/A3, her ruling 2026-08-19, CONFIRMED BY HER 2026-08-23] --------
   🔴 THIS REPLACES THE 2026-08-13 FULL-HIDE RULING, AND `.sidebar-rail`/`.railtab` ARE DELETED.
   ✅ SHE WAS ASKED DIRECTLY ABOUT THE CONTRADICTION AND SETTLED IT ON 2026-08-23: *"I changed my mind.
   I wanted to use the specific image design I sent you as reference and I want collapsing to look like
   that. I did some research on design and that's what I came up with so the later note supercedes the
   first."* So the icon rail is not merely the later of two rulings, it is the one she chose knowing
   both. DO NOT REINSTATE FULL-HIDE from any older note.
   Her reference image is docs/Snapshot_Review_Notes/SideBar-Collapse-Concept.webp. What the old model
   said, for the record: full-hide, the whole sidebar as one unit, "the collapsed state doesn't need to
   access anything in it", with a full-height seam rail as the toggle.
   WHY IT CHANGED, and it is a DEFECT FIX rather than a preference: `display:none` on the sidebar
   bought 224px of width and paid for it by destroying navigation outright — collapsed, there was
   nothing to click from anywhere, which is exactly why the control was not worth using. Collapsed is
   now a 76px ICON RAIL: every destination still reachable, labels hidden, and it still reclaims 184px.
   A2 · THE ARROW AND ITS BUBBLE ARE GONE. Her words: *"I don't think the collapse bar needs the little
   arrow… I don't like it."* The deleted `.railtab` comment recorded THREE rounds on that one control,
   quoting her twice ("the arrow bubble is bigger than the bar", "the bubble overflows the width of the
   collapsed bar again"). README §7 says stop after two failed attempts on an approved surface and
   offer accepting the defect instead of trying again — so this is a removal, not a fourth attempt.
   A3 · The toggle lives in the header row now (`.logorow` / `.sbtoggle` above), right-aligned when
   expanded and alone-and-centred when collapsed. */
.app.sidebar-collapsed { grid-template-columns:76px 1fr; }
.app.sidebar-collapsed .sidebar { padding:22px 10px 16px; }
/* (The stacking context the hover labels below need is set once on `.sidebar` itself, further up.) */
/* A3 · nothing survives collapsed but the glyph, centred. */
.app.sidebar-collapsed .logorow { justify-content:center; padding:4px 0 18px; }
.app.sidebar-collapsed .logo { display:none; }
/* The rows become icon buttons. */
.app.sidebar-collapsed .nav a { justify-content:center; gap:0; padding-left:0; padding-right:0; }
/* 🔴 THE LABEL IS HIDDEN WITH OPACITY, NEVER `display:none` — AND IT DOUBLES AS THE HOVER BUBBLE.
   `display:none` would strip the label from the accessibility tree, leaving a screen reader eight
   unlabelled links: a worse defect than the one A1 fixes. Opacity keeps the accessible name intact,
   and since the element is still there it can BE the tooltip her reference image shows, so the rail
   needs no extra markup and no JS placer. `:focus-visible` is included so a keyboard user gets the
   same label a mouse user does — the parity rule `.htip` and `.qtip` already follow. */
.app.sidebar-collapsed .nav a .lbl {
  position:absolute; left:calc(100% + 12px); top:50%; transform:translateY(-50%);
  opacity:0; pointer-events:none; z-index:60;
  background:var(--panel); border:1px solid var(--line-hi); border-radius:9px;
  padding:7px 11px; font-size:13px; white-space:nowrap;
  box-shadow:0 10px 30px -8px rgba(0,0,0,.65); transition:opacity .12s;
}
.app.sidebar-collapsed .nav a:hover .lbl,
.app.sidebar-collapsed .nav a:focus-visible .lbl { opacity:1; }
/* A dot means "this needs you", so it has to survive the collapse — it re-anchors onto the icon
   rather than going with the words. This is why the dot is a SIBLING of `.lbl` in app/ui.php. */
.app.sidebar-collapsed .nav a .navdot-plain,
.app.sidebar-collapsed .nav a .navdot { position:absolute; top:6px; right:8px; }
/* The account card collapses to the avatar plus its three controls, stacked. */
.app.sidebar-collapsed .acctbox { padding:8px 4px; }
.app.sidebar-collapsed .idblock { justify-content:center; padding:2px 0 8px; }
.app.sidebar-collapsed .idblock .who { display:none; }
.app.sidebar-collapsed .navcircles { flex-direction:column; align-items:center; gap:6px; }

.main { padding:26px 34px 80px; max-width:1560px; width:100%; }
.main-wide { max-width:none; }

/* ---------- Auth layout (login / join / setup) ---------- */
.authwrap { min-height:100vh; display:flex; flex-direction:column; align-items:center; padding:9vh 16px 40px; }
.logo-auth { font-size:18px; padding-bottom:26px; }
.authcard {
  width:100%; max-width:440px;
  background:var(--panel); border:1px solid var(--line); border-radius:var(--r-lg);
  padding:26px 30px 22px; box-shadow:var(--shadow-2);
}
.authcard.authwide { max-width:880px; }
.authwide .themepick label { flex:0 0 calc(20% - 8px); max-width:none; min-width:0; }
.authwide .avpick { justify-content:flex-start; gap:12px; }
.authwide .avpick .avopt, .authwide .avmorebox { flex:0 0 calc(12.5% - 11px); width:auto; }
@media (max-width:900px) {
  .authwide .themepick label { flex:1 1 150px; }
  .authwide .avpick .avopt, .authwide .avmorebox { flex:0 0 72px; }
}
.authcard h1 { font-family:var(--disp); font-size:24px; font-weight:700; margin:0 0 6px; letter-spacing:-.01em; }
/* ---- Signup wizard header [2026-08-02] ----
   A centred title block: the constant "CREATE ACCOUNT" plus the changing step line. Her ask was that
   the step line be "bigger than they are currently and centered under the header like a real
   subheading" — it used to be small `.dim` caption text with a negative top margin.
   Scoped to `.wizhead` ON PURPOSE: `.authcard h1` above is shared by login.php, setup.php and
   teacher-signup.php, so centring it globally would restyle three pages nobody asked about.
   The step line takes `--brand` because it is the "you are here" signal, which is worth colour;
   17px keeps it clearly secondary to the 24px h1 while still reading as a heading, not a caption. */
.wizhead { text-align:center; margin-bottom:20px; }
.wizhead h1 { margin:0 0 6px; }
.wizsub { font-family:var(--disp); font-size:17px; font-weight:600; color:var(--brand); margin:0; }

/* ---------- Page head + sprint rail ---------- */
.pagehead { display:flex; align-items:flex-end; justify-content:space-between; gap:20px; flex-wrap:wrap; }
.pagehead h1 { font-family:var(--disp); font-size:32px; font-weight:700; letter-spacing:-.02em; margin:0; }
.pagehead .sub { color:var(--dim); margin:4px 0 0; font-size:15px; }

/* Classes page: class-picker row under the page head. Scoped to .classrow (unique to this page)
   so it can't bleed into other pages' layouts. */
.classrow { display:flex; align-items:center; gap:14px; flex-wrap:wrap; margin-bottom:22px; justify-content:flex-start; }
.classrow .styledsel { font-size:19px; font-weight:700; padding:11px 16px; min-width:340px; }

/* Classes page: Class Overview tab header row + Leadership/Course Dates/Blocks/Sub-Classes sections. */
/* 🔴 `nowrap` MADE THE PAGE SCROLL SIDEWAYS [measured 2026-08-18, six widths, Classes design audit].
   §37a set `flex-wrap:nowrap` so the row "can never break onto a second line and lose the alignment
   that way" — but the row grew from three buttons to four when Manage Add-Ons landed (its own note at
   classes.php:1568 predicted "expect the row to wrap sooner"), and at 1100px the measurement is
   `.clshead` scrollWidth 878 against clientWidth 762, which pushes the whole document to 1159/1100:
   a horizontal scrollbar on the page, which is worse than the thing nowrap was protecting.
   ⚠ THE RULING'S GOAL IS KEPT, ONLY ITS MECHANISM CHANGES. §37a's alignment comes from
   `.clsheadactions { margin-left:auto }`, not from nowrap, and `margin-left:auto` right-aligns the
   group just as hard on its own wrapped line as it does inline. Verified at all six widths: 1920 /
   1600 / 1440 / 1366 unchanged (single line, actions hard right); 1280 and 1100 drop the actions to
   their own right-aligned line instead of overflowing. An ENDED class has two buttons and never
   wraps at any of the six. */
/* D2 [Snapshot Review Group D]: the class name (and its 26px heading rule, and its own pencil
   override) moved up to `.classrow` and no longer render in here at all — see classes.php's own
   note. That freed the row for exactly what D2 asked for: bigger chips, more space between them.
   `gap` widened 10px -> 18px and `.statchip` grew (padding/font) accordingly, below. */
.clshead { display:flex; align-items:center; gap:18px; margin-bottom:14px; flex-wrap:wrap; }
.clsheadactions { display:flex; align-items:center; gap:10px; margin-left:auto; flex:none; }
.statchip { display:inline-flex; align-items:center; gap:6px; padding:8px 16px; font-size:14px;
  border:1px solid var(--line-hi); border-radius:20px; background:var(--panel-hi); color:var(--dim);
  text-decoration:none; white-space:nowrap; }
a.statchip:hover { border-color:var(--brand); color:var(--text); }
/* 🔴 SAME MEASUREMENT, HARDER FAIL [2026-08-18]. This is 13.5px, so the bar is 4.5, and raw `--brand`
   on `.statchip`'s `--panel-hi` fails in **5 of 16**: pumpkinspice 2.67, catalystcats 2.97 (the
   DEFAULT theme), ultraviolet 3.06, sunsettide 4.09, overclock 4.30. Mixed to 55% — the SAME share
   `.b-aday`/`.b-bday` and `.abbadge` already carry, so this codebase keeps one number for
   "a token used as text on a tint of itself" rather than accumulating a second near-miss. Verified at
   55%: worst 4.89 (pumpkinspice). 60% would also clear (4.55) but only just; the derivation ran
   100/90/80/70/65/60/55/50/45/40 and is in the audit's scratch output. */
.statchipnum { color:color-mix(in srgb, var(--brand) 55%, var(--text)); font-family:var(--mono); font-size:13.5px; }
.statchip.statchip-static .statchipnum { color:var(--text); }

.sechead { display:flex; align-items:center; gap:10px; margin:0 0 8px; }
.sechead h2 { font-size:17px; margin:0; }
.ovrow { display:flex; align-items:baseline; gap:10px; padding:6px 0; border-bottom:1px solid var(--line); }
.ovrow:last-child { border-bottom:0; }
.ovk { color:var(--dim); font-size:13px; min-width:110px; flex:none; }
.ovv { font-weight:600; }
.ovv.ovmono { font-family:var(--mono); font-size:13.5px; font-weight:500; }

.subtablewrap { max-height:420px; overflow-y:auto; border:1px solid var(--line); border-radius:var(--r-md); }
table.subtable { width:100%; border-collapse:collapse; font-size:13.5px; }
/* D6 [Snapshot Review Group D, her note: "the background does not span the header row's full
   height"]. Measured, not assumed: the generic `table { margin: var(--sp-3) 0 }` rule (style.css,
   Tables section) was never reset on `table.subtable`, so it carried a 12px top margin INSIDE its
   scrolling wrapper — a gap between the wrapper's own top edge and where the sticky header's
   painted background actually starts. Confirmed live on `#blktable`: wrapper top 597.02px, header
   row top 610.02px, a 12.999px difference matching --sp-3 almost exactly.
   ⚠ SCOPED TO THE TWO SCROLLING WRAPPERS ONLY (`.subtablewrap`, `.gpcap`), not `table.subtable`
   itself: the Sprint Schedule table (`.sptable`) shares this class but sits in plain flow inside
   `.spbody`'s own padding, never a scrolling container, so it has no sticky header to gap and
   resetting its margin there would just be an unrelated, unasked-for spacing change. */
.subtablewrap table.subtable, .gpcap table.subtable, .teachcap table.subtable { margin:0; }
table.subtable th { position:sticky; top:0; background:var(--panel-hi); text-align:left;
  font-size:11.5px; text-transform:uppercase; letter-spacing:.04em; color:var(--dim); padding:8px 10px;
  border-bottom:1px solid var(--line-hi); }
table.subtable td { padding:8px 10px; border-bottom:1px solid var(--line); }
table.subtable tr:last-child td { border-bottom:0; }
/* 🔴 THE COLOUR-ON-ITS-OWN-TINT PATTERN, FOURTH INSTANCE [measured 2026-08-18, Classes design audit].
   `--accent-2` on a 14% tint of `--accent-2` failed WCAG AA in **7 of 16 themes**: aurora 2.43,
   pumpkinspice 2.44, arcade 2.82, frostbyte 3.17, bubblegum 3.40, catalystcats 3.56 (the DEFAULT),
   synthwave 4.44. Exactly the shape `.abbadge` hit in the Teacher Meetings audit and `.b-aday`/
   `.b-bday` hit before that, so it takes the SAME 55% share rather than a third computed number.
   Verified at 55% across all 16: worst 4.81 (pumpkinspice), best 9.71 (overclock). */
.subsprintcount { display:inline-block; min-width:22px; padding:2px 8px; border-radius:20px;
  font-family:var(--mono); font-weight:700;
  color:color-mix(in srgb, var(--accent-2) 55%, var(--text));
  background:color-mix(in srgb, var(--accent-2) 14%, transparent); }

.sprintrail {
  margin-top:22px;
  background:linear-gradient(180deg, var(--panel-hi), var(--panel));
  border:1px solid var(--line-hi); border-radius:var(--r-lg);
  padding:18px 24px 20px;
  display:grid; grid-template-columns:minmax(0, 1fr) auto auto; gap:6px 24px; align-items:center;
}
/* 🔴 THE RAIL'S REFLOW FIX, and the constraint was the TRACKS rather than the item [2026-08-16].
   `1fr auto auto` cannot shrink: the two `auto` columns (the stat blocks and the chip) size to their
   content, so at a 320px viewport — 1280px at 400% browser zoom, WCAG 1.4.10's own target — the grid
   claimed 389px inside a 296px parent and the overflow was clipped by the page's `overflow-x:hidden`,
   taking the right-hand sprint date and part of the START RETROSPECTIVE control with it.
   `minmax(0, 1fr)` above lets the first column actually shrink (a bare `1fr` has an implicit
   min-content floor, which is the same trap as a grid item's `min-width:auto`). Below the phone
   breakpoint the three columns stack instead, because at 250-odd px they genuinely do not fit side by
   side and stacking is what the rest of this layout already does. Desktop is untouched. */
@media (max-width:640px) {
  .sprintrail { grid-template-columns:minmax(0, 1fr); }
  .sprintrail .stats { flex-wrap:wrap; gap:14px 20px; text-align:left; }
}
/* The rail's middle slot: compact retro button for the chair, chair chip for everyone else */
.railmid { display:flex; align-items:center; gap:10px; }
.retrobtn2 { white-space:nowrap; padding:9px 14px; letter-spacing:1px; text-decoration:none; }
.chip-chair { font-family:var(--mono); font-size:11.5px; letter-spacing:1.5px; font-weight:700; color:var(--amber);
  border:1px solid color-mix(in srgb, var(--amber) 45%, transparent); border-radius:999px; padding:5px 12px;
  background:color-mix(in srgb, var(--amber) 10%, transparent); white-space:nowrap; }
.sprintrail .label { font-family:var(--mono); font-size:11.5px; letter-spacing:2px; color:var(--brand); font-weight:600; }
.sprintrail h2 { margin:2px 0 0; font-family:var(--disp); font-size:22px; font-weight:700; border:0; padding:0; text-transform:none; letter-spacing:-.01em; color:var(--text); }
.sprintrail h2 .dot { color:var(--dimmer); font-weight:500; padding:0 7px; }
.sprintrail .stats { display:flex; gap:26px; text-align:right; }
.sprintrail .stat b { display:block; font-family:var(--disp); font-size:24px; }
.sprintrail .stat span { font-size:12.5px; color:var(--dim); }
/* 🔴 `min-width:0` IS THE WHOLE REFLOW FIX FOR THE SPRINT RAIL [2026-08-16]. A grid item defaults to
   `min-width:auto`, which refuses to shrink below its own MIN-CONTENT width — so at a 320px viewport
   (1280px at 400% browser zoom, WCAG 1.4.10's target) `.rail` measured 389px inside a parent that was
   correctly 296px, and the overflow was then clipped by the page's `overflow-x:hidden`, taking the
   right-hand sprint date with it. Measured, not guessed: the parent `section.sprintrail` was already
   fitting; only the item refused to.
   `min-width:0` is a no-op wherever there IS room, so this changes nothing at desktop widths — it just
   stops the item from claiming a floor it does not need. `.marks` may then wrap rather than push,
   which is the correct behaviour when three date labels genuinely cannot sit on one 250px line. */
.rail { grid-column:1 / -1; margin-top:8px; min-width:0; }
.rail .marks { flex-wrap:wrap; gap:2px 10px; }
.rail .track { height:9px; border-radius:99px; background:var(--bg-deep); border:1px solid var(--line); overflow:hidden; }
.rail .fill { height:100%; border-radius:99px; background:linear-gradient(90deg, var(--accent-2), var(--brand)); }
.rail .marks { display:flex; justify-content:space-between; font-size:11.5px; color:var(--dimmer); margin-top:6px; font-family:var(--mono); }
.chip-halfway { display:inline-block; margin-left:8px; font-size:11.5px; font-weight:700; color:color-mix(in srgb, var(--accent-2) 55%, var(--text)); background:var(--accent-2-soft); border-radius:99px; padding:3px 11px; vertical-align:3px; }
.rail-compact { padding:12px 20px 14px; margin-top:14px; }
.rail-compact h2 { font-size:17px; }
.rail-compact .stat b { font-size:19px; }
.rail-compact .rail { margin-top:5px; }

/* 🔴 GROUP L, NOTE 4 · *"There is WAY too much extra space between the alerts/absences lines and the
   next boxes."* MEASURED, and it was three spacings stacking on one seam: `.tracker-grid` is itself a
   grid ITEM inside `.grid`, which already supplies `gap:16px` between rows, and it was adding its own
   `margin-top:22px` on top of that — and its nested `.tracker-panel`s are not direct `.grid` children,
   so `.grid > .panel { margin:0 }` never reached them and they kept `.panel`'s own margin too.
   Measured gap between the Alerts/Absences row and the trackers: 54px, against 15-16px between every
   other row on the page. Removing the duplicate margin puts this seam on the same 16px rhythm as the
   rest, which is what makes it read as one page rather than two stacked ones. */
.tracker-grid { display:grid; grid-template-columns:repeat(2, minmax(0, 1fr)); gap:16px; margin-top:0; }
.tracker-grid > .panel { margin:0; }
.tracker-panel { min-height:0; display:flex; flex-direction:column; }
.tracker-panel .tracker-body { flex:1; min-height:0; max-height:360px; overflow:auto; padding-right:6px; }
.tracker-panel .tracker-body .prow { padding:11px 4px 11px 0; }
.tracker-panel .empty { padding:12px 4px 4px 0; }
[data-panel] { display:flex; flex-direction:column; }
[data-panel] > h2 + * { flex:1; min-height:0; }
[data-panel] .empty { padding-top:8px; }
[data-panel] .delrow, [data-panel] .pacerow { padding-right:8px; }
@media (max-width:900px) {
  .tracker-grid { grid-template-columns:1fr; }
}

/* ---------- Grid of cards / panels ---------- */
.grid { margin-top:22px; display:grid; gap:16px; grid-template-columns:repeat(12, 1fr); }
/* 24-column panel grid (blessed settings design, 2026-07-24). Every legacy span DOUBLES so
   existing pages are unaffected; .span2h/.span9h give the 2.5/9.5 split the blessed layout uses.
   The set was previously missing span2, span8, span10 and span11 entirely — using an undefined
   span silently drops the grid-column rule and the panel collapses to one column. */
.grid { grid-template-columns:repeat(24, 1fr); }
.span2  { grid-column:span 4;  } .span3  { grid-column:span 6;  } .span4  { grid-column:span 8;  }
.span5  { grid-column:span 10; } .span6  { grid-column:span 12; } .span7  { grid-column:span 14; }
.span8  { grid-column:span 16; } .span9  { grid-column:span 18; } .span10 { grid-column:span 20; }
.span11 { grid-column:span 22; } .span12 { grid-column:span 24; }
.span2h { grid-column:span 5;  }  /* 2.5 of 12 */
.span9h { grid-column:span 19; }  /* 9.5 of 12 */
@media (max-width:1150px) {
  .span2,.span3,.span4,.span5,.span6,.span7,.span8,.span9,.span10,.span11,.span2h,.span9h
    { grid-column:span 24; }
}

.panel {
  background:var(--panel); border:1px solid var(--line); border-radius:var(--r-lg);
  padding:20px 22px; margin:var(--sp-4) 0 0; box-shadow:var(--shadow-1);
}
.grid > .panel { margin:0; }
/* 🔴 `min-width:0` ON EVERY GRID ITEM — THE SAME REFLOW FIX `.rail` ALREADY CARRIES [2026-08-25].
   A grid item defaults to `min-width:auto`, which refuses to shrink below its own MIN-CONTENT
   width, so the TRACK grows to fit it and the panel overhangs a container that is itself sized
   correctly. Measured at a 320px viewport (= 1280px at 400% browser zoom, WCAG 1.4.10's own
   target), before this rule:
     · /helpqueue.php   .grid was 296px wide and its single track computed to 623px;
                        `.panel.span12` rendered 623px, right edge at 635.
     · /class_schedule.php  same shape, track 345px, right edge at 357.
   And it was NOT visible as a scrollbar, which is why nothing had caught it: `html, body
   { overflow-x:hidden }` at <=640px CLIPPED the overhang instead of scrolling it, so roughly half
   of the help queue and of My Classes was not merely awkward to reach, it was UNREACHABLE. That is
   the same "clipped, not scrolled — REAL CONTENT LOSS" failure the inbox table's own note in this
   file describes, and the worst kind of 1.4.10 failure.
   ⚠ THE INNER ROWS ALREADY HAD min-width:0 (`.qsechead`, `.clshead`, `.ovrow` all measured 0) —
   only their PARENT panel did not, so the fix had to go on the grid ITEM, not further in.
   `min-width:0` is a no-op wherever there is room, so desktop layout is unchanged; it only stops an
   item claiming a floor it does not need. Same reasoning, verbatim, as the sprint rail's. */
.grid > * { min-width:0; }

/* 🔴 WCAG 2.5.8 TARGET SIZE ON THE CONTROLS THAT MISSED IT [2026-08-25, the magnifier pass].
   2.5.8 (AA) asks for 24x24 CSS px. Measured across every student page at 640px and 320px, these
   were the only real shortfalls once the measurement was corrected to score the LABEL of a
   label-wrapped control and to honour the spec's inline-text exemption:
     · the inbox's select-all and per-message checkboxes rendered 13x13 — bare `<input>`s in table
       cells with no wrapping label to enlarge the hit area, and the worst gap of the set;
     · the help queue's `?` tooltip trigger at 18x18;
     · a My Classes text input 20px tall.
   These are deliberately NOT inside a media query: 2.5.8 applies at every viewport, and a control
   that is hard to hit at 400% zoom is hard to hit with a trackpad too. The checkboxes keep their
   own painted size — only the box they occupy grows — so the change reads as spacing, not as a
   sudden pair of large checkboxes. */
/* 🔴 18px BOX, 24px TARGET [her report 2026-08-27]: *"these checkboxes... are WAY too big compared to
   everything else."* This used to be width/height:24px -- the WCAG 2.5.8 target floor applied to the
   visible control, which renders a native checkbox as a 24px square next to 13px text.
   The floor is still met, via the standard's own SPACING exception: an 18px control with >=3px of
   undisturbed space on every side has a 24x24 effective target. The cell padding below is what
   provides that space, so it is declared here rather than inherited by luck.
   ⚠ Do not "simplify" this back to sizing the input. How easy something is to hit and how large it
   looks are only the same thing when it has no room around it. */
#msgtable input[type=checkbox] {
  appearance:none; -webkit-appearance:none; width:24px; height:24px; margin:0; padding:0;
  background:transparent; border:0; cursor:pointer; position:relative; vertical-align:middle;
}
/* The visible box: 18px, centred in the 24px target. inset:3px is what makes those two numbers
   independent -- shrink this and the target does not move. */
#msgtable input[type=checkbox]::before {
  content:''; position:absolute; inset:3px; border-radius:4px;
  border:1px solid var(--line-hi); background:var(--bg-deep);
}
#msgtable input[type=checkbox]:checked::before { background:var(--brand); border-color:var(--brand); }
/* ⚠ appearance:none removes the native TICK as well as the box, so it has to be drawn back. A
   checkbox that cannot show it is checked would be a far worse bug than one that looks too big. */
#msgtable input[type=checkbox]:checked::after {
  content:'✓'; position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
  font-size:13px; font-weight:700; color:var(--bg-deep);
}
/* And the focus ring, for the same reason: there is no native control left to show it. */
#msgtable input[type=checkbox]:focus-visible { outline:2px solid var(--brand); outline-offset:1px; border-radius:6px; }
#msgtable th:first-child, #msgtable td:first-child { padding-left:10px; padding-right:10px; }
/* ⚠ NO RULE FOR `.qtip-trigger` HERE, AND THAT IS THE CORRECTION, NOT AN OVERSIGHT [2026-08-25].
   A `min-width/min-height:24px` was briefly added here and it was WRONG TWICE over: the trigger
   already carries a centred transparent `::after` of exactly 24x24 (see its own rule further down),
   so 2.5.8 was satisfied before anything was touched — and forcing the BUTTON to 24px made it
   overflow the 20px `.qtip` circle it sits inside, which `tests/ui/t_orphan_classes.py` caught as
   "the trigger no longer overflows its .qtip circle". The reflow suite's own measurement was what
   was wrong: a bounding box does not see a pseudo-element hit area. It now accounts for one. */
/* The Board's assignee chips (`Theo`, `Maya`, ...) are real buttons — clicking one reassigns the
   card — and measured 44-52 x 22, two pixels short. Only the height moves; the pill keeps its
   shape and its padding. */
.pchip-btn { min-height:24px; }
/* The "Show Completed Sprints" toggles on My Classes are a bare checkbox inside an unclassed
   <label>, so the label IS the target and it measured 167x20. `.spbody`/`.gpcap` are the two
   containers those rows live in, which scopes this without needing `:has()` — worth avoiding, since
   the school's browsers are not all guaranteed to be new enough for it. */
.spbody label, .gpcap label { min-height:24px; display:inline-flex; align-items:center; gap:6px; }
.panel h1 { font-family:var(--disp); font-size:22px; font-weight:700; margin:0 0 8px; letter-spacing:-.01em; }
.panel h2, h3.ph {
  margin:0 0 4px; font-family:var(--disp); font-size:16.5px; font-weight:700; color:var(--text);
  letter-spacing:0; text-transform:none; border:0; padding:0;
  display:flex; align-items:center; gap:9px; flex-wrap:wrap;
}
.panel .hint { color:var(--dimmer); font-size:12.5px; margin:0 0 12px; }
/* A heading that is immediately followed by its own hint keeps the tighter pairing: the hint belongs
   to the heading, so the 14px above would read as a gap INSIDE one unit [note 7, 2026-08-23]. */
.panel h2:has(+ .hint) { margin-bottom:4px; }
/* 🔴 `.hint` INSIDE A DIALOG HAD NO RULE AT ALL [added 2026-08-17, Teacher Teams audit]. The rule
   above is scoped to `.panel`, and #absdlg/.switchdlg each had to restyle it by hand — so a `.hint`
   in any OTHER dialog silently fell through to the dialog's inherited `--text` at the body's 15.5px.
   Measured on Teams, where three delete dialogs share one component family and rendered their
   warning line three different ways: `#delteam*` and `#delallteams-*` painted 15.5px `--text`
   (indistinguishable from the sentence above them), while `#delallcosdlg-*` happened to sit inside
   `<section class="panel">` and picked up 12.5px `--dimmer` — which FAILS AA on `--panel-hi` in all
   15 themes (3.94 bubblegum to 4.48 arcade). Same silent-degradation shape as `.qtip-trigger`,
   `.dashfilter` and `.spill`.
   `--dim`, NOT `--dimmer`: this is the caveat under a destructive inventory, `--dimmer` cannot clear
   AA on this surface in any theme, and `--dim` is already what every `dialog label` uses (worst 4.55,
   summersorbet). Specificity (0,1,1) deliberately loses to `.panel .hint` (0,2,0), so a hint inside a
   real panel — Team Settings' Join code and Slack panels — keeps the treatment it already had. */
dialog .hint { color:var(--dim); font-size:12.5px; }
p { margin:var(--sp-2) 0 var(--sp-3); }

/* ---------- Unified escalation panel rows + chips ---------- */
.prow {
  display:grid; grid-template-columns:1fr auto; gap:4px 14px;
  padding:11px 4px; border-top:1px solid var(--line); align-items:center;
}
.prow:first-of-type { border-top:0; }
.prow .l1 { font-weight:700; font-size:15px; }
.prow .l2 { color:var(--dim); font-size:13px; }
.prow .l2 .team { font-family:var(--mono); color:var(--dimmer); font-size:11.5px; letter-spacing:1px; }
.prow .l3 { color:var(--dim); font-size:12.5px; margin-top:4px; padding:6px 10px; background:var(--bg-deep); border-radius:var(--r-sm); }
.chip {
  justify-self:end; font-size:11px; font-weight:700; letter-spacing:.04em;
  border-radius:99px; padding:4px 12px; white-space:nowrap; border:0;
}
.chip.g0 { background:var(--panel-hi); color:var(--dim); }
.chip.g1 { background:var(--amber-soft); color:var(--amber); }
.chip.g2 { background:var(--orange-soft); color:var(--orange); }
.chip.g3 { background:var(--red-soft); color:var(--red); }
.empty { color:var(--dimmer); font-size:14px; padding:12px 4px 4px; }
.empty b { color:var(--accent-3); }

/* stat tiles */
.tiles { display:grid; grid-template-columns:repeat(3, 1fr); gap:12px; }
.tile { background:var(--bg-deep); border:1px solid var(--line); border-radius:var(--r-md); padding:14px 16px; }
.tile b { display:block; font-family:var(--disp); font-size:28px; letter-spacing:-.02em; }
.tile span { font-size:12.5px; color:var(--dim); }
.tile.hl b { color:var(--brand); }
.tile.ok b { color:var(--accent-3); }

/* team overview cards */
.teamgrid { display:grid; grid-template-columns:repeat(auto-fill, minmax(235px, 1fr)); gap:13px; }
.teamgrid-capped { max-height:calc(3 * 154px + 2 * 13px); overflow:auto; padding-right:6px; }
.teamcard {
  display:block; text-decoration:none; color:var(--text);
  background:var(--bg-deep); border:1px solid var(--line); border-radius:var(--r-md);
  padding:15px 17px; transition:border-color .15s, transform .15s;
}
.teamcard:hover { border-color:var(--line-hi); transform:translateY(-2px); }
.teamcard .code { font-family:var(--mono); font-size:11.5px; color:var(--brand); letter-spacing:1.5px; }
/* h3 since 2026-08-07 — the card's team name was an <h4> directly under the panel's <h2>Teams</h2>,
   which skipped a heading level 19 times over. See teacher/index.php's own note.
   The audit that made this change also kept `.teamcard h4` in the selector "in case another caller
   appears"; that shim is removed, per AI-INSTRUCTIONS' delete-freely rule — `.teamcard` renders in
   teacher/index.php and nowhere else (grepped), so the h4 arm was dead the moment the markup changed,
   and a dead branch that still looks live is the expensive thing here.
   (For the record, the claim that no blessed snapshot styles `.teamcard h4` was wrong — ten of them
   do. It makes no difference: each snapshot carries its own inlined copy of this stylesheet and never
   reads the live file, so they are unaffected either way.) */
.teamcard h3 { margin:3px 0 9px; font-family:var(--disp); font-size:16.5px; font-weight:700; }
.teamcard .meta { display:flex; gap:13px; font-size:12.5px; color:var(--dim); flex-wrap:wrap; }
.teamcard .meta b { color:var(--text); font-family:var(--mono); font-weight:600; }
.teamcard .flag { margin-top:9px; font-size:12px; font-weight:700; color:var(--amber); }
.teamcard .flag.bad { color:var(--red); }
/* 🔴 THIS WAS PAINTING "Healthy — no warnings" ORANGE [fixed 2026-08-07, Teacher Dashboard audit].
   `--accent-3` is a DECORATIVE per-theme accent, and in 5 of the 16 themes it resolves to an amber or
   orange: catalystcats #d9925c, synthwave #ff7a00, overclock #f5a800, arcade #f0d59a, sunsettide
   #f2d2b6. Measured live in Chromium as vanpatten (catalystcats): computed colour rgb(217,146,92).
   So the one GOOD state on the card was rendering in the warning palette — and in the same column as
   its own amber `.flag` and red `.flag.bad` siblings, which made "healthy" and "2 active warnings"
   near-indistinguishable. Straight against the house rule at the top of this file.
   `--green` is the right token and the only correct one here: it is defined once in the shared
   `:root, [data-theme]` block so it is identical in all 15 themes (unlike `--accent-3`), and Livewire's
   colourblind-safe remap of `--green` is picked up for free. `.flag`/`.flag.bad` already read from
   that same shared severity set, so all three variants now come from one palette instead of two. */
.teamcard .flag.good { color:var(--green); }
.teamcard .actions { margin-top:10px; display:flex; gap:8px; }

/* ---------- Forms ---------- */
label { display:block; margin:var(--sp-4) 0 var(--sp-1); color:var(--dim); font-size:12.5px; font-weight:700; }
/* 🔴 EVERY BLOCK-PICKER ROW IS ITSELF A <label> [Snapshot Review note 12, 2026-08-19: "too much space
   between these checkbox rows"], so the generic rule above stacked its own 16px-top/4px-bottom margin
   ON TOP of the 8px flex `gap` these rows already use for spacing — 28px between rows, not 8px. The
   inline style on each row overrides `display` but never touched `margin`, so it was invisible from
   the markup alone; found by measuring, not guessing. Scoped to the two block-picker row lists
   (`#blkpickdlg`'s existing-class picker, the New Class wizard's own Blocks step) rather than a global
   `label { margin }` change, which would also touch every stacked label-above-field pair sitewide. */
#blkpickrows label, #blkwizrows label { margin:0; }
input[type=text], input[type=password], input[type=number], input[type=date],
input[type=datetime-local], select, textarea {
  width:100%; background:var(--bg-deep); color:var(--text);
  border:1px solid var(--line); border-radius:var(--r-sm);
  padding:10px 12px; font-family:var(--sans); font-size:14.5px;
  transition:border-color .15s, box-shadow .15s;
}
select {
  padding-right:38px;
  appearance:none; -webkit-appearance:none; -moz-appearance:none;
  /* 🔴 N2 / O18 · BIGGER DROPDOWN ARROWS, APP-WIDE [her note, built 2026-08-23]. Her words, given
     twice — once on Meetings and once on Teams: *"The drop down arrow symbols could be bigger, this
     is true of everywhere we use them but it's been a struggle to make them bigger. It's possible we
     need to make our own svg arrow."*
     ⚠ **HER SECOND SENTENCE IS ALREADY DONE, AND BOTH ITEMS SAY OTHERWISE.** N2 and O18 both describe
     this arrow as *"a CSS `linear-gradient` triangle pair… which cannot scale cleanly"* and conclude
     that a real SVG is the fix. Checked before building: there is no `linear-gradient` arrow anywhere
     in this file. It has been an inline SVG chevron at a 24-unit viewBox for some time, which is why
     enlarging it is a one-number change rather than the rebuild the items budgeted for. The stale
     premise is presumably why *"it's been a struggle"* — the struggle was per-page overrides fighting
     a base size that was simply too small.
     13px → 17px, and `stroke-width` 3 → 2.5 with it: stroke-width is in viewBox units, so it scales
     WITH the glyph, and keeping 3 at the larger size would have made a chunkier arrow rather than a
     bigger one. `padding-right` grows to clear the wider glyph (12px offset + 17px arrow = 29px).
     ⚠ THE COLOUR IS DELIBERATELY UNCHANGED, and that is a measured call, not an oversight. It is a
     hardcoded grey — a data-URI SVG is a separate document and cannot read a CSS variable, so this
     one glyph cannot follow the theme the way `.nav .ico svg` does. Measured against every theme's
     own `--bg-deep`: 8.20:1 in the worst case (bubblegum), well past WCAG 2.2's 3:1 non-text floor,
     in all 15. Making it per-theme would mean redefining this whole `background-image` in 15 blocks
     to fix a problem the measurement says does not exist. */
  background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='17' height='17' viewBox='0 0 24 24' fill='none' stroke='%23b8bdc9' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>");
  background-repeat:no-repeat;
  background-position:right 12px center;
}
select::-ms-expand { display:none; }
input[type=number] {
  padding-right:36px;
  appearance:textfield; -webkit-appearance:textfield; -moz-appearance:textfield;
  background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23b8bdc9' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M7 10l5-5 5 5'/><path d='M17 14l-5 5-5-5'/></svg>");
  background-repeat:no-repeat;
  background-position:right 10px center;
}
input[type=number]::-webkit-outer-spin-button,
input[type=number]::-webkit-inner-spin-button {
  -webkit-appearance:none;
  margin:0;
}
input[type=date] { color-scheme:dark; }
input[type=date]::-webkit-calendar-picker-indicator {
  background:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23b8bdc9' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><rect x='3' y='5' width='18' height='16' rx='2'/><line x1='3' y1='10' x2='21' y2='10'/><line x1='8' y1='2' x2='8' y2='6'/><line x1='16' y1='2' x2='16' y2='6'/></svg>") center/contain no-repeat;
  opacity:.8; cursor:pointer;
}
input[type=date]::-webkit-calendar-picker-indicator:hover { opacity:1; }
input[type=text], input[type=password] { font-family:var(--mono); font-size:13.5px; }
/* 🔴 NOTHING A FINGER TYPES INTO IS UNDER 16px [2026-08-28, POST-DEPLOY 4.1]. iOS Safari zooms the
   whole viewport when a field below 16px takes focus and never zooms back out, so the student is
   left scrolled sideways with the nav off screen. It fires on the join code, the password, a sprint
   goal, a card title, a message -- everything they type during a jam.
   ⚠ (pointer:coarse) rather than a max-width, for the phonewall's reason [see ~line 3336]: width
   cannot tell a phone from a desktop at 400% browser zoom, and an iPad in landscape is 1024px wide
   and still zooms on focus. This keys on the primary input actually being a finger.
   ⚠ FONT SIZE ONLY. The 44px target sweep is a separate item and needs measuring page by page; a
   min-height here would silently reshape rows all over the app.
   The list is explicit rather than a bare `input`: checkboxes and radios must not be touched, and
   `:not([type])` catches the handful written without one, which default to text. */
/* The free-text boxes a student writes into: the help bubble's description and location, the shared
   Send a Message box, Messages' own compose box. 15px is what they have always been -- it used to be
   an INLINE style on each one, which outranks every selector below and made them the last four
   fields still zooming an iPhone. */
.writefield { font-size:15px; }
@media (pointer:coarse) {
  input:not([type]), input[type=text], input[type=password], input[type=number], input[type=date],
  input[type=datetime-local], input[type=email], input[type=search], input[type=tel],
  input[type=url], select, textarea { font-size:16px; }
  /* ⚠ LISTED SEPARATELY BECAUSE A CLASS OUTRANKS AN ELEMENT SELECTOR. `.writefield`'s 15px above
     would otherwise beat the element rule on this very line and those four boxes would stay at 15. */
  .writefield { font-size:16px; }
}
input:focus, select:focus, textarea:focus {
  outline:none; border-color:var(--brand);
  box-shadow:0 0 0 3px var(--brand-soft);
}

/* ---------- INLINE FORM VALIDATION (category 3 of the refusal rule) ----------
   flash_field() (app/core.php) -> formError() (app/ui.php) render exactly these two things: a red
   ring on the control, and one sentence directly after it. Nothing here is page-specific — every
   teacher and student form shares this, which is the point.

   ⚠ COLOUR IS COMPUTED, NOT CHOSEN — same house method as `.b-red` (70%) and `.b-info` (85%): keep
   the theme's own `--red` and mix toward `--text` by the LARGEST share that still clears 4.5:1 in
   ALL 15 themes, measured against the DIALOG background (`--panel-hi`), which is where nearly every
   one of these lands and is the tightest of the four surfaces. Raw `--red` FAILS there — bubblegum
   4.12 — so `.joinerr`'s bare `color:var(--red)` (which sits on `--bg-deep`, 6.29, and is fine) is
   deliberately NOT copied. 85% measured across all 15: worst bubblegum 4.59, best arcade 8.49.
   Do not move `--red` itself; several selectors read it, and the standing rule is to mix at the
   call site. */
.fielderr {
  color:color-mix(in srgb, var(--red) 85%, var(--text));
  font-size:12.5px; line-height:1.35; margin:6px 0 0;
}
/* At the top of a form (nothing owned the error) it needs a little more separation from the first
   field than it does when it is tucked under one. */
.fielderr-top { margin:0 0 var(--sp-3); }
/* The ring. `input:focus` above is (0,1,1) and this is (0,1,0), so the BORDER needs !important to
   survive the focus formError() gives the field immediately — without it the control would go brand
   the instant it was pointed at, losing the signal at exactly the moment it is needed. The
   box-shadow deliberately does NOT carry !important, so focus still paints its own brand glow over
   the red wash: red border = this value is wrong, brand glow = and you are in it now. Both clear on
   the first keystroke (formError's `drop`). */
[aria-invalid="true"] { border-color:var(--red) !important; box-shadow:0 0 0 3px var(--red-soft); }

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   🔴 `hidden` MEANS HIDDEN. THIS ONE LINE ENDS A BUG THIS FILE HIT FIVE TIMES IN A SINGLE DAY.
   The UA stylesheet's `[hidden] { display:none }` is the weakest rule there is: ANY class that sets
   a `display` beats it. So an element given a `display` in this file and toggled with `el.hidden =
   true` in JavaScript stays on screen while its own property insists it is gone -- and the code
   reads as correct on both sides, which is why it kept happening:
     · `.cpgrid`   (display:grid)        the class picker would not hide
     · `.bubrow`   (display:flex)        the help bubbles would not hide
     · `.reqflag`  (display:inline-block) "Required" showed beside fields that were not
     · `.ci-menu`  (display:inline-block) every student could still see a ⋯ they no longer had
     · `.btn`      (display:inline-flex)  "Add a task" sat under the row it had just opened, and a
                                          second click opened a second row [her report 2026-08-29]
   The first four were patched one class at a time. This is the same fix applied once, at the level
   the problem actually lives at.
   ⚠ `!important` IS LOAD-BEARING HERE, not laziness: the whole point is to outrank a class's own
     `display`, and every one of the cases above is exactly that.
   ⚠ IT CANNOT BREAK ANYTHING THAT WAS WORKING. `hidden` already means "not rendered" in the HTML
     spec; anything relying on a hidden element staying visible was relying on a bug. The full gate
     runs after this change for that reason rather than on trust.
   ══════════════════════════════════════════════════════════════════════════════════════════════ */
[hidden] { display:none !important; }

.btn {
  display:inline-flex; align-items:center; justify-content:center; gap:6px;
  cursor:pointer; text-decoration:none; user-select:none;
  background:var(--brand); color:var(--brand-ink);
  border:1px solid transparent; border-radius:var(--r-md);
  padding:9px 18px; height:38px;
  font-family:var(--sans); font-size:13.5px; font-weight:700; letter-spacing:.02em;
  margin-top:var(--sp-3); box-shadow:var(--shadow-1);
  transition:filter .15s;
}
.btn:hover { filter:brightness(1.1); }
.btn:active { filter:brightness(.95); }
/* 🔴 `.btn`'s `--brand-ink` on `--brand` fails AA in two themes [2026-08-15 Meetings design pass;
   every primary button app-wide, including SUBMIT RETROSPECTIVE]: catalystcats 3.92, pumpkinspice
   4.41. Not a case for the usual computed-mix-toward-`--text` recipe used elsewhere in this file —
   swept the full 0-100% range and no single shared mix works for both without breaking other
   themes, because the two failures need OPPOSITE fixes. catalystcats' brand luminance is low enough
   that even pure-black ink only reaches 4.23 (mathematically short of 4.5), so its own `.btn`
   background is lightened a hair toward `--text` instead — 89%/11% clears 4.6 with margin, and the
   shift (#b45424 -> ~#ba6339) is barely perceptible. pumpkinspice's ink just wasn't quite dark
   enough (its own black-ink ceiling is 4.94); darkening it toward black by a third clears 4.6.
   Neither touches the shared `--brand`/`--brand-ink` tokens themselves — `.logoinit`/`.pillbtn` etc.
   read the original values unchanged, exactly as the "computed-mix rather than moving a locked
   token" rule intends. */
/* 🔴 `:not(.btn-ghost):not(.btn-red)` IS LOAD-BEARING, AND ITS ABSENCE WAS A LIVE REGRESSION
   [found 2026-08-16 by the new axe-core suite; introduced by this very rule earlier the same day].
   `[data-theme="…"] .btn` is specificity (0,2,0). `.btn-ghost` and `.btn-red` are (0,1,0). So these
   two overrides beat BOTH variants and repainted every ghost and red button with the solid primary
   background/ink — in catalystcats, which is the DEFAULT THEME, so this was every student by default.
   Measured by axe: `.btn-ghost` text rendered #9aabc9 on #ba653b = **1.8:1**, against a 4.5 minimum.
   Manage Leave, the account buttons, the help-queue actions — all of them.
   The `.btn` contrast fix these rules exist for is still correct and still applies; it just has to
   stop at the variants that deliberately paint themselves transparent. This is exactly the class of
   bug an automated engine finds and a human sweep does not: nothing looked broken enough to report,
   and the theme suite measures `.btn` (which was fixed) rather than `.btn-ghost` (which was not).
   ⚠ THE EXCLUSION LIST IS EXACTLY THE VARIANTS THAT SET `background:transparent` — `.btn-ghost`,
   `.btn-red` AND `.btn-amber`. Amber was missed on the first pass of this fix and axe caught it
   separately, as the card SPLIT button rendering #f2b95c on #ba653b (2.36:1). Enumerated from the
   stylesheet rather than remembered, so a future fourth variant is a known thing to add here.
   ⚠⚠ TWO WAYS THIS COMMENT ITSELF BROKE THE RULES BELOW, both worth knowing because neither shows up
   as a failing test — a CSS syntax error does not throw, it just makes rules silently vanish:
   (1) a paragraph was once left OUTSIDE the comment, and (2) the prose contained a literal
   close-comment marker. CSS comments DO NOT NEST, so the first such marker ends the comment and
   everything after it is parsed as garbage, taking the following rules with it. Both times the
   symptom was seductive: axe stopped reporting the ghost buttons — not because they were fixed, but
   because the rule breaking them had ceased to exist — while `.btn` quietly sat at 3.92:1.
   So: never write that two-character sequence in prose here. If these rules ever appear not to
   apply, confirm they are in `document.styleSheets` before blaming specificity. */
[data-theme="catalystcats"] .btn { background:color-mix(in srgb, var(--text) 11%, var(--brand)); }
[data-theme="pumpkinspice"] .btn { color:color-mix(in srgb, black 33%, var(--brand-ink)); }
/* ...and then hand the three transparent variants their own look back, at the SAME specificity and
   LATER in source, which is what makes them win. Written this way rather than as
   `.btn:not(.btn-ghost):not(.btn-red):not(.btn-amber)` because that chained-`:not()` form was
   measured being DROPPED by Chromium's parser entirely — verified against document.styleSheets, where
   every other rule added the same day was present and this one simply was not, with the stylesheet
   freshly fetched and the comment above balanced. Whatever the cause, a rule that silently does not
   exist is not worth being clever for: two plain rules do the same job and can be seen to work. */
/* 🔴 FOUR VARIANTS NOW, NOT THREE [chat 7b, 2026-09-05], and the fourth was caught by the trap this
   comment block already describes rather than by reading it. `.btn-neutral` (the tracker's "you can
   finalize, but this team is not complete" state) is a transparent variant like the three below, and
   it was added down beside the tracker's own CSS without being added here — so on catalystcats it
   painted brand-orange under a `--text` label, exactly the failure the 1.8:1 measurement above
   records for `.btn-ghost`. Measured across all 15 themes plus the default: every other theme was
   fine and catalystcats alone was wrong, which is why a single-theme look is worth measuring rather
   than eyeballing. Guarded now by tests/ui/t_mvp_wizard.py's theme sweep, which asserts all four. */
[data-theme="catalystcats"] .btn-ghost,
[data-theme="catalystcats"] .btn-red,
[data-theme="catalystcats"] .btn-neutral,
[data-theme="catalystcats"] .btn-amber { background:transparent; }
[data-theme="pumpkinspice"] .btn-ghost { color:var(--dim); }
[data-theme="pumpkinspice"] .btn-red   { color:var(--red); }
[data-theme="pumpkinspice"] .btn-neutral { color:var(--text); }
[data-theme="pumpkinspice"] .btn-amber { color:var(--amber); }
.btn-amber { background:transparent; color:var(--amber); border-color:color-mix(in srgb, var(--amber) 45%, transparent); }
.btn-amber:hover { background:var(--amber-soft); }
.btn-red { background:transparent; color:var(--red); border-color:color-mix(in srgb, var(--red) 45%, transparent); }
.btn-red:hover { background:var(--red-soft); }
.btn-ghost { background:transparent; color:var(--dim); border-color:var(--line-hi); }
.btn-ghost:hover { color:var(--text); border-color:var(--dim); }
.btn-sm { padding:4px 12px; height:29px; font-size:12px; margin-top:0; border-radius:var(--r-sm); }

/* ---------- Tables ---------- */
table { width:100%; border-collapse:collapse; margin:var(--sp-3) 0; }
th {
  text-align:left; color:var(--dimmer); font-size:11px; font-weight:700;
  text-transform:uppercase; letter-spacing:1.2px;
  border-bottom:1px solid var(--line-hi); padding:8px 10px;
}
td { border-bottom:1px solid var(--line); padding:11px 10px; vertical-align:middle; }
tr:last-child td { border-bottom:none; }
tr:hover td { background:rgba(255,255,255,.018); }

/* ---------- Badges ---------- */
/* 🔴 THE "AWKWARD SPACE AT THE END" [her words, fixed 2026-08-02]. `letter-spacing` adds its gap
   AFTER every character INCLUDING THE LAST, so the glyphs sat 1px left of centre inside symmetric
   padding and every badge read as having a hole on its right. Trimming 1px off the right padding
   cancels exactly the trailing space — the text is genuinely centred now rather than nudged.
   Global on purpose: it is a centring bug in every badge on the site, not a Meetings-page one. The
   Meetings review table's own size bump is scoped to `.rrow .badge` further down. */
.badge {
  display:inline-block; font-family:var(--mono);
  font-size:10px; padding:2.5px 8px 2.5px 9px; border-radius:20px;
  border:1px solid; letter-spacing:1px; text-transform:uppercase; font-weight:600;
}
/* 🔴 THE TWO ATTENDANCE BADGES SHARE ONE WIDTH [2026-08-24, her standing rule].
   "✓ ATTENDED" and "ABSENT" are alternatives in the SAME slot on the Meetings card, so they are the
   badges-in-one-column case: one min-width for the group, never each sized to its own text.
   Measured at 390px before this rule: 89px and 61px, so the card visibly changed shape depending on
   whether the student had checked in. 92px clears the wider of the two with the border included.
   Scoped to `.attbadge` rather than widening `.badge`, which is used all over the app and must not
   inherit a floor it never asked for. `inline-flex` + `center` is what makes the shorter word sit in
   the middle of the wider box instead of hugging the left edge. */
.badge.attbadge { min-width:92px; display:inline-flex; align-items:center; justify-content:center; box-sizing:border-box; }

/* 🔴 THE ATTENDANCE ROW LINES UP [her note 2026-08-25: *"the 'in progress' pill should be lined up
   with the 'verify attendance' button"*]. The row is `align-items:center`, but the pill was 36px
   and the button 38px, so centring them still left visibly different top edges. One shared height
   is what actually makes them read as a row. Only two things can sit here now — the meeting pill
   and the Verify Attendance button — because the attendance badge became `.attline` below. */
.mtg-statepill, .attbtn {
  min-height:38px; display:inline-flex; align-items:center; justify-content:center;
  box-sizing:border-box; vertical-align:middle;
}
/* 🔴 AND THIS is what was actually misaligning them, not the flex settings. `.btn` carries a base
   `margin-top:var(--sp-3)`, so even with both boxes at 38px in an `align-items:center` row the
   button sat 6px lower than the pill — measured: pill centre 450.5, button centre 456.5. Equal
   heights alone could never have fixed that. */
.attbtn { margin-top:0; }

/* The student's own attendance, as a line rather than a second pill [her ruling 2026-08-25].
   RIGHT-ALIGNED and sitting directly under the pill / Verify button, so the card's right-hand column
   reads as one thing: "the state of this meeting, for me". The left column stays the facts about the
   meeting itself (team, chair, submissions).
   ⚠ NO MEDIA QUERY, deliberately — *"we should mirror this on mobile"*. One rule, both widths, so
   the phone and the desktop cannot drift on what a student is told about their own attendance.
   The three colours are hers: green for checked in, amber for a REPORTED absence (following the
   rule), red for an unreported one. */
.attline { margin-top:8px; font-size:13.5px; font-weight:600;
  display:flex; align-items:center; justify-content:flex-end; gap:6px; text-align:right; }
.attline-ok       { color:var(--green); }
.attline-reported { color:var(--amber); }
.attline-absent   { color:var(--red); }
/* 🔴 ON A PHONE IT FOLLOWS THE PILL TO THE LEFT, and that IS the mirroring rather than a departure
   from it. The rule she asked for is that the status sits directly under the meeting pill. At
   desktop width the pill is pushed right by the team-name row, so the status is right-aligned to
   match. On a phone that row WRAPS and the pill lands on the left — leaving the status right-aligned
   there would have put the two on opposite sides of the card, which is the one thing this layout
   exists to avoid. Same relationship, both widths. */
@media (max-width:640px) {
  .attline { justify-content:flex-start; text-align:left; }
}
.b-brand { color:var(--brand); border-color:color-mix(in srgb, var(--brand) 40%, transparent); background:var(--brand-soft); }
/* 🔴 `.b-green` WAS GREEN TEXT IN AN AMBER BOX [found 2026-08-25 by measuring, after her eye caught
   *"the weird yellow border around the green attended pill"*].
   The 2026-08-15 ruling moved green off `--accent-3` onto the locked `--green` — and the note beside
   `.sp-green` in this file says `.b-green` "was already re-pointed this way". It was not, or only
   half was: `color` used `--green` while `border-color` AND `background` were still `--accent-3`,
   which is `#d9925c` in this theme. Measured on the ATTENDED badge: text rgb(74,222,128) (green),
   border color(srgb 0.851 0.573 0.361) (amber). So every `.b-green` badge app-wide has been wearing
   an amber outline, not just this one. Now all three come from `--green`. */
.b-green { color:var(--green); border-color:color-mix(in srgb, var(--green) 40%, transparent); background:var(--green-soft); }
.b-sub { color:var(--accent-3); border-color:color-mix(in srgb, var(--accent-3) 40%, transparent); background:var(--accent-3-soft); }
.b-amber { color:var(--amber); border-color:color-mix(in srgb, var(--amber) 40%, transparent); background:var(--amber-soft); }
/* `.b-red` fails AA in bubblegum 3.83 / pumpkinspice 4.27 / sunsettide 4.28 [2026-08-15 Meetings
   design pass] — the same "text on a tint of itself" shape the big comment above (line 411)
   describes, just not swept the first time since `--red` is a shared cross-theme token, not a
   per-theme one. Same fix: lighten the text toward `--text` — swept the full 100-50% range against
   all 15 registered themes (config.php's THEMES, not 16 — corrected here) and 70% is the highest
   (most-red, least-diluted) share that still clears AA everywhere, with real margin (bubblegum's
   own worst case: 4.78). Leaves `--red`/`--red-soft` themselves untouched; they're fine as tokens
   and paint plenty else besides this badge. */
.b-red   { color:color-mix(in srgb, var(--red) 70%, var(--text));   border-color:color-mix(in srgb, var(--red) 40%, transparent);   background:var(--red-soft); }
/* 🔴 WAS `.b-blue` ON `--accent-2` UNTIL 2026-08-14 [her ruling]. Two things were wrong with it.
   (1) SEMANTICS: this is the app's informational badge — PICKED UP, archived, COMPANY, PER TRACK,
   SCALE — so it belongs on `--info`, the informational token, not on a decorative accent. She spotted
   it: *"can it just use the --info one?"* There was no `.b-info` class; `.b-blue` WAS it, mis-wired.
   (2) CONTRAST: `--accent-2` text on `--accent-2-soft` (the same hue at 13% alpha) failed WCAG AA in
   **7 of 16 themes**, catalystcats — the default — among them at 3.63:1.
   ⚠ AND THE FIX IS NOT "RAISE `--info` IN THE FAILING THEMES", WHICH IS WHERE THIS STARTED. Measured:
   `--info` clears AA on `--panel` and `--bg-deep` — its own documented invariant (see the note at the
   top of the theme block) — in **16 of 16** themes. The token is correct. What fails is the PATTERN of
   putting a colour on a ~10% tint of ITSELF, which is inherently low contrast whatever the hue. Moving
   the token would also have broken its other invariants: holding hue and saturation, scrumnoir is
   unsolvable and pumpkinspice only clears by going nearly white.
   So the text is lightened toward `--text` instead, exactly as the HALFWAY badge was.
   **85% is COMPUTED, not chosen:** it is the largest `--info` share where all 16 themes clear 4.5:1
   (90% still left pumpkinspice at 4.35). Worst case is now 4.55. Mixing toward each theme's own
   `--text` rather than toward white keeps the hue direction, so the badge still reads as that theme's
   info colour. */
.b-info  { color:color-mix(in srgb, var(--info) 85%, var(--text)); border-color:color-mix(in srgb, var(--info) 40%, transparent); background:var(--info-soft); }
/* 🔴 SAME COLOUR-ON-ITS-OWN-TINT PROBLEM AS `.b-info` HAD, FIXED THE SAME WAY [2026-08-14].
   `--brand` on `--brand-soft` failed WCAG AA in 7 of 16 themes (pumpkinspice 2.54, ultraviolet 2.91,
   catalystcats 2.92 — the default — sunsettide 3.70, overclock 4.10, bubblegum 4.19, summersorbet
   4.46). Found while measuring her A/B-day proposal, not by looking for it: this class also carries
   the "Ready for Check-Off" count badge (`teacher/index.php:564`), so that was failing too.
   **55% is computed, not chosen:** the largest `--brand` share where all 16 clear 4.5:1. Worst 4.65. */
.b-pink  { color:color-mix(in srgb, var(--brand) 55%, var(--text)); border-color:color-mix(in srgb, var(--brand) 40%, transparent); background:var(--brand-soft); }

/* 🔴 A DAY / B DAY [her ruling 2026-08-14]: *"a and b markers should use the theme accents... A day
   as like accent 1 and B day as like accent 2."* They previously borrowed `.b-info` and `.b-pink`,
   which made the A-day marker share a class with PICKED UP / archived / COMPANY — a colour pair
   wearing an informational badge's name. Own classes, because A/B day is its own semantic and she may
   want to tune the two independently.
   She accepted the overlap with track colours ("a fair overlap"), and it is only partial: `--brand`
   alone is not a track colour, and `--accent-2` matches track ord 1 only at a different mix. The two
   never share a column.
   ⚠ MEASURED AND WORTH KNOWING: no accent pairing in this app is fully distinct. `--brand` vs
   `--accent-2` sits below the app's own OKLab dE>=0.20 "must not be confusable" bar in 6 of 16 themes
   (bubblegum 0.103 worst) — but that is BETTER than the pairing it replaces (`--info` vs `--brand`,
   9 of 16), and the badges spell out "A DAY" and "B DAY" in words, so colour reinforces the meaning
   rather than carrying it. Same 55% computed share as `.b-pink`; worst case 4.65 (A) and 4.72 (B). */
.b-aday  { color:color-mix(in srgb, var(--brand) 55%, var(--text)); border-color:color-mix(in srgb, var(--brand) 40%, transparent); background:var(--brand-soft); }
.b-bday  { color:color-mix(in srgb, var(--accent-2) 55%, var(--text)); border-color:color-mix(in srgb, var(--accent-2) 40%, transparent); background:var(--accent-2-soft); }
.b-dim   { color:var(--dim); border-color:var(--line-hi); background:rgba(255,255,255,.03); }
button.badge { cursor:pointer; transition:all .15s; }
/* 🔴 `.spill`/`.spill-ans`/`.spill-wait` HAD NO RULE ANYWHERE [found 2026-08-09, same sweep as
   .qtip-trigger]. `msg_receipt()` (app/domain.php) returns exactly these two states for a sent
   message's own receipt pill on public/messages.php's Status column — "Replied" and "UNREAD" —
   and both render as bare inline text with no colour, background or border at all, indistinguishable
   from the surrounding row. "UNREAD" is a pending/notable state, not a warning (the SEPARATE
   "No reply · N class days" escalation line right below it already carries the real urgency signal
   in qt-red/qt-orange), so it takes --info rather than amber/red; "Replied" is the settled/positive
   state, so it takes --green — same two-state semantic split this app already uses elsewhere.
   Built as a small pill matching this file's own `.tag`/`.age` construction, not `.badge`'s (that
   family is uppercase/pill-shaped for a different context; this one sits inline in a dense table
   cell). Text case is left exactly as the markup already types it ("Replied", "UNREAD") — no
   text-transform added, so this fix is additive (colour/background/border) and does not also change
   what the words look like. */
.spill { font-family:var(--mono); font-size:9.5px; letter-spacing:.5px; font-weight:600;
  border-radius:4px; padding:2px 7px; border:1px solid transparent; }
.spill-ans  { color:var(--green); background:rgba(74,222,128,.12); border-color:color-mix(in srgb, var(--green) 35%, transparent); }
/* 🔴 SAME PATTERN, SAME FIX, FOUND BY THE SAME PASS [2026-08-14]. This is `--info` text on
   `--info-soft` — the identical "colour on a 10% tint of itself" combination `.b-info` above had, so
   it was failing WCAG AA in the same 4 themes (catalystcats 4.00, pumpkinspice 3.98, scrumnoir 4.31,
   ultraviolet 4.32) and had been since it was written on 2026-08-09. Nothing on the Messages or Help
   Queue pages renders it; it was found by asking what ELSE consumes the pattern, and fixing it here
   is why this is a root-cause change rather than a per-badge patch. Same computed 85% share. */
.spill-wait { color:color-mix(in srgb, var(--info) 85%, var(--text)); background:var(--info-soft); border-color:color-mix(in srgb, var(--info) 35%, transparent); }

/* ---------- Avatars ---------- */
.avatar { display:inline-flex; align-items:center; justify-content:center; border-radius:50%; overflow:hidden; vertical-align:middle; flex-shrink:0; }
.avatar-img { object-fit:cover; border-radius:50%; border:1px solid var(--line-hi); }
.avatar-init { color:var(--brand-ink); font-weight:800; font-family:var(--disp); }
.avatar-svg svg { width:100%; height:100%; display:block; }

/* pickers (settings + join customize) */
.themepick { display:flex; flex-wrap:wrap; gap:10px; margin:8px 0 4px; }
.themepick label { margin:0; }
.themepick input { position:absolute; opacity:0; pointer-events:none; }
.tswatch {
  display:flex; flex-direction:column; gap:7px; align-items:center; cursor:pointer;
  border:2px solid var(--line); border-radius:var(--r-md); padding:11px 14px 9px; min-width:104px;
  transition:border-color .15s, background .15s;
}
.tswatch:hover { border-color:var(--line-hi); }
.themepick input:checked + .tswatch { border-color:var(--brand); background:var(--brand-soft); }
.tswatch .dots { display:flex; gap:5px; }
.tswatch .dots i { width:16px; height:16px; border-radius:50%; }
.tswatch span { font-size:12px; font-weight:700; color:var(--dim); }
.themepick input:checked + .tswatch span { color:var(--text); }

/* 🔴 ACCOUNT INFO IN TWO COLUMNS [her ruling 2026-08-29] -- see the panel's own note. The facts are
   short lines and the actions are short buttons; neither needed the full width, and stacking all four
   made the panel tall and half empty at the same time. */
.acctgrid { display:flex; gap:26px; align-items:flex-start; margin-top:14px; }
.acctfacts { flex:1 1 auto; min-width:0; display:flex; flex-direction:column; gap:20px; }
.acctacts { flex:0 0 auto; display:flex; flex-direction:column; gap:10px; }
/* Same width for both, so they read as a pair rather than as two unrelated buttons. */
.acctacts .btn { width:100%; justify-content:center; margin:0; }
.acctacts form { margin:0; }
/* One column on a phone: side by side, the buttons would squeeze the username to nothing. */
@media (max-width:640px) {
  .acctgrid { flex-direction:column; gap:18px; }
  .acctacts { align-self:stretch; }
}
.avpick { display:flex; gap:12px; flex-wrap:wrap; margin:8px 0 4px; }
.avpick label { margin:0; }
.avpick input { position:absolute; opacity:0; pointer-events:none; }
.avopt {
  display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer;
  border:2px solid var(--line); border-radius:var(--r-md); padding:10px 12px 8px;
  transition:border-color .15s, background .15s;
}
.avopt:hover { border-color:var(--line-hi); }
.avpick input:checked + .avopt { border-color:var(--brand); background:var(--brand-soft); }
.avopt span:not(.avatar) { font-size:11.5px; font-weight:700; color:var(--dim); }
/* §21: scoped off .avatar — this label rule was ALSO matching the initials avatar span nested
   inside .avopt, and at (0,1,1) it outranked .avatar-init (0,1,0), so the picker rendered the
   initials in --dim while the account bubble rendered them in --brand-ink. */

/* ---------- Member rows ----------
   🔴 THE FLEX ROW IS GONE [2026-08-28]. The teacher's roster is `.mtbl` now -- the same table the
   student's My Team page uses -- so .member-row, .m-left, .m-right, .m-name and .m-roles have no
   markup left to style. Three rounds of min-widths lived here to make a flex row line up in columns
   (her note 6: *"I like things to line up, like names, usernames, tracks, roles"*); a table does that
   by being a table, and keeping the rules would leave the next reader two ways to build the same row.
   `.m-user` survives because the rename dialog still prints a username under its heading. */
.m-user { font-family:var(--mono); color:var(--dimmer); font-size:12px; }
/* 🔴 24x24 MINIMUM [2026-08-17, Teacher Teams audit]. The Board audit measured this at 29x23 — one
   pixel under WCAG 2.2 SC 2.5.8 (AA) — and deliberately fixed only `.card-edit`, noting `.pencil` "is
   also the roster and class-header pencil on three other pages and is not this audit's to resize".
   Two of those three pencils are on Teams (the team-name pencil and one per roster row, plus the
   Company Settings heading), so it IS this audit's, and the shared class is where the fix belongs
   rather than a fourth page-scoped override. Re-measured live after the change: 29x24.
   min-* rather than height, so the existing padding still drives the box wherever it is already
   bigger, and inline-flex centring so the glyph does not sit off-centre in the taller box. */
.pencil {
  cursor:pointer; background:transparent; border:1px solid transparent;
  color:var(--dimmer); font-size:13px; border-radius:var(--r-sm); padding:3px 8px;
  transition:all .15s;
  min-width:24px; min-height:24px;
  display:inline-flex; align-items:center; justify-content:center;
}
.pencil:hover { color:var(--brand); border-color:var(--line-hi); background:var(--panel-hi); }
/* O14 · both kinds of class pill share the Class column, so they share one width — her standing rule
   that badges in a column are never sized to their own text. */
.clspill { min-width:112px; text-align:center; }
/* 🔴 O16 · the Join code column's values follow their own header [her note 12]. See teams.php for the
   measurement. ⚠ THE SPECIFICITY MATTERS AND THE FIRST ATTEMPT LOST TO IT: `#allteams td.mono` (an ID
   plus an element plus a class, 1-1-1) centres every mono cell in this table and beat a bare `.joincol`
   (0-1-0), so the rule applied and changed nothing. Measured again after: header text and code text
   both at the same x. The Members column is deliberately NOT included -- its header IS centred, so
   there the two already agree. */
#allteams td.mono.joincol, #allcos td.mono.joincol { text-align:left; }
/* 🔴 O17 · A REAL NUMERIC STEPPER. The old "arrows" were a `background-image` painted where the
   native spinner used to be (`input[type=number] { appearance:textfield }` removes it), so there was
   nothing clickable at any size — which is why she could not click them. These are buttons.
   `background-image:none` on the field is load-bearing: without it the decorative chevrons would sit
   underneath the real ones and the control would read as having four arrows. */
.numstep { display:flex; align-items:stretch; gap:6px; margin-top:4px; }
.numstep input[type=number] {
  width:auto; flex:1; min-width:0; text-align:center;
  padding-right:12px; background-image:none;
}
.numstep-btn {
  flex:0 0 38px; min-height:38px; cursor:pointer;
  background:var(--panel-hi); color:var(--text);
  border:1px solid var(--line-hi); border-radius:var(--r-sm);
  font-size:18px; font-weight:700; line-height:1;
  display:inline-flex; align-items:center; justify-content:center;
  transition:border-color .12s, color .12s;
}
.numstep-btn:hover { border-color:var(--brand); color:var(--brand); }
.numstep-btn:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }
.rolecell .badge { font-size:12px; padding-top:3px; padding-bottom:3px; }
/* 🔴 A ROLE PILL IS DIFFERENT IN COLOUR, NOT IN SIZE [her ruling 2026-08-27]: *"I want them distinctly
   different from tracks in color but not necessarily in size. I don't want to inflate their
   importance."*
   ⚠ IT WAS NEVER THE FONT SIZE -- both pills in this cell were already 12px. `.badge` also brings
   UPPERCASE, letter-spacing:1px and the mono face, and "LEAD PROGRAMMING" set that way runs about
   twice the width of "Programming" in the body font, which is the whole of what she is seeing. The
   colour is untouched (amber for the producer, the track's own colour for a lead), so the two stay
   distinguishable exactly the way she wants.
   ⚠ SCOPED TO .rolecell on purpose: everywhere else in the app the uppercase mono badge IS the
   intended treatment. */
.rolecell .badge {
  font-family:inherit; text-transform:none; letter-spacing:0; font-weight:700;
  padding-left:10px; padding-right:10px;
}

/* ---------- Flash / errors / rules ---------- */
.flash {
  background:var(--accent-3-soft); border:1px solid color-mix(in srgb, var(--accent-3) 35%, transparent);
  color:var(--text); border-radius:var(--r-md);
  padding:11px 16px; margin:0 0 var(--sp-4); font-size:13.5px;
}
.errors {
  background:var(--red-soft); border:1px solid color-mix(in srgb, var(--red) 40%, transparent);
  border-radius:var(--r-md); padding:6px 16px; margin:var(--sp-3) 0; font-size:13.5px;
}
.errors ul { margin:8px 0; padding-left:18px; }
.rule { color:var(--line-hi); font-family:var(--mono); font-size:11px; overflow:hidden; white-space:nowrap; margin:var(--sp-4) 0; }
.bigcode {
  font-family:var(--mono); font-size:24px; font-weight:600; letter-spacing:1px;
  background:var(--bg-deep); border:1px dashed var(--line-hi); border-radius:var(--r-md);
  color:var(--brand); padding:14px 18px; margin:var(--sp-3) 0; text-align:center;
}

/* ---------- Board ---------- */
.boardtool { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin:14px 0 4px; }
/* 🔴 `.dashfilters` / `.dashfilter` HAD NO RULE ANYWHERE [added 2026-08-07, Teacher Dashboard audit].
   `git log -S` confirms neither class has ever existed in this file, so the Teacher Dashboard's
   Class + Block bar (teacher/index.php:348, 358) fell through to the generic STACKED-FORM `label` rule
   at line 582 (`display:block; margin:16px 0 4px; font-size:12.5px`) — which is built for a caption
   sitting ABOVE its input, not beside it. Measured consequence in Chromium: 0px between the "Block"
   caption and the select pill (they touched), and 4px from the pill down to the first sprint rail.
   Same values as `.boardtool` directly above deliberately: these two are the app's only horizontal
   filter bars, they use the same `.styledsel.fsel` control, and they should not be two near-identical
   near-misses. `.dashfilter { margin:0 }` is what cancels the inherited 16px/4px. */
/* 🔴 NOTE 1 · ROOM AROUND THE FILTER [her ruling 2026-08-22]: *"this should still look nice and big
   with spacing around it"*. The row was `margin:14px 0 4px` — 4px under a control that sits directly
   above the sprint rail, which is the "squished on the top" half of her note. The label also grows
   with the control it now labels (the `.fsel` shrink modifier came off in index.php). */
.dashfilters { display:flex; align-items:center; gap:12px; flex-wrap:wrap; margin:20px 0 22px; }
.dashfilter { display:inline-flex; align-items:center; gap:10px; margin:0; font-size:14px; }
.fchip {
  cursor:pointer; user-select:none; font-size:12px; font-weight:700; color:var(--text);
  border:1.5px solid var(--line-hi); border-radius:20px; padding:4px 12px;
  transition:all .12s;
}
.fchip:hover { border-color:rgba(255,255,255,.55); }
.fchip.on { border-color:var(--brand); color:color-mix(in srgb, var(--brand) 55%, var(--text)); background:var(--brand-soft);
  box-shadow:0 0 0 2px rgba(255,255,255,.35); }
/* 🔴 THESE THREE ARE <button> NOW, NOT <span onclick> [2026-08-08, Board audit]. Measured on the
   teacher board: tabbing through `.boardtool` reached the three <select>s and nothing else — the
   OVERDUE chip, every track chip and ✕ CLEAR all reported tabIndex -1 and document.activeElement
   never landed on them, so 4 of the 7 filters were mouse-only (WCAG 2.1.1). Same for `.teamchip` on
   every card and all 20 `.swatch`es in the card-colour popover. Converting the element to a real
   button is the whole fix: it is focusable, Enter/Space fire the existing `click` listeners with no new
   JS, and it inherits the global focus ring. A button does not inherit font-family, hence the reset.
   The ring is --text rather than the global --brand because `.fchip.person` paints its border from
   --pc, which in several themes IS brand-adjacent — same reasoning as `.styledsel:focus-visible`. */
/* BOTH inherits are load-bearing. A <button> inherits neither font-family NOR line-height from the
   UA stylesheet, and dropping line-height was measured, not guessed: with `normal` the OVERDUE chip
   rendered 26px tall against the span's 30.59px (body's 1.55 x 12px = 18.6px line box + 8px padding +
   4px border), so the conversion would have quietly shrunk every filter chip on the page by 4.6px.
   With both inherits the button's box is byte-identical to the span it replaced. */
.fchip, .teamchip { font-family:inherit; line-height:inherit; }
.teamchip { font-family:var(--mono); }   /* re-assert: the inherit above would otherwise take --sans */
.swatch { padding:0; }
.fchip:focus-visible, .teamchip:focus-visible, .swatch:focus-visible {
  outline:2px solid var(--text); outline-offset:2px;
}

.wipwarn {
  display:flex; gap:10px; align-items:flex-start;
  background:var(--amber-soft); border:1px solid color-mix(in srgb, var(--amber) 35%, transparent);
  border-radius:var(--r-md); padding:11px 16px; margin:var(--sp-3) 0 0; font-size:13.5px;
}
.wipwarn span:first-child { color:var(--amber); }
/* 🔴 ALERT-ROW LINKS MIX 50% TOWARD `--text` [2026-08-16, contrast pass]. These are the actionable
   half of the dashboard alerts — "go to your Board", "go to Meetings" — and plain `--brand` on the
   rows' tinted backgrounds measured 2.39:1 (pumpkinspice), 2.64 (catalystcats, the DEFAULT), 2.66
   (ultraviolet), and under 4.5 in six of fifteen themes. For the student this pass exists for, the
   links telling her what she owes were the least readable text on the page.
   50% is computed: the highest brand share clearing 4.5 across all 15 themes AND across the row
   variants (.wipwarn / .wipred / .quietabs), measured with the rgba(...,0.13) row tint properly
   COMPOSITED over the panel beneath — an earlier version of that measurement treated the tint as
   opaque and produced nonsense, which is worth knowing if these numbers are ever re-derived.
   ⚠ STILL DISTINCT AS A LINK at 50%, and that is not left to colour: the base `a` rule already
   carries `text-decoration-color`, so these stay underlined. WCAG 1.4.1 requires a link in a block of
   text to be told apart by something other than colour anyway, so the underline is doing the work the
   hue no longer can once it moves toward the body colour. */
.wipwarn a { color:color-mix(in srgb, var(--brand) 50%, var(--text)); }
.statusline {
  display:flex; gap:10px; align-items:center; justify-content:space-between;
  background:var(--panel); border:1px solid var(--line);
  border-radius:var(--r-md); padding:9px 16px; margin:var(--sp-2) 0 0; font-size:13.5px;
}
.statusline.t3 { border-color:color-mix(in srgb, var(--red) 40%, transparent); background:var(--red-soft); }

/* 🔴 THE BOARD'S 4x250px FLOOR WAS BLOWING OUT THE WHOLE PAGE, NOT JUST THE BOARD
   [fixed 2026-08-08, Board audit]. `repeat(4, minmax(250px,1fr))` has a min-content inline size of
   1042px (4x250 + 3x14 gap). Nothing contained that floor, so it propagated up through `.main`'s
   `1fr` track in `.app` and the DOCUMENT grew a horizontal scrollbar. Measured in Chromium on the
   teacher board with no test rows involved: window 1280 -> documentElement.scrollWidth 1334; window
   1100 -> still 1334, i.e. 234px of page-level horizontal scroll, sidebar dragged along with it.
   `teacher/index.php` at the same two widths did NOT overflow (1280/1280, 1100/1100), which is what
   proved this was the board's own floor rather than shared chrome.
   The old `@media (max-width:1050px)` guard could never have helped: it keys on WINDOW width, and the
   content area is already down to 1042px while the window is still 1280 — README §6.7 exactly, the
   window lies about the container by hundreds of pixels.
   `.boardwrap` carries `container-type:inline-size`, which does both jobs at once: inline-size
   containment makes the wrapper take its width from its containing block instead of from the board's
   floor (that is what stops the propagation), and it establishes the query container §6.7 asks for.
   Sticky `.col-head` still works because size containment does not create a scroll container — the
   scrollport is still the viewport (verified in Chromium at 1280 and 1100). */
.boardwrap { container-type:inline-size; }
/* 🔴 THE BOARD GETS ITS OWN SCROLL REGION AT PHONE/ZOOM WIDTHS [2026-08-16, reflow pass]. A four-column
   kanban is the textbook case for WCAG 1.4.10's two-dimensional-layout EXCEPTION — W3C names grids and
   "interfaces where it is necessary to keep toolbars in view", and its own dragging-movements failure
   technique (F108) uses a kanban board as the example. The exception permits the two dimensions; what
   it still requires is that nothing be LOST.
   That second half was failing. Measured at a 320px viewport (1280px at 400% browser zoom): the board
   claimed 375px of content inside a 296px box with `overflow-x:visible`, so `scrollLeft` stayed pinned
   at 0 and roughly 79px — including the column HEADINGS — was clipped with no way to reach it.
   `overflow-x:auto` turns that from clipped into scrollable, which is the difference between "two
   dimensions, as the exception allows" and "content lost, which it never allows". Scoped to the phone
   breakpoint, so the desktop container-query layout above is untouched. */
@media (max-width:640px) { .boardwrap { overflow-x:auto; } }
.board {
  display:grid; grid-template-columns:repeat(4, minmax(250px, 1fr)); gap:14px;
  margin-top:16px; align-items:start;
}
/* 🔴 THE GRID CHILD IS `.colwrap` NOW, NOT `.col` [2026-09-03] — see board.php's own note where the
   wrapper is emitted. `.col` keeps the scrolling and the cards; the ＋ new card button sits BELOW it
   as a sibling, so it is outside the scrollport and outside the drop target.
   `min-width:0` because a grid item's default `min-width:auto` would let a long card title push the
   column past its track, which is the same floor `.board`'s own minmax() is guarding at the top. */
.colwrap { display:flex; flex-direction:column; min-width:0; }
/* 1041px is one below the floor above: past it the four columns narrow together instead of the page
   growing a scrollbar. Four columns is the model and it deliberately does not reflow to 2x2. */
@container (max-width:1041px) { .board { grid-template-columns:repeat(4, minmax(0, 1fr)); } }
/* 🔴 A CAPPED, SCROLLING COLUMN [her ruling 2026-08-29]. `--colcap` is written by boardCap() in
   board.php from the board's REAL distance to the bubbles; the fallback keeps a column sane if that
   script never runs.
   ⚠ `.col` BECOMING THE SCROLL CONTAINER IS WHAT MAKES THE STICKY HEADER WORK PROPERLY. It was
     sticky against the VIEWPORT before (see the .boardwrap note above, which says so), so on a long
     column the heading scrolled away with the page. Now it pins inside its own column, which is what
     "sticky scroll" means on a kanban board.
   ⚠ AND THE ADD BUTTON PINS TO THE BOTTOM, for the same reason: it is the column's primary action and
     it was the first thing a long list pushed out of reach. */
.col {
  background:var(--panel); border:1px solid var(--line); border-radius:var(--r-lg);
  padding:0 10px 10px; min-height:280px;
}
/* 🔴 THE CAP IS A DESKTOP RULE, AND THE 641px FLOOR IS A CORRECTNESS FIX rather than tidiness
   [caught by t_reflow_zoom in the full gate, 2026-08-29].
   THE FIRST VERSION APPLIED AT EVERY WIDTH AND CARRIED `overflow-x:hidden` with it. At 320px -- a
   1280px screen at 400% browser zoom -- that CLIPPED the cards instead of letting `.boardwrap`
   scroll them: the suite found a column heading, a count badge, two assignee chips and a track tag
   sitting past the right edge with no scroll container able to reach them. That is WCAG 1.4.10
   content loss, and it is the exact failure `.boardwrap`'s own `overflow-x:auto` was added to fix.
   Her request was about the desktop board making the page endless (*"keep it from making the page
   eternity long"*); at 320px the board is already a horizontally scrolling strip, which is the
   layout that reflow rule deliberately produces. So the cap belongs above the phone breakpoint and
   nowhere else. */
@media (min-width:641px) {
  .col {
    max-height:var(--colcap, calc(100dvh - 422px)); overflow-y:auto;
    /* A thin, quiet scrollbar: four of these side by side would otherwise read as chrome. */
    scrollbar-width:thin; scrollbar-color:var(--line-hi) transparent;
    /* The drag rulebook drops onto `.col`; keeping the scroll here rather than on an inner wrapper
       means the drop target and the scrollport are the same element and nothing had to be rewired. */
    scroll-behavior:smooth;
  }
  .col::-webkit-scrollbar { width:8px; }
  .col::-webkit-scrollbar-thumb { background:var(--line-hi); border-radius:99px; }
  .col::-webkit-scrollbar-track { background:transparent; }
  /* 🔴 THE STICKY BAND IS GONE [2026-09-03]. It read, in full: "THE FIX IS AN OFFSET SHADOW, NOT A
     NEW WRAPPER ELEMENT... wrapping the cards in an inner scroller would have meant rewiring the
     drop handler, colTail(), reorderTarget() and resyncOrder() to chase a bug that is a paint
     problem."
     It was not a paint problem. `position:sticky` keeps an element VISIBLE while leaving it a child
     of the scroll container, so cards still passed under the button, the button was still inside the
     drop target, and a drop "below" it still produced a reorder the server had to refuse. She
     reported all three of those on 2026-09-03 as still happening.
     The button now sits OUTSIDE `.col` entirely, as a sibling in `.colwrap` -- see board.php where
     it is emitted. That needed no rewiring after all, because the CARDS did not move: `.col` is
     still the scrollport, still the drop target, still their direct parent. */
}
/* The pinned footer of a column. Outside `.col`, so nothing scrolls under it and nothing can be
   dropped on it. `margin-top:8px` matches the gap `.card`'s own margin leaves between cards, so the
   button sits the same distance below the list as the cards sit from each other. */
.colwrap > .addcard, .colwrap > .htip { margin-top:8px; flex:0 0 auto; }
.colwrap > .htip { display:block; }
.colwrap > .htip .addcard { margin-top:0; width:100%; }
.col.dragover { border-color:var(--brand); background:var(--panel-hi); }
.col-head {
  display:flex; justify-content:space-between; align-items:center;
  padding:12px 8px 10px; margin:0 -10px 8px; border-bottom:1px solid var(--line);
  position:sticky; top:0; background:var(--panel); z-index:5;
  border-radius:var(--r-lg) var(--r-lg) 0 0; padding-left:18px; padding-right:16px;
}
/* margin:0 is load-bearing: this is an <h2> as of 2026-08-08 (the four columns were <span>s and so
   were not in the document outline at all), and there is no global heading-margin reset in this file,
   so the UA's 0.83em would otherwise push it out of `.col-head`'s flex row. */
.col-title { font-family:var(--disp); font-size:13.5px; font-weight:700; letter-spacing:1.2px;
  text-transform:uppercase; color:var(--dim); margin:0; }
.col-count { font-family:var(--mono); font-size:12px; color:var(--dimmer); background:var(--bg-deep); border-radius:20px; padding:1px 9px; }
.card {
  position:relative; background:var(--bg-deep); border:1px solid var(--line);
  border-left:3px solid var(--dimmer); border-radius:var(--r-md);
  padding:11px 30px 10px 13px; margin:8px 0; cursor:grab;
  transition:border-color .15s, transform .12s;
}
.card:hover { border-color:var(--line-hi); }
.card.dragging { opacity:.5; }
/* 🔴 WAS opacity:.55 [raised 2026-08-08, Board audit]. A whole-card fade multiplies the TEXT down
   too: computed across all 16 themes, every `--dim` chip on a trophy card (`.assg`, the "N/N subtasks
   done" line, `.tag-sub`) landed between 2.69:1 and 3.40:1 — below WCAG AA 1.4.3 in every single
   theme, worst in scrumnoir. .85 is the strongest fade that keeps those chips at AA everywhere
   (worst 4.74:1, summersorbet); .80 already fails at 4.33. The "this one is done" read does not rest
   on the fade alone — board.php also prefixes the title with a check and prints "N/N subtasks done",
   so the state is in text, not only in opacity. */
.card.trophy { opacity:.85; }
.card.hidden-by-filter { display:none; }
/* overflow-wrap: a card title is free-typed up to maxlength=140 with no requirement that it contain a
   space, and a long unbroken run used to escape the card outright — measured 514px of scrollWidth in a
   181px title box, which then pushed #board's scrollWidth past its container. Same treatment dialog
   content already gets further down this file. */
.card-title { font-weight:700; font-size:14px; line-height:1.35; overflow-wrap:anywhere; }
.card-meta { display:flex; gap:7px; flex-wrap:wrap; align-items:center; margin-top:7px; }
/* min-height: the shared `.pencil` padding rendered this at 29x23, one pixel under WCAG 2.2 SC 2.5.8's
   24x24 (AA). Scoped to `.card-edit` rather than `.pencil`, which is also the roster and class-header
   pencil on three other pages and is not this audit's to resize. */
.card-edit { position:absolute; top:6px; right:5px; min-width:24px; min-height:24px; }
/* 🔴 THE KEYBOARD/MAGNIFIER PATH FOR MOVING A CARD [2026-08-16, accessibility build 1]. Drag-and-drop
   was the ONLY way to move a card, which is a WCAG 2.1.1 (Keyboard) failure and the reason a screen-
   magnifier user cannot use the Board at all: nothing on a card was focusable, so the magnifier — which
   follows keyboard focus — had nothing to follow.
   Placed BOTTOM-right rather than stacked under the pencil on purpose: the card's 30px right padding is
   already reserved for `.card-edit`, so reusing that same gutter at the other end adds this control
   without changing the card's text width or height by a single pixel. Same 24x24 minimum as .card-edit,
   which is the AA tap-target size that audit settled on. */
.card-move { position:absolute; bottom:6px; right:5px; min-width:24px; min-height:24px; line-height:1; }
/* Available to assistive tech, invisible on screen. The app had no such utility; the board's live
   region (move announcements) is the first thing that needed one. Standard clip-rect implementation —
   `display:none` and `visibility:hidden` both remove it from the accessibility tree, which would make
   the live region silent. */
.vishidden {
  position:absolute !important; width:1px; height:1px; margin:-1px; padding:0;
  overflow:hidden; clip:rect(0 0 0 0); clip-path:inset(50%); white-space:nowrap; border:0;
}
/* 🔴 `border:1px solid transparent` IS LOAD-BEARING on both .tag and .age [2026-08-08, Board audit].
   Only the bordered variants (.tag-attempt, .age-est) used to declare a border, so they rendered 2px
   taller than the unbordered ones sitting in the SAME .card-meta row: measured .tag-track 18.72px and
   .tag-sub 18.72px against .tag-attempt 20.72px, and .age-stale 19.5px against .age-est 21.5px. A
   transparent border here means every variant is one box size and the variants only set border-COLOR. */
.tag {
  font-family:var(--mono); font-size:9.5px; letter-spacing:1px; font-weight:600;
  border-radius:4px; padding:2px 6px; border:1px solid transparent;
}
/* Variable tracks: every tag carries its name; color comes from the ruled 4-slot palette via --pc */
.tag-track { color:var(--pc); background:color-mix(in srgb, var(--pc) 14%, transparent); }
/* --dimmer here failed AA in ALL SIXTEEN themes (3.94:1 bubblegum to 4.48:1 arcade) at 9.5px, which
   gets no large-text allowance. --dim is 4.55:1-7.86:1 and is what `.assg` next to it already uses. */
.tag-sub  { color:var(--dim); background:var(--panel-hi); }
.assg { font-size:11.5px; color:var(--dim); }
.age { font-family:var(--mono); font-size:10px; letter-spacing:.5px; border-radius:4px; padding:2px 6px;
  font-weight:600; border:1px solid transparent; }
/* All three signal badges use ONE plate construction: the hue mixed into --bg-deep rather than a
   translucent *-soft over whatever happens to be behind. The soft tokens composite over the surface
   they land on, and on a .card-stale card (red-tinted) or inside a dialog (--panel-hi) that lifted the
   plate until the text failed AA: .age-stale was 4.45:1 in bubblegum and 4.46:1 in pumpkinspice. The
   mixed plate is theme-stable at 5.29:1-7.26:1. */
.age-warn { color:var(--amber); background:color-mix(in srgb, var(--amber) 12%, var(--bg-deep)); }
.age-stale { color:var(--red); background:color-mix(in srgb, var(--red) 12%, var(--bg-deep)); }
/* 🔴 HALFWAY IS NOT A WARNING, AND --accent-2 MADE IT ONE IN TWO THEMES [fixed 2026-08-08].
   --accent-2 is per-theme: it resolves to #e0a878 in sunsettide (hue 28°, orange) and #ff4d6d in
   biohazard (hue 349°, red) — so a purely informational "the sprint is half over" badge painted
   itself in the two hues README §5 reserves for warning semantics. It also failed AA in five themes
   (2.74:1 aurora, 2.89 pumpkinspice, 2.91 arcade, 3.62 frostbyte, 4.37 catalystcats).
   --info is #7cc4f0 in every one of the 16 themes (declared once in the universal `:root,[data-theme]`
   signal block and never overridden, livewire's colourblind remap included), so no theme can turn this
   badge into an alarm. 6.37:1-8.76:1. */
/* 🔴 BACK TO --accent-2, HER RULING 2026-08-08 [SUPERSEDED 2026-08-09 — see below]. The audit had
   repainted this to a fixed --info blue because --accent-2 resolves to orange in sunsettide and red
   in biohazard, and amber/orange/red are reserved for warning semantics. She overruled that for THEME
   ACCENTS specifically: *"I like the halfway in the accent color. It's just accepted that for some
   themes, it's going to cross into the red territory, but like if you pick Overclocked, you accept
   that, that's what the theme is... I want to see the theme colors pull through places or what is the
   point of the theme?"*
   So the reserved-palette rule still governs colours the APP chooses; it does not override a colour the
   THEME chose. ~~Do not "fix" this back to --info.~~ — see the reversal below; kept for the reasoning
   trail rather than deleted.
   ⚠ SEPARATE, STILL OPEN AT THE TIME: at 9.5px on its own 12% plate this was below WCAG AA 4.5:1 in
   five themes — aurora 2.76, pumpkinspice 3.04, arcade 3.05, frostbyte 3.69, catalystcats 4.42 (her
   own). Resolved by the reversal below, which starts from a token that is AA-clean by construction. */
/* The LABEL is lifted 30% toward the theme's own --text; the plate is untouched. Her call 2026-08-08
   after seeing the numbers: keep the theme colour, fix only the legibility. Pure --accent-2 was below
   AA in 5 of 16 themes (aurora 2.76, pumpkinspice 3.04, arcade 3.05, frostbyte 3.69, catalystcats
   4.42). 70% is the LARGEST accent share where all 16 clear 4.5:1 — computed, not guessed: 75% still
   left pumpkinspice at 4.49. Worst case is now 4.85 (pumpkinspice), and mixing toward --text rather
   than toward white keeps each theme's own hue direction, so the badge still reads as that theme's
   accent instead of a generic pale blue. [Superseded 2026-08-09 — see below.] */
/* 🔴 REVERSED 2026-08-09, HER CALL — moved to --info, and the reversal is sound, not a re-litigation.
   Her own words: *"the whole idea was that --info would become a color of theme (not hard coded
   blue), not just straight up brand but some combo that reads the theme but isn't screaming... all
   the 'info-like' things were going to go under --info."* The 2026-08-08 objection above was to a
   FIXED, flat #7cc4f0 blue that could never "pull the theme through" — exactly the complaint. A
   per-theme --info is no longer that: it IS a colour derived from this theme (brand's own hue,
   softened; accent-2's for the two themes where brand sits too close to --red/--amber to soften
   safely — see each theme's own `--info:` comment). So the reason to avoid it dissolves the same way
   it did for the on-call banner (`.ab-brand`, below).
   No more 70%-toward-text legibility mix needed: unlike --accent-2, every theme's --info was DERIVED
   to already clear WCAG AA 4.5:1 as full-strength text against both --panel and --bg-deep (one of the
   four checkable constraints in the per-theme derivation) — so the plate can use it at 100%, same as
   `.ab-blue` and every other --info consumer, instead of a workaround this token no longer needs. */
.age-half { color:var(--info);
  background:color-mix(in srgb, var(--info) 12%, var(--bg-deep)); }
/* 🔴 AN EMPTY COLUMN WAS AN UNDESIGNED 280px VOID [added 2026-08-08, Board audit]. `.col` keeps its
   min-height whatever is in it, and only three of the four columns carry an "＋ new card" button
   underneath — Completed deliberately has none — so a board filtered down to nothing rendered a
   Completed column whose entire innerText was "COMPLETED 0" above 260px of nothing. Measured with the
   OVERDUE chip active on the teacher board: three of the four columns at boxH 280 and Completed with no
   words in it at all. README §5: never render a section that silently vanishes; empty states are designed.
   Toggled in pure CSS on purpose — `:has()` covers BOTH shapes at once (the server rendered no cards,
   and the client filters hid every card via `.hidden-by-filter`) with no hook into applyFilters() or
   colCounts(), which stay exactly as they were. `:has()` predates color-mix(), which this file already
   depends on, so it costs no browser support. */
.colempty { display:none; color:var(--dim); font-size:12.5px; line-height:1.5; padding:16px 12px 6px; }
.col:not(:has(.card:not(.hidden-by-filter))) .colempty { display:block; }
.addcard {
  display:block; width:100%; text-align:left; cursor:pointer;
  background:transparent; border:1px dashed var(--line); border-radius:var(--r-md);
  color:var(--dimmer); font-size:12.5px; font-weight:700; padding:9px 12px; margin-top:8px;
  transition:all .15s;
}
.addcard:hover { color:color-mix(in srgb, var(--brand) 55%, var(--text)); border-color:var(--brand); background:var(--brand-soft); }
/* 🔴 ENDED CLASS = READ ONLY, and the control stays put [her ruling 2026-08-08]. Removing the button
   made the column look broken; disabling it reads as intentional, and the class-ended banner above the
   board says why. The :hover rule above is cancelled so it cannot pretend to still be clickable. */
.addcard.isoff { cursor:not-allowed; opacity:.55; border-style:dashed; }
.addcard.isoff:hover { color:var(--dimmer); border-color:var(--line); background:transparent; }
/* 24px, not 22px: WCAG 2.2 SC 2.5.8 (Target Size, Minimum) is Level AA and wants 24x24 CSS px. This
   button sits in a 7px-gap flex row of text chips, so the spacing exception does not apply to it.
   flex-shrink:0 so a narrow column shrinks the chips beside it and never the target itself. */
.addbtn {
  cursor:pointer; background:transparent; border:2px solid var(--line-hi);
  color:var(--text); border-radius:50%; width:24px; height:24px; line-height:1;
  font-size:13px; padding:0; transition:all .15s; flex-shrink:0;
  display:inline-flex; align-items:center; justify-content:center;
}
.addbtn:hover { color:var(--brand); border-color:var(--brand); }
/* See the app-wide `[hidden]` rule near the top of this file -- `.addbtn` was one of the classes it
   was written for. */
.addbtn.big { width:30px; height:30px; font-size:16px; margin-top:8px; }

/* ---------- Modals ---------- */
dialog {
  background:var(--panel-hi); color:var(--text);
  border:1px solid var(--line-hi); border-radius:var(--r-lg);
  padding:var(--sp-5) 28px; min-width:360px; max-width:92vw;
  box-shadow:0 20px 60px rgba(0,0,0,.6);
  /* A DIALOG SCROLLS INSTEAD OF OVERFLOWING [2026-08-28, MOBILE_BUILD_PLAN §5 + §11].
     ⚠ READ THIS BEFORE BUDGETING THAT ITEM AGAIN -- ITS PREMISE IS STALE. The plan says a tall
     dialog "overflows a 667px phone viewport with no way to reach the Back button", i.e. controls
     genuinely unreachable. MEASURED, not assumed: it does not, and it has not for years. Every
     browser's UA stylesheet gives `dialog:modal` both a max-height (Chromium:
     calc(100% - 6px - 2em), which is 630px of a 667px phone) and `overflow:auto`, and all 103
     dialog opens in this app are showModal(). A red probe with the three lines below DELETED still
     passed tests/ui/t_dialog_reach.py -- that is how this was found, and it is why that suite now
     says out loud what it is and is not proving.
     WHAT THESE LINES STILL BUY, which is narrower but real:
      - `dvh` instead of the UA's percentage. A modal dialog's containing block is the initial
        containing block, so a UA `100%` resolves against the LARGE viewport -- the one with the
        URL bar hidden -- and can run under the bar while it is showing. `dvh` follows the bar.
      - The behaviour stops depending on a UA default nobody here controls or tests against.
     vh first, then dvh, so a browser without dvh still gets a clamp rather than none.
     ⚠ NOT scoped to the 640 block: a short laptop window and a 400% browser zoom are the same
     case. .dlg-lg / .dlg-xl / .switchdlg keep their own 88vh, a class outranking this selector. */
  max-height:85vh;
  max-height:85dvh;
  overflow-y:auto;
}
dialog::backdrop { background:rgba(5,7,12,.7); }
/* B1 [Snapshot Review Group B, her ruling]: "ALL POPUP HEADERS NEED TO BE BIGGER... Header like you
   mean it!" Measured: 17px against 15.5px body is a 1.10 ratio; a primary heading floor is 1.25x-1.5x.
   20px matches .dlgc, the value already blessed for centered dialog headers -- reused, not invented. */
dialog h3 { margin:0 0 2px; font-family:var(--disp); font-size:20px; font-weight:700; }
.dialog-actions { display:flex; gap:8px; margin-top:var(--sp-4); align-items:center; }
/* O1 · *"the confirm button move to the bottom right"* [her note 10, for Generate New Teams and
   Print Join Codes]. A modifier, not a change to `.dialog-actions` itself: that row is shared by
   every dialog in the app, and several of them are destructive confirms whose button placement she
   has not asked about. ⚠ OPEN FOR HER: now that O1 has taken CANCEL off all nine dialogs on this
   surface, the other seven also have a lone confirm sitting hard left. Right-aligning those too
   would be consistent, but it is a change she did not ask for on dialogs she did not name. */
.dialog-actions.dlgend { justify-content:flex-end; }
.dialog-actions .btn { margin-top:0; }
/* 🔴 THE COUNTER WAS A FLEX ITEM ON THE FIELD'S OWN LINE -- the SECOND instance of the bug she ruled
   on for `.wiztrackrow` (2026-08-23), and fixed the same way so the two cannot drift. The shared
   listener inserts `.charcount` as the field's next sibling, which is correct in a stacked form and
   wrong in a flex row: it landed beside the box and squeezed the very field it was counting.
   The title also gets the whole width now. 140 characters in a one-line input sharing its row with a
   track dropdown is not a box you can read a sentence in [her ruling 2026-08-27], so the field is a
   two-row textarea on its own line, the track select drops under it, and the count takes the line
   below that -- `order:99` keeps it last no matter which of the two fields wraps first. */
.splitrow { display:flex; flex-wrap:wrap; gap:8px; margin-top:8px; align-items:flex-start; }
.splitrow textarea[name="sub_title[]"] {
  flex:1 1 100%; min-width:0; width:100%; resize:vertical; line-height:1.4;
}
.splitrow select { max-width:130px; }
.splitrow .charcount { order:99; flex-basis:100%; margin-top:2px; }

/* 🔴 TEAM SETTINGS' TWO SET-ONCE TOOLS [her ruling 2026-08-29]. The Slack webhook and the join code
   used to occupy the top half of the page above the roster she actually came for. They are buttons
   now, sitting on one line under the team name, and each opens the panel it replaced.
   ⚠ THEY SHOW THEIR OWN STATE, which is the whole reason a button is enough: the Slack one carries a
     dot when a webhook is set, and the join-code one prints the code on its face and says "(closed)"
     when it is closed. Hiding something behind a button is only fair if the button answers the
     question you would have opened it to ask. */
.tstools { display:flex; gap:10px; flex-wrap:wrap; margin:14px 0 0; }
.tstool { display:inline-flex; align-items:center; gap:9px; cursor:pointer;
  background:var(--panel); border:1px solid var(--line-hi); border-radius:var(--r-md);
  padding:9px 14px; min-height:40px; color:var(--dim); font-family:var(--sans); font-size:12.5px; }
.tstool:hover { border-color:var(--brand); color:var(--text); }
.tstool:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }
.tstool svg { flex:none; }
.tstool-code { color:var(--text); font-weight:700; letter-spacing:.04em; }
.tstool-on { width:7px; height:7px; border-radius:50%; background:var(--green); display:inline-block; }
/* 🔴 A COUNT ON THE FACE OF THE TOOL, not the dot [My Team's Upcoming leave, 2026-09-04]. Team
   Settings' three tools are in a row and only have room for `.tstool-on`; My Team's stands alone and
   the accordion it replaced already showed the sentence ("3 coming up" / "nobody out"), so it keeps
   it. `.tstool-code` was the wrong reuse -- it is 700-weight `--text` for a join code you have to
   read off a screen, and "nobody out" said in that voice is louder than the thing it is reporting.
   Same mono/12px/--dim treatment `.qsechead .cnt` and `.bhacc>summary .cnt` already use for a count
   beside a heading, which is what this is. */
.tstool-cnt { font-family:var(--mono); font-size:12px; color:var(--dim); }

/* The webhook steps, in the popup rather than behind a `?` that could not fit -- see the markup. */
.hookhelp { margin-top:18px; padding:14px 16px; border:1px solid var(--line);
  border-radius:var(--r-md); background:var(--bg-deep); }
.hookhelp-h { font-family:var(--disp); font-weight:800; font-size:13px; margin:0 0 8px; }
.hookhelp ol { margin:0 0 10px; padding-left:20px; }
.hookhelp li { font-size:12.5px; line-height:1.6; color:var(--dim); }
.hookhelp li b { color:var(--text); font-weight:700; }

/* 🔴 THE JOIN CODE, ONE LINE, WHATEVER IT SAYS [her ruling 2026-08-29]. A code is read off a screen
   and typed by hand, so wrapping it is worse than shrinking it: a break in the middle looks like part
   of the code. `clamp()` lets it shrink with the popup instead of wrapping, and `white-space:nowrap`
   makes that the only thing it can do. */
.joincodebig { font-family:var(--mono); font-weight:700; letter-spacing:.06em;
  font-size:clamp(18px, 7.5cqw, 34px); white-space:nowrap; overflow:hidden; text-overflow:clip;
  text-align:center; padding:14px 10px; margin:0 0 14px;
  background:var(--bg-deep); border:1px solid var(--line-hi); border-radius:var(--r-md); }
.joincodebig.off { opacity:.35; }
/* Full width, under the code, so the destructive action reads as its own step rather than as a
   decoration beside the thing it acts on. */
.joincodebtn { width:100%; justify-content:center; margin:0; }

/* ---------- The typed delete confirm [her ruling 2026-08-29, from Jira] ----------
   Red, wordy and slow on purpose. Every other confirm in this app is one sentence and one button,
   which is right for actions that can be undone; this is the one that cannot be. */
dialog.deldlg { width:min(520px, 94vw); }
.deldlg h3 { color:var(--red); }
.delwarn { border:1px solid var(--red); background:var(--red-soft); border-radius:var(--r-md);
  padding:13px 15px; margin:12px 0 14px; }
.delwarn p { margin:0 0 8px; font-size:13.5px; line-height:1.5; }
.delwarn p:last-child { margin-bottom:0; }
/* The way out, styled as the calm thing in a loud box: it is the answer most of the time. */
.delwarn .delalt { color:var(--dim); font-size:12.5px; border-top:1px solid color-mix(in srgb, var(--red) 35%, transparent);
  padding-top:9px; margin-top:10px; }
.delcardname { font-family:var(--disp); font-weight:800; font-size:16px; margin:0 0 14px;
  word-break:break-word; }
.deldlg label { display:block; font-size:12.5px; color:var(--dim); margin-bottom:6px; }
/* 16px: the app-wide floor for anything typeable, or iOS Safari zooms on focus and does not come
   back -- and this control is inside a dialog, where that strands the reader mid-decision. */
.deldlg #deltypein { width:100%; box-sizing:border-box; font-size:16px; margin:0; }
.deldlg .btn-red[disabled] { opacity:.45; cursor:not-allowed; }

/* What a subtask is part of, in the rail [her ruling 2026-08-29]. A button that looks like the title
   it names rather than like a control, because what it mostly does is inform. */
/* Read-only, like the estimate beside it: the team a card belongs to is changed by moving the card,
   not by editing a field here. */
.citeam { font-family:var(--disp); font-weight:800; font-size:13.5px; line-height:1.3;
  word-break:break-word; }
.ciparent { display:block; width:100%; text-align:left; background:none; border:1px solid var(--line);
  border-radius:var(--r-md); padding:8px 10px; color:var(--text); font-family:var(--disp);
  font-size:13.5px; font-weight:700; line-height:1.3; cursor:pointer; word-break:break-word; }
.ciparent:hover { border-color:var(--brand); color:var(--brand); }
.ciparent:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }

/* RETIRED WITH THE SAVE BUTTON [2026-08-29] -- see .cibub's note. A member x used to mark and wait
   for SAVE; the click is the removal now, so there is no pending state left to draw. #editrmnote
   survives and still carries role="status", but it announces what just happened rather than what
   SAVE would do. */

/* ---------- Card Info, laid out to her Trello reference [2026-08-27] ----------
   Order is the design: a tinted bar carrying MOVE, then the title, then members beside the estimate,
   then the description. Her spec, and the Labels/Dates/Checklist row from the reference is
   deliberately absent because she named it as the part she did not want. */
.cibar {
  display:flex; align-items:center; gap:10px;
  background:var(--bg-deep); border:1px solid var(--line);
  border-radius:var(--r-md); padding:10px 12px; margin:0 0 16px;
  position:relative;                /* the move panel anchors to this bar, not to the dialog */
}
/* The title IS the heading here (the real <h3> is .vishidden for screen readers), so it carries the
   weight of one and the full width of the dialog. */
.cititle {
  width:100%; font-size:20px; font-weight:700; line-height:1.3;
  padding:10px 12px; margin:0 0 4px;
}
/* Members and estimate share a row; the estimate is a fixed column so the bubbles keep the rest.
   ⚠ align-items:start, or a one-line estimate stretches to the bubble row's height and its label
   drifts away from its own value. */
.cirow2 { display:flex; gap:22px; align-items:flex-start; flex-wrap:wrap; margin:10px 0 4px; }
.cimem  { flex:1 1 260px; min-width:0; }
.ciest  { flex:0 0 auto; }
.ciestval { font-size:19px; font-weight:700; padding-top:2px; }
.ciestval .dim { font-size:12.5px; font-weight:400; }
/* The bubbles: an avatar per person, then the "+" that reveals the add control. 34px keeps the whole
   row above the 24x24 target floor without turning the avatars into the loudest thing in the dialog. */
.cibubbles { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin-top:4px; }
/* ══════════════════════════════════════════════════════════════════════════════════════════════
   CARD INFO, REBUILT [her design 2026-08-28 from three Trello references; mockups approved before a
   line moved: HTMLSnapshots/LIVE-DESIGN/cardinfo-*.png].

   🔴 THE WIDTH WAS CHOSEN OVER THE REAL BOARD, NOT IN ISOLATION. 1040px covered three of the four
   columns and 760 crowded the rail; 880 is hers -- *"Scale-b is perfect."* The scale set that
   decided it is cardinfo-scale-{a,b,c}, shot at 1440x900 because "covering" is a ratio and a laptop
   is what she works on.
   ⚠ IT SHRINKS ON A SMALL SCREEN. `min()` rather than a flat 880, or the dialog is wider than a
     phone and the whole thing scrolls sideways.
   ══════════════════════════════════════════════════════════════════════════════════════════════ */
dialog.cidlg { width:min(880px, 94vw); max-width:min(880px, 94vw); padding:0; overflow:visible; }
dialog.cidlg form { margin:0; }
/* THE TOP STRIP: darker than the dialog, full width, move on the left and the icons on the right. */
/* 🔴 `#addmodal` IS ON THESE TWO SELECTORS DELIBERATELY [her report 2026-08-29: *"the add card
   doesn't look like the mockup on top. Everything is all squished up there."*]. Add card's markup has
   said `class="ci-top add-top"` since the rebuild, but every rule that MAKES a top strip was scoped
   to `.cidlg` -- a class only Card Info carries. So Add card got the element and none of the
   appearance: no padding, no dark band, no flex row. That last one is why the ✕ sat tucked against
   the column chip instead of out at the right edge; `margin-left:auto` needs a flex parent, and
   there was not one.
   ⚠ THE MOCK WAS RIGHT AND THE MARKUP WAS RIGHT. Only the stylesheet's reach was wrong, which is
     exactly why it looked like a design that had not been followed. */
.cidlg .ci-top, #addmodal .ci-top, .bdlg .ci-top, .rmdlg .ci-top,
.attdlg .ci-top, .deldlg .ci-top, .popbody .ci-top, .switchdlg > .ci-top,
.dlg-lg > .ci-top, .dlg-xl:not(.formsdlg) > .ci-top { background:var(--bg-deep); border-bottom:1px solid var(--line);
  padding:11px 14px; display:flex; align-items:center; gap:10px;
  border-radius:var(--r-lg) var(--r-lg) 0 0; }
.cidlg .ci-topright, #addmodal .ci-topright, .bdlg .ci-topright, .rmdlg .ci-topright,
.attdlg .ci-topright, .deldlg .ci-topright, .popbody .ci-topright, .switchdlg .ci-topright,
.dlg-lg .ci-topright, .dlg-xl .ci-topright { margin-left:auto; display:flex; align-items:center; gap:4px; }
/* 🔴 THE BIG DIALOGS TAKE THE STRIP BY NEGATIVE MARGIN, NOT BY GIVING UP THEIR PADDING
   [2026-09-07, chat 12]. `.dlg-lg` and `.dlg-xl` are both `padding:26px 30px`, and SEVEN dialogs
   share `.dlg-lg` (style.css:4867 has said so since #addclass got its scoped width override).
   The obvious move -- `padding:0` plus a body wrapper, which is what `.bdlg` does -- would have
   made every direct child of all seven lose its inset at once, and their bodies are arbitrary:
   a stepper, a chip list, a wizard, two question forms. That is a blast radius of seven working
   surfaces to gain a strip.
   So the dialog keeps its 26/30 and the strip alone reaches back out over it. The visual result
   is identical -- a full-bleed dark band with the ✕ in it -- and NOTHING below the strip moves.
   MEASURED after: every one of the seven reports its strip flush to both edges with its body
   still inset 31px, unchanged from before the strip existed.
   ⚠ `:not(.formsdlg)` IS LOAD-BEARING. Edit Forms is `.dlg-xl` too, but chat 11 gave it
     `padding:0` and its own `> .ci-top` block at :3290. Without the exclusion this margin would
     drag its strip 26px above and 30px outside a dialog that has no padding to reach back over,
     which is a strip hanging off the corner of the hardest dialog in the app. */
/* 🔴 EACH BORROWED STRIP CARRIES TWO NUMBERS, AND NEITHER IS SHARED [2026-09-07, chat 12].
   MARGIN = the negative of that dialog's own padding, so the strip reaches both edges.
   PADDING-INLINE = that dialog's body inset MINUS 8, because `.colchip.addwhere` adds its own
     8px and what has to line up is where the caption TEXT lands, not where its box starts.
     This is the same arithmetic chat 10 wrote over `.repdlg .ci-top` ("22px is the family's
     body inset, and 14 + 8 lands the strip's caption on it too") and chat 11 re-derived for
     Edit Forms, where the strip is 22 and NOT the family's 14 because `.dlg-xl`'s inset is 30.
       .bdlg / #addmodal / .cidlg / .rmdlg   inset 22  ->  strip padding 14   (the rule above)
       .dlg-lg / .dlg-xl                     inset 30  ->  strip padding 22
       .attdlg / .deldlg / .switchdlg        inset 28  ->  strip padding 20
       .popbody                              inset 26  ->  strip padding 18
   ⚠ THE FIRST DRAFT OF THESE RULES USED THE FAMILY'S 14 EVERYWHERE and every borrowed strip
     sat its caption 8px left of its own title. It was caught by t_dialog_chrome.py §3, whose
     alignment assertion chat 5 wrote for an entirely different reason -- #leadertracksdlg came
     back chip=463 h3=471. Do not "simplify" these back to one number. */
.dlg-lg > .ci-top, .dlg-xl:not(.formsdlg) > .ci-top { margin:-26px -30px 20px; padding-left:22px; padding-right:22px; }
.attdlg > .ci-top, .deldlg > .ci-top, .switchdlg > .ci-top { margin:-24px -28px 16px; padding-left:20px; padding-right:20px; }
.popbody > .ci-top { margin:-24px -26px 16px; padding-left:18px; padding-right:18px;
  border-radius:13px 13px 0 0; }
/* 🔴 `.rmdlg` IS ON THOSE TWO SELECTORS FOR THE SAME REASON `#addmodal` IS [2026-09-07, chat 12].
   The removal wizard takes the house strip but NOT the house class: every option card in it is a
   `<label class="opt">`, and `.bdlg label` is uppercase 10.5px dim at (0,1,1) -- `text-transform`
   and `color` both inherit, so joining `.bdlg` would have shouted the `.lab` and `.desc` text
   inside all four cards of a dialog that has a blessed mockup. MEASURED in Chromium before the
   decision: 12.5px -> 10.5px, none -> uppercase, normal -> 1.05px letter-spacing on all three.
   Borrowing the strip and keeping the class is what chat 10 did for `.repdlg` and chat 11 for
   `.formsdlg`; this is the same call, made the same way, and it needs exactly the four lines
   below rather than a copy of the family. */
dialog.rmdlg { padding:0; }
/* The steps carry the body inset the dialog gave up, and 22px is the family's own figure -- the
   same one `.bdlg > form` and `.rep-body` use, so the wizard lines up with its neighbours. */
.rmdlg [data-rmstep] { padding:0 22px 22px; }
/* Card Info's title scale, matching `.bdlg > h3`. Scoped to the step because this wizard's
   headings live one level down -- each step has its own, and the strip above them is shared. */
.rmdlg [data-rmstep] > h3 { margin:20px 0 5px; font-family:var(--disp); font-size:26px;
  font-weight:800; line-height:1.2; }
/* `dlg-close` is float:right app-wide; inside the strip the flex row does the placing. */
.ci-top .dlg-close { float:none; position:static; margin:0; }
.ci-icon { width:32px; height:32px; border-radius:8px; border:1px solid transparent; background:none;
  color:var(--dim); cursor:pointer; font-size:16px; line-height:1;
  display:inline-flex; align-items:center; justify-content:center; }
.ci-icon:hover, .ci-icon[aria-expanded="true"] { background:var(--panel); color:var(--text); border-color:var(--line-hi); }
.ci-menu { position:relative; display:inline-block; }
/* 🔴 REQUIRED, for the same reason as `.reqflag[hidden]` above: a class `display` outranks the UA
   sheet's `[hidden] { display:none }`. openEdit() hides this whole menu when the viewer may not
   delete [her ruling 2026-08-29], and without this line it stayed on screen for everybody. */
.ci-menu[hidden] { display:none; }
.ci-pop { position:absolute; right:0; top:38px; z-index:40; background:var(--panel-hi);
  border:1px solid var(--line-hi); border-radius:10px; padding:6px; min-width:170px;
  box-shadow:0 14px 34px -10px rgba(0,0,0,.6); }
.ci-pop[hidden] { display:none; }
.ci-item { display:block; width:100%; text-align:left; background:none; border:0; color:var(--text);
  font:600 13px var(--sans); padding:9px 10px; border-radius:7px; cursor:pointer; }
.ci-item:hover { background:var(--panel); }
.ci-item.ci-red { color:var(--red); }

/* THE TWO COLUMNS. The rail holds what the card IS; the main column holds what it says. */
.cidlg .ci-cols { display:flex; align-items:stretch; }
.cidlg .ci-main { flex:1 1 auto; min-width:0; padding:20px 22px 22px; }
.cidlg .ci-rail[hidden] { display:none; }
.cidlg .ci-rail { flex:0 0 210px; background:var(--panel); border-left:1px solid var(--line);
  padding:20px 18px; }
.cidlg .ci-rail label { display:block; font-family:var(--disp); font-size:10.5px; letter-spacing:.10em;
  text-transform:uppercase; color:var(--dim); font-weight:800; margin:0 0 8px; }
.cidlg .ci-railblock + .ci-railblock { margin-top:20px; }
/* 🔴 16px, NOT .styledsel's 13 [caught by tests/ui/t_input_zoom.py the run after the rebuild].
   Any typeable control under 16px makes iOS Safari ZOOM the page when it is focused, and the zoom
   does not come back on blur -- which on a dialog leaves the reader stranded mid-form. `.styledsel`
   is 13px and is right for the filter pills it was written for; it is not right for a control inside
   a dialog. The class is kept for its caret: the sitewide select rule strips the native one, so a
   bare <select> here would have no affordance at all. */
.cidlg .ci-rail select { width:100%; font-size:16px; }
.cisub-new select, .cipick select { font-size:16px; }
/* .ci-save is RETIRED [her ruling 2026-08-29]: the dialog has no SAVE row, so the last block of
   padding under the columns went with it. Card Info's bottom edge is .ci-main's own padding now. */
.cidlg .cititle { font-family:var(--disp); font-size:26px; font-weight:800; line-height:1.2;
  width:100%; box-sizing:border-box; background:none; border:1px solid transparent;
  border-radius:var(--r-sm); padding:5px 8px; margin:0 0 4px -8px; color:var(--text); }
.cidlg .cititle:hover { border-color:var(--line); }
.cidlg .cititle:focus { border-color:var(--brand); background:var(--bg-deep); outline:none; }
.cidlg .cimem { margin-top:18px; position:relative; }
.cidlg .cimem > label, .cidlg #cidesclbl, .cidlg #cisubslbl {
  display:block; font-size:12px; font-weight:700; color:var(--dim); margin:0 0 7px; }
.cidlg #editaddwrap { display:none; }   /* the list replaced it; it only carries the value now */

/* THE MEMBER PICKER: a list anchored to the "+", her pick of the two drawings. */
/* ⚠ IT HANGS BELOW THE DIALOG ON PURPOSE, which is what an anchored dropdown does and is why
   `dialog.cidlg` sets overflow:visible. Measured on a four-person team: 130px past the dialog's own
   bottom edge, and it reads correctly on the scrim. The cap is for the case that measurement does
   NOT cover -- a large roster would otherwise run off the bottom of the screen with no way back. */
.cipick { position:absolute; z-index:30; left:0; top:52px; width:262px; background:var(--panel-hi);
  border:1px solid var(--line-hi); border-radius:10px; padding:9px 0 7px;
  max-height:min(420px, 60vh); overflow-y:auto; overscroll-behavior:contain;
  box-shadow:0 16px 40px rgba(0,0,0,.6); }
.cipick[hidden] { display:none; }
.cipick-h { display:flex; align-items:center; padding:0 11px 8px; border-bottom:1px solid var(--line); }
.cipick-h b { flex:1; text-align:center; font-size:13.5px; }
.cipick-x { background:none; border:0; color:var(--dim); cursor:pointer; font-size:13px;
  width:26px; height:26px; border-radius:6px; }
.cipick-x:hover { color:var(--text); background:var(--panel); }
.cipick-sec { font-size:10.5px; letter-spacing:.08em; text-transform:uppercase; color:var(--dim);
  font-weight:800; padding:10px 11px 4px; margin:0; }
.cipick-row { display:flex; align-items:center; gap:10px; padding:6px 11px; width:100%;
  background:none; border:0; color:var(--text); cursor:pointer; text-align:left; }
.cipick-row:hover { background:var(--panel); }
/* 🔴 THE CHOSEN ROW HAS TO LOOK CHOSEN. addBubSync() has toggled `.on` on these rows since Add
   card got this list, but nothing ever styled it, so the one control that lets you click a name again
   to un-choose it gave no sign which name that was. Assign & Move is a toggle over two slots and made
   that gap impossible to leave. */
.cipick-row.on { background:var(--panel); box-shadow:inset 3px 0 0 var(--brand); }
.cipick-row.on .cipick-nm { font-weight:700; }
.cipick-row:disabled { opacity:.45; cursor:not-allowed; }
.cipick-row:disabled:hover { background:none; }
.cipick-row .avatar { width:30px; height:30px; }
.cipick-nm { font-size:13.5px; }
.cipick-rx { margin-left:auto; color:var(--dim); font-size:12px; }

/* THE DESCRIPTION'S TWO MODES. 62ch is the reading measure: past roughly 75 characters the eye loses
   the start of the next line, so a wider dialog must not mean a longer line. */
.cidlg .cidesc { margin-top:20px; }
.cidesc-head { display:flex; align-items:center; justify-content:space-between; gap:10px; }
.cidesc-read { max-width:62ch; font-size:14.5px; line-height:1.6; white-space:pre-line;
  border-radius:var(--r-sm); padding:8px 10px; margin-left:-10px; cursor:text; color:var(--text); }
.cidesc-read:hover { background:var(--panel); }
.cidesc-read:focus-visible { outline:2px solid var(--brand); outline-offset:-2px; }
.cidesc-read.cidesc-empty { color:var(--dimmer); }
.cidlg #editdesc { max-width:62ch; width:100%; box-sizing:border-box; margin:0; }
.cidesc-acts { display:flex; align-items:center; gap:9px; margin-top:9px; max-width:62ch; }
.cidesc-count { margin-left:auto; font-size:11.5px; color:var(--dim); font-family:var(--mono); }

/* BIG PICTURE'S SPLIT TASKS. Done is crossed off AND ticked AND green: her "something satisfying",
   and three signals so it never rests on colour alone. */
.cidlg #cisubs { margin-top:22px; }
.cisubs-pct { font-size:11.5px; color:var(--dim); font-family:var(--mono); }
.cisubs-bar { height:7px; border-radius:99px; background:var(--line); overflow:hidden; margin:4px 0 12px; }
.cisubs-bar i { display:block; height:100%; background:var(--green); transition:width .3s; }
/* 🔴 THE ROW READS TITLE ... PEOPLE ... COLUMN [her ruling 2026-08-29]. `margin-left:auto` on the
   people block pushes both of the right-hand parts out, so the column chips line up in a single
   column down the list and the eye can run the progression without reading a word. */
.cisub { display:flex; align-items:center; gap:11px; padding:7px 0; }
.cisub .cisub-t { flex:1 1 auto; min-width:0; }
.cisub-who { display:inline-flex; align-items:center; gap:-4px; margin-left:auto; flex:0 0 auto; }
.cisub-face { display:inline-flex; margin-left:-6px; }   /* a slight overlap, like the card faces */
.cisub-face .avatar { width:22px; height:22px; border:1.5px solid var(--panel); border-radius:50%; }
/* 🔴 ONE SHARED WIDTH, NOT A MINIMUM [her standing rule: badges sharing a column are all one width,
   never sized to their own text]. `min-width:84px` was the first attempt and it is exactly the bug
   that rule exists to stop: it is a FLOOR, so "BIG PICTURE" grew past it and sat wider than the other
   three. Measured, not guessed -- t_cardinfo_autosave §8 compares the rendered widths and found two.
   104px clears the longest label with room, and box-sizing keeps the padding inside it. */
.cisub-col { flex:0 0 104px; width:104px; box-sizing:border-box;
  text-align:center; font-size:10.5px; font-weight:700;
  font-family:var(--disp); letter-spacing:.06em; text-transform:uppercase; border-radius:99px;
  padding:4px 9px; border:1px solid var(--line-hi); color:var(--dim); }
.cisub-col-current { color:var(--brand); border-color:color-mix(in srgb, var(--brand) 55%, transparent);
  background:color-mix(in srgb, var(--brand) 12%, transparent); }
.cisub-col-completed { color:var(--green); border-color:color-mix(in srgb, var(--green) 55%, transparent);
  background:color-mix(in srgb, var(--green) 12%, transparent); }
/* ⚠ `.cisub-box` IS GONE FROM THE MARKUP [2026-08-29] and these two rules with it -- see ciSubs().
   Left as a note rather than silently dropped: a checkbox here was not a styling choice that went
   out of fashion, it was an affordance for something the list cannot do. */
.cisub-box-RETIRED { width:19px; height:19px; border-radius:5px; border:1.5px solid var(--line-hi);
  flex:0 0 19px; display:grid; place-items:center; font-size:12px; }
    .cisub-box-RETIRED.done { background:var(--green); border-color:var(--green); color:#0b0f14; font-weight:800; }
.cisub.done .cisub-t { text-decoration:line-through; color:var(--dim); }
.cisub-t { font-size:14px; }
.cisub-new { display:flex; gap:8px; align-items:center; margin-top:9px; }
.cisub-new input { flex:1; margin:0; }
.cisub-new select { margin:0; max-width:150px; }

/* On a phone the rail stacks under the body rather than squeezing both. */
@media (max-width:720px) {
  .cidlg .ci-cols { flex-direction:column; }
  .cidlg .ci-rail { flex:1 1 auto; border-left:0; border-top:1px solid var(--line); }
}
@media (pointer:coarse) { .ci-icon { min-width:44px; min-height:44px; } .ci-item { min-height:44px; }
  .cipick-row { min-height:44px; } }

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   ADD CARD, IN THE SAME FAMILY [her ask 2026-08-28: *"It's just a tiny popup with three fields and it
   doesn't FEEL good."* Mock: HTMLSnapshots/LIVE-DESIGN/boardpopup-1-addcard.png].
   The diagnosis was not "it is ugly": it was a stack of label-over-dropdown rows, the shape of a
   settings form, while Card Info two clicks away speaks a different language. These rules give it the
   same top strip, a title sized like the card it becomes, and pickers instead of dropdowns wherever
   the options are few and visual.
   ══════════════════════════════════════════════════════════════════════════════════════════════ */
/* 🔴 ONE FAMILY, FOUR DIALOGS [her ruling 2026-08-29]: *"This dialogue needs to be reworked to match
   all the new popups on the board page ... to match the vibe of everything else we re-designed
   here."* `.bdlg` is what Add card's rules were already doing, given a name so the estimate dialog,
   the Big Picture checkpoint and the completion pair get them without a second copy.
   ⚠ #addmodal KEEPS ITS OWN SELECTOR rather than gaining the class in markup: it is addressed by id
     in half a dozen tests and two shot scripts, and renaming what those hold onto is a bigger change
     than sharing a rule. */
/* `container-type:inline-size` so `.joincodebig`'s clamp() can size against the POPUP's width rather
   than the viewport's -- the code has to fit this box, not the screen. */
/* 🔴 `overflow:visible`, THE SAME AS CARD INFO [her ruling 2026-08-29]: *"just make it like it is on
   edit card where it can overflow on top of the popup. The problem is that when it drops down, it's
   hidden inside by the bottom of the popup."*
   `dialog.cidlg` has carried this since the member picker was built, for exactly this reason -- a
   list anchored to a control near the bottom of a dialog has to be allowed OUT of it. These popups
   did not, so the list was clipped at the popup's edge and had to be scrolled to.
   ⚠ `container-type:inline-size` DOES NOT FIGHT THIS. It applies layout, inline-size and style
     containment -- not PAINT containment -- so overflow still escapes. Checked, because a container
     that clipped would have made the join code's clamp() and this rule mutually exclusive. */
/* 🔴 440, AND IT ALWAYS HAS BEEN [her ruling 2026-09-07, chat 12, CHAT-QUEUE §13 item 17].
   This declaration read `width:min(520px, 94vw)` from the day the family was named until today, and
   not one dialog in the app has ever rendered at 520: the later bare `dialog { max-width:440px }` at
   :3232 clamps every one of them. DIFFERENT PROPERTIES, so specificity never entered into it -- the
   smaller max-width simply wins over the width.
   MEASURED, not read off the file. Chat 11 found it on the live dialogs on 2026-09-06 and chat 12
   re-confirmed it on 2026-09-07 across all twelve: tsleavedlg, tsslackdlg, tscodedlg, teamiddlg,
   ulvdlg, addsprintdlg, assignmodal, donedlg, donecheer, estmodal, checkpoint and #addmodal each
   report `width=440 maxWidth=440px` in Chromium.
   ⚠ SHE WAS OFFERED THE OTHER DIRECTION AND REFUSED IT. Adding `dialog.bdlg { max-width:min(520px,
     94vw) }` after the clamp would have made the declared width win -- and widened twelve shipping
     dialogs by 80px, plus the ~35 chat 12 was about to convert. Her answer was to fix the
     declaration instead, so the rule says what ships. DO NOT "restore" the 520: it never rendered,
     and putting it back is a silent 80px design change to every popup in the app. */
dialog#addmodal, dialog.bdlg { width:min(440px, 94vw); padding:0; container-type:inline-size; }
/* 🔴 THE FAMILY SCROLLS BY DEFAULT NOW, AND `overflow:visible` IS THE EXCEPTION [2026-09-07,
   chat 12, caught by tests/ui/t_dialog_reach.py on the full gate].
   `overflow:visible` used to be on the shared declaration above, for her 2026-08-29 ruling that
   an anchored list near the bottom of a popup has to be allowed OUT of it -- *"when it drops
   down, it's hidden inside by the bottom of the popup."* That was safe while the family was
   four board dialogs whose heights were known. Chat 12 put ~50 more dialogs into it, and on a
   1100x620 window FOUR of them immediately stranded their own content:
       delcls-600      scrollH=641 clientH=525   116px unreachable, the DELETE button in it
       delallteams-600 scrollH=654 clientH=525   129px
       blkpickdlg      scrollH=601 clientH=525    76px
       addonsdlg-600   scrollH=541 clientH=525    16px
   That is the same defect Add Sprint hit on 2026-09-04 and Assign & Move on 2026-08-30:
   content present, content unreachable. Both were fixed one dialog at a time, and the note over
   `dialog#addsprintdlg` says why that was right THEN -- *"the family's other members do not
   overflow at any viewport the suite measures."* That sentence stopped being true the moment
   the family grew, and a per-dialog allowlist for a SAFETY property is the wrong way round: a
   dialog nobody has listed yet should be reachable, not clipped.
   ⚠ WHO ACTUALLY NEEDED `visible`, MEASURED RATHER THAN ASSUMED. Every `.bdlg` in the app was
     opened on a short window and asked for absolutely-positioned descendants. Exactly ONE holds
     the thing the ruling is about: #addmodal, whose `.cipick` member picker is `position:absolute`
     -- so it keeps `overflow:visible` below, by the same id selector it already had.
     #assignmodal holds the other `.cipick` and does NOT need it: asgPickOpen() already takes
     that list out of flow with `position:fixed`, which no ancestor's overflow can cut.
     The five dialogs holding a `.qtiptext` tooltip (addcodlg, addonsdlg, addsprintdlg, deldlg,
     gendlg) are fine too, and that is not a guess: #addsprintdlg has shipped with
     `overflow-y:auto` AND a qtip since chat 8b, whose own tests measure the tooltip's flip.
   ⚠ DO NOT PUT `overflow:visible` BACK ON THE SHARED RULE. If one dialog needs a list to escape,
     give that list `position:fixed` the way Assign & Move does, or scope the visible to that
     dialog's own id. */
dialog.bdlg { overflow-y:auto; }
dialog#addmodal { overflow:visible; }
/* 🔴 ADD SPRINT TAKES ITS SCROLL BACK [caught by tests/ui/t_dialog_reach.py, 2026-09-04]. Measured on
   a 1100x620 teacher window: `#addsprintdlg scrollH=580 clientH=525 overflow-y=visible`. 57ddc6c gave
   Auto-Generate an optional date window -- two more fields, a note above them and a hint below -- and
   that grew the dialog past what a short window's clamp allows. `overflow:visible` above then meant
   those 55px, the ADD button among them, could not be scrolled to at all: content present, content
   unreachable. Exactly the defect #assignmodal hit on 2026-08-30 (see its own note ~150 lines down),
   which is why the fix is the same one rather than a second idea.
   ⚠ NOTHING IN THIS DIALOG NEEDS TO ESCAPE IT, which is what makes the plain re-enable correct here
     rather than the fixed-position dance Assign & Move needed. Her `overflow:visible` ruling was
     about a `.cipick` list anchored to a control near the bottom of a popup -- *"when it drops down,
     it's hidden inside by the bottom of the popup"*. Add Sprint has radios, a number field and date
     inputs; a native date picker is painted by the browser outside the page's box model and no
     overflow rule can clip it. Checked before writing this, because re-clipping a dropdown to fix a
     scroll would just move the same bug.
   ⚠ SCOPED TO THIS ONE DIALOG, NOT TO `.bdlg`. The family's other members do not overflow at any
     viewport the suite measures, and one of them is #addmodal, which genuinely does hold a `.cipick`. */
/* ⚠ SUBSUMED BY THE FAMILY DEFAULT since 2026-09-07 -- `dialog.bdlg { overflow-y:auto }`
   above now says this for every member. Kept because the measurement and the reasoning
   below it are still true about THIS dialog, and because it is the case that taught the
   app the difference between a dialog that needs to scroll and a dropdown that must not
   be clipped by whatever scrolls. */
dialog#addsprintdlg { overflow-y:auto; }
#addmodal .add-top { border-radius:var(--r-lg) var(--r-lg) 0 0; }
/* 🔴 `.addwhere` IS A LABEL, NOT A BUTTON [her ruling 2026-08-29]: *"unlike the board the little box
   in the upper left of those is NOT clickable and does not have actions so it should not have a hover
   color or a little arrow on it."*
   `.colchip` was written for Card Info's move control, which DOES open a panel -- so it carries a
   caret and lights up on hover. Every dialog that has since borrowed the strip uses the chip to say
   where you are, which is a caption. Same box, no promises.

   🔴 `.colchip.addwhere`, NOT `.addwhere` -- AND THAT ONE EXTRA CLASS IS THE WHOLE FIX [2026-09-04].
   The three rules below were written on 2026-08-29 and did NOTHING AT ALL for six days. `.colchip`'s
   own rules sit at :3331-3336, ~940 lines LATER in this same file, at EXACTLY EQUAL specificity:
       .addwhere::after (0,1,1)  vs  .colchip::after (0,1,1)   -> the caret
       .addwhere        (0,1,0)  vs  .colchip        (0,1,0)   -> cursor:pointer
       .addwhere:hover  (0,2,0)  vs  .colchip:hover  (0,2,0)   -> the hover colour
   Equal specificity means SOURCE ORDER decides, and the later block won all three. Measured in
   Chromium before this fix: every one of the nine decorative chips still computed `content: "⌄"`
   and `cursor: pointer`, which is precisely the affordance her ruling exists to remove.
   ⚠ RAISED, NOT MOVED. Reordering the two blocks fixes it today and breaks again the first time
     somebody adds a `.colchip` rule below this one -- which is exactly how it broke the first time.
     All nine markup sites are `class="colchip addwhere"`, so the compound selector matches every one
     of them and cannot lose to a single-class rule wherever either block ends up.
   ⚠ THE ONE BARE `.colchip` IS DELIBERATE AND KEEPS EVERYTHING: board.php:2230's Card Info move
     control really does open `.movepanel`, so it keeps the caret, the pointer and the hover.
     tests/ui/t_dialog_chrome.py §2 pins that as a CONTROL -- without it, "delete the caret from
     .colchip" would pass every other assertion in that file while gutting the one control that has
     earned the arrow.
   ⚠ WHY THIS NEEDED A BROWSER TO FIND. The rules were present and read correctly; a grep for
     `.addwhere::after` returned 1 the entire time the arrow was on screen. Only the cascade knows
     which declaration wins, so the coverage is getComputedStyle(), never a grep over this file.

   🔴 AND IT IS NOT A BOX AT ALL ANY MORE [her ruling 2026-09-04]: *"the title should probably just
   be there but not inside a 'box' at all. Inside the darker header yes, but not in a separate box."*
   Confirmed with her the same day against the two readings -- strip the caption's box, rather than
   delete the caption and lift the <h3> into the strip. The strip goes on saying WHERE you are; it
   just stops looking like something you could press. Taking the caret off a box that still looked
   like a button was only ever half of it: a bordered, filled, rounded chip reads as a control
   whatever its cursor says.
   ⚠ THE 8px LEFT PADDING IS ARITHMETIC, NOT TASTE. `.ci-top` is `padding:11px 14px`, and every
     `.bdlg > h3` / `> form` / `> .bdlgsub` under it is `padding-left:22px`. 14 + 8 lands the caption
     on 22px, so the strip's text and the title beneath it share one left edge. Without it the
     caption sits 8px to the LEFT of the title it introduces, which is more obviously wrong once
     there is no box drawing a boundary around it.
   ⚠ THE VERTICAL RHYTHM IS DELIBERATELY UNCHANGED: `6px + 1px border` became `7px + no border`, so
     the strip is the same height and the ✕ does not move.
   ⚠ THE `:hover` OVERRIDE IS GONE RATHER THAN KEPT. It existed to hold the border at `--line-hi`
     against `.colchip:hover`'s `--brand`; with `border:0` there is no border to colour, so it could
     only ever be a rule that did nothing. Deleted per this repo's delete-what's-superseded rule --
     a live-looking rule that cannot fire is the thing the next reader wastes a session on. */
.colchip.addwhere { cursor:default; background:none; border:0; padding:7px 0 7px 8px; }
.colchip.addwhere::after { content:none; }
#addmodal > h3, #addmodal > form, #addmodal > .addsub,
.bdlg > h3, .bdlg > form, .bdlg > .bdlgsub, .bdlg > .dialog-actions,
.bdlg .doneband, .bdlg .cheerbody, .bdlg > .bdlgbody { padding-left:22px; padding-right:22px; }
/* 🔴 `.bdlgbody` IS WHAT THE `.dlg-cn` SWEEP NEEDED, AND IT IS NOT TIDINESS [2026-09-07, chat 12].
   `.bdlg` sets `padding:0` on the dialog and pays the 22px back only to the direct children named
   above. Every dialog in the ORIGINAL four-dialog family happened to keep its whole body inside one
   `<form>`, so nothing else ever needed it. Half the dialogs this sweep converted do not: their
   direct children are loose `<p>`, `<ul>`, `<label>`, `<select>`, `<input>` and `<div>` elements.
   MEASURED before this rule existed, in Chromium, by swapping the class on the live dialogs: a
   loose child's text went from 29px to 1px from the dialog's own left edge -- flush against the
   border, not merely tight. Sixteen dialogs did that, delcls / delteam / delallteams / caskdlg /
   senddlg / replydlg / absdlg / blkpickdlg among them.
   So the loose body goes in one wrapper and the wrapper takes the family's inset. Same shape as
   `.rep-body` in app/reports_hub.php, which is the same problem solved for one dialog before there
   were sixteen of them.
   ⚠ NO VERTICAL PADDING HERE, deliberately: `.bdlg > .dialog-actions` already carries the 20px
     bottom, and a dialog whose actions sit inside the wrapper gets that from the wrapper's own last
     child. Adding it here double-spaced the eight dialogs that keep their buttons outside. */
.bdlg > .bdlgbody { padding-bottom:4px; }
.bdlg > .bdlgbody > :first-child { margin-top:0; }
/* 🔴 THE TITLE AT CARD INFO'S SCALE, not the app's default h3 [2026-08-29]. "Same family as Card
   Info" was the whole brief for this dialog, and the two were a size apart. */
#addmodal > h3, .bdlg > h3, .bdlg .doneband h3 { margin:20px 0 5px; font-family:var(--disp);
  font-size:26px; font-weight:800; line-height:1.2; }
#addmodal > .addsub, .bdlg .bdlgsub { margin:0 0 4px; font-size:13px; }
#addmodal > form, .bdlg > form { padding-bottom:22px; }

/* 🔴 THE RHYTHM IS THE FIX SHE ASKED FOR. Her word was *"squished"*, and the label-then-control
   stack had no vertical rhythm of its own: it inherited whatever margin each control happened to
   carry, so blocks that mean different things sat the same distance apart as a label from its own
   field. The mock spaces the GROUPS and keeps each label tight to what it labels. */
#addmodal form > label, #addmodal #addextras > label, #addmodal #addextras > div,
.bdlg form > label, .bdlg form > div { margin-top:20px; }
#addmodal form > label:first-of-type { margin-top:14px; }
#addmodal #addtrackwrap > label, #addmodal #addestwrap > label { margin-top:0; }
/* Uppercase, letter-spaced, dim: the same label voice Card Info's rail speaks, so the two dialogs
   sound alike as well as look alike. */
#addmodal label, .bdlg label { display:block; font-family:var(--disp); font-size:10.5px;
  letter-spacing:.10em; text-transform:uppercase; color:var(--dim); margin-bottom:7px; }
/* ⚠ The parenthetical inside a label is a SENTENCE, not a label. Uppercasing "(required for
   Current)" along with its label turned an aside into shouting. */
#addmodal label .dim, .bdlg label .dim { text-transform:none; letter-spacing:0; font-size:11.5px;
  font-family:var(--sans); }
/* 🔴 `.dlgbig` IS THE SAME DISTINCTION ONE LEVEL UP, and the sweep is what made it load-bearing
   [2026-09-07, chat 12]. The rule above gives every `.bdlg` label Card Info's voice, which is right
   for a FIELD NAME ("Class name", "Start date"). `.dlgbig` was invented for the `.dlg-cn` family to
   mark the labels that are not field names but QUESTIONS PUT TO A STUDENT -- *"What do you need?"*,
   *"Type a brief description of your problem. Include any tools you're using (Maya, Blender,
   Substance Painter, Blueprints, etc.)"*.
   MEASURED on the live dialogs before the sweep touched them: joining `.bdlg` took those from 14px
   sentence case to 10.5px uppercase with 1.05px letter-spacing, because `.bdlg label` is (0,1,1) and
   a bare `.dlgbig` is (0,1,0). A two-line question in 10.5px uppercase is not a smaller label, it is
   a different thing being said.
   `.bdlg label.dlgbig` is (0,2,1), so it wins without !important and without reordering anything.
   ⚠ THE SIZE IS RESTORED, NOT INVENTED: 14px/none/normal is exactly what `.dlgbig` computed on
     these six labels before the swap (caskdlg ×3, helpdlg ×3, msgdlg, senddlg). */
.bdlg label.dlgbig { font-family:var(--sans); font-size:14px; letter-spacing:normal;
  text-transform:none; color:var(--text); }
/* 🔴 THE INLINE "Required" [her ruling 2026-08-29]. Beside the label it belongs to, in the app's red,
   and only when it applies -- the same shape a form field's own error takes everywhere else.
   ⚠ NOT uppercase like its label. It is a sentence about the field, not a name for it, and the
     #addmodal label rule would otherwise shout it. */
.reqflag { display:inline-block; margin-left:8px; color:var(--red); font-family:var(--sans);
  font-size:11.5px; font-weight:700; letter-spacing:0; text-transform:none; }
/* 🔴 AND THIS LINE IS NOT OPTIONAL. Any `display` on a class outranks the UA sheet's
   `[hidden] { display:none }`, so without it every marker is permanently visible and the dialog says
   "Required" beside a field that is not. Caught in the first screenshot of the finished control: On
   Deck read "(optional for On Deck)  ⚠ Required" on the same line.
   ⚠ THE THIRD TIME THIS EXACT SHAPE HAS BITTEN THIS FILE (`.cpgrid[hidden]`, `.bubrow`, now this).
     Anything given a `display` and toggled with `hidden` needs its own `[hidden]` rule. */
.reqflag[hidden] { display:none; }
/* The control itself, so the eye can find the field from across the dialog and not only the label. */
.reqbad { border-radius:var(--r-md); outline:1.5px solid var(--red); outline-offset:3px; }
/* The field that BECOMES the card, so it is sized like one rather than like a settings input. */
.addtitlein { font-family:var(--disp); font-size:17px; font-weight:700; }
.addtitlein::placeholder { font-weight:400; color:var(--dimmer); }
/* 🔴 BOTTOM RIGHT, AND BIGGER [her ruling 2026-08-29]: *"the 'Add Card' button should be in the
   bottom right I think and maybe a little bigger?"* `dlgend` is the existing right-align modifier
   (her note 10, for Generate New Teams), reused rather than reinvented, and the button drops
   `btn-sm` so it is the app's full 38px rather than the 29px compact one. */
#addmodal .dialog-actions, .bdlg .dialog-actions { margin-top:26px; }
.bdlg > .dialog-actions { padding-bottom:20px; }

/* The checkpoint's advice, which is the reason that dialog exists rather than a footnote to it. */
.cpnote { border:1px solid var(--amber, var(--brand)); background:color-mix(in srgb, var(--amber, var(--brand)) 10%, transparent);
  border-radius:var(--r-md); padding:12px 14px; margin:16px 0 0; font-size:13.5px; line-height:1.5; }

/* ---------- Completing a card: the question, then the celebration ---------- */
/* 🔴 BRIGHT, NOT A DARK TINT [her ruling 2026-08-29]: *"I don't like the 'dark green' for the
   completion background. That was the only thing from the mock that I didn't love. It would be funner
   if it was like a neon bright green or maybe just use a brand color or something instead."*
   `--brand` is the answer that works in all fifteen themes: it is the app's own accent, it is vivid
   by definition in every one of them, and it arrives with `--brand-ink` -- a text colour already
   chosen and contrast-checked against it for every primary button in the app. A hardcoded neon green
   would have been bright in one theme and wrong in the other fourteen, and would have had to carry
   its own ink colour with no guarantee about contrast.
   ⚠ THE TICK STAYS GREEN-FREE FOR THE SAME REASON: a green disc on a brand-coloured band is two
     accent colours arguing. It inverts the band instead, which reads as deliberate everywhere. */
.donedlg .doneband { background:var(--brand); color:var(--brand-ink);
  border-bottom:1px solid var(--brand); padding-bottom:18px; }
.donedlg .doneband h3 { color:var(--brand-ink); }
/* !important because `.dim` is a utility used everywhere and would otherwise grey this out against
   its own bright ground. color-mix keeps it a shade quieter than the heading without going pale. */
.donedlg .doneband .bdlgsub { color:color-mix(in srgb, var(--brand-ink) 78%, transparent) !important; }
.donedlg .doneband-tall { padding-top:6px; padding-bottom:24px; text-align:center; }
.donedlg .doneband-tall h3 { margin-top:10px; }
.donedlg .doneband-tall .bdlgsub { margin-bottom:0; }
/* 🔴 THE TASK IN A BORDERED BOX [her ruling 2026-08-29]: *"like a nice bordered box or something
   that shows the task name in it. Something just clean and nice."* The question is the heading; this
   is the thing being asked about, and giving it its own frame is what stops the two running together
   as one grey paragraph. Left-aligned and full width so a long title wraps like a card title rather
   than centring into a ragged block. */
.donebox { margin:14px 22px 0; padding:14px 16px; border:1px solid var(--line-hi);
  border-radius:var(--r-md); background:var(--bg-deep);
  font-family:var(--disp); font-weight:800; font-size:16px; line-height:1.35;
  word-break:break-word; }

.donetick { width:64px; height:64px; margin:18px auto 0; border-radius:50%;
  background:var(--brand-ink); color:var(--brand);
  font-size:30px; font-weight:800; display:grid; place-items:center; }
.cheerbody { padding-top:18px; padding-bottom:20px; }
.cheerwho { display:flex; gap:9px; align-items:center; flex-wrap:wrap; }
.cheerface .avatar { width:40px; height:40px; }

/* 🔴 THE TRACK PILLS. `--pc` is the track's own colour, handed over in the TRACKS payload from
   track_color_css() -- the same helper the board and the reports use, so there is one palette.
   ⚠ THE <select> IS STILL THERE, HIDDEN. It is what submits, and these write into it. Do not delete
     it "because nothing shows it": every validation on add_card reads that field. */
.addpills { display:flex; gap:8px; flex-wrap:wrap; margin-bottom:4px; }
.addpill { font-size:12px; font-weight:700; border-radius:9px; padding:7px 14px; min-width:104px;
  text-align:center; border:1.5px solid var(--line-hi); background:none; color:var(--dim);
  cursor:pointer; font-family:var(--sans); }
.addpill:hover { color:var(--text); }
.addpill.on { color:var(--pc, var(--brand)); border-color:var(--pc, var(--brand));
  background:color-mix(in srgb, var(--pc, var(--brand)) 16%, transparent); }
.addpill:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }
/* The pills are the control; these are what submit. Same arrangement in both dialogs, so the two
   teach one thing. */
#addtrackwrap select, #addassignee, #cptrackwrap select { display:none; }

/* 🔴 THE "WHO TAKES IT" TRIGGER [her ruling 2026-08-29]. Shows the chosen face AND name, so the
   closed control answers the question opening it would. `.addwho` is the positioned anchor the
   `.cipick` list hangs from -- the same list Card Info opens, reused rather than re-drawn. */
.addwho { position:relative; }
.addwho-btn { display:inline-flex; align-items:center; gap:9px; cursor:pointer;
  background:var(--bg-deep); border:1px solid var(--line-hi); border-radius:var(--r-md);
  padding:7px 12px; min-height:44px; min-width:200px; color:var(--text);
  font-family:var(--sans); font-size:13.5px; text-align:left; }
.addwho-btn:hover { border-color:var(--brand); }
.addwho-btn:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }
.addwho-btn::after { content:'⌄'; color:var(--dim); margin-left:auto; font-size:12px; line-height:1; }
.addwho-face { display:inline-flex; }
.addwho-face .avatar { width:28px; height:28px; }
.addwho .cipick { top:calc(100% + 6px); left:0; right:auto; min-width:240px; }
/* Assign & Move borrows this same trigger and is the only place it can hold TWO people, so the wrap
   lives on that button rather than on .addwho-btn, where it would only ever affect one name. */
#asgwhobtn { width:100%; flex-wrap:wrap; row-gap:5px; }
/* 🔴 THE ASSIGN DIALOG SCROLLS, AND ITS PICKER STILL ESCAPES [caught by t_dialog_reach, 2026-08-30].
   Giving Assign & Move the wide trigger made it ~19px taller than a short window's dialog box, and
   `overflow:visible` meant those 19px, the Assign & move button among them, could not be scrolled to.
   That is the same class of defect as the board's `overflow-x:hidden` clip: content present, content
   unreachable.
   ⚠ THE TWO REQUIREMENTS LOOK OPPOSED AND ARE NOT. The dialog needs to scroll; the dropdown needs to
     not be clipped by whatever scrolls. Taking the list OUT of the flow with `position:fixed` settles
     both -- a fixed box is positioned against the viewport, so no ancestor's overflow can cut it, and
     the dialog is then free to scroll like any other. asgPickOpen() sets the coordinates, because only
     it knows where the trigger currently is.
   ⚠ ADD CARD IS DELIBERATELY UNTOUCHED. #addmodal does not overflow at any viewport the suite
     measures, so it keeps the simpler absolute positioning it was built with. */
/* ⚠ SUBSUMED BY THE FAMILY DEFAULT since 2026-09-07. Kept for the note above it, which is
   the ONLY record of why #asgpick is `position:fixed` while #addpick is not. */
dialog#assignmodal { overflow-y:auto; }
#assignmodal .cipick { position:fixed; margin:0; }
.addwho-one { display:inline-flex; align-items:center; gap:7px; min-width:0; }
.addwho-sub { color:var(--dim); font-size:11.5px; }
/* Opens upward instead, when addWhoToggle() has measured that downward would run off the bottom. */
.addwho .cipick.flipup { top:auto; bottom:calc(100% + 6px); }

/* ⚠ `.addbubs` / `.addbub` ARE RETIRED [2026-08-29]: Add card picks people from the same named list
   Card Info uses, because a row of bare faces is only legible to someone who already knows them. */

/* THE AVATAR ROW: the same language Card Info uses for "who", so the board says it one way. */
.addbubs { display:flex; gap:9px; align-items:center; flex-wrap:wrap; margin-bottom:4px; }
.addbub { width:38px; height:38px; border-radius:50%; padding:0; background:none; cursor:pointer;
  border:2px solid transparent; opacity:.34; filter:grayscale(1); display:grid; place-items:center; }
.addbub .avatar { width:34px; height:34px; }
.addbub:hover { opacity:.7; }
.addbub.on { opacity:1; filter:none; border-color:var(--text); }
.addbub:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }
/* .addbub-none is RETIRED [her ruling 2026-08-29]. On On Deck it offered the default as a choice;
   on Current it offered an option whose only outcome is a refusal. Clicking the lit face is the way
   off now, which the bubbles already supported. */
@media (pointer:coarse) { .addpill { min-height:44px; } .addbub { width:44px; height:44px; } }


.cibub { position:relative; display:inline-flex; align-items:center; }
.cibub .avatar { width:34px; height:34px; }
/* The ✕ rides the bubble's corner. It is a real button at 20px with a 24px hit area via padding, so
   it clears the target floor without a 24px disc covering the face it belongs to. */
.cibub .assgx {
  position:absolute; top:-4px; right:-6px; width:20px; height:20px; line-height:18px;
  border-radius:50%; border:1px solid var(--line-hi); background:var(--panel);
  color:var(--dim); font-size:11px; padding:0; cursor:pointer; text-align:center;
}
.cibub .assgx:hover, .cibub .assgx:focus-visible { color:var(--red); border-color:var(--red); }
/* ⚠ `.pending-rm` IS RETIRED [2026-08-29]. A member ✕ used to mark and wait for SAVE; with no SAVE
   button the click IS the removal, so there is no pending state left to draw. */
/* The add control: a dashed circle that reads as "there is room for one more". */
.cibubadd {
  width:34px; height:34px; border-radius:50%; border:1.5px dashed var(--line-hi);
  background:transparent; color:var(--dim); font-size:18px; line-height:1; cursor:pointer;
  display:inline-flex; align-items:center; justify-content:center; padding:0;
}
.cibubadd:hover, .cibubadd:focus-visible { border-color:var(--brand); color:var(--brand); }
/* A name under each bubble would double the row's height for something the avatar already says; the
   name lives in the button's accessible label instead. */
.cibub .cibubname { position:absolute; width:1px; height:1px; overflow:hidden; clip-path:inset(50%); }

/* ---------- Estimate stepper [her report, raised twice, 2026-08-27] ----------
   A native number input's arrows are UA-drawn at roughly 10px each and cannot be resized from CSS,
   which is why they were unusable and why no amount of styling the input would have helped. Hide them
   and give the field real buttons at 34x34 -- comfortably over the 24x24 target floor. */
.estep { display:inline-flex; align-items:stretch; gap:0; }
.estep .estepnum {
  max-width:74px; text-align:center; border-radius:0; margin:0; position:relative; z-index:1;
  -moz-appearance:textfield; appearance:textfield;      /* kill the native spinner in Firefox */
}
/* 🔴 THE ARROWS ARE GONE BECAUSE THE FIELD IS NO LONGER A NUMBER INPUT [her ruling 2026-08-29,
   reported three times: *"the little up/down arrows are STILL showing on the estimate even on the
   popup you did rework"*].
   TWO CSS FIXES FAILED BEFORE THIS ONE, and both looked correct. `-webkit-appearance:none` on the
   spin-button pseudo had been in this file for weeks; adding `display:none; width:0` to it changed
   nothing either. Measured in Chromium with both rules matching the element: the computed style of
   `::-webkit-inner-spin-button` still came back `display:block; width:70px; appearance:textfield`.
   Chromium does not honour author `display` on that pseudo-element, and the input's own
   `appearance:textfield` re-asserts the widget inside it.
   SO THE FIELD IS `type="text" inputmode="numeric"`. A text input cannot have a spin button, which
   ends the argument rather than continuing it, and `inputmode` still gives a phone the number pad.
   ⚠ NOTHING WAS LOST WITH `type=number`. The − and + buttons already did the stepping (estStep()
     reads `min`/`max`, which reflect through the IDL for a text input exactly as before), every one
     of the three dialogs validates the range in JS before it posts, and add_card / move_card cast and
     re-check the value server-side. Native `min` validation was never what refused a bad estimate.
   ⚠ THE RULES BELOW STAY. They cost nothing, they are correct for any OTHER number input this app
     grows, and removing them would invite someone to "restore" type=number and reopen this. */
.estep .estepnum::-webkit-outer-spin-button,
.estep .estepnum::-webkit-inner-spin-button {
  -webkit-appearance:none; appearance:none; display:none; margin:0; width:0;
}
.estepbtn {
  width:34px; min-height:34px; flex:none; cursor:pointer;
  background:var(--panel-hi); color:var(--text);
  border:1px solid var(--line-hi); font-size:17px; line-height:1; padding:0;
}
.estepbtn:first-child { border-radius:var(--r-sm) 0 0 var(--r-sm); border-right:0; }
.estepbtn:last-child  { border-radius:0 var(--r-sm) var(--r-sm) 0; border-left:0; }
.estepbtn:hover, .estepbtn:focus-visible { color:var(--brand); border-color:var(--brand); }

/* ---------- Slips (print stays black on white) ---------- */
.slip {
  border:2px dashed var(--line-hi); border-radius:var(--r-md);
  padding:16px 20px; margin:12px 0; max-width:520px;
  background:var(--panel);
}
@media print {
  body { background:#fff !important; color:#000 !important; font-size:12pt; }
  .noprint, .sidebar { display:none !important; }
  .app { display:block; }
  .main { padding:0; max-width:none; }
  .slip { background:#fff; color:#000; border-color:#000; page-break-inside:avoid; }
  .slip .bigcode { color:#000; background:#fff; border-color:#000; }
}

/* ---------- Responsive: small laptops & below ---------- */
@media (max-width:900px) {
  .app { grid-template-columns:1fr; }
  .sidebar { position:static; height:auto; flex-direction:row; align-items:center; flex-wrap:wrap; gap:6px; padding:10px 14px; border-right:0; border-bottom:1px solid var(--line); }
  .sidebar .logo { padding:0 10px 0 0; }
  .sidebar .nav { display:flex; gap:2px; }
  .sidebar .nav a { padding:7px 10px; font-size:13px; }
  .sidebar .spacer { display:none; }
  .sidebar .devmode { margin:0; }
  /* `.me .who .role { display:none }` stood here — dead since `.me` was deleted 2026-08-14. NOT
     ported to `.idblock .who .role`: whether the identity block's role line should still hide at
     this width is an undesigned mobile question (README: mobile work hasn't started), not a
     mechanical rename — `.role`'s own content changed meaning in the 2026-08-13 redesign (it now
     shows the class/team name, not a role word), so the old intent may not even carry over. */
  .main { padding:18px 16px 60px; }
  .pagehead h1 { font-size:26px; }
}

/* ============ Step 5 additions ============ */

/* Stale card tints (ambient board signal mirroring escalation colors)
   The left bar is identity (assignee color, set inline in board.php) — never status. Ruled 2026-08-08. */
.card.card-warn { background:color-mix(in srgb, var(--amber) 7%, var(--bg-deep)); }
.card.card-stale { border-color:color-mix(in srgb, var(--red) 45%, var(--line)); background:color-mix(in srgb, var(--red) 9%, var(--bg-deep)); }

/* Compact idle line at the top of the board */
.idleline {
  display:flex; gap:10px; align-items:center;
  background:var(--amber-soft); border:1px solid color-mix(in srgb, var(--amber) 35%, transparent);
  border-radius:var(--r-md); padding:8px 14px; margin:10px 0 0; font-size:13px;
}
.idleline b { color:var(--amber); }

/* Team chip on unified-board cards */
.teamchip {
  cursor:pointer; font-family:var(--mono); font-size:9px; letter-spacing:1px; font-weight:600;
  color:var(--dim); background:var(--panel-hi); border:1px solid var(--line);
  border-radius:4px; padding:2px 6px;
}
/* Measured 62x18, i.e. under WCAG 2.2 SC 2.5.8's 24x24 — but it MEETS that criterion via the spacing
   exception, not by size: the nearest other target in the row (SPLIT, or the ＋ subtask button) sits
   ~100px away and `.card-edit` is diagonally clear, so no two 24px circles centred on these targets
   intersect. Deliberately left at 18px rather than grown: a 24px chip would be visibly taller than the
   20.72px tags beside it in the same row and would raise the meta row on every card on the teacher's
   board. Flagged for her eyes rather than changed. */
.teamchip:hover { color:var(--brand); border-color:var(--brand); }

/* Filter bar selects */
.boardtool select {
  width:auto; min-width:150px; padding:5px 30px 5px 12px; font-size:12.5px; font-family:var(--sans);
  border-radius:20px; background-color:var(--bg-deep);
  border:1.5px solid var(--line-hi); color:var(--text); font-weight:700;
}
.boardtool select:hover { border-color:rgba(255,255,255,.55); }
.boardtool select:focus { border-color:var(--brand); box-shadow:0 0 0 2px rgba(255,255,255,.25); }

/* Add button with SVG plus */
.addbtn { display:inline-flex; align-items:center; justify-content:center; }
.addbtn svg { display:block; }
.addcard { display:flex; align-items:center; gap:7px; }
.addcard svg { flex-shrink:0; }

/* Sortable table headers */
th.sortable { cursor:pointer; user-select:none; white-space:nowrap; }
th.sortable:hover { color:var(--text); }
th.sortable .arrow { display:inline-block; width:12px; opacity:.9; color:var(--brand); }

/* Table search box */
.tsearch { max-width:280px; margin:2px 0 6px; padding:8px 14px; border-radius:20px; }

/* Teams/Companies filter bar (2026-07-27): search with an icon inside so the field is not a bare
   dark box, then the Class/Block scope dropdowns. Both tables use the same bar. */
.filterbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;margin:12px 0 14px}
.searchwrap{position:relative;flex:1;min-width:220px;max-width:380px}
.searchwrap .searchico{position:absolute;left:11px;top:50%;transform:translateY(-50%);
  width:15px;height:15px;color:var(--dimmer);pointer-events:none;display:block}
.searchwrap .searchico svg{width:15px;height:15px;display:block}
.filterbar .tsearch{margin:0 !important;width:100%;padding-left:34px !important}
.filterbar .styledsel{min-width:150px}

/* Teams table scroll cap (2026-07-27): a sanity limit only, ~20 rows before it scrolls, so the
   page cannot run away — not meant to constrain normal use. A border-bottom on a sticky <th>
   does not paint reliably and a box-shadow shifts the row by a pixel, so the rule is painted as
   a background gradient instead. */
.tcap{max-height:820px;overflow-y:auto;overflow-x:hidden;padding-right:6px}
.tcap table th{position:sticky;top:0;z-index:3;border-bottom:1px solid transparent;
  background:linear-gradient(to top, var(--line) 1px, var(--panel) 1px)}

/* Grading Periods scroll cap (Schedule tab, classes.php): same sticky-header-can't-take-
   a-border-bottom-inside-a-scroll-container technique as .tcap above, just a shorter cap
   (4 rows) — reused rather than redefined under a new gradient. */
.gpcap{max-height:232px;overflow-y:auto;overflow-x:hidden;padding-right:6px}
.gpcap table th{position:sticky;top:0;z-index:3;border-bottom:1px solid transparent;
  background:linear-gradient(to top, var(--line) 1px, var(--panel) 1px)}
.gpcap table th.icocell, .gpcap table td.icocell{width:34px}
/* Past grading periods: hidden by default, revealed by the "Show past grading periods"
   checkbox toggling .gpshowpast onto #gptable. Greyed while shown since they're read-only
   (no pencil renders in their icon cell at all). */
.gpcap table tr.gppast{display:none}
.gpcap table.gpshowpast tr.gppast{display:table-row}
.gpcap table tr.gppast td{color:var(--dimmer)}
/* Checked-off deliverables on the student dashboard: same hidden-by-default, greyed-when-shown
   idiom as .gpshowpast above, just for .delrow instead of table rows. [RULED 2026-07-28] */
#delcap .delrow.deldone{display:none}
#delcap.delshowdone .delrow.deldone{display:flex}
#delcap .delrow.deldone{color:var(--dimmer)}

/* Sprint Schedule (Schedule tab, classes.php): one <details> accordion per grading period.
   Past sprints hide per-accordion via the same show/hide-by-class technique as .gpcap's own past-
   period checkbox above, just scoped to each accordion's own .spbody so one period's "Show past
   sprints" checkbox never affects another period's table. The current sprint's green row tint
   reuses the exact color-mix(in srgb,var(--green) 8%,transparent) value already established for
   this on HTMLSnapshots/CLASSES-desktop-teacher.html's own `.spbody tr.now td` rule — not a new green. */
.spacc{border:1px solid var(--line);border-radius:var(--r-md);margin-bottom:10px;overflow:hidden}
.spacc:last-child{margin-bottom:0}
/* D8 [Snapshot Review Group D — DESIGN, not spacing, and the one piece of this group that should
   come back with a LOOK before it ships]. Her note: "There's so little color on Schedule and Class
   Overview... the accordions all just bleed together... I almost feel like they should alternate
   color from the theme with full borders." A FIRST PASS, not a final answer: alternates the border
   between the only two colours README §5 leaves available (amber/orange/red are reserved for
   warning semantics) — --brand on odd periods, --accent-2 on even, both at 2px so "full" reads as
   weight, not just hue. Background/summary intentionally left untouched here; a tint on top of an
   already-decided border felt like answering a question she did not ask. */
.spacc:nth-of-type(odd){border-color:var(--brand);border-width:2px}
.spacc:nth-of-type(even){border-color:var(--accent-2);border-width:2px}
.spacc>summary{display:flex;align-items:center;gap:12px;padding:11px 14px;cursor:pointer;
  background:var(--panel-hi);list-style:none;font-size:14px}
.spacc>summary::-webkit-details-marker{display:none}
.spacc>summary::before{content:'\25B8';color:var(--dim);transition:transform .15s;flex:none}
.spacc[open]>summary::before{transform:rotate(90deg)}
.spacc .spnm{font-weight:700}
.spacc .spdates{font-family:var(--mono);font-size:12.5px;color:var(--dim)}
.spbody{padding:12px 14px 14px}
.spbody table.sptable tr.sppast{display:none}
.spbody.showpast table.sptable tr.sppast{display:table-row}
.spbody table.sptable tr.sppast td{color:var(--dimmer)}
/* D7 [Snapshot Review Group D]: the running sprint's row tint (`tr.spcur td{background:...}`) is
   gone, replaced by a CURRENT pill on the row itself (classes.php) matching the grading period's
   own badge. `.spcur` stays as the row's class — it is still "which row is running", just no longer
   a paint instruction. */
/* Shared A/B date component (README-CLASSES-RULINGS §21a, via CLASSES-RECON-NOTES): a horizontal
   pair of badge+range, reused by Sprint Schedule, Deliverables due-A/B and Grading Periods'
   A/B review dates. Horizontal, NOT stacked — §17a stacked it, §21a reversed that and is final.
   A uses --accent-2 and B uses --accent-3, the meaning proposed in §10f. */
.abdates{display:flex;gap:26px;flex-wrap:wrap;align-items:center}
/* 🔴 §24c's SHARED FLOOR, BUILT AT LAST — WITH THE NUMBER RE-DERIVED PER SURFACE [2026-08-18].
   §24c diagnosed this exactly right ("`.abpair` had no minimum width, so each pair sized to its own
   text… B's starting position drifted row to row") and it was never applied: the live rule had no
   `min-width` at all. Measured on real deliverable rows before the fix: the four due-date pairs came
   out 176/176/176/168px wide, so the A badge on the "Oct 9" row started **8px** right of every other
   row's — visible drift down a column, the exact symptom.
   §24c's own figure is **160px**, and that is kept for the surface it was derived on: Sprint Schedule,
   where a pair holds a whole RANGE ("Sep 1 to Sep 12", measured 107px). It is deliberately NOT applied
   to the other two, because in the live app they hold a SINGLE date, not a range — a 160px floor there
   would reserve 85px of empty space per pair and blow the Deliverables due column out to ~350px. Those
   get 80px, the same derivation applied to their own longest realistic content (75px measured, plus
   room), which removes the drift without inventing space. A floor, not a fixed width: a longer value
   still grows. */
.abpair{display:inline-flex;align-items:center;gap:7px;white-space:nowrap;min-width:80px}
.sptable .abpair{min-width:160px}
/* The Class Days column (chat 8a) is the third surface to need its own floor, for the reason the
   note above already gives: the 160px above is derived from a whole RANGE ("Sep 1 to Sep 12", 107px
   measured) and this pair holds a COUNT. Two of them at 160px would reserve ~320px for "12" and "9"
   and push the MVP and action columns off a 1280 screen. 48px = the 20px badge + the 7px gap + room
   for a three-digit count, which no grading period can reach. Her standing badge rule is what the
   floor is FOR: both pairs in the column are one width, so B's number starts at the same x on every
   row instead of sliding with A's digit count. */
.sptable .abpair.cdays{min-width:48px}
/* 🔴 THE CLASS DAYS CELL GETS ITS OWN WRAPPER CLASS, NOT `.abdates`, and that is a test contract
   rather than a style preference. `.abdates` is how tests/ui/t_classes_schedule.py FINDS the Dates
   cell's paired ranges — `tr:has(.abdates)`, then the badges and boxes inside it — so a second
   `.abdates` in the same row would have made every one of those locators ambiguous and several of
   them would have measured the wrong element. The PAIR inside keeps `.abpair`/`.abbadge`, because
   that idiom is what the column is meant to mirror; only the container is renamed. Narrower gap
   than `.abdates`' 26px: these hold a count, not a written range. */
.sptable .cddates{display:inline-flex;gap:14px;align-items:center}
/* 🔴 THE STUDENT SCHEDULE'S SPRINT TABLES SCROLL SIDEWAYS ON A NARROW SCREEN [chat 8a, 2026-09-06].
   Adding the Class Days column took the table past a 320px viewport — measured by
   tests/ui/t_reflow_zoom.py the run it landed: `TABLE.sptable right=373` with no scrollable
   ancestor, which is a real 1.4.10 failure. Same answer, and the same reasoning, as `.cppast` a few
   thousand lines down: the columns are the reason to be on this page, so dropping one to make it fit
   would leave a list the rest of the page already gives. 1.4.10 names data tables in its own
   exception and the probe honours a scrollable ancestor.
   ⚠ `.spscroll`, a wrapper on the STUDENT page only — never `.spbody`, which the teacher's Schedule
   tab shares: its sprint rows carry `.qtip` tooltips, and an overflow container would clip them. */
@media (max-width:640px) { .spscroll { overflow-x:auto; } }
/* The Grading Periods A/B Review column shows a written-out date (§35e), not a range and not the
   compact `M j` the Deliverables row uses, so it needs its own floor. Measured with two periods on a
   split class: at the shared 80px the B pair's left edge sat at 1097.6 on one row and 1105.6 on the
   next — **8px of drift**, §24c's exact symptom. 175px covers the longest written-out date
   ("September 27, 2026" measures 171px) and takes the drift to **0.0 at 1920/1600/1440/1366/1280 and
   1100**, with no overflow of `#gptable`, `.gpcap` or the document at any of the six. */
#gptable .abpair{min-width:175px}
/* 🔴 A AND B STAY ON ONE LINE; THE CHIP DROPS UNDER ITS OWN DATE [her report 2026-08-23: *"Performance
   review A and B dates should be in one line not stacked on top of each other."*].
   This is a regression I caused. `.abdates` is `flex-wrap:wrap`, and Group J's chip added ~126px to
   each pair: measured, the pairs went 160px -> 288px, so one line went from needing 346px to needing
   **602px**. At 1920 the cell is 821px and it still fit, which is why the change looked clean when I
   built it; at 1280 the cell is 498px, so it was 104px short and the wrap put B under A. Her
   screenshot is the narrow case.
   104px cannot be recovered horizontally without breaking something she has already ruled: the chip
   copy is verbatim ("Not an A Day"), the three chips share one min-width by her standing badge rule,
   §35e sets the written-out date format in this column, and §21a fixed the A/B pair as horizontal.
   So the chip moves ONTO ITS OWN LINE WITHIN its pair instead: A and B stay side by side at every
   width, and each chip sits under the date it annotates rather than beside it. Pairs fall back to
   their 175px floor, so one line needs 376px and clears the 498px cell at 1280 with room.
   ⚠ GRID, NOT `flex-wrap`. Wrapping was the obvious fix and it silently does nothing here: `.abpair`
   is `inline-flex`, an inline-level box sizes shrink-to-fit = MAX-CONTENT, and max-content resolves
   with every item on one line — so a `flex-basis:100%` chip just made the pair 19px WIDER (288 ->
   307, measured) and still did not wrap. Wrapping there would need a width cap, and a cap on a column
   holding a written-out date is exactly the kind of magic number §24c's note warns about.
   A two-column grid states the intent directly instead: badge and date auto-place on row 1, and the
   chip is pinned to column 2, which puts it on row 2 under the date with no width constraint
   anywhere. With no chip present the grid is a single row and renders identically to before. */
#gptable .abpair{display:inline-grid;grid-template-columns:auto auto;justify-content:start;
  align-items:center;column-gap:7px;row-gap:3px}
#gptable .abpair .driftwrap{grid-column:2}
.abpair.now{color:var(--green);font-weight:700}
.abbadge{display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;
  border-radius:6px;font-family:var(--sans);font-size:11px;font-weight:800;line-height:1;flex:0 0 20px}
/* 🔴 THE COLOUR-ON-ITS-OWN-TINT PATTERN AGAIN, THIRD INSTANCE [fixed 2026-08-15, Teacher Meetings
   audit]. `--accent-2` on a 16% tint of `--accent-2` failed WCAG AA in **9 of 16 themes**:
   pumpkinspice 2.37, aurora 2.39, arcade 2.77, frostbyte 3.12, bubblegum 3.29, catalystcats 3.48
   (the DEFAULT), synthwave 4.33, scrumnoir 4.36, sunsettide 4.43. `.abbadge.b` on `--accent-3`
   passed in all 16 on its own.
   HER 2026-08-14 A/B RULING ALREADY SOLVED THIS — for the OTHER A/B component. `.b-aday`/`.b-bday`
   in the badge block got `color-mix(... 55%, var(--text))`, computed as the largest share where all
   16 clear 4.5:1. `.abbadge` is the same A/B semantic in the 20x20 marker component and was missed
   by that pass, so it takes the SAME 55% rather than a second computed number — one A/B treatment,
   not two near-misses. Verified at 55%: worst 4.68 (A, pumpkinspice) and 6.16 (B, bubblegum).
   B is mixed too even though it did not fail, so the pair keeps matching visual weight.
   ⚠ THIS ALSO LANDS ON teacher/classes.php (Sprint Schedule, Deliverables due-A/B, Grading Periods)
   — same component, same improvement; flagged rather than scoped, because scoping it would mean the
   A-day marker read one way on Meetings and another on Classes. */
.abbadge.a{color:color-mix(in srgb, var(--accent-2) 55%, var(--text));background:color-mix(in srgb, var(--accent-2) 16%, transparent);
  border:1px solid color-mix(in srgb, var(--accent-2) 40%, transparent)}
.abbadge.b{color:color-mix(in srgb, var(--accent-3) 55%, var(--text));background:color-mix(in srgb, var(--accent-3) 16%, transparent);
  border:1px solid color-mix(in srgb, var(--accent-3) 40%, transparent)}
.abfields{display:flex;gap:14px;flex-wrap:wrap}
.abfields>div{flex:1;min-width:150px}
.abfields .ablab{display:flex;align-items:center;gap:7px;margin-top:10px}

.spfoot{display:flex;align-items:center;gap:16px;margin-top:10px;flex-wrap:wrap}
.spfoot label{font-size:13px;font-weight:400;display:flex;align-items:center;gap:6px;margin:0}

/* ---------- Student Class Schedule page (public/class_schedule.php) ---------- */
/* An entirely-past grading period's whole accordion, hidden by default — distinct from .gppast/
   .gpshowpast above, which hide individual TABLE ROWS in the teacher's separate Grading Periods
   table; the student page has no such table, only one accordion per period, so it needs its own
   whole-element hide/show pair. */
.gpcap .spacc.qpast{display:none}
.gpcap.showqpast .spacc.qpast{display:block}
.showpastwrap{display:flex;align-items:center;gap:7px;font-size:12.5px;color:var(--dim);cursor:pointer;margin-bottom:12px}
/* Important Dates panel values: bumped up from the base .ovv.ovmono size (a scoped class rather
   than changing .ovv.ovmono itself, which the teacher's Course Dates panel also uses — bumping
   that globally would resize a page nobody asked to change). */
.impdate{font-size:16.5px}
/* Sub-class Important Dates: a single big, prominent stat is the whole box, live or ended. */
.impbig{text-align:center;padding:10px 0 4px}
.impbig .k{font-size:13px;color:var(--dim);font-weight:700;text-transform:uppercase;letter-spacing:.5px}
.impbig .v{font-family:var(--disp);font-size:30px;font-weight:800;color:var(--brand);margin-top:6px}
.impended{text-align:center;padding:6px 0 2px}
.impended .v{font-family:var(--disp);font-size:22px;font-weight:800;color:var(--dim);letter-spacing:.5px}

/* Fixed layout so filtering to shorter names doesn't resize the columns underneath the cursor;
   the name column truncates with an ellipsis instead of pushing the row wider. */
/* 🔴 `#allteams`'s COLUMN WIDTHS MOVED INTO A <colgroup> IN THE MARKUP [2026-08-17, Teacher Teams
   audit]. Six `#allteams th:nth-child(N){width:…}` rules used to live here, and they only added up
   for the SIX-column shape — but the Class column is conditional on a sub-class existing, so on the
   five-column shape (12 of the 15 seeded classes, and every class outside a jam) every width landed
   one column to the left and the action column got 10% instead of 18%. With `table-layout:fixed` plus
   `#allteams td{overflow:hidden}` two lines down, that clips the row's own buttons. Measured in a real
   browser at 1440px on class 600: the action cell needed 224px in a 134px box; 373/373 after.
   teams.php now emits the `<col>` set from the SAME
   `$tf_hassub9` condition that decides whether the column exists, so the count and the widths cannot
   drift apart again. `#allcos` keeps its nth-child rules below: its column count is fixed at five and
   its widths already sum to 100%. */
#allteams, #allcos { table-layout:fixed; width:100%; }
#allteams td, #allcos td, #allteams th, #allcos th { overflow:hidden; }
#allteams td:first-child, #allcos td:first-child {
  white-space:nowrap; text-overflow:ellipsis;
}
#allteams th[data-sort="members"], #allcos th[data-sort="members"],
#allteams td.mono, #allcos td.mono { text-align:center; }
/* Per-column widths, one set per table (6 cols vs 5), summing to 100%. */
#allcos th:nth-child(1), #allcos td:nth-child(1) { width:26%; }
#allcos th:nth-child(2), #allcos td:nth-child(2) { width:14%; }
#allcos th:nth-child(3), #allcos td:nth-child(3) { width:18%; }
#allcos th:nth-child(4), #allcos td:nth-child(4) { width:10%; }
#allcos th:nth-child(5), #allcos td:nth-child(5) { width:32%; }

/* Trash icon button */
.trashbtn {
  cursor:pointer; background:transparent; border:1px solid transparent; color:var(--dimmer);
  border-radius:var(--r-sm); padding:4px 8px; font-size:14px; transition:all .15s;
}
.trashbtn:hover { color:var(--red); border-color:color-mix(in srgb, var(--red) 45%, transparent); background:var(--red-soft); }

/* Large settings dialog (Team Settings popup) */
dialog.dlg-lg { width:min(1080px, 94vw); max-height:88vh; overflow-y:auto; padding:26px 30px; }
dialog.dlg-lg .grid { margin-top:14px; }
/* .switchdlg added 2026-08-03: the Switch Teams popup now closes with the same circle-X every other
   dialog uses, instead of a CANCEL button — her call, see sw_popup_html()'s own note. */
/* `dialog.movedlg` added 2026-08-16 — the Move This Card popup closes with the same circle-X as every
   other dialog rather than a CANCEL button (§9b), so it joins this list rather than styling its own. */
/* B2 [Snapshot Review Group B, 2026-08-19]: this used to be gated to a whitelist of wrapper classes
   (`dialog.dlg-lg .dlg-close, dialog.dlg-xl .dlg-close, ...`), which is exactly why her "the 'x' for
   manage add-ons popup is not in the upper right corner and is not stylized" was true — addons_hub.php's
   `<dialog>` carries no class at all, so its `.dlg-close` button fell through the whitelist to the bare
   unstyled UA button. Swept for every `.dlg-close` site app-wide and found a SECOND, undiscovered
   instance of the identical bug: teams.php's Edit Company dialog (`<dialog class="dlg-cn">`) has the
   same gap. Broadened to any `.dlg-close` inside any `<dialog>` — "fix once, in the shared dialog
   chrome" per her framing — which is purely additive for every dialog that already had a qualifying
   class (their `.dlg-close` already matched one of the old selectors) and fixes both real gaps at once. */
/* 🔴 O3 · A TOOLTIP INSIDE A DIALOG NEEDS A POSITIONED ANCESTOR, AND ONLY `label` HAD ONE
   [her note 3, fixed 2026-08-23]. Her words: *"The Slack webook tooltip doesn't work, it just changes
   the look of the page slightly but no tooltip pops up."* **"Changes the look slightly" is the tell —
   it IS rendering, just nowhere it can be seen.**
   The dialog tooltip rules anchor the bubble to its `<label>` (`dialog .qtip{position:static}` +
   `dialog label{position:relative}` + `dialog .qtiptext{left:0;top:calc(100% + 6px);width:100%}`) so
   it can span the dialog's content width. But this qtip lives in an `<h2>`, and neither `h2` nor
   `.panel` is positioned, so the containing block falls all the way through to the `<dialog>` itself:
   `top:calc(100% + 6px)` then means 6px below the bottom of the WHOLE dialog, at full dialog width.
   `qtipPlace()` (app/ui.php) flips it to `bottom:calc(100% + 6px)` when there is no room below, which
   puts it above the top of the dialog instead. Both placements are outside the visible box.
   This is the minimal fix and it matches exactly what `dialog label` already does. It is also a
   CLASS of bug, not one tooltip: any `.qtip` in a dialog that is not inside a `<label>` had it, so
   this is written against the headings rather than against the Slack panel. */
/* 🔴 `.bdlgsub` JOINS THIS LIST [her report 2026-08-29: *"The question mark tooltip text for the
   slack webhook is broken, it just shows on the popup permanently rather than being in popup text on
   hover."*] and it is the SAME bug her 2026-08-23 note above describes, in a place it was moved to.
   Moving the Slack panel into a dialog put its `?` inside a `<p class="bdlgsub">`. A `<p>` is not
   positioned and `dialog .qtip` is forced static, so the containing block fell through to the
   `<dialog>` and `top:calc(100% + 6px)` meant "below the whole popup". Measured: on hover the bubble
   rendered at y=71 while the dialog sat at y=400, entirely outside it -- which from the front looks
   like a block of text stuck near the popup rather than a tooltip that answers to the pointer.
   ⚠ THE RULE IS WRITTEN AGAINST THE CLASS, not against the Slack popup, for the same reason the
     heading rule was: any `.qtip` in a subtitle line of any of these dialogs had it. */
dialog h2, dialog h3, .bdlg .bdlgsub { position:relative; }
/* 🔴 THE LOOK IS NOT SCOPED TO A CONTAINER ANY MORE [2026-08-27]. It was `dialog .dlg-close`, widened
   once before from a whitelist of dialog size-classes after two buttons fell through it -- and it
   still missed the board's FILTER popover, which is not a <dialog> at all, so that ✕ rendered as the
   browser's raw button: her *"the board filters box has an unstyled x to close."*
   Every earlier fix widened the selector by one more container instead of asking what the class means.
   A `.dlg-close` is a close button wherever it lives, so its appearance belongs on the bare class and
   anything given that class from here on looks right by default. */
.dlg-close {
  cursor:pointer; background:var(--panel);
  border:1px solid var(--line-hi); color:var(--dim); border-radius:50%;
  width:30px; height:30px; font-size:15px; padding:0; line-height:1;
}
.dlg-close:hover, .dlg-close:focus-visible { color:var(--text); border-color:var(--dim); }
/* Only the POSITIONING is about being inside a dialog: sticky+float is what keeps it in the corner of
   a panel that scrolls. A popover sets position:static (see .fpopx) and keeps the circle. */
dialog .dlg-close { position:sticky; top:0; float:right; z-index:10; }

/* Pacing rows */
.pacerow {
  display:flex; justify-content:space-between; align-items:center; gap:12px;
  padding:9px 4px; border-top:1px solid var(--line); text-decoration:none; color:var(--text);
}
.pacerow:first-of-type { border-top:0; }
a.pacerow:hover .l1 { color:var(--brand); }
.pacerow .l1 { font-weight:700; font-size:14.5px; }

/* Student dashboard task rows */
.taskrow {
  display:flex; justify-content:space-between; align-items:center; gap:10px;
  padding:9px 4px; border-top:1px solid var(--line); font-size:14px;
}
.taskrow:first-of-type { border-top:0; }
.taskrow .t { font-weight:700; }
.taskrow .w { color:var(--dimmer); font-size:12px; font-family:var(--mono); }

/* Clickable prow */
a.prow { text-decoration:none; color:var(--text); }
a.prow:hover .l1 { color:var(--brand); }

/* ============ Step 7 additions ============ */
.stat.railalarm b { color:var(--red); font-size:15px; letter-spacing:1.5px; font-family:var(--mono); }
.stat.railalarm span { color:var(--dim); }

/* Estimate badges on cards. border-COLOR only — the 1px transparent border now lives on `.age`/`.tag`
   themselves so a bordered variant is the same box height as an unbordered one in the same row.
   --dimmer -> --dim on .age-est for the same reason .tag-sub moved: 10px mono text gets no large-text
   allowance, and this badge shares a row with `.assg`, which is already --dim. */
.age.age-est { background:transparent; border-color:var(--line-hi); color:var(--dim); }
.tag.tag-attempt { background:color-mix(in srgb, var(--amber) 12%, var(--bg-deep)); color:var(--amber);
  border-color:color-mix(in srgb, var(--amber) 35%, transparent); }

/* Deliverable status pills */
.dpill { display:inline-block; font-family:var(--mono); font-size:9.5px; font-weight:700; letter-spacing:1px;
  border-radius:4px; padding:3px 8px; border:1px solid transparent; }
.d-not     { color:var(--dimmer); border-color:var(--line-hi); }
.d-started { color:#60a5fa; background:color-mix(in srgb, #60a5fa 12%, transparent); }
.d-prog    { color:#a78bfa; background:color-mix(in srgb, #a78bfa 12%, transparent); }
.d-ready   { color:#f472b6; background:color-mix(in srgb, #f472b6 14%, transparent); }
.d-done    { color:var(--green); background:var(--green-soft); }

/* Mini status strip on pacing rows */
.dstrip { display:inline-flex; gap:3px; }
.dstrip i { width:10px; height:10px; border-radius:2px; display:inline-block; background:var(--line-hi); }
.dstrip i.d-started { background:#60a5fa; }
.dstrip i.d-prog { background:#a78bfa; }
.dstrip i.d-ready { background:#f472b6; }
.dstrip i.d-done { background:var(--green); }

/* Deliverable rows (student dashboard + teacher pages) */
.delrow { display:flex; justify-content:space-between; align-items:center; gap:10px;
  padding:9px 4px; border-top:1px solid var(--line); font-size:14px; flex-wrap:wrap; }
.delrow:first-of-type { border-top:0; }
.delrow .t { font-weight:700; }
.delrow form { display:flex; gap:8px; align-items:center; margin:0; }
.delrow select { width:auto; padding:5px 24px 5px 10px; font-size:12.5px; }
.sendback { background:var(--amber-soft); border:1px solid color-mix(in srgb, var(--amber) 30%, transparent);
  border-radius:var(--r-sm); padding:6px 10px; font-size:12.5px; margin-top:6px; width:100%; }

/* ============ Step 8 ============ */
/* 🔴 NOTE 7 · PANEL HEADINGS WERE TOO SMALL AGAINST BODY TEXT, AND TOO CLOSE TO WHAT THEY LABEL
   [her ruling 2026-08-22]: *"I'm not sure that any of the headers are meeting the ui/ux standards for
   header sizes in relation to page text. They all seem really small. There's also not good spacing
   after any of the headers, especially in the teams section where the boxes are way too close to that
   header text."*
   MEASURED: `body` is 15.5px and this was 18.5px — a ratio of 1.19, where a section heading is
   normally 1.3-1.5x its body copy. 21px puts it at 1.35. The gap under it was `margin:0 0 4px` from
   `.panel h2, h3.ph` above, which is what puts the Teams tiles hard against the word "Teams";
   14px is the same rhythm `.panel .hint` already uses beneath a heading (12px). */
.panel h2 { font-size:21px; margin-bottom:14px; }
.sprintrail h2 { font-size:22px; }
.rail-compact h2 { font-size:17px; }
.appdialog .dlgmsg { font-size:16px; margin:4px 0 2px; line-height:1.55; padding:14px 16px;
  background:var(--bg-deep); border:1px solid var(--line-hi); border-radius:var(--r-md);
  /* ⚠ ITS OWN COLOUR, not `dialog`'s by inheritance [2026-08-27]. This is refusal text: if anything
     re-colours the dialog around it -- a reading extension, a user stylesheet, an OS forced mode --
     inherited text goes with it and the student is left with two buttons and no sentence, which is
     exactly the report this came from. An explicit rule on the element itself survives most of that.
     Ruled out first, by measurement: all 15 themes and all three tiers are legible (worst 6.75:1,
     tests/ui/t_alert_themes.py), and an unknown theme falls back readable. */
  color:var(--text); }
/* Windows High Contrast and friends: the OS repaints backgrounds and borders but honours `forced-
   color-adjust`, so a plate built from color-mix() can end up a system colour while the text keeps
   one the theme chose. Pinning both to the system pair keeps the sentence readable there, and this
   mode is invisible to the in-app theme picker -- which fits "he switched themes and nothing
   changed". */
@media (forced-colors: active) {
  .appdialog .dlgmsg { background:Canvas; color:CanvasText; border-color:CanvasText; }
}
.appdialog .dlgtier-warn { background:color-mix(in srgb, var(--amber) 9%, var(--bg-deep));
  border-color:color-mix(in srgb, var(--amber) 45%, transparent); }
.appdialog .dlgtier-warn { background:var(--amber-soft); border:1px solid color-mix(in srgb, var(--amber) 30%, transparent);
  border-radius:var(--r-sm); padding:10px 12px; }
/* 🔴 THE GREEN TIER [added 2026-08-22 for "Ready to complete this task?"]. Her ruling was that the
   completion prompt is a CELEBRATION that happens to prevent an accidental drag, explicitly not a
   warning: *"not even like a 'this can't be undone'"*. Amber would have contradicted that in colour
   while the words said otherwise. Built the same way `.dlgtier-warn` is — the hue mixed into --bg-deep
   rather than a soft token — for the contrast reason the note directly below spells out: these plates
   sit on --panel-hi inside a dialog, and a soft alpha token composited over that fails AA. */
.appdialog .dlgtier-good { background:color-mix(in srgb, var(--green) 9%, var(--bg-deep));
  border-color:color-mix(in srgb, var(--green) 45%, transparent); }
/* The confirm button turns green with it, so the safe/positive answer is the one that reads as the
   action. Scoped to the dialog so it cannot leak into the board's other .btn instances. */
/* 🔴 UNSCOPED FROM `.appdialog` [2026-08-29]. The completion question left the shared confirm dialog
   and became #donedlg, and her ruling is that the green belongs on its YES and nowhere else in that
   popup: *"The green 'yes' button carries the green there, we don't need it elsewhere."* Scoped to
   `.appdialog` the class was inert there, so the button came out in the brand colour like any other
   primary. Nothing else in the app uses `btn-green`, so widening it changes only the two places that
   ask for it. */
.btn.btn-green { background:var(--green); color:var(--bg-deep); border-color:var(--green); }
.btn.btn-green:hover { filter:brightness(1.08); }
/* The plate is amber mixed into --bg-deep rather than --amber-soft. These notices live inside a
   <dialog>, whose surface is --panel-hi, and a .14-alpha soft token composited over that lifted the
   plate far enough to fail AA: 4.28:1 in bubblegum. Mixed into --bg-deep it is 6.43:1-8.98:1 in all 16
   themes and reads identically on the pages where the surface is already dark. */
.estlock { background:color-mix(in srgb, var(--amber) 14%, var(--bg-deep));
  border:1px solid color-mix(in srgb, var(--amber) 35%, transparent);
  border-radius:var(--r-sm); padding:8px 10px; font-size:13px; font-weight:600; color:var(--amber); margin-top:6px; }
/* 🔴 `.esterr` HAD NO RULE ANYWHERE IN THIS FILE [added 2026-08-08, Board audit]. `git log -S` aside,
   the class appears exactly twice in the app — board.php's #esterr (estimate dialog) and #asgerr
   (assign-and-move dialog) — and public/assets/style.css is the only stylesheet the app loads, so
   both of those validation messages were rendering as plain --text body copy inside the dialog: no
   red, no plate, nothing to distinguish "that estimate is longer than the sprint has left" from the
   instructions directly above it. Built as .estlock's red sibling on purpose — same box, same plate
   construction, warning hue instead of caution. 5.29:1-7.26:1 across the 16 themes. */
.esterr { background:color-mix(in srgb, var(--red) 12%, var(--bg-deep));
  border:1px solid color-mix(in srgb, var(--red) 40%, transparent);
  border-radius:var(--r-sm); padding:8px 10px; font-size:13px; font-weight:600; color:var(--red); margin-top:6px; }
/* bigger, hover-friendly status strips */
.dstrip { gap:5px; }
.dstrip i { width:17px; height:17px; border-radius:4px; cursor:help; }
/* permanent accomplished green (theme-proof) */
.dpill.d-done { color:var(--green) !important; background:color-mix(in srgb, var(--green) 14%, transparent) !important; border-color:color-mix(in srgb, var(--green) 35%, transparent) !important; }
.dstrip i.d-done { background:var(--green) !important; }
/* pace tags */
.pacetag { font-family:var(--mono); font-size:10.5px; font-weight:800; letter-spacing:1px;
  border-radius:4px; padding:4px 9px; }
.pace-ok { color:var(--green); background:color-mix(in srgb, var(--green) 13%, transparent); }
.pace-warn { color:var(--amber); background:var(--amber-soft); }
.pace-red { color:var(--red); background:var(--red-soft); }
.needstag { background:var(--red); color:#fff; font-family:var(--mono); font-size:9.5px; font-weight:800;
  letter-spacing:1px; border-radius:4px; padding:3px 7px; margin-left:6px; }
/* pace gauge (student dashboard) */
.pacegauge { text-align:center; min-width:190px; padding:14px; border-left:1px solid var(--line); }
.pacegauge .big { font-family:var(--mono); font-weight:800; font-size:17px; letter-spacing:1px; }
.pacegauge .sub { color:var(--dim); font-size:12.5px; margin-top:6px; }
/* Right now slim tiles */
.tiles-slim { display:grid; grid-template-columns:1fr 1fr; gap:8px; }
.tiles-slim .tile { padding:10px 8px; }
.tiles-slim .tile b { font-size:22px; }
/* color swatch pickers */
.swatches { display:flex; flex-wrap:wrap; gap:8px; margin:8px 0; }
.swatch { width:30px; height:30px; border-radius:8px; cursor:pointer; border:2px solid transparent; position:relative; }
.swatch.on { border-color:#fff; box-shadow:0 0 0 2px var(--bg-deep); }
.swatch.taken { opacity:.28; cursor:not-allowed; }
.swatch.taken::after { content:'✕'; position:absolute; inset:0; display:flex; align-items:center; justify-content:center;
  color:#fff; font-size:13px; }
/* avatar more tile + pending */
/* `background:transparent` and the two inherits are LOAD-BEARING, not tidiness [2026-08-16]: this
   became a real <button> in the magnifier sweep, and a button inherits neither font-family nor
   line-height from the UA stylesheet and arrives with a grey UA background. Same reset, and the same
   reason, as `.fchip, .teamchip` above — see that rule's note, where dropping line-height was
   measured to shrink every chip by 4.6px. */
.avmore { display:flex; align-items:center; justify-content:center; font-size:20px; color:var(--dim);
  border:1px dashed var(--line-hi); border-radius:10px; cursor:pointer;
  background:transparent; font-family:inherit; line-height:inherit; padding:0; }
.avpend { position:relative; }
.avpend::after { content:'PENDING'; position:absolute; bottom:-4px; left:50%; transform:translateX(-50%);
  background:var(--amber); color:#000; font-size:8px; font-weight:800; padding:1px 5px; border-radius:3px; }
.avx { position:absolute; top:-6px; right:-6px; width:20px; height:20px; border-radius:50%; background:var(--red);
  color:#fff; border:0; font-size:11px; cursor:pointer; display:none; line-height:1; }
.avwrap { position:relative; display:inline-block; }
.avwrap:hover .avx { display:block; }
/* team logo spots */
.teamlogo { width:44px; height:44px; border-radius:10px; object-fit:cover; vertical-align:middle; margin-right:10px; }
/* deliverables page fills width */
.delrow .t { font-size:15px; }
.trashbtn { font-size:18px; padding:6px 10px; }


/* ============ Step 9 ============ */
/* dialogs: reading width, wrapped, buttons pinned */
dialog { max-width:440px; }
dialog.dlg-lg { max-width:560px; }
dialog.dlg-xl { max-width:min(980px, 94vw); width:min(980px, 94vw); max-height:88vh; overflow-y:auto; padding:26px 30px; }
/* Edit Forms: pin the header, form picker and Copy-from; scroll ONLY the question list.
   Whole-dialog scrolling took the form selector off-screen at 20 questions, which is exactly the
   control you need while working through a long list. Same pinned-top / scroll-middle pattern the
   Course Plan quarter boxes use. */
/* ---- Themed scrollbars, global. There was NO scrollbar styling in the app before this, which
   is why dark themes showed stark white rectangles. Blessed in the settings design pass: 8px,
   transparent track, rounded thumb tinted from --brand and brightening on hover, with a 2px
   transparent border + background-clip so it reads as a floating pill. Both engines. ---- */
* { scrollbar-width:thin;
    scrollbar-color:color-mix(in srgb, var(--brand) 30%, var(--line-hi)) transparent; }
::-webkit-scrollbar { width:8px; height:8px; }
::-webkit-scrollbar-track { background:transparent; }
::-webkit-scrollbar-thumb { background:color-mix(in srgb, var(--brand) 30%, var(--line-hi));
  border-radius:8px; border:2px solid transparent; background-clip:content-box; }
::-webkit-scrollbar-thumb:hover { background:color-mix(in srgb, var(--brand) 55%, var(--line-hi));
  background-clip:content-box; }
::-webkit-scrollbar-corner { background:transparent; }

dialog.dlg-xl.formsdlg[open] { display:flex; flex-direction:column; overflow:hidden; }
/* 🔴 THE HEADER STRIP IS BORROWED FROM `.ci-top`, NOT JOINED TO `.bdlg` [2026-09-06, chat 11],
   and the reason is MEASURED here rather than reasoned from the file.
   WHY NOT `class="dlg-xl formsdlg bdlg"`: chat 10 hit this on the 600px Generate Reports dialog and
   wrote the arithmetic down, but THE ARITHMETIC DOES NOT TRANSFER, and copying its conclusion would
   have put a confidently wrong comment on a working dialog. `dialog.bdlg` (style.css:2385) and
   `dialog.dlg-xl` (:3234) are BOTH (0,1,1) -- neither outranks anything, so SOURCE ORDER decides,
   and `.dlg-xl` is the later of the two. Measured in Chromium on three throwaway dialogs carrying
   only these classes (so that this file's own `.formsdlg` rules at (0,2,1) could not mask the
   contest being measured), the damage is the OPPOSITE of `.repdlg`'s: width survives, padding dies.
       class="dlg-xl"        width 980px  padding 26px 30px  strip inset from the dialog edge: 31px
       class="bdlg"          width 440px  padding 0          strip inset:  1px   <- correct
       class="dlg-xl bdlg"   width 980px  padding 26px 30px  strip inset: 31px   <- the swap
   So the 980 would have SURVIVED and the `padding:0` the strip needs would not: a header strip
   floating 30px inside a dialog it is supposed to span edge to edge. That is a subtler failure than
   chat 10's crushed 520 -- it still looks like a header, just a wrong one -- and `.bdlg > h3`
   (padding-left:22px) and `.bdlg form > label` (display:block, 10.5px, uppercase) come with it.
   ⚠ AND ONE THING TO HAND ON RATHER THAN FIX HERE: `dialog.bdlg` DECLARES `width:min(520px,94vw)`
     and computes 440px. `.bdlg` never overrides the base `dialog { max-width:440px }` (:3232), and a
     width loses to a smaller max-width -- which is the trap the note at the bottom of this file
     already records for `.dlg-xl`, where BOTH are set together for exactly this reason. Measured on
     the real `#addsprintdlg`, not only on the synthetic one: `width 440px, maxWidth 440px, rect 440`.
     Every `.bdlg` dialog in the app is 440 wide, not 520. Out of scope for chat 11 (this dialog is
     not joining the family) but it is chat 12's whole subject, so it is written down here rather
     than re-derived there.
   SO THE RULES ARE COPIED AND SCOPED. `dialog.dlg-xl.formsdlg` is (0,2,1) and beats `dialog.dlg-xl`
   (0,1,1) outright in either source order, which is what a `padding:0` on a family whose own rule
   says `padding:26px 30px` actually needs.
   WHAT IS NOT COPIED: `.ci-top .dlg-close`, `.ci-icon` and `.colchip.addwhere` are already unscoped
   in this file, so the strip's ✕ and its caption need nothing added here.
   ⚠ 22px, NOT `.ci-top`'s OWN 14, AND THAT IS THE ONE NUMBER NOT TO TIDY. The caption is
     `.colchip.addwhere`, which carries its own 8px of left padding, so 22 + 8 lands the class name
     on 30 -- `.dlg-xl`'s body inset, and the left edge `.fscroll` keeps below it. The `.bdlg` family
     uses 14 + 8 = 22 because 22 is ITS body inset; copying the 14 would have hung the caption 8px
     left of everything under it, which is the misalignment tests/ui/t_dialog_chrome.py exists to
     catch. */
dialog.dlg-xl.formsdlg { padding:0; }
dialog.dlg-xl.formsdlg > .ci-top { background:var(--bg-deep); border-bottom:1px solid var(--line);
  padding:11px 60px 11px 22px; display:flex; align-items:center; gap:10px; flex-wrap:wrap;
  flex:none; position:relative; border-radius:var(--r-lg) var(--r-lg) 0 0; }
/* ⚠ THE ✕ IS PINNED TO THE STRIP, NOT CARRIED BY THE ROW, and that is the narrow-width half of the
   `flex-wrap` rule below. Left in the flow it is the LAST item in a wrapping row, so the moment the
   row takes a second line the close button drops to the bottom LEFT of the header -- measured at
   400px CSS: a 254px-tall strip with the ✕ on its own final line. Her B2 complaint was exactly this
   shape of thing (*"the 'x' for manage add-ons popup is not in the upper right corner"*), so it is
   taken out of flow and pinned instead of being left to wrap.
   ⚠ THE MARKUP IS UNCHANGED BY THIS. The button stays inside `.ci-topright` where the house format
     puts it (and where t_dialog_chrome.py's HOUSE check looks for it); an absolutely positioned
     element resolves against its nearest POSITIONED ancestor, and `.ci-topright` is static, so
     `position:relative` on the strip is what catches it. `padding-right:60px` reserves its lane so
     the Copy-from group never runs underneath it -- 22 + 30 (the button) + 8 lands the group's right
     edge on 60, which is within a pixel of where it sat when the button was still in the row.
   ⚠ SPECIFICITY, because there is an existing rule pulling the other way: `.ci-top .dlg-close`
     (:2219) is (0,2,0) and sets `position:static` for the whole house. The selector below is
     (0,4,1), so it wins on specificity rather than on source order. */
dialog.dlg-xl.formsdlg .ci-top .dlg-close { position:absolute; top:11px; right:22px; }
dialog.dlg-xl.formsdlg > .ci-top > .ci-topright { margin-left:auto; display:flex;
  align-items:center; gap:8px; }
.formsdlg .fpicklab { font-size:12.5px; color:var(--dim); }
.formsdlg .fcopyrow { display:flex; align-items:center; gap:8px; margin:0; flex:0 1 auto;
  min-width:0; white-space:nowrap; }
/* The dialog's own 26px/30px padding is gone -- the strip has to reach both edges -- so `.fscroll`
   carries the body inset instead. The right side is `margin 26 + padding 8` rather than a flat 34 so
   the SCROLLBAR sits 26px in and the text clears it by 8: the same sum the old
   `padding-right:8px; margin-right:-4px` made against a 30px dialog padding. It is a MARGIN now
   rather than that negative one, because -4px against `padding:0` would hang the bar 4px off the
   dialog's own edge. 18px on top is the margin the control row used to carry below itself; 26px at
   the bottom is the dialog padding that used to close the box. */
dialog.dlg-xl.formsdlg > .fscroll { flex:1 1 auto; min-height:0; overflow-y:auto;
  margin:0 26px 0 30px; padding:18px 8px 26px 0; }
/* ⚠ NOTHING IN THE STRIP MAY CLIP, and this rule is here because the ✕ MOVED INTO THE ROW. The
   old body row did not carry it -- it floated in a zero-height sticky div above -- so the row is
   ~38px longer than the one it replaces, inside a box only 16px wider (11px/22px here against the
   dialog's own 26px/30px before). At 980px that is slack either way. At phone width neither version
   fits, and `dialog.dlg-xl.formsdlg[open]` is `overflow:hidden`, so anything past the edge is not
   scrollable-to, it is GONE -- COPY first. Measured at 320 CSS px, which is WCAG 1.4.10's own test
   width and the one that caught chat 8a's Class Days column.
   `min-width:0` is the half that is easy to miss: a flex item defaults to `min-width:auto` = its
   intrinsic width, so `max-width` alone cannot shrink a <select> whose widest option is
   "Self-Evaluation (Performance Review Survey)". Same mechanism tests/ui/t_dialogs_narrow.py's
   docstring records for the Additional Avatars file input.
   ⚠ D9's CAP IS KEPT, NOT REPLACED: `min(220px, 100%)`, so the Copy-from select is still the 220px
     her ruling widened it to wherever there is room for 220. `.formsdlg .fcopysrc` is (0,2,0) and
     `.fcopysrc` is (0,1,0), so this wins on specificity rather than on source order. */
/* ⚠ WRAP, NOT SHRINK, and these four rules are that one sentence. Four controls share this strip
   and the widest option in the Form picker is "Self-Evaluation (Performance Review Survey)", which
   is what sizes it. At 980px they fit on one line with room to spare; narrower than that the strip
   takes a second row rather than squeezing the Copy-from select back under the 150px D9 widened it
   OFF. `.ci-topright`'s own `margin-left:auto` still holds its half to the right on the second row,
   and the ✕ is not in this flow at all -- see the pin above. */
dialog.dlg-xl.formsdlg > .ci-top > .styledsel { max-width:100%; min-width:0; }
dialog.dlg-xl.formsdlg > .ci-top > .ci-topright { flex-wrap:wrap; min-width:0; }
dialog.dlg-xl.formsdlg .fcopyrow { flex-wrap:wrap; }
dialog.dlg-xl.formsdlg .fcopysrc { max-width:min(220px, 100%); min-width:0; }
/* D9 [Snapshot Review Group D, her note]: the Copy-from select next to it, matched to .btn-sm's own
   29px height (padding 4px 12px there; a select needs its chevron gutter kept, so 4px/28px/4px/10px)
   and widened so a full class title fits instead of eliding into the sitewide select's 150px max. */
.fcopysrc { width:auto; max-width:220px; height:29px; padding:4px 28px 4px 10px; font-size:12.5px; }
.addcirc, .remcirc { width:22px; height:22px; border-radius:50%; display:inline-flex; align-items:center; justify-content:center;
  cursor:pointer; background:transparent; font-size:15px; line-height:1; padding:0; flex:none; }
.addcirc { border:1px solid color-mix(in srgb, var(--green) 60%, transparent); color:var(--green); }
.addcirc:hover { background:color-mix(in srgb, var(--green) 12%, transparent); }
.remcirc { border:1px solid color-mix(in srgb, var(--red) 55%, transparent); color:var(--red); }
.addcirc.big { width:34px; height:34px; font-size:22px; }
.b-member { color:color-mix(in srgb, var(--accent-2) 55%, var(--red) 45%);
  border-color:color-mix(in srgb, color-mix(in srgb, var(--accent-2) 55%, var(--red) 45%) 40%, transparent);
  background:color-mix(in srgb, color-mix(in srgb, var(--accent-2) 55%, var(--red) 45%) 12%, transparent); }
.acc.dragging { opacity:.45; }
.remcirc:hover { background:color-mix(in srgb, var(--red) 12%, transparent); }
dialog .dlgmsg, dialog p, dialog li, dialog label, dialog input, dialog textarea, dialog select { overflow-wrap:anywhere; word-break:break-word; }
/* NEEDS ATTENTION: soft red badge style (both uses) */
.needstag { background:var(--red-soft); color:var(--red); }
/* number-first countdown */
.cdown { display:flex; align-items:baseline; gap:8px; }
.cdown b { font-family:var(--mono); font-size:36px; font-weight:800; line-height:1; }
.cdown span { font-size:13px; font-weight:700; letter-spacing:.5px; color:var(--dim); text-transform:uppercase; }
.rail-compact .cdown b { font-size:22px; }
/* color rings replace squares */
.pchip { border:2px solid var(--pc, var(--line-hi)); border-radius:999px; padding:2px 9px;
  font-size:11.5px; font-weight:700; display:inline-flex; align-items:center; }
.fchip.person { border:2px solid var(--pc, var(--line-hi)); border-radius:999px; }
/* 🔴 THE ✕ INSIDE AN ASSIGNEE CHIP [her ruling 2026-08-19, Snapshot Review note 13 — it replaced the
   two-select swap control]. Lives INSIDE .pchip, so the ring is the hit target's own outline and the
   glyph never floats loose beside a name.
   ⚠ 24×24 MINIMUM, NOT the 11.5px the glyph reads at: WCAG 2.5.8 (Target Size, Minimum) wants 24px,
   and this sits in a row of chips where the next control is inches away — the padding is the target,
   the ✕ is just what it looks like. --dim (not --dimmer) for the same AA reason `.assg` beside it
   already carries, going --red only on hover/focus so a destructive control announces itself at the
   moment of aiming rather than sitting permanently red in a row of names (README §5 reserves red for
   warning semantics; an idle remove button is not a warning). */
/* 🔴 THE MOVE CONTROL INSIDE CARD INFO [her ruling 2026-08-22]. A chip naming the column the card is
   in now, and an inline panel it opens in the SAME window — her words: *"it's just a drop down like
   the filter dropdown from there within the same window."* Not a second <dialog>: two modals at once
   is the thing the old Move menu was written to avoid, and nesting one inside an open dialog is worse. */
/* 🔴 THE MEMBER AVATAR ROW [her ruling 2026-08-22, I3/I4]. Overlapped faces beside the Filter button,
   your own first and ringed in brand so it is findable at a glance — it is the only one that opens a
   colour picker. Replaces the lone colour dot, which read as a filter because it sat among filters. */
/* ⚠ .mbubrow, NOT .bubrow: that name was already taken by the FLOATING help/message bubbles
   (app/ui.php, .bubrow at :2311 here) which are position:fixed at z-index 900 — reusing it turned the
   avatar row into a fixed overlay pinned bottom-right that swallowed every click on the board. Same
   class of collision as the .ghost one; check before naming. */
.mbubrow { position:relative; display:inline-flex; align-items:center; }
.mbub { border:0; padding:0; background:none; cursor:pointer; margin-left:-8px; border-radius:50%;
  line-height:0; transition:transform .12s ease; }
.mbub:first-child { margin-left:0; }
.mbub .avatar { border:2px solid var(--bg-deep); box-sizing:content-box; }
.mbub-me .avatar { border-color:var(--brand); }
.mbub:hover, .mbub:focus-visible { transform:translateY(-3px); z-index:2; position:relative; outline:none; }
.mbub:focus-visible .avatar { border-color:var(--accent-2); }
/* The popup is anchored in JS to the face that opened it (her note: its top-left should sit near the
   bubble, not park at a fixed offset). */
.mempop { position:absolute; top:calc(100% + 9px); left:0; z-index:40; width:min(280px, calc(100vw - 40px));
  background:var(--panel); border:1px solid var(--line-hi); border-radius:12px;
  box-shadow:0 16px 40px rgba(0,0,0,.55); overflow:hidden; }
.mempop[hidden] { display:none; }
.memcap { display:flex; align-items:center; gap:13px; padding:14px 15px; }
/* The header takes the member's own card colour, so the popup IS the colour it is talking about. Text
   on it is the deep ground rather than --text: these are saturated PALETTE20 values, and light text on
   them fails AA in several. */
.memcap .memav { line-height:0; }
.memcap .memav .avatar { border:2px solid rgba(10,16,28,.35); box-sizing:content-box; }
.memwho { display:flex; flex-direction:column; min-width:0; }
.memnm { font-family:var(--disp); font-weight:700; font-size:17px; color:var(--bg-deep); line-height:1.2; }
.memrl { font-family:var(--mono); font-size:10.5px; letter-spacing:1px; color:rgba(10,16,28,.75); margin-top:4px; }
.meminner { padding:13px 15px 15px; }
.memmeta { display:flex; align-items:baseline; gap:10px; }
.memk { font-family:var(--mono); font-size:10px; font-weight:700; letter-spacing:1.6px; color:var(--dimmer); margin:0; }
.memv { font-size:13.5px; color:var(--text); }
/* 🔴 THE DRAG GHOST [her ruling 2026-08-22, Snapshot Review I6] — "an opaque angled version of the
   card". Parked far off-screen because setDragImage() snapshots a RENDERED element: display:none or
   visibility:hidden would snapshot as nothing, and leaving it on-screen would flash. The tilt and the
   shadow are the whole point; opacity stays at 1 so it reads as solid rather than as the browser's
   own washed-out default. */
.dragghost { position:absolute; top:-9999px; left:-9999px; pointer-events:none;
  transform:rotate(4deg); box-shadow:0 16px 34px rgba(0,0,0,.5); opacity:1; }
/* 🔴 THE FILTER POPOVER [her ruling 2026-08-22, Snapshot Review I1]. Replaces a bar of up to seven
   controls with one button. The COUNT BADGE is the price of hiding them: a bar announced itself by
   being loud, so a popover has to say how many filters are on or an active filter becomes invisible. */
.fpopwrap { position:relative; display:inline-flex; }
.fglyph { display:inline-flex; align-items:center; gap:9px; background:var(--panel);
  border:1px solid var(--line-hi); color:var(--text); border-radius:9px; padding:8px 14px;
  font-family:var(--sans); font-size:13.5px; font-weight:700; cursor:pointer; }
.fglyph:hover { border-color:var(--brand); }
.fglyph.on, .fglyph[aria-expanded="true"] { border-color:var(--brand); }
.fbars { display:inline-flex; flex-direction:column; gap:2.5px; }
.fbars i { display:block; height:2px; border-radius:2px; background:currentColor; }
.fbars i:nth-child(1) { width:13px; } .fbars i:nth-child(2) { width:9px; } .fbars i:nth-child(3) { width:5px; }
.fcount { font-family:var(--mono); font-size:10.5px; font-weight:700; background:var(--brand);
  color:#fff; border-radius:999px; padding:1px 7px; }
.fcount[hidden] { display:none; }
.fpop { position:absolute; top:calc(100% + 8px); left:0; z-index:40; width:min(340px, calc(100vw - 40px));
  background:var(--panel); border:1px solid var(--line-hi); border-radius:12px;
  box-shadow:0 18px 44px rgba(0,0,0,.55); overflow:hidden; }
.fpop[hidden] { display:none; }
.fpophead { display:flex; justify-content:space-between; align-items:center; padding:12px 14px;
  border-bottom:1px solid var(--line); font-family:var(--mono); font-size:11px; font-weight:700;
  letter-spacing:1.5px; color:var(--dim); }
.fpopx { position:static; }
.fpopbody { padding:13px 14px 14px; max-height:min(62vh, 520px); overflow-y:auto; }
.fpopfoot { padding:10px 14px; border-top:1px solid var(--line); display:flex; justify-content:flex-end; }
.fsec { font-family:var(--mono); font-size:10px; font-weight:700; letter-spacing:1.6px;
  color:var(--dimmer); margin:0 0 8px; }
.fsec-tch { color:var(--accent-3); }
.frule { border:0; border-top:1px solid var(--line); margin:14px 0 12px; }
/* Her spacing note: the search box sat too far from KEYWORD and too close to the section under it.
   The label hugs the field and the gap moves below. */
.fsearch { width:100%; background:var(--bg-deep); border:1px solid var(--line-hi); color:var(--text);
  border-radius:8px; padding:9px 12px; font-family:var(--sans); font-size:13.5px; margin:-2px 0 2px; }
.fsearch:focus { outline:2px solid var(--accent-2); outline-offset:1px; border-color:var(--accent-2); }
.fopt { display:flex; align-items:center; gap:10px; padding:6px; border-radius:7px; cursor:pointer;
  font-size:13.5px; color:var(--text); margin:0; font-weight:400; }
.fopt:hover { background:var(--panel-hi); }
.fopt input[type=checkbox] { appearance:none; width:16px; height:16px; flex:0 0 16px; border-radius:4px;
  border:1.5px solid var(--line-hi); background:var(--bg-deep); cursor:pointer; position:relative; }
.fopt input[type=checkbox]:checked { background:var(--brand); border-color:var(--brand); }
.fopt input[type=checkbox]:checked::after { content:'✓'; position:absolute; inset:0; display:flex;
  align-items:center; justify-content:center; color:#fff; font-size:11px; font-weight:700; }
.fopt input[type=checkbox]:focus-visible { outline:2px solid var(--accent-2); outline-offset:2px; }
/* Overdue keeps its red [her ruling 2026-08-22, reversing note 2] — see the markup's own note. */
.fopt-od .odtext { color:var(--red); }
.swdot { width:14px; height:14px; border-radius:4px; flex:0 0 14px; }
/* multi-selects: one control per level, "click 1 to all", the same shape at all three so it is
   learned once. A checkbox per person does not survive a class of thirty. */
.msrow { display:flex; align-items:center; gap:10px; margin:0 0 7px; }
.mslab { font-size:12.5px; color:var(--dim); flex:0 0 62px; }
.ms { position:relative; flex:1; min-width:0; }
.msbtn { width:100%; display:flex; align-items:center; justify-content:space-between; gap:8px;
  background:var(--bg-deep); border:1px solid var(--line-hi); color:var(--text); border-radius:8px;
  padding:8px 11px; font-family:var(--sans); font-size:13px; cursor:pointer; text-align:left; }
.msbtn:hover { border-color:var(--brand); }
.msbtn:focus-visible { outline:2px solid var(--accent-2); outline-offset:1px; }
.ms.on .msbtn { border-color:var(--brand); }
.mssum { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mscar { color:var(--dim); font-size:11px; flex:0 0 auto; }
.mslist { position:absolute; top:calc(100% + 5px); left:0; right:0; z-index:50; background:var(--panel-hi);
  border:1px solid var(--line-hi); border-radius:9px; box-shadow:0 12px 30px rgba(0,0,0,.5);
  padding:5px; max-height:210px; overflow-y:auto; }
.mslist[hidden] { display:none; }
.msempty { font-size:12px; color:var(--dimmer); padding:8px 7px; }
/* I9: an assignee chip is a filter toggle now. It must LOOK unchanged (no on-state ring, her call),
   so this only strips the button chrome and restores the cursor. */
.pchip-btn { background:none; font-family:var(--sans); cursor:pointer; }
.pchip-btn:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }
/* 🔴 THE CARD DESCRIPTION [her ruling 2026-08-22]. Two lines on the face.
   -webkit-line-clamp is the only cross-browser way to clamp by LINE rather than height, and it is
   supported everywhere this app runs; the height is not hard-capped, so a one-line description takes
   one line rather than always reserving two.
   ⚠ THE HOVER IS GONE [her ruling 2026-08-29] and `.carddesc-wrap` / `.carddesc-full` with it -- see
     the markup's note. The margin those rules carried moves onto `.carddesc`, which is now the
     element itself rather than the thing inside the tooltip wrapper. */
.carddesc { display:-webkit-box; -webkit-line-clamp:2; -webkit-box-orient:vertical; overflow:hidden;
  margin:5px 0 0; font-size:12.5px; line-height:1.45; color:var(--dim); }
#cimovewrap { position:relative; margin:2px 0 4px; }
.colchip { display:inline-flex; align-items:center; gap:7px; background:var(--panel-hi);
  border:1px solid var(--line-hi); color:var(--text); border-radius:7px; padding:6px 12px;
  font-family:var(--sans); font-size:12.5px; cursor:pointer; }
.colchip:hover { border-color:var(--brand); }
.colchip::after { content:'⌄'; color:var(--dim); font-size:11px; line-height:1; }
.colchip[aria-expanded="true"] { border-color:var(--brand); }
.movepanel { position:absolute; top:calc(100% + 6px); left:0; z-index:30; min-width:250px;
  background:var(--panel-hi); border:1px solid var(--line-hi); border-radius:10px;
  padding:13px 14px; box-shadow:0 12px 30px rgba(0,0,0,.5); }
.movepanel[hidden] { display:none; }
.movepanel .mvh { font-family:var(--mono); font-size:10px; font-weight:700; letter-spacing:1.6px;
  color:var(--dimmer); margin:0 0 10px; }
.movepanel label { margin-top:8px; }
.movepanel label:first-of-type { margin-top:0; }
.movepanel select { width:100%; }
.assgx { background:none; border:0; cursor:pointer; color:var(--dim); font-size:11px; line-height:1;
  margin:-4px -6px -4px 5px; padding:6px; min-width:24px; min-height:24px;
  display:inline-flex; align-items:center; justify-content:center; border-radius:50%; }
.assgx:hover, .assgx:focus-visible { color:var(--red); background:color-mix(in srgb, var(--red) 14%, transparent); }
/* circle-plus: visible ring, confident glyph */
.circleplus { border:2px solid var(--line-hi); color:var(--dim); font-weight:800; font-size:13px;
  width:22px; height:22px; display:inline-flex; align-items:center; justify-content:center; border-radius:50%; }
.circleplus:hover { border-color:var(--brand); color:var(--brand); }
/* svg trash + pencil sizing */
.icobtn { background:none; border:0; cursor:pointer; padding:4px; color:var(--dim); display:inline-flex; }
.icobtn:hover { color:var(--red); }
.icobtn svg { width:17px; height:17px; }
/* avatar grids: nameless, current-first */
.avgrid { display:flex; flex-wrap:wrap; gap:10px; }
.avgrid .avopt span:last-child { display:none; }   /* kill names */
/* 🔴 THE BOX IS THE CONTROL, NOT A RADIO SITTING ON IT [her ask 2026-08-27, mid-jam]:
   *"I just want to select the avatar box (the box around the avatar) and have it show that as
   highlighted/selected with no radio button."*
   `.avpick` (line ~1470) already hid its radios and painted the selection onto `.avopt`. The DIALOG
   grids never got either rule: join.php's "Additional Avatars" (#javmore) and settings.php's picker
   dialog carry `.avgrid` WITHOUT `.avpick`, so a live radio sat on top of each tile and choosing one
   changed nothing visible. Scoped to `.avgrid` so both dialogs are fixed by one rule rather than each
   growing its own; the selected look is copied from `.avpick` deliberately, so selection means the
   same thing on every avatar surface in the app.
   ⚠ opacity:0, NOT display:none. The input has to stay focusable and reachable by keyboard and by
   screen reader -- display:none would drop it out of the tab order and make the grid mouse-only. The
   :focus-visible rule below is what makes that focus visible, and it is not optional decoration.
   The <label> already wraps the whole tile, so clicking the box was always what selected it; only
   the radio's visibility and the missing selected state ever needed changing. */
.avgrid input[type="radio"] { position:absolute; opacity:0; width:0; height:0; pointer-events:none; }
.avgrid input[type="radio"]:checked + .avopt { border-color:var(--brand); background:var(--brand-soft); }
.avgrid input[type="radio"]:focus-visible + .avopt { outline:2px solid var(--brand); outline-offset:3px; }
/* The "More" tile on join.php became a real <button> when it became keyboard-reachable, so it needs
   the browser's button chrome stripped back to what the sibling <span> tiles look like. `.on` is the
   same selected state the radios get above -- the tile wears it when the chosen avatar is one of the
   uploads that live behind the dialog. */
.avpick button.avmorebox { font:inherit; color:inherit; background:transparent; }
.avpick button.avmorebox.on { border-color:var(--brand); background:var(--brand-soft); }
.avopt.on { outline:2px solid var(--brand); border-radius:12px; }
.avdlg-scroll { max-height:300px; overflow-y:auto; }
.avdlg-up { border-top:1px solid var(--line); padding-top:12px; margin-top:12px; }
/* 🔴 THE UPLOAD ROW, WHICH WAS THE ONLY DIALOG IN THE APP TO OVERFLOW AT 360px [2026-08-28,
   MOBILE:1047; 91 dialogs measured and this was the one]. It used to be three inline styles, and the
   Close button painted 104px past the dialog's right edge -- off screen on a phone, with the popup
   scrolling sideways instead.
   ⚠ `min-width:0` IS THE WHOLE FIX AND IT IS NOT COSMETIC. A flex item's default `min-width:auto`
   resolves to its intrinsic width, and for `<input type=file>` that is the "Choose file" button plus
   a filename column -- about 240px in Chromium, and not negotiable. So `flex:1` on the form was a
   promise it could not keep: the form would not shrink, and whatever sat beside it got pushed out.
   The same three lines are the general answer any time a file input lands in a flex row.
   ⚠ No width query and no numbers: the row wraps only when there is not room, so a desktop renders
   exactly what it rendered before. */
.avdlg-uprow { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
/* 🔴 `flex:1 1 0`, NOT `flex:1` (which is basis auto), and the difference is the whole reason the
   desktop row still reads as one line. With basis AUTO the form's hypothetical size is its content
   width -- 272px of file input plus the Upload button, ~345px -- so at the dialog's 440px the row
   asks for 412 against 382 available and WRAPS, pushing Close onto a second line on a desktop that
   never had a problem. With basis 0 the form has no opinion about its own width, shrinks into
   whatever Close leaves, and the row stays on one line at every width that can hold it.
   Measured before the fix: the row overflowed at 360 by 104px AND on a 1440 desktop by ~30px, so
   "desktop was fine" was never true -- it was just less obviously broken. */
.avdlg-upform { display:flex; gap:8px; align-items:center; flex:1 1 0; min-width:0; margin:0; }
/* The file input is what refuses to shrink (min-width:auto = its intrinsic ~272px), so it needs the
   override explicitly. The 110px floor keeps its own "Choose file" button intact rather than letting
   it clip; below that the row wraps instead, which is what flex-wrap above is for. */
.avdlg-upform input[type=file] { flex:1 1 auto; min-width:110px; max-width:100%; }
/* starter-pack toggles on library */
.avtoggle.off { opacity:.28; }
.avtoggle { cursor:pointer; }
/* grading queue single line */
.ckrow { display:flex; align-items:center; gap:10px; white-space:nowrap; }
.ckrow .ckt { flex:1; overflow:hidden; text-overflow:ellipsis; }
/* deliverables: nested duals + header pacing */
.dhead { display:flex; justify-content:space-between; align-items:baseline; flex-wrap:wrap; gap:8px; }
.dnest { margin-left:22px; }
/* team logo initials default */
.logoinit { border-radius:26%; background:var(--brand); color:var(--brand-ink);
  display:inline-flex; align-items:center; justify-content:center; overflow:hidden;
  font-family:var(--disp); font-weight:800; letter-spacing:.5px; line-height:1;
  vertical-align:middle; flex:none; }
/* status color blocks row */
.dcolrow { display:flex; gap:22px; flex-wrap:wrap; }
.dcolrow .dcol { width:86px; }
/* `.fieldcap` alongside `label` [2026-08-18]: the swatch caption names a whole swatch (box + pencil
   + hidden value), not one control, so it became a `.fieldcap` — same type treatment, correct element. */
.dcolrow .dcol label, .dcolrow .dcol .fieldcap { display:block; text-align:center; }
.dcolrow .dcol { text-align:center; }
.dcolrow .dcolbox { width:86px; height:46px; border-radius:10px; border:1px solid var(--line-hi); position:relative; }
/* status pill: bigger, clickable for leadership */
.dpill { font-size:13.5px; padding:6px 12px; }
.dpill-btn { cursor:pointer; border-width:1.5px; }
.dpill-btn:hover { filter:brightness(1.25); }
/* filter polish: sprint rail title bigger */
.sprintrail h2 { font-size:26px; }
/* Right Now: three slim tiles, one row, numbers front and center */
.rn-tiles { display:flex; gap:8px; flex-wrap:nowrap; }
.rn-tiles .tile { flex:1; min-width:0; padding:10px 4px; text-align:center;
  display:flex; flex-direction:column; align-items:center; gap:2px; }
.rn-tiles .tile b { font-size:26px; line-height:1; }
.rn-tiles .tile span { font-size:10.5px; letter-spacing:.5px; text-transform:uppercase; white-space:nowrap; }

/* dashboard row 1: rail stretches to match the Right Now panel */
.railwrap > * { flex:1; margin:0; min-height:0; }
/* absence dialog typography */
#absdlg .hint { color:var(--text); font-size:13.5px; }
#absdlg label { font-size:15px; color:var(--text); }
#absdlg input[type=radio] { transform:scale(1.25); margin-right:6px; }
/* no-school chips: the ✕ appears on hover, whole chip removes */
.nschip { cursor:pointer; }
.nschip .nsx { display:none; margin-left:6px; color:var(--red); font-weight:800; }
.nschip:hover .nsx { display:inline; }
.nschip:hover { border-color:var(--red); }
/* the bubble */
:root { --bub-size: 52px; }
.bubble { position:fixed; right:20px; bottom:20px; width:var(--bub-size); height:var(--bub-size); border-radius:50%;
  background:var(--brand); color:#101010; display:flex; align-items:center; justify-content:center;
  font-size:24px; cursor:pointer; z-index:900; box-shadow:0 4px 18px rgba(0,0,0,.45); user-select:none; }
.bubble:hover { transform:scale(1.06); }
.bubdot { position:absolute; top:2px; right:2px; width:13px; height:13px; border-radius:50%;
  background:var(--red); border:2px solid var(--bg-deep); }
.bubpanel { position:fixed; right:20px; bottom:calc(20px + var(--bub-size) + 30px); width:330px; max-height:65vh; overflow-y:auto;
  background:var(--panel); border:1px solid var(--line-hi); border-radius:var(--r-lg);
  padding:12px; z-index:901; box-shadow:0 -6px 30px rgba(0,0,0,.45); }
.bubcard { background:var(--bg-deep); border:1px solid var(--line); border-radius:var(--r-md);
  padding:10px 12px; margin-bottom:8px; font-size:13.5px; }
.bubdim { opacity:.62; }
.navdot { display:inline-flex; align-items:center; justify-content:center; min-width:17px; height:17px;
  padding:0 4px; border-radius:9px; background:var(--navdot-bg); color:#fff; font-size:10.5px; font-weight:800;
  margin-left:6px; font-family:var(--mono); }
/* Plain lit/unlit dot — no digits, for indicators that are deliberately never a count (items 6475/6500):
   a genuinely-new-change flag or a persistent "something actionable" reminder, not a tally.
   🔴 11px, NOT 8px [her report 2026-08-27]: *"the one is tiny and barely noticeable, the other is very
   big and even the red shades are different."*
   ⚠ THE COLOUR WAS NEVER DIFFERENT -- this and `.navdot` above have always read the same
   `var(--navdot-bg)`. Do not go looking for a second red. At 8px a solid circle is mostly EDGE, so
   antialiasing blends its rim into the dark sidebar and the mark reads darker and duller than a 17px
   pill of the identical colour behind white text. Area was the whole difference, and 11px is the
   smallest size at which the two read as the same red side by side.
   ⚠ AND THEY STAY DIFFERENT SHAPES on purpose: this one says "there is something here", the badge
   says "there are N". Evening their weight is the fix; merging them would lose the distinction. */
.navdot-plain { display:inline-block; width:11px; height:11px; border-radius:50%;
  background:var(--navdot-bg); margin-left:7px; vertical-align:middle; }
.b-green { color:var(--green); background:rgba(74,222,128,.12); }
.sorth { cursor:pointer; user-select:none; }
.sorth:hover { color:var(--text); }
/* goal box */
.railrow { display:flex; gap:14px; align-items:stretch; margin-top:22px; }
.railrow .sprintrail { flex:3; min-width:0; margin-top:0; }
.goalbox { flex:1; min-width:210px; background:var(--panel); border:1px solid var(--line-hi);
  border-radius:var(--r-lg); padding:16px 20px; position:relative; display:flex; flex-direction:column; gap:5px; }
.goal { font-size:15px; line-height:1.4; display:flex; gap:8px; align-items:baseline; font-weight:600; }
.goal-met { color:var(--green); text-shadow:0 0 6px color-mix(in srgb, var(--green) 85%, transparent), 0 0 18px color-mix(in srgb, var(--green) 55%, transparent); }
.goalck-met { color:var(--green); text-shadow:0 0 8px color-mix(in srgb, var(--green) 90%, transparent); font-weight:800; }
.goalbullet { color:var(--dim); font-size:13px; }
/* 🔴 `.goal-red` IS DELETED [her ruling 2026-09-01]: *"I don't like the red, it feels like that goal
   is 'bad' or something. Gives the wrong vibes about a goal you purposefully kept and want to try to
   reach."* It painted the whole row `--red`, the colour this stylesheet otherwise reserves for
   refusals and overdue work. A carried goal now keeps normal text and wears the CARRIED chip below.
   The `goals.red` COLUMN is untouched and still does all of its non-visual work — see the chip's own
   note in app/ui.php. Nothing else in the app ever used this class. */
/* The chip. Sits after the goal text, so a list where only some goals are carried keeps every row's
   words starting at the same place. `--amber` for weight without blame; no `min-width` here (unlike
   the RESULTS badges) because there is only ever one label in this list, so there is no column of
   them to line up. */
.goalcarry { margin-left:6px; vertical-align:1px; letter-spacing:.06em; }
.goalck { background:none; border:none; color:var(--text); cursor:pointer; font-size:16px; padding:0; }
.goalhist { margin-top:auto; align-self:flex-end; font-size:11.5px; color:var(--dim); cursor:pointer; user-select:none; }
.goalhist:hover { color:var(--text); }
/* 🔴 THE POST-RETROSPECTIVE OUTCOME ROWS [her ruling 2026-09-01]. What the goal box shows between the
   meeting closing and the next sprint starting. `align-items:center`, not `.goal`'s own `baseline`:
   these rows lead with a badge rather than a glyph, and a badge sitting on a text baseline hangs
   below the line it belongs to.
   ⚠ ONE WIDTH FOR ALL FOUR BADGES [her standing rule]. COMPLETED, REVISED, CARRIED and UNMET share a
     column, so they are sized by the longest of them and not by their own text — a ragged left edge
     is exactly the objection that rule exists for. 78px clears COMPLETED at this font size with the
     badge's own padding; `flex:none` stops the flex row shrinking one of them back below it.
   ⚠ THE GOAL TEXT IS NOT DIMMED. These are finished goals, but the point of the block is that a team
     can read what they did, so the words stay full strength and the BADGE carries the state. */
.goal-outcome { align-items:center; }
.goal-outcome .gobadge {
  flex:none; min-width:78px; text-align:center; letter-spacing:.06em;
}
@media (max-width:900px) { .railrow { flex-direction:column; } }
.bubmvp { border-color:var(--brand); box-shadow:0 0 14px rgba(255,170,0,.18); }
.trk { width:14px; height:14px; border-radius:50%; display:inline-block; flex:none; }
.trk-g { background:var(--green); box-shadow:0 0 8px rgba(74,222,128,.6); }
.trk-r { background:var(--red); box-shadow:0 0 8px rgba(248,113,113,.55); }
.trk-y { background:var(--amber); box-shadow:0 0 8px rgba(251,191,36,.55); }
.trk-grey { background:var(--dim); opacity:.6; }
/* the retrospective */
.meethead { display:flex; align-items:center; gap:14px; margin:14px 0 0; }
.meetnav { display:flex; justify-content:space-between; align-items:center; margin:16px 0 30px; }
/* deliverable reorder: arrows appear on hover */
.delrow-ord { position:relative; }
.ordarrows { display:flex; flex-direction:column; gap:0; opacity:1; margin-right:2px;
  flex:0 0 26px; align-items:center; }   /* always visible (ruled): discoverability beats tidiness */
.delrow-ord .t { flex:1; text-align:left; }
/* 🔴 24x24 [2026-08-18]. Measured with real deliverable rows (the seed's `deliverables` table is
   empty, so these arrows had never been measured at all): **21 x 18.09**, under WCAG 2.2 SC 2.5.8.
   The two arrows stack directly on each other inside `.ordarrows`, so the SC's spacing exception
   cannot apply — a 24px circle on one always intersects the other's. `min-*` rather than fixed size,
   and the padding is trimmed so the glyph stays where it was inside the bigger box. */
.ordbtn { background:none; border:none; color:var(--dim); cursor:pointer; font-size:14px; line-height:1.15;
  padding:0 5px; min-width:24px; min-height:24px; display:inline-flex; align-items:center; justify-content:center; }
.ordbtn:hover { color:var(--text); }

/* ---- Classes.php: Deliverables tab (Schedule/Deliverables sub-nav), Status Colors subsection ----
   Swatches scaled down from the standalone page's 86x46 to 84x32: a subsection here, not the whole
   panel it's borrowed from, so it shouldn't compete with the deliverables list for visual weight —
   the extra width (vs. a plain square) keeps longer labels like "In progress"/"Checked off" from
   wrapping at the smaller height. */
.statuscolors .dcolbox { width:84px; height:32px; }
.statuscolors .dcol { width:84px; }
.statuscolors .dcol label, .statuscolors .dcol .fieldcap { white-space:nowrap; font-size:11.5px; }
/* Tight pencil+trash pair on each deliverable row */
.delactions { display:flex; gap:6px; align-items:center; }
/* Plain-text due date on a deliverable row (single date only — no due-A/due-B pairing here) */
.delrow .due { font-family:var(--mono); font-size:12.5px; color:var(--dim); white-space:nowrap; }

/* ============ Step 14 — Performance Reviews + design overhaul ============ */
/* 🔴 --info MOVED OFF THIS UNIVERSAL BLOCK, 2026-08-09 — her ruling: "the whole idea was that
   --info would become a color of theme (not hard coded blue)... a combo that reads the theme but
   isn't screaming." --green/--bad/--navdot-bg stay locked here on purpose (good/bad must never be
   theme-variable); --info and --info-soft are now declared per-theme, in each theme's own block
   (and the base :root above), so this selector matching EVERY theme can no longer silently
   override them. See each `--info:` line for that theme's derivation (OKLab, checked against
   --amber/--red/that theme's own --brand, and WCAG AA on --panel/--bg-deep). */
:root, [data-theme] { --green:#4ade80; --green-soft:rgba(74,222,128,.13);
  --bad:#ff4655; --bad-soft:rgba(255,70,85,.12);
  --navdot-bg:#d5303f; }

/* Review wizard */
.rvsteph { font-size:21px !important; }
.rvsteph .dim { font-weight:500; font-size:14px; }
.rvstats { display:grid; grid-template-columns:repeat(auto-fill, minmax(215px, 1fr)); gap:12px; margin-top:12px; }
.rvstat { background:var(--bg-deep); border:1px solid var(--line); border-left:3px solid var(--sc, var(--line-hi));
  border-radius:var(--r-md); padding:16px 18px 14px; }
.rvstat b { display:block; font-family:var(--disp); font-size:34px; letter-spacing:-.02em; line-height:1.05; color:var(--sc, var(--text)); }
.rvstat span { display:block; font-size:13px; color:var(--dim); font-weight:700; margin-top:4px; }
.rvstat i { display:block; font-style:normal; font-size:11.5px; color:var(--dimmer); margin-top:2px; }
.rvq { padding:14px 4px; border-top:1px solid var(--line); }
.rvq:first-of-type { border-top:0; }
.rvqp { font-weight:700; font-size:15px; margin-bottom:6px; }
.rvqa { font-size:16px; display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.rvdelta { font-family:var(--mono); font-weight:800; font-size:14px; border-radius:6px; padding:2px 8px; }
.rvup { color:var(--green); background:var(--green-soft); }
.rvdown { color:var(--bad); background:var(--bad-soft); }
.rvflat { color:var(--dimmer); background:var(--panel-hi); }
.rvfree { background:var(--bg-deep); border:1px solid var(--line); border-radius:var(--r-md); padding:10px 14px; margin-top:6px; font-size:14.5px; }
.rvfree-then { opacity:.65; }
.rvwhen { display:block; font-family:var(--mono); font-size:10px; letter-spacing:1.5px; color:var(--dimmer); margin-bottom:3px; }
.rvprompt { display:flex; align-items:center; justify-content:center; min-height:320px; }
.rvbigprompt { text-align:center; padding:30px 0; }
.rvbigprompt .label { font-family:var(--mono); font-size:12px; letter-spacing:3px; color:var(--brand); font-weight:600; }
.rvbigprompt h2 { font-size:40px !important; margin:10px 0 8px; justify-content:center; letter-spacing:-.02em; }
.rvbigprompt p { font-size:16px; max-width:560px; margin:0 auto; }
.rvtable td, .rvtable th { padding:10px 12px; }
.rvgoal { background:var(--bg-deep); border:1px solid var(--line-hi); border-radius:var(--r-md);
  padding:16px 18px; font-size:17px; font-weight:700; margin-top:8px; }
.rvgoal-met { color:var(--green); border-color:color-mix(in srgb, var(--green) 40%, transparent); text-shadow:0 0 14px color-mix(in srgb, var(--green) 45%, transparent); }

/* MY GOAL dashboard card */
.mygoal { border-color:color-mix(in srgb, var(--brand) 35%, var(--line)); }
.mygoal .label { font-family:var(--mono); font-size:11.5px; letter-spacing:2px; color:var(--brand); font-weight:600; }
.mygoal .g { font-family:var(--disp); font-size:18px; font-weight:700; margin-top:6px; line-height:1.45; }
.mygoal .g.met { color:var(--green); text-shadow:0 0 14px color-mix(in srgb, var(--green) 50%, transparent); }

/* Upgraded filter pills (app-wide: schedule, deliverables, messages tabs, teacher dash) */
.pillrow-lg .fchip { font-size:14px; padding:8px 20px; border-width:2px;
  border-color:color-mix(in srgb, var(--dim) 55%, transparent); }
.pillrow-lg { margin-bottom:20px; gap:12px; }

/* ---- Step 14: split bubbles ---- */
.bubrow { position:fixed; right:20px; bottom:20px; display:flex; flex-direction:row-reverse; gap:12px; z-index:900; }
/* `.bubrow-trk` dropped from these two selectors 2026-08-02 with the tracker bubble itself — see
   app/ui.php's removal note. `.bubrow` is the remaining two-bubble row (messages + help queue). */
.bubrow .bubble { position:static; background:var(--panel); color:var(--brand);
  border:1.5px solid color-mix(in srgb, var(--brand) 60%, transparent);
  box-shadow:0 0 14px color-mix(in srgb, var(--brand) 30%, transparent), 0 4px 18px rgba(0,0,0,.45); }
.bubrow .bubble:hover { transform:scale(1.06);
  box-shadow:0 0 22px color-mix(in srgb, var(--brand) 45%, transparent), 0 4px 18px rgba(0,0,0,.5); }
/* 🔴 REGRESSION FIX 2026-08-02. The bubble icons were wrapped in a real <button class="bubble-trigger">
   for accessibility (commit ef47607) and NO CSS was ever added for it, so all three bubbles — help
   queue, messages and the tracker — rendered the browser's DEFAULT button chrome: an opaque pale box
   with a border and padding, sitting on top of the dark circle. Her description was exact: "a symbol
   on a white square background on a dark circle looking like pasted in pictures."
   This resets the button back to invisible chrome so the SVG once again inherits .bubble's
   color:var(--brand), sits centred, and picks up the glow from the rule below. */
.bubble-trigger { background:none; border:0; padding:0; margin:0; font:inherit; color:inherit;
  cursor:pointer; display:flex; align-items:center; justify-content:center;
  width:100%; height:100%; -webkit-appearance:none; appearance:none; }
.bubble-trigger:focus-visible { outline:2px solid var(--brand); outline-offset:2px; border-radius:50%; }
.bubble svg { width:26px; height:26px; filter:drop-shadow(0 0 5px color-mix(in srgb, var(--brand) 65%, transparent)); }
.bubnum { position:absolute; top:-5px; right:-5px; min-width:20px; height:20px; padding:0 5px; border-radius:10px;
  background:var(--navdot-bg); color:#fff; font-family:var(--mono); font-size:11px; font-weight:800;
  display:inline-flex; align-items:center; justify-content:center; border:2px solid var(--bg-deep); }
.bubrow .bubble { position:relative; }
.bubpanel { width:415px; max-height:70vh; padding:16px; font-size:14.5px;
  border:1.5px solid color-mix(in srgb, var(--brand) 45%, var(--line-hi));
  box-shadow:0 0 26px color-mix(in srgb, var(--brand) 22%, transparent), 0 -8px 34px rgba(0,0,0,.5); }
.bubpanel .bubcard { font-size:14.5px; padding:12px 14px; }
/* Bigger + bolder [her call 2026-08-02] — these panel titles were reading too quietly now that the
   panels themselves are stripped back and there is little else in them. 12 -> 14px, 600 -> 700.
   ⚠ APPROVE OR ADJUST: she asked for "bigger/bolder" without numbers, so the exact values are mine.
   Kept the mono face and the letter-spacing, which are the app's established section-label voice. */
.bubhead { font-family:var(--mono); font-size:14px; letter-spacing:2.5px; color:var(--brand); font-weight:700;
  text-align:center; padding:2px 0 12px; }
.bubbig { width:100%; margin-top:12px; }
.bubqueue { max-height:38vh; overflow-y:auto; }
.bubrcpt { position:relative; padding-right:34px; }
/* Measured 21x19px in Chromium on the Teacher Dashboard's alert rows, under WCAG 2.5.8's 24x24
   minimum [fixed 2026-08-07]. The glyph size is untouched — `min-width`/`min-height` grow only the hit
   box, and the flex centring keeps the ✕ where it already was. The phone override further down was
   already comfortable; this is the desktop half that never got the same treatment. */
.bubx { position:absolute; top:8px; right:8px; background:none; border:0; color:var(--dim); cursor:pointer;
  font-size:13px; padding:2px 5px; border-radius:var(--r-sm);
  min-width:24px; min-height:24px; display:inline-flex; align-items:center; justify-content:center; }
.bubx:hover { color:var(--text); background:var(--panel-hi); }
/* `.bubpanel-trk`, `.trk-third`, `.trk-solo`, `.trkwide`, `.trkhead` and `.trksub` were deleted here
   2026-08-02 with the tracker bubble — nothing else ever used them. `.trkgrid` / `.trkname` /
   `.trkblk` / `.trk2*` STAY: teacher/classes.php's Track Submissions, teacher/meetings.php's chips
   and the student Meetings card all still render them. */
.trkgrid { display:grid; grid-template-columns:1fr max-content; gap:8px 16px; align-items:center;
  justify-content:start; padding:8px 2px 12px; }
.trkname { font-size:14px; font-weight:600; white-space:nowrap; }
.trk2 { width:15px; height:15px; border-radius:50%; display:inline-block; }
.trk2-g { background:var(--green); box-shadow:0 0 10px color-mix(in srgb, var(--green) 75%, transparent); }
.trk2-r { background:var(--bad); box-shadow:0 0 10px color-mix(in srgb, var(--bad) 70%, transparent); }
.trk2-y { background:var(--amber); box-shadow:0 0 9px color-mix(in srgb, var(--amber) 65%, transparent); }
.trk2-grey { background:var(--dim); opacity:.55; }

/* Centered-header dialogs (Get Help / Send a Message / Report an absence / Mark Ready ...) */
.dlgc { text-align:center; font-size:20px !important; padding-bottom:6px; }
.dlg-cn { min-width:400px; }
.dlgbig { font-size:14px; color:var(--text); }
.dlg-cn input[type=radio] { transform:scale(1.15); margin-right:4px; }

/* ---- REMOVAL OPTIONS: the roster's two-step remove/move/merge dialog [2026-08-03] ----
   Blessed mockup: HTMLSnapshots/PROPOSAL-remove-student-three-options.html. Four new pieces; the
   rest of the dialog reuses what already exists (.dlg-cn, .dlgc, .dialog-actions, .btn/.btn-ghost/
   .btn-sm/.btn-red, and the sitewide bare `select` rule at line 546 which already strips the native
   appearance and draws the chevron).

   ⚠ EVERY COLOUR IS AN EXISTING THEME VARIABLE, nothing hard-coded, so this inherits all 15 themes
   with no per-theme work. The mockup inlines an approximation of these ONLY because it is a
   standalone file; its :root block is deliberately not copied.

   ⚠ SCOPED UNDER .rmdlg, deliberately. The design names these .x / .opt / .rows / .row / .mk, and
   those are kept, but as bare globals in a 2000-line stylesheet `.row` and `.x` are landmines for
   whatever gets written next. Scoping matches what this file already does with equally generic names
   (.idblock .who, .impbig .k, .sprintrail h2 .dot) and changes nothing visually. */
.rmdlg { position:relative; width:min(520px, 92vw); }
/* 🔴 `.rmdlg .x` AND `.rmdlg .who` WERE DELETED HERE [2026-09-07, chat 12], not lost.
   The bespoke circled ✕ ("no Cancel competes with the primary button") is now the strip's own
   `.ci-icon.dlg-close`, and the centred "<name> · <team>" line -- which every one of the four
   steps carried its own identical copy of -- is now the strip caption, said once above all of
   them. Both selectors matched nothing after that, and this file's own rule over
   `.rep-crow[hidden]` is that a live-looking rule which cannot fire is what the next reader
   wastes a session on.
   ⚠ `.rmdlg` KEEPS `position:relative` above. It is not left over from the absolute ✕: the
     `.opt` cards hide their real radios with `position:absolute`, so the containing block is
     still needed. Checked before deleting, because "the ✕ is gone so the relative can go too"
     is exactly the plausible next tidy-up. */
/* an option card. .sel is the chosen one; .danger.sel swaps brand for red */
.rmdlg .opt {
  display:flex; gap:11px; align-items:flex-start; border:1px solid var(--line); border-radius:8px;
  padding:12px 13px; margin:0 0 9px; cursor:pointer; background:color-mix(in srgb, var(--text) 1.5%, transparent);
}
.rmdlg .opt:hover { border-color:var(--line-hi); }
.rmdlg .opt.sel { border-color:var(--brand); box-shadow:0 0 0 3px var(--brand-soft); }
.rmdlg .opt.danger.sel { border-color:var(--red); box-shadow:0 0 0 3px var(--red-soft); }
.rmdlg .opt.off { opacity:.45; cursor:not-allowed; }
.rmdlg .opt input[type=radio] { position:absolute; opacity:0; pointer-events:none; }
.rmdlg .dot { width:15px; height:15px; border-radius:50%; border:2px solid var(--line-hi); margin-top:2px; flex:0 0 auto; }
.rmdlg .opt.sel .dot { border-color:var(--brand); background:radial-gradient(circle, var(--brand) 0 45%, transparent 46%); }
.rmdlg .opt.danger.sel .dot { border-color:var(--red); background:radial-gradient(circle, var(--red) 0 45%, transparent 46%); }
.rmdlg .lab { font-weight:700; font-size:14px; }
/* 🔴 COMPUTED [2026-08-17, Teacher Teams audit]. `.rmdlg .opt` fills itself with a 1.5% `--text` wash
   to lift the option card off the dialog, which lightens the background just enough to eat `--dim`'s
   margin: measured 4.37 in summersorbet and 4.48 in sunsettide against 4.5:1. This is the option
   DESCRIPTION in a three-way destructive chooser, so it is real text that has to be readable.
   96% is the largest `--dim` share that clears 4.5:1 in all 15 themes; worst after is 4.53. The card
   itself is not touched, so the wash she approved still paints.
   ⚠ WORTH KNOWING, NOT FIXED HERE: plain `--dim` on `--panel-hi` is 4.55 at worst app-wide, i.e.
   every `dialog label`, `.bdlgsub` and `.rmdlg .fl` in the app sits 0.05 above AA. That is a
   token-level question, not a Teams-page one. */
.rmdlg .desc { color:color-mix(in srgb, var(--dim) 96%, var(--text)); font-size:12.7px; margin-top:2px; }
.rmdlg .fl {
  display:block; font-size:12px; color:var(--dim); margin:0 0 5px;
  letter-spacing:.03em; text-transform:uppercase;
}
/* the consequence list: a fixed marker column and a flexible text column, one sentence per row */
.rmdlg .rows { margin:16px 0 0; border-top:1px solid var(--line); }
.rmdlg .row {
  display:flex; gap:10px; padding:9px 0; border-bottom:1px solid var(--line);
  font-size:13.3px; align-items:flex-start;
}
.rmdlg .mk { flex:0 0 auto; width:17px; text-align:center; font-weight:800; line-height:1.5; }
.rmdlg .mk.k { color:var(--green); }   /* keeps  */
/* COMPUTED, same house method as `.circbtn-red` directly above [2026-08-17]: the ✕ is 13.3px at
   weight 800 — normal text for WCAG — and raw `--red` on `--panel-hi` measured 4.12 in bubblegum.
   87% is the largest share clearing 4.5:1 in all 15 themes; worst after is 4.53. `.mk.a` (--amber,
   worst 5.53) and `.mk.k` (--green, worst 5.61) were measured and need no change. */
.rmdlg .mk.l { color:color-mix(in srgb, var(--red) 87%, var(--text)); }   /* loses */
.rmdlg .mk.s { color:var(--dim); }     /* stays behind */
.rmdlg .mk.a { color:var(--amber); }   /* heads up */
.rmdlg .rv { flex:1; }
.rmdlg .rv b { color:var(--text); }
/* BACK bottom-left, CONFIRM bottom-right */
.rmdlg .rmacts { justify-content:space-between; }

/* ---- Single-click deck launcher host [2026-08-02] ----
   The container that gets put into fullscreen BEFORE the deck is fetched into it (see
   app/ui.php's launchDeck). It must paint the deck background itself: between requestFullscreen()
   resolving and the fetch landing there is a real, visible moment with nothing in it, and a
   transparent fullscreen element shows as a white flash on most browsers.
   `:fullscreen` rather than a fixed size — outside fullscreen it is display:none anyway, and this
   way it inherits whatever the browser gives it instead of fighting it. */
.deckhost { background:var(--bg-deep); }
.deckhost:fullscreen { width:100%; height:100%; overflow:hidden; background:var(--bg-deep); }
/* "Like it came out of the button and then folds back into it" [her words, 2026-08-02]. The OPEN
   half is the browser's own fullscreen transition, which we cannot restyle; this is the CLOSE half,
   a short fold-and-fade so the deck recedes instead of vanishing. Kept to 160ms — the teardown
   empties the host at 180ms, so the animation must finish first. */
.deckhost-closing { animation:deckfold .16s ease-in forwards; }
@keyframes deckfold { to { opacity:0; transform:scale(.96); } }
@media (prefers-reduced-motion: reduce) { .deckhost-closing { animation:none; opacity:0; } }
/* The injected deck/cover are position:fixed; inside a fullscreen element that resolves against the
   fullscreen box, which is what we want — but the host must not also scroll behind them. */
body.deckhosted { overflow:hidden; }

/* ---- Step 14: retrospective presentation deck ---- */
.deck { position:fixed; inset:0; z-index:1000; background:var(--bg-deep); display:flex; flex-direction:column;
  background-image:radial-gradient(ellipse 70% 55% at 50% -10%, color-mix(in srgb, var(--brand) 9%, transparent), transparent); }
.deckbar { display:flex; align-items:center; gap:12px; padding:14px 22px; border-bottom:1px solid var(--line); }
.decklogo { font-family:var(--mono); font-size:12.5px; letter-spacing:2px; color:var(--dim); font-weight:600; }
.decklogo .dot { color:var(--brand); }
.decktimer { font-family:var(--mono); font-size:15px; font-weight:700; color:var(--dim); letter-spacing:1px; }
.decktimer.meetover { color:var(--red); }
.deckbtn { background:var(--panel); border:1px solid var(--line-hi); color:var(--dim); border-radius:var(--r-sm);
  width:34px; height:34px; display:inline-flex; align-items:center; justify-content:center; cursor:pointer;
  font-size:15px; text-decoration:none; }
.deckbtn:hover { color:var(--text); border-color:var(--brand); }
/* 🔴 scrollbar-gutter:stable — HER "a bar flashes down the right edge on every Next click"
   [reported 2026-08-25, mechanism measured the same night]. This is the ONLY scrolling element in
   the deck, and whether it scrolls depends on how tall the CURRENT slide is: measured on the retro
   fixture at a 380px-tall viewport, slides 6 and 7 overflowed (438/317 and 356/317) while 1-5 and 8
   did not. So the scrollbar appears on one slide and is gone on the next, which is exactly a bar
   flashing on every advance. Her class has more teams and more goals per slide than the one-team
   fixture, so it happens at ordinary heights for her and not at 900px here.
   ⚠ Worse than a flicker on Windows: headless Chromium uses OVERLAY scrollbars, so the measured
   gutter stayed 0 and the content did not move. Her Chrome uses classic scrollbars, which take
   ~15px out of the content box — so the slide also SHIFTS sideways as the bar comes and goes.
   ⚠ SHE IS RIGHT THAT THIS IS THE SYMPTOM, NOT THE CAUSE [2026-08-25]: *"it's kind of weird to
   have a scrollbar at all on a full screen presentation. The content should scale to fit."* A
   scale-to-fit was built and REVERTED the same night, and the reason is recorded in BUILD_PLAN so
   the next attempt does not rediscover it: #meetform holds a 71px timer ABOVE a `flex:1` slide, so
   its scrollHeight reads 1088 before the flex resolves and 1017 after, and a fit that measures the
   wrong one shrinks slides that fit perfectly well. Reserving the gutter is the safe half. */
.deckbody { flex:1; overflow-y:auto; scrollbar-gutter:stable; display:flex; align-items:stretch; }
.slide { flex:1; display:flex; flex-direction:column; align-items:center; justify-content:center;
  padding:40px 60px 90px; max-width:1080px; margin:0 auto; width:100%; text-align:center; }
.slide-on { animation:slidein .28s ease-out; }
@keyframes slidein { from { opacity:0; transform:translateY(10px) } to { opacity:1; transform:none } }
/* 🔴 WAS `color:var(--brand)` [fixed 2026-08-15, Teacher Meetings audit]. Measured against each
   theme's own `--bg-deep` (the deck's background), plain `--brand` failed WCAG AA in 3 of 16:
   catalystcats 3.84 — the DEFAULT theme — ultraviolet 3.62, pumpkinspice 3.65. It is 13px at
   weight 600, so 4.5:1 applies (large-text 3.0 needs 18.66px bold or 24px). This one matters more
   than most: the deck renders FULL SCREEN ON A PROJECTOR, where the kicker is the only thing naming
   which slide the room is on.
   Fixed the same way `.b-info`, `.b-pink` and `.b-aday`/`.b-bday` were — lighten toward the theme's
   own `--text` rather than move the token, which keeps the hue reading as that theme's brand.
   **83% is COMPUTED, not chosen:** the largest `--brand` share where all 16 themes clear 4.5:1
   (84% left pumpkinspice at 4.49). Worst case is now 4.57. */
.slidekicker { font-family:var(--mono); font-size:13px; letter-spacing:3.5px;
  color:color-mix(in srgb, var(--brand) 83%, var(--text)); font-weight:600; }
.slidetitle { font-family:var(--disp); font-size:44px; font-weight:800; letter-spacing:-.02em; margin:14px 0 6px; line-height:1.1; }
.slidetitle-xl { font-size:64px; }
.slidesub { font-size:17px; color:var(--dim); max-width:640px; margin:6px auto 0; }
.slide-prompt .slidesub { font-size:19px; }

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   NOMINATION FLIP CARDS — THE MVP WALL, SIZED FOR A PROJECTOR AND NOT FOR A LAPTOP
   🔴 REBUILT 2026-09-08, her report after running MVPs in front of a class: *"the cards were too
   small and the text was too small. I want them bigger and louder and way easier to read spread
   across the screen."*
   WHAT WAS ACTUALLY WRONG, measured against the old values rather than guessed:
     · `.nomcard` was a FIXED 230x170 and `.nomquote` was **13px**. 13px is a comfortable caption on
       a laptop 60cm away and it is unreadable from the back of a classroom — the quotes are the
       whole point of the slide, and they were the smallest text on it.
     · `.slide` caps content at **1080px**. On a 1920px projector that leaves 44% of the screen
       empty, which is the "spread across the screen" half of her sentence: three nominees sat in a
       huddle in the middle with a third of the wall unused on either side.
     · A flex row of fixed-width cards cannot use the space it is given. The cards now come from a
       GRID whose columns divide the real width (`1fr`), so a wall of three fills the screen with
       three big cards and a wall of twelve fills it with twelve smaller ones — the count decides,
       which is what `$nomtier9` in public/meeting.php picks the tier for.
   ⚠ EVERY SIZE IS clamp(min, vw, max) — NOT a fixed px, and not a bare `vw`. Bare `vw` on a 4K
     projector would be comic and on a 1280x720 hall display would be too small; the floor is what
     the old design measured plus a margin, and the ceiling is where a name stops being a name and
     becomes a banner. `vw` and not `vh`, because a slide's width is what varies between the four
     screens this runs on (a laptop lid, a 1080p projector, a 4K TV, an iPad mirrored).
   ⚠ THE CARD STAYS A FLIP CARD. `.nomface` is `position:absolute; inset:0`, so the card's own box
     is what gives it height — hence `min-height` on `.nomcard` rather than height on the faces, and
     hence the aspect-ratio floor: an absolutely-positioned face cannot push its parent taller, so a
     long quote in a short card would clip. That is why `min-height` grows with the tier too.
   ⚠ AND IT STILL FITS: deckFit() (public/meeting.php) scales any slide that would overflow, which
     is what makes a deliberately big default safe for a 25-nominee company wall. Without it, this
     change would trade her too-small complaint for a scrollbar, which she ruled against on
     2026-08-25 (*"Powerpoints don't have scroll bars"*).
   ══════════════════════════════════════════════════════════════════════════════════════════════ */
/* 🔴 THE COLUMN COUNT COMES FROM PHP (`--nomcols`, set by $nomwall9 in public/meeting.php), NOT
   FROM auto-fit. The first draft was `repeat(auto-fit, minmax(...))` and it put THREE nominees in
   two columns with an orphan underneath at 1920px — 3 x 560 + 2 gaps = 1740 against a 1723px
   content box, seventeen pixels. The server already knows how many cards there are, so the layout
   is told rather than guessed; see that helper's note for the per-count choices (4 is 2x2).
   `minmax(0, 1fr)` and not `1fr`: a long unbroken quote would otherwise blow a track out past its
   share, because `1fr` means `minmax(auto, 1fr)` and `auto` floors at the content's min size. */
.nomgrid { display:grid; grid-template-columns:repeat(var(--nomcols, 3), minmax(0, 1fr));
  justify-items:center; align-items:stretch;
  gap:clamp(16px, 1.6vw, 30px); margin-top:clamp(22px, 2.4vh, 40px); width:100%; }
.nomcard { display:grid; width:100%; max-width:clamp(300px, 26vw, 520px);
  min-height:clamp(190px, 15vw, 260px); perspective:1200px; cursor:pointer; }
/* A CAP ON THE CARD, not just on the row [the same trick `.cpgrid` documents for My Classes]: one
   or two nominees divide the whole wall between them, and without this a single card would render
   as a 1700px letterbox with a name floating in the middle of it. Capped and centred in its own
   track instead, so a wall of two reads as two cards rather than as two panels. */
.nomgrid-lg .nomcard { max-width:clamp(320px, 30vw, 580px); }
.nomgrid-xl .nomcard { max-width:clamp(340px, 34vw, 660px); }
.nomgrid-lg .nomcard { min-height:clamp(210px, 17vw, 300px); }
.nomgrid-xl .nomcard { min-height:clamp(230px, 19vw, 340px); }
.nomaward { min-height:clamp(230px, 18vw, 320px); }
.nomgrid-lg .nomaward, .nomgrid-xl .nomaward { min-height:clamp(260px, 21vw, 380px); }
/* 13+ CARDS: A WHOLE COMPANY ON ONE WALL. Denser by design — see $nomwall9's note for the
   measurement that forced it (16 cards wanted 1485px of a 1017px screen and pinned deckFit() to its
   floor). Everything here is still bigger than the design this replaces. */
.nomgrid-sm .nomcard { max-width:clamp(230px, 20vw, 380px); min-height:clamp(150px, 11vw, 210px); }
/* ONE ROW ⇒ TAKE THE HEIGHT NOBODY ELSE IS USING [see $nomwall9's note: measured 340px of an 887px
   content box, centred, with ~270px dead above and below]. `vh` here and not `vw`, deliberately —
   this is the one measurement on the wall that is about the space left over vertically.
   ⚠ IT MUST COME AFTER THE TIER HEIGHTS ABOVE. `.nomgrid-1row .nomcard` and `.nomgrid-xl .nomcard`
     are both (0,2,0), so source order is the whole tiebreak — written above them, this rule loses
     and the boost silently does nothing. Measured exactly that way first: 340px with the class on
     the element and applying to nothing. */
.nomgrid-1row .nomcard, .nomgrid-1row .nomaward { min-height:clamp(240px, 42vh, 520px); }
/* 🔴 THE TWO FACES ARE GRID-STACKED, NOT ABSOLUTELY POSITIONED [changed 2026-09-08, and it fixes a
   defect that predates the resize]. They were `position:absolute; inset:0`, which means neither face
   can make the card taller — so a quote longer than `min-height` PAINTED STRAIGHT THROUGH the card's
   own rounded border and, being scrollable overflow, quietly added height to the SLIDE. Measured on
   a 13-card wall: 1017px before the cards were flipped and 1033px after, i.e. flipping the last card
   put a scrollbar on a slide that had just been fitted.
   `grid-area:1/1` puts both faces in the SAME grid cell, in flow: the taller of the two sizes the
   card, the shorter stretches to match, and the flip is unchanged because it only ever needed
   `backface-visibility` and a `transform`. This is the modern flip-card idiom for exactly this
   reason. `min-height` on `.nomcard` is now a FLOOR rather than the card's whole height. */
.nomface { grid-area:1/1; backface-visibility:hidden; border-radius:var(--r-lg);
  display:flex; flex-direction:column; align-items:center; justify-content:center;
  gap:clamp(8px, .9vw, 16px); padding:clamp(18px, 1.8vw, 34px);
  transition:transform .55s cubic-bezier(.2,.7,.25,1); }
/* LOUDER: 2px of border and a wider glow, so the card reads as an object from the back of the room
   rather than as a slightly lighter rectangle. Both faces gain it — the back is the one on screen
   while the room is guessing, and it was the quieter of the two. */
.nomback { background:var(--panel); border:2px solid color-mix(in srgb, var(--brand) 55%, var(--line-hi));
  box-shadow:0 0 30px color-mix(in srgb, var(--brand) 26%, transparent); }
.nomback span { font-family:var(--disp); font-weight:800; color:var(--brand);
  font-size:clamp(56px, 6vw, 110px); line-height:1;
  text-shadow:0 0 30px color-mix(in srgb, var(--brand) 60%, transparent); }
.nomfront { background:var(--panel); border:2px solid var(--line-hi); transform:rotateY(180deg); }
.nomcard.flipped .nomback { transform:rotateY(180deg); }
.nomcard.flipped .nomfront { transform:rotateY(360deg); }
.nomcard.flipped { cursor:default; }
.nomaward .nomfront { border-color:color-mix(in srgb, var(--amber) 62%, transparent);
  box-shadow:0 0 38px rgba(242,185,92,.34); }
/* `overflow-wrap:anywhere` is the guard the old 22px name never needed: at 67px a long single-token
   name ("Konstantinopoulos") is wider than its own card, and a flip card cannot scroll — the face is
   `position:absolute`, so anything past the edge is simply gone. It breaks rather than disappears. */
.nomname { font-family:var(--disp); font-weight:800; line-height:1.1; letter-spacing:-.01em;
  font-size:clamp(28px, 2.5vw, 52px); max-width:100%; overflow-wrap:anywhere; text-wrap:balance; }
.nomgrid-xl .nomname { font-size:clamp(32px, 3.1vw, 62px); }
/* 🔴 THE QUOTE IS THE SLIDE, so it gets read-from-the-back size and a colour ABOVE `--dim`
   [her "louder"]. `--dim` is this app's caption grey; mixed 55% toward `--text` it keeps the
   quieter-than-the-name relationship the design has always had while clearing the projector, and
   the direction is safe in all 16 themes by construction — `--text` IS each theme's readable
   foreground on `--panel`, so mixing toward it can only raise contrast.
   The italic stays: it is what marks these as somebody's words rather than the app's. */
.nomquote { font-size:clamp(17px, 1.45vw, 30px); line-height:1.45; font-style:italic;
  color:color-mix(in srgb, var(--dim) 45%, var(--text)); max-width:34ch; text-wrap:balance; }
.nomgrid-xl .nomquote { font-size:clamp(19px, 1.7vw, 34px); }
/* The single-row wall's type, sized to FILL the taller card rather than float in the middle of it —
   the two go together and changing one without the other is what left the first capture looking
   like a big empty card. This is the state her own classes hit most often (a team nominates two or
   three), so it is the one worth tuning by eye. */
.nomgrid-1row .nomname { font-size:clamp(34px, 3.5vw, 74px); }
.nomgrid-1row .nomquote { font-size:clamp(21px, 1.95vw, 40px); max-width:30ch; }
/* The dense wall's own type. The quote floor here is the tier's whole reason for existing and it is
   still 60% up on the 13px it replaces; below about 20px a projected quote stops being readable from
   the back and the wall would be decoration. */
.nomgrid-sm .nomname { font-size:clamp(22px, 1.85vw, 38px); }
.nomgrid-sm .nomquote { font-size:clamp(15px, 1.15vw, 23px); line-height:1.4; max-width:28ch; }
.nomtrophy { font-size:clamp(46px, 4.4vw, 92px); line-height:1; }
/* 🔴 THE CARD WALL IS THE ONE SLIDE THAT IS NOT CAPPED AT 1080px, and the cap is otherwise right:
   `.slide`'s max-width is a READING measure (a 44px title and a 17px subtitle need a line length,
   not a screen), which is why this is a per-slide class from PHP and not a change to `.slide`.
   96vw rather than 100% so the wall still has a margin on either side and does not read as bleeding
   off the edges of the projection; 2200px is the ceiling where a 4K TV would otherwise stretch four
   cards to half a metre each. */
.slide-wall { max-width:min(2200px, 96vw); }

/* Scoreboard */
.scoreboard { display:flex; flex-wrap:wrap; gap:18px; justify-content:center; margin-top:30px; }
.score { background:var(--panel); border:1px solid var(--line-hi); border-radius:var(--r-lg);
  padding:24px 32px 20px; min-width:170px; }
.score b { display:block; font-family:var(--disp); font-size:52px; font-weight:800; letter-spacing:-.03em; line-height:1; }
.score b i { font-style:normal; font-size:26px; color:var(--dim); }
.score span { display:block; font-size:13px; color:var(--dim); font-weight:700; margin-top:8px; }
.score.big-ok b { color:var(--green); text-shadow:0 0 18px color-mix(in srgb, var(--green) 40%, transparent); }
.score.big-warn b { color:var(--amber); text-shadow:0 0 18px rgba(242,185,92,.4); }
.score.big-bad b { color:var(--bad); text-shadow:0 0 18px color-mix(in srgb, var(--bad) 40%, transparent); }

/* Big pacing gauge */
.pacegaugebig { margin:30px auto 0; max-width:560px; width:100%; }
.pacegaugebig .pgtag { font-family:var(--mono); font-size:16px; font-weight:800; letter-spacing:1.5px; margin-bottom:12px; }
.pacegaugebig.sev-ok .pgtag { color:var(--green); }
.pacegaugebig.sev-warn .pgtag { color:var(--amber); }
.pacegaugebig.sev-bad .pgtag { color:var(--bad); }
.pgbar { position:relative; height:12px; background:var(--panel); border:1px solid var(--line-hi); border-radius:7px; }
.pgbar em { position:absolute; left:50%; top:-5px; bottom:-5px; width:2px; background:var(--dim); opacity:.6; }
.pgbar i { position:absolute; top:50%; transform:translate(-50%,-50%); width:18px; height:18px; border-radius:50%;
  background:var(--text); box-shadow:0 0 12px rgba(255,255,255,.35); }
.sev-ok .pgbar i { background:var(--green); box-shadow:0 0 14px color-mix(in srgb, var(--green) 70%, transparent); }
.sev-warn .pgbar i { background:var(--amber); box-shadow:0 0 14px rgba(242,185,92,.7); }
.sev-bad .pgbar i { background:var(--bad); box-shadow:0 0 14px color-mix(in srgb, var(--bad) 70%, transparent); }
.pgsub { font-size:13px; color:var(--dim); margin-top:12px; }
.shoutout { margin-top:30px; font-size:19px; background:var(--panel); border:1px solid var(--line-hi);
  border-radius:var(--r-lg); padding:16px 26px; display:inline-block; }

/* Met-goal celebration wall */
.metwall { background:color-mix(in srgb, var(--green) 7%, var(--panel)); border:1.5px solid color-mix(in srgb, var(--green) 45%, transparent);
  border-radius:var(--r-lg); padding:22px 28px; margin:24px auto 8px; max-width:720px; width:100%;
  box-shadow:0 0 26px color-mix(in srgb, var(--green) 16%, transparent); }
.metkicker { font-family:var(--mono); font-size:11.5px; letter-spacing:2.5px; color:var(--green); font-weight:700; }
.metgoal { font-family:var(--disp); font-size:24px; font-weight:800; color:var(--green); margin-top:10px;
  text-shadow:0 0 16px color-mix(in srgb, var(--green) 50%, transparent); }
.metnote { font-size:13px; color:var(--dim); margin-top:12px; }

/* Goal decision cards */
.goalcard { background:var(--panel); border:1px solid var(--line-hi); border-radius:var(--r-md);
  padding:16px 20px; margin:14px auto 0; max-width:720px; width:100%; text-align:left; }
.goalcard-red { border-left:3px solid var(--red); }
.gctitle { font-size:16px; font-weight:700; }
.gcacts { display:flex; flex-wrap:wrap; gap:8px 20px; margin:10px 0 8px; }
.gcacts label { font-weight:400; font-size:13px; margin:0; white-space:nowrap; }
.goalcard input[type=text] { width:100%; }
.newgoals { margin:26px auto 0; max-width:720px; width:100%; text-align:left; }
.ngkicker { font-family:var(--mono); font-size:11.5px; letter-spacing:2px; color:var(--dim); font-weight:700; margin-bottom:8px; }
.newgoals [data-ngrows] input { width:100%; margin-top:8px; }
/* 🔴 THE JAM POST-MORTEM'S ONE DISCUSSION SLIDE [her ruling 2026-08-26]. Three prompts that used to
   be three full-screen slides now share one, so each needs to read as its own block rather than as a
   run of paragraphs. Same surface tokens as `.goalcard` above — `--panel` on `--line-hi` at
   `--r-md`, left-aligned inside a centred slide — because that combination is already proven on this
   deck in all 16 themes and inventing a second card look for one slide would be a second thing to
   keep in sync. Slightly wider (760 vs 720) and slightly tighter vertically, because there are three
   of them on a projector at once and the deck's own fit-to-screen has no headroom to give.
   `--dim` on `--panel` is the pairing the board's own card sub-text uses; the size stays at 16px,
   which is below the large-text threshold either way, so the 4.5:1 the token already meets applies
   unchanged. */
.pmprompt { background:var(--panel); border:1px solid var(--line-hi); border-radius:var(--r-md);
  padding:14px 20px; margin:14px auto 0; max-width:760px; width:100%; text-align:left; }
.pmprompt-t { font-family:var(--disp); font-size:22px; font-weight:800; letter-spacing:-.01em; line-height:1.2; }
.pmprompt-s { font-size:16px; color:var(--dim); margin-top:5px; }
@media (max-width:720px) {
  .pmprompt { padding:12px 15px; }
  .pmprompt-t { font-size:18px; }
  .pmprompt-s { font-size:14.5px; }
}

/* Deck navigation */
.decknav { position:fixed; bottom:16px; left:50%; transform:translateX(-50%); z-index:1002;
  display:flex; align-items:center; gap:14px; }
.decknavbtn { width:42px; height:42px; font-size:16px; }

/* Confetti */
.confetti { position:fixed; z-index:1005; width:9px; height:13px; border-radius:2px; pointer-events:none;
  animation:confall 1.7s cubic-bezier(.2,.6,.4,1) forwards; opacity:0; }
@keyframes confall {
  0% { opacity:1; transform:translate(0,0) rotate(0); }
  35% { opacity:1; transform:translate(calc(var(--dx) * .6), var(--dy)) rotate(calc(var(--rot) * .5)); }
  100% { opacity:0; transform:translate(var(--dx), 360px) rotate(var(--rot)); }
}

/* ---- Step 14: Meetings page ---- */
.secthead { font-family:var(--mono); font-size:12.5px; letter-spacing:2px; color:var(--dim); font-weight:700;
  text-transform:uppercase; margin:20px 0 10px; }
h2 + .secthead { margin-top:12px; }
.meetcard { background:var(--bg-deep); border:1px solid var(--line); border-radius:var(--r-md);
  padding:14px 18px; margin-bottom:10px; }
.mchead { display:flex; align-items:center; gap:10px; flex-wrap:wrap; }
.mcname { font-family:var(--disp); font-size:18px; font-weight:800; }
.mcchips { display:flex; flex-wrap:wrap; gap:8px 16px; margin-top:8px; }
.mcchip { display:inline-flex; align-items:center; gap:6px; }
.mcchair { font-size:13px; color:var(--dim); margin-top:8px; display:flex; align-items:center; gap:8px; }
.mcchair b { color:var(--text); }
.mcmvp { font-size:13px; color:var(--text); margin-top:8px; font-weight:600; }
.mcunsub { margin-top:6px; }
/* `min-height` measured, not guessed [2026-08-15, Teacher Meetings audit]: with `padding:0` the
   button's box is just the 11px dot plus a 13px `.dim small` label, which rendered **117 x 20.1px**
   in Chromium on class 600's in-progress retro — under WCAG 2.2 SC 2.5.8's 24x24 (AA). Same fix and
   same reasoning as `.bubx` and `.card-edit` above: the glyph and the label are untouched, only the
   hit box grows, and `.mcchip`'s own `align-items:center` keeps the contents where they already were. */
.attdot { background:none; border:0; padding:0; cursor:pointer; font:inherit; color:inherit; min-height:24px; }
.attdot:hover .trk2 { outline:2px solid var(--line); outline-offset:1px; }
.attdlg { max-width:380px; }
.attdlg h3 { margin:0 0 12px; }
.attrow { display:flex; align-items:center; justify-content:space-between; gap:12px; padding:7px 0; border-bottom:1px solid var(--line); }
.attrow:last-of-type { border-bottom:0; }
.nomrow { display:flex; align-items:center; gap:14px; padding:8px 4px; border-top:1px solid var(--line); }
.nomrow:first-of-type { border-top:0; }
.nomrow .t { flex:0 0 auto; min-width:220px; }
.nomreason { flex:1; }
.nomrow-ok { opacity:.55; }

/* ---- Step 14: teacher Messages / Help Queue ---- */
.fchip { background:var(--panel); appearance:none; -webkit-appearance:none; }
/* DEAD RULES REMOVED 2026-08-14 (Phase 2 audit of teacher/messages.php + teacher/helpqueue.php).
   Each was checked against every .php/.js/.html in the repo INCLUDING HTMLSnapshots/ markup, not
   just these two pages, and appears in zero class attributes anywhere:
     .me-accent / .mr-glow — the "Me" highlight on the old `Sent to` column, deleted with that
       column in §4a-i. docs/COMMUNICATION-PAGE-DESIGN-SPEC.md:1584 already asserts `me-accent`
       is ABSENT from the body, so the rule outliving the markup was the stale half.
     .hqclass / .hqclass:first-of-type — the per-class grouping header the Help Queue lost when
       migration 60 made a block the teacher's and the queue block-scoped.
   `.dlgdivider` was checked the same way and KEPT: it is live markup in the blessed
   HTMLSnapshots/Meetings-Page-Scenarios/*.html dialogs, so it is a design source, not dead. */
.hqrow { display:flex; align-items:center; gap:12px; background:rgba(255,255,255,.02); border:1px solid var(--line);
  border-radius:9px; margin-bottom:7px; padding:10px 14px; }
.hqrow .t { flex:1; min-width:0; }
.hqbadge { flex:0 0 150px; display:flex; justify-content:flex-start; }
.hqacts { flex:0 0 168px; display:flex; align-items:center; gap:8px; justify-content:flex-end; }
.dlgdivider { text-align:center; color:var(--dimmer); letter-spacing:8px; margin:12px 0 4px; font-size:13px; }
/* .hqbadge holds EITHER a `.dcount` class-day counter or the `archived` badge, one per row, in a
   fixed 150px slot. Both were sized to their own text (measured live: "Today" 45px, "2 Class Days"
   108px, "archived" ~70px), so the column read ragged. One shared min-width, per her standing rule
   that badges in one column are all one width and the width comes from min-width. 118px covers the
   widest label the counter can produce ("14 Class Days" measures 117px) and still clears the 150px
   slot. */
.hqbadge > * { min-width:118px; }
.hqbadge .badge { text-align:center; }

/* ============ Step 14: phone shell (≤640px) ============ */
/* ════════════════════════════════════════════════════════════════════════════════════════════
   THE THREE-TIER VISIBILITY CONTRACT [MOBILE_BUILD_PLAN §1, built 2026-08-25]
   The foundational item §1 asks for, because "hide a panel that does not fit" and "remove a
   feature because you are on a phone" were being made by the SAME 640px block and they are not
   the same decision.

     TIER 1 · SLIMMING, <=640px — a wide panel that simply does not fit narrow.
              `.ph-hide`, `.desk-only-dash`, `.phonehero`, `.phone-only`, `.teachdesk`.
              A split-screen desktop user losing the sprint rail is CORRECT.
     TIER 2 · FEATURE REMOVAL, <=520px — the feature is gone because this is a phone.
              `.desk-only` (hidden on a phone) and `.truephone-only` (only on a phone), below.
              Bound to the SAME query as the wall, deliberately: a desktop user in a 600px window
              must keep every feature.
     TIER 3 · THE WALL, <=520px — unchanged.

   🔴 THE DECISION §1 ASKS FOR, MADE AND RECORDED: `.phone-only` STAYS ON 640, and tier 2 gets a
   NEW `.truephone-only` rather than `.phone-only` being moved down to 520.
   Moving it was the tidier-looking option and it is WRONG, checked rather than assumed: its only
   real use is dashboard.php's `.phonehero` Manage Leave button, and the desk fallback for that
   button (dashboard.php, the `Our Tasks Right Now` panel) is `.desk-only-dash`, which hides at
   640. So moving `.phone-only` to 520 would hide BOTH between 521 and 640 and leave Manage Leave
   — a primary phone surface — completely unreachable in exactly the dead-end band §1 warns about.
   ⚠ Anyone tempted to unify these two names later: re-read this paragraph first.
   ════════════════════════════════════════════════════════════════════════════════════════════ */
.phone-only { display:none !important; }
.truephone-only { display:none !important; }
/* The slim phone bar, its menu button and the drawer scrim exist only below 640 — see the drawer
   block in the 640 media query. Hidden by default so they cost a desktop nothing. */
.phonebar, .phonebar-btn, .navscrim { display:none; }
.phonewall { display:none; }
/* The wall blocks non-phone pages only at true phone widths — a narrow desktop window
   (side-by-side testing, split screens) gets the compact layout, not a locked door.
   🔴 AND `pointer:coarse` IS NOW PART OF THAT TEST [2026-08-16, accessibility build 4]. Width alone
   could not tell a phone apart from a ZOOMED DESKTOP, and those need opposite answers.
   THE BUG IT FIXES, measured rather than assumed: browser zoom shrinks the CSS viewport, so 400%
   zoom on a 1280px screen leaves an effective 320px — under this 520px threshold. A low-vision
   student zooming in to read was therefore shown "This page works best on a computer" and LOCKED OUT
   of the Board, the Meetings deck and everything else, while sitting at a laptop. That is the exact
   opposite of what she needs, and it is a self-inflicted wall rather than a layout limitation.
   (Note this never fired for the Windows Magnifier itself, which magnifies after layout and does not
   change the CSS viewport at all — see BUILD_PLAN's research note. This is the browser-zoom path.)
   `pointer:coarse` is true when the PRIMARY input is a finger — a real phone or tablet — and false
   for a mouse-driven desktop no matter how far it is zoomed. A touchscreen laptop with a mouse still
   reports `fine`, so it keeps the full site too, which is correct.
   ⚠ THIS IS THE ONE ITEM IN THE ACCESSIBILITY PASS THAT CHANGES WHAT SOMEBODY ELSE SEES, so it is
   the one with its own test (tests/ui/t_a11y_tips.py §3): a real phone viewport must still get
   the wall, a zoomed desktop must not, and the nav behind it must stay tappable.
   Everything else in that pass is purely additive. */
@media (max-width:520px) and (pointer:coarse) {
  /* 🔴 BELOW THE NAV, NOT ABOVE IT [her report 2026-08-29: *"even if and when we get the 'view this
     on a browser', the nav should still be accessible to change pages and it isn't."*]. At 1200 the
     wall covered the drawer (900), its scrim (880) and the menu button (930), so a walled page was a
     dead end: the one thing a student needs there is a way to somewhere else.
     ⚠ THE `inert` SCRIPT WAS NEVER THE PROBLEM and is unchanged -- it only touches `main`'s own
       children, and the nav is a sibling `<aside>`. The nav was reachable the whole time and simply
       had a fixed overlay painted on top of it.
     ⚠ 780 IS BELOW EVERY LAYER OF THE NAV, counted rather than guessed: the phone bar and its
       wordmark (790, and that wordmark is itself a link home), the drawer scrim (880), the drawer
       (900) and the menu button (930). Landing between them would leave the button floating alone
       over a blank field with its own bar painted out behind it.
     ⚠ BELOW THE SCRIM MATTERS TOO: opening the drawer now dims the wall exactly as it dims a page,
       so the drawer still reads as being on top of something rather than replacing it.
     ⚠ EVERY OTHER LAYER ABOVE 780 WAS CHECKED BEFORE MOVING THIS, because lowering a full-viewport
       overlay lets everything in between float on top of it. In this range there are only three
       groups and none of them can appear on a walled phone: the bubbles (900/901) are
       `display:none` a few rules down in THIS SAME media query, the retro deck and its confetti
       (1000-1005) live on meetings.php which is allowlisted and never walled, and the toast rail
       (1400) was already above the old 1200. Anything NEW landing between 780 and 1400 has to be
       thought about again. */
  .phonewall { display:flex; position:fixed; inset:0; z-index:780; background:var(--bg-deep);
    align-items:center; justify-content:center; padding:26px; }

  /* ---- TIER 2 · FEATURE REMOVAL [MOBILE_BUILD_PLAN §1] ----
     🔴 `and (pointer:coarse)` IS LOAD-BEARING HERE, not copied by habit. Tier 2 means "this feature
     is gone BECAUSE you are on a phone", so it must key on actually being a phone — the same test
     the wall makes. Bound to a bare `(max-width:520px)` it would also strip features from a
     MOUSE-driven desktop at 400% browser zoom (1280px zoomed = 320px CSS), which is a WCAG 1.4.10
     content-loss failure and the precise mistake §1 says getting the tier wrong produces.
     A touchscreen laptop with a mouse reports `fine` and correctly keeps everything. */
  .desk-only { display:none !important; }
  .truephone-only { display:block !important; }

  /* ---- §8 · THE BUBBLES ARE REMOVED ON A PHONE [ruled; built 2026-08-25] ----
     Her reasoning and §8's are the same one: the bubbles are a floating shortcut to Messages and
     the help queue, which are exactly the two things the nav now reaches directly AND exactly the
     two things a phone renders in reduced form. Keeping them would put a bottom-right control that
     opens a FULL-featured panel next to a nav item that opens a stripped one. They also sat on top
     of the content — visible in the 2026-08-24 phone screenshots, where both bubbles overlapped the
     Performance Reviews tab.
     ⚠ THE NAV BADGES ARE NOT AFFECTED, and §8 flags this as the thing not to miss: `bub_help_data()`
     and `notif_badge()` feed the sidebar dot as well as the bubbles, and that dot is now the only
     surviving signal on a phone. This is CSS-only and touches neither.
     ⚠ Tier 2 on purpose: a split-screen desktop keeps its bubbles, because for that user they are
     still a genuine shortcut to full-featured panels. */
  .bubrow, .bubpanel { display:none !important; }
}

@media (max-width:640px) {
  /* global overflow guard */
  dialog { max-width:calc(100vw - 20px) !important; min-width:0 !important; width:calc(100vw - 20px); }
  .authcard, .authwide { width:auto; max-width:none; margin:0 10px; }
  .dlg-lg, .dlg-xl, .dlg-cn { min-width:0 !important; }
  html, body { overflow-x:hidden; }
  /* 🔴 THE INBOX TABLE WAS BEING CLIPPED, NOT SCROLLED — REAL CONTENT LOSS [2026-08-16, reflow pass].
     `overflow-x:hidden` above is what makes the rest of the page behave at narrow widths, but a
     `<table>` sizes to its own content and will not shrink below it. Measured at a 320px viewport
     (= 1280px at 400% browser zoom, WCAG 1.4.10's own target): #msgtable rendered 869px wide,
     `document.scrollWidth` stayed at 320, `window.scrollTo(500,0)` left scrollX at 0, and the last
     data cell sat at right=704 with `visible=false`. So ~550px of the student's own inbox — the
     message body, status, flag and date columns — was not merely awkward to reach, it was
     UNREACHABLE. That is a 1.4.10 failure of the worst kind ("without loss of content"), and it is
     worse than the two-directional scrolling 1.4.10 exists to prevent.
     Giving the table its own horizontally-scrollable region is the fix W3C explicitly allows: data
     tables are named in the two-dimensional-layout exception, and the requirement there is that the
     content stays REACHABLE, not that a table become one column. Scoped to this breakpoint, so the
     desktop layout everyone else uses is untouched. */
  #inboxform { overflow-x:auto; }

  /* 🔴 THE HELP-QUEUE ROW RELEASES ITS FIXED FLEX BASES HERE [2026-08-25, the 400%-zoom reflow pass].
     `.hqrow` is a nowrap flex row whose children carry HARD bases — `.qright` is `flex:0 0 232px`,
     `.qtime` `flex:0 0 84px`, `.hqbadge` 150px, `.hqacts` 168px. Those cannot shrink, so at a 320px
     viewport (1280px at 400% zoom) the row measured 250px wide while `.qright` alone started at
     x=322 and ran to 554 — entirely outside the viewport, and CLIPPED rather than scrolled because
     of the `overflow-x:hidden` above, so the claim/resolve buttons were unreachable.
     Letting the row wrap and the bases go auto is the whole fix. Scoped to this breakpoint, so the
     desktop queue keeps its aligned columns exactly as designed. */
  .hqrow { flex-wrap:wrap; }
  .hqrow .qright, .hqrow .qtime, .hqbadge, .hqacts { flex:0 1 auto; }
  .hqrow .qright { justify-content:flex-start; }

  .main, .main-wide { padding:14px 12px 120px; }

  /* 🔴 THE SIDEBAR IS NO LONGER A TOP BAR [2026-08-25]. It used to become a sticky horizontal
     strip here — `flex-direction:row`, nav hidden — and the drawer rules further down now make it
     an off-canvas column instead. What survives from that layout is only the two lines below: the
     app stops being a two-column grid, and the wordmark shrinks. `.devmode` stays hidden because a
     DEV banner is desk furniture. `.nav` and `.spacer` are switched back ON by the drawer block. */
  .app { display:block; }
  .sidebar .logo { font-size:15px; margin:0; }
  .sidebar .devmode { display:none; }
  .phone-only { display:block !important; }

  /* ---- THE SLIM BAR: a menu button and the wordmark, nothing else ----
     Everything that used to crowd this row — the account block, the switch/settings/logout circles
     — now lives at the BOTTOM OF THE DRAWER, which is where her Slack reference puts it. */
  /* 🔴 ABOVE THE DRAWER [her ruling 2026-08-29]. 790 was fine while the menu button floated over the
     bar on its own; now the button is INSIDE the bar, and a bar underneath the drawer would take its
     own control down with it the moment the drawer opened. 940 clears the drawer (900), its scrim
     (880) and the old fixed button (930), so the header stays put and the drawer slides beneath it.
     ⚠ THE PHONE WALL IS AT 780 AND STAYS BELOW ALL OF THIS -- see its own note. Raising the bar does
       not change that relationship; it only moves the bar past the drawer. */
  .phonebar { display:flex; align-items:center; gap:10px; position:sticky; top:0; z-index:940;
    /* ⚠ 12px ALL ROUND NOW, not 56 on the left. That 56 was a hole reserved for a button that was
       `position:fixed` OVER this bar; the button is a flex item in the row since 2026-08-29, so the
       reservation would just push it 56px inboard of its own edge. */
    padding:10px 12px; background:var(--bg-deep);
    border-bottom:1px solid var(--line); min-height:52px; }
  /* Mirrors `.logo`'s type deliberately rather than reusing the class — see the note in ui.php:
     `.logo` must stay unique to the sidebar, because selectors app-wide assume it is. */
  .pblogo { font-family:var(--disp); font-weight:800; font-size:15px; letter-spacing:1.5px;
    color:color-mix(in srgb, var(--brand) 80%, var(--text));
    text-decoration:none; white-space:nowrap; display:inline-flex; align-items:baseline;
    position:relative; }
  /* 44px OF THUMB WITHOUT A TALLER BAR [2026-08-28, the 44px sweep]. The wordmark is a real link to
     `/` and measured 148x23 -- a finger's worth of width, half a finger of height. Growing the text
     box would push the bar from 52px to 65px and eat that back off every phone screen, so the
     target grows and the bar does not: a transparent 44px overlay centred on the link, the same
     answer `.qtip-trigger` already uses for a control whose drawn size is the design.
     ⚠ Nothing to swallow: the only other thing in this bar is the menu button, which is FIXED at
     z-index 930 against the bar's 790 and therefore stays on top of the overlay regardless. */
  .pblogo::after { content:''; position:absolute; left:0; right:0; top:50%;
    transform:translateY(-50%); height:44px; }
  /* ⚠ NO LONGER `position:fixed` [2026-08-29]. It is a flex item in the bar now, and the bar is what
     is pinned -- see the note there. It keeps its 44x44 and its own focus ring; what it loses is a
     second, independent way of being stuck to the corner. */
  .phonebar-btn { display:flex; align-items:center; justify-content:center; flex:none;
    /* 44x44, not the 40 it shipped with [2026-08-28]: this is the ONE control that reaches the nav
       on a phone, so it is the last one that should be under the floor. It still clears both
       neighbours -- 8+44=52 against the drawer's 56px top padding and the bar's 56px left padding,
       measured rather than assumed. */
    width:44px; height:44px; padding:0; border-radius:10px;
    background:var(--panel); border:1px solid var(--line-hi); color:var(--text); cursor:pointer; }
  .phonebar-btn:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }

  /* The scrim: dims the page so the drawer reads as on top of it, and guarantees a tap target for
     "close by tapping the page". `hidden` is toggled in JS, so it costs nothing while closed. */
  .navscrim { position:fixed; inset:0; z-index:880; background:rgba(0,0,0,.5); border:0; }
  .navscrim[hidden] { display:none; }
  /* Stops the PAGE scrolling under an open drawer, which otherwise reads as the drawer slipping. */
  body.navlock { overflow:hidden; }

  /* ---- THE PHONE NAV [2026-08-24] ----------------------------------------------------------
     🔴 THE LINKS WERE ALWAYS HERE. `display:none` on `.nav` above is what made every destination
     except Settings and Log out unreachable from a phone — measured at 360 and 390, all seven
     student links present in the DOM and every one of them 0px tall. This reveals the SAME list
     rather than rendering a second one, so it lists every desktop item by construction and cannot
     drift when an item is added.
     `flex-wrap` + `flex-basis:100%` is what drops it BELOW the bar: `.sidebar` is already a flex
     ROW at this width, so the nav becomes a full-width third line instead of a fourth item squeezed
     between the wordmark and the account circle. No absolute positioning, so it pushes the page
     down instead of covering the first thing the student came to read. */
  /* ══════════════════════════════════════════════════════════════════════════════════════════
     THE DRAWER [rebuilt 2026-08-25 to her Slack reference]
     Her spec: the PAGE owns the screen; a small icon top-left opens the nav; the nav slides in
     OVER the page covering most but not all of the width, so the page still shows at the right
     edge and reads as underneath; the account block sits at the BOTTOM of the drawer.

     🔴 WHAT THIS REPLACED, AND WHY IT WAS WRONG. The first build made the sidebar a ~200px-tall
     top bar (wordmark + account block + three circles) and dropped the nav in UNDERNEATH it as a
     wrapped third row. That is not a drawer: it spent a third of a phone screen before any
     content, it pushed the page down instead of covering it, and nothing about it implied there
     was a page behind. `flex-wrap` and an `order:99` hack were holding it together; both are gone.

     The drawer is just the DESKTOP sidebar again — same column, same order, same account block at
     the bottom — only fixed and translated off-canvas. That is why `.nav` and `.spacer` are simply
     switched back on here rather than restyled: reusing the desktop shape is what keeps the drawer
     from drifting from it.
     ══════════════════════════════════════════════════════════════════════════════════════════ */
  .sidebar {
    position:fixed; top:0; left:0; bottom:0; z-index:900;
    /* min(86vw, 340px): "most of the way, not all" — the remaining sliver of page is the whole
       point of her note, and the 340px cap stops it becoming a full-screen sheet on a big handset. */
    width:min(86vw, 340px); height:100dvh; min-height:0; max-height:none;
    flex-direction:column; align-items:stretch; gap:0;
    /* 56px of top padding, not 16: the menu button is fixed at top:8 left:8 and sits ABOVE the
       drawer (z 930 vs 900) so it can also close it — without this the first nav row renders
       underneath it and "Dashboard" is half-covered by the icon. Measured, not guessed. */
    padding:56px 0 12px; border-right:1px solid var(--line-hi); border-bottom:0;
    background:var(--panel); box-shadow:6px 0 28px -6px rgba(0,0,0,.6);
    transform:translateX(-101%);            /* -101% so no seam shows at fractional widths */
    transition:transform .22s ease;
    overflow-y:auto; overscroll-behavior:contain;
  }
  .sidebar.phonenav-open { transform:translateX(0); }
  /* The two things the old top-bar layout switched off, switched back on: the nav IS the drawer,
     and the spacer is what pins the account block to the bottom the way it does on desktop. */
  .sidebar .nav { display:block; }
  .sidebar .spacer { display:block; flex:1 1 auto; }
  /* The wordmark lives in the slim bar now, so the drawer's own logo row would be a duplicate. */
  .sidebar .logorow { display:none; }

  /* ══════════════════════════════════════════════════════════════════════════════════════════════
     🔴 MY STUDIO'S ROSTER, STACKED [her report 2026-08-29]: *"the new my team page overflows and
     looks bad on a mobile device. We need a mobile version of this."*
     Four columns of name, track, role and actions cannot fit 390px, and nothing was telling them to
     try: `.mtbl` had no phone rules at all, so the Role column simply ran off the right edge and the
     longest role ("Lead Programmer") was cut in half.
     THE FIX IS THE STANDARD TABLE-TO-CARDS ONE, and it is a TIER 1 layout change: keyed on width
     alone, per this stylesheet's own tier rules, because a narrow window needs it for exactly the
     same reason a phone does and nothing is being taken away.
     ⚠ THE SORT HEADERS GO. They are buttons whose whole purpose is to reorder COLUMNS that no longer
       exist as columns, and a stacked list has nothing to sort against visually. A team is one
       screenful of people, which is why this costs nothing here and would not be acceptable on a
       long table.
     ⚠ `display:block` ON A <table>, NOT `display:grid` or a rewrite of the markup. The same rows,
       the same cells and the same server-rendered content, so roster.php and team_settings.php both
       get this without either of them knowing about it.
     ══════════════════════════════════════════════════════════════════════════════════════════════ */
  table.mtbl, table.mtbl tbody, table.mtbl tr, table.mtbl td { display:block; width:100%; }
  table.mtbl thead { display:none; }
  table.mtbl tr { position:relative; padding:12px 0; border-bottom:1px solid var(--line); }
  /* 🔴 ROOM FOR THE ACTIONS ONLY WHEN THERE ARE ACTIONS. A flat reservation cost 84px on EVERY row,
     including a student's own view where that cell renders empty -- and 84px was exactly enough to
     push Track and Role onto separate lines, adding 62px of height to every person on the list to
     make room for nothing. Measured: content width 236px against 240px of chips.
     `:has()` is already this stylesheet's idiom for the same kind of question (see `.colempty`). */
  table.mtbl tr:has(.mtbl-acts > *) { padding-right:76px; }
  table.mtbl tr:last-child { border-bottom:0; }
  table.mtbl td { padding:4px 0; border:0; }
  /* The identity cell keeps its own row shape; the two chips below it line up under the name rather
     than under the avatar, so the block reads as one person. */
  table.mtbl td:first-child { padding-top:0; }
  .mtbl-id { align-items:center; }
  /* Track and Role sit side by side on one line, which is what stops a two-person team taking four
     screens. `.mtbl-trk` and `.mtbl-role` are inline already, so only their cells need the change. */
  table.mtbl td:nth-child(2), table.mtbl td:nth-child(3) { display:inline-block; width:auto;
    margin-right:10px; }
  table.mtbl td.mtbl-acts {
    position:absolute; top:10px; right:0; width:auto; display:flex; align-items:center; gap:2px;
    padding:0;
  }
  /* The dash for "no track" is a column-alignment device; stacked, it is a stray character. */
  table.mtbl .mtbl-dash { display:none; }
  @media (prefers-reduced-motion:reduce) { .sidebar { transition:none; } }
  /* "Ruled": one hairline between rows, none above the first or below the last, so the list reads
     as a group rather than as boxes. */
  .sidebar.phonenav-open .nav a { display:flex; align-items:center; gap:12px;
    padding:13px 18px; margin:0; border-radius:0; border-bottom:1px solid var(--line);
    min-height:48px; box-sizing:border-box; }
  .sidebar.phonenav-open .nav a:last-child { border-bottom:0; }
  /* The desktop rail hides its labels with opacity when collapsed (never display:none, so the link
     keeps its accessible name). On a phone the label IS the row, so force it back to full opacity
     in case a collapsed preference was saved on this device at a wider width. */
  .sidebar.phonenav-open .nav a .lbl { opacity:1; position:static; display:inline;
    background:none; border:0; box-shadow:none; padding:0; font-size:15px; white-space:normal; }
  .sidebar.phonenav-open .nav a.active { background:var(--panel-hi); }
  /* The toggle is the menu button here, so it gets a real 44px thumb target rather than the 30px
     glyph the desktop rail can afford with a mouse. */
  .sidebar .sbtoggle { min-width:44px; min-height:44px; }
  /* Switch / Settings / Log out, at the bottom of the drawer [2026-08-28, the 44px sweep]. 40px on
     the desktop rail is a mouse target and stays one; inside the drawer these are three of the
     account block's four controls and a thumb has to hit them. Three 44s plus two 10px gaps = 152px
     inside a drawer that is min(86vw, 340px) wide, so nothing wraps at 360px either. */
  .navcirc { width:44px; height:44px; }

  /* ---- THE CHECK-IN CONTROL [2026-08-24] ----
     Measured 158x38 at 390px. It is the one control a student taps to check in to a retro from a
     phone, and it was under the 44px minimum. Phone-width only: the desktop button is unchanged. */
  .attbtn { min-height:44px; padding-top:11px; padding-bottom:11px; }
  /* The dialog's own actions, same reason. */
  #verifydlg .dialog-actions .btn { min-height:44px; }
  #verifydlg input[name=code] { min-height:48px; font-size:17px; letter-spacing:.06em; }

  /* the laptop wall. `.pwico` now holds an inline SVG instead of the 💻 it used to size [2026-08-02,
     her call] — brand-coloured with the same glow the bubbles and the active nav icon get, so it
     reads as part of the theme rather than a pasted-in picture. */
  .pwcard { text-align:center; max-width:340px; }
  .pwico { color:var(--brand); margin-bottom:14px; }
  .pwico svg { width:46px; height:46px; display:block; margin:0 auto;
    filter:drop-shadow(0 0 7px color-mix(in srgb, var(--brand) 55%, transparent)); }
  .pwcard h2 { font-size:22px; justify-content:center; }
  .pwcard p { font-size:14.5px; margin:10px 0 20px; }

  /* dashboard: hero + slimming */
  .phonehero { width:100%; height:52px; font-size:16px; margin:14px 0 4px; }
  .ph-hide { display:none !important; }
  .grid { grid-template-columns:1fr; gap:12px; }
  .span2, .span3, .span4, .span5, .span6, .span7, .span8, .span9, .span10, .span11,
  .span12, .span2h, .span9h { grid-column:1 / -1; }
  .railwrap { order:1; }
  .railrow { flex-direction:column; }
  .pagehead h1 { font-size:24px; }

  /* bubbles: full-width panels, big taps. The `.bubrow-trk { display:none }` phone override that
     used to head this block is gone 2026-08-02 — the tracker bubble it hid no longer exists. */
  .bubrow { right:12px; bottom:14px; gap:10px; }
  .bubpanel { position:fixed; left:10px; right:10px; bottom:calc(14px + var(--bub-size) + 26px); width:auto !important;
    max-height:72vh; overflow-y:auto; font-size:15px; }
  .bubpanel .bubcard { padding:14px 16px; font-size:15px; }
  .bubbig { height:50px; font-size:15.5px; }
  .bubpanel .btn-sm { min-height:40px; }
  .bubx { font-size:16px; padding:6px 9px; }

  /* dialogs: comfortable inputs */
  dialog input[type=text], dialog textarea, dialog select { font-size:16px; min-height:44px; }
  dialog .btn, dialog .btn-sm { min-height:44px; }
  .dialog-actions { flex-wrap:wrap; gap:8px; }

  /* deck (walled anyway, belt & suspenders) */
  .slidetitle { font-size:30px; }
  .slidetitle-xl { font-size:40px; }
  .slide { padding:24px 18px 90px; }
}

/* ---- Step 14: goal box header + equal heights + history rows ---- */
.goalbox .label { font-size:12.5px; font-weight:800; letter-spacing:2.2px; }
.goalbox { gap:8px; }
.railrow { align-items:stretch; }
.railrow .goalbox { align-self:stretch; }
.ghrow { display:flex; align-items:flex-start; gap:12px; padding:8px 2px; border-top:1px solid var(--line); }
.ghrow:first-child { border-top:0; }
.ghpill { flex:0 0 130px; display:flex; }
.ghtext { flex:1; font-size:14px; }

/* Rail final-day fix: height-match without crushing the rail's internals.
   The old `.railwrap > *` forced every child into a centered flex column, which
   collapsed the rail when the alarm state swapped the stats region. Scope the
   height-matching to the wrapper itself and let the rail keep its own layout. */

/* ---- Step 14: quiet-absence alert family (both surfaces) ---- */
.wipwarn.quietabs { background:var(--info-soft); border-color:color-mix(in srgb, var(--info) 35%, transparent);
  color:var(--text); }
.wipwarn.quietabs span:last-child { color:var(--dim); }

/* ---- Step 14: teacher dashboard pacing alignment ---- */
.pacecols { display:grid; grid-template-columns:150px 150px 170px; gap:12px; align-items:center;
  justify-items:end; margin-left:auto; }
.pacecols .dstrip { justify-content:flex-end; display:inline-flex; flex-wrap:wrap; }
.pacecols .ckpill { display:inline-flex; justify-content:flex-end; }
.pacecols .pacetag { justify-self:end; text-align:right; }
.pacerow + .pacegroup, .pacegroup { margin-top:16px; }
/* 🔴 SAME COLUMN, SAME WIDTH [her standing rule; applied 2026-08-07, Teacher Dashboard audit].
   Measured in Chromium before this rule, the three `.pacetag` pills stacked in this one column came
   out 158px / 151px / 144px wide ("13 CLASS DAYS BEHIND" / "2 CLASS DAYS BEHIND" / "1 CLASS DAY
   BEHIND") — three different-width filled pills directly above one another. `min-width` rather than
   `width`, per the rule, and `100%` resolves against the FIXED grid track above, so every pill in a
   column is exactly that column's width and the pair can never be sized by its own text again.
   The `.ckpill` column measured equal only by luck: 1/4, 4/4 and 0/4 happen to be the same glyph
   count, and a two-digit total would have split it the same way. */
.pacecols .ckpill, .pacecols .pacetag { min-width:100%; text-align:center; justify-content:center; }
/* 🔴 THE THREE FIXED TRACKS ABOVE OVERFLOWED THE PANEL, AND THE PAGE WITH IT [fixed 2026-08-07].
   150+150+170 plus two 12px gaps is 494px of fixed px inside `#delivpanel`, which is a `.span6` —
   about a third of the viewport. README §6.7 exactly: the window says there is room and the PANEL
   does not. Measured in Chromium at class 600 with real pace rows:
       1920px window -> panel fits, 9px to spare
       1440px window -> .pacecols spills 26-35px past the panel border
       1366px window -> spills 63-72px, and the DOCUMENT gains 16px of horizontal scroll
       1280px window -> spills 106-115px, document gains 59px of horizontal scroll
   1366x768 and 1440x900 are ordinary school-laptop sizes, so this was the common case, not an edge.
   A container query, not a media query, for the reason §6.7 gives — and it wraps the row rather than
   shrinking the tracks, because shrinking them would just move the overflow inside the pills and
   clip her copy instead of the panel. 620px is the measured threshold plus room for a long team
   name (the row needs ~566px of content box before the name has to start wrapping). */
#delivpanel { container-type:inline-size; }
@container (max-width:620px) {
  .pacerow { flex-wrap:wrap; }
  .pacerow .l1 { flex:1 1 100%; }
  /* The two badge tracks keep their fixed widths so the same-width rule above still holds in the
     wrapped state; only the swatch strip gives up space (it is `flex-wrap:wrap` and can take a
     second line). */
  .pacecols { margin-left:0; width:100%; grid-template-columns:1fr 150px 170px; }
}

/* ---- Step 14: deliverables rows pin left ---- */
.delrow .t { text-align:left; justify-content:flex-start; }
.delrow-ord .t { text-align:left; }


/* ---- Step 14: swatch grid hardening (join-page oval bug) ---- */
.swatch { width:30px; height:30px; flex:0 0 30px; box-sizing:border-box; display:inline-block; }
.swatch input { position:absolute; opacity:0; width:0; height:0; margin:0; pointer-events:none; }
.swatches { display:flex; flex-wrap:wrap; gap:8px; }

/* ---- Step 14: join customize + theme cards pinned ---- */
.themepick .tswatch { width:100%; height:64px; box-sizing:border-box; overflow:hidden; }
.themepick label { flex:1 1 150px; max-width:210px; min-width:138px; }
.avpick { display:flex; flex-wrap:wrap; gap:10px; align-items:flex-start; }
.avpick .avopt, .avmorebox { width:72px; box-sizing:border-box; text-align:center; cursor:pointer; }
.avpick .avopt .avatar { margin:0 auto; }
.avpick input { position:absolute; opacity:0; width:0; height:0; }

/* ---- Step 14: final-day red alert tier (halfway nudge stays yellow) ---- */
.wipwarn.wipred { background:var(--red-soft, rgba(255,70,85,.10));
  border-color:color-mix(in srgb, var(--red) 55%, transparent); color:var(--red); }
.wipwarn.wipred span:first-child { color:var(--red); }

/* ---- The Dashboard Alerts box is the ONE home for alerts now [2026-08-04, Communication dissolved].
       It holds the whole ledger, so it scrolls — one scrollbar on the list, never a second one on the
       panel. `.wipread` is the greyed "already cleared" state brought across from the Notifications
       tab's own `.ab-read`, so a cleared alert still reads as cleared in its new home. ---- */
.alertscroll { max-height:340px; overflow-y:auto; overflow-x:hidden; }

/* ---- 🔴 GROUP L, NOTES 6 + 8 + 9: THE ALERT ROW IS THE BANNER NOW [her ruling 2026-08-22] ----
   Her note 9, which is the design direction for the whole box: she does NOT like the red/yellow
   plates and DOES like the theme-coloured banners that used to sit outside it (*"I like that those
   are short, I like the formatting and the colors and I like that the links stand out as a different
   color and clickable. I think we should format the dashboard alerts like these but these should be
   in the alerts box"*). Note 6 is the same complaint from the other end: the one row that did show up
   was *"a red alert with yellow text and it's all underline and its a really long alert too"*.
   So `.alertrow` takes the OLD `.flash` banner treatment — accent-3 tint, a quiet border, ordinary
   `--text` body copy — and the alarm styling is now opt-in via `.wipred`.
   ✅ RED IS NOT RETIRED, ruled explicitly: theme-coloured by DEFAULT, red KEPT for real urgency. That
   narrows what earns red rather than removing it, which is consistent with README §5.
   ⚠ THE ROWS NO LONGER USE `.wipwarn`, so the amber plate and its `span:first-child` amber glyph rule
   no longer reach them. `.wipwarn` itself is untouched and still paints the STUDENT dashboard's rows
   and the absences strip — this is a dashboard-alerts change, not an app-wide one. */
.alertrow {
  display:flex; gap:10px; align-items:flex-start;
  background:var(--accent-3-soft);
  border:1px solid color-mix(in srgb, var(--accent-3) 35%, transparent);
  border-radius:var(--r-md); padding:11px 16px; margin:8px 0 0;
  color:var(--text); font-size:13.5px; line-height:1.5;
}
/* THE LINK IS THE POINT OF THE ROW — she named it twice. Brand-mixed rather than raw `--brand` for
   the same measured reason `.wipwarn a` carries: raw brand on a tinted row falls under 4.5:1 in six
   of fifteen themes. The underline stays, because WCAG 1.4.1 wants a link in a block of text told
   apart by something other than colour. */
.alertrow a { color:color-mix(in srgb, var(--brand) 55%, var(--text)); font-weight:600; }
/* 🔴 SEVERITY IS THE GLYPH ONLY [her ruling 2026-08-23, and it is the second pass on this]. The first
   pass kept a red PLATE for urgent rows — red tint, red border — and she came back with the reason it
   still felt wrong: *"we have a dedicated alerts box (already a designated space to look for alerts)
   and then ALSO have them colored red, yellow, etc.. and all stacked on top of each other... from a
   ui/ux design viewpoint, we have a dedicated alerts box it feels a little weird to have the alerts
   screaming inside there."*
   She is right, and the box is the reason: a panel headed "Alerts" has already told the reader what
   these are, so painting each row in alarm colours is the same information a second time, and when
   every row does it the loud ones stop standing out at all.
   So EVERY row now wears the same quiet banner above, and urgency is carried by the ⚠ alone — her own
   suggestion (*"maybe only the 'symbol' is red"*), which is also the smallest thing that can still be
   spotted at a glance down a column. `app/../index.php` renders that glyph ONLY on a red row, so the
   presence of the symbol and its colour say the same thing twice, which is the redundancy WCAG 1.4.1
   wants when meaning would otherwise be carried by colour alone. */
.alertrow.wipred span:first-child { color:var(--red); font-weight:700; font-size:15px; line-height:1.2; }
.alertrow.wipread {
  background:transparent; border-color:var(--line); color:var(--dim);
}
.alertrow.wipread a { color:var(--dim); }
/* The schedule reminders have no dismiss button, so nothing reserves space on their right. */
.alertrow.schedrow { justify-content:flex-start; }
.wipwarn.wipread { background:transparent; border-color:var(--line); color:var(--dim); }
.wipwarn.wipread span:first-child { color:var(--dimmer); }
.wipwarn.wipread a { color:var(--dim); }

/* ---- ALERT-TABLE S12: the leadership survey's pinned row. Never red — it is an invitation, not a
       warning, so it must stand out without implying anyone did anything wrong (her ruling, and the
       reason amber/orange/red stay reserved for warning semantics).
       🔴 REVERSED 2026-08-09: was brand-highlighted, now --info — her list of what belongs on the
       per-theme --info token explicitly named "leadership survey" alongside absences/on-call/
       halfway, so this moves with the rest of that family rather than staying brand-only. ---- */
.wipwarn.lvpin { background:var(--info-soft);
  border-color:color-mix(in srgb, var(--info) 55%, transparent); color:var(--text); }
.wipwarn.lvpin span:first-child { color:var(--info); }

/* ---- Step 14: roster absence pills in a fixed rail column ---- */
.rosterid { display:inline-flex; align-items:center; gap:8px; flex-wrap:wrap; }
.rostertable td:first-child, .roster td:first-child { white-space:nowrap; }
.rosterabs { display:inline-flex; min-width:86px; justify-content:flex-start; margin-left:12px; vertical-align:middle; }

/* ---- Step 14: theme picker subtext ---- */
.tswatch .tsub { display:block; font-style:normal; font-size:10px; color:var(--dimmer); letter-spacing:.3px; margin-top:1px; }
.themepick .tswatch { height:auto; min-height:64px; }

/* ---- Step 14 audit: due-date line on survey cards ---- */
.duedate { color:var(--red); font-weight:700; font-size:13px; margin:3px 0 2px; }

/* ---- Step 14 audit: tracker block labels (dim, small — studio name leads) ---- */
/* `.trkblk` (the block-name suffix on a tracker row) was deleted here 2026-08-02 with the tracker
   bubble — it was the only thing that ever rendered it. */

/* ---- Step 14 audit: circle buttons + question rows ---- */
.circbtn { width:28px; height:28px; border-radius:50%; border:1.5px solid var(--line-hi); background:var(--panel-hi);
  color:var(--text); font-size:17px; font-weight:800; line-height:1; cursor:pointer; display:inline-flex;
  align-items:center; justify-content:center; padding:0; flex:0 0 28px; }
/* 🔴 THE GLYPH IS COMPUTED, NOT CHOSEN [2026-08-17, Teacher Teams audit] — same house method as
   `.fielderr` (85%), `.b-red` (70%) and `.b-info` (85%). The "−" is 17px at weight 800, which is
   NORMAL text for WCAG (large-scale needs 18.66px bold), so 4.5:1 applies, and raw `--red` on this
   button's own `--red 10% over --panel` fill failed in three themes: bubblegum 4.03, pumpkinspice
   4.50, sunsettide 4.50. 84% is the largest `--red` share that clears 4.5:1 in all 15 registered
   themes; worst case after is bubblegum 4.52, best arcade 7.76. `--red` itself is untouched.
   `.circbtn-add` was measured too and passes on `--green` as-is (worst bubblegum 5.38). */
.circbtn-red { color:color-mix(in srgb, var(--red) 84%, var(--text)); border-color:color-mix(in srgb, var(--red) 55%, transparent);
  background:color-mix(in srgb, var(--red) 10%, var(--panel)); }
.circbtn-add { color:var(--green); border-color:color-mix(in srgb, var(--green) 55%, transparent);   /* add = green, every theme */
  background:color-mix(in srgb, var(--brand) 10%, var(--panel)); }
.circbtn:hover { transform:scale(1.08); }
.rqrow { display:flex; align-items:flex-start; gap:12px; padding:10px 2px; border-top:1px solid var(--line); }
.rqrow:first-of-type { border-top:0; }
.rqrow .t { flex:1; text-align:left; }

/* Company Settings teams list (1j): same raised treatment as .stucard, one box per row. */
/* A caption that LOOKS like a form label but has no control to point at [2026-08-17, Teacher Teams
   audit]: the heading over Company Settings' boxed team list, and Team Settings' "Leadership Role"
   when every role is taken and the field collapses to a sentence plus a hidden input. Both were bare
   `<label>`s, which a screen reader announces as an empty label pointing nowhere. Same type treatment
   as `label` above, deliberately — this is only ever an element correction, never a visual one. */
.fieldcap { display:block; margin:var(--sp-4) 0 var(--sp-1); color:var(--dim); font-size:12.5px; font-weight:700; }
.cobox { background:var(--bg-deep); border:1px solid var(--line); border-radius:var(--r-md);
  padding:10px 14px; margin-bottom:8px; box-shadow:0 2px 8px -2px rgba(0,0,0,.3); }
.cobox:last-child { margin-bottom:0; }

/* ---- Step 14 audit: leadership wizard option cards ---- */
.lsopts { display:flex; flex-direction:column; gap:8px; margin-top:8px; }
.lsopt { display:flex; gap:12px; align-items:flex-start; background:var(--bg-deep); border:1px solid var(--line);
  border-radius:var(--r-md); padding:12px 14px; cursor:pointer; font-size:13.5px; line-height:1.5;
  font-weight:400; text-transform:none; letter-spacing:0; color:var(--dim); }
.lsopt input { margin-top:3px; flex:0 0 auto; }
.lsopt:hover { border-color:var(--line-hi); }
.lsopt:has(input:checked) { border-color:color-mix(in srgb, var(--brand) 55%, transparent);
  background:color-mix(in srgb, var(--brand) 6%, var(--bg-deep)); color:var(--text); }
.lsopt b { color:var(--text); }

/* ---- Step 14 audit: roster action grid (item 50) ---- */
.rosterabs-td { white-space:nowrap; width:96px; }
.rosteracts-td { text-align:right; }
.rosteracts { display:inline-flex; justify-content:flex-end; align-items:center; gap:8px; white-space:nowrap; }
.rosteracts form { margin:0; }

/* ---- Step 14 audit: eight-stat review grid, 4 across, big and even ---- */
.rvstats8 { grid-template-columns:repeat(4, 1fr); }
.rvstats8 .rvstat b { font-size:32px; }
.rvstats8 .rvstat { padding:16px 14px; }
@media (max-width:820px) { .rvstats8 { grid-template-columns:repeat(2, 1fr); } }

/* ---- Step 14 audit: deck timer as a big in-card rectangle (item 56) ---- */
.decktimer-big { position:absolute; top:18px; right:22px; z-index:5;
  font-family:var(--mono); font-size:34px; font-weight:800; letter-spacing:2px;
  background:var(--panel); border:1.5px solid var(--line-hi); border-radius:var(--r-md);
  padding:8px 18px; box-shadow:0 6px 22px rgba(0,0,0,.4); }
.decktimer-big.meetover { color:var(--red); border-color:color-mix(in srgb, var(--red) 60%, transparent);
  box-shadow:0 0 18px color-mix(in srgb, var(--red) 30%, transparent); }
.deckbody { position:relative; }

/* ---- Step 14 audit: goal ceremony (item 58) ---- */
.goalcard-metdone { border-color:color-mix(in srgb, var(--green) 60%, transparent);
  background:color-mix(in srgb, var(--green) 7%, var(--panel)); }
.goalcard-metdone .gctitle { color:var(--green);
  text-shadow:0 0 8px color-mix(in srgb, var(--green) 70%, transparent); }
.goalcard-metnow { border-color:color-mix(in srgb, var(--green) 60%, transparent);
  background:color-mix(in srgb, var(--green) 7%, var(--panel)); }
.goalcard-metnow .gctitle, .goalcard-metnow .grevedit { color:var(--green); }
.gbtns { display:flex; gap:8px; flex-wrap:wrap; margin-top:10px; }
.gbtn { background:var(--bg-deep); border:1.5px solid var(--line-hi); color:var(--dim);
  border-radius:99px; padding:6px 16px; font-size:13px; font-weight:700; cursor:pointer; }
.gbtn:hover { color:var(--text); border-color:var(--dim); }
/* 🔴 `color:var(--brand)` ON A 10% TINT OF ITSELF [fixed 2026-08-15, Teacher Meetings audit] — the
   same colour-on-its-own-tint pattern `.b-info` and `.b-pink` were corrected for. Failed AA in 3 of
   16: catalystcats 3.57 (the DEFAULT), ultraviolet 3.39, pumpkinspice 3.31, at 13px/700.
   This is the deck's SELECTED state — the only thing telling the chair which call they just made on
   a goal, on a projector — so an unreadable "on" is a functional failure, not a cosmetic one.
   The `.gbtn-met.gbtn-on` twin below already passes in all 16 (it is on `--green`, which is locked).
   **76% is COMPUTED, not chosen:** 77% is the literal largest share that clears, but it lands
   pumpkinspice on 4.504 — inside rounding of the bar itself — so this takes the next step down.
   Worst case is now 4.56 (pumpkinspice). */
.gbtn-on { color:color-mix(in srgb, var(--brand) 76%, var(--text));
  border-color:color-mix(in srgb, var(--brand) 60%, transparent);
  background:color-mix(in srgb, var(--brand) 10%, var(--bg-deep)); }
.gbtn-met.gbtn-on { color:var(--green); border-color:color-mix(in srgb, var(--green) 60%, transparent);
  background:color-mix(in srgb, var(--green) 10%, var(--bg-deep)); }
.grevedit, .grevlive { width:100%; margin-top:8px; font-size:15px; }
.goalcard .grevlive { margin-top:0; }

/* ---- Step 14 audit: phone dashboard slims to hero + alerts + bubbles (item 62) ---- */
@media (max-width:640px) {
  .desk-only-dash { display:none !important; }
}

/* ---- Teacher settings theme picker: bigger cards, even rows, full width ---- */
.themepick-lg { display:grid; grid-template-columns:repeat(auto-fit, minmax(205px, 1fr)); gap:10px; }
.themepick-lg label { max-width:none; min-width:0; flex:none; }
.themepick-lg .tswatch { height:auto; min-height:72px; padding:12px 14px; }

/* ---- Bug F: uniform rail stacking regardless of block filter ---- */
.railstack { display:flex; flex-direction:column; gap:10px; }
.railstack [data-rail] .sprintrail { margin-top:0; }

/* ---- Bug H: Livewire's colorblind signal remap must WIN the cascade. The universal
   signal blocks above pin --green/--red identically across themes; Livewire's entire
   purpose is remapping those signals (doctrine exception, ruled 2026-07-17), so its
   remap is re-declared here, after everything, at higher specificity. Amber stays
   universal — yellow is colorblind-legible. ---- */
html[data-theme="livewire"] {
  /* §23 (ruled 2026-07-24): good was #58b0ff, a THIRD blue alongside --brand #43b5ff and
     --info #8ad3ff, so "good"/"branded"/"informational" were indistinguishable for everyone.
     Now Wong's bluish-green. Chosen over the brighter #00d6a0 because it also separates from
     --info on LUMINANCE (greyscale gap 47 vs 13), so it survives deuteranopia, protanopia,
     tritanopia AND a projector. Danger stays Wong vermilion; amber stays universal. */
  --green:#00b083; --green-soft:rgba(0,176,131,.13);           /* good = Wong bluish-green */
  --red:#ff5714; --red-soft:rgba(255,87,20,.13);               /* bad = hot vermilion (Wong) */
  --bad:#ff5714; --bad-soft:rgba(255,87,20,.13);
  --navdot-bg:#ff5714;
}

/* ---- Phase 2: RED alert tier (can't-do-this / truly destructive) ---- */
.appdialog .dlgtier-red { background:color-mix(in srgb, var(--red) 10%, var(--bg-deep));
  border-color:color-mix(in srgb, var(--red) 50%, transparent); }
.dlgtier-red { background:var(--red-soft); border:1px solid color-mix(in srgb, var(--red) 40%, transparent);
  border-radius:var(--r-sm); padding:10px 12px; }


/* ---- Deck pass 2: new-goal cards, glowy add, next-meeting cards ---- */
/* 🔴 `color:var(--ink)` WAS A TOKEN THAT DOES NOT EXIST [fixed 2026-08-15, Teacher Meetings audit].
   `grep -rn -- '--ink\s*:'` over the whole repo returns nothing; the app's near-black button ink is
   `--brand-ink`, which is a different thing. An unresolvable `var()` makes the declaration invalid
   at computed-value time, so `color` fell back to `inherit` — which on a `.goalcard` inside `.deck`
   happens to resolve to `--text` anyway. Measured in Chromium: `var(--ink)` and `var(--text)` both
   computed to rgb(233,238,248) on catalystcats, so naming the real token is byte-identical output
   and removes a declaration that silently did nothing (and would have kept doing nothing if anyone
   ever tried to change it). */
.ngtext { width:100%; background:transparent; border:none; outline:none; font-size:17px;
  color:var(--text); padding:2px 0; }
/* 🔴 THE ONE FOCUS INDICATOR THAT WAS GENUINELY MISSING [2026-08-16, accessibility build 5]. Audited
   every `outline:none` in this file: the form-field rule, `.styledsel` and `.joincode-input` all
   replace the ring with a brand border plus a 3px halo, which is a legitimate custom indicator. This
   one replaced it with NOTHING — `border:none; outline:none` — so a focused new-goal field on the
   retrospective deck was indistinguishable from an unfocused one. WCAG 2.4.7, and worse than usual
   here because the deck is where the chair types, and the magnifier follows the caret to a field she
   then cannot confirm she is in. Restored on `:focus-visible` only, so the mouse path is unchanged
   and the deck's clean typing surface is kept for everyone else. */
.ngtext:focus-visible { outline:2px solid var(--brand); outline-offset:3px; border-radius:3px; }
/* Was hardcoded #fff border/text/glow instead of a theme token [2026-08-15 Meetings design pass].
   Reuses `.nav a.active`'s own already-verified 55%-toward-`--text` mix (re-checked directly here
   against all 15 registered themes, both `--panel` and `--bg-deep`, comfortable margin in every one)
   rather than a fresh, unverified number — same colour the sidebar's own active icon glows, so this
   reads as "the app's accent", not a new one-off. */
.ngaddbtn { margin-top:12px; padding:11px 26px; font-family:var(--disp); font-weight:700;
  letter-spacing:1.5px; font-size:14px; color:color-mix(in srgb, var(--brand) 55%, var(--text)); background:transparent; cursor:pointer;
  border:1.5px solid color-mix(in srgb, var(--brand) 55%, var(--text)); border-radius:var(--r-md);
  box-shadow:0 0 16px color-mix(in srgb, var(--brand) 35%, transparent); }
.nextcards { display:flex; gap:14px; justify-content:center; margin-top:22px; flex-wrap:wrap; }
.nextcard { border:1.5px solid var(--line-hi); border-radius:16px; padding:14px 26px; min-width:200px;
  background:color-mix(in srgb, var(--brand) 6%, var(--bg-deep)); }
.nclabel { font-size:12px; letter-spacing:1.5px; text-transform:uppercase; color:var(--dim); }
.ncval { font-size:21px; font-weight:700; margin-top:4px; }

/* ---- MVP nominations queue + meetings dormant texts ---- */
.nomqueue { width:100%; margin-top:6px; }
.nomqueue th { text-align:left; font-size:12px; letter-spacing:1px; text-transform:uppercase; color:var(--dim); padding:6px 8px; }
/* 🔴 `middle`, WAS `top` [chat 7a, 2026-09-05]. The name cell is now a two-line block (avatar, name,
   studio underneath) through person_line_html(), so top-aligning the other three cells hung the
   nominee and the reason off the FIRST line of it and left the verdict buttons floating above
   centre. Every cell in the row is now centred against the tallest thing in it, which is the
   avatar. Padding went 8px -> 10px 8px with it: the avatar is 30px and 8px made the rows touch. */
.nomqueue td { padding:10px 8px; border-top:1px solid var(--line); vertical-align:middle; }
.nomqueue .mtbl-id { gap:9px; }
.nomnom { font-weight:700; }
.nomacts { text-align:right; white-space:nowrap; }
/* 🔴 ROW, WAS COLUMN [chat 7a]. Her design puts the two verdicts SIDE BY SIDE; stacked, they made
   every row two buttons tall, which is the opposite of the "tightened" this queue was asked for. */
.nomacts form { display:flex; flex-direction:row; gap:8px; align-items:center; justify-content:flex-end; }
.mtdate { color:var(--brand); font-size:1.08em; }

/* ---- MVP nominations: REAL TABS, JOINED TO THE PANEL [chat 7a, 2026-09-05] ----
   Her approved design, HTMLSnapshots/PROPOSAL-mvp-nomination-text.html: the BAR carries the
   dividing line and the ACTIVE tab overlaps it by 1px painting the body colour, so the tab and the
   list below read as one surface descending into the list, not a control sitting on a box.
   ⚠ A SECOND NAVIGATION PATTERN, AND THAT IS DELIBERATE. The Live Queue / Follow-Ups sub-nav, the
     Help Queue filters and the Retrospectives / MVP / Reviews row directly above this very panel
     are all `.fchip` pills. She has seen that and chose tabs for THIS surface. Do not convert the
     others to match, and do not convert these to pills for consistency.
   ⚠ THE APP'S TOKENS, NEVER THE MOCKUP'S HEX. That file approximates the dark theme on purpose, so
     the DESIGN was what got judged; eyedropping it would hard-code one theme into a 16-theme app. */
.nomtabs { display:flex; gap:3px; align-items:flex-end; flex-wrap:wrap;
  margin:14px -22px 0; padding:11px 22px 0; background:var(--panel-hi);
  border-top:1px solid var(--line); border-bottom:1px solid var(--line); }
.nomtab { font-family:inherit; font-weight:600; font-size:13.5px; line-height:1.4; color:var(--dim);
  background:transparent; cursor:pointer; border:1px solid transparent; border-bottom:0;
  border-radius:var(--r-sm) var(--r-sm) 0 0; padding:9px 16px; margin-bottom:-1px; }
.nomtab:hover { color:var(--text); }
/* THE 1px OVERLAP. `margin-bottom:-1px` pulls the tab down over the bar's own border-bottom and
   `background:var(--panel)` paints the panel colour across it, so the seam vanishes under the
   active tab and only under it. Both halves are required: without the negative margin there is
   nothing to paint over, without the matching background the tab paints a stripe of the wrong
   colour. `--panel` because that is what the tab body below sits on. */
.nomtab.on { color:var(--brand); background:var(--panel); border-color:var(--line); padding-bottom:10px; }
/* A COUNT, NOT AN ALARM [her explicit note]: brand-coloured, and deliberately NEVER the red the
   notification dots use (--navdot-bg). Noticeable without reading as a problem. */
.nomtab-n { display:inline-grid; place-items:center; min-width:20px; height:20px; padding:0 6px;
  margin-left:8px; border-radius:999px; background:var(--brand-soft); color:var(--brand);
  border:1px solid color-mix(in srgb, var(--brand) 38%, transparent);
  font-family:var(--mono); font-weight:600; font-size:11.5px; font-variant-numeric:tabular-nums; }
/* The tab body sits flush under the bar; the panel's own padding is cancelled and re-applied above
   so the bar bleeds edge to edge while the list keeps its normal gutters. */
.nomtabbody { padding-top:4px; }

/* ---- THE VERDICT PAIR: a big green check and a red cross, side by side [chat 7a] ----
   --green / --red are the app's LOCKED good/bad signal tokens (see the :root,[data-theme] block
   that pins them across every theme, and Livewire's deliberate colourblind remap of both). Using
   --brand for the approve would have made "good" mean "themed" and broken exactly that. */
.nomv { display:inline-flex; align-items:center; justify-content:center;
  width:40px; height:34px; margin:0; padding:0; cursor:pointer;
  font-family:inherit; font-size:17px; line-height:1; border:1.5px solid transparent;
  border-radius:var(--r-sm); background:transparent; }
.nomv-y { color:var(--green); border-color:color-mix(in srgb, var(--green) 45%, transparent); }
.nomv-y:hover { background:var(--green-soft); border-color:var(--green); }
.nomv-n { color:var(--red); border-color:color-mix(in srgb, var(--red) 45%, transparent); }
.nomv-n:hover { background:var(--red-soft); border-color:var(--red); }

/* ══ MVP · THE NOMINATION TEXT TRACKER AND ITS WIZARD [chat 7b, 2026-09-05] ═════════════════════
   Her approved design, HTMLSnapshots/PROPOSAL-mvp-nomination-text.html.
   ⚠ THE APP'S TOKENS, NEVER THE MOCKUP'S HEX — that file approximates one dark theme deliberately so
     the DESIGN was what got judged, and eyedropping it would hard-code a single theme into a
     16-theme app. Two substitutions in particular are not cosmetic: the mockup's `--brand` IS green,
     so everywhere it means "good" this uses `--green`, the app's LOCKED good/bad signal token (see
     the `:root,[data-theme]` block that pins --green/--red across every theme and Livewire's
     colourblind remap of both). Using --brand would make "approved" mean "themed" and turn the
     approved chip pink on the default theme. Same reasoning as the verdict pair directly above. */

/* THE THIRD BUTTON STATE. `.btn-ghost` is the app's existing quiet outline and it paints its label
   `--dim` -- which is exactly the grey she rejected: her note on the mockup is that grey read as
   DISABLED, and all three tracker states are live actions. This is `.btn-ghost`'s geometry with a
   `--text` label, which is what the mockup's own `.btn-neutral` is. Named generally because it is a
   general answer (an outline button that is not quiet), but introduced by this design.
   ⚠ IT ALSO HAD TO BE ADDED TO THE PER-THEME EXCLUSION LIST beside `.btn` (search
   `data-theme="catalystcats"` above). `[data-theme="…"] .btn` is specificity (0,2,0) and this is
   (0,1,0), so without that line catalystcats painted this button brand-orange under a `--text`
   label. That block's own comment already records the same thing happening to `.btn-ghost`, twice.
   A FIFTH transparent variant will need the same, and will look fine on 14 of 15 themes. */
.btn-neutral { background:transparent; color:var(--text); border-color:var(--line-hi); }
.btn-neutral:hover { border-color:var(--dim); background:var(--panel-hi); }

.mvptrk { margin-top:6px; }
/* 🔴 THE ACTION COLUMN IS A FIXED TRACK, AND THAT IS THE WHOLE REASON THIS IS A GRID. With `auto` it
   shrinks to fit a shorter label ("Resume Finalizing" vs "Finalize Nominations" vs a Finalized
   pill), which lets the first two columns grow and knocks the stat chips out of line row to row.
   The chips are the thing that must align, so the button gets a fixed track and its label length
   stops mattering. */
.mvprow { display:grid; grid-template-columns:minmax(170px,1fr) max-content 186px; gap:20px;
  align-items:center; padding:14px 8px; border-top:1px solid var(--line); }
.mvprow:first-child { border-top:0; }
/* ⚠ `!important`, AND IT IS REQUIRED. `display:grid` above beats the UA's own `[hidden]{display:none}`
   on specificity, so a finalized row would render even while hidden. The mockup file carries this
   exact warning about its own `.row`; it is not a stylistic flourish. */
.mvprow[hidden] { display:none !important; }
/* A FINALIZED ROW KEEPS ITS CHIPS, DIMMED [§13 item 3's recorded default]. Dimming the row rather
   than rebuilding it is what lets the columns still line up with the live rows above. */
.mvprow-done { opacity:.62; }
.mvpwho { display:flex; align-items:center; min-width:0; }
.mvpname { font-weight:600; font-size:15px; line-height:1.25; }
.mvpmeta { font-family:var(--mono); font-size:11.5px; color:var(--dimmer); }

/* 🔴 THREE FIXED SLOTS, PACKED LEFT — SHE ASKED FOR THIS SPECIFICALLY. Every chip is exactly one
   slot wide, so the first chip on every row starts at the same x, the second at the same x and the
   third at the same x, even when slot 2 holds "pending" on one row and "missing" on the next.
   `repeat(3, 108px)` rather than auto-fit: a row with one chip must still leave slots 2 and 3 empty
   at their own x positions, or a complete team's single green figure would slide under the next
   row's amber one. Zero counts render no element at all [her "zeros hidden" decision], which is what
   makes a finished team read as one number. */
.mvpstats { display:grid; grid-template-columns:repeat(3,108px); gap:9px; align-items:center; }
.mvpstat { width:100%; align-items:baseline; gap:6px; padding:5px 10px; border-radius:var(--r-sm);
  border:1px solid var(--line-hi); background:var(--panel-hi); cursor:default; }
.mvpstat b { font-family:var(--mono); font-weight:600; font-size:15px; font-variant-numeric:tabular-nums; }
.mvpstat > span:not(.htiptext) { font-size:12px; color:var(--dim); }
.mvpstat-ok { border-color:color-mix(in srgb, var(--green) 42%, transparent); background:var(--green-soft); }
.mvpstat-ok b { color:var(--green); }
.mvpstat-pend { border-color:color-mix(in srgb, var(--amber) 42%, transparent); background:var(--amber-soft); }
.mvpstat-pend b { color:var(--amber); }
.mvpstat-miss { border-color:color-mix(in srgb, var(--red) 42%, transparent); background:var(--red-soft); }
/* `--red` mixed 70% toward `--text`, the same lift `.b-red` uses and for the same measured reason:
   raw --red on --red-soft misses AA in several themes. The border and plate keep the raw token. */
.mvpstat-miss b { color:color-mix(in srgb, var(--red) 70%, var(--text)); }
.mvpact { display:flex; justify-content:flex-end; }
.mvpact .btn { margin-top:0; white-space:nowrap; }
.mvpdone { font-family:var(--mono); font-size:11px; letter-spacing:.1em; text-transform:uppercase;
  color:var(--dimmer); border:1px solid var(--line); border-radius:999px; padding:5px 12px; }
.mvpreveal { margin-top:12px; padding-top:12px; border-top:1px solid var(--line); }
.mvpreveal .btn { margin-top:0; }

/* ---- the wizard ---- */
.mvpwiz { width:min(920px, calc(100vw - 32px)); max-width:none; padding:0; overflow:hidden;
  border:1px solid var(--line-hi); border-radius:var(--r-lg); background:var(--panel); color:var(--text); }
/* Same `[hidden]` trap as `.mvprow` above: `.btn` is `display:inline-flex`, so the footer's
   Next / Save and Finish Later / Finalize would all stay visible on every screen without this. */
.mvpwiz [hidden] { display:none !important; }
.mvpwiz-top { display:flex; align-items:center; gap:14px; padding:14px 20px; background:var(--panel-hi);
  border-bottom:1px solid var(--line); flex-wrap:wrap; }
.mvpwiz-ttl { font-weight:600; font-size:15px; }
.mvpwiz-saved { font-family:var(--mono); font-size:11px; color:var(--green); letter-spacing:.07em; }
.mvpwiz-saved:not(:empty)::before { content:"\25CF"; margin-right:5px; font-size:8px; vertical-align:middle; }
.mvpwiz-steps { display:flex; gap:5px; margin-left:auto; align-items:center; }
.mvpwiz-step { width:26px; height:4px; border-radius:2px; background:var(--line-hi); }
.mvpwiz-step.on { background:var(--green); }
.mvpwiz-step.did { background:color-mix(in srgb, var(--green) 46%, transparent); }
.mvpwiz-n { font-family:var(--mono); font-size:11.5px; color:var(--dimmer); }
.mvpwiz-x { background:transparent; border:1px solid var(--line-hi); color:var(--dim);
  border-radius:var(--r-sm); width:26px; height:26px; cursor:pointer; font-size:12px; line-height:1;
  display:grid; place-items:center; font-family:inherit; }
.mvpwiz-x:hover { color:var(--text); border-color:var(--dim); }
.mvpwiz-body { display:grid; grid-template-columns:1fr 1fr; }
.mvpwiz-pane { padding:18px 20px; min-width:0; }
.mvpwiz-pane + .mvpwiz-pane { border-left:1px solid var(--line); }
.mvpwiz-pane h3 { margin:0 0 13px; font-size:12px; font-family:var(--mono); letter-spacing:.13em;
  text-transform:uppercase; color:var(--dimmer); font-weight:500; }
.mvpwiz-nominee { display:flex; align-items:center; gap:11px; margin-bottom:14px; }
.mvpwiz-nm { font-weight:600; font-size:16px; line-height:1.2; }
.mvpwiz-sub { font-family:var(--mono); font-size:11.5px; color:var(--dimmer); }
.mvpwiz-box { width:100%; min-height:186px; resize:vertical; background:var(--bg); color:var(--text);
  border:1px solid var(--line-hi); border-radius:var(--r-md); padding:12px;
  font-family:var(--sans); font-size:14px; line-height:1.6; }
/* Capped with a scroll, per her design: a nominee with eight quotes must not push the compose box
   off the screen, and the box is the half she is working in. */
.mvpwiz-quotes { display:flex; flex-direction:column; gap:9px; max-height:250px; overflow-y:auto;
  padding-right:4px; }
.mvpwiz-quote { background:var(--panel-hi); border:1px solid var(--line); border-radius:var(--r-md);
  padding:10px 12px; display:flex; gap:10px; align-items:flex-start; }
.mvpwiz-quote p { margin:0; font-size:13.5px; line-height:1.5; }
.mvpwiz-copy { flex:0 0 auto; background:transparent; border:1px solid var(--line-hi); color:var(--dim);
  border-radius:var(--r-sm); width:26px; height:26px; cursor:pointer; font-size:12px; line-height:1;
  display:grid; place-items:center; font-family:inherit; }
.mvpwiz-copy:hover { color:var(--text); border-color:var(--dim); }
.mvpwiz-foot { display:flex; align-items:center; gap:9px; padding:13px 20px; border-top:1px solid var(--line);
  background:var(--panel-hi); flex-wrap:wrap; }
.mvpwiz-foot .btn, .mvpwiz-foot form { margin-top:0; }
@media (max-width:820px) {
  .mvprow { grid-template-columns:1fr; gap:12px; }
  .mvpstats { grid-template-columns:repeat(auto-fill,108px); }
  .mvpact { justify-content:flex-start; }
  .mvpwiz-body { grid-template-columns:1fr; }
  .mvpwiz-pane + .mvpwiz-pane { border-left:0; border-top:1px solid var(--line); }
}

/* ---- Help queue block controls + notes header ---- */
/* REMOVED 2026-08-14 (Phase 2 audit), all four verified unused across every .php/.js/.html in the
   repo including HTMLSnapshots/ markup:
     .hqcontrol — superseded by `.hqctl` (below) when the Class -> Block two-step went with
       migration 60. Nothing has carried this class since.
     .hqpill { font-size:13px; padding:5px 12px; letter-spacing:1px } — a DUPLICATE, dead by
       cascade, not by markup: the `.hqpill` rule further down re-declares font-size and padding
       with !important and letter-spacing later-wins, so all three of these declarations lost.
       Editing this one was a silent no-op, which is the trap worth removing.
     .notehead / .appdialog .dlgmsg .notehead — the Follow-Ups notes-dialog header, superseded by
       `.popbody h3` when notes became the `.pop` popup (§2c).
     .dlghr — the notes-dialog rule, superseded with it. */

/* ---- Phase 3 polish ---- */
.btn[disabled], .btn-red[disabled] { opacity:.38; filter:grayscale(.7); cursor:not-allowed;
  box-shadow:none; }
/* schedule filter pills: breathe above, tuck below (was reversed) */
.boardtool.pillrow-lg { margin-top:22px !important; margin-bottom:4px; }
/* 🔴 THE ROSTER'S PENCIL GUTTER AND COLUMN MIN-WIDTHS ARE GONE [2026-08-28], with the flex row they
   propped up. Both were table behaviour hand-built out of flex items:

     · O12 [her note 1, 2026-08-23, her strongest complaint on that surface]: *"still have the same
       problem I had before with the edit pencil being way too far to the left and so much padding
       around it… Or maybe the edit pencil needs to live 'off-grid' like to the left of the left edge
       of content."* The pencil was taken out of the flow into the row's left gutter so it stopped
       acting as a first column.
     · O13 [her note 6]: *"I like things to line up, like names, usernames, tracks, roles."* Measured
       then: names aligned to the pixel, usernames spread 27px, role badges 5px and track chips
       130px, because every piece was a flex item starting wherever the last one ended. Each
       min-width was the widest measured value plus air.

   Both complaints are answered by the roster being an actual table now, and Edit name has moved off
   the pencil into the row's ⋮ menu. Kept as the record of what those numbers were for, so nobody
   re-derives them. `.pencil` itself is untouched and still styled above; it is on three other
   surfaces. */
/* theme cards: equal width in every row (student + join) */
.themepick { display:grid; grid-template-columns:repeat(auto-fill, minmax(148px, 1fr)); }
.themepick label { width:auto; }
.themepick .tswatch { width:100%; box-sizing:border-box; }
/* theme card labels center even when wrapped (Livewire two-liner) */
.tswatch { text-align:center; }

/* roster actions: fixed columns — buttons hold their lane whether or not neighbors render */
.rosteracts-cols { display:grid; grid-template-columns:110px 130px 150px; gap:8px; align-items:center; justify-content:end; }
.ract { display:flex; justify-content:flex-start; }
.ract-reset { justify-content:flex-end; }
@media (max-width: 900px) { .rosteracts-cols { grid-template-columns:1fr; justify-items:end; } }

/* tracker chips: one right-aligned rail regardless of name length. Still live — teacher/classes.php's
   Track Submissions uses .trkgrid. The `.trkpanel, .trackerpanel { max-width:520px }` rule that sat
   here went 2026-08-02 with the tracker bubble; neither class was ever rendered by any page. */
.trkgrid > :nth-child(even) { justify-self:end; }

/* teacher tier-2 phone dashboard.
   ⚠ `.phonedash` / `.pdcard` / `.pdnum` / `.pdlbl` ARE RETIRED [her ruling 2026-08-29]: the four
     counter cards are gone and the Alerts box is the phone view. Rules removed rather than left
     behind, because a stylesheet full of classes nothing renders is how the last person ends up
     styling a card that will never appear. */
@media (max-width: 640px) {
  /* 🔴 THE ALERTS BOX IS THE TEACHER'S PHONE DASHBOARD [her ruling 2026-08-29]. `.teachdesk` used to
     be hidden whole and four counter cards stood in for it; the cards are gone and this lets exactly
     one panel through instead.
     ⚠ `:has()` RATHER THAN NTH-CHILD, so the row holding Alerts is found by what it CONTAINS. A
       positional rule would silently start hiding the alerts and showing something else the first
       time anything on this page is reordered, and it would look like the alerts had stopped working.
     ⚠ THREE RULES, IN ORDER: show the wrapper, hide every child of it that does not contain the
       panel, then hide the panel's siblings inside the row that does. */
  .teachdesk { display:block; }
  .teachdesk > *:not(:has(.talerts)) { display:none; }
  .teachdesk .grid > *:not(.talerts) { display:none; }
  .teachdesk .talerts { grid-column:1 / -1; }
}

/* class wizard: clear question groups — titles lead, fields hug, groups breathe */
.wizgroup { margin-top:22px; }
.wizgroup:first-child { margin-top:14px; }
.wizq { font-size:15.5px; font-weight:700; display:block; margin-bottom:6px; }
.wizgroup input[type="text"], .wizgroup input[type="date"] { margin-top:2px; }
.lsopts { display:flex; flex-direction:column; gap:6px; margin-top:4px; }
.lsopt { padding:9px 11px; }
.wizaside { font-style:italic; font-size:12.5px; color:var(--dimmer); margin:12px 0 0; font-weight:400; }

/* ---- SCREEN 0 of the New Class wizard: two choices, centred, and nothing else ----
   [her rulings 2026-08-16, in order: the explanatory copy came out first — *"the buttons are pretty
   clear... We don't need the extra explanation"* — then *"The buttons should just be centered
   vertically and horizontally in the wizard box as well. Decent size as well."*]

   ⚠ SCOPED TO THIS SCREEN, deliberately. `.lsopt` is the app's shared option-row (Add Sprints, the
   leadership roles, the sub-class delete tiers) and those are LISTS you read down — left-aligned,
   compact, each with a description. This screen is a fork with two answers and no prose, so it gets
   its own treatment rather than the shared one growing a variant that leaks everywhere.

   THE min-height IS MEASURED, NOT PICKED: the four real steps render at 251 / 215 / 582 / 368px
   (Chromium, 560px dialog). At its natural 87px this screen was less than half of the shortest real
   step, so the dialog snapped noticeably taller on the first NEXT. 250px puts the PANEL level with
   steps 1 and 2. ⚠ Note the DIALOG is still shorter here (377 vs 456 on step 1) because screen 0
   also hides the 1-2-3-4 chip row — measured, not assumed, and left that way: this wizard already
   ranges 419-786px across its four steps, so chasing an exact match would mean padding this screen
   out with height it has no content for.

   BOTH OPTIONS ARE ONE WIDTH, never sized to their own text — same rule as the badges sharing a
   table column. "Start from scratch" is much shorter than "Start from another class", and two
   choice buttons of different widths read as different WEIGHTS of choice, which these are not.
   ⚠ THE WIDTH LIVES ON THE WRAPPER, NOT ON `.lsopt`. First attempt put `width:min(320px,100%)` on
   the options themselves and measured 257px — because their flex parent is itself a centred flex
   ITEM, so it shrink-wraps to its content and `100%` resolved against 257, not against the dialog.
   Sizing the wrapper and letting the options fill it is what makes the number mean what it says. */
.acstep0 { display:flex; flex-direction:column; align-items:center; justify-content:center;
  gap:12px; min-height:250px; }
.acstep0 .lsopts { width:min(320px, 100%); }
.acstep0 .lsopt { width:100%; box-sizing:border-box; justify-content:center;
  align-items:center; gap:10px; padding:18px 20px; font-size:15px; }
.acstep0 .lsopt input { margin-top:0; }
.acstep0 .lsopt b { font-size:15.5px; }
/* The class picker only appears under the second option, and matches its width so the pair and the
   thing they reveal read as one stack rather than three different measures. */
.acstep0 .acsrcpick { width:min(320px, 100%); }
.acstep0 .acsrcpick label { margin-top:2px; }

/* Create New Class wizard (#addclass): step strip + Step 3's amber warning + grading-period/block rows */
.wstep { display:inline-flex; align-items:center; padding:5px 12px; border-radius:999px;
  font-size:12.5px; font-weight:700; border:1px solid var(--line); color:var(--dim); background:var(--panel); }
.wstep.on { border-color:var(--brand); color:color-mix(in srgb, var(--brand) 55%, var(--text)); background:var(--brand-soft); }
.wstep.done { border-color:color-mix(in srgb, var(--green) 55%, transparent); color:var(--green); }
.notice-amber { background:color-mix(in srgb,var(--amber) 12%,transparent); border:1px solid color-mix(in srgb,var(--amber) 40%,transparent);
  border-radius:9px; padding:11px 14px; font-size:13.5px; line-height:1.55; color:var(--text); margin:14px 0 4px; }
.gprow { display:flex; align-items:center; gap:8px; margin-top:8px; flex-wrap:nowrap; }
/* 🔴 150px, NOT 128px [her report 2026-08-27]. At 128 the content box is 128 − 12 − 12 − 2 = 102px,
   and `mm/dd/yyyy` at 14.5px (~85px in Chrome) plus the 16px calendar picker indicator needs ~101 --
   so the text and the icon collided by design, not by rendering quirk. 150 leaves 124px.
   ⚠ A CEILING, not a floor: a .gprow in a narrower container still shrinks to fit, so raising this
   cannot overflow a layout that was already tight. */
.gprow input[type=date] { max-width:150px; }
/* The wizard's grading-period row is flex-wrap:nowrap and also carries a label, the word "to" and a
   remove button, so widening the inputs inside a 560px dialog would just move the squeeze onto the
   label. #addclass gets the room instead.
   ⚠ SCOPED TO THE WIZARD ON PURPOSE. Seven dialogs share .dlg-lg (#checkindlg, #holdlg,
   #leadertracksdlg, #resubse, #rselfdlg among them) and widening the class would resize all of them.
   Worth knowing while reading this: .dlg-lg declares width:min(1080px,94vw) earlier in this file and
   is then capped at 560px lower down, so a "large" dialog has always been narrower than its own
   declared width. That oddity is deliberately left alone here rather than fixed blind. */
#addclass.dlg-lg { max-width:min(760px, 94vw); }
.gplabel { font-weight:700; font-size:14px; min-width:82px; }
.blkrow { display:flex; align-items:center; gap:8px; margin-top:8px; flex-wrap:nowrap; }
.blkrow input[type=text] { max-width:200px; }

/* 🔴 `.rolesplus` WAS REMOVED HERE 2026-08-24, with the button it styled. O9 deleted the roster's
   circle-plus on her ruling (*"the + is removed"*, verified first that the pencil opens the same
   dialog), and nothing has rendered the class since — the rule and its WCAG note outlived their
   markup by a day and read as though the control were still on the page. The tap-target assertion
   that pointed at it moved to `.member-row .pencil`, and moved again on 2026-08-28 when that
   row became a table row
   (tests/ui/t_pulse_taptarget.py). */
.badge.b-track { background:transparent; border:1px solid; }
/* GROUP J's drift chip [2026-08-22 copy ruling]. `.badge` is uppercase with 1px letter-spacing by
   house convention (POST-MORTEM, PER TRACK, NOT SET); her drift copy is a sentence-case phrase
   ("No longer an A day"), so this returns just those two properties and changes nothing else — the
   colour, geometry and border all stay `b-amber`'s. The wrapper keeps the chip off the date it
   annotates without disturbing `.abpair`'s own min-width alignment. */
/* 🔴 ONE WIDTH FOR BOTH, her standing rule: badges sharing a table column get a min-width and are
   never sized to their own text. These two sit in the same Performance Reviews / Due cell, and the
   only difference between the two strings is the article ("an A day" / "a B day") — a 7px gap that is
   easy to miss by eye, which is why tests/ui/shot_daytype.py MEASURES them and prints them.
   THREE strings can appear here, not two: her 2026-08-23 ruling added the school-day case for a
   one-track class (and for any date that stops being a school day at all), and a split class can show
   a letter tag beside a school-day tag. Re-measured after the same day's copy revision shortened all
   three ("No longer an X" → "⚠ Not an X"): "⚠ Not an A day" 102px, "⚠ Not a B day" 95px,
   "⚠ Not a school day" 121px. **126px** clears the widest with a little air; the previous 146px was
   sized for the older, longer wording and left 25px of dead space once the copy shrank.
   `* { box-sizing:border-box }` above means this min-width is the whole tag including its padding.
   inline-flex + center is what makes the shorter string sit centred in the shared width rather than
   left-aligned with a ragged right edge. */
/* ⚠ `var(--sans)` EXPLICITLY, NOT `inherit`. `inherit` was the first version and it makes the chip's
   width depend on whichever cell it lands in: the grading-period dates sit in `td.mono` (monospace)
   and the Deliverables list does not, so the same string measured two different widths in the two
   places it appears — which defeats the shared min-width below and made measuring it unreliable.
   An explicit family also keeps the chip off the mono look `.badge` has by default. */
/* 🔴 A DRIFT FLAG IS NOT A STATUS BADGE, AND MUST NOT BE ONE IN THE DOM EITHER. The first version of
   this reused `.badge b-amber` for style economy, and that was a real mistake: it made a warning
   indistinguishable from a STATUS badge to every consumer that reasons about badges in these tables.
   Three separate things broke on the gate, none of them cosmetic — `#gptable .badge` began resolving
   to three elements and ABORTED t_classes_schedule on a strict-mode violation; t_classes_fixtures
   started counting a drift chip as a PER TRACK badge; and the same suite's "only the current period
   wears a CURRENT badge" count went 1 → 3.
   So it gets its own class and restates the geometry it had been borrowing. That is a few more lines
   than inheriting `.badge`, and it is the correct trade: the alternative was editing four assertions
   in two suites to teach them to ignore it, which would have left the next badge-counting selector to
   discover the same collision on its own. Colours are `.b-amber`'s exact values, so it still reads as
   the app's amber warning and its contrast is unchanged (t_axe covers it). */
.driftchip {
  display:inline-flex; align-items:center; justify-content:center; gap:5px; min-width:126px;
  font-family:var(--sans); font-size:11px; font-weight:600;
  padding:2.5px 8px 2.5px 9px; border-radius:20px; border:1px solid;
  color:var(--amber); border-color:color-mix(in srgb, var(--amber) 40%, transparent);
  background:var(--amber-soft);
}
.driftwrap { margin-left:8px; }
/* The chip's tip opens DOWNWARD. `.htip .htiptext` defaults to opening upward, which is right for the
   controls it was drawn for but wrong here: the first row of the Grading Periods table sits directly
   under the table head, so an upward tip was clipped along its top edge (measured on the capture, not
   guessed). This is the same collision `dialog .qtiptext` already solves by opening down, so it reuses
   that answer rather than adding another JS placer like Group B's qtipPlace(). Both surfaces this chip
   appears on (the periods table and the Deliverables list) have room below every row. */
/* ⚠ `.htip.driftwrap`, NOT `.driftwrap`: the base `.htip .htiptext` rule lives ~420 lines BELOW this
   one and carries the same specificity (0,2,0), so a plain `.driftwrap .htiptext` loses every property
   they both set — measured, not assumed: `top` appeared to work only because the base rule sets
   `bottom` and CSS resolves top-wins when height is auto, while `max-width:220px` and
   `white-space:normal` silently kept overriding and the tip went on wrapping onto the row below.
   0,3,0 wins wherever the rule happens to sit. */
.htip.driftwrap .htiptext {
  bottom:auto; top:calc(100% + 8px);
  white-space:nowrap; width:max-content; max-width:none;
}

/* Toasts: bottom-left of the viewport, to the right of the sidebar; hold then fade */
/* 🔴 THE INSTALL OFFER [her ruling 2026-08-29] -- see app/ui.php for how Chrome decides to show it.
   Bottom of the screen and out of the way: it is an offer, not an interruption, and the thing it sits
   over is a page the student came here to read.
   ⚠ It is `hidden` until `beforeinstallprompt` fires, and it has its own `[hidden]` rule because a
     class `display` outranks the UA sheet's -- the same trap that had `.reqflag` and `.ci-menu` on
     screen when they thought they were hidden. */
.pwabar { position:fixed; left:10px; right:10px; bottom:10px; z-index:1300;
  display:flex; align-items:center; gap:12px; padding:12px 14px;
  background:var(--panel); border:1px solid var(--line-hi); border-radius:var(--r-lg);
  box-shadow:0 8px 30px rgba(0,0,0,.45); font-size:13px; }
.pwabar[hidden] { display:none; }
.pwabar-ico { flex:none; display:flex; }
.pwabar-ico img { border-radius:9px; }
.pwabar-txt { flex:1 1 auto; min-width:0; line-height:1.4; }
.pwabar-txt .dim { font-size:12px; }
.pwabar-acts { flex:none; display:flex; gap:8px; align-items:center; }
.pwabar-acts .btn { margin:0; }
/* Above 640 there is no home screen to add to, so the offer is phone-only. Chrome on a desktop can
   install a PWA too, but she asked about phones and a bar across a teacher's laptop is noise. */
@media (min-width:641px) { .pwabar { display:none !important; } }
@media (max-width:400px) {
  .pwabar { flex-wrap:wrap; }
  .pwabar-acts { width:100%; justify-content:flex-end; }
}

#toastrail { position:fixed; left:calc(224px + 22px); bottom:22px; z-index:1400;
  display:flex; flex-direction:column; gap:10px; pointer-events:none; }
.toast { background:var(--panel-hi); border:1px solid var(--line-hi); border-left:3px solid var(--brand);
  border-radius:var(--r-md); padding:12px 18px; font-size:14.5px; color:var(--text);
  box-shadow:0 10px 30px rgba(0,0,0,.5); opacity:0; transform:translateY(8px);
  transition:opacity .25s ease, transform .25s ease; max-width:420px; }
.toast.on { opacity:1; transform:none; }
.toast.fade { transition:opacity 3s ease; opacity:0; }
/* The dismiss control [2026-08-16, accessibility build 2 — see app/ui.php's toast() for the why].
   `pointer-events:auto` on the BUTTON only: #toastrail stays none, so a plain confirmation still
   never intercepts a click behind it (a live ruling), while the one thing that has to be clickable
   is. Sized to the 24x24 AA tap target the Board audit settled on. */
.toast { display:flex; align-items:flex-start; gap:12px; }
.toastx {
  pointer-events:auto; cursor:pointer; flex:none; margin:-2px -6px 0 0;
  min-width:24px; min-height:24px; line-height:1;
  background:transparent; border:1px solid transparent; border-radius:var(--r-sm);
  color:var(--dim); font-size:13px;
}
.toastx:hover { color:var(--text); border-color:var(--line-hi); }
/* #toastrail is pointer-events:none so plain confirmation toasts never intercept clicks behind
   them; the help-queue toast carries real buttons, so it opts back in. */
.qtoast { pointer-events:auto; border-left-color:var(--amber); }
@media (max-width:900px) { #toastrail { left:14px; right:14px; } .toast { max-width:none; } }

/* Manage Leave dialog */
.leavesec { border-top:1px solid var(--line); padding:12px 0 10px; }
.leavesec:first-of-type { border-top:0; padding-top:2px; }
.leaveh { margin:0 0 8px; font-family:var(--mono); font-size:12px; letter-spacing:1.5px; color:var(--brand); }
.leaverow { display:flex; gap:10px; align-items:end; flex-wrap:wrap; margin:0; }
.leaveitem { justify-content:space-between; align-items:center; padding:4px 0; }
/* 🔴 THE ATTENDANCE SELECT KEEPS ITS COMPACT SIZE, IN ITS NEW HOME [2026-08-28]. It lives inside the
   roster row's ⋮ menu now rather than at the end of a flex row, so the `.m-right { flex-wrap:nowrap }`
   half of this fix is gone with the row it was fixing. Her original complaint stands and is why the
   rest stays: *"Present and Accounted For is really long and makes that box way too long. That drop
   down also isn't stylized."* The long option is still "On Time" (HER RULING, O-COPY 2026-08-23) and
   `.styledsel` is still what gives it a pill and a chevron -- the sitewide select rule strips the
   native caret, so a bare <select> here would have no affordance at all. */
.astatus { padding:6px 8px; font-size:13px; margin:0; }
.astatus.styledsel { padding:6px 34px 6px 12px; font-size:13px; }

/* Step 15: leadership vote report (print-first) */
.lvreport { max-width:900px; }
.lvteam { margin-top:16px; break-inside:avoid; }
.lvq { margin:12px 0 0; }
.lvqt { font-weight:600; margin-bottom:6px; }
.lvbars { display:flex; flex-direction:column; gap:5px; }
.lvbar { display:grid; grid-template-columns:170px 1fr 30px; align-items:center; gap:10px; position:relative; }
.lvname { font-size:13.5px; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.lvfill { height:14px; border-radius:4px; background:color-mix(in srgb, var(--brand) 45%, transparent); min-width:3px; }
.lvtop .lvfill { background:var(--brand); }
.lvtop .lvname { font-weight:700; }
.lvn { font-family:var(--mono); font-size:12.5px; text-align:right; }
.lvtie { font-family:var(--mono); font-size:10px; letter-spacing:1px; color:var(--amber);
  border:1px solid var(--amber); border-radius:4px; padding:1px 5px; margin-left:6px; }
@media print {
  .lvreport .panel { border:1px solid #999; box-shadow:none; }
  .lvfill { background:#888 !important; -webkit-print-color-adjust:exact; print-color-adjust:exact; }
  .lvtop .lvfill { background:#333 !important; }
  body { background:#fff !important; color:#111; }
}

/* Class switcher rows in the account popup */
.swrow { display:block; width:100%; text-align:left; padding:7px 9px; margin:3px 0 0; font-size:13px;
  background:var(--panel); border:1px solid var(--line); border-radius:var(--r-sm); color:var(--text); cursor:pointer; }
.swrow:hover { border-color:var(--brand); }
.swrow-on { border-color:var(--brand); color:var(--brand); font-weight:700; cursor:default; }

/* The merge search list's CSS (.msearch/.mlist/.mrow/.mrow-name/.mrow-user/.mnone) lived here and was
   DELETED 2026-08-03 with the Removal Options rebuild — the dialog it styled is gone and the merge
   step is a plain sitewide `select` now. Nothing else in the app referenced any of those six classes.
   (It was also the only user of --accent-dim / --accent / --fg, three variables no theme block in this
   file defines, so those rules had already been rendering with invalid values.) */

/* ⚠ `.colorpop` / `.colordot-btn` REMOVED 2026-08-22. They styled the board header's standalone
   card-colour dot and its popover, which her I3 note retired: a lone coloured circle sitting in a row
   of filters could only be read as a filter. The picker lives inside your own face in the member
   avatar row now (`.mempop` / `#memmine`), and the swatch styles it reuses (`.swatches`, `.swatch`)
   are shared with Settings and are still very much live — only these two board-only rules went. */

/* ---------- Manage Leave (redesign): menu -> screen flow ---------- */
.lvscreen { display:none; }
.lvscreen.on { display:block; }
/* The Request Leave From/To pair. These values are exactly what used to sit inline on the div, so
   the desktop row is unchanged; the class exists only so the phone rule below can win without
   `!important` (an inline style outranks any selector). */
.lvdaterow { display:flex; gap:12px; }
/* 🔴 STACKED ON A PHONE [2026-08-24, MOBILE_BUILD_PLAN §5]. Measured before: From and To shared one
   row at 150px each (both top:360, at left:39 and left:201). A native date control has to hold
   yyyy-mm-dd plus a picker glyph, and 150px is not enough for that on a phone.
   ⚠ MEASURING THIS CORRECTLY REQUIRES FILTERING `offsetParent === null`. The Manage Leave dialog
   holds a THIRD date input — `name="day"`, on the sick-call-in screen — and `.lvscreen` hides the
   inactive screens with `display:none`. A check that collects every `input[type=date]` in the
   dialog compares From against that hidden one, finds different offsets, and reports the row as
   already stacked when it is not. That has happened; the audit for this now asserts exactly two
   VISIBLE date inputs before it compares anything. */
@media (max-width:640px) {
  .lvdaterow { flex-direction:column; gap:10px; }
}
.lvopt { display:flex; align-items:center; justify-content:space-between; width:100%; text-align:left;
  background:var(--bg-deep); border:1px solid var(--line-hi); border-radius:var(--r-md);
  padding:17px 18px; margin-top:10px; cursor:pointer;
  font-family:var(--sans); font-size:16px; font-weight:700; color:var(--text); transition:all .12s; }
.lvopt:hover { border-color:var(--brand); background:var(--brand-soft); color:color-mix(in srgb, var(--brand) 55%, var(--text)); }
.lvchev { color:var(--dim); font-size:22px; line-height:1; }
.lvscreen h4.lvhd { font-family:var(--disp); font-size:18px; font-weight:800; margin:0 0 14px; }
.lvitem { display:flex; align-items:center; justify-content:space-between;
  background:var(--bg-deep); border:1px solid var(--line-hi); border-radius:var(--r-md);
  padding:13px 16px; margin-top:9px; }
.lvitem .d { font-weight:700; }

/* Approved MVP nominations shown greyed in the Show-approved view.
   🔴 WAS `opacity:.55` [fixed 2026-08-15, Teacher Meetings audit]. The Reason cell is ALREADY
   `<td class="dim">` (teacher/meetings.php:711), so the row multiplied a dimmed colour by a second
   dimming factor — and measured against every theme's own `--panel`, `--dim @55%` came out at
   2.54-3.34:1, failing WCAG AA in **16 of 16 themes**. `--text @55%` failed in 4 more (bubblegum
   4.10 worst). This is not a value that can be tuned: the search for the largest alpha where all 16
   clear 4.5:1 returns 100% — i.e. any opacity below 1 fails somewhere in this palette, so opacity is
   simply the wrong device for de-emphasising text here.
   `--dimmer` is the app's existing "quieter than --dim" token and clears AA in all 16 (4.50 worst,
   sunsettide). It also lands on EVERY cell in the row, including the `.dim` Reason one, so the row
   de-emphasises uniformly instead of compounding — which is what the opacity was reaching for. */
tr.nomgrey td { color:var(--dimmer); }
/* 🔴 THE AVATAR DIMS WITH THE ROW [chat 7a, 2026-09-05]. The rule above reaches every cell's TEXT,
   which is what makes the row de-emphasise uniformly instead of compounding — but the approved rows
   now carry person_line_html(), and its avatar is a coloured bubble rather than text, so it kept
   full strength and became the BRIGHTEST thing in a row that is supposed to read as already dealt
   with. Caught in the design capture, not by a test.
   ⚠ OPACITY IS RIGHT HERE AND WRONG ON THE TEXT, which is not a contradiction of the note above:
     that note rejected opacity because dimmed TEXT has to keep clearing 4.5:1 in all 16 themes and
     no alpha below 1 does. An avatar carries no such requirement — the name beside it is the label,
     and the bubble is decoration — so lowering it costs no information. */
tr.nomgrey .avatar { opacity:.5; }

/* ---------- Attendance check-in slide (deck) ---------- */
.codebox { margin:28px auto 0; display:inline-flex; align-items:center;
  font-family:var(--mono); font-size:52px; font-weight:700; letter-spacing:6px;
  color:color-mix(in srgb, var(--brand) 55%, var(--text)); background:var(--brand-soft);
  border:2px solid color-mix(in srgb, var(--brand) 55%, transparent);
  border-radius:var(--r-lg); padding:20px 40px; box-shadow:var(--shadow-2); }
.codehint { font-family:var(--mono); font-size:12px; letter-spacing:2px; color:var(--dimmer);
  text-transform:uppercase; margin-top:16px; }
@media (max-width:820px) { .codebox { font-size:38px; letter-spacing:4px; padding:16px 26px; } }

/* ---------- Meetings page: state pills, glow buttons, retro card, review rows ---------- */
.statepill { font-family:var(--mono); font-size:13px; font-weight:700; letter-spacing:1px; text-transform:uppercase; padding:7px 16px; border-radius:20px; border:1px solid; }
/* 🔴 SAME-WIDTH RULE APPLIED TO THE RETRO STATUS COLUMN [her standing rule of 2026-08-02, extended
   here 2026-08-15 during the Teacher Meetings audit]. Every `<details>` in the retro list ends with
   one of these four pills at the same right edge, so they ARE a column in her sense — and measured
   in Chromium they were ragged by 36px: Complete 106, Skipped 97, In Progress 133, Not Started 133,
   with `min-width` computing to `auto`. The width is the LONGEST label in the set (11 characters,
   IN PROGRESS / NOT STARTED = 133px), same derivation as `.rrow .badge, .revstat`'s own 104px, and
   `min-width` rather than `width` so a longer label would still grow rather than overflow.
   `display:inline-block` is load-bearing, not decoration: `min-width` does not apply to a
   non-replaced inline element, and `.statepill` is a bare `<span>`.
   ⚠ SCOPED TO THIS LIST ON PURPOSE, not put on `.statepill` itself. The student Meetings page
   (public/meetings.php:313/439/448/486-490) also stacks statepills down one edge and has the same
   raggedness, but its label set includes longer strings, so one shared number cannot be derived
   from this page's four. Same reasoning as `.card-edit` being scoped rather than `.pencil`. That
   page's own set is flagged for its own audit rather than guessed at from here. */
.mtscroll-retro .statepill { min-width:133px; text-align:center; display:inline-block; }
/* THE STUDENT SIDE'S OWN VERSION, flagged above as needing its own number [built 2026-08-15]. This
   page's three pill sites (MVP nomination, retro attendance, self-eval) don't share a single parent
   the way the teacher list's rows do (two live under `#tab-retro`, one under `#tab-reviews`), so a
   marker class on each `<span>` — same idea as `.sp-*` doing the same job for colour — rather than a
   scoped-ancestor selector. Longest of its 11 possible labels, measured in Chromium exactly like the
   teacher column was: "NOT SUBMITTED" at 148.75px. 152px gives it the same few pixels of margin the
   teacher column's 133px carries over its own 131.09px measured longest. */
.mtg-statepill { min-width:152px; text-align:center; display:inline-block; }
.sp-red    { color:var(--red);      border-color:color-mix(in srgb, var(--red) 45%, transparent);      background:var(--red-soft); }
.sp-yellow { color:var(--amber);    border-color:color-mix(in srgb, var(--amber) 45%, transparent);    background:var(--amber-soft); }
/* 🔴 `.sp-green` MOVED OFF `--accent-3` ONTO THE LOCKED `--green` [her ruling 2026-08-15: *"If it's
   supposed to be green then make it green (or whatever it is in the color blind one)."*].
   `--accent-3` is a DECORATIVE per-theme token, so the "Complete" pill was not green in most themes:
   catalystcats (the default) resolves it to #d9925c, burnt orange; overclock to #f5a800. Measured in
   OKLab against `--amber` — the "In Progress" pill sitting in the SAME 133px column — `--accent-3`
   fell below the app's own dE >= 0.20 "must not be confusable" bar in 11 of 16 themes: overclock
   0.050, summersorbet 0.079, pumpkinspice 0.101, catalystcats 0.110 (which is also 0.108 from
   `--red`). So the done pill, the warning pill and the danger pill were near-indistinguishable in the
   shipped default, and an orange "Complete" also read against the reserved-palette rule.
   `--green` is the LOCKED semantic token (`:root, [data-theme]` further up), which is why this also
   answers the colourblind half of her ruling for free: livewire overrides `--green` to Wong bluish-
   green #00b083, so the pill follows that theme's own accessible palette with no second rule here.
   `.b-green` was already re-pointed this way; `.sp-green` was simply missed at the time. */
.sp-green  { color:var(--green);    border-color:color-mix(in srgb, var(--green) 45%, transparent);    background:var(--green-soft); }
.sp-dim    { color:var(--dim);      border-color:var(--line-hi); background:rgba(255,255,255,.03); }
.glowbtn   { box-shadow:0 0 0 3px var(--brand-soft), 0 2px 14px color-mix(in srgb, var(--brand) 45%, transparent); }
.bignext   { font-family:var(--disp); font-size:15px; line-height:1.6; }
.bignext .date { color:var(--brand); font-weight:700; font-size:17px; }

/* THE CEREMONY DUE / CLOSES LINE — variant B [placement chosen 2026-08-02, colours FINAL 2026-08-02,
   built 2026-08-03]. Markup shape comes from HTMLSnapshots/PROPOSAL-close-date-placement.html
   (variant B) and the reviews twin PROPOSAL-reviews-close-date.html; the COLOURS come from her later
   prose ruling, which the mockups predate. Where the two disagree, prose wins on behaviour/colour and
   the snapshot wins on structure, per AI-INSTRUCTIONS.

   `Closes` IS RED FROM THE START and never changes — her call: *"closes needs to be red from the start
   to scream at you as the final date to not pass ... Both starting neutral feels too neutral."* The
   close date is a fixed fact that never becomes less important, and while the ceremony is on time it is
   the ONLY coloured thing on the line, so the "an always-on alarm says nothing" concern does not apply
   (that is about a whole lit-up line, not one anchor element).

   `Due` IS NEUTRAL, TURNING AMBER ONCE LATE — one boolean (`today > due`) drives it, and that is the
   escalation the earlier all-neutral draft was reaching for: when a ceremony goes late the line visibly
   worsens instead of looking the same as an on-time one.

   🔴 NEUTRAL HERE MEANS --text, NOT --brand, so these deliberately override .bignext .date above. A
   brand-coloured Due date was requested ("something fun") and ruled out for a MEANING collision, not
   an aesthetic one: across the 16 themes' --brand, three are green (reads as done/all-good next to a
   red cliff), one is yellow (indistinguishable from amber) and one is literally red (Due and Closes
   would render identically and the whole distinction disappears). --amber/--red are the right vars
   because they are per-theme AND carry the colourblind-safe Wong remap, so they adapt while holding one
   constant meaning — exactly the split the design system states.

   Same specificity as .bignext .date (two classes), so ORDER is what makes these win. Keep them after. */
.bignext .cddue       { color:var(--text); }
.bignext .cddue.late  { color:var(--amber); }
.bignext .cdclose     { color:var(--red); }

/* The "this one shut early for its own reason" line on the MVP / Self-Evaluation cards. Deliberately
   quiet: the statepill above it already shouts CLOSED / LOCKED, and this only says why. Her copy
   ruling 2026-08-03 cut the redundant "Closed —" / "Locked —" prefix, which removed an em dash too. */
.cdclosed { font-size:13px; color:var(--dimmer); font-style:italic; }
.stucard   { background:var(--bg-deep); border:1px solid var(--line); border-radius:var(--r-md); padding:20px 22px; margin-bottom:12px; }
.stuname   { font-family:var(--disp); font-size:22px; font-weight:800; }
/* The review overview rows. Bumped 2026-08-02 — her words: *"that whole review overview table is
   small, text-wise."* 15 -> 16px with a little more air, and the status column widened from 130 to
   150 so a pill is never the thing being squeezed.
   ⚠ APPROVE OR ADJUST: she asked for "bigger", modestly, without numbers — the exact sizes here and
   in the two rules below are mine. */
.rrow      { display:grid; grid-template-columns:minmax(150px,1fr) 200px 150px; align-items:center; gap:12px; padding:15px 0; border-top:1px solid var(--line); font-size:16px; }
.rrow:first-child { border-top:0; }
/* THE SINGLE-ROW CASE [her note: *"I hope it's bigger for a student that only has one thing there
   and not the whole tracker"*]. A plain member sees exactly ONE row — their own — and inheriting a
   density chosen for a producer's five-row list left it looking like a stray line rather than the
   panel's actual content. `:first-of-type:last-of-type` is the honest test: every div inside this
   panel IS an .rrow, so matching both ends means there is genuinely only one. */
.rrow:first-of-type:last-of-type { font-size:17px; padding:6px 0 4px; }
.rrow:first-of-type:last-of-type .badge { font-size:12px; padding:5px 11px 5px 12px; }
/* And the pills inside a multi-row table, which are what she actually pointed at: 10px was reading
   as fine print next to the 13px .statepill on the retro card directly above it. */
.rrow .badge { font-size:11px; padding:4px 10px 4px 11px; }

/* 🔴 SAME-WIDTH STATUS PILLS [her rule, 2026-08-02]: *"I always want multi badges in the same table
   to be the same width, so fixed width. And if you size the badge to the text, they may not all stay
   this way. Only important to ones with multiple in a table/row."* A badge sized to its own text
   makes a COLUMN of them ragged — NOT STARTED, COMPLETE and EXCUSED all different widths down one
   edge — which is the thing being objected to. min-width plus centred text fixes it without
   hardcoding a width that a longer label would overflow: the pill grows if it ever has to.
   The width is set from the LONGEST label in each set (NOT STARTED / IN PROGRESS, 11 characters).
   `.revstat` (the teacher Meetings review rows) already solved this on its own and is folded in here
   rather than kept as a second copy of the same three declarations. */
.rrow .badge, .revstat { min-width:104px; text-align:center; display:inline-block; }
/* 🔴 GROUP N, N11: the MVP Nominations CLOSED pill. *"slightly bigger, with more space off the
   header"* — 12px against `.badge`'s default 10px, and 12px of air after the heading text it follows.
   Colour comes from `b-red` on the element; only size and spacing are here, so it still reads as the
   app's red badge rather than becoming a one-off. Deliberately NOT given the 104px min-width above:
   that rule exists so the review-row status badges share a column, and this one stands alone next to
   a heading, where a fixed width would just add a gap. */
.badge.mvpclosed { font-size:12px; margin-left:12px; vertical-align:2px; }
/* 🔴 GROUP N, N1 · the Meetings Block picker row: label BESIDE the control [her ruling 2026-08-23].
   `label { display:block; margin:16px 0 4px }` (the caption-above-input default) was stacking it, and
   the inline `margin-right` it carried could not act on a block element. `margin:0` on the label is
   what cancels the caption margins; `align-items:center` is what puts the two on one centre line. */
.mtblkrow { display:flex; align-items:center; gap:10px; margin:0 0 12px; }
.mtblkrow label { display:inline-block; margin:0; }
.rrow:first-of-type:last-of-type .badge { min-width:114px; }   /* 12px text needs the extra 10px */
.revbtn    { min-width:100px; }
/* ---------- N9 · the merged Performance Review list [her redesign, built 2026-08-23] ----------
   One list per team accordion: her reviewees, a rule, then the other students. Replaces a flat list
   sitting above an `hr` with a second `<details>` for everyone else. The row itself is the same flex
   shape the old markup carried inline; it moves here because there is now ONE renderer producing it
   instead of two copies. */
.rvrow { display:flex; align-items:center; gap:10px; padding:9px 0; border-bottom:1px solid var(--line); }
.rvname { flex:1; display:flex; flex-direction:column; gap:2px; }
/* Her per-row note: *"Reviewer: You" highlighted for theirs, "Reviewer: Bob" for the rest.* Under
   the name rather than beside it, so the name stays the thing you scan down the column and the
   note never pushes the status pill out of its shared right edge. */
.rvrev { font-size:12px; color:var(--dimmer); }
.rvrev-you { color:var(--brand); font-weight:600; }
/* Her rule between the two groups. Deliberately not `<hr>`'s default: this is a divider inside a
   list that already uses 1px --line borders per row, so it needs to read as heavier than one of
   those without becoming a second panel edge. */
.rvsplit { border:0; border-top:1px solid var(--line-hi); margin:14px 0 4px; }
/* *"the reviewer's own Review buttons ringed in brand colour and the others plain"*. The fill/ghost
   split already distinguished them; the ring is what she actually asked for, so it goes on every
   button in her own rows including the ghost ones (Edit Goal, and N5's disabled Review). */
.rvrow.rvmine .revbtn { border-color:var(--brand); }
/* 🔴 N5 · the disabled Review button. See meetings.php for why `disabled` is what makes this grey
   legal — a previous pass had to REMOVE `opacity:.55` from this same button because it was enabled,
   which put it under WCAG 1.4.3 and failed AA in 16 of 16 themes at 2.69-3.63:1. Disabled controls
   are exempt from 1.4.3, so the dimming is sound now and only now.
   `pointer-events:none` is load-bearing, not tidiness: a disabled button does not dispatch mouse
   events in Chromium, and without this the hover dies on the button instead of reaching the `.htip`
   wrapper that owns the tooltip. The wrapper also carries `tabindex="0"` so the reason survives for
   keyboard users, who can no longer focus the button itself. */
.revbtn[disabled] { opacity:.55; cursor:default; pointer-events:none; }
.htip[tabindex]:focus-visible { outline:2px solid var(--brand); outline-offset:2px; border-radius:6px; }

/* ---------- Self-eval / check-in stepper (one question per screen) ---------- */
.sestep { padding:0; }
.sestep-head { padding:4px 2px 14px; border-bottom:1px solid var(--line); margin-bottom:0; }
.sestep-head h3 { margin:0; font-family:var(--disp); font-size:20px; font-weight:800; }
.seprog { height:5px; background:var(--line); border-radius:99px; margin-top:12px; overflow:hidden; }
.seprog > span { display:block; height:100%; background:var(--brand); border-radius:99px; transition:width .2s; }
.seqwrap { padding:22px 2px; }
.seqtext { font-family:var(--disp); font-size:19px; font-weight:700; line-height:1.35; margin-bottom:16px; }
.seopt { display:block; border:1.5px solid var(--line-hi); border-radius:var(--r-md); padding:13px 16px; margin-bottom:10px; cursor:pointer; font-size:15px; transition:all .12s; }
.seopt:hover { border-color:var(--dim); }
.seopt.sel { border-color:var(--brand); background:var(--brand-soft); color:color-mix(in srgb, var(--brand) 55%, var(--text)); }
.sefoot { display:flex; align-items:center; gap:10px; padding:14px 2px 2px; border-top:1px solid var(--line); }

/* ---------- Retro deck: full-screen cover (present entry; deck stays put behind it) ---------- */
/* The `.deckcover*` family (8 rules: the full-screen pre-deck overlay, its kicker/title/sub, the
   "Enter Full Screen to Present" button and the back link) was deleted here 2026-08-02 when the
   cover itself went — the title is a normal slide now, so nothing rendered any of them. They remain
   inlined in the frozen HTMLSnapshots, which have their own copy of this stylesheet and are not
   regenerated. `.slide-title` below is what styles the replacement. */
.slide-title .slidesub { max-width:none; }

/* ---------- Review wizard: centered full-screen overlay (two people, one laptop) ---------- */
.revdeck { position:fixed; inset:0; z-index:190; background:var(--bg-deep); display:flex; flex-direction:column; }
/* scrollbar-gutter for the same reason .deckbody has it — see that note. */
.revbody { flex:1; overflow:auto; scrollbar-gutter:stable; display:flex; flex-direction:column; align-items:center; padding:34px 20px 26px; }
.revbody #rvform, .revbody .meetnav { width:100%; max-width:840px; }
.revbody .grid { margin:0; }

/* ===== Meetings restructure (P3) — accordions + review buttons =====
   🔴 THE NINE `.fdd*` RULES THAT SAT HERE ARE GONE [2026-08-15, Teacher Meetings audit]. They styled
   a hand-rolled click-menu that was the Meetings page's block filter, on exactly one line of one
   file — and that control had no click handler anywhere in the repo, so it never filtered anything.
   Her ruling was that the page genuinely should filter by block, so it is now a real
   `<select class="styledsel fsel">` like every other block filter in the app, and this markup has no
   remaining user. Checked before deleting: zero `fdd` references in any `.php` or `.js` outside the
   replacement's own explanatory comment. (The blessed Meetings snapshots still contain `.fdd`
   markup, but each snapshot inlines its own CSS, so removing it here cannot affect them — the same
   reasoning that kept `.dlgdivider` alive in the 2026-08-14 sweep applies in reverse.) */
/* 🔴 N6 · `overflow:hidden` IS GONE, AND IT WAS THE CAUSE OF BOTH N5's AND N6's SYMPTOM
   [her notes, fixed 2026-08-23]. She reported *"an invisible hover on the reviewee names"* clipped
   inside the top of the accordion, and separately that N5's disabled-Review tooltip was cut off by
   the accordion's right edge. One rule did both: `.htip .htiptext` renders at
   `bottom:calc(100% + 8px)`, i.e. ABOVE its trigger, so a tooltip on anything in a summary row is
   drawn above the top edge of the very box that was clipping it. Her call was *"remove it; if it
   stays, it has to render outside the box"* — this makes every tooltip in every accordion render
   outside the box, app-wide, rather than fixing the two she happened to land on.
   WHAT THE CLIPPING WAS FOR: `border-radius` on a box whose children could paint into the corners.
   Nothing here does — `.acc` carries the only background, `summary` and `.accbody` are transparent —
   so the radius holds on its own. `.acc>summary` takes the matching top radius anyway, so a future
   hover or drag background cannot square off the corner and reintroduce the reason for the clip.
   ⚠ If something ever DOES need clipping here, clip that child, not the accordion. */
.acc{background:var(--bg-deep);border:1px solid var(--line);border-radius:var(--r-md);margin-bottom:10px}
.acc>summary{border-radius:calc(var(--r-md) - 1px) calc(var(--r-md) - 1px) 0 0}
.acc:not([open])>summary{border-radius:calc(var(--r-md) - 1px)}
.acc>summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:12px;padding:14px 18px}
.acc>summary::-webkit-details-marker{display:none}
.acc>summary::before{content:"\25B8";color:var(--dim);font-size:18px;line-height:1;transition:transform .15s;display:inline-block;flex-shrink:0}
.acc[open]>summary::before{transform:rotate(90deg)}
.accbody{padding:2px 18px 16px 44px}
.amberbtn{background:var(--amber)!important;border-color:var(--amber)!important;color:#241a05!important}
.pillbtn{font-family:var(--mono);font-size:12px;font-weight:700;letter-spacing:1px;text-transform:uppercase;padding:6px 15px;border-radius:20px;border:1px solid var(--brand);background:var(--brand);color:var(--brand-ink);cursor:pointer;transition:filter .15s}
.pillbtn:hover{filter:brightness(1.1)}
/* `.revstat`'s own standalone rule lived here; it is now part of the shared same-width-badges rule
   in the Meetings block above [2026-08-02]. Kept as a pointer rather than a second copy. */

/* ===== Communication hub (P3) — blessed design 2026-07-22 ===== */
.commsec h2{font-size:22px !important;margin:0 0 12px !important}
.styledsel{appearance:none;-webkit-appearance:none;width:auto;font:600 13px var(--sans);color:var(--text);
  background:var(--panel);border:1px solid var(--brand);border-radius:20px;padding:7px 38px 7px 15px;cursor:pointer;
  box-shadow:0 0 0 3px var(--brand-soft),0 0 11px -2px color-mix(in srgb,var(--brand) 45%,transparent);
  /* 🔴 N2 / O18 · THE FILTER PILLS HAD THE SMALLER, CRUDER ARROW OF THE TWO, and these are the
     dropdowns she looks at most (the Block pickers on Dashboard, Meetings and Teams). Three things
     were wrong with it and only one was size:
       · 12px against the base select's 13, so the MORE prominent control had the SMALLER glyph;
       · a second hardcoded grey, `#bbb`, where the base select uses `#b8bdc9` — the same glyph in two
         colours depending on which rule won;
       · no `stroke-linecap`/`stroke-linejoin`, so its ends were square while every other icon in the
         app is round. That is most of why it read as cruder rather than merely smaller.
     Now 16px, the same `#b8bdc9`, the same round stroke language, and `stroke-width` 2.5 to match.
     Measured on `--panel` in all 15 themes: 5.87:1 worst case (bubblegum), past the 3:1 floor. */
  background-image:url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='16' height='16' viewBox='0 0 24 24' fill='none' stroke='%23b8bdc9' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'><path d='M6 9l6 6 6-6'/></svg>");
  background-repeat:no-repeat;background-position:right 13px center}
.styledsel:hover{box-shadow:0 0 0 3px var(--brand-soft),0 0 16px -1px color-mix(in srgb,var(--brand) 60%,transparent)}
/* 🔴 THIS CONTROL HAD NO FOCUS INDICATOR AT ALL [fixed 2026-08-07, Teacher Dashboard audit].
   Measured in Chromium on the dashboard's Block picker: the resting and focused computed styles were
   byte-identical. `outline:none` here killed the global `:focus-visible` ring at line 334 (a
   `.styledsel:focus` selector outranks it), and the only thing `select:focus` at line 620 had left to
   offer was `border-color:var(--brand)` + `box-shadow:0 0 0 3px var(--brand-soft)` — both of which
   `.styledsel` ALREADY paints at rest, so focusing the pill changed nothing a sighted keyboard user
   could see. WCAG 2.4.7.
   `outline:none` stays for the mouse path (the resting pill already reads as a ring, and a brand-on-
   brand outline just muddied it) — the ring comes back on `:focus-visible` only, and in `--text`
   rather than `--brand` so it is visible AGAINST the brand-coloured border in all 15 themes rather
   than blending into it. */
.styledsel:focus{outline:none}
.styledsel:focus-visible{outline:2px solid var(--text);outline-offset:3px}
/* .fsel: the compact sizing modifier for filter bars (Board, Dashboard) — always paired with
   .styledsel now, never used alone, so the filter-pill look is ONE component everywhere instead of
   a separate near-duplicate. Two-class selector wins on specificity over .styledsel's own padding. */
/* N2: the compact filter variant needs the same clearance as its full-size parent — 13px offset plus
   a 16px glyph is 29px, and 30px of padding left the text a pixel off the arrow. */
.styledsel.fsel{width:auto;padding:6px 34px 6px 12px;font-size:12.5px;min-height:0;height:32px}
.boardtool .styledsel.fsel{margin:0}
.msgclip{max-width:340px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer}
.hqctl{display:flex;align-items:center;gap:12px;margin:2px 0 18px}
.hqmid{flex:1;text-align:center}
.hqpill{font-size:14px !important;padding:7px 18px !important;letter-spacing:1.5px}
/* `.hqpill-lg` was deleted with the student's ACTIVE/INACTIVE pill [2026-08-27] -- it had exactly one
   user. `.hqpill` and its .qactive/.qinactive colours are KEPT: `.hqpill` is still referenced by the
   two rules below and by the note at the top of this file, and removing a shared rule on the same day
   its only visible user went would be guesswork. Whether they are now dead belongs to the §3.5
   dead-rule sweep, which can see the whole file at once. */
.qhelp{position:absolute;top:16px;right:16px}
.qtime{flex:0 0 auto;text-align:center;font:700 18px var(--mono);min-width:74px}
/* §7c FIX (2026-07-25): --orange/--amber tokens, not literal hex. .qt-red already used a token
   while these two were hardcoded, so two thirds of the queue timer palette ignored the theme. */
.qt-red{color:var(--red)} .qt-orange{color:var(--orange)} .qt-yellow{color:var(--amber)}
.qright{flex:1;display:flex;align-items:center;justify-content:flex-end;gap:10px}
.qwait{color:var(--dim);font-size:12px;white-space:nowrap}
.qinfo{flex:1;min-width:0}
.hqrow.mine{position:relative;z-index:1;border-color:color-mix(in srgb,var(--red) 55%,transparent);box-shadow:0 0 16px -3px color-mix(in srgb,var(--red) 55%,transparent)}
.btn-redfill{background:var(--red) !important;color:#fff !important;border-color:var(--red) !important}
.btn-redfill:hover{filter:brightness(1.08)}
.helping{background:color-mix(in srgb,var(--brand) 15%,transparent);border:1px solid var(--brand);border-radius:11px;
  padding:14px 16px;margin-bottom:16px;box-shadow:0 0 20px -4px color-mix(in srgb,var(--brand) 55%,transparent)}
.helping .htitle{font:700 12px var(--mono);letter-spacing:1.5px;color:var(--brand);margin-bottom:9px}
.hqmine2{background:var(--brand-soft);border:1px solid color-mix(in srgb,var(--brand) 45%,transparent);border-radius:11px;padding:16px}
.qtitle{font-size:19px;font-weight:700;margin-bottom:12px}
.statbox{background:var(--bg-deep);border:1px solid var(--line);border-radius:9px;padding:8px 16px;text-align:center;min-width:84px}
.statnum{font:700 20px var(--mono)} .statlbl{font-size:11px;color:var(--dim);text-transform:uppercase;letter-spacing:.5px}
.alertbox{position:relative;border-radius:10px;padding:12px 36px 12px 14px;margin-bottom:8px;font-size:14.5px;line-height:1.35;display:flex;align-items:center;gap:12px}
.alertbox .atext{flex:1}
.ab-red{background:color-mix(in srgb,var(--red) 16%,transparent);color:var(--red);border:1px solid color-mix(in srgb,var(--red) 40%,transparent)}
.ab-orange{background:color-mix(in srgb,#e8862b 15%,transparent);color:#f0a24d;border:1px solid color-mix(in srgb,#e8862b 40%,transparent)}
.ab-yellow{background:color-mix(in srgb,#ffe066 13%,transparent);color:#f2cd52;border:1px solid color-mix(in srgb,#ffe066 35%,transparent)}
.ab-blue{background:color-mix(in srgb,var(--info,#4aa3ff) 13%,transparent);color:var(--info,#7cbcff);border:1px solid color-mix(in srgb,var(--info,#4aa3ff) 35%,transparent)}
/* The on-call strip [her call 2026-08-04, REVERSED 2026-08-09 — see below]. It needs to STAND OUT
   without borrowing alert semantics — being on call is a duty, not a warning — so it took the
   theme's brand colour rather than any of the amber/red/blue tiers, which are reserved.
   🔴 REVERSED 2026-08-09: moved onto --info. Her 2026-08-04 objection was to borrowing a FIXED,
   alert-adjacent blue that meant the same thing in every theme — and a per-theme --info is no
   longer that, it IS the theme's own colour (derived from --brand's own hue, softened). So the
   reason to avoid it dissolves, same as `.wipwarn.lvpin` above. Class name kept as `ab-brand`
   rather than renamed to `ab-blue` even though it now resolves the same way `.ab-blue` does —
   her ruling: the shared token means "the theme thinks this is notable," not "these are the same
   kind of thing," and the class name is what carries that distinction in the markup. */
.ab-brand{background:var(--info-soft);color:var(--text);border:1px solid color-mix(in srgb,var(--info) 45%,transparent)}
.ab-brand .atext b{color:var(--info)}
/* .noteamber — carried over VERBATIM from the blessed snapshots (HTMLSnapshots/CLASSES-desktop-
   teacher.html, and the same rule in communication-teacher-empty.html). It was used in live markup
   (teacher/messages.php's queue-closed notice) but the rule itself never made it into this file, so
   the notice had been rendering as plain body text. Found in the alert audit's containment sweep. */
.noteamber{background:var(--amber-soft, color-mix(in srgb, var(--amber) 10%, transparent));
  border:1px solid color-mix(in srgb, var(--amber) 30%, transparent);border-radius:9px;
  padding:11px 14px;font-size:13.5px;margin:0 0 12px}
.ab-read{background:rgba(255,255,255,.04);color:var(--dim);border:1px solid var(--line)}
.ab-read.hide{display:none}
/* ALERT-TABLE §2's block filter on the Notifications tab. Its own class, NOT `.hide`, because that
   one is already owned by the Show Cleared Alerts toggle — sharing it would make either control
   silently undo the other. */
.alertbox.blkhide{display:none}
/* Which block a grouped teacher alert is about. A quiet label, not a badge: it identifies the row,
   it is not a second thing to react to, so it stays out of the warning palette entirely. */
/* 🔴 NOTE 8 · *"The border around the period for alerts that have a period block is barely
   visible."* It was `--line-hi` on a tinted row, which is a divider token: designed to be almost
   invisible against a panel, and it disappears entirely once the row behind it has its own tint. It
   now borrows the row's own accent at a share that actually reads, and the text moves off `--dim` for
   the same reason — a label nobody can see is not labelling anything. */
.blklab{display:inline-block;font-size:11px;letter-spacing:.04em;text-transform:uppercase;
  color:color-mix(in srgb, var(--text) 78%, transparent);
  border:1px solid color-mix(in srgb, var(--text) 38%, transparent);
  border-radius:5px;padding:1px 6px;margin-right:7px;
  white-space:nowrap;vertical-align:1px}
/* On a red row the label sits on a red tint, where a neutral border reads as dirt; match the row. */
.alertrow.wipred .blklab{color:color-mix(in srgb, var(--red) 70%, var(--text));
  border-color:color-mix(in srgb, var(--red) 45%, transparent)}
.ab-x{position:absolute;top:8px;right:9px;width:20px;height:20px;border:none;background:transparent;color:inherit;opacity:.65;cursor:pointer;font-size:17px;line-height:1;padding:0}
.ab-x:hover{opacity:1}
.notifgrid{display:grid;grid-template-columns:1fr 1fr;gap:18px;align-items:start}
@media(max-width:860px){.notifgrid{grid-template-columns:1fr}}
.abstbl{width:100%;border-collapse:collapse;font-size:14px}
.abstbl th{text-align:left;color:var(--dim);font-weight:600;font-size:12px;padding:7px 10px;border-bottom:1px solid var(--line)}
.abstbl td{padding:9px 10px;border-bottom:1px solid var(--line)} .abstbl tr:last-child td{border-bottom:none}
.unrep{color:var(--red);font-weight:600}
.warn{color:var(--amber);font-weight:600}
.qempty{color:var(--dimmer);font-size:14px;padding:18px 4px;line-height:1.4}
.qtip{position:relative;display:inline-flex;align-items:center;justify-content:center;width:20px;height:20px;border-radius:50%;
  border:1px solid var(--line-hi);color:var(--dim);font:700 12px var(--sans);cursor:help;flex:0 0 auto}
/* 🔴 `.qtip-trigger` HAD NO RULE ANYWHERE IN EITHER STYLESHEET [found 2026-08-09, sweeping for the
   .tag.tie shape]. The "?" mark is a real `<button class="qtip-trigger">` nested inside the `.qtip`
   circle (`.qtip` is the wrapper span, not the trigger itself), so with no rule of its own it fell
   back to the bare UA button: light grey fill, a 2px black OUTSET (3D-bevel) border, square corners,
   Arial, black text — rendered live and measured, not assumed: 23x21px poking out of its own 20x20
   circular parent. Same failure shape as `.esterr`/`.tag.tie` — an unstyled class silently degrading
   to a browser default — except this one sits on **29 sites app-wide**, every "?" tooltip trigger the
   app has, not one page. Reset explicitly (matching this file's own convention elsewhere, e.g.
   `.addbtn`) rather than `all:unset`, which has no precedent here; sized to fill the parent circle
   exactly, everything else inherited from `.qtip` so the two stay visually one component. */
/* 🔴 24x24 POINTER TARGET WITHOUT RESIZING THE CIRCLE [2026-08-18, Classes design audit]. Measured in
   Chromium: the "?" button renders **18x18** (its 20x20 parent less the 1px border each side), which
   fails WCAG 2.2 SC 2.5.8 (AA, 24x24) on every page that carries one — 29 sites app-wide, not just
   this one. The repo's earlier answer for `.pencil`/`.card-edit`/`#msgtable .icobtn` was `min-width`/
   `min-height`, but those are square icon buttons with no fixed visual diameter; `.qtip` is a drawn
   circle whose SIZE is the design, so growing it is a visible app-wide restyle and hers to call, not
   an audit's. The transparent overlay below is the other standard answer to 2.5.8: it makes the real
   target 24x24 while the painted circle stays 20x20.
   ⚠ Safe against swallowing neighbours: the overlay extends 3px past each side, and the tightest
   neighbour gap on this page is `.sechead { gap:10px }`. */
.qtip-trigger{background:transparent;border:none;padding:0;margin:0;width:100%;height:100%;
  border-radius:50%;font:inherit;color:inherit;cursor:inherit;display:flex;align-items:center;justify-content:center;
  position:relative}
.qtip-trigger::after{content:'';position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);
  width:24px;height:24px;border-radius:50%}
.qtiptext{display:none;position:absolute;top:27px;right:0;z-index:60;width:228px;max-width:min(228px,calc(100vw - 24px));background:var(--panel);
  border:1px solid var(--line-hi);border-radius:9px;padding:11px 13px;font:400 12.5px var(--sans);color:var(--text);
  line-height:1.5;letter-spacing:0;text-transform:none;box-shadow:0 10px 30px -8px rgba(0,0,0,.65);overflow-wrap:anywhere;word-break:break-word}
.qtip:hover .qtiptext{display:block}
/* 🔴 KEYBOARD PARITY, matching what `.htip` already had [2026-08-16, accessibility build 3]. The "?"
   mark is a real <button class="qtip-trigger"> on all 29 of these app-wide, so it can take focus —
   but `:hover` was the only trigger, which meant the explanation existed for a mouse and did not
   exist for a keyboard. That also made it invisible to a screen-magnifier user, who navigates by
   focus and cannot hover precisely at high zoom. `:focus-within` catches the focusable child. */
.qtip:focus-within .qtiptext{display:block}
/* Global tooltip bugs (1w/1y), first hit on the Teams page: white-space is inherited, so
   .qtiptext went unreadable-wide inside anything with white-space:nowrap (e.g. th.sortable). */
.qtiptext{white-space:normal}
/* .qtiptext anchors right:0 so it opens leftward; a header cell can clip it entirely unless the
   cell itself allows overflow. Table-specific, not global — clipping elsewhere is intentional. */
#allcos th, #allteams th{overflow:visible}
/* Inside a dialog the mark can sit on either side of its label, so anchoring to the mark itself
   always risks running off one edge or the other. Anchor to the label instead — it spans the
   dialog's own content width, so the bubble can't overflow past it in either direction. */
dialog label{position:relative}
dialog .qtip{position:static}
/* .htip — the site-styled hover popup for elements that are NOT a dedicated "?" trigger (colored
   status dots/swatches, disabled-button reasons): same box chrome as .qtiptext, but wraps whatever
   content is already there instead of imposing the qtip circle, and anchors centered above it
   rather than qtip's below-right (these sit inline in rows/tables, where anchoring below risks
   clipping under the next row). §2473: replaces native title= everywhere a native browser tooltip
   was standing in for real UI (Windows-default hover box), never used alongside one. */
.htip{position:relative;display:inline-flex;vertical-align:middle}
.htip .htiptext{display:none;position:absolute;bottom:calc(100% + 8px);left:50%;transform:translateX(-50%);
  z-index:60;width:max-content;max-width:min(220px,calc(100vw - 24px));background:var(--panel);
  border:1px solid var(--line-hi);border-radius:9px;padding:9px 11px;font:400 12.5px var(--sans);color:var(--text);
  line-height:1.4;letter-spacing:0;text-transform:none;text-align:left;white-space:normal;
  box-shadow:0 10px 30px -8px rgba(0,0,0,.65);overflow-wrap:anywhere;word-break:break-word}
.htip:hover .htiptext{display:block}
/* KEYBOARD PARITY [added 2026-08-07, Teacher Dashboard audit]. `:hover` was the only trigger, so every
   `.htip` that IS focusable (the disabled-button reasons this component was also written for) showed
   its popup to a mouse and nothing to a keyboard. `:focus-within` covers a focusable child, so both
   shapes are handled. Note this does NOT rescue the Deliverables Overview swatch strip, which is a
   non-focusable <i> — that one carries `role="img"` + `aria-label` on the swatch itself instead
   (teacher/index.php), because making 4-16 decorative squares per row into tab stops would be worse
   than the problem. */
.htip:focus-visible .htiptext,.htip:focus-within .htiptext{display:block}
/* 🔴 DISMISSABLE WITH ESCAPE — WCAG 1.4.13 [2026-08-16, accessibility build 3]. Content shown on
   hover or focus must be dismissable WITHOUT moving the pointer or focus away, and neither tooltip
   family could be. It matters most at high magnification, where a 228px bubble can cover the very
   thing underneath it that she was reading, and where moving the mouse away to clear it means losing
   her place. The handler that adds `.tipoff` lives in app/ui.php — one delegated listener, the same
   shared seam the attendance radiogroup keys off, rather than a script per page.
   Cleared again on mouseleave/focusout, so a dismissed tip is dismissed for that visit only and the
   control still works the next time it is reached. */
.qtip.tipoff .qtiptext,.htip.tipoff .htiptext{display:none !important}
dialog .qtiptext{left:0;right:auto;top:calc(100% + 6px);width:100%;box-sizing:border-box}
/* Flip-on-collision (Snapshot Review Group B3, app/ui.php's qtipPlace()): a row near the bottom of
   a dialog's rendered bounds opens its tooltip UPWARD instead, so it never runs off the box. */
dialog .qtiptext.tipflip{top:auto;bottom:calc(100% + 6px)}
/* blessed note 1: bigger, spaced hub tabs (shared with Meetings by design) */
.boardtool.pillrow-lg{margin-top:20px !important;margin-bottom:24px !important;gap:14px}
.pillrow-lg .fchip{font-size:15.5px;padding:11px 26px;border-width:2px}
.msgbox{max-width:520px;border:1px solid var(--line-hi);border-radius:14px;background:var(--panel);
  box-shadow:0 24px 70px -14px rgba(0,0,0,.75);padding:22px}
.msgbox h3{margin:0 0 12px;font-size:18px}
.msgbox .rd{font-size:15px;line-height:1.55;color:var(--text);background:var(--bg-deep);border-radius:9px;padding:14px}

/* ---- §23 part two: signal glyphs, LIVEWIRE ONLY. Colour alone cannot make a pill
   self-describing, and glyphs cannot reach the small coloured dots (.trk2-*, .navdot, .dot) —
   each covers the other's gap. Plain lowercase "i" for info: the circled ⓘ was rejected. ---- */
html[data-theme="livewire"] .badge.b-green::before { content:"\2713\00a0"; }   /* check */
html[data-theme="livewire"] .badge.b-red::before   { content:"\2715\00a0"; }   /* cross */
html[data-theme="livewire"] .badge.b-amber::before { content:"\26a0\00a0"; }   /* warning */
html[data-theme="livewire"] .badge.b-info::before  { content:"i\00a0"; }        /* info */

/* ---- Meetings list scrolling (§24 / teacher ruling 2026-07-25) -------------------------------
   RETRO LIST: capped, because the MVP Nominations panel sits directly below it in the same tab —
   with 20 teams the approval panel gets pushed off-screen at exactly the moment it is needed, and
   its position would move with class size. Companies already collapse 20 teams into a handful of
   groups, so the cap is generous.
   MVP NOMINATIONS: also capped, overriding the general "do not cap a trailing list" rule. The
   teacher's argument wins on scale — an unattended review day can bring 60 nominations, and at that
   length the page itself is the problem, not reachability.
   REVIEWS LIST: NOT capped — it is the only content in its tab and is bounded by class size, so a
   sticky header (below) solves scanning without nesting a second scrollbar. ---- */
.mtscroll { overflow-y:auto; padding-right:8px; }
.mtscroll-retro { max-height:min(62vh, 620px); }
.mtscroll-noms  { max-height:min(46vh, 460px); }

/* A team that finished DROPS OFF the day after [RULED 2026-07-29]: hidden by default, revealed by
   the "Show N completed" checkbox toggling .showdropped onto .mtscroll-retro — same show/hide-by-
   class technique as the Schedule tab's past-sprints/past-grading-periods checkboxes. */
.mtscroll-retro details.acc.mtdropped { display:none; }
.mtscroll-retro.showdropped details.acc.mtdropped { display:block; }

/* Performance Reviews' own drop-off [item 417]: same rule, same technique, but NOT .mtscroll — this
   list is deliberately uncapped (see the comment above), so .revscroll carries no overflow/max-
   height, only the show/hide-by-class scoping. */
.revscroll details.acc.mtdropped { display:none; }
.revscroll.showdropped details.acc.mtdropped { display:block; }
/* 🔴 THE BLOCK FILTER'S OWN HIDE CLASS [2026-08-15, her "Meetings should filter by block" ruling].
   Deliberately a THIRD independent class rather than a `style.display` write from JS: `.mtdropped`
   and `.showdropped` above already share control of these same rows, and a JS inline style would
   outrank both and republish completed retros whenever a block was picked. That exact collision
   (two filters, one `style.display`) is the Help Queue bug found 2026-08-14. `!important` so it
   still wins over `.showdropped`'s `display:block` — a row the teacher filtered out must stay out
   even with "Show N completed" ticked, since the block choice is the narrower intent. */
.mtscroll-retro details.acc.mt-blkhide,
.revscroll details.acc.mt-blkhide { display:none !important; }
/* 🔴 AND THE MVP PANE'S ROWS, WHICH ARE NOT ACCORDIONS [her report 2026-09-08: *"none of the MVP
   Nominations are filtering by block"*]. The two rules above are why the filter looked broken on
   exactly one of the three panes: they name `details.acc`, and the MVP pane holds a `<table>` of
   nominations and a grid of tracker rows. Same class, same `!important`, same reasoning — the hide
   must outrank `.mvprow[hidden]`'s own `!important` in the one case a row is both finalized and
   filtered out, and it must not be an inline style that could fight it.
   ⚠ `.mvprow` is `display:grid`, so its `!important` is load-bearing here for the same reason its
     own `[hidden]` rule documents: without it, `display:grid` wins on specificity. */
.nomqueue tr.mt-blkhide { display:none !important; }
.mvprow.mt-blkhide { display:none !important; }

/* sticky header for the capped nomination table and the (uncapped) reviews table. Same
   geometry-preserving technique as the blessed settings header: a transparent border keeps the box
   height and the rule is painted as a background gradient, so it cannot double-line or shift 1px. */
.mtscroll-noms table th, .rvtable th {
  position:sticky; top:0; z-index:3;
  border-bottom:1px solid transparent !important;
  background:linear-gradient(to top, color-mix(in srgb, var(--brand) 35%, var(--line-hi)) 1px,
                             var(--panel) 1px);
}

/* ============================================================================
   COMMUNICATION REBUILD — lifted VERBATIM from blessed/communication-teacher.html's
   own override block (2026-07-25), per README-DESIGN-RULINGS Appendix A: "CSS is
   verbatim... Do not re-derive values. Several encode a workaround for a specific
   browser behaviour, and the comment above each says why."

   Two deliberate deviations from a blind paste:
   1. The global themed-scrollbar block was REMOVED from this paste — it is already
      merged higher up in this file (BLESSED-VS-LIVE-settings.md §3 lists it as
      "already merged and must not be duplicated").
   2. A few selectors appear TWICE below (.qsechead h3, #ldtable th). That is not a
      mistake to tidy: the blessed snapshot carries the same duplicates, later-wins,
      and those later values ARE the blessed ones (§7k's 20px brand subheader, §7j's
      13px header padding). Collapsing them by hand risks picking the earlier value.

   NOTE on .hqrow.mine: the doc's §7a prose says .hqrow "carries only border-top".
   That is stale — it carries a full border here AND in the snapshot. The outline rule
   below is still correct: it is appended AFTER the original .hqrow.mine (~line 1864),
   which is exactly the cascade the blessed snapshot was approved with. Verified by
   diffing the snapshot's own inlined .hqrow against this file's.
   ============================================================================ */
/* ---- Communication changes, 2026-07-25 ----------------------------------- */

/* Aligned columns. Tag and time hold their slot even when a row has no track, so
   the eye can read straight down. */
.qtagcol{flex:0 0 152px;display:flex;justify-content:flex-start}
/* 🔴 THE FIXED COLUMN WAS NOT FIXED [measured live 2026-08-14 on the seed's own rows, not assumed].
   `flex:0 0 152px` sets the BASIS; a flex item still defaults to `min-width:auto`, so a row whose
   tag column holds more than one thing pushes past the basis and steals the width back from
   `.qinfo`. Real numbers off the seeded queue: rows with one tag measured 152px, the row carrying
   "Programming" + "1 lead passed" measured 158.2px — so the `.qtime` column started 6px further
   right on that one row and the "eye can read straight down" this column exists for was already
   broken by the app's own data.
   `min-width:0` is what actually pins the basis; `flex-wrap` is what the content then does instead
   of overflowing (stack inside the column, row grows in height); the gap replaces the two tags
   previously touching. */
.qtagcol{min-width:0;flex-wrap:wrap;gap:5px;align-content:center}
/* Same column, same width, from min-width — her standing badge rule. The passed-count badge is the
   only badge here whose text length varies with data, and it went singular -> plural at two:
   measured max-content: "1 lead passed" 110px, "N leads passed" 117px, "12 leads passed" 124px.
   It also must not SHRINK: in the un-wrapped column it was being squeezed to 61px and wrapping its
   own text inside the pill, which is how a "fixed" column ended up 158.2px wide. min-width and
   flex-shrink:0 hold both ends of that. */
.qtagcol .badge{min-width:124px;text-align:center;flex-shrink:0}
.hqrow .qtime{flex:0 0 84px;text-align:center}
.qwait{flex:0 0 116px;text-align:right}
.hqrow .qright{flex:0 0 232px;display:flex;align-items:center;gap:8px;justify-content:flex-end}

/* Section headers inside the Help Queue panel. These were not reading as headers. */
.qsechead{display:flex;align-items:center;gap:10px;margin:0 0 8px}
.qsechead h3{font-size:19px;margin:0;line-height:1.3}
.qsechead .cnt{font-family:var(--mono);font-size:12px;color:var(--dim)}
.qsechead .qtip{width:19px;height:19px;font-size:11.5px}
.qsechead .qtiptext{left:0;right:auto}
.qsec{margin-top:0;padding-top:24px;border-top:1px solid var(--line)}

/* FIX: `.hqrow` only carries border-top, so setting border-color on .mine left the
   box open at the bottom. Give the highlighted row a full border and radius, and
   pull the extra pixel back so no row shifts. */
/* The frame is an OUTLINE, not a border. A border on .hqrow (which only carries
   border-top) left the box open at the bottom, and compensating with padding shifted
   every cell in the row by a pixel, which knocked the claimed pills out of column.
   An outline follows border-radius and occupies no layout space at all. */
.hqrow.mine, .hqrow.urgentrow{
  border-radius:11px;border-top-color:transparent;
  outline:1px solid color-mix(in srgb,var(--red) 55%,transparent);outline-offset:-1px;
  box-shadow:0 0 16px -3px color-mix(in srgb,var(--red) 55%,transparent);
  position:relative;z-index:1}

/* ACTIVE pill: dark green. b-green maps to --accent-3, which is a soft orange in
   CatalystCats, which is why it read as an amber border. */
.hqpill.qactive{color:var(--green) !important;
  border-color:color-mix(in srgb,var(--green) 55%,transparent) !important;
  background:color-mix(in srgb,var(--green) 20%,transparent) !important}
/* The INACTIVE pill is NOT in the teacher snapshot's own override block, because that snapshot
   was rendered with a live queue so the state never appeared. Taken from Appendix A4 #3, which
   ships it alongside .qactive, and Appendix G lists it as CSS to add. Without this the closed-
   queue empty state (§13) renders its INACTIVE pill unstyled. */
/* 🔴 CONTRAST FIX 2026-08-14, measured in a browser across all 16 palettes, not eyeballed.
   `--red-soft` is a TRANSLUCENT wash, so this pill's real background was whatever panel sat behind
   it — and on the three themes with a light panel that put `--red` text on a light ground:
   bubblegum 3.83:1, pumpkinspice 4.27:1, sunsettide 4.28:1, all under WCAG AA 4.5:1 for 14px text.
   The sibling `.qactive` never had the problem because it is already a color-mix that pulls its own
   ground. Mixing this one into `--bg-deep` instead of into `transparent` makes the pill carry its
   own background rather than inheriting the panel's, keeps the red tint at roughly the strength it
   had, and lifts the worst theme to 4.90:1 with every theme passing (max 8.17:1). Verified after
   the change on all 16. */
.hqpill.qinactive{color:var(--red) !important;
  border-color:color-mix(in srgb,var(--red) 55%,transparent) !important;
  background:color-mix(in srgb,var(--red) 16%,var(--bg-deep)) !important}

/* Claimed pill in Being Helped */
/* 🔴 THIS ONE WAS MISSED BY THE 2026-08-14 MESSAGES + HELP QUEUE PAGE AUDIT ITSELF — found only by
   the app-wide sweep afterward, not by that page's own contrast pass. `.cpill` is the base rule (not
   a hover/selected state), so it was failing AA on EVERY "Claimed by X" row in Being Helped, in the 7
   affected themes, the whole time that page was being reviewed. Same fix as the rest of this sweep. */
.cpill{display:inline-flex;align-items:center;justify-content:center;
  min-width:146px;box-sizing:border-box;font-family:var(--mono);font-size:10px;
  letter-spacing:1px;text-transform:uppercase;font-weight:600;padding:3px 10px;border-radius:20px;
  border:1px solid color-mix(in srgb,var(--accent-2) 40%,transparent);
  color:color-mix(in srgb, var(--accent-2) 55%, var(--text));background:var(--accent-2-soft);white-space:nowrap}
.cpill.urgent{color:var(--red);border-color:color-mix(in srgb,var(--red) 65%,transparent);
  box-shadow:inset 0 0 0 1px color-mix(in srgb,var(--red) 65%,transparent);
  background:var(--red-soft)}
/* `min-width:146px` held the FLOOR but nothing held the ceiling, and the pill is `white-space:nowrap`
   inside a 152px column. Display names are capped at 30 characters (the account dialog's own
   maxlength), so "Claimed by " + a 30-character name is roughly 300px of mono uppercase — it blew
   the tag column and pushed the whole row wider than its list. Measured on a long name live: one
   `.cpill` at 596px in a 152px slot. The ceiling belongs on the pill, and the ellipsis needs a real
   element to happen on because text-overflow does not apply to a flex container's anonymous text —
   hence the `.cpname` span in helpqueue.php rather than clipping mid-letter. */
.cpill{max-width:100%}
.cpname{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;min-width:0}

/* Track tag */
.ttag{display:inline-block;font-family:var(--mono);font-size:9.5px;letter-spacing:1.2px;
  text-transform:uppercase;font-weight:700;padding:2.5px 8px;border-radius:20px;border:1px solid;
  white-space:nowrap}

/* Follow-Ups class-day counter, in the .hqbadge slot */
.dcount{font:700 15px var(--mono)}
.dc-y{color:var(--amber)}.dc-o{color:var(--orange)}.dc-r{color:var(--red)}



/* 3 · leadership table spacing. From and To were crowding Message. */
#ldtable th:nth-child(2), #ldtable td:nth-child(2){padding-right:20px;white-space:nowrap}
#ldtable th:nth-child(3), #ldtable td:nth-child(3){padding-right:26px;white-space:nowrap;width:1%}
#ldtable th:nth-child(4), #ldtable td:nth-child(4){padding-left:6px}
#ldtable th:nth-child(5), #ldtable td:nth-child(5){padding-left:18px;white-space:nowrap}
#ldtable th:nth-child(6), #ldtable td:nth-child(6){padding-left:14px;white-space:nowrap}
#ldtable td:last-child{padding-left:16px;padding-right:12px}

/* 4 · a table row cannot take a border directly, so the red frame is painted on the
   cells: top and bottom on every cell, sides and radius on the first and last. */
/* The leadership table mirrors the inbox. */
#ldtable{width:100%;border-collapse:collapse}
#ldtable th{text-align:left;padding:0 10px 9px;font-family:var(--mono);font-size:10px;
  letter-spacing:1.4px;text-transform:uppercase;color:var(--dim);font-weight:700;
  white-space:nowrap;position:sticky;top:0;z-index:3;
  border-bottom:1px solid transparent;
  background:linear-gradient(to top, var(--line) 1px, var(--panel) 1px)}
#ldtable td{padding:10px;border-bottom:1px solid var(--line);font-size:13.5px;vertical-align:middle}
#ldtable tr:last-child td{border-bottom:none}

/* Waiting and Being Helped each cap and scroll independently, so a long queue cannot
   push Being Helped and Follow-Ups off the screen. */
.qcap{max-height:250px;overflow-y:auto;overflow-x:hidden;padding-right:6px;padding-bottom:2px}
/* Do NOT strip the last row's padding to make a box sit flush against a divider. It
   removes the row's own bottom space and the row reads as cut off. Flush spacing belongs
   on the CONTAINER (.qsec has no top margin), never on the row. */

/* Every leadership message is listed, so the list is capped and scrolls. */
.ldcap{max-height:340px;overflow-y:auto;overflow-x:hidden;padding-right:8px}

/* A highlighted row needs breathing room on the right, or the button touches the border. */



/* My Inbox caps and scrolls so Leadership Inboxes below it is never pushed off screen.
   Roughly eight rows, matching the leadership cap. */
.mcap{max-height:392px;overflow-y:auto;overflow-x:hidden;padding-right:8px}
#msgtable th{position:sticky;top:0;z-index:3;border-bottom:1px solid transparent;
  background:linear-gradient(to top, var(--line) 1px, var(--panel) 1px);padding-bottom:11px}

/* Being Helped collapses. The summary carries everything needed at a glance: the count,
   and a red flag only when a claim has passed 15 minutes. Static, no pulse. */
.bhacc{background:var(--bg-deep);border:1px solid var(--line);border-radius:var(--r-md);
  margin:0;overflow:visible}
.bhacc>summary{padding:13px 16px}
.bhacc>summary h3{font-size:20px;color:var(--brand);margin:0;letter-spacing:.2px}
.bhacc>summary .cnt{font-family:var(--mono);font-size:12px;color:var(--dim)}
.bhacc>summary .qtip{margin-left:auto}
.bhacc .accbody{padding:0 14px 12px}
.bhflag{font-family:var(--mono);font-size:9.5px;letter-spacing:1.1px;text-transform:uppercase;
  font-weight:700;padding:3px 9px;border-radius:20px;color:var(--red);
  border:1px solid color-mix(in srgb,var(--red) 55%,transparent);background:var(--red-soft);
  white-space:nowrap}

/* Follow-up rows carry a status word plus two buttons, which does not fit the stock
   168px .hqacts. Widened for this row type only. */
.furow .hqacts{flex:0 0 340px;white-space:nowrap}

/* 🔴 TWO INDEPENDENT FILTERS WERE FIGHTING OVER ONE `style.display` [found + reproduced live
   2026-08-14]. Show Archived (fuArch) and the block filter (fblk) both wrote `r.style.display`
   directly, and the archived rows started hidden by an INLINE `display:none` in the markup — so
   touching the block filter at all republished every archived follow-up with the checkbox still
   unchecked. Reproduced in the browser: `.furow.archived` went `none` -> `''` on choosing "All",
   `#showarch` still false.
   The root cause is two rules writing one property, so the fix is to stop writing the property:
   each filter owns its own class, CSS ORs them, and neither can undo the other. */
.furow.fu-archhide, .furow.fu-blkhide { display:none }

/* The leadership table's Sent column reuses `.qtime` for its severity colours only, and was
   fighting the rest of the class off with `style="font-size:13.5px;min-width:0"` in the markup.
   `min-width:0` was overriding `.qtime{min-width:74px}` on an element computed `display:inline`,
   where min-width does not apply at all (CSS 2.1 §10.4) — verified live, so it was a no-op that
   read like a live constraint. Only the size override is real, and it belongs in one named place
   rather than on 1..N inline attributes. */
.qtime.qtdate{font-size:13.5px}

/* KEYBOARD PARITY FOR THE CLICK-ONLY CONTROLS IN THESE TWO TABLES (WCAG 2.1.1 / 2.4.7).
   `.sorth` (9 sortable headers), `.msgclip` (open the full message) and `.openrep` (open the
   reply) were `onclick` handlers on non-interactive elements: reachable by mouse only, with no
   focus indicator anywhere. messages.php now makes them focusable and Enter/Space activatable;
   this is the ring that makes the focus visible. `:focus-visible` only, so the mouse path is
   unchanged — same convention as `.styledsel` above. */
.openrep{cursor:pointer}
.sorth:focus-visible, .msgclip:focus-visible, .openrep:focus-visible{
  outline:2px solid var(--text);outline-offset:-2px;border-radius:4px}

/* The flag toggle is a text glyph and the trash beside it is a 17px svg, so in one row the two
   icon buttons rendered 20x23 and 25x25 — different hit areas for the same control class. Scoped
   to this table rather than fixing `.icobtn` globally: six other pages share that class and are
   another agent's surface today. */
#msgtable .icobtn{min-width:25px;min-height:25px;align-items:center;justify-content:center}

/* popup shell, matching the app's .msgbox treatment */
dialog.pop{border:none;background:transparent;padding:0;max-width:560px;width:calc(100vw - 32px)}
dialog.pop::backdrop{background:rgba(4,7,13,.72)}
.popbody{border:1px solid var(--line-hi);border-radius:14px;background:var(--panel);
  box-shadow:0 24px 70px -14px rgba(0,0,0,.75);padding:24px 26px}
.popbody h3{margin:0 0 10px;font-size:19px}
.popbody .popnote{margin:0 0 12px;font-size:13px;color:var(--dim);line-height:1.5}
.popbody textarea{width:100%;box-sizing:border-box;background:var(--bg-deep);color:var(--text);
  border:1px solid var(--line-hi);border-radius:9px;padding:11px 13px;
  font:14px/1.6 var(--sans);min-height:120px;resize:vertical}
.popacts{display:flex;justify-content:flex-end;gap:12px;margin-top:18px;flex-wrap:wrap}
.popacts .btn{margin:0}

/* 3 · Waiting and Being Helped need to read as subheaders, not as body text. */
.qsechead h3{font-size:20px !important;color:var(--brand);letter-spacing:.2px}

/* 4 · the overdue frame was clipping under the sticky header. Push the first data row
   clear of the header rule so its own top border is visible. */
#ldtable th{padding-bottom:13px}

/* two-option dialog for Remove */
.optbtn{display:block;width:100%;text-align:left;background:var(--panel-hi);
  border:1px solid var(--line-hi);border-radius:11px;padding:14px 16px;margin-bottom:10px;
  cursor:pointer;color:var(--text);font:inherit}
.optbtn:hover{border-color:var(--brand)}
.optbtn b{display:block;font-size:14.5px;margin-bottom:3px}
.optbtn span{display:block;font-size:12.5px;color:var(--dim);line-height:1.5}

/* Receipt model, ruled 2026-07-25: a REPLIED row is done and needs nothing further, so it greys
   out and reads as removable. This is the app's existing vocabulary for "resolved" (cleared alerts
   grey and sink to the bottom, archived follow-ups drop to .7 opacity), not an imported email
   bold/grey idiom — which would not fit a list that shows the message body inline. Chosen over an
   unread-emphasis treatment because marking-read-on-render can never display as new: the stamp
   lands on the same render that would show it. */
tr.msgdone td { opacity:.62 }
tr.msgdone:hover td { opacity:1 }
.mact { display:flex; align-items:center; gap:8px; justify-content:flex-end; white-space:nowrap }

/* "On it right now" strip, lifted verbatim from blessed/communication-student-lead.html.
   §1c ruled the CONTENT (one dim non-actionable line, capped at three then "and N more",
   contributing nothing to any badge). §15f proposed this pill-pair FORM and the blessed
   snapshot carries it, so the form is approved too. */
.onit{margin-top:18px;padding-top:15px;border-top:1px solid var(--line)}
.onit .onitlbl{font-family:var(--mono);font-size:10px;letter-spacing:1.5px;text-transform:uppercase;
  color:var(--dim);font-weight:700;margin-bottom:9px}
.onitrow{display:flex;flex-wrap:wrap;gap:8px}
.onitpair{display:inline-flex;align-items:center;gap:7px;font-size:12.5px;padding:5px 11px;
  border-radius:20px;border:1px solid color-mix(in srgb,var(--accent-2) 32%,transparent);
  background:var(--accent-2-soft);white-space:nowrap}
.onitpair b{color:var(--accent-3);font-weight:700}
/* `.onitpair .arrow` held a `&rarr;` glyph until 2026-08-02 and now holds the word "helping" (see
   messages.php for why). A word needs the OPPOSITE weight to a glyph: an arrow had to be bold and
   coloured to read at all, but a bold coloured verb between two names makes three competing emphases
   in one small pill. Dropped to normal weight and the dim colour so it reads as connective tissue
   and the two NAMES stay the things the eye lands on. */
.onitpair .arrow{color:var(--dim);font-weight:400;font-size:11.5px}
.onitmore{display:inline-flex;align-items:center;font-size:12.5px;padding:5px 11px;border-radius:20px;
  border:1px dashed var(--line-hi);color:var(--dim);white-space:nowrap}

/* 🔴 WIDENED 2026-08-24 [her ruling: *"The switch class popup is way too squished, should be much
   bigger to show all content on a regular screen without scrolling. It's tiny right now."*] Measured
   before: 420px wide, 139px tall, with its class list in a 260px panel that scrolled at 519px of
   content — so eleven classes were a scroll inside a box a third the height of the screen.
   ⚠ THE GLOBAL `dialog { max-width:440px }` (Step 9) IS WHY A BIGGER NUMBER HERE DID NOTHING ON ITS
   OWN: `width` loses to a smaller `max-width`. Both are set together, the way `.dlg-xl` already does
   it — see the note on `dialog.dlg-lg`, which is still clamped and is hers to rule on. */
.switchdlg { width:min(640px, 94vw); max-width:min(640px, 94vw); }
/* `.hint` is scoped to `.panel` globally, so a dialog has to restyle it (same as #absdlg does). */
.switchdlg .hint { color:var(--dimmer); font-size:12.5px; margin:2px 0 0; }
.teamsel { position:relative; }
.teamsel-trigger { width:100%; display:flex; align-items:center; justify-content:space-between; gap:10px;
  background:var(--bg-deep); border:1px solid var(--line-hi); border-radius:var(--r-md); color:var(--text);
  padding:12px 14px; font-family:var(--sans); font-size:14.5px; cursor:pointer; text-align:left; }
.teamsel-trigger:hover { border-color:var(--brand); }
.teamsel-trigger .car { color:var(--dim); font-size:11px; transition:transform .15s; flex:none; }
.teamsel-trigger.open .car { transform:rotate(180deg); }
.teamsel-panel { position:absolute; top:calc(100% + 6px); left:0; right:0; z-index:40;
  background:var(--panel); border:1px solid var(--line-hi); border-radius:var(--r-md);
  box-shadow:0 20px 50px -10px rgba(0,0,0,.6); max-height:260px; overflow-y:auto; padding:6px; }
/* 🔴 INSIDE THE SWITCH POPUP THE LIST IS INLINE, NOT AN OVERLAY [2026-08-24, her ruling: *"should be
   much bigger to show all content on a regular screen without scrolling"*].
   ⚠ RAISING THE OVERLAY'S max-height WAS MY FIRST ATTEMPT AND IT WAS WRONG — measured: the scroll went
   away (519px of rows in a 519px box) but the panel then ended at y=1022 on a 900px-tall screen, so the
   bottom of the list was off the screen instead of behind a scrollbar. Strictly worse, and only a
   measurement caught it.
   ⚠ THE CAUSE was that the panel is absolutely positioned below the trigger, so it hangs OUTSIDE the
   dialog and the dialog's own centring never accounts for it: a 139px-tall dialog was centred while
   500px of list hung off its bottom edge. Static positioning puts the list back inside, so the dialog
   grows with it and `<dialog>` re-centres the whole thing — which is what makes "all of it on screen,
   no scrolling" actually true rather than nominally true.
   ⚠ `max-height` on the dialog is the backstop for a teacher with far more classes than fit any
   screen: then, and only then, the dialog itself scrolls.
   ⚠ Applies to BOTH switch popups on purpose. app/ui.php builds the teacher's Switch Class and the
   student's Switch Teams from the same `.switchdlg`/`.teamsel` markup precisely "so the two bubbles are
   the same control"; scoping this to one of them would break that on the first screenshot. */
.switchdlg { max-height:88vh; overflow-y:auto; }
.switchdlg .teamsel-panel { position:static; max-height:none; overflow:visible;
  box-shadow:none; margin-top:6px; }
/* Re-asserts what the `hidden` attribute already means. Needed only because this panel is absolutely
   positioned, and it is the one display rule in this block — never put `display` on the <dialog>
   itself without `[open]` (README §6.1: every dialog on the page then renders at once). */
.teamsel-panel[hidden] { display:none; }
.teamsel-row { display:flex; align-items:center; justify-content:space-between; gap:10px; width:100%;
  background:transparent; border:0; border-radius:var(--r-sm); color:var(--text); text-align:left;
  padding:10px 12px; font-family:var(--sans); font-size:14px; cursor:pointer; }
.teamsel-row:hover { background:var(--panel-hi); }
/* The CURRENT row is inert (a div, not a button) — switching to the persona you are already on would
   post and redirect for nothing. So it must not advertise itself as clickable. */
.teamsel-cur, .teamsel-cur:hover { cursor:default; background:transparent; }
.teamsel-row .nm, .teamsel-trigger .nm { font-weight:700; min-width:0; overflow-wrap:anywhere; }
.teamsel-row .nm i, .teamsel-trigger .nm i { font-style:italic; font-weight:400; color:var(--dim); margin-left:5px; }
.teamsel-row .badge { flex:none; }
.teamsel-join { display:block; width:100%; text-align:center; background:transparent;
  border:1px dashed var(--line-hi); border-radius:var(--r-sm); color:var(--brand); padding:10px 12px;
  font-family:var(--sans); font-size:13.5px; font-weight:700; cursor:pointer; margin-top:6px; }
/* the base rule's text stays plain --brand (on transparent, which passes); this override only
   applies once :hover swaps the background to --brand-soft, which is the failing combination */
.teamsel-join:hover { background:var(--brand-soft); border-color:var(--brand); color:color-mix(in srgb, var(--brand) 55%, var(--text)); }

/* Join-code entry, inline in the same dialog's second view. */
.joincode-input { width:100%; background:var(--bg-deep); color:var(--text); border:1px solid var(--line);
  border-radius:var(--r-sm); padding:10px 12px; font-family:var(--mono); font-size:15px; letter-spacing:1.5px;
  text-transform:uppercase; margin-top:10px; }
.joincode-input:focus { outline:none; border-color:var(--brand); box-shadow:0 0 0 3px var(--brand-soft); }
.joincode-input.err { border-color:var(--red); box-shadow:0 0 0 3px var(--red-soft); }
.joinerr { display:flex; gap:7px; align-items:flex-start; color:var(--red); font-size:12.5px; margin-top:8px; }
.joinerr[hidden] { display:none; }

/* ---------- A PANEL WHOSE FEATURE IS PAUSED BY A RUNNING SUB-CLASS ----------
   Her call 2026-08-04, choosing between her own two rules (the jam spec said "remove pacing, ready for
   check off sections"; the standing rule says a section must never silently vanish): *"I think you can
   keep the panels, as long as they're entirely greyed out with over text that says 'Paused during
   {subclass name}' or something like that that makes it very obvious there's nothing to pay attention
   to there."* So the panel keeps its place in the grid and its heading, and reads as switched off.

   `.pausedpanel > *` greys and de-interacts everything the panel already contained; the note is a
   sibling overlay so it stays full-strength and legible over the top. Deliberately `--dim` and NOT
   amber: amber/orange/red are reserved for warning semantics, and a paused panel is information, not a
   warning. No animation, per the sensory-friendly rule.

   🔴 BUG FOUND AND FIXED 2026-08-14, unrelated to whatever else this session was building: this
   comment used to END right here, with no comment-opener starting the paragraph below it — so
   everything from "RESHAPED 2026-08-07" down to that paragraph's own comment-closer was raw text
   sitting outside any comment, which CSS's error recovery reads as one giant invalid selector and
   silently drops the whole rule it was attached to. That rule was the very next one, `.pausedpanel`
   (position:relative) — so EVERY "Paused during {jam}" overlay on EVERY dashboard has been
   positioning itself against the viewport instead of its own panel (`.pausednote`'s own
   absolute/inset:0 falling through to the initial containing block), covering the entire page
   center-to-edge and blocking clicks on everything under it, sidebar included. Caught by chance
   testing something else entirely on a page with a paused sub-class; confirmed via
   `document.styleSheets` that the rule was never being applied at all. Merging the two comments
   back into one is the whole fix — the rule itself was always correct, only unreachable.
   ⚠ IF YOU ARE EDITING THIS COMMENT: do not spell out the two-character comment-close sequence
   literally anywhere in this paragraph, even quoted — CSS comments do not nest and do not respect
   quoting, so typing it re-triggers the exact bug this paragraph describes. Ask past-me how it was
   found the hard way.
   ⚠ RESHAPED 2026-08-07 TO HER DESCRIPTION: *"centered (vertically and horizontally) over the box
   with the rest of the text behind being dimmed down a lot. So it's like a big white word overlay
   over a dimmed box in the center."* Three changes from the first build: the word is now BIG (was
   13px, lost against the panel it sits on), it is `--text` rather than `--dim` so it reads as white,
   and the content behind drops from .22 to .12 so the overlay owns the box.
   This also settles the audit finding that the overlay struck through the panel heading: the heading
   is deliberately part of what dims, which is her answer to it — not an exclusion.
   `--text`, not a literal #fff: all 16 themes are dark, so `--text` IS the near-white in each, and a
   hardcoded white would drift the moment a light theme is added. `z-index` because the greyed
   siblings carry `filter`, which makes its own stacking context and would otherwise paint over an
   absolutely-positioned sibling. */
/* 🔴 THE FIVE `.pausedpanel` / `.pausednote` RULES ARE DELETED [Group L note 3, 2026-08-23]. Her
   2026-08-22 note reverses the 2026-08-04 ruling this whole block was built to: the Ready for
   Check-Off and Deliverables Overview panels are no longer greyed out during a jam, they are not
   rendered at all (*"I don't want to see ready for check off or deliverables at all during a jam on
   the dashboard. It's just not helpful."*). Those two panels were the only markup in the app that
   ever carried these classes, so the rules had no remaining subject.
   ⚠ THE PARAGRAPHS ABOVE ARE KEPT ON PURPOSE, because one of them is not about this component at all:
   the 2026-08-14 note records a CSS comment that was accidentally left unopened, which CSS error
   recovery swallowed as one giant invalid selector and silently dropped the rule that followed it.
   That lesson, and its warning about never typing the comment-terminator sequence inside a comment,
   applies to every rule in this file and outlives the component it was found on. */

/* ---------- GROUP L, NOTE 10a: the ENDED-CLASS collapse [her ruling 2026-08-22] ----------
   *"huge and obvious, like centered and very large"*, and on its own — everything else on the teacher
   dashboard stops rendering for an ended class, so this block is the whole page body.
   `--dim` and mono for the headline rather than red: a finished class is a STATE, not a warning, and
   README §5 reserves red for warnings. It is size and centring that carry "obvious" here, which is
   exactly what she asked for. Same reasoning `.pausednote` used before it was removed. */
.endedwrap {
  display:flex; flex-direction:column; align-items:center; justify-content:center;
  text-align:center; gap:14px; padding:12vh 20px; min-height:46vh;
}
.endedbig {
  font-family:var(--mono); font-size:clamp(38px, 7vw, 74px); font-weight:800;
  letter-spacing:.14em; text-transform:uppercase; color:var(--text); line-height:1.05;
}
.endedsub { margin:0; font-size:16px; color:var(--dim); max-width:52ch; }

/* ---------- ADD-ONS: the one-line row, and the app's FIRST toggle switch ----------
   Lifted verbatim from the approved mockup (HTMLSnapshots/PROPOSAL-addon-scoping.html), where the
   switch was drawn as snapshot-only CSS and then chosen [2026-08-05].

   `.thinrow` is the Help Queue box in Teacher Settings; `.tiprow` is one add-on line inside the
   class's Manage Add-Ons dialog. Both put the DESCRIPTION on the app's existing `.qtip` circle
   rather than inline, and that is load-bearing rather than cosmetic: the real descriptions run
   152-292 characters and inline they made the panel ~500px tall. On hover, five rows is ~150px.

   ⚠ GREEN ON / RED OFF is a KNOWING EXCEPTION to the rule at the top of this file that amber, orange
   and red are reserved for warning semantics. A red OFF is a state, not a problem. She was told and
   chose it. Written against the --green/--red TOKENS, never literal hex, so Livewire's colourblind
   remap (bluish-green / vermilion) applies automatically — and the state is spelled out ON/OFF in
   the mono face besides, so it never depends on colour alone.
   No blinking, no pulsing, no bounce: .15s linear-ish transitions only. */
.thinrow { display:flex; align-items:center; gap:10px; }
.thinrow .lbl { font-weight:700; font-size:14px; }
.thinrow .sp { flex:1; }
.swtoggle { position:relative; width:66px; height:24px; border-radius:12px; cursor:pointer;
  flex:0 0 auto; margin-left:14px; font:700 10px var(--mono); letter-spacing:.12em;
  background:color-mix(in srgb, var(--red) 30%, transparent);
  border:1px solid color-mix(in srgb, var(--red) 60%, transparent);
  transition:background .15s ease, border-color .15s ease; }
.swtoggle i { position:absolute; top:0; line-height:22px; font-style:normal; color:var(--text); }
.swtoggle .offtx { right:11px; }
.swtoggle .ontx { left:13px; display:none; }
.swtoggle::after { content:""; position:absolute; top:2px; left:2px; width:18px; height:18px;
  border-radius:50%; background:var(--red); transition:transform .15s ease, background .15s ease; }
.swtoggle.on { background:color-mix(in srgb, var(--green) 30%, transparent);
  border-color:color-mix(in srgb, var(--green) 60%, transparent); }
.swtoggle.on .ontx { display:block; }
.swtoggle.on .offtx { display:none; }
.swtoggle.on::after { transform:translateX(42px); background:var(--green); }
.swtoggle:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }

/* One add-on line in the Manage Add-Ons dialog: checkbox, label, and the ? circle. */
.tiplist { margin-top:12px; display:flex; flex-direction:column; }
.tiprow { display:flex; align-items:center; gap:10px; padding:7px 0; font-weight:400;
  border-bottom:1px solid var(--line); }
.tiprow:last-child { border-bottom:0; }
.tiprow input { width:auto; margin:0; }
.tiprow .tl { font-weight:700; font-size:14px; }
.tiprow .sp { flex:1; }
.addsave { display:flex; justify-content:flex-end; margin-top:6px; }

/* ---------- The join screen's secondary route [her note 2026-08-23] ----------
   The returning-student instructions used to be a full panel ABOVE the join-code box. Her objection
   was who that served: *"it's just a lot of text blocking a new user which is who will normally be on
   this screen."* It is a `<details>` now — one line until asked for — so the common case reads as one
   short form and the returning student still has a visible door. Styled as a quiet link-ish row
   rather than a button, because it is deliberately the secondary path. */
.joinalt { margin-top:14px; }
.joinalt > summary {
  cursor:pointer; font-weight:600; font-size:13.5px;
  color:color-mix(in srgb, var(--brand) 55%, var(--text));
  list-style:none; display:inline-flex; align-items:center; gap:7px; padding:4px 0;
}
.joinalt > summary::-webkit-details-marker { display:none; }
/* The caret is drawn rather than left to the UA marker, so it matches the app's other disclosures
   and rotates on open. */
.joinalt > summary::before {
  content:"\25B8"; display:inline-block; transition:transform .15s; font-size:12px;
}
.joinalt[open] > summary::before { transform:rotate(90deg); }
.joinalt > summary:focus-visible { outline:2px solid var(--brand); outline-offset:3px; border-radius:4px; }

/* 🔴 THE ONE PLACE THE COUNTER LANDED BESIDE THE FIELD INSTEAD OF UNDER IT [her ruling 2026-08-23].
   The shared listener inserts `.charcount` as the field's next sibling, which is exactly right in a
   normal stacked form — but `.wiztrackrow` is a FLEX row (input, then a remove button), so a block
   sibling became a third flex ITEM and sat on the same line, squeezing the row. Audited every
   `[data-counter]` field on Settings, Teams and Class Settings: 11 of 13 were already correct and
   these two track-name rows were the only offenders, which matches what she spotted.
     · `flex-wrap` lets the counter take a line of its own.
     · `order:99` sends it after the remove button, so the BUTTON stays up on the field's line and
       only the counter drops. Without it the button would be pushed to a third line.
     · `max-width` matches the input's own 300px cap so the count right-aligns under the BOX's right
       edge rather than the row's, which is what her references show. */
.wiztrackrow { flex-wrap:wrap; }
.wiztrackrow .charcount { order:99; flex-basis:100%; max-width:300px; margin-top:2px; }

/* ---------- Settings ▸ Blocks: cap the list, then scroll ----------
   🔴 **234px WAS WRONG AND THE COMMENT SAID WHY WITHOUT NOTICING** [corrected 2026-08-24]. It claimed
   "six rows: 6 × 34px + 5 × 6px gap" — but the rows are **25px**, not 34px, so 234px showed **seven**
   full rows. Her words on seeing it: *"It's still showing too many blocks. I said to cap scroll to
   show 4 - 5. It's showing almost 8 right now."* Almost 8 was accurate. 34px was assumed, never
   measured, which is the whole failure here — the arithmetic was fine and its input was invented.
   ⚠ **118px = 4 rows, MEASURED: 4 × 25px + 3 × 6px gap.** Four rather than five because her reason
   was a layout one — *"Really I just want all three columns the same height, can you just cap a max
   height to match the other columns?"* — and the measurements say exact equality is not reachable:
   Account Info wants 372px, Avatar 323px, and School Calendar is already 310px BEFORE the block list,
   so matching exactly would leave the list 62px (two rows) and contradict "4 - 5". Four rows is the
   closest point inside her range: it takes the panel from 544px to 428px and the row shrinks with it.
   ⚠ Still a max-HEIGHT rather than a row count, for the original reason: these rows are one line each
   and a height keeps the cut-off in a consistent place whether or not a row carries an "A day" suffix.
   `padding-right` reserves the scrollbar's width so the pencil and trash buttons do not shift left the
   moment one more block is added. */
/* 🔴 THE LIST TAKES THE ROOM THE PANEL ACTUALLY HAS [her report 2026-08-29]: *"blocks has a scroll
   wheel even though there's tons of extra space under it which is not great."*
   THE 118px CAP WAS RIGHT WHEN IT WAS WRITTEN and wrong by the time she saw it. Her 2026-08-23 ruling
   was *"a cap on the number of blocks shown before a scroll (like 5 - 6 then scroll)"* -- written to
   stop eight periods pushing Manage Holidays off the panel. But Settings' panels STRETCH to a shared
   row height, which she likes (*"The top row is a consistent height which I love"*), so this panel is
   routinely taller than its contents: the cap then produced a scrollbar with empty space underneath
   it, which is the worst of both.
   the FLOOR AND THE CEILING ARE BOTH HERS. `min-height` keeps her six-row cap when the row is short;
   `flex:1` spends whatever the stretch gave; `max-height` stops the list dominating the page when
   this panel is the one SETTING the row height, which is the case her original ruling was about.
   Between them, it scrolls only when it genuinely has more blocks than room.
   the FLEX CHAIN IS WHY `.calpanel` AND `.blkwrap` EXIST. A flex child can only take leftover height
   if every ancestor up to the panel is a column with `min-height:0`; without that the list simply
   grows and pushes Manage Holidays off again, which is the bug the cap was added for. */
.calpanel { display:flex; flex-direction:column; }
.calpanel .blkwrap { display:flex; flex-direction:column; min-height:0; flex:1 1 auto; }
.blkscroll { flex:1 1 auto; min-height:118px; max-height:340px;
  overflow-y:auto; overflow-x:hidden; padding-right:6px; }

/* ---------- Settings ▸ Teachers: cap the list, then scroll [2026-08-24] ----------
   Her instruction was *"height capped to match the Teacher Invite Code box, scrolling inside"*, and
   the number below is MEASURED, not reasoned about — which is the entire lesson of the .blkscroll
   note directly above, where a confidently-stated piece of arithmetic showed eight rows because its
   row height had been invented rather than read off the page.
   THE MEASUREMENTS, read off the live page with Playwright, never assumed:
     · Teacher Invite Code panel at its natural height: 185px.
     · Teachers panel chrome above the list: 20px padding-top, h2 33px + 4px margin, .hint 19px +
       12px margin, 20px padding-bottom = 108px.
     · So matching the invite box exactly leaves 185 - 108 = 77px for the list.
     · Measured row height: 56px plain, 59px with an OWNER badge. Sticky header: 34px.
   ⚠ 77px IS THE HEADER AND ONE TEACHER, so a literal reading of "match the invite box" produces a
   list that cannot list. That is the SAME failure as the blocks cap showing eight rows, arrived at
   from the other end: a number followed past the point where it still served her.
   ⚠ HOW BOTH HALVES OF HER INSTRUCTION ARE KEPT ANYWAY: the two panels share one grid row, and CSS
   grid stretches row items to equal height by default. So whichever is taller sets both, and they
   MATCH — measured at 548 x 382 each — regardless of which one drove the number. The invite box has
   room to spare either way: at full width it was 1112px wide holding 450px of content.
   ⚠ 272px = the sticky header (34px) + FOUR rows at their worst case (4 x 59px), + 2px because
   these boxes are border-box and the border is inside the max-height (measured: max-height 253
   gave clientHeight 251). Four is also the figure she settled on for the blocks list one panel
   over, for the same "cap it, don't cramp it" reason.
   👁 SHE MAY WANT THIS TALLER OR SHORTER. It is one number, on this line; nothing else depends on
   it, and a taller pair only makes the invite box beside it emptier. Logged in docs/BUILD_PLAN.md. */
.teachcap { max-height:272px; overflow-y:auto; overflow-x:hidden; border:1px solid var(--line);
  border-radius:var(--r-md); }
/* "Never signed in" is the commonest value in this column on a fresh install and wrapped onto two
   lines at this panel width, which made those rows taller than the rest for no reason. */
table.subtable td.teachwhen { white-space:nowrap; }
/* ---------- ...and its two columns that must NOT be sized by their own content ----------
   🔴 MEASURED, AFTER THE FIRST VERSION VISIBLY BROKE [2026-08-24]. Letting the table size itself
   meant ONE row going pending — which adds a second button, "Reset again" — widened the action
   column from 133px to 247px, i.e. half the 500px table, and every OTHER row paid for it: teacher
   names wrapped onto two lines, the OWNER badge dropped below its own name, and rows grew tall
   enough to push the fourth one out of the cap. A transient state on one row silently relaid out the
   whole panel.
   ⚠ SAME PRINCIPLE AS THE BADGE RULE: things sharing a table column get ONE width, never a width
   sized to whichever cell happens to hold the most today. Fixed here, so the panel looks identical
   whether nobody is mid-reset or everybody is.
   ⚠ 140px = the widest single button measured ("Reset password", 119.4px) plus the cell's own 20px
   of padding. The two-button pending cell holds 211px of buttons, which does not fit on one line at
   that width BY DESIGN — they stack, which is the whole reason the column can stay narrow. That one
   row is taller while a reset is pending; nothing else moves.
   ⚠ 120px on Last login is "Never signed in" at its nowrap width, which is the longest value this
   column ever holds and also the commonest one on a fresh install.
   That leaves 240px for the teacher's name and username, measured as enough for the longest real
   case (a display name plus the OWNER badge on one line). */
table.subtable th.teachact, table.subtable td.teachact { width:140px; text-align:right; }
table.subtable th.teachwhen, table.subtable td.teachwhen { width:120px; }
table.subtable td.teachact form { display:flex; flex-direction:column; align-items:flex-end; gap:5px; }
table.subtable td.teachact .btn { margin:0; }

/* 🔴 N13 · the ✕ that takes a new goal back off [her note 13, built 2026-08-23]. Her words: *"you can
   add up to 4, but you can't remove if you accidentally clicked too many. You should be able to click
   an x in the upper right corner of a goal box to delete it."* Upper right, as asked.
   `position:relative` goes on the row card only (`.ngcard`), not on `.goalcard` at large -- the same
   class dresses the CARRIED-goal cards on the previous slide, which have their own absolutely
   positioned furniture and must not gain a new containing block.
   28px square: past WCAG 2.5.8's 24px floor, the same reasoning that sized `.pencil`. */
.ngcard { position:relative; }
.ngcard .ngtext { padding-right:38px; }      /* the text never runs under the button */
.ngdel {
  position:absolute; top:8px; right:8px;
  width:28px; height:28px; padding:0; line-height:1;
  display:inline-flex; align-items:center; justify-content:center;
  background:transparent; border:1px solid var(--line-hi); border-radius:50%;
  color:var(--dim); font-size:13px; cursor:pointer; transition:color .12s, border-color .12s;
}
.ngdel:hover { color:var(--red); border-color:var(--red); }
.ngdel:focus-visible { outline:2px solid var(--brand); outline-offset:2px; }
.ngdel[hidden] { display:none; }             /* at the floor it is GONE, not greyed: a disabled
                                                control still reads as "maybe", and here it is "no" */

/* ================= MY CLASSES picker [her design 2026-08-27] =================
   Lifted from the approved mockup (HTMLSnapshots/LIVE-DESIGN/class-profiles-mockup.html) rather than
   re-invented, so what shipped is what she signed off. Every value below was through
   tests/ui/audit_class_picker_a11y.py before the build: axe-core clean, reflow clean at 320px and
   640px, every target >= 24x24.
   🔴 THREE OF THESE RULES ARE FIXES THE AUDIT FORCED, and they must not be "tidied" back:
     · .cpactive mixes the brand TOWARD the text colour. Brand-on-brand-tint failed contrast at 6
       nodes -- the same failure class her 2026-08-16 ruling already cleaned up once.
     · the second control in .cpacts has a 24px minimum. It was a 13px checkbox in a 19px row, which
       is a hard target for the magnifier student this app is built around. That checkbox is gone
       (see .cppastbtn below) but the floor it forced is why the button that replaced it is a .btn-sm
       and not a link.
     · finished rows are NOT dimmed by opacity anywhere. Stacking opacity on already-dim text was the
       third contrast failure, and a finished sprint's dates are exactly what a student scrolls back
       to read. ============================================================================= */
dialog.cpdlg { max-width:min(860px, 94vw); width:min(860px, 94vw); }
/* padding-right clears the dialog's own circle-X, which is absolutely positioned in this corner.
   Without it the X sits ON TOP of the Add New Class button -- caught in the first live render, not
   visible in the mockup because the mockup has no dialog chrome around it. */
.cphead { display:flex; align-items:flex-start; justify-content:space-between; gap:16px;
          /* 26px, up from 18px [her ask 2026-08-27]: the heading sat too close to the first card. */
          margin:0 0 26px; padding-right:44px; }
.cphead h3 { margin:0; font-size:22px; }
.cpacts { display:flex; flex-direction:column; align-items:flex-end; gap:8px; flex:none; }

/* 230px min fits "Game Development" and "Night Owl Games" unbroken -- the first pass was 178px and
   truncated both, which she rejected. */
/* 🔴 auto-FIT AND CENTRED, NOT auto-fill [her ask 2026-08-27]: *"when there is only one class,
   there's extra space on the right which doesn't look good... how about the class cards are CENTERED
   in the popup."* auto-fill RESERVES the empty columns, which is exactly what parked a lone card hard
   left with a gap beside it; auto-fit collapses them, and centring then puts the surviving card in
   the middle of the dialog. Two centre as a pair; three or more fill the row exactly as before, so
   the counts that already looked right are untouched.
   ⚠ The 230px minimum stays -- it is derived from "Game Development" and "Night Owl Games" fitting
   unbroken, and she rejected a narrower one. Centring moves the tracks; it must not stretch them. */
/* 🔴 TWO ROWS, THEN IT SCROLLS [her ruling 2026-08-28: *"maybe scroll after 2 rows. I think it should
   show 2 rows cleanly, can fit on a laptop, and scroll after that (if needed)"*].
   ⚠ THE GRID SCROLLS, NOT THE DIALOG, and that is the whole point of putting it here. Measured
   before this rule: `.cpgrid` had `max-height:none` and `overflow-y:visible`, so nothing capped it at
   all -- the only thing saving a teacher with twelve classes (four rows, measured live) was the
   browser's own `dialog:modal` max-height, which scrolls the WHOLE dialog and carries the "My
   Classes" heading and the + Add New Class button off the top with it. Capping the grid keeps the
   heading and the action where they are and moves only the cards. Same pattern as `.avdlg-scroll`,
   `.alertscroll` and `.teamsel-panel`.
   THE NUMBER IS ARITHMETIC, NOT A GUESS: a card measures 114px and the row gap is 14px, so two rows
   is 114 + 14 + 114 = 242. A third row is then cut cleanly at the container edge rather than half a
   card being shown.
   ⚠ `padding` and the NEGATIVE margin that cancels it exist so the active card's glow is not clipped
   by the scroll box. `.cpcard.active` paints an 18px box-shadow; without room either side, scrolling
   would shave it. The negative margin keeps the grid's own alignment identical to before.
   ⚠ It is a MAX-height, so one or two classes still size to their content and nothing empty is
   reserved -- the state she cares most about (a student with one class) is untouched. */
.cpgrid { display:grid; grid-template-columns:repeat(auto-fit, minmax(230px, 1fr)); gap:14px;
          justify-content:center;
          max-height:242px; overflow-y:auto; overscroll-behavior:contain;
          padding:4px 6px; margin:-4px -6px; }
/* 🔴 THE `hidden` ATTRIBUTE NEEDS SAYING OUT LOUD HERE, and it is not boilerplate: the UA stylesheet
   gives [hidden] `display:none`, but ANY explicit display rule outranks it, so `display:grid` above
   quietly wins and a hidden grid stays on screen. Caught by tests/ui/t_class_picker.py the first time
   Past Classes was clicked -- the table appeared and the cards never left. Same fix and same reason
   as .mempop[hidden], .fpop[hidden] and .teamsel-panel[hidden] elsewhere in this file. */
.cpgrid[hidden] { display:none; }
/* 🔴 THE CAP IS ON THE CARD, NOT ON THE TRACK, and that is the whole trick [2026-08-28].
   auto-fit collapses the empty tracks, but the one that survives is `1fr` and takes the entire row,
   so a single class rendered as one ~800px card -- stretched, not centred, and justify-content has
   nothing to distribute once the tracks already fill the container.
   ⚠ DO NOT "FIX" THIS BY CAPPING THE TRACK (minmax(230px, 300px)). auto-fit takes its repetition
   count from the max sizing function whenever that max is definite, so a track cap silently drops an
   860px dialog from three columns to two -- changing the multi-class layout she said looked right in
   order to fix the single-class one. `1fr` is indefinite, so the count keeps coming from the 230px
   minimum at every card count.
   .cpdone-wrap is GONE from this selector [2026-08-28]: finished classes left the grid entirely for
   the Past Classes table, so the grid's only child is a live .cpcard. */
.cpgrid > .cpcard { max-width:300px; margin-inline:auto; width:100%; }
.cpcard { background:var(--bg-deep); border:1px solid var(--line); border-radius:12px; padding:14px;
          display:flex; flex-direction:column; gap:10px; }
.cptop { display:flex; align-items:center; gap:10px; }
.cplogo { width:40px; height:40px; border-radius:10px; flex:0 0 40px; display:grid; place-items:center;
          font-family:var(--disp); font-weight:800; font-size:15px; color:#0b0f14; }
/* min-width:0 lets a long class name shrink inside the flex row instead of pushing through the
   card's border. */
.cptext { min-width:0; }
.cpname { display:block; font-weight:700; font-size:14.5px; line-height:1.25; }
.cpmeta { display:block; color:var(--dim); font-size:12px; margin-top:3px; line-height:1.3; }
/* Buttons pinned to the bottom so a card with a longer meta line does not leave its neighbour's
   button floating halfway up the row. */
.cpcard .btn, .cpcard form { margin-top:auto; }
.cpcard .btn { width:100%; justify-content:center; }
.cpcard form .btn { width:100%; }

.cpcard.active { border-color:var(--brand);
                 box-shadow:0 0 0 1px var(--brand), 0 0 18px -2px color-mix(in srgb, var(--brand) 55%, transparent); }
.cpactive { margin-top:auto; text-align:center; font-family:var(--disp); font-size:11.5px;
            letter-spacing:.10em; text-transform:uppercase; font-weight:800;
            color:color-mix(in srgb, var(--brand) 42%, var(--text));
            background:color-mix(in srgb, var(--brand) 14%, transparent);
            border:1px solid color-mix(in srgb, var(--brand) 45%, transparent);
            border-radius:var(--r-sm); padding:7px 8px; }
.cpempty { text-align:center; padding:26px 10px 8px; }
.cpempty p { color:var(--dim); font-size:13.5px; margin:0 0 16px; }

/* ---------- PAST CLASSES, the picker's second view [her design 2026-08-28] ----------------------
   *"it changes the view from the 'cards' to a scrollable table that has the logo, class name, start
   date and end date (all clickable headers to sort by) and a 'view' button in the row at the end."*

   ⚠ IT SCROLLS AT THE SAME 242px THE GRID DOES, on purpose: the two views swap inside one dialog,
     and a table that sized to its own content would make the dialog jump a few hundred pixels every
     time the button is pressed. Same cap, same feel, and the dialog stays put.
   ⚠ THE HEAD IS STICKY, which the card grid has no equivalent of and needs one here: scrolling six
     years of classes past an unlabelled column is the failure this avoids. It needs an opaque
     background of its own or the rows show through it, and --panel-hi is the dialog's own ground.
   ⚠ NO SORT ARROWS [her note]. `[aria-sort]` gets an underline and full-strength text instead, so
     the active column is visible without a glyph and audible without either. */
.cppast { max-height:242px; overflow-y:auto; overscroll-behavior:contain; }
table.cptbl { width:100%; border-collapse:collapse; }
table.cptbl th { text-align:left; padding:0; border-bottom:1px solid var(--line);
                 position:sticky; top:0; background:var(--panel-hi); z-index:1; }
/* The header is a real <button>, so it is tab-reachable and Enter/Space sort it for free. Stripped
   back to text: this is a column label that happens to be pressable, not a control in a row of them. */
.cptbl-h { display:block; width:100%; text-align:left; background:none; border:0; cursor:pointer;
           font:inherit; font-size:11px; letter-spacing:.09em; text-transform:uppercase;
           font-weight:700; color:var(--dim); padding:8px 12px 10px; }
.cptbl-h:hover { color:var(--text); }
.cptbl-h:focus-visible { outline:2px solid var(--brand); outline-offset:-2px; border-radius:6px; }
table.cptbl th[aria-sort] .cptbl-h { color:var(--text); }
/* 🔴 THE CARET SLOT IS RESERVED WHETHER IT IS FILLED OR NOT [her ruling 2026-08-28: *"Do the caret
   exactly like it is on the teacher teams page where it only shows up when you click on the header
   rows and are sorting."*]. `th.sortable .arrow` above (line ~2131) is the rule she is pointing at
   and this is the same 12px, for the same reason: an inline-block of fixed width holds the space, so
   filling it on sort does not shove the heading sideways. Drop the width and every header jumps the
   first time you click it.
   ⚠ THE UNDERLINE CAME OFF BOTH TABLES with this change. It was standing in for a glyph she had said
     she did not want; now there IS a glyph on the sorted column, so an underline as well would be
     two marks saying one thing. `aria-sort` still carries it for a screen reader. */
.cptbl-h .arrow, .mtbl-h .arrow { display:inline-block; width:12px; color:var(--brand); }
table.cptbl td { padding:9px 12px; border-bottom:1px solid var(--line); vertical-align:middle; }
table.cptbl tr:last-child td { border-bottom:0; }
/* 30px, not the card's 40px: the plate is an identifier in a dense row here rather than the subject
   of a card, and a 40px mark would set the row height on its own. */
.cptbl-mk { width:30px; padding-right:0; }
.cptbl-mk .cplogo { width:30px; height:30px; flex:0 0 30px; border-radius:8px; font-size:12px; }
.cptbl-nm { font-weight:700; font-size:14px; }
.cptbl-d { color:var(--dim); font-size:12.5px; white-space:nowrap; }
.cptbl-dash { color:var(--dimmer); }
.cptbl-go { width:1%; text-align:right; white-space:nowrap; }
.cptbl-go form { margin:0; }
/* Matches .cpactive's colour so "Active" means one thing in both views, at the weight a table row
   can carry -- the card's full uppercase plate would shout in here. */
.cptbl-now { font-family:var(--disp); font-size:10.5px; letter-spacing:.10em; text-transform:uppercase;
             font-weight:800; color:color-mix(in srgb, var(--brand) 42%, var(--text)); }

@media (max-width:640px) { .cpgrid { grid-template-columns:1fr; } }
/* On a narrow screen the table keeps every column and scrolls sideways inside its own box rather
   than reflowing: the span is the reason to be on this screen at all, so dropping the dates to make
   it fit would leave a list of names that the cards already give. */
@media (max-width:640px) {
  .cppast { overflow-x:auto; }
  table.cptbl { min-width:430px; }
}

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   🔴 44px TAP TARGETS WHEREVER THE POINTER IS A FINGER
   [2026-08-28 · POST-DEPLOY §4.1 "44px tap-target sweep across the phone surfaces" · MOBILE:766]

   MEASURED FIRST, NOT LISTED FIRST. `tests/ui/probe_taptargets.py` walked the phonewall's own
   allowlist at 390x844 with touch on, opening every closed <dialog> on the way, because half the
   suspects live in one and a dialog's buttons are exactly what a thumb has to hit. What it found is
   what this block fixes: page-level `.btn-sm` at 29px tall, `.btn` at 38, `.dlg-close` at 30,
   `.bubx` at 31, `.icobtn` at 25, `.pencil` at 24.

   ⚠ `(pointer:coarse)`, NOT A WIDTH, and it is the same reasoning the 16px input floor above is
   bound to: a phone in LANDSCAPE is 844px wide and still has fingers, an iPad is 1024 and still has
   fingers, and a mouse-driven desktop at 400% browser zoom reports 320px and does NOT. Width cannot
   tell those apart; the pointer can. A touchscreen laptop with a mouse reports `fine` and correctly
   keeps the compact desktop controls.

   ⚠ IT GROWS THE BOX, IT DOES NOT OVERLAY IT -- deliberately the opposite choice from
   `.qtip-trigger`, and MOBILE:766's own instruction ("fix by phone-only padding"). A transparent
   overlay reaches 44px without moving anything, which is why it is right for a control whose drawn
   size IS the design -- but it also floats over whatever sits next to it, and an overlay wide enough
   to matter is wide enough to eat a neighbour's taps. `min-height`/`min-width` reflows instead:
   the row gets taller and nothing is ever covered. On a phone a taller row is the correct answer
   anyway.

   THREE DELIBERATE EXCEPTIONS, each measured rather than skipped:
   · `.qtip-trigger` keeps its 24px overlay. Its 20px circle is DRAWN, so reaching 44 means either
     resizing that circle app-wide across 29 sites -- her call, not a sweep's, and already recorded
     as such where the overlay was added -- or a 44px overlay extending 13px past each side, against
     a tightest neighbour gap of 10px (`.sechead`). It clears WCAG 2.2 AA (24x24) as it stands.
   · `.card-edit` keeps its 24px. The card reserves EXACTLY 30px of right gutter for it -- the
     measurement is in that rule's own note, which was written to add the control "without changing
     the card's text width or height by a single pixel". A 44px box eats 14px back off the title it
     sits beside. The Board is behind the phonewall so no phone reaches this at all; it is the
     tablet Board pass's to settle, with the card's own padding, not this sweep's to force.
   · `.ab-x` is NOT here. Swept for it before writing the rule: it exists in style.css and in the
     blessed HTMLSnapshots and in NO live PHP, so it is a design-source class with no live control
     to grow. Left alone for the same reason `.dlgdivider` was kept. ⚠ `.card-move` (:1800) is the
     same shape and is genuinely a corpse -- her 2026-08-22 ruling took the move button off the card
     face and Card Info carries it now -- so it is named here for the §3.5 style.css sweep rather
     than being given a rule that would render for nobody.
   ══════════════════════════════════════════════════════════════════════════════════════════════ */
/* 🔴 DECLARED BEFORE THE COARSE BLOCK, NOT AFTER IT, AND THE ORDER IS THE WHOLE RULE. Both
   selectors are `.rememberme` — (0,1,0) each — so a media query adds no specificity and SOURCE ORDER
   decides. Written after the block, this 24 silently beat the 44 inside it and the login checkbox
   measured 246x24 on a phone with the touch rule apparently applied. Caught by re-running
   probe_auth_phone.py rather than by reading the file, which is the only way this kind of tie shows
   itself.
   Unconditional for the reason the 2.5.8 block near the top of this file gives: 2.5.8 applies at
   every viewport, and a control that is hard to hit at 400% browser zoom is hard to hit with a
   trackpad too. 24 is the floor everywhere; 44 is the touch figure, in the block below. */
.rememberme { min-height:24px; }

@media (pointer:coarse) {
  /* `.btn` declares `height:38px` and `.btn-sm` `height:29px`; min-height beats a fixed height in
     the used-value stage, so neither base rule needs touching. `dialog .btn` already reached 44 in
     the ≤640 block -- this is the same figure for the buttons that are NOT in a dialog, and for
     every touch width rather than only phone ones. */
  .btn, .btn-sm { min-height:44px; }
  /* `.bubpanel .btn-sm` is (0,2,0) and pins 40px further up this file; it would win on specificity
     no matter where this block sits, so it is named. The bubbles are hidden below 520 anyway --
     this is the 521-to-tablet band where they still render and are still tapped. */
  .bubpanel .btn-sm { min-height:44px; }
  /* `#msgtable .icobtn` is (1,1,0) and pins 25px near the end of this file. Messages is a phone
     route and the flag and the delete on every row ARE those buttons, so the ID selector has to be
     matched here or the one page this most affects would keep the 25px control. */
  .icobtn, #msgtable .icobtn { min-width:44px; min-height:44px;
    align-items:center; justify-content:center; }
  /* Both dimensions are explicit on the base rule (30x30) rather than min-*, so both are restated.
     This is also the popover ✕ (`.fpopx` sets position:static and keeps the circle). */
  .dlg-close { width:44px; height:44px; font-size:17px; }
  .bubx { min-width:44px; min-height:44px; }
  .pencil { min-width:44px; min-height:44px; }
  /* The Board's card pencil, held at the 24px AA floor its own rule sets -- see the gutter
     arithmetic in this block's header. It carries `.pencil`, so without this the rule above would
     grow it. */
  .card-edit { min-width:24px; min-height:24px; }
  /* A PENCIL PINNED ONTO A GRAPHIC is the one shape that cannot grow: at 44x44 the roster's
     team-logo pencil covers most of the 56px logo it is meant to annotate, and the class colour
     pencil covers the swatch whose colour it exists to change. These get the overlay instead, and
     they are safe to overlay for the reason the general case is not -- what they extend over is an
     <img>/<span> logo and a colour box, neither of which is a control. Both are already
     position:absolute, so `::after` anchors to the button itself. */
  .pencil-pin { min-width:24px; min-height:24px; }
  .pencil-pin::after { content:''; position:absolute; left:50%; top:50%;
    transform:translate(-50%,-50%); width:44px; height:44px; }
  /* 🔴 `.tstool` REACHED A PHONE ROUTE ON 2026-09-04 and its base rule is `min-height:40px`, four
     pixels under. It was written for the Team Settings popup, which lives on `teacher/teams.php` --
     not on the phonewall's allowlist, so nothing here had ever measured it. My Team's Upcoming leave
     button is the same component on `/roster.php`, which IS a phone route, so it now goes through
     t_taptargets.py like every other control a thumb has to hit.
     ⚠ NOT AN EXEMPTION, WHICH IS THE POINT. The exception list at the top of that suite says a new
       small control does not get to join it by being added -- it goes red until somebody decides.
       This is that decision, and it is the ordinary one: the button has room to grow and nothing
       around it to crowd. Team Settings' three tools get the same four pixels on a tablet, which is
       an improvement there too. */
  .tstool { min-height:44px; }
  /* join.php's "Already have a SCRUM Dojo login?" disclosure, measured at 241x29. It is a real
     standalone control on the page a student meets FIRST during a jam, and the one thing standing
     between a returning student and a duplicate account. `padding:4px 0` is what makes it 29; the
     min-height grows the row without touching the type or the caret. */
  .joinalt > summary { min-height:44px; }
  /* 🔴 A TYPED FIELD IS A TAP TARGET TOO, AND EVERY ONE OF THEM WAS 43px [2026-08-28, measured at
     360x740 by probe_auth_phone.py]. One pixel under, everywhere, because the base rule is
     `padding:10px 12px` plus the 16px this same media query already sets for iOS: 16 * 1.15 line
     box + 20 + 2 borders lands at 43. It is the smallest possible fix and it applies to the join
     code, the password, and every box in the app a thumb has to land in.
     ⚠ THE 16px RULE ABOVE DELIBERATELY DID NOT DO THIS, and said so: "FONT SIZE ONLY. The 44px
     target sweep is a separate item and needs measuring page by page; a min-height here would
     silently reshape rows all over the app." That measurement is now done -- this is that sweep, and
     the reshape it warned about turns out to be a single pixel.
     ⚠ CHECKBOXES AND RADIOS ARE EXCLUDED, the same exclusion the font rule makes. They are square
     controls whose painted size IS their design, so a min-height would stretch them into ovals
     without widening the thing being aimed at. `.rememberme` below is how the one that matters gets
     its target instead. */
  input:not([type=checkbox]):not([type=radio]):not([type=hidden]),
  select, textarea { min-height:44px; }
  /* "Keep me signed in on this device" -- the login form's only checkbox, and the control that
     decides whether a student re-types a password on a phone keyboard several times a week. The
     input paints 13x13 and the LABEL is the real target (2.5.8 scores the label of a label-wrapped
     control), but the label was only as tall as its own text: 19px. Growing the label rather than
     the box keeps the checkbox looking like every other checkbox in the app. */
  .rememberme { min-height:44px; }
}

/* ══════════════════════════════════════════════════════════════════════════════════════════════
   MY TEAM — the member table [her ruling 2026-08-28, option C of four mockups]
   Mockups: HTMLSnapshots/LIVE-DESIGN/myteam-c-*.png, source myteam-c-mockup.html.

   Her words: *"it just needs to be a real table with sorting properties."* What this replaced put
   the leader badge, the LEAD-track badge, the track chip AND the Mark absent button in one cell.
   ══════════════════════════════════════════════════════════════════════════════════════════════ */
table.mtbl { width:100%; border-collapse:collapse; }
/* 🔴 THE PADDING MOVED FROM THE <th> TO THE BUTTON INSIDE IT [2026-08-28, when the headers became
   sort controls]. A button with the cell's padding around it is a target the size of the word only;
   with the padding on the button it fills the header cell, so the whole column heading is what you
   press. Same reasoning as .cptbl-h on the Past Classes table, which is deliberately its twin. */
table.mtbl th { text-align:left; padding:0; border-bottom:1px solid var(--line); }
.mtbl-h { display:block; width:100%; text-align:left; background:none; border:0; cursor:pointer;
  font:inherit; font-size:11px; letter-spacing:.09em; text-transform:uppercase;
  color:var(--dim); font-weight:700; padding:0 12px 10px; }
.mtbl-h:hover { color:var(--text); }
.mtbl-h:focus-visible { outline:2px solid var(--brand); outline-offset:-2px; border-radius:6px; }
/* No arrow glyph [her note]. aria-sort is the state, and this is what paints it. */
table.mtbl th[aria-sort] .mtbl-h { color:var(--text); }
table.mtbl td { padding:10px 12px; border-bottom:1px solid var(--line); vertical-align:middle; }
table.mtbl tr:last-child td { border-bottom:0; }
.mtbl-acts { width:52px; text-align:right; }
.mtbl-dash { color:var(--dimmer); }

/* The identity cell. `min-width:0` on the text column so a long display name ellipsises inside the
   cell rather than pushing the table wider. */
.mtbl-id { display:flex; align-items:center; gap:11px; min-width:0; }
.mtbl-who { display:flex; flex-direction:column; min-width:0; }
.mtbl-name { font-weight:700; font-size:14.5px; line-height:1.25;
  overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.mtbl-user { font-family:var(--mono); color:var(--dimmer); font-size:11.5px; line-height:1.3; }
.mtbl-you { font-weight:400; color:var(--dim); }

/* 🔴 THE TRACK PILL, REPROPORTIONED [her note: *"they're way too long and skinny"*]. Her standing
   rule holds — badges in one column share ONE width via min-width, so it cannot shrink for a short
   track name — and what was wrong was the OTHER axis. 124x24 is 5:1, a stretched lozenge; 12.5px
   type with 10px of vertical padding and a 9px radius is nearer 2.6:1.
   ⚠ `box-sizing` is border-box globally here, so this min-width INCLUDES the padding. The mockup's
   first pass used 96px against 22px of padding and "Programming" spilled out of its own background. */
.mtbl-trk { display:inline-flex; align-items:center; justify-content:center; box-sizing:border-box;
  font-size:12.5px; font-weight:700; letter-spacing:.02em; white-space:nowrap;
  border-radius:9px; padding:10px 16px; min-width:118px; border:1px solid transparent; }

/* 🔴 THE ROLE RAMP. Dot AND text take one colour per tier [her ruling], and ALL TRACK LEADS SHARE
   ONE COLOUR whatever their track — deliberately not track_color_css(), which answers "which track",
   the question the Track column already answers.
   ⚠ EVERY VALUE IS MEASURED, NOT PICKED: tests/ui/probe_role_contrast.py scores all three against
   --panel in all 15 themes. That probe exists because this stylesheet already records the track
   palette being chosen by eye and failing AA as text in four of them.
   ⚠ HER LITERAL REQUEST DID NOT SURVIVE THE MEASUREMENT. "A dimmer version of brand" for track leads
   fails AA in pumpkinspice (4.12:1 at 42% into --dim, 4.43 at 30%) because that theme's brand is too
   dark to go below the producer value. The fix is this file's own lift-only-as-far-as-needed trick,
   the same shape track_color_css() uses: mix the dimmer brand, then lift toward --text by the
   LARGEST share that still clears 4.5 everywhere. 85% is that share; pumpkinspice lands at 4.77.
   Worst cases: producer 5.45, track lead 4.77, member 4.99. All AA, all 15 themes.
   ⚠ SENIORITY READS THROUGH HUE AND WEIGHT, NOT BRIGHTNESS, and that is forced rather than chosen —
   across 15 themes the ratios cannot be ordered consistently (pumpkinspice: producer 5.45, member
   4.99; catalystcats: 6.51 against 6.97). Being brand-tinted at all versus grey is the signal. */
.mtbl-role { display:inline-flex; align-items:center; gap:8px; font-size:13.5px; font-weight:600; }
.mtbl-role i { width:8px; height:8px; border-radius:50%; flex:none; background:currentColor; }
.mtbl-role.role-top  { color:color-mix(in srgb, var(--brand) 55%, var(--text)); font-weight:700; }
.mtbl-role.role-lead { color:color-mix(in srgb, color-mix(in srgb, var(--brand) 45%, var(--dim)) 85%, var(--text)); }
.mtbl-role.role-mem  { color:var(--dim); font-weight:500; }

/* The ⋮ and its menu. 44px on a touch device comes from the sweep's `.btn`/`.icobtn` rules; this is
   its own control, so it carries the floor itself. */
.mtbl-menu { position:relative; display:inline-block; }
.mtbl-kebab { width:32px; height:32px; min-width:32px; border-radius:8px; border:1px solid transparent;
  background:none; color:var(--dim); cursor:pointer; font-size:16px; line-height:1; }
.mtbl-kebab:hover, .mtbl-kebab[aria-expanded="true"] {
  border-color:var(--line-hi); background:var(--panel-hi); color:var(--text); }
.mtbl-pop { position:absolute; right:0; top:38px; z-index:40; background:var(--panel-hi);
  border:1px solid var(--line-hi); border-radius:10px; padding:6px; min-width:196px;
  box-shadow:0 14px 34px -10px rgba(0,0,0,.6); text-align:left; }
.mtbl-pop[hidden] { display:none; }
/* Opens upward instead, when mtblMenu() has measured that downward would run off the bottom. The
   38px mirrors the downward offset so the gap between button and menu is the same either way. */
.mtbl-pop.flipup { top:auto; bottom:38px; }
.mtbl-item { display:block; width:100%; background:none; border:0; color:var(--text);
  font:600 13px var(--sans); padding:9px 10px; border-radius:7px; cursor:pointer; text-align:left; }
.mtbl-item:hover { background:var(--panel); }
.mtbl-item.mtbl-amber { color:var(--amber); }
.mtbl-item.mtbl-ok { color:var(--green); }
/* 🔴 `.mtbl-red`, `.mtbl-pop-wide`, `.mtbl-lbl` and `.mtbl-pop .astatus` ALL WENT ON 2026-08-28.
   They existed for a teacher menu that held a labelled five-option attendance select and a red
   "Remove from team" item. Her ruling replaced the select with the same two toggles the leads get
   and moved the wizard out to its own button, so the teacher's menu is now the SAME SHAPE as the
   student's and needs no extra width, no section label and no red item. Removed rather than left
   behind: a rule with no markup reads to the next person as though the control still exists, which
   is the exact trap `.rolesplus` set on 2026-08-24. */

/* ---------- the TEACHER's roster, which is .mtbl plus two controls ----------
   Everything else about the table -- widths, the identity cell, the kebab -- is inherited, which is
   the point: the two pages must not drift into two tables. */
.tstbl .tssel { width:100%; max-width:190px; margin:0; padding:6px 30px 6px 11px; font-size:13px; }
/* 🔴 THE WIZARD BUTTON IS AMBER, NOT RED, AND THAT IS A MEANING RATHER THAN A PREFERENCE
   [her call 2026-08-28: *"It could go yellow/amber on hover though if we wanted."*].
   `appConfirm()` (app/ui.php) already runs three tiers and names them: **red = truly destructive,
   amber = proceed-with-awareness, green = a good thing is about to happen.** This button opens
   Change Team / Merge with Duplicate Username / Remove From Class, which is proceed-with-awareness
   almost by definition. So this is the app's existing vocabulary used correctly, not a new colour.
   ⚠ IT HAS TO OVERRIDE, NOT EDIT, `.icobtn:hover`. That rule paints red and is UNSCOPED, because
     until now every `.icobtn` on this surface was a trash can — changing it would silently repaint
     every one of them on the Teams page. Scoped here instead.
   ⚠ The 44x44 floor comes from `.icobtn` in the (pointer:coarse) block at the end of this file, so
     the finger target is already handled and must not be re-specified here. */
.tsmove:hover, .tsmove:focus-visible { color:var(--amber); }
/* Enough gap that the ⋮ and the person are two controls rather than one smudge, and the action cell
   widens to hold both. */
.tstbl .mtbl-acts { width:92px; }
.tsmove { margin-right:2px; vertical-align:middle; }
/* A control in a cell must not set the row height on its own; the identity cell's 34px avatar does. */
.tstbl td { padding-top:7px; padding-bottom:7px; }
@media (pointer:coarse) { .tstbl .tssel { min-height:44px; } }
/* 🔴 A COLUMN HEADING IS A TAP TARGET NOW [caught by tests/ui/t_taptargets.py the run after the
   headers became buttons: `mtbl-h 160x27`, three of them on /roster.php at 390x844]. Wide enough and
   27px tall, which is over the 24px AA floor and well under the 44px figure this app holds itself to
   wherever the pointer is a finger. The padding-bottom that gives the header its gap under the label
   is what makes `min-height` the right lever: it grows the box, not the text's position. */
@media (pointer:coarse) {
  .mtbl-kebab { min-width:44px; min-height:44px; }
  .mtbl-item { min-height:44px; }
  .mtbl-h, .cptbl-h { min-height:44px; }
}

/* ============================================================================================
   HELP QUEUE CARDS [2026-09-02, her redesign]. Replaces the single-line `.hqrow` flex row --
   *"right now it's just one giant long line of text... I want it taller"* -- with a card that
   carries the person block, the question at reading size, and a left rail holding queue position
   and wait time.

   APPENDED, NOT EDITED IN PLACE. The old `.hqrow` rules (~3961, ~5311-5590) still style the
   Follow-Ups rows, which keep the one-line shape. `.hqcard` re-declares only what it must and
   wins on source order. Every `.hqcard` also carries `.hqrow` so the block filter's
   `.hqrow[data-blk]` selector keeps working.
   ============================================================================================ */
.hqcard {
  display:grid; grid-template-columns:64px 1fr; gap:0 16px;
  align-items:start; flex-wrap:nowrap;          /* override .hqrow's flex row */
  background:var(--panel); border:1px solid var(--line); border-radius:10px;
  padding:14px 16px 14px 0; position:relative; overflow:hidden;
}
/* Severity reads DOWN A RAIL rather than as a glow around the whole row -- faster to scan in a
   list of six, and it does not fight the route badges for attention. */
.hqcard::before { content:""; position:absolute; inset:0 auto 0 0; width:3px; background:var(--line-hi); }
.hqcard.esc::before  { background:var(--red); }
.hqcard.esc          { border-color:color-mix(in srgb, var(--red) 42%, var(--line)); }
.hqcard.held::before { background:var(--accent-3); }
.hqcard.held         { background:color-mix(in srgb, var(--panel) 82%, var(--bg-deep)); }
.hqcard.urgentrow::before { background:var(--red); }
/* your own claim: the one card allowed to glow, and it sits above the filter anyway */
.hqcard.mine         { border-color:color-mix(in srgb, var(--brand) 46%, var(--line));
                       box-shadow:0 0 22px -6px color-mix(in srgb, var(--brand) 46%, transparent); }
.hqcard.mine::before { background:var(--brand); }

.hqrail { grid-row:1 / 4; display:flex; flex-direction:column; align-items:center;
          gap:7px; padding:2px 0 0 3px; align-self:stretch; border-right:1px solid var(--line); }
.hqpos  { font:700 15px var(--mono); color:var(--dimmer); font-variant-numeric:tabular-nums; }
/* .qtime rides along for the JS clock + its colour bands; this re-sizes it for the rail and
   undoes the fixed 74/84px widths that rule sets for the old row. */
.hqcard .hqwait { font:700 20px/1 var(--mono); font-variant-numeric:tabular-nums;
                  min-width:0; flex:0 0 auto; text-align:center; }
.hqcard .hqwait .u { display:block; font:500 9.5px var(--mono); letter-spacing:.13em;
                     text-transform:uppercase; color:var(--dimmer); margin-top:4px; }

.hqhead  { grid-column:2; display:flex; align-items:flex-start; gap:12px; flex-wrap:wrap; min-width:0; }
.hqchips { display:flex; align-items:center; gap:7px; flex-wrap:wrap; margin-left:auto; }
.tt-loc  { color:var(--dim); border-color:var(--line-hi); background:transparent; }
.tt-held { color:var(--accent-3); border-color:color-mix(in srgb, var(--accent-3) 34%, transparent);
           background:color-mix(in srgb, var(--accent-3) 12%, transparent); }

/* the question, given room to actually be read */
.hqq     { grid-column:2; margin-top:12px; background:var(--panel-hi);
           border:1px solid var(--line); border-left:2px solid var(--line-hi);
           border-radius:7px; padding:13px 16px; display:flex; flex-direction:column; gap:6px; }
.hqcard.esc .hqq { border-left-color:color-mix(in srgb, var(--red) 55%, var(--line-hi)); }
.hqkind  { font:10.5px var(--mono); letter-spacing:.11em; text-transform:uppercase; color:var(--dimmer); }
.hqtext  { margin:0; font-size:15.5px; line-height:1.55; color:var(--text); max-width:72ch;
           overflow-wrap:anywhere; }

.hqfoot  { grid-column:2; display:flex; align-items:center; gap:10px; margin-top:12px; flex-wrap:wrap; }
.hqcard .qwait { flex:0 1 auto; margin-right:auto; text-align:left; white-space:normal;
                 font-family:var(--mono); font-size:12.5px; }
/* "you" is the whole point of the line, so it takes the theme's brightest token; a claimer who is
   NOT you takes a different one, so colour alone answers "is this mine?" */
.hl-you  { color:var(--brand);    font-weight:700; }
.hl-who  { color:var(--accent-3); font-weight:700; }
/* Claim on a non-escalated row and Done are primary-but-calm: brand, not the red used for
   escalated claims. `.btn-redfill` already exists; this is its quiet twin. */
.btn-brandfill { background:var(--brand); border-color:var(--brand); color:var(--brand-ink); }
.btn-brandfill:hover { background:color-mix(in srgb, var(--brand) 84%, #000); border-color:var(--brand); }

.qcap-mine { margin-bottom:18px; }
.hqfilter  { display:flex; align-items:center; gap:8px; flex-wrap:wrap; margin:16px 0 12px; }
.hqfilter .n { opacity:.7; margin-left:5px; font-variant-numeric:tabular-nums; }

@media (max-width:640px) {
  .hqcard { grid-template-columns:54px 1fr; padding-right:12px; }
  .hqchips { margin-left:0; }
  .hqcard .qwait { flex:1 1 100%; margin-right:0; }
}

/* ---- HELP QUEUE ON A PHONE [2026-09-02, her ruling] --------------------------------------
   The teacher works the queue while circulating, and student leads work it from their own
   phones. Both reverse MOBILE_BUILD_PLAN §7's "no queue monitoring on a phone for anyone";
   see her 2026-09-02 note there and in public/helpqueue.php.

   KEYED TO THE SAME TEST THE PHONE WALL MAKES (<=520px AND a coarse pointer), not to width
   alone -- a low-vision teacher at 400% browser zoom on a laptop is not on a phone and must
   keep the full desktop layout. `.desk-only` is exactly this pair, and these rules have to
   agree with it or the two disagree about what "phone" means.
   ------------------------------------------------------------------------------------------ */
@media (max-width:520px) and (pointer:coarse) {
  /* The rail stops being a column and becomes a header strip: 64px of vertical gutter is a lot
     on a 390px screen, and position + time read fine side by side. */
  .hqcard      { grid-template-columns:1fr; gap:0; padding:12px 13px; }
  .hqrail      { grid-row:auto; grid-column:1; flex-direction:row; align-items:baseline;
                 gap:10px; border-right:0; border-bottom:1px solid var(--line);
                 padding:0 0 9px; margin-bottom:10px; }
  .hqcard .hqwait { font-size:17px; margin-left:auto; }
  .hqcard .hqwait .u { display:inline; margin:0 0 0 4px; font-size:9px; }
  .hqhead, .hqq, .hqfoot { grid-column:1; }
  .hqq         { padding:11px 12px; }
  .hqtext      { font-size:14.5px; }
  .hqchips     { margin-left:0; }
  .hqcard .qwait { flex:1 1 100%; margin:0 0 4px; }

  /* 44px targets: operated one-handed, standing up, mid-classroom. `.btn-sm` is a desk size. */
  .hqfoot .btn, .helping .btn, .lqwrap .btn {
    min-height:44px; min-width:96px; flex:1 1 auto; font-size:14px; padding:0 14px;
  }
  .hqfoot      { gap:8px; }

  /* The lead's ON IT box is a row of three controls on desktop; stack it so each is tappable. */
  .helping > div:last-child { flex-wrap:wrap; }
  .helping .qright { display:flex; gap:8px; flex-wrap:wrap; width:100%; }

  /* Filter pills wrap rather than scrolling off the edge. */
  .hqfilter    { gap:6px; margin:12px 0 10px; }
  .hqfilter .fchip { min-height:38px; }
}

/* ---- UPCOMING LEAVE STRIP [2026-09-02] -----------------------------------------------------
   One row per real CLASS DAY for this team, not a month grid. See upcoming_leave_html()'s
   docblock for why: on an A/B schedule half a month's cells are days the team never meets, so a
   blank square would mean "no class" and "nobody out" at the same time.

   SPARSE IS THE POINT. Clear days are deliberately thin and low-contrast; a day carrying someone
   gets the panel fill, the accent rail and the full-weight date. Scanning it should be "where are
   the heavy rows", not "read fourteen dates".
   -------------------------------------------------------------------------------------------- */
.ulv        { display:flex; flex-direction:column; gap:4px; max-height:52vh; overflow-y:auto; }
.ulv-row    { display:flex; align-items:center; gap:10px; padding:7px 10px; border-radius:7px;
              border:1px solid transparent; min-height:34px; }
/* a clear day: no fill, no border, just a quiet line of text */
.ulv-row .ulv-d    { font-family:var(--mono); font-size:12.5px; color:var(--dimmer);
                     min-width:82px; font-variant-numeric:tabular-nums; }
.ulv-none   { font-size:12.5px; color:var(--dimmer); opacity:.6; }
/* a day someone is out: this is what you are scanning for */
.ulv-row.has       { background:var(--panel); border-color:var(--line);
                     border-left:2px solid var(--accent-2); padding-left:12px; }
.ulv-row.has .ulv-d { color:var(--text); font-weight:600; }
.ulv-row.today     { border-color:color-mix(in srgb, var(--brand) 42%, var(--line));
                     background:color-mix(in srgb, var(--brand) 7%, transparent); }
.ulv-row.today.has { border-left-color:var(--brand); }
.ulv-tag    { font-family:var(--mono); font-size:9px; letter-spacing:.12em; text-transform:uppercase;
              color:var(--brand); border:1px solid color-mix(in srgb, var(--brand) 40%, transparent);
              border-radius:99px; padding:2px 7px; white-space:nowrap; }
.ulv-who    { display:flex; align-items:center; gap:10px; flex-wrap:wrap; margin-left:auto;
              justify-content:flex-end; }
.ulv-p      { display:flex; align-items:center; gap:6px; font-size:13px; color:var(--text); }
.ulv-p .avatar { flex-shrink:0; }

@media (max-width:520px) and (pointer:coarse) {
  .ulv-row  { flex-wrap:wrap; }
  .ulv-who  { margin-left:0; justify-content:flex-start; width:100%; }
  .ulv-row.has { padding-top:9px; padding-bottom:9px; }
}
