/* IEP Navigator design system.
   Extracted from base.html's former inline <style> block (8/16/26, QA
   look-and-flow review): that block was ~1,500 lines re-sent inside EVERY
   page response -- even the login page weighed ~207KB, and the Guide page
   ~347KB, with nothing cacheable between clicks. As one external file it
   downloads once and is served from browser cache afterward (see the
   Cache-Control middleware in app/main.py and the ?v= content-hash version
   param from app/deps.py's asset_version, which busts that cache exactly
   when this file actually changes). Every comment below is unchanged from
   the inline block -- the history in them still applies. */
    :root {
      --primary: #182D62;
      --primary-light: #E0E4EE;
      /* Hue-matched to --primary (8/21/26 fix): was #E0E1E0, a flat
         2%-saturation grey with no relationship to the Dark Royal Blue
         --primary hue at all -- a leftover value that predates this
         color's several rebrand passes and never got carried along with
         them, unlike --danger-bg/--warning-bg which already keep real
         saturation relative to their own base color. Used in 40+ places
         (checkbox pills, "future"/status badges, career-match pills,
         timeline markers) that are all meant to read as "a pale tint of
         the brand color," not neutral grey. New value keeps the same
         hue (~223 deg) and lightness/saturation relationship the
         danger/warning pairs already use; contrast against --primary
         text actually improved slightly in the process (10.07:1 ->
         10.37:1), both comfortably past WCAG AA's 4.5:1 floor. */
      /* Split from --primary, added for dark mode: --primary is used both as
         a FIELD background with white text on top (header, buttons) and as
         a standalone TEXT color on a light surface (h2, links, tag text).
         Those need opposite lightness in dark mode -- a button needs to stay
         dark enough for white text, but heading text needs to get lighter to
         read against a dark card. In light mode this equals --primary
         exactly, so nothing changes visually until dark mode is on. */
      --primary-text: var(--primary);
      /* Rebrand (8/19/26): forest green (#1F4D3F) + gold (#C08A2E) read as
         Packers colors to Casey (unprompted -- not a design critique, a
         real association he wanted gone). New primary is a warmed navy: a
         25% blend from pure navy (#1B3A5C) toward a saturated royal blue
         (#2547B5), then nudged toward indigo/denim (slightly more red
         relative to blue) so it reads as a rich undertone rather than an
         icy pure blue -- verified this warming step actually IMPROVED
         white-text contrast slightly (11.0:1 vs 10.7:1 unwarmed), not a
         tradeoff. New accent is a deep amber, chosen specifically because
         the OLD gold accent (#C08A2E) was already failing WCAG AA as text
         (3.0:1 on white, under the 4.5:1 floor) -- pre-existing, never
         caught since accent wasn't usually used as body text color. This
         fixes it as a side effect (4.9:1 on white) rather than carrying
         the same silent failure forward into the new palette. --primary-
         light no longer blends toward white -- it blends toward the warm
         cream --paper color instead, so it picks up warmth from the page
         itself instead of reading as a cold blue-gray patch.

         Second pass (8/20/26): the 25%-blend navy (#213C67) shipped above
         tested as the LEAST saturated of every real candidate compared
         against it (51% vs. 55-66% for the others) -- a side effect of the
         "warming" nudge softening saturation along with hue. Casey and his
         wife independently preferred two different candidates from a wider
         comparison (rendered live across the real homepage, dashboard, and
         Student View, not just swatches) -- Casey: Dark Royal Blue
         (#182D62, hue 223°, 24% light, 61% sat -- royal blue's actual hue
         family, darkened to navy's own depth rather than diluted toward
         it). His wife: plain Navy (#1B3A5C, hue 211°, 55% sat). Went with
         Dark Royal Blue for now, on Casey's call as the one actually
         building and living with it daily -- but SAVING every real
         candidate here in case this gets revisited, since re-deriving good
         hex values from scratch is real work these already did once:
           - Navy:              #1B3A5C (his wife's pick)
           - Prussian Blue:     #003366 (near-identical to Navy, hue 210°;
                                 a real historical pigment, independently
                                 converged on the same hue as Navy)
           - Dark Royal Blue:   #182D62 (Casey's pick, now live)
           - Royal Blue, bright:#2547B5 (same hue family, much lighter/more
                                 saturated -- ruled out, too vivid to read
                                 as calm chrome rather than a brand
                                 statement)
           - Oxford Blue:       #002147 (ruled out -- 14% lightness reads
                                 as near-black, too somber/austere for an
                                 app trying to feel warm, not funereal)
           - Phthalo Blue:      #000F89 (ruled out -- most violet-leaning
                                 hue of the set, near-pure blue channel
                                 gives it an electric quality that pulls
                                 focus rather than sitting quietly as
                                 background chrome)
         Dark mode's own --primary (#4A6BAE, hue 220°) was NOT touched --
         checked first, and it already sits within 3° of Dark Royal Blue's
         hue (223°), independently landing in the same family without any
         change needed. */
      --accent: #96650F;
      --text: #2A2E29;
      --muted: #6E6A5C;
      --border: #DDD7C9;
      --border-strong: #C7BCA0;
      --muted-light: #EDEAE0;
      --danger: #962C34;
      /* Rebrand follow-up (8/19/26): pushed from an orange-leaning red
         (#A6432F, hue 10°) to a true red (hue 355°) specifically because
         it sat only 7 degrees apart in hue from the new --tab-resume
         (#7A4530, hue 17°) -- close enough that "something's overdue"
         and "you're on the Resume tab" started to read as the same color
         family, diluting exactly the distinctiveness a danger color exists
         for. Found by Casey looking at his own real screenshot and saying
         the warning red didn't feel like it belonged with the new palette
         -- verified with real hue-angle math before touching anything,
         same discipline as the hub-palette redesign. Bonus, not the goal:
         this also widens the gap from --warning (now 44° apart instead of
         29°), so the two severity tiers read as more distinct from each
         other too. --warning itself (#A8791F, hue 39°) was deliberately
         NOT touched -- it already sits right next to --accent's 38°, a
         genuinely good pairing, not a coincidence worth disturbing. */
      --warning: #A8791F;
      /* Status/urgency background tints -- named as variables (not left as
         bare hex in .badge.* and elsewhere) specifically so every place that
         needs "the overdue color" or "the warning color" pulls from the same
         source and can't silently drift out of sync, and so a future dark
         mode pass only has to redefine these once. */
      --danger-bg: #EADBD6;
      /* Rebrand follow-up (8/19/26): the old #fbe3e1 was a separately-
         invented bright pastel pink, unrelated to anything else in the
         palette -- swapped for a tint of the NEW --danger blended toward
         --paper (the same technique --primary-light already uses), so it
         picks up warmth from the actual page instead of sitting on top of
         it as an unrelated candy-pink patch. Also, separately, just
         calmer -- fits this app's own deliberately non-alarmist Findings
         language ("never a determination the district broke the law")
         better than a bright pink ever did. */
      --warning-bg: #fdf1de;
      --paper: #F6F3EC;
      /* Card/panel surface -- was a literal `white` on both .card and
         .section-list until the dark-mode pass; now a variable so a dark
         theme can give cards their own (slightly-lighter-than-page)
         surface tone instead of staying literally white. */
      --surface: #FFFFFF;
      --accent-light: #F3E7D2;
      --info: #3B6E8C;
      --info-light: #DEE9EF;
      /* Accessible-text variants (added 8/11/26, real WCAG audit -- not
         just eyeballed -- prompted by Casey asking whether the "future"
         status pill should be made MORE prominent; checking that led to
         computing actual contrast ratios for every status/tag pill combo
         in the app, not just the one that was asked about). Result: the
         "future" pill everyone worried about is fine (8:1, AAA) but four
         other real, always-visible pill/tag combos were genuinely failing
         WCAG AA (needs 4.5:1) using --warning/--info/--muted directly as
         text on their own tinted backgrounds -- .badge.upcoming measured
         3.47:1, .tag-accent 3.21:1, .badge.completed/.tag-count 4.48:1,
         .tag-muted 4.50:1 (all computed programmatically, not judged by
         eye). Rather than darken --warning/--info/--muted themselves
         (used in many other places -- borders, dots, plain body text on
         much lighter --paper/--surface backgrounds where they already
         pass -- redefining them risks fixing 4 spots and silently
         breaking contrast or visual intent somewhere else unaudited),
         these are new, narrowly-scoped variables: same hue, darkened just
         enough to clear 4.5:1 against the SPECIFIC tinted backgrounds
         they're paired with (verified against the worse of two
         backgrounds where a color is reused, e.g. --warning-text is used
         on both --warning-bg and --accent-light). In dark mode these
         simply alias back to the existing --warning/--info/--muted dark
         values below, which already independently pass AA/AAA. */
      --warning-text: #876119;
      --info-text: #3A6C8A;
      --muted-pill-text: #6C685B;
      /* PWN doc-type chip color (added 8/1/26, see .tag-doctype-* comment
         below for why this needed its own variable rather than reusing
         --warning): reuses the same plum/wine hue family as --tab-advocacy
         (the Advocacy Center folder tab, named Compliance Audit until the
         same-day rename), since a PWN is exactly the kind of document its
         Findings/PWN Analyzer sub-feature analyzes -- but as its own
         variable (not a direct reuse of --tab-advocacy) since that one is
         only ever a solid opaque tab background, never paired with a light
         tint the way a chip needs.
         Rebrand (8/19/26): updated alongside --tab-advocacy's own hub-
         palette redesign (see that block's comment) specifically BECAUSE
         of the deliberate coupling described above -- unlike --info
         (which only coincidentally shared an old hex with a hub color and
         was deliberately left alone), --pwn is INTENTIONALLY meant to
         track --tab-advocacy's hue family. Leaving it at the old plum
         while --tab-advocacy moved to a new wine would have silently
         broken the exact relationship this variable exists for. */
      --pwn: #5B294B;
      --pwn-bg: #F3E4EA;
      /* 504 Plan doc-type chip color (9/21/26, 504 plan MVP) -- every hue
         already in the doc-type set (blue/light-blue/plum/gold/gray) was
         spoken for, same reasoning --pwn's own comment above gives, so a
         genuinely new hue was needed. Picked green: distinct from all 5
         existing doc-type chips, and not otherwise claimed by a doc-type
         (reused elsewhere for --tab-transition/.callout-success, but never
         for a document-type chip specifically). */
      --plan504: #2D5D32;
      --plan504-bg: #E1EADD;
      /* Bucketed timeline "This month" color (added 8/4/26): the redesigned
         Timeline groups deadlines into proximity buckets, one of which
         needed a color distinct from --warning ("This week") -- a second
         shade of gold/amber sitting right next to warning read as nearly
         the same color at a glance, exactly the "two variables, visually
         identical" failure the --pwn fix above already addressed once for
         a different pairing. Reuses the same hex as --tab-resume (the
         Resume hub folder tab) since it's a real, already-in-palette warm
         brown genuinely distinct from every other bucket color -- but as
         its OWN variable, not a direct reuse of --tab-resume, for the
         identical reason --pwn isn't a direct reuse of --tab-advocacy:
         that one's only ever a solid opaque tab background, never paired
         with a light tint.
         Rebrand (8/19/26): same deliberate-coupling reasoning as --pwn
         above -- updated alongside --tab-resume's redesign specifically
         because this variable is meant to track it, not coincidence. */
      --month: #7A4530;
      --month-bg: #F0E4D9;
      /* One color per hub, used by the folder-tab nav (see .folder-tabs
         below) -- a parent should be able to tell which section they're in
         by color alone after a few visits, the way you'd color-code
         binder tabs by subject.
         Rebrand (8/19/26): Profile/Transition simply mirror the new
         --primary/--accent (same as always).
         Second pass, same day: the FIRST rebrand pass only changed Careers
         and Guide (#3B6E8C -> #2D7A78, #3D4A66 -> #4F5D3F) -- the minimum
         needed to stop them colliding with the new navy primary -- and left
         Resume and Advocacy as completely untouched leftovers from the old
         green/gold era. Casey caught, correctly, that this read as a patch
         job: one confident new color (navy) sitting next to a mix of
         barely-nudged and entirely-unchanged old ones, not a palette
         designed as a set. This second pass redesigns all four non-brand
         hubs together as one coordinated "deep jewel tone" family, matched
         to navy/amber's own register (roughly 25-35% lightness, moderate-
         to-high saturation) rather than just avoiding collisions:
         Careers -> deep teal (174° hue, cool/exploratory), Resume -> deep
         rust (17°, warm/craft), Advocacy -> deep wine (340°, serious/
         protective), Guide -> deep pine (127°, grounding/reference).
         Checked real HSL hue angles for all six hub colors together, not
         just eyeballed: no two closer than ~40 degrees apart on the wheel
         after accounting for saturation differences (rust and amber sit
         closest in raw hue at ~20 degrees apart, but amber's saturation is
         nearly double rust's, which is what actually keeps them visually
         distinct at a glance). All six clear 5:1+ contrast as white tab
         text. --info (#3B6E8C, used app-wide for "completed" badges/
         markers) is a SEPARATE variable that only coincidentally shared
         Careers' original hex -- still deliberately left unchanged, same
         reasoning as the first pass: it was never actually "the Careers
         color," just a historical coincidence, unlike --pwn/--month above
         which genuinely are meant to track their hub colors.

         Third pass (8/21/26): Casey and his wife weren't in love with
         Transition or Advocacy's colors from the second pass. Went through
         a real live A/B process in a standalone HTML comparison tool
         (checked into no file, just built for this session) rather than
         guessing -- multiple rounds of candidates, each checked against
         every OTHER color on this whole page for real HSL hue separation,
         not eyeballed, the same discipline as every pass before this one.
         Landed on:
           - Transition: #96650F (amber) -> #2D5D32 ("Pine, lighter") --
             DECOUPLED from --accent for the first time. Transition and
             Profile were the only two tabs riding on a global brand color
             instead of having their own dedicated hue, same inconsistency
             already fixed for the OTHER four hubs in the second pass;
             --accent itself (#96650F, still used everywhere else --
             buttons, links, badges) is UNCHANGED. Same green-family hue as
             Guide's OLD pine (that hue was free to reuse precisely because
             Guide itself moved away from it in this same pass -- verified,
             not assumed: Guide's new color sits 71 degrees away from this
             one, comfortably clear).
           - Advocacy: #6B2D42 (deep wine) -> #5B294B ("Deep Berry") -- the
             second-pass wine already had a real problem (only 15 degrees
             from --danger's red, since Advocacy pages are exactly where
             overdue/urgent red badges show up most). Deep Berry keeps
             Advocacy's warm, confident register (a quieter charcoal-plum
             candidate was tried and rejected -- Advocacy is the hub where
             a parent is actively building a case, and "quiet, least
             attention-grabbing," which is genuinely the right register for
             Guide, undersold what Advocacy actually is) while pulling a
             real 35 degrees clear of danger, not 15.
           - Guide: #2F5233 (pine) -> #314F5E ("Dusty Slate-Blue, richer")
             -- moved OFF the green family entirely so Transition could
             move onto it without a collision. Deliberately matched in
             SATURATION (32%) to Pine-lighter (35%) and Deep Berry (38%),
             not just checked for hue separation -- an earlier, less
             saturated version of this same hue (16%) technically cleared
             every distance check but looked visibly quieter than its two
             neighbors once rendered together, which is a real cost a pure
             hue-angle check doesn't catch on its own.
         All three re-verified together against Profile/Careers/Resume/
         accent/danger, not just against each other -- closest pairing
         anywhere on the six-hub set is now Deep Berry to --danger at 35
         degrees, still comfortably clear. */
      --tab-profile: #182D62;
      --tab-transition: #2D5D32;
      --tab-careers: #1F6B63;
      --tab-resume: #7A4530;
      --tab-advocacy: #5B294B;
      --tab-guide: #314F5E;
      /* Homepage-only accent for the Student View tile (8/18/26) -- Student
         View has no real top-level folder tab of its own (it lives inside
         a child's own profile page, entered via a PIN, not a colored nav
         tab), so unlike every other homepage tile color above, this one
         isn't reused from an existing in-app hub -- it's a genuinely new
         color, picked to sit visibly apart from every hue already in this
         palette (not close to the wine/plum family, not close to the
         blue-slate family, not a repeat of green/gold/brown). Verified
         4.5:1+ contrast against white icon glyphs, same bar every tab-*
         color above already needs to clear on its own solid circle. Scoped
         to the homepage tile only -- do not reuse this as an in-app folder
         tab color without deciding Student View deserves one first. */
      --tile-student: #5C5A8C;
      --font-display: 'Fraunces', Georgia, serif;
      --font-body: 'Public Sans', -apple-system, BlinkMacSystemFont, "Segoe UI", Helvetica, Arial, sans-serif;
      --font-mono: 'IBM Plex Mono', 'SFMono-Regular', Consolas, monospace;
    }
    /* Dark mode (Visual QoL, bigger design pass). Every value below was
       checked against WCAG AA (4.5:1 for text, 3:1 for UI-component
       borders) against the surface it actually appears on -- not eyeballed.
       Applied via [data-theme="dark"] on <html>, toggled by the button in
       the header and remembered in localStorage (see the script at the end
       of this file); falls back to the OS-level prefers-color-scheme on a
       first visit with no saved preference (the media query block further
       below).

       --primary and --accent etc. keep their light-mode values here where
       they're only ever used as a small dot/line/mark (already checked
       against a dark surface) -- only --primary-text, the surfaces, and the
       *-light/*-bg tint pairs actually needed new values. */
    [data-theme="dark"] {
      --paper: #14170F;
      --surface: #1D2118;
      --text: #E9E6DA;
      --muted: #A7A38F;
      --border: #3D4332;
      --border-strong: #666D51;
      --muted-light: #22261C;

      --primary: #4A6BAE;
      --primary-text: #A8C4EA;
      --primary-light: #16213A;

      --accent: #E0B563;
      --accent-light: #2D2115;

      --info: #8FC1E3;
      --info-light: #182530;
      --info-text: var(--info);

      --danger: #E48B93;
      /* Rebrand follow-up (8/19/26): same hue-shift as light mode's
         --danger (see that block's comment) -- pushed from #E4897A (hue 8°,
         same orange-red family as the light-mode value it mirrors) to a
         true red (hue 355°) for the same reason: sat too close in hue to
         the new warm-brown/rust family to read as clearly distinct. */
      --danger-bg: #2E1517;

      --warning: #D9A855;
      --warning-bg: #2C2214;
      --warning-text: var(--warning);
      --muted-pill-text: var(--muted);

      --pwn: #D98CAE;
      --pwn-bg: #2C1620;

      --plan504: #8FC48F;
      --plan504-bg: #172417;

      --month: #D9A876;
      --month-bg: #2C2014;
    }
    /* First visit, no saved preference yet: respect the OS-level setting.
       The inline script at the very top of <head> (see below) sets
       data-theme on <html> before first paint whenever a saved preference
       exists, so this media query only ever applies on that first visit. */
    @media (prefers-color-scheme: dark) {
      :root:not([data-theme]) {
        --paper: #14170F;
        --surface: #1D2118;
        --text: #E9E6DA;
        --muted: #A7A38F;
        --border: #3D4332;
        --border-strong: #666D51;
        --muted-light: #22261C;
        --primary: #4A6BAE;
        --primary-text: #A8C4EA;
        --primary-light: #16213A;
        --accent: #E0B563;
        --accent-light: #2D2115;
        --info: #8FC1E3;
        --info-light: #182530;
        --danger: #E48B93;
        --danger-bg: #2E1517;
        --warning: #D9A855;
        --warning-bg: #2C2214;
        --pwn: #D98CAE;
        --pwn-bg: #2C1620;
        --plan504: #8FC48F;
        --plan504-bg: #172417;
        --month: #D9A876;
        --month-bg: #2C2014;
      }
    }
    /* The resume itself, and the theme-thumbnail previews of it, represent
       a fixed white-paper artifact (something meant to be printed/exported)
       -- not a themeable part of the app UI. Locking these variables back
       to their light-mode values here means the resume preview always
       looks like paper regardless of which app theme is active, the same
       way a document canvas stays white in most apps' dark modes. */
    .resume-content, .theme-thumb-preview {
      --paper: #F6F3EC;
      --surface: #FFFFFF;
      --text: #2A2E29;
      --muted: #6E6A5C;
      --border: #DDD7C9;
      --border-strong: #C7BCA0;
      --muted-light: #EDEAE0;
      --primary: #182D62;
      --primary-text: #182D62;
      --primary-light: #E0E4EE;
      --accent: #96650F;
      --accent-light: #F3E7D2;
      /* Pre-existing dark-mode gap, found 9/4/26 while checking the new
         Bold/Sunset themes in dark mode: redefining the custom properties
         above locks THEM correctly, but `color` is an ordinary inherited
         property -- if it was already resolved to the dark-mode --text
         value on an ancestor OUTSIDE this scope (e.g. body), descendants
         in here just inherit that already-resolved literal color, not a
         live var() that would re-check the locked value. Confirmed this
         silently affected Modern/Compact/Friendly too (their entry text
         was near-invisible in dark mode) -- only Classic/Large Print
         happened to look fine, because they separately hardcode their
         own literal `color` on the theme root. One explicit re-assertion
         here fixes it for every theme at once, past and future. */
      color: var(--text);
    }
    * { box-sizing: border-box; }
    /* item 43: keyboard/screen-reader users otherwise have to tab through
       the entire header nav (and, on pages with one, the child sub-nav)
       before reaching the actual page content on every single page load.
       Hidden off-screen until focused, same pattern used on most sites. */
    .skip-link {
      position: absolute;
      left: -9999px;
      top: 0;
      z-index: 10000;
      background: var(--primary);
      color: white;
      padding: 0.75rem 1.25rem;
      border-radius: 0 0 6px 0;
    }
    /* Same specificity bug as header's own fix just above (8/19/26) --
       .skip-link is one class, specificity (0,1,0), which loses to
       `a:visited`'s (0,1,1) the moment a keyboard user has actually
       triggered it before (its href is a same-page fragment, #main-content
       -- browsers do mark those visited). Caught while auditing every
       "always white text on a colored <a>" spot in this file for the same
       bug class the header fix just found, not from a separate report. */
    .skip-link:link, .skip-link:visited, .skip-link:hover, .skip-link:focus-visible { color: white; }
    .skip-link:focus {
      left: 0;
    }
    /* item 43: visually-hidden but still reachable by screen readers, for
       real <label> elements on inputs where a visible label would be
       redundant with placeholder/heading text already on the page. */
    .sr-only {
      position: absolute;
      width: 1px;
      height: 1px;
      padding: 0;
      margin: -1px;
      overflow: hidden;
      clip: rect(0, 0, 0, 0);
      white-space: nowrap;
      border: 0;
    }
    body {
      font-family: var(--font-body);
      margin: 0;
      background: var(--paper);
      color: var(--text);
      line-height: 1.5;
    }
    header {
      background: var(--primary);
      color: white;
      /* Horizontal padding trimmed 2rem -> 1.25rem (9/16/26, Casey's ask
         after seeing the live header -- "push it a little further towards
         the edge of the page"): moves both the brand mark on the left and
         nav-links on the right closer to the viewport edge, matching how
         most real app headers sit rather than floating with a wide gutter
         on a wide screen. Mobile's own header padding (0.75rem 1rem,
         below) is untouched -- already narrower than this. */
      padding: 1rem 1.25rem;
      display: flex;
      justify-content: space-between;
      align-items: center;
    }
    header a { color: white; text-decoration: none; }
    /* Hidden above the mobile breakpoint -- nav-links renders inline as
       always at that width, this button has nothing to do. Must come
       BEFORE the mobile media query below in source order -- a real bug
       caught live (9/13/26): with this rule placed AFTER that media query
       instead, equal specificity + later-in-file meant this always won,
       even inside the media query's own matching width, hiding the button
       at every width including the one it's actually meant for. */
    .nav-menu-toggle { display: none; }
    /* Real bug, caught 8/19/26 by Casey screenshotting his own real browser
       against the just-deployed rebrand -- "IEP Navigator" (the wordmark)
       and "Resources" rendered in --accent gold instead of white, but only
       on links Casey had actually visited before. This is the EXACT SAME
       class of bug already diagnosed and fixed for .btn on 8/11/26 (see
       that fix's own comment a bit further down this file) -- `a:visited`
       is an element + pseudo-class, specificity (0,1,1), which beats plain
       `header a` (0,0,2) despite `header a` naming two real elements,
       because a pseudo-class outweighs any number of extra element
       selectors in CSS's specificity model. It was invisible with the OLD
       gold-on-forest-green palette (a visited link going gold on a green
       header didn't read as obviously "wrong" the way amber-on-navy does)
       -- the rebrand didn't introduce this bug, it just made an
       already-existing one visible for the first time. The .btn fix from
       8/11/26 should have been generalized to header links at the time and
       wasn't -- same root cause, two separate places it could bite,
       genuinely missed rather than a new regression. Explicit
       :link/:visited/:hover/:focus-visible here ties the specificity fight
       and wins by coming later in the file, same fix shape as .btn's. */
    header a:link, header a:visited, header a:hover, header a:focus-visible { color: white; }
    header > a { font-weight: 600; font-family: var(--font-display); font-size: 1.2rem; letter-spacing: 0.01em; }
    /* Brand mark (9/16/26) -- the wordmark link became a flex row (icon +
       text) instead of plain text. gap, not a margin on the img, so it
       can't silently double up against any future change to this link's
       own layout. Round 4: round 3 sized it to exactly 1em (literally
       matching header > a's font-size) per Casey's own explicit ask, but
       he then reported it still looked a little small -- confirms round
       2's original reasoning was right after all: a thin 8-point star's
       actual ink covers much less of its bounding box than the bold
       serif letterforms next to it, so matching the em box exactly still
       reads as visually smaller than the text. Sized in em (not a fixed
       rem) so it still tracks header > a's font-size if that ever
       changes, just scaled up from a 1:1 ratio to one that reads as
       comparable weight next to bold text. */
    .brand-link { display: inline-flex; align-items: center; gap: 0.5rem; }
    .brand-mark { height: 1.6em; width: 1.6em; flex: none; }
    header .nav-links a { margin-left: 1.5rem; font-weight: 400; opacity: 0.9; }
    /* Mobile nav fix (8/15/26, external audit item 39): below 600px the
       wordmark was wrapping mid-word and "Log out" was breaking across two
       lines inside the same header row -- there was no mobile media query
       for the header at all. Wrapping the header onto two rows (wordmark on
       its own row, nav-links below) fixes both: each side gets its own full
       width instead of fighting for space in one row, and nav-links'
       min-height gives "Log out"/"Log in" real 44px tap targets instead of
       25-33px. */
    @media (max-width: 600px) {
      header { flex-wrap: wrap; padding: 0.75rem 1rem; row-gap: 0.5rem; }
      header .nav-links a {
        display: inline-flex; align-items: center; min-height: 44px; margin-left: 1rem;
      }
      /* Mobile pass follow-up (9/12/26): the 8/15/26 fix above gave every
         header NAV LINK a real 44px tap target but missed .theme-toggle --
         a real <button>, not an <a>, so `header .nav-links a` never matched
         it. Confirmed live via getBoundingClientRect(): 29x29px, the same
         class of undersized target the 8/15/26 fix was written to solve,
         sitting right next to links that already got fixed -- on every
         single page, since the header is site-wide. Same min-height
         treatment, plus min-width since this one's a square icon button
         rather than a text link (text links get adequate width for free
         from their label; an icon-only button doesn't). */
      .theme-toggle { min-height: 44px; min-width: 44px; justify-content: center; }

      /* Real mobile nav menu (9/13/26, second pass -- the first attempt at
         this, docking the theme toggle to the header's own corner via
         position:absolute, made things WORSE per Casey's own live-phone
         screenshot: "Log out" (or whichever link fell last) still wrapped
         onto its own near-empty row, AND the toggle now sat visually
         disconnected from the rest of the nav, floating separately near
         the wordmark. That patched the symptom (toggle no longer paired
         with a stray link) without fixing the actual cause: 5 links plus
         a real brand wordmark simply don't fit on one row at phone width,
         no matter which item ends up wrapping alone.

         Real fix: a proper collapsed menu, the standard answer to "too
         many nav items for one row" on any site, not another arrangement
         of the same 5 links. .nav-menu-toggle (hidden entirely above this
         breakpoint) sits on the SAME row as the wordmark, at the far
         right via ordinary flex space-between -- no position:absolute
         needed this time, since a 2-item row (wordmark, hamburger) then a
         separate full-width nav-links row is just how header's existing
         flex-wrap naturally lays things out once nav-links is empty/
         hidden by default. Tapping it reveals the full link list (theme
         toggle included, now genuinely the last item in a real vertical
         list, not a stray pairing) as a full-width dropdown below --
         plenty of room for every item including Log out to render as its
         own clean, unambiguous row, which a half-row of wrapped inline
         links could never really guarantee regardless of ordering. */
      header { position: relative; }
      .nav-menu-toggle {
        display: inline-flex; align-items: center; justify-content: center;
        background: none; border: none; color: white; cursor: pointer;
        min-height: 44px; min-width: 44px; font-size: 1.3rem; opacity: 0.9;
        margin-left: 1rem; order: 1;
      }
      .nav-menu-toggle:hover, .nav-menu-toggle:focus-visible { opacity: 1; }
      header .nav-links {
        display: none;
        order: 2;
        flex-basis: 100%;
        flex-direction: column;
        align-items: stretch;
      }
      header .nav-links.nav-links-open { display: flex; }
      header .nav-links.nav-links-open a,
      header .nav-links.nav-links-open .theme-toggle {
        margin-left: 0;
        padding: 0.6rem 0.25rem;
        border-top: 1px solid rgba(255, 255, 255, 0.15);
        justify-content: flex-start;
      }
    }
    /* Dark mode toggle: a plain icon button in the header, swapping between
       a moon (currently light, click to go dark) and a sun (currently dark,
       click to go light). Icon visibility is CSS-only (no JS needed just to
       show the right icon) -- keyed off data-theme when it's explicitly
       set, and off prefers-color-scheme when it isn't (a first visit with
       no saved preference yet, still following the OS setting). */
    .theme-toggle {
      background: none;
      border: none;
      color: white;
      cursor: pointer;
      padding: 0.3rem;
      margin: 0 0 0 1.5rem;
      font-size: 1.2rem;
      display: inline-flex;
      align-items: center;
      opacity: 0.9;
      vertical-align: middle;
    }
    .theme-toggle:hover { opacity: 1; }
    .theme-toggle-icon-light { display: none; }
    [data-theme="dark"] .theme-toggle-icon-dark { display: none; }
    [data-theme="dark"] .theme-toggle-icon-light { display: inline; }
    @media (prefers-color-scheme: dark) {
      :root:not([data-theme]) .theme-toggle-icon-dark { display: none; }
      :root:not([data-theme]) .theme-toggle-icon-light { display: inline; }
    }
    main {
      max-width: 900px;
      margin: 2rem auto;
      padding: 0 1.5rem;
    }
    .card {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 10px;
      padding: 1.5rem;
      margin-bottom: 1.5rem;
      /* Root-cause fix for a real mobile bug (screenshot, 9/19/26): a long
         unbroken string with no spaces (a real district-uploaded filename
         like "..._Prior_Written_Notice_Annual_Review_INCOMPLETE.pdf") has
         nowhere for normal line-wrapping to break, so it overflowed its
         card and dragged the WHOLE PAGE into horizontal scroll on a phone.
         Applied here at the .card level (not just the one child-list-item
         it was first spotted on) since this is a general class of bug --
         any card anywhere can end up holding a real filename or other long
         unbroken string a parent didn't choose the wording of. Only kicks
         in when a word would otherwise overflow; never affects normal
         short text. */
      overflow-wrap: break-word;
    }
    h1 { font-size: 1.6rem; font-family: var(--font-display); font-weight: 600; }
    h2 { font-size: 1.2rem; color: var(--primary-text); font-family: var(--font-display); font-weight: 600; }
    /* Resume themes each have their own deliberate typography (e.g. Georgia
       for the classic look) -- don't let the app-wide display font override
       that inside a printed resume. */
    .resume-content h1, .resume-content h2 { font-family: inherit; font-weight: inherit; }
    /* Plain inline text links (mailto:, "Forgot your password?", "Document
       uploaded: ...", "See Maya Chen's current matches", etc.) were falling
       through to the browser's default blue/underline -- the only unstyled
       anchor color in the app (visual QA pass, 8/11/26). Confirmed this was
       actually happening app-wide, not just on the login page, by checking
       every prose-context link, not just the one that was first noticed.
       Nav links, quick-nav pills, folder tabs, and badges all already have
       their own more specific color rule (e.g. `.quick-nav a`, `header a`)
       that overrides this by CSS specificity, so this only affects plain
       content links -- exactly the ones that were falling through. Left the
       underline in place (still real navigational affordance) and used
       --accent so it reads as "part of this app," distinct from the Guide's
       dotted-underline glossary terms, which intentionally mean something
       different ("hover for a definition," not "click to go somewhere"). */
    a { color: var(--accent); }
    a:visited { color: var(--accent); }
    /* --warning itself measured 3.87:1 on white / 3.50:1 on --paper here --
       under AA's 4.5:1 even though this is a transient hover/focus state,
       not the static text WCAG's minimum is strictly written for. Swapped
       to --warning-text (added in the same audit as the badge fixes above)
       for the same reason: same gold hue, actually passes (5.6:1 / 5.0:1),
       so a keyboard user tabbing through links isn't relying on a
       borderline-legible focus indicator. */
    a:hover, a:focus-visible { color: var(--warning-text); }
    /* Real bug, caught 8/11/26 by Casey screenshotting his own actual
       browser (not this sandbox) -- "Take/Retake the Career Profiler" and
       every other <a class="btn"> (89 of them app-wide) were rendering
       their text in --accent (gold) instead of white, but ONLY on links
       Casey had actually clicked before, or was hovering/tabbed onto. Root
       cause: a:visited and a:hover/a:focus-visible above are both an
       element selector + a pseudo-class, which counts as ONE class-level
       selector for specificity (0,1,1) -- that's MORE specific than plain
       `.btn { color: white }` above (0,1,0), even though .btn is a class
       selector, because a pseudo-class's specificity weight is identical
       to a real class's. Declaration order doesn't save it either, since
       .btn comes first in the file. A pure white-on-primary-green button
       silently going gold-on-green (a real contrast problem, not just a
       "wrong" color) the moment a user had clicked it before was
       invisible in this sandbox's own screenshots, which never carry real
       browsing history -- caught only because Casey checked his own
       actual browser. Explicit :link/:visited/:hover/:focus-visible on
       .btn itself ties the specificity fight and wins by coming later in
       the file, keeping every button's text white in every state,
       regardless of whether that particular link has been visited. */
    .btn:link, .btn:visited, .btn:hover, .btn:focus-visible { color: white; }
    label { display: block; font-weight: 600; margin-top: 0.75rem; margin-bottom: 0.25rem; font-size: 0.9rem; }
    /* Grouped headings inside a single long form (Edit Child form, added
       8/11/26 -- 11 fields in one undifferentiated list, unlike the Resume
       page's headed sections). Not a fieldset/legend: a <legend> renders
       inconsistently across browsers and this only needs a visual break,
       not a new accessibility grouping semantic on top of each label's own
       <label for>, which already ties every input to its text correctly. */
    .form-section-heading {
      font-family: var(--font-display);
      font-size: 1rem;
      font-weight: 600;
      color: var(--primary-text);
      margin: 1.5rem 0 0 0;
      padding-top: 1.25rem;
      border-top: 1px solid var(--border);
    }
    /* Modifier, not :first-child -- the csrf_field() hidden input each form
       starts with is a real preceding sibling element, so :first-child
       never matched and the divider showed up above the very first
       heading too. */
    .form-section-heading.form-section-heading-first { margin-top: 0; padding-top: 0; border-top: none; }
    input, select, textarea {
      width: 100%;
      padding: 0.5rem;
      border: 1px solid var(--border);
      border-radius: 6px;
      font-size: 1rem;
      font-family: inherit;
      background: var(--surface);
      color: var(--text);
    }
    /* Native form controls kept sitting as a visible OS-default seam next to
       everything else's custom styling (visual QA pass, 8/11/26 -- the
       state dropdown on the child form, plus the file-upload button on
       School Calendar and the plain checkboxes on Evidence Binder found in
       a follow-up pass). `<select>` already inherited the same border/
       radius/padding as every text input via the rule above -- what still
       read as "native" was specifically the OS-drawn dropdown arrow, so
       this swaps that for an on-brand chevron via `appearance: none` +
       a background SVG, without giving up the real native <select>
       (still fully keyboard/screen-reader accessible, no JS widget). */
    select {
      appearance: none;
      -webkit-appearance: none;
      background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='%236E6A5C' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 6l4 4 4-4'/%3E%3C/svg%3E");
      background-repeat: no-repeat;
      background-position: right 0.65rem center;
      background-size: 0.9rem;
      padding-right: 2.2rem;
    }
    /* Checkboxes/radios (Evidence Binder's document/communication list,
       goal/service "mark addressed" toggles, etc.) rendered in the
       browser's default blue check instead of the app's palette --
       `accent-color` is the one-line, no-JS fix for the native check
       itself; kept as plain native checkboxes here on purpose rather than
       switching to `.checkbox-pill` (the compact tag-style multi-select
       used for Interests/Strengths and the Career Profiler's activities) --
       these are real list-row selections, not a short set of tags, and a
       full-width pill per document/communication row would look wrong at
       that scale. */
    input[type="checkbox"], input[type="radio"] {
      width: 1.25rem;
      height: 1.25rem;
      accent-color: var(--primary);
      cursor: pointer;
      flex-shrink: 0;
    }
    /* Mobile UI pass (9/11/26, Casey's ask to make the app "smooth and easy"
       on a phone): every checkbox/radio in the app was rendering at the
       browser's native ~13x13px default (this rule only ever set `width:
       auto`, never an explicit size) -- well under Apple's 44pt / Google's
       48dp minimum recommended touch target, and a real problem on the
       exact devices the homepage itself promises this app works well on
       ("accessible from your phone at 11pm before a meeting"). Found while
       testing the bulk-document-actions checkboxes shipped earlier this
       same session -- confirmed via getBoundingClientRect(), not just by
       eye, that it's app-wide (Meeting Prep's bulk-mark checkboxes, the
       "delete tagged items" checkbox, Career Profiler's checkbox-pill
       inputs -- everything). Bumped further still on narrow screens below,
       since that's specifically where a thumb (not a mouse cursor) is
       doing the tapping.
       .tool-checklist's own explicit 1.15rem override (below) is now
       smaller than this base size and was removed as redundant rather than
       left to silently shrink checkboxes back down; .checkbox-pill's own
       override (further below) needed updating to the same explicit size
       for the same reason -- it used to reset back to `width: auto` (the
       native tiny default) specifically to stop the checkbox stretching to
       fill the pill's flex width, which an explicit size prevents just as
       well. */
    @media (max-width: 600px) {
      input[type="checkbox"], input[type="radio"] {
        width: 1.5rem;
        height: 1.5rem;
      }
    }
    /* File inputs (calendar upload, document upload) -- can't restyle the
       OS file-picker dialog itself, but `::file-selector-button` (the
       "Choose File" button portion) is a real, broadly-supported styling
       hook so at least that piece matches the app's own buttons instead of
       sitting next to one as an obvious OS-default control. */
    input[type="file"] {
      padding: 0.4rem;
    }
    input[type="file"]::file-selector-button {
      background: var(--primary);
      color: white;
      border: none;
      padding: 0.5rem 1rem;
      border-radius: 6px;
      font-family: inherit;
      font-size: 0.9rem;
      cursor: pointer;
      margin-right: 0.75rem;
    }
    input[type="file"]::file-selector-button:hover { opacity: 0.9; }
    button, .btn {
      background: var(--primary);
      color: white;
      border: none;
      padding: 0.6rem 1.2rem;
      border-radius: 6px;
      font-size: 1rem;
      font-family: inherit;
      cursor: pointer;
      margin-top: 1rem;
      display: inline-block;
      text-decoration: none;
    }
    button:hover, .btn:hover { opacity: 0.9; }
    /* Visible disabled state (9/12/26 mobile pass): pairs with app.js's
       preventDoubleFormSubmit() -- without this, a browser's default
       disabled treatment doesn't reliably dim a button with an author-set
       solid background the way it dims plain text, so a parent on a slow
       connection could tap Save, have the button silently disable
       underneath them, and see no visible confirmation the tap even
       registered (the exact confusion this fix exists to prevent). */
    button:disabled, .btn:disabled { opacity: 0.6; cursor: not-allowed; }

    /* Filter/toggle buttons vs. real action buttons (added 8/5/26, real
       reviewer feedback: app-wide, not Transition-Hub-specific -- the
       Careers page's "All / Animals / Art & Design" filters, which just
       change what's displayed and submit nothing, used the exact same
       solid .btn as "Take the quiz" / "Learn more", real actions. No
       visual way to tell "this changes what I'm looking at" from "this
       does something" before clicking either. .btn-filter is a lighter
       outline style by default; .btn-filter-active (the currently
       selected filter) switches to the same solid fill as a real .btn,
       so the one exception -- "this filter is currently on" -- still
       reads clearly. */
    .btn-filter {
      background: transparent;
      color: var(--primary-text);
      border: 1.5px solid var(--border);
      padding: 0.6rem 1.2rem;
      border-radius: 6px;
      font-size: 1rem;
      font-family: inherit;
      cursor: pointer;
      margin-top: 1rem;
      display: inline-block;
      text-decoration: none;
    }
    .btn-filter:hover { background: var(--primary-light); border-color: var(--primary); }
    .btn-filter.btn-filter-active {
      background: var(--primary);
      color: white;
      border-color: var(--primary);
    }

    /* Career browse index cards (8/16/26, QA look-and-flow review: the 121
       careers in app/data/careers.json used to render as a wall of
       .btn-filter pills -- bare titles, no hook, no visual distinctiveness
       between a 5-minute-to-skim career and any other. .btn-filter itself
       stays exactly as-is above (it's still the right, lighter treatment
       for the actual FILTER buttons on this same page -- "Working with
       people", "All", etc. -- which are toggles, not destinations). This
       is a separate, purpose-built card for something you're browsing
       TOWARD, reusing the transition-hub-pillar-card's hover-lift language
       at a much lighter weight (smaller padding, no icon circle, denser
       grid) since 121 of the heavier pillar treatment would make this an
       enormous page -- that heavier style is right for 4 hero tiles, not
       121 browsable items. */
    .career-card-grid {
      display: grid;
      grid-template-columns: repeat(auto-fill, minmax(230px, 1fr));
      gap: 0.75rem;
      margin-top: 0.75rem;
      margin-bottom: 1.25rem;
    }
    .career-card {
      display: block;
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 8px;
      padding: 0.85rem 1rem;
      text-decoration: none;
      color: inherit;
      transition: border-color 0.15s ease, transform 0.15s ease;
    }
    .career-card:hover {
      border-color: var(--tab-careers);
      transform: translateY(-1px);
    }
    .career-card h4 {
      margin: 0 0 0.3rem;
      font-size: 0.98rem;
      font-family: var(--font-display);
      font-weight: 600;
      color: var(--primary-text);
      display: flex;
      align-items: baseline;
      gap: 0.3rem;
    }
    .career-card h4 .career-card-star {
      color: var(--accent);
      font-size: 0.85rem;
    }
    .career-card p {
      margin: 0;
      color: var(--muted);
      font-size: 0.82rem;
      line-height: 1.4;
    }
    .career-card .career-card-pay {
      display: block;
      margin-top: 0.5rem;
      font-family: var(--font-mono);
      font-size: 0.72rem;
      font-weight: 600;
      color: var(--tab-careers);
    }

    /* Quiet action link (8/5/26) -- for occasional maintenance actions
       (edit profile, export a calendar file) that don't deserve the same
       visual weight as a real primary .btn, which was making them compete
       with actual page content right below (Casey's real "looks way out
       of place" catch on the profile page's identity card). Small pill
       outline, not a solid-filled button -- clearly still clickable, but
       clearly secondary. */
    .quiet-action-link {
      display: inline-flex;
      align-items: center;
      gap: 0.35rem;
      background: none;
      color: var(--muted);
      font-size: 0.85rem;
      font-family: inherit;
      font-weight: 600;
      text-decoration: none;
      padding: 0.3rem 0.7rem;
      border: 1px solid var(--border);
      border-radius: 20px;
      cursor: pointer;
      margin-top: 0;
    }
    .quiet-action-link:hover {
      color: var(--primary);
      border-color: var(--primary);
      background: var(--primary-light);
    }

    /* Suggested-next-step cards, differentiated by urgency tone (added
       8/11/26, visual QA finding: a genuine finding "worth a look" and a
       routine "ready to auto-fill" suggestion used the identical beige
       card + thin colored border, reading as the same weight at a skim --
       undercutting the whole point of ranking these by urgency). Urgent/
       attention now get a real color-tinted background, reusing the same
       --danger-bg/--warning-bg a parent already reads as "this needs
       attention" from status pills elsewhere in the app. Routine drops the
       card treatment entirely for a plain divided list row -- deliberately
       LESS visually heavy than before, not just re-colored, since routine
       items are explicitly not urgent and the rest of the app avoids
       alarming a parent over nothing. */
    .suggestion-card {
      border-radius: 8px; padding: 0.85rem 1rem; margin-bottom: 0.75rem;
      display: flex; align-items: flex-start; gap: 0.75rem;
      transition: transform 0.15s ease, box-shadow 0.15s ease;
    }
    .suggestion-card:last-child { margin-bottom: 0; }
    .suggestion-card-urgent { background: var(--danger-bg); border-left: 4px solid var(--danger); }
    .suggestion-card-attention { background: var(--warning-bg); border-left: 4px solid var(--warning); }
    .suggestion-card-starter { background: var(--primary-light); border-left: 4px solid var(--primary); }
    /* Hover-lift on the 3 "real card" tones only (added 9/19/26, promoting
       this panel to the primary landing experience -- Casey's ask: "make it
       look and feel smooth and easy"). A subtle, already-established
       interaction language in this app (.transition-hub-pillar-card uses
       the exact same lift), not a new pattern invented just for this panel.
       Explicitly NOT applied to routine (see below) -- a plain text row
       lifting on hover would read as a glitch, the same reasoning already
       documented on the pillar-nav's own compact state. */
    .suggestion-card-urgent:hover, .suggestion-card-attention:hover, .suggestion-card-starter:hover {
      transform: translateY(-2px);
      box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
    }
    /* Icon badge (added 9/19/26, same visual-polish pass): a small circle
       punched out of the tinted card background in the card's OWN surface
       color, with the icon glyph colored solid (--danger/--warning/
       --primary) -- reuses the exact --danger-bg+--danger /
       --warning-bg+--warning pairing .badge.overdue/.badge.upcoming already
       establish elsewhere (pale background, solid-color glyph/text), rather
       than a solid-fill circle with white text: --warning and --danger are
       deliberately LIGHT, readable-as-text colors in dark mode (see this
       file's dark-mode :root block), so a white icon on top of either would
       fail contrast there even though it would look fine in light mode --
       this sidesteps that entirely instead of shipping a dark-mode-only bug. */
    .suggestion-icon-badge {
      width: 2.1rem; height: 2.1rem; border-radius: 50%; flex: none;
      display: flex; align-items: center; justify-content: center;
      font-size: 1rem; background: var(--surface);
    }
    .suggestion-icon-badge.urgent { color: var(--danger); }
    .suggestion-icon-badge.attention { color: var(--warning); }
    .suggestion-icon-badge.starter { color: var(--primary); }
    .suggestion-card-body { flex: 1; min-width: 0; }
    .suggestion-card-routine {
      display: block;
      padding: 0.6rem 0;
      border-bottom: 1px solid var(--border);
      border-radius: 0;
      margin-bottom: 0;
    }
    .suggestion-card-routine:last-child { border-bottom: none; }
    /* Panel header + positive "nothing urgent" state (9/19/26) -- this
       panel used to vanish entirely (no heading, no content) whenever
       nothing needed attention, which is the ordinary, GOOD outcome on most
       days -- a primary landing panel that only ever shows up with bad news
       reads as alarmist by omission and gives no positive confirmation nothing
       needs to be checked right now. Reuses the same calm-checkmark language
       as .empty-state elsewhere in this app rather than a new visual idiom. */
    .suggestion-panel-subtitle { color: var(--muted); font-size: 0.85rem; margin: -0.35rem 0 0.85rem; }
    .suggestion-caught-up { display: flex; align-items: center; gap: 0.6rem; color: var(--muted); }
    .suggestion-caught-up i { color: var(--primary); font-size: 1.4rem; flex: none; }

    /* Advocacy Center visual-hierarchy pass (9/19/26, Casey's approved
       follow-up: "Advocacy Center's 11 sub-pages" need the same "most
       urgent first, visually weighted" treatment already built for
       Suggested Next Steps). Findings and the Evidence Binder both used to
       render every category (deadline findings, behavior-log deadlines,
       unreviewed PWNs, incomplete PWNs, progress check-ins, follow-ups,
       date-consistency notes) as an IDENTICAL plain `.card`, in a FIXED
       order regardless of which ones actually contain a genuine, still-
       outstanding legal-deadline problem versus a calm advisory check-in --
       a parent had to read every section to find what's actually urgent.
       Reuses the exact --danger-bg+--danger pairing .suggestion-card-urgent
       already established (same colors as the "still outstanding"/
       "incomplete" badges these sections' own items already use), applied
       at the SECTION level -- only for a section that can genuinely
       represent an unresolved legal-deadline/completeness problem
       (deadline findings with a real outstanding item, behavior-log
       deadlines, incomplete PWNs), never for the calm advisory categories
       (progress check-ins, follow-ups, date-consistency, unreviewed-PWN
       reminders) -- same "don't alarm a parent over nothing" principle
       already applied to routine suggestions. */
    .finding-card-urgent { background: var(--danger-bg); border-left: 4px solid var(--danger); }
    /* Same pass, Communication Log specifically (9/19/26): a self-flagged
       "follow-up needed" entry isn't a missed legal deadline (it's the
       parent's own reminder to themselves, same "advisory" severity class
       detect_correspondence_followup_findings already uses on the Findings
       page) -- so this reuses --warning-bg/--warning (amber, the SAME
       colors this exact signal's "check in" badge already uses elsewhere),
       never --danger, to stay honest about what this actually is. */
    .child-list-item-followup { background: var(--warning-bg); border-left: 4px solid var(--warning); }

    .error { color: var(--danger); margin-top: 0.5rem; }
    /* Font swapped from --font-mono to --font-body 8/11/26 (real design
       review, before/after mockups with actual embedded fonts, not just
       description -- see CHANGELOG.md). A status word like "overdue" in a
       monospace face reads like log/terminal output, at odds with the
       plain-language warmth this app is going for elsewhere. Tabs/nav
       (.folder-tabs, .sub-tabs, .quick-nav) deliberately KEPT --font-mono
       -- that's structural chrome, a legitimate signature look, not
       status text competing with the actual content next to it. */
    .badge {
      display: inline-block;
      padding: 0.2rem 0.75rem;
      border-radius: 999px;
      font-size: 0.78rem;
      font-weight: 600;
      font-family: var(--font-body);
      letter-spacing: 0.02em;
    }
    .badge.overdue { background: var(--danger-bg); color: var(--danger); }
    .badge.upcoming { background: var(--warning-bg); color: var(--warning-text); }
    .badge.future { background: var(--primary-light); color: var(--primary); }
    /* "completed" status (added 8/2/26, alongside the calculate_deadlines
       fix for showing a satisfied deadline as still-overdue forever) --
       blue/--info rather than reusing green (already "future"/not-due-yet)
       or red/amber (both mean "needs attention"), so a parent can tell at
       a glance that a completed deadline is neither a problem nor
       something still pending. */
    .badge.completed { background: var(--info-light); color: var(--info-text); }
    /* "advisory" (added 8/2/26, progress-reporting/correspondence-followup
       nudges): shares .completed's calm blue/--info styling on purpose --
       neither finding is a missed legal deadline, so it deliberately does
       NOT reuse the red/amber "needs attention" colors. Kept as its own
       class (not just reusing .completed) so the two stay semantically
       distinct in the CSS even though they render identically today. */
    .badge.advisory { background: var(--info-light); color: var(--info-text); }
    .child-list-item {
      display: flex;
      justify-content: space-between;
      align-items: center;
      background: var(--paper);
      border: 1px solid var(--border-strong);
      border-radius: 8px;
      padding: 0.85rem 1rem;
      margin-bottom: 0.75rem;
      /* Belt-and-suspenders alongside .card's own overflow-wrap (see that
         rule's comment for the full real-filename-overflow bug this
         fixes) -- a .child-list-item can render with display:block via an
         inline style override (e.g. advocacy_findings.html's PWN list),
         so its own explicit rule here doesn't rely on inheritance alone. */
      overflow-wrap: break-word;
    }
    .child-list-item:last-child { margin-bottom: 0; }
    /* Lighter-weight list row for dashboard summary cards (Advocacy Center
       Overview, added 8/2/26). .child-list-item's full border+background
       treatment is right for an actual list of records (documents,
       contacts, letters) but too heavy for a handful of one-line
       at-a-glance stats -- a bordered box per stat competed visually with
       the card itself instead of reading as a quick skim. Same flex
       layout, just a thin bottom divider instead of a full box. */
    .dashboard-stat-row {
      display: flex;
      justify-content: space-between;
      align-items: center;
      padding: 0.55rem 0;
      border-bottom: 1px solid var(--border);
    }
    .dashboard-stat-row:last-child { border-bottom: none; }
    .tag {
      display: inline-block;
      background: var(--primary-light);
      color: var(--primary-text);
      padding: 0.25rem 0.7rem;
      border-radius: 999px;
      font-size: 0.8rem;
      font-family: var(--font-body);
      font-weight: 600;
      letter-spacing: 0.01em;
      margin-right: 0.3rem;
    }
    /* Color-coded by what the tag represents, not just one green everywhere:
       .tag-accent for the child's disability category (the single most
       identifying classification -- gold, matching the accent color used
       for it elsewhere), .tag-count for anything that's a number/count
       linking into a section (documents uploaded, goals, services,
       accommodations, modifications -- blue, a neutral "data" tone). */
    .tag-accent { background: var(--accent-light); color: var(--warning-text); }
    .tag-count { background: var(--info-light); color: var(--info-text); }
    /* .tag-muted: identity/context facts that are neither a category
       classification nor a count -- state, grade level, "most recent IEP"
       date. A neutral warm gray so it reads as plain background context,
       distinct from the gold (category) and blue (count) tints above,
       instead of every tag defaulting to the same green regardless of what
       it represents. */
    .tag-muted { background: var(--muted-light); color: var(--muted-pill-text); }
    /* Career Matches match-strength cue (added 8/11/26): each match already
       carries a computed `score`, but nothing on the page showed which
       matches were the strong ones vs. the long tail -- a student saw a
       flat list with no ranking signal. Reuses .tag's pill shape and the
       same three-tier color vocabulary as .tag-accent/.tag-count/.tag-muted
       (and the "Really stood out / Some real interest / A little" language
       already used on the interest/skill results page) so the tiering
       reads consistently app-wide instead of inventing a fourth palette. */
    .match-strength-strong { background: var(--primary-light); color: var(--primary-text); }
    .match-strength-medium { background: var(--accent-light); color: var(--warning-text); }
    .match-strength-light { background: var(--muted-light); color: var(--muted-pill-text); }
    /* Clickable suggestion chips (Resume Builder's "not sure what counts as
       experience?" helper, 8/6/26) -- same pill shape/size as .tag above,
       but a real <button> (not a static label): hover state signals it is
       interactive, since tapping one quick-fills a title field elsewhere on
       the page rather than just displaying information. */
    .suggestion-chip-group {
      display: flex;
      flex-wrap: wrap;
      gap: 0.4rem;
      margin: 0.4rem 0 1rem 0;
    }
    .suggestion-chip {
      display: inline-block;
      background: var(--primary-light);
      color: var(--primary-text);
      border: none;
      padding: 0.3rem 0.75rem;
      border-radius: 999px;
      font-size: 0.8rem;
      font-family: var(--font-body);
      font-weight: 600;
      letter-spacing: 0.01em;
      cursor: pointer;
    }
    .suggestion-chip:hover, .suggestion-chip:focus-visible {
      background: var(--primary);
      color: #fff;
    }
    /* Visual QoL (medium batch): color-coded document-vault chips, one tint
       per doc_type, so a parent scanning the vault can tell document types
       apart at a glance instead of every row's tag being the same plain
       green. Each pairs with an existing CSS variable pair already used
       elsewhere in the app (nothing new introduced) -- deliberately avoids
       --danger/--danger-bg (the "overdue" red), since a document TYPE isn't
       an urgency signal and using that red here risked a parent reading a
       PWN chip as "something's wrong" rather than just "this is a PWN".

       PWN fix (8/1/26): prior_written_notice originally reused --warning/
       --warning-bg, which -- despite being a different variable name from
       progress_report's --accent/--accent-light -- renders as essentially
       the same light gold/tan tint (Casey caught this visually, and it's
       real: --warning #A8791F vs --accent #C08A2E are neighboring amber
       tones, and their -bg/-light tints are both pale cream). Every other
       hue already in this 5-item set (green/blue/gold/gray) was already
       spoken for by a different doc_type, so a genuinely new hue was
       needed rather than another reuse -- see --pwn/--pwn-bg above, which
       borrows the plum/wine hue family from --tab-advocacy (thematically
       apt, since PWNs are exactly what the Compliance Audit's PWN Analyzer
       checks) as its own light/dark-tuned variable pair. */
    .tag-doctype-iep { background: var(--primary-light); color: var(--primary); }
    .tag-doctype-504_plan { background: var(--plan504-bg); color: var(--plan504); }
    .tag-doctype-evaluation { background: var(--info-light); color: var(--info-text); }
    .tag-doctype-prior_written_notice { background: var(--pwn-bg); color: var(--pwn); }
    .tag-doctype-progress_report { background: var(--accent-light); color: var(--accent); }
    .tag-doctype-correspondence { background: var(--muted-light); color: var(--muted-pill-text); }
    /* Groups a row of buttons (or a button + tags) with consistent gap
       instead of relying on each button's own margin-top, which stacks
       oddly when two+ buttons sit side by side. */
    .button-row {
      display: flex;
      flex-wrap: wrap;
      gap: 0.75rem;
      margin-top: 1rem;
    }
    .button-row .btn, .button-row button { margin-top: 0; }
    /* Small "Added ... / edited ..." caption under a manually-entered or
       auto-filled item (goals, services, accommodations, contacts) --
       kept muted so it reads as metadata, not content. Font swapped from
       mono to --font-body 8/11/26 -- see .badge's comment above for the
       full reasoning (same design review). */
    .meta-text {
      font-size: 0.78rem;
      color: var(--muted);
      font-family: var(--font-body);
      margin: 0.35rem 0 0;
    }
    /* Countdown ("14 days left") sitting alongside the existing overdue/
       upcoming/future status badge on the Timeline. Deliberately KEPT mono
       during the 8/11/26 font pass that moved .badge/.meta-text/
       .audience-tag to --font-body -- this is a number, not a status word,
       and a monospace face's fixed digit width reads as more precise/
       legible for a count than the body font does. */
    .countdown-text {
      font-size: 0.78rem;
      color: var(--muted);
      font-family: var(--font-mono);
      text-align: right;
      margin-top: 0.3rem;
    }
    /* Small icon button for "copy to clipboard" next to a contact's email/
       phone -- deliberately understated (outline only) so it doesn't compete
       with the actual contact info it sits next to. */
    .copy-btn {
      background: none;
      border: 1px solid var(--border);
      color: var(--muted);
      padding: 0.1rem 0.45rem;
      border-radius: 6px;
      font-size: 0.85rem;
      line-height: 1.4;
      margin: 0;
      cursor: pointer;
      display: inline-flex;
      align-items: center;
    }
    .copy-btn:hover { background: var(--primary-light); color: var(--primary); opacity: 1; }
    .copy-btn.copy-btn-done { color: var(--primary-text); border-color: var(--primary-text); }
    /* "Filter by ..." dropdown sitting above a section's list (goals by
       area, services by type, documents by document type) -- a plain-JS
       client-side filter, same "no new dependency, no new route" pattern
       already used for the glossary search below. */
    .filter-row {
      display: flex;
      align-items: center;
      gap: 0.6rem;
      margin: 0.75rem 0 1rem;
      flex-wrap: wrap;
    }
    .filter-row label { margin: 0; font-size: 0.9rem; }
    .filter-row select { width: auto; display: inline-block; }
    .filter-empty-message { display: none; color: var(--muted); font-size: 0.9rem; font-style: italic; }
    /* Undo-style delete banner (QoL backlog #8) -- shown right after a
       delete-redirect when the ?undo=<snapshot_id> query param is present
       and still within its grace period (see deps.py get_undo_banner). */
    .undo-banner {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 0.75rem;
      background: var(--paper);
      border: 1px solid var(--border-strong);
      border-radius: 8px;
      padding: 0.6rem 0.9rem;
      margin: 0 0 1rem;
      font-size: 0.9rem;
      flex-wrap: wrap;
    }
    .undo-banner button { margin-top: 0; }
    .undo-banner .undo-dismiss {
      background: none; border: none; color: var(--muted); cursor: pointer;
      font-size: 1rem; padding: 0 0.25rem; margin-top: 0;
    }
    /* Used by the per-section filter dropdowns and the child-data search box
       (base.html JS) to hide a row/year-group without touching its own
       inline style attribute -- rows like .child-list-item rely on an
       inline style="display:block;" override that must never be erased. */
    .is-filtered-out { display: none !important; }
    ul { padding-left: 1.2rem; }
    /* Collapsible goal card (9/23/26 -- see _goal_card_macro.html's own
       comment). Selectors are prefixed with .child-list-item.goal-card on
       purpose: `details.year-group summary` (the tinted Goals section
       header, further down this file) matches EVERY summary inside the
       Goals section, not just its own -- that descendant rule is what made
       the first version's "Where things stand" toggle render as a tinted
       bar on the live site, identical to the add-form bars. These need
       higher specificity to win regardless of file order. */
    .child-list-item.goal-card details > summary {
      background: none;
      margin: 0;
      border-bottom: none;
      list-style: none;
    }
    .child-list-item.goal-card details > summary::-webkit-details-marker { display: none; }
    .child-list-item.goal-card details.goal-collapse > summary {
      border-bottom: none;  /* beats details.year-group[open] summary, see above */
      display: flex;
      align-items: flex-start;
      gap: 0.5rem;
      padding: 0.1rem 0;
      cursor: pointer;
      font-weight: normal;
      color: var(--text);
    }
    .goal-collapse-chevron {
      color: var(--muted);
      margin-top: 0.2rem;
      transition: transform 0.15s;
      flex-shrink: 0;
    }
    details.goal-collapse[open] > summary .goal-collapse-chevron { transform: rotate(90deg); }
    .goal-collapse-main { display: flex; flex-direction: column; align-items: flex-start; gap: 0.3rem; min-width: 0; }
    .goal-collapse-text { font-size: 1rem; line-height: 1.45; }
    .goal-collapse-meta { font-size: 0.8rem; color: var(--muted); }
    .goal-collapse-body { margin: 0.6rem 0 0 1.5rem; font-size: 0.9rem; }
    /* Phone: the chevron indent costs ~24px of a 375px screen -- the body
       starts flush instead (9/23/26 phone pass). */
    @media (max-width: 600px) {
      .goal-collapse-body { margin-left: 0; }
    }
    .goal-collapse-body > p { margin: 0.5rem 0; }
    /* "Where things stand" -- a quiet text toggle, not a tinted bar. */
    .child-list-item.goal-card details.goal-detail-disclosure > summary {
      border-bottom: none;  /* beats details.year-group[open] summary, see above */
      display: inline-flex;
      align-items: center;
      gap: 0.35rem;
      padding: 0.2rem 0;
      cursor: pointer;
      font-size: 0.9rem;
      font-weight: 600;
      color: var(--primary-text);
    }
    .goal-detail-disclosure { margin: 0.6rem 0 0.2rem; }
    .goal-detail-chevron { transition: transform 0.15s; }
    details.goal-detail-disclosure[open] > summary .goal-detail-chevron { transform: rotate(90deg); }
    /* Quiet action footer. An opened form takes the whole row. */
    .goal-actions {
      display: flex;
      flex-wrap: wrap;
      align-items: center;
      gap: 0.25rem 1rem;
      margin-top: 0.9rem;
      padding-top: 0.6rem;
      border-top: 1px solid var(--border);
    }
    .goal-actions-date { margin: 0; flex-basis: 100%; }
    .goal-actions details.add-form { margin-top: 0; }
    .goal-actions details.add-form[open] { flex-basis: 100%; }
    .child-list-item.goal-card .goal-actions details.add-form > summary {
      border-bottom: none;  /* beats details.year-group[open] summary, see above */
      padding: 0.25rem 0;
      font-size: 0.85rem;
      font-weight: 600;
      color: var(--primary-text);
      display: inline;
    }
    .goal-delete-form { margin: 0; }
    .goal-delete-link {
      background: none;
      border: none;
      padding: 0.25rem 0;
      margin: 0;
      font-size: 0.85rem;
      font-weight: 600;
      color: var(--danger);
      cursor: pointer;
      text-decoration: underline;
    }
    .goal-detail-body { font-size: 0.9rem; margin-top: 0.4rem; }
    /* Meeting brief (9/23/26): sections as quiet disclosures, each item a
       "what the IEP says" line plus a tinted "Ask:" line -- the question is
       the part a parent carries into the room, so it's the one that stands
       out. */
    details.brief-section { border-top: 1px solid var(--border); padding: 0.6rem 0; }
    details.brief-section > summary { cursor: pointer; font-weight: 600; list-style: none; }
    details.brief-section > summary::-webkit-details-marker { display: none; }
    details.brief-section > summary::before { content: "\25B8"; display: inline-block; width: 1.1em; color: var(--muted); }
    details.brief-section[open] > summary::before { content: "\25BE"; }
    .brief-items { list-style: none; margin: 0.4rem 0 0; padding: 0; }
    .brief-items > li { padding: 0.55rem 0; border-bottom: 1px dashed var(--border); }
    .brief-items > li:last-child { border-bottom: none; }
    .brief-item-heading { font-weight: 600; margin: 0 0 0.2rem; font-size: 0.95rem; }
    .brief-item-says { margin: 0 0 0.25rem; font-size: 0.9rem; }
    .brief-item-progress { margin: 0 0 0.25rem; font-size: 0.85rem; color: var(--muted); }
    .brief-item-ask {
      margin: 0.3rem 0 0;
      font-size: 0.9rem;
      background: var(--primary-light);
      color: var(--primary-text);
      border-radius: 6px;
      padding: 0.4rem 0.6rem;
    }
    /* The IEP's general present levels, above the goal cards (9/23/26). */
    .present-levels-overview {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 8px;
      padding: 0.8rem 1rem;
      margin: 0.5rem 0 0.75rem;
      font-size: 0.9rem;
    }
    .present-levels-overview p { margin: 0 0 0.45rem 0; }
    .goal-detail-body p { margin: 0 0 0.4rem 0; }
    .goal-detail-heading {
      font-size: 0.8rem !important;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.04em;
      color: var(--muted);
      margin-top: 0.6rem !important;
    }
    .goal-objective-list { margin: 0 0 0.4rem 0; padding-left: 1.25rem; }
    .goal-objective-list li { margin-bottom: 0.5rem; font-size: 0.9rem; }
    .goal-objective-progress {
      display: block;
      margin-top: 0.2rem;
      font-size: 0.85rem;
      color: var(--muted);
    }
    /* Visual QoL (medium batch): dot-timeline for goal progress notes,
       replacing a plain bulleted <ul> with a connected vertical line +
       dot per entry so a parent can see "how many check-ins, how recent"
       at a glance, not just read a list. The most recent entry (first,
       since notes render most-recent-first) gets a larger accent-colored
       dot to draw the eye; older entries use a smaller muted dot. This
       qualitative timeline never shows a progress *percentage* itself --
       inventing one from free-text notes would be presenting a
       synthesized number as fact. (Updated 8/17/26: a percentage CAN now
       exist, but only from the SEPARATE .goal-progress-meter below, which
       is gated on a parent explicitly entering real baseline_value/
       target_value numbers -- see IEPGoal's docstring in app/database.py.
       That's a genuinely different thing from parsing a number out of
       this timeline's own note text, which this file still never does.) */
    .goal-progress-timeline {
      list-style: none;
      margin: 0 0 0.5rem;
      padding-left: 0;
    }
    .goal-progress-timeline li {
      position: relative;
      padding: 0 0 0.85rem 1.35rem;
    }
    .goal-progress-timeline li::before {
      content: "";
      position: absolute;
      left: 0.15rem;
      top: 0.3rem;
      width: 0.55rem;
      height: 0.55rem;
      border-radius: 50%;
      background: var(--border-strong);
    }
    .goal-progress-timeline li.is-latest::before {
      left: 0.05rem;
      top: 0.22rem;
      width: 0.75rem;
      height: 0.75rem;
      background: var(--accent);
      box-shadow: 0 0 0 3px var(--accent-light);
    }
    .goal-progress-timeline li::after {
      content: "";
      position: absolute;
      left: 0.42rem;
      top: 1rem;
      bottom: 0;
      width: 1px;
      background: var(--border-strong);
    }
    .goal-progress-timeline li.is-latest::after { left: 0.42rem; top: 1.15rem; }
    .goal-progress-timeline li:last-child::after { display: none; }
    .goal-progress-timeline .progress-date {
      font-family: 'IBM Plex Mono', monospace;
      font-size: 0.78rem;
      color: var(--muted);
      display: block;
      margin-bottom: 0.1rem;
    }
    .goal-progress-timeline li.is-latest .progress-date { color: var(--accent); font-weight: 600; }
    .goal-progress-timeline .latest-tag {
      font-size: 0.7rem;
      text-transform: uppercase;
      letter-spacing: 0.03em;
      color: var(--accent);
      font-weight: 600;
      margin-left: 0.4rem;
    }
    @media print {
      .goal-progress-timeline li::before,
      .goal-progress-timeline li::after { background: #999; box-shadow: none; }
    }
    /* Goal progress meter bar (priority-list item #6, 8/17/26) -- a plain
       CSS horizontal fill, not a JS chart. Only rendered when a goal has
       real parent-entered baseline_value/target_value numbers (see the
       progress-summary guard in child_detail.html and
       compute_goal_progress_summary in app/goal_progress.py). Deliberately
       small and understated -- this sits inside an already-dense goal
       card, not a dedicated dashboard widget. */
    .goal-progress-meter { margin: 0.5rem 0; }
    .goal-progress-meter-track {
      background: var(--muted-light);
      border-radius: 999px;
      height: 0.5rem;
      overflow: hidden;
    }
    .goal-progress-meter-fill {
      background: var(--accent);
      height: 100%;
      border-radius: 999px;
      transition: width 0.2s ease;
    }
    @media print {
      .goal-progress-meter-track { background: #eee; }
      .goal-progress-meter-fill { background: #666; }
    }
    /* Visual QoL (medium batch): horizontal deadline timeline bar, above the
       existing plain deadline list (list stays -- this is an at-a-glance
       overview, not a replacement for the full title/note/countdown text).
       Marker color reuses the same status colors as .badge.overdue/.upcoming
       everywhere else in the app, plus --primary for "future" so a green
       dot doesn't get read as "on track" (it isn't a judgment, just distant).

       Full redesign, 8/2/26 (Casey's ask, after three rounds of spacing
       bugs -- "rethink the timeline visuals," "have one go down and one go
       up" for close markers): all of the vertical layout math (where the
       track sits, where each label and leader line lands, how tall the
       whole bar needs to be) now lives in Python
       (deadline_timeline_positions / _compute_vertical_layout in
       app/timeline.py) instead of inline template style="" arithmetic --
       that arithmetic was exactly where the last two rounds of bugs came
       from, since it couldn't be checked without an actual browser. The
       computed values arrive as --marker-top/--track-top CSS custom
       properties (shared by the track and every marker -- only their
       "left" position differs) plus a per-marker top/height for its own
       label and leader line.

       The real behavior change: stacked labels (two deadlines close
       together) now alternate ABOVE and BELOW the track instead of both
       stacking downward -- label_row 0 below, 1 above, 2 below (one level
       further out), 3 above, etc. Every marker also gets a leader line
       back to its own dot now, even an isolated one (a short "lollipop"
       stem), for visual consistency rather than some markers having one
       and others not. The bar's height is set to exactly what the content
       needs (Python already computed it), so no more separate
       margin-bottom hack to avoid the next element on the page colliding
       with overflowing content.

       IMPORTANT: .deadline-timeline-today's "top" below (1.3rem) MUST
       match _TODAY_PILL_ZONE_REM in app/timeline.py -- both reserve the
       same fixed strip at the top of the bar for the "Today" pill. If one
       changes, the other must too. */
    .deadline-timeline-bar {
      position: relative;
      margin: 1.5rem 0.25rem;
    }
    .deadline-timeline-track {
      position: absolute;
      top: var(--track-top);
      left: 0;
      right: 0;
      height: 5px;
      background: var(--border-strong);
      border-radius: 3px;
      box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.12);
    }
    .deadline-timeline-marker {
      position: absolute;
      top: var(--marker-top);
      width: 16px;
      height: 16px;
      border-radius: 50%;
      transform: translateX(-50%);
      border: 2px solid var(--paper);
      cursor: pointer;
      transition: transform 0.12s ease, box-shadow 0.12s ease;
    }
    .deadline-timeline-marker:hover {
      transform: translateX(-50%) scale(1.2);
      z-index: 2;
    }
    .deadline-timeline-marker.overdue { background: var(--danger); box-shadow: 0 0 0 2px var(--danger-bg); }
    .deadline-timeline-marker.upcoming { background: var(--warning); box-shadow: 0 0 0 2px var(--warning-bg); }
    .deadline-timeline-marker.future { background: var(--primary-text); box-shadow: 0 0 0 2px var(--primary-light); }
    .deadline-timeline-marker.completed { background: var(--info); box-shadow: 0 0 0 2px var(--info-light); }
    /* "+N more" grouped marker (added 8/4/26, the row-overflow safety net --
       see _MAX_LABEL_ROWS in app/timeline.py): deliberately muted/neutral,
       not one of the four status colors above, since it can bundle
       multiple different statuses together and shouldn't read as "this is
       specifically an overdue/upcoming/etc deadline." Slightly larger than
       a normal dot so it doesn't read as just another individual deadline
       at a glance -- it's a different KIND of thing (a summary, not a
       single date), and the size difference is a quieter signal of that
       than an added text badge would be. */
    .deadline-timeline-marker.grouped {
      background: var(--muted-light);
      border-color: var(--border-strong);
      width: 20px;
      height: 20px;
      top: calc(var(--marker-top) - 2px);
      box-shadow: 0 0 0 2px var(--paper);
    }
    .deadline-timeline-marker.overdue:hover { box-shadow: 0 0 0 5px var(--danger-bg); }
    .deadline-timeline-marker.upcoming:hover { box-shadow: 0 0 0 5px var(--warning-bg); }
    .deadline-timeline-marker.future:hover { box-shadow: 0 0 0 5px var(--primary-light); }
    .deadline-timeline-marker.completed:hover { box-shadow: 0 0 0 5px var(--info-light); }
    .deadline-timeline-marker.grouped:hover { box-shadow: 0 0 0 5px var(--muted-light); }
    /* Leader line: connects every label back to its own dot, above or
       below depending on which side it landed on -- top/height are set
       per-marker inline (from app/timeline.py's computed
       leader_top_rem/leader_height_rem), since they differ by side and
       stacking depth. */
    .deadline-timeline-leader {
      position: absolute;
      width: 1px;
      background: var(--border-strong);
      transform: translateX(-50%);
    }
    .deadline-timeline-label {
      position: absolute;
      transform: translateX(-50%);
      font-size: 0.7rem;
      line-height: 1.25;
      font-weight: 500;
      color: var(--text);
      text-align: center;
      white-space: normal;
      width: 8rem;
      max-height: 1.75rem;
      overflow: hidden;
      display: -webkit-box;
      -webkit-line-clamp: 2;
      -webkit-box-orient: vertical;
      text-overflow: ellipsis;
    }
    /* Today line: top (1.3rem) is the fixed reserved strip for the pill --
       see the IMPORTANT note above the .deadline-timeline-bar rule. Runs
       down through the track and every label zone. Dimmed via opacity
       rather than full accent color, so it reads as a quiet reference
       line rather than another loud element competing with the
       status-colored dots. */
    /* Today: previously just a line + a small decorative dot up near the
       pill, with nothing actually marking "today" at the track itself --
       every real deadline gets a dot on the line, but today didn't, so
       the line just passed through without a clear anchor point (Casey's
       catch, 8/2/26: "the today marker should be above the actual dot" --
       there wasn't a dot there to be above). Now gets a real marker right
       on the track (a diamond, not a circle, so it reads as "the
       reference point," not one more deadline), and the line stops
       shortly past it instead of running all the way to the bottom of
       the bar even when nothing else is near today's position -- that
       trailing tail into empty space was exactly what made the previous
       version feel disconnected. The line's height uses calc() against
       the shared --track-top custom property (already set once per
       render on .deadline-timeline-bar) rather than a separate computed
       value, so it can never drift out of sync with where the track
       actually is. */
    .deadline-timeline-today {
      position: absolute;
      top: 1.3rem;
      height: calc(var(--track-top) - 1.3rem + 0.5rem);
      width: 2px;
      background: var(--accent);
      opacity: 0.5;
      transform: translateX(-50%);
    }
    .deadline-timeline-today-dot {
      position: absolute;
      top: var(--marker-top);
      width: 14px;
      height: 14px;
      transform: translateX(-50%) rotate(45deg);
      background: var(--accent);
      border: 2px solid var(--paper);
      box-shadow: 0 0 0 2px var(--accent-light);
    }
    .deadline-timeline-today-label {
      position: absolute;
      /* Cancels the line's own top: 1.3rem so this lands at the bar's
         real top edge (0) -- a fixed, always-reserved strip, not
         computed from content, so it can never collide with anything
         below it (the pill zone comes before any label zone starts). */
      top: -1.3rem;
      transform: translateX(-50%);
      font-size: 0.68rem;
      color: var(--accent);
      font-weight: 700;
      white-space: nowrap;
      background: var(--accent-light);
      padding: 0.15rem 0.55rem;
      border-radius: 999px;
      opacity: 1;
    }
    .deadline-timeline-legend {
      display: flex;
      gap: 1.1rem;
      flex-wrap: wrap;
      font-size: 0.8rem;
      color: var(--muted);
      margin-bottom: 0.5rem;
    }
    .deadline-timeline-legend span { display: inline-flex; align-items: center; gap: 0.4rem; }
    .deadline-timeline-legend .swatch { width: 10px; height: 10px; border-radius: 50%; display: inline-block; box-shadow: 0 0 0 2px var(--paper); }
    .deadline-timeline-legend .swatch.overdue { background: var(--danger); }
    .deadline-timeline-legend .swatch.upcoming { background: var(--warning); }
    .deadline-timeline-legend .swatch.future { background: var(--primary-text); }
    .deadline-timeline-legend .swatch.completed { background: var(--info); }
    @media print {
      .deadline-timeline-bar { display: none; }
    }

    /* ===================================================================
       Bucketed timeline (replaces the row-stacked bar above as of 8/4/26
       -- see app/timeline.py's deadline_bucket_layout docstring for the
       full "why". Two parts: a compact dots-only bar (shape at a glance,
       non-linear positions from Python, no labels/leader lines so nothing
       can ever overlap) and the buckets themselves (the actual detail,
       replacing the old flat itemized deadline list one-for-one -- see
       the print-page check in CHANGELOG.md confirming iep_summary.html
       has its own separate loop, unaffected by this).
       =================================================================== */
    .dot-bar {
      position: relative;
      height: 3.2rem;
      margin: 0 0.25rem 1.6rem;
    }
    .dot-track {
      position: absolute;
      top: 1.5rem;
      left: 0;
      right: 0;
      height: 5px;
      background: var(--border-strong);
      border-radius: 3px;
      box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.12);
    }
    .dot {
      position: absolute;
      top: 1.5rem;
      width: 16px;
      height: 16px;
      border-radius: 50%;
      transform: translate(-50%, -50%);
      border: 2px solid var(--paper);
      cursor: pointer;
      z-index: 1;
      transition: transform 0.12s ease, box-shadow 0.12s ease;
    }
    .dot:hover, .dot:focus-visible { transform: translate(-50%, -50%) scale(1.3); z-index: 2; }
    .dot.overdue { background: var(--danger); box-shadow: 0 0 0 2px var(--danger-bg); }
    .dot.this-week { background: var(--warning); box-shadow: 0 0 0 2px var(--warning-bg); }
    .dot.this-month { background: var(--month); box-shadow: 0 0 0 2px var(--month-bg); }
    .dot.next-90 { background: var(--primary-text); box-shadow: 0 0 0 2px var(--primary-light); }
    .dot.later { background: var(--pwn); box-shadow: 0 0 0 2px var(--pwn-bg); }
    .dot.completed { background: var(--info); box-shadow: 0 0 0 2px var(--info-light); }
    .dot-today {
      position: absolute;
      top: 1.5rem;
      width: 14px;
      height: 14px;
      transform: translate(-50%, -50%) rotate(45deg);
      background: var(--accent);
      border: 2px solid var(--paper);
      box-shadow: 0 0 0 2px var(--accent-light);
      z-index: 3;
    }
    .dot-today-label {
      position: absolute;
      top: 0;
      transform: translateX(-50%);
      font-size: 0.62rem;
      color: var(--accent);
      font-weight: 700;
      white-space: nowrap;
      background: var(--accent-light);
      padding: 0.1rem 0.45rem;
      border-radius: 999px;
      z-index: 3;
    }
    /* home.html's 4 feature tiles (8/8/26, real-user feedback: an
       auto-fit grid left 3 tiles on one row and 1 alone on the next at
       common viewport widths, an unbalanced layout for exactly 4 items).
       Fixed 2-column grid instead -- 4 always divides evenly into a
       clean 2x2, unlike auto-fit's width-dependent column count. Same
       600px mobile breakpoint convention as the rest of this file. */
    .landing-tile-grid {
      display: grid;
      grid-template-columns: repeat(2, 1fr);
      gap: 1rem;
      margin-top: 1.25rem;
    }
    @media (max-width: 600px) {
      .landing-tile-grid { grid-template-columns: 1fr; }
    }

    /* Comparison table scroll hint (fresh-eyes mobile pass, 9/12/26,
       "pretend you've never seen this" review of the public homepage): the
       "How families tracked this before" table has always scrolled
       horizontally on narrow screens (5 columns don't fit under ~400px)
       via plain `overflow-x:auto` on its wrapper div -- but nothing ever
       told a first-time visitor there was more to see. Confirmed live at
       375px: the two right-most columns (Spreadsheet, Generic app) sit
       entirely past the visible edge with zero hint, on the exact page a
       parent reads BEFORE deciding whether to sign up. Same underlying
       problem the in-app folder-tabs/sub-tabs nav had (see that CSS
       comment on this file for the 9/11/26 fix), but a genuinely simpler
       fix here: unlike the nav tabs (a flex container that IS the
       scrollport, needing the sticky-flex-item trick), this table already
       has its own non-scrolling wrapper div, so the fade can just be
       `position: absolute` and pinned to the WRAPPER's own edge directly
       -- no need for the flex/sticky trick at all. `pointer-events: none`
       so it can never intercept a tap/swipe meant for the table underneath. */
    .table-scroll-wrap { position: relative; overflow-x: auto; }
    /* Gated to the same max-width:600px breakpoint the rest of the app
       uses for "does this actually need mobile scroll treatment" --
       confirmed live the table stops overflowing (fits without scrolling)
       by 600px wide, so outside that width this ::after would otherwise
       sit as an unwanted fade over real, fully-visible table content
       (the table has width:100%, so on a wide screen the wrapper's right
       edge IS the table's own last column, not empty space). */
    @media (max-width: 600px) {
      .table-scroll-wrap::after {
        content: "";
        position: absolute;
        top: 0;
        bottom: 0;
        right: 0;
        width: 28px;
        background: linear-gradient(to right, transparent, var(--surface) 20px);
        pointer-events: none;
      }
    }

    /* Landing-page visual polish (8/16/26, front-page review). Scoped to
       classes only home.html uses -- deliberately NOT touching the shared
       .card/h1/h2 rules every in-app page also relies on. The logged-in
       app stays exactly as utilitarian as it's always been; the marketing
       page in front of it gets a bit more typographic confidence and
       breathing room, same colors and fonts throughout for continuity. */

    /* Bigger, dedicated hero type -- the in-app h1 (used on every child/
       hub page header) is sized for a compact utility screen, not a
       first-impression landing page. A landing hero conventionally
       commands more of the viewport (see Linear/Superhuman-style sites)
       -- this is the cheapest, highest-impact single change available:
       pure type-scale, no new imagery, no added weight. */
    .landing-hero h1 {
      font-size: 2.35rem;
      line-height: 1.15;
    }
    .landing-hero p {
      font-size: 1.15rem;
    }
    /* Small credibility line under the hero CTAs (front-page review
       finding: the strongest trust signal this app has -- a real
       counselor/former-SPED-teacher building it -- sat six sections down
       the page, after a screenshot and a comparison table, before this
       change). Deliberately plain text, not a badge/pill -- a quiet
       aside, not a marketing claim shouting for attention. */
    .landing-hero-credibility {
      color: var(--muted);
      font-size: 0.85rem;
      margin-top: 0.85rem;
    }

    /* Browser-chrome frame around the two real product screenshots
       (front-page review: these were flat bordered <img>s, reading more
       like an email attachment than a product). Pure CSS, zero new
       images -- three plain dots in a title-bar strip is enough to read
       as "a real running app" without faking an actual browser UI in
       detail (which would need real chrome-colored assets and upkeep
       every time the real in-app header changes). */
    .browser-frame {
      display: inline-block;
      max-width: 100%;
      border: 1px solid var(--border);
      border-radius: 10px;
      overflow: hidden;
      background: var(--surface);
      text-align: left;
    }
    .browser-frame-bar {
      display: flex;
      gap: 0.4rem;
      align-items: center;
      padding: 0.55rem 0.75rem;
      background: var(--paper);
      border-bottom: 1px solid var(--border);
    }
    .browser-frame-dot {
      width: 0.6rem;
      height: 0.6rem;
      border-radius: 50%;
      background: var(--border-strong);
    }
    .browser-frame img {
      display: block;
      max-width: 100%;
      height: auto;
      border-radius: 0;
      border: none;
    }

    /* Sample AI-answer chat mockup (front-page review: competitors show
       the AI actually answering a question inline, not just describe it
       in a paragraph -- this does the same, cheaply, as styled HTML, no
       image, matching the app's own real "grounded, cites what's
       verified, says so when it doesn't know" behavior rather than
       inventing a generic AI-demo look). Reuses the same avatar-initials
       circle language as the child-header icon (app/utils.py's initials
       filter) for visual consistency with the rest of the app, not a new
       component language. Every instance of this markup MUST carry a
       visible "Sample question and answer" label -- same honesty
       discipline as the two screenshots' own "shown with sample data"
       captions -- since a chat mockup reads far more like "this is what
       YOU will see" than a static screenshot does, so the labeling
       matters even more here. */
    .chat-mock {
      max-width: 32rem;
      margin: 1.25rem auto 0;
      text-align: left;
    }
    .chat-mock-bubble {
      display: flex;
      gap: 0.65rem;
      align-items: flex-start;
      margin-bottom: 0.75rem;
    }
    .chat-mock-avatar {
      flex-shrink: 0;
      width: 2rem;
      height: 2rem;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 0.8rem;
      font-weight: 600;
      color: white;
    }
    .chat-mock-bubble-question .chat-mock-avatar { background: var(--muted); }
    .chat-mock-bubble-answer .chat-mock-avatar { background: var(--primary); }
    .chat-mock-text {
      background: var(--paper);
      border: 1px solid var(--border);
      border-radius: 10px;
      padding: 0.65rem 0.9rem;
      font-size: 0.9rem;
    }
    .chat-mock-bubble-answer .chat-mock-text {
      background: var(--primary-light);
      border-color: var(--primary-light);
    }

    /* Testimonial card component (front-page review): built now, styled,
       and ready -- but deliberately NOT populated or placed on the live
       page yet. There is no real family testimonial to show, and this
       app never fabricates one (same rule the "Why this exists" section
       already follows -- see that section's own comment in home.html).
       When a real quote exists (Casey's own, or a real family's, with
       real attribution), the markup is: a div.testimonial-card wrapping
       a div.testimonial-avatar (holding the name's initials, same filter
       already used for the child-header icon), a p.testimonial-quote,
       and a p.testimonial-attribution for the name and descriptor.
       Avatar circle reuses the exact same initials-circle language as
       the child-header icon and the chat mockup above -- one consistent
       visual pattern for "a real person," not three different ones. */
    .testimonial-card {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 10px;
      padding: 1.25rem 1.5rem;
      text-align: left;
    }
    .testimonial-avatar {
      width: 2.5rem;
      height: 2.5rem;
      border-radius: 50%;
      background: var(--accent);
      color: white;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 0.9rem;
      font-weight: 600;
      margin-bottom: 0.75rem;
    }
    .testimonial-quote {
      font-family: var(--font-display);
      font-size: 1.05rem;
      font-style: italic;
      color: var(--text);
      margin: 0 0 0.5rem;
    }
    .testimonial-attribution {
      color: var(--muted);
      font-size: 0.85rem;
      margin: 0;
    }

    /* Mobile: scroll instead of hide (9/13/26, second pass -- Casey's own
       real-phone screenshot: hiding it meant a mobile parent never saw the
       actual visual timeline at all, only the bucket cards below, even
       though this exact dot-bar is what the public homepage uses as its
       own hero screenshot). The dot bar's percent-based horizontal
       positions can't know the actual rendered pixel width, so below the
       width where dots would start crowding into an unreadable smear, the
       ORIGINAL problem was real -- but hiding the whole thing solved it by
       removing the feature, not by fixing the crowding. Giving .dot-bar a
       real minimum pixel width (wider than any phone screen) and letting
       its wrapper scroll horizontally solves the actual crowding problem
       instead: the same percentage math now has real room to spread the
       dots out, exactly like on desktop, just off-screen until swiped to.
       .table-scroll-wrap (reused, not reinvented -- see that class's own
       comment further up this file) provides the scroll + edge-fade;
       dot-bar-swipe-hint (real text in the template, not just the fade)
       covers the part a fade alone doesn't: a data visualization doesn't
       read as "swipeable" the same instinctive way a tab bar does. */
    .dot-bar-swipe-hint { display: none; }
    @media (max-width: 600px) {
      .dot-bar-swipe-hint {
        display: flex; align-items: center; gap: 0.35rem;
        font-size: 0.75rem; color: var(--muted); margin: 0 0 0.5rem;
      }
      .dot-bar { min-width: 700px; }
    }

    .bucket-row {
      display: flex;
      gap: 0.7rem;
      flex-wrap: wrap;
      align-items: flex-start;
    }
    /* Below a real "can these sit side by side and still be legible"
       width, stack buckets full-width in one column instead of wrapping
       into a cramped multi-per-row grid -- flex-wrap alone still allows
       2-3 narrow, hard-to-read columns per row on a mid-width phone. */
    @media (max-width: 560px) {
      .bucket-row { flex-direction: column; }
    }
    /* Mobile-only merge for the 4 near-term buckets when all of them are
       empty (9/13/26, Casey's own real-phone screenshot -- see timeline.py's
       near_term_all_empty docstring for the full reasoning). Hidden by
       default so desktop's existing wrapped grid is completely untouched;
       only kicks in at the same column-stacking breakpoint above, and only
       on a .bucket-row that actually has nothing near-term to report --
       .near-term-merged is never added to the row otherwise, so these rules
       are inert on any child whose near-term buckets have real content. */
    .bucket-near-term-summary { display: none; }
    @media (max-width: 560px) {
      .bucket-row.near-term-merged .bucket-near-term { display: none; }
      .bucket-row.near-term-merged .bucket-near-term-summary {
        display: flex; align-items: center; gap: 0.5rem; margin: 0;
      }
    }
    details.bucket, .bucket.bucket-empty {
      flex: 1 1 130px;
      min-width: 130px;
      border-radius: 8px;
      border: 1px solid var(--border);
      overflow: hidden;
      transition: box-shadow 0.15s ease, transform 0.15s ease;
    }
    @media (max-width: 560px) { details.bucket, .bucket.bucket-empty { flex-basis: auto; } }
    details.bucket.highlighted {
      box-shadow: 0 0 0 2px var(--border-strong), 0 2px 8px rgba(0,0,0,0.1);
      transform: translateY(-2px);
    }
    /* Shared header styling for BOTH a real <summary> (a non-empty
       bucket -- clickable, has a chevron, expands to real content) and a
       plain <div> (an empty bucket -- deliberately NOT a <details> at
       all, added 8/4/26 at Casey's suggestion: a chevron implies "click
       to reveal more," and there's nothing more to reveal in a bucket
       that's already showing "None" -- that was a dead interaction, so
       empty buckets render as a flat, non-clickable card instead). Using
       a shared CLASS rather than a shared TAG selector so one rule works
       for both the <summary> and <div> cases. */
    .bucket-summary {
      padding: 0.5rem 0.6rem;
      display: flex;
      justify-content: space-between;
      align-items: flex-start;
      gap: 0.4rem;
      list-style: none;
    }
    details.bucket > .bucket-summary { cursor: pointer; }
    details.bucket > .bucket-summary::-webkit-details-marker { display: none; }
    .bucket.overdue > .bucket-summary { background: var(--danger-bg); color: var(--danger); }
    .bucket.this-week > .bucket-summary { background: var(--warning-bg); color: var(--warning-text); }
    .bucket.this-month > .bucket-summary { background: var(--month-bg); color: var(--month); }
    .bucket.next-90 > .bucket-summary { background: var(--primary-light); color: var(--primary-text); }
    .bucket.later > .bucket-summary { background: var(--pwn-bg); color: var(--pwn); }
    .bucket.completed > .bucket-summary { background: var(--info-light); color: var(--info-text); }
    /* Empty buckets get a slightly quieter header than an active one --
       reinforces "nothing to do here" at a glance, on top of not being
       clickable at all. */
    .bucket.bucket-empty > .bucket-summary { opacity: 0.75; padding-bottom: 0.65rem; }
    .bucket-name-row { display: flex; justify-content: space-between; align-items: baseline; font-size: 0.75rem; font-weight: 700; gap: 0.4rem; }
    .bucket-count { font-size: 0.7rem; font-weight: 500; opacity: 0.85; }
    .bucket-date-range { font-size: 0.65rem; font-weight: 500; opacity: 0.75; margin-top: 0.1rem; }
    .bucket-chevron {
      font-size: 0.7rem;
      opacity: 0.7;
      flex-shrink: 0;
      transition: transform 0.15s ease;
      display: inline-block;
    }
    details.bucket[open] > .bucket-summary .bucket-chevron { transform: rotate(90deg); }
    .bucket-items { padding: 0.5rem; display: flex; flex-direction: column; gap: 0.35rem; }
    /* Empty bucket's "None" -- reuses the same .empty-state treatment
       (muted italic + small icon) already used everywhere else in the
       app for "nothing here yet", rather than a bare unstyled word. */
    .bucket-items .empty-state { font-size: 0.68rem; padding: 0.1rem; }
    .bucket-items .empty-state i { font-size: 0.9rem; }

    details.item-chip {
      background: var(--paper);
      border: 1px solid var(--border);
      border-radius: 6px;
      font-size: 0.7rem;
      line-height: 1.3;
    }
    /* Redesigned 8/4/26 (2nd attempt) -- the previous flex-row layout
       (title + date side by side, flex-grow on the title) kept squeezing
       the title away to near-nothing in practice, and the badge+countdown
       row had the same problem. Rather than keep chasing a flex/line-
       clamp interaction that's hard to verify without a real browser,
       this drops flex from the summary entirely: chevron is an inline
       prefix, title flows as plain inline text right after it (wraps
       naturally, no line-clamp trick to interact badly with anything),
       and the date moves to its OWN line below instead of competing for
       horizontal space next to a long title. Plainer, but far less likely
       to have a subtle cross-browser flex bug hiding in it. */
    details.item-chip > summary {
      padding: 0.32rem 0.5rem;
      cursor: pointer;
      list-style: none;
    }
    details.item-chip > summary::-webkit-details-marker { display: none; }
    /* Explicit affordance that a chip opens for more -- the cursor change
       alone (the only signal before this) is easy to miss entirely. */
    details.item-chip > summary::before {
      content: "▸";
      color: var(--muted);
      display: inline-block;
      width: 0.9em;
      transition: transform 0.15s ease;
    }
    details.item-chip[open] > summary::before { transform: rotate(90deg); }
    .item-title {
      font-weight: 600;
    }
    .item-date {
      display: block;
      margin-left: 0.9em;
      margin-top: 0.15rem;
      color: var(--muted);
      font-size: 0.64rem;
    }
    .item-detail { padding: 0 0.5rem 0.4rem 1.35rem; }
    .item-note { color: var(--muted); font-size: 0.68rem; margin-bottom: 0.3rem; }
    /* Redesigned 8/4/26, 3rd pass -- badge and countdown now always stack
       (badge above countdown) instead of trying to fit side-by-side and
       sometimes wrapping. Two earlier attempts at this row-layout kept
       needing another tweak because "row that sometimes wraps" has two
       different alignment needs depending on whether it wrapped or not,
       and one justify-content value can't satisfy both. A vertical
       stack sidesteps that entirely: display: inline-flex makes this box
       shrink to fit its own widest child (whichever of "future"/the
       countdown text is wider) rather than spanning the full available
       width, so align-items: center centers the NARROWER one relative to
       the WIDER one -- "81 days left" centered under "future" specifically,
       not centered in the whole card (Casey's actual ask). And because
       it's inline-flex (shrinks to content, doesn't span full width), the
       whole block still starts flush at .item-note's left edge above it
       for free -- no separate left-alignment rule needed alongside the
       centering, the two aren't in tension the way they were in a
       full-width row. */
    .item-badge-row {
      display: inline-flex;
      flex-direction: column;
      align-items: center;
      gap: 0.25rem;
    }
    .item-countdown { font-size: 0.66rem; color: var(--muted); font-weight: 500; }

    /* "+N more" overflow disclosure (app/timeline.py's _MAX_ITEMS_PER_BUCKET
       safety net) -- same visual family as an item-chip but deliberately
       quieter (dashed border, muted text) so it reads as "expand for the
       rest," not one more deadline competing for attention. */
    details.overflow-toggle {
      border: 1px dashed var(--border-strong);
      border-radius: 6px;
      font-size: 0.68rem;
      color: var(--muted);
    }
    details.overflow-toggle > summary {
      padding: 0.3rem 0.5rem;
      cursor: pointer;
      list-style: none;
      font-weight: 600;
      display: flex;
      align-items: center;
      gap: 0.35rem;
    }
    details.overflow-toggle > summary::-webkit-details-marker { display: none; }
    details.overflow-toggle > summary::before {
      content: "▸";
      transition: transform 0.15s ease;
    }
    details.overflow-toggle[open] > summary::before { transform: rotate(90deg); }
    details.overflow-toggle .bucket-items { padding: 0.35rem 0 0; }

    @media print {
      .dot-bar { display: none; }
    }
    /* Mobile timeline fix (8/2/26, Casey's screenshot catch): the bar's
       horizontal label-spacing math (_LABEL_ROW_MIN_GAP_PCT in
       app/timeline.py) is a fixed percentage threshold tuned for "a
       typical desktop section-width card" -- its own docstring already
       flagged this as an approximation that can't know the actual
       rendered pixel width. On a phone-width card, that assumption
       breaks: two labels far enough apart in percentage terms to NOT
       need row-stacking on desktop can still be close enough in actual
       pixels to overlap each other's text on a narrow screen -- a
       different, worse problem than one label's own text wrapping to
       too many lines (the earlier fix in this same session). Rather
       than chase an ever-more-specific percentage threshold that will
       always be wrong for some clustering scenario at some width,
       hide the bar (and its now-meaningless color-key legend) below
       this breakpoint entirely -- same technique already used for
       print, for the same underlying reason. The full deadline list
       just below the bar already shows every deadline's title, date,
       note, status, and countdown with zero positioning math, so
       nothing is lost. */
    @media (max-width: 600px) {
      .deadline-timeline-bar, .deadline-timeline-legend { display: none; }
    }
    .disclaimer {
      font-size: 0.85rem;
      color: var(--muted);
      background: var(--primary-light);
      border-radius: 6px;
      padding: 0.75rem 1rem;
      margin-top: 1rem;
    }
    /* A distinctly STRONGER warning than .disclaimer -- for content that's
       synthesized/not-yet-verified rather than merely AI-assisted (e.g. the
       resume builder's "starting draft" suggestions). Shares the same
       --warning-bg tint as the "upcoming deadline" badge so warning-toned
       color means the same thing everywhere in the app, not just here. */
    .callout-warning {
      background: var(--warning-bg);
      border: none;
      border-radius: 8px;
      padding: 0.9rem 1.1rem;
    }
    .callout-warning p { font-size: 0.85rem; margin: 0.25rem 0 0 0; }
    /* Bulk document toolbar layout, moved here from an inline style on the
       element itself (9/21/26, real bug -- caught during a "make this
       cleaner" pass ahead of the PACER demo: the toolbar was supposed to
       stay hidden via its `hidden` attribute until at least one document
       checkbox is checked (app.js's updateDocBulkToolbar() toggles
       `.hidden`), but an inline `style="display:flex"` on the SAME element
       always wins over the browser's own `[hidden] { display: none }` UA
       rule -- inline styles beat any stylesheet rule regardless of the
       attribute's true/false state, so the toolbar showed "0 documents
       selected" and every control permanently, on every page load,
       whether or not anything was actually selected. Fixed by moving the
       flex layout into the stylesheet and giving the hidden state a rule
       that can actually win: `#id[hidden]` has higher specificity than
       plain `#id`, so it correctly overrides the flex layout below once
       the attribute is present, the way inline `display:none` used to
       fail to even before this element had an inline style at all. */
    #documents-bulk-toolbar { display: flex; flex-wrap: wrap; align-items: center; gap: 0.6rem; }
    #documents-bulk-toolbar[hidden] { display: none; }
    /* Positive/success confirmations (e.g. "your password was reset").
       Reuses --primary-light/--primary-text -- the same pine-green pairing
       already used for "good" states elsewhere -- rather than inventing a
       new hue, same principle as .callout-warning reusing --warning-bg. */
    .callout-success {
      background: var(--primary-light);
      color: var(--primary-text);
      border: none;
      border-radius: 8px;
      padding: 0.9rem 1.1rem;
    }
    /* Empty-state rows (e.g. "No goals added yet") get a small muted icon
       instead of reading as plain text -- same treatment everywhere a
       section has nothing in it yet, so it reads as "nothing here yet"
       rather than looking like an error or missing content. */
    .empty-state {
      display: flex;
      align-items: center;
      gap: 0.75rem;
      color: var(--muted);
      font-style: italic;
      padding: 0.5rem 0;
    }
    /* Visual polish (8/10/26, one of 3 app-wide ideas scoped after Casey
       asked what could visually improve the whole app) -- was a bare,
       muted, reduced-opacity icon sitting flat next to the text. A soft
       tinted circular backdrop gives it real visual weight without
       needing a bordered box around the whole state (would look like a
       nested card, since every one of the 21 real usages of this class
       already sits inside an existing .card -- checked directly before
       choosing this direction over a bordered-container approach).
       Reuses --primary/--primary-light, the same treatment as the
       landing page's own feature-tile icons, rather than inventing a
       new color pairing just for this. Pure CSS change, zero markup
       changes needed anywhere -- every usage already has the exact same
       `<i class="ti ti-X" aria-hidden="true"></i>` structure this
       targets, confirmed by checking real usages across the 11 templates
       that use this class before trusting that claim. */
    .empty-state i {
      font-size: 1.1rem;
      color: var(--primary);
      background: var(--primary-light);
      width: 2.1rem;
      height: 2.1rem;
      border-radius: 50%;
      display: flex;
      align-items: center;
      justify-content: center;
      flex-shrink: 0;
      opacity: 1;
    }
    /* IEP Snapshot's "Document summaries" / "From evaluation reports" /
       "From Prior Written Notices" blocks -- small cards quoting a specific
       source document. Matches the same "bordered/tinted box" language as
       the Snapshot's "Next up" deadline callout just above it (stronger
       border, a bit more padding than the old var(--border) treatment)
       so the whole card reads as one consistent design, not two eras. */
    .snapshot-doc-card {
      margin: 0.5rem 0;
      background: var(--paper);
      border: 1px solid var(--border-strong);
      border-radius: 8px;
      padding: 0.7rem 1rem;
    }
    /* Quick-nav: pill bar for in-page anchor jumps on a long page (e.g.
       the main child page's #timeline / #documents / etc. section links).
       Not sticky -- only the hub folder-tabs below are, to avoid two
       sticky bars competing for the same screen space. */
    .quick-nav {
      display: flex;
      gap: 0.5rem;
      row-gap: 0.85rem;
      flex-wrap: wrap;
      padding: 0.75rem 0;
      margin: 1.5rem 0 0.5rem;
      border-bottom: 1px solid var(--border);
    }
    /* Plain quick-nav usages (Profile, Guide) sit right after the top
       folder-tabs, same as .sub-tabs does for Advocacy Center/Transition
       Planning -- but .quick-nav's own 1.5rem top margin (needed
       elsewhere, and left alone here since the shared top folder-tabs bar
       also uses this same base class and has never explicitly overridden
       margin-top itself) left a much bigger gap above these two than
       .sub-tabs has, which is exactly what Casey caught side-by-side
       (8/5/26). Scoped to exclude .folder-tabs specifically, rather than
       lowering the base .quick-nav margin-top directly, since that shared
       top nav element relies on this same class and its own spacing
       hasn't been flagged as needing any change. */
    .quick-nav:not(.folder-tabs) {
      margin-top: 0;
    }
    .quick-nav a {
      background: var(--primary-light);
      color: var(--primary-text);
      padding: 0.35rem 0.8rem;
      border-radius: 20px;
      font-size: 0.8rem;
      font-family: var(--font-mono);
      font-weight: 600;
      text-decoration: none;
      white-space: nowrap;
    }
    /* Real bug, caught 8/20/26 from Casey's own screenshot: hovering ANY
       hub folder-tab (Transition, Advocacy, Guide) turned it flat navy
       instead of just dimming its own color, exactly the moment a parent
       is about to click into a hub -- undermining the whole point of the
       color-coded wayfinding right when it matters most. Same root class
       of bug as the two :visited specificity fixes earlier this session
       (header wordmark, .skip-link), but via a shared/reused CLASS this
       time rather than a bare tag selector: .folder-tabs's own <nav>
       element still carries the older "quick-nav" class it was built on
       top of (see the margin-top fix a few lines up, which already had to
       work around this same overlap once) -- so `.quick-nav a:hover`
       (class + pseudo-class + element, specificity (0,2,1)) was silently
       beating `.folder-tabs .tab-advocacy`'s own background rule (two
       classes, (0,2,0)): tied on the class bucket, but the extra pseudo-
       class pushes the quick-nav rule's TOTAL specificity higher. Scoped
       both this rule and its hover variant below with the same
       `:not(.folder-tabs)` pattern already used for the margin-top fix,
       rather than inventing a new pattern -- .folder-tabs .tab:hover
       (further down this file, `opacity: 0.85`) already does the RIGHT
       thing and needs no change; it just couldn't win this fight before. */
    .quick-nav:not(.folder-tabs) a:hover { background: var(--primary); color: white; }

    /* Slim "jump to" line (8/11/26, restructuring the old quick-nav finding:
       a row of pill buttons right above a set of accordion section headers
       repeated the exact same labels with no visual distinction between
       "this is a shortcut" and "this is the section itself" -- on a first
       visit it read as duplicated content, not navigation. Replaces the old
       pill-button row on the child page and Guide with a single quiet
       sentence instead, since these links are genuinely a shortcut past
       content that's already fully listed immediately below, not a
       separate menu of their own. Kept the underlying behavior exactly --
       same #anchor hrefs, still handled by the existing
       openTargetSection() JS (opens a collapsed <details> and scrolls to
       it) -- only the visual weight changed. */
    .jump-nav {
      font-size: 0.85rem;
      color: var(--muted);
      padding: 0.6rem 0;
      margin: 0 0 0.5rem;
      border-bottom: 1px solid var(--border);
      line-height: 2.1;
    }
    /* Real tap-target room on mobile (8/11/26 follow-up: dropping the old
       pill buttons' padding also dropped their touch-friendly hit area --
       these sit close together with just a middle-dot between them, which
       is fine with a mouse cursor but genuinely harder to tap accurately
       on a phone, which is exactly where a parent is most likely to be
       checking this at 11pm). inline-block + a small amount of padding
       restores a comfortable tap area without bringing back the pill
       background/border -- still reads as plain text in a sentence, not a
       button. */
    .jump-nav a {
      font-weight: 600;
      display: inline-block;
      padding: 0.25rem 0.2rem;
      margin: -0.25rem -0.2rem;
    }
    .jump-nav .jump-nav-sep { color: var(--border-strong); margin: 0 0.3rem; }

    /* Pillar tab panels (added 8/5/26, Casey's own direction): only one
       of the three sections (Where do I go / Who can help me / What do I
       need) shows at a time now, switched by clicking a hero box or a
       compact nav pill, instead of all three stacked in one long scroll.
       Hiding is scoped to `.pillar-tab-panels-js-ready .pillar-tab-panel`
       rather than `.pillar-tab-panel` directly -- the "js-ready" class is
       only added by JS once it actually runs (see initPillarTabs() near
       the bottom of this file), so with JS disabled/blocked, nothing
       ever gets hidden and every panel just stays visible (the original
       all-stacked-in-one-scroll layout) -- a real fallback, not a page
       that quietly loses 2 of 3 sections with no way to reach them. */
    .pillar-tab-panels-js-ready .pillar-tab-panel { display: none; }
    .pillar-tab-panels-js-ready .pillar-tab-panel.active { display: block; }

    /* Transition Hub pillar pages (added 8/4/26, Phase 0 -- Entering
       Special Ed): the three questions are real top-level sections, not
       collapsible reference material like Guide's .section-group, so a
       plain <section> is used instead of <details>. Still needs the same
       scroll-margin-top trick so a quick-nav jump doesn't land underneath
       the sticky folder-tabs bar. */
    main section[id] { scroll-margin-top: 5.5rem; margin-top: 2rem; }
    main section[id] > h2 {
      display: flex; align-items: center; gap: 0.5rem;
      font-family: var(--font-display); color: var(--primary-text);
    }
    /* Visual current-step roadmap for Entering Special Ed's evaluation
       process (backlog item, 8/6/26) -- replaces the old plain numbered
       roadmap-list (removed, no longer used anywhere) with a real
       horizontal stepper: filled dots + a highlighted connector for
       completed steps, an accent ring + a small badge marking the
       CURRENT step (computed per-child from real recorded dates, see
       evaluation_roadmap_status() in app/timeline.py), and muted/outline
       dots for steps still ahead. Stacks vertically under 600px, same
       breakpoint every other mobile-stacking rule in this file uses.
       NOTE FOR ANYONE WRITING A REGRESSION CHECK AGAINST THIS FEATURE:
       don't put the current-step badge's own class attribute, in its
       exact quoted HTML form, anywhere in this comment (or its display
       text either) -- this project has hit that exact bug twice already
       in one session (8/6/26). This <style> block is server-rendered as
       part of EVERY page's HTML, comments included, so a check counting
       or searching for an exact string will pick up a copy living in
       this comment too, on every page, regardless of whether the real
       badge is present. The one real instance of that element lives only
       in transition_hub_entering_special_ed.html -- write any check
       against a plain description of it, never copy/paste the literal
       markup here as an example. */
    .eval-roadmap {
      display: flex;
      align-items: flex-start;
      margin: 1rem 0 0.5rem 0;
    }
    .eval-roadmap-step {
      flex: 1;
      display: flex;
      flex-direction: column;
      align-items: center;
      text-align: center;
      padding: 0 0.4rem;
      min-width: 0;
    }
    .eval-roadmap-connector {
      flex: 0 0 2rem;
      height: 2px;
      background: var(--border);
      margin-top: 1.05rem;
    }
    .eval-roadmap-connector-complete { background: var(--primary); }
    .eval-roadmap-dot {
      width: 2.1rem; height: 2.1rem;
      border-radius: 50%;
      display: flex; align-items: center; justify-content: center;
      font-family: var(--font-mono); font-weight: 700; font-size: 0.85rem;
      border: 2px solid var(--border);
      background: var(--surface);
      color: var(--muted);
      flex-shrink: 0;
    }
    .eval-roadmap-step-complete .eval-roadmap-dot {
      background: var(--primary);
      border-color: var(--primary);
      color: #fff;
    }
    .eval-roadmap-step-current .eval-roadmap-dot {
      border-color: var(--accent);
      color: var(--accent);
      box-shadow: 0 0 0 4px var(--accent-light);
    }
    .eval-roadmap-label {
      margin-top: 0.5rem;
      font-size: 0.8rem;
      font-weight: 600;
    }
    .eval-roadmap-step-upcoming .eval-roadmap-label { color: var(--muted); font-weight: 400; }
    .eval-roadmap-here-badge {
      display: inline-block;
      margin-top: 0.3rem;
      padding: 0.1rem 0.5rem;
      border-radius: 999px;
      background: var(--accent-light);
      color: var(--warning);
      font-size: 0.62rem;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.03em;
    }
    @media (max-width: 600px) {
      .eval-roadmap { flex-direction: column; align-items: stretch; }
      .eval-roadmap-step { flex-direction: row; text-align: left; align-items: center; padding: 0.4rem 0; }
      .eval-roadmap-connector { display: none; }
      .eval-roadmap-label { margin-top: 0; margin-left: 0.75rem; }
      .eval-roadmap-here-badge { margin-top: 0; margin-left: 0.5rem; }
    }

    /* Elementary->Middle pillar (Phase 1, 8/5/26): audience tags mark
       whether a content card is written for the student or the parent --
       real visual distinction, not just a text label, both because it's
       genuinely useful (skim to what's relevant to you) and because
       Phase 0's own feedback flagged the plain card-after-card layout as
       too uniform for a page partly meant for a tired parent OR a kid to
       read. Reuses --info (student) and --accent (parent) -- both
       already contrast-checked pairings elsewhere in this app, not new
       unverified colors. */
    .audience-tag {
      display: inline-block;
      padding: 0.15rem 0.6rem;
      border-radius: 20px;
      /* Font swapped from mono to --font-body 8/11/26 -- see .badge's
         comment for the full reasoning (same design review). */
      font-family: var(--font-body);
      font-size: 0.7rem;
      font-weight: 700;
      text-transform: uppercase;
      letter-spacing: 0.02em;
    }
    .audience-tag.audience-student { background: var(--info); color: white; }
    .audience-tag.audience-parent { background: var(--accent); color: white; }
    .audience-tag.audience-both { background: var(--muted); color: white; }

    /* Career detail page redesign (8/12/26, Casey's ask for "a little bit
       more" before the AI plan-generation step) -- generic, shared
       patterns rather than page-specific one-off classes, in case another
       page wants a fact-grid/tag-row/match-banner later. */
    .fact-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
      gap: 0.9rem;
      margin: 1rem 0 1.35rem;
    }
    .fact {
      border: 1px solid var(--border);
      border-radius: 8px;
      padding: 0.85rem 0.95rem;
      background: var(--paper);
    }
    .fact-label {
      display: flex; align-items: center; gap: 0.4rem;
      font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em;
      color: var(--muted); font-weight: 600; margin-bottom: 0.3rem;
    }
    .fact-label i { color: var(--tab-careers); font-size: 0.95rem; }
    .fact-value { font-size: 0.88rem; line-height: 1.4; color: var(--text); }
    .fact-caveat { display: block; margin-top: 0.3rem; font-size: 0.76rem; color: var(--muted); }

    .info-tag-row { display: flex; flex-wrap: wrap; gap: 0.4rem; margin-bottom: 0.5rem; }
    .info-tag {
      display: inline-flex; align-items: center; gap: 0.3rem;
      font-size: 0.78rem; font-weight: 600;
      padding: 0.3rem 0.65rem; border-radius: 100px;
      background: var(--primary-light); color: var(--primary-text);
    }
    /* --warning-text, not --accent, on --accent-light -- same reasoning as
       .tag-accent/.match-strength-medium above: --accent alone on
       --accent-light only hits ~2.5:1 contrast (fails WCAG AA for this
       text size); --warning-text is the already-verified darker gold this
       app uses everywhere else for text on that exact background. */
    .info-tag.skill { background: var(--accent-light); color: var(--warning-text); }

    .match-banner {
      display: flex; align-items: center; gap: 0.6rem;
      /* --info-text (not --info) on --info-light -- matches the existing
         verified pattern used by .badge.advisory/.tag-count/etc. above,
         rather than introducing a new, unverified combination. */
      background: var(--info-light); color: var(--info-text);
      border-radius: 8px; padding: 0.65rem 0.9rem;
      font-size: 0.85rem; font-weight: 600;
      margin-bottom: 1.1rem;
    }
    .match-banner i { font-size: 1.05rem; flex-shrink: 0; }

    /* Transition Hub content cards, redesigned 8/5/26 after Casey's
       screenshot review flagged the pillar pages as an undifferentiated
       card-after-card stack. Two independent visual channels, not one:
       the card's LEFT BORDER color signals audience (student/parent/both
       -- the "who is this for" split real reviewer feedback said deserved
       more visual weight than a small badge), and the ICON CIRCLE signals
       kind (legal/warning/contact/etc. -- see app/transition_hub.py's
       KIND_STYLE). Entering Special Ed's content has no audience field at
       all, so its border falls back to kind's own color -- see
       content_card()'s border_var logic in _transition_hub_macros.html. */
    .transition-content-card {
      display: flex;
      gap: 0.85rem;
      align-items: flex-start;
      background: var(--surface);
      border: 1px solid var(--border);
      border-left: 4px solid var(--card-accent);
      border-radius: 0 10px 10px 0;
      padding: 1rem 1.25rem;
      margin-bottom: 0.85rem;
    }
    .transition-content-card-icon {
      flex-shrink: 0;
      width: 2rem;
      height: 2rem;
      border-radius: 50%;
      background: var(--card-icon-accent);
      color: white;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 1rem;
    }
    .transition-content-card-body h3 {
      margin: 0.3rem 0 0.3rem 0;
      font-size: 1rem;
    }
    .transition-content-card-body p {
      margin: 0;
    }
    .transition-content-card-body .audience-tag {
      margin: 0;
    }
    /* Citation line (8/5/26) -- smaller and muted, same visual weight as
       Guide's own "Source: ..." line for the Grounded AI lookup, so a
       parent learns this pattern once and recognizes it everywhere it
       appears. Only ever rendered when item.citation is actually
       present, so this never shows for the (currently much more common)
       card with no citation behind it. */
    .transition-content-card-citation {
      font-size: 0.8rem;
      color: var(--muted);
      margin-top: 0.5rem !important;
    }

    /* Crisis-resources card (added 8/5/26): the single most important
       card on the Middle->High page, and real reviewer feedback correctly
       flagged that "wellbeing" kind gave it the same weight as a card
       about joining clubs. A tinted background on top of the usual
       border/icon treatment, so it reads as visually different from the
       rest of the page even before anyone reads a word -- reuses
       --danger-bg, not a new color, and deliberately NOT applied to the
       rest of "wellbeing" content (most of which -- "stress is normal" --
       genuinely should read as calm, not alarming). */
    .transition-content-card-crisis {
      background: var(--danger-bg);
      border-color: var(--danger);
    }

    /* Warning-kind card (added 8/13/26, Career Pathways flow review): same
       "give it its own tinted background so it reads as different before
       anyone reads a word" reasoning as the crisis card above, but with
       the existing --warning/--warning-bg pair instead of --danger's --
       a warning ("this deadline has real consequences") shouldn't carry
       the same visual alarm as a crisis card (988/Crisis Text Line). */
    .transition-content-card-warning {
      background: var(--warning-bg);
      border-color: var(--warning);
    }

    /* Grouped-cluster container (8/5/26): related content cards (e.g. the
       full guardianship spectrum on High School->Adulthood) render inside
       ONE bordered container with a shared heading, instead of N separate
       identical-looking cards in a row -- directly answers the "same box,
       four times" effect Casey's screenshot showed. The individual
       .transition-content-card children lose their own border/background
       inside a cluster (kept only their icon + left accent) so they read
       as one connected group, not four more boxes nested in a bigger box. */
    .transition-content-cluster {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 12px;
      padding: 1.25rem 1.5rem;
      margin-bottom: 0.85rem;
    }
    .transition-content-cluster > h3 {
      margin: 0 0 0.25rem 0;
    }
    .transition-content-cluster > p.cluster-intro {
      color: var(--muted);
      font-size: 0.9rem;
      margin: 0 0 1rem 0;
    }
    .transition-content-cluster .transition-content-card {
      background: none;
      border: none;
      border-left: 3px solid var(--card-accent);
      border-radius: 0;
      padding: 0.6rem 0 0.6rem 1rem;
      margin-bottom: 0;
    }
    .transition-content-cluster .transition-content-card:not(:last-child) {
      border-bottom: 1px solid var(--border);
      padding-bottom: 1rem;
      margin-bottom: 0.5rem;
    }
    .transition-content-cluster .transition-content-card-icon {
      width: 1.6rem;
      height: 1.6rem;
      font-size: 0.85rem;
    }
    /* Crisis card keeps its own distinct warning background even inside a
       cluster (added 8/5/26, once clusters started being used for ordinary
       reference-card lists too, not just the guardianship spectrum -- the
       cluster's own background:none reset above would otherwise strip the
       one card on the page that's deliberately supposed to look different
       from everything else). Same specificity as the reset rule (two
       classes each); wins by appearing later in the stylesheet. */
    .transition-content-cluster .transition-content-card-crisis {
      background: var(--danger-bg);
    }
    /* Same reasoning as the crisis override immediately above, extended
       8/13/26 to the new warning-kind background so a warning card keeps
       its tint if it's ever grouped inside a cluster too. */
    .transition-content-cluster .transition-content-card-warning {
      background: var(--warning-bg);
    }

    .schedule-practice-table {
      width: 100%;
      border-collapse: collapse;
      margin: 0.75rem 0;
      font-size: 0.9rem;
    }
    .schedule-practice-table th, .schedule-practice-table td {
      border: 1px solid var(--border);
      padding: 0.4rem 0.6rem;
      text-align: left;
    }
    .schedule-practice-table th { background: var(--primary-light); font-family: var(--font-mono); }
    /* Generalized 8/5/26 from .schedule-quiz-question to .quiz-question --
       this same multiple-choice quiz pattern is now shared by more than
       just the schedule tool (see renderMultipleChoiceQuiz() below), so
       one reusable class instead of a schedule-specific one that a second
       quiz would've had to duplicate. */
    .quiz-question {
      padding: 0.75rem 0;
      border-top: 1px solid var(--border);
    }
    .quiz-question button {
      background: var(--paper);
      color: var(--primary-text);
      border: 1.5px solid var(--border);
      border-radius: 999px;
      padding: 0.4rem 0.9rem;
      font-size: 0.85rem;
      font-weight: 600;
      cursor: pointer;
      transition: background 0.15s ease, border-color 0.15s ease, transform 0.1s ease;
    }
    .quiz-question button:hover { background: var(--primary-light); border-color: var(--primary); }
    .quiz-question button:active { transform: scale(0.96); }
    /* Correct/incorrect feedback classes, applied by renderMultipleChoiceQuiz()'s
       click handler directly to the clicked button (added 8/5/26 -- previously
       only the feedback TEXT below changed color, the button itself
       stayed plain, which real reviewer feedback correctly flagged as
       "looks like unstyled form defaults, not a game"). Reuses the same
       --primary/--danger pairing as .callout-success/.callout-warning
       elsewhere, not new colors. */
    .quiz-question button.quiz-btn-correct {
      background: var(--primary-light);
      border-color: var(--primary);
    }
    .quiz-question button.quiz-btn-incorrect {
      background: var(--danger-bg);
      border-color: var(--danger);
      color: var(--danger);
    }
    /* Checklist-style tool (scavenger hunt): a plain checkbox list with a
       live "X of Y found" progress line -- entirely client-side, nothing
       saved, same "practice, not real data" principle as the quizzes. */
    .tool-checklist { list-style: none; margin: 0.5rem 0 0 0; padding: 0; }
    .tool-checklist li {
      display: flex; align-items: center; gap: 0.6rem;
      padding: 0.5rem 0; border-top: 1px solid var(--border);
    }
    .tool-checklist li:first-child { border-top: none; }
    /* Explicit 1.15rem checkbox size REMOVED (9/11/26 mobile pass) -- now
       smaller than the app-wide checkbox size set above, so this would have
       silently shrunk these checkboxes back down instead of leaving them
       at the improved default. flex-shrink already covered by the base
       rule too. */
    .tool-checklist label { cursor: pointer; }
    .tool-checklist li.checklist-item-done label {
      text-decoration: line-through;
      color: var(--muted);
    }
    .checklist-progress {
      font-weight: 600;
      margin: 0.75rem 0 0 0;
      color: var(--accent);
    }
    /* "Practice building a budget" tool (High School->Adulthood, built
       8/6/26) -- a real, live-calculating widget, not a walkthrough or
       checklist. Each row is a label input + a bordered "$___" amount
       field styled to look like one control, not two stacked inputs (the
       shared input{} rule already gives every <input> its own border/
       padding/background, so the amount input's OWN border/background get
       explicitly zeroed out here to sit inside the wrapper's border
       instead of doubling it). */
    .budget-row {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      margin-bottom: 0.5rem;
    }
    .budget-row-label { flex: 1; }
    .budget-row-amount-wrap {
      display: flex;
      align-items: center;
      width: 7rem;
      flex-shrink: 0;
      border: 1px solid var(--border);
      border-radius: 6px;
      background: var(--surface);
      padding: 0 0.5rem;
    }
    .budget-row-amount-wrap .budget-row-currency {
      color: var(--muted);
      font-size: 0.9rem;
    }
    .budget-row-amount-wrap .budget-row-amount {
      border: none;
      background: transparent;
      width: 100%;
      padding: 0.5rem 0.25rem;
    }
    .budget-row-amount-wrap .budget-row-amount:focus { outline: none; }
    .budget-row-remove {
      background: none;
      color: var(--muted);
      border: none;
      padding: 0.3rem;
      font-size: 1.2rem;
      line-height: 1;
      cursor: pointer;
      flex-shrink: 0;
    }
    .budget-row-remove:hover { color: var(--danger); }
    .budget-builder-add-btn {
      background: var(--primary-light);
      color: var(--primary-text);
      font-size: 0.85rem;
      padding: 0.4rem 0.85rem;
      margin-top: 0.35rem;
    }
    .budget-builder-add-btn:hover { background: var(--accent-light); }
    .budget-remaining-bar-track {
      height: 0.5rem;
      border-radius: 999px;
      background: var(--border);
      overflow: hidden;
      margin-top: 0.75rem;
    }
    .budget-remaining-bar-fill {
      height: 100%;
      width: 0%;
      background: var(--primary);
      transition: width 0.2s ease, background 0.2s ease;
    }
    /* Step-through walkthrough (locker practice): one step visible at a
       time, Back/Next buttons -- same "guided, not overwhelming" principle
       as the pillar tabs themselves, just at a smaller scale within one tool. */
    .tool-walkthrough-step { display: none; }
    .tool-walkthrough-step.active { display: block; }
    .tool-walkthrough-nav {
      display: flex; justify-content: space-between; align-items: center;
      margin-top: 1rem; padding-top: 0.75rem; border-top: 1px solid var(--border);
    }
    .tool-walkthrough-progress { color: var(--muted); font-size: 0.85rem; }

    /* Shared checkbox-pill style (8/5/26): pulled out of what used to be
       copy-pasted inline styles on transition.html and the old
       careers_profiler.html -- one real class now, for the Career
       Profiler's interactive wizard specifically (transition.html's own
       simpler checkbox form is untouched this pass, a separate smaller
       cleanup, not the ask this time). */
    .checkbox-pill {
      display: inline-flex; align-items: center; gap: 0.4rem;
      font-weight: 400; font-size: 0.95rem; margin: 0;
      background: var(--primary-light); padding: 0.5rem 0.8rem;
      border-radius: 8px; width: auto;
    }
    /* Explicit size (9/11/26 mobile pass), not `width: auto` -- this
       existed only to stop the checkbox stretching to the pill's full flex
       width, and an explicit size prevents that just as well while also
       getting the same bigger, easier-to-tap size as every other checkbox
       in the app instead of falling back to the tiny ~13px native default. */
    .checkbox-pill input[type="checkbox"] { width: 1.25rem; height: 1.25rem; }
    @media (max-width: 600px) {
      .checkbox-pill input[type="checkbox"] { width: 1.5rem; height: 1.5rem; }
    }

    /* Career Profiler concrete-activity groups (redesigned 8/5/26,
       research-backed: the student never rates a category directly --
       O*NET Interest Profiler / Holland's Self-Directed Search both infer
       broad categories from concrete activity items instead, since direct
       self-rating of abstract categories is the weakest link in
       self-assessment, per Kruger & Dunning 1999). One bordered group per
       category, its 3 concrete activities each a simple checkbox --
       replaces the old direct "rate this category 1-3" radio-row design
       entirely (see _weight_from_activities() in app/routers/careers.py
       for how the category's weight is now INFERRED from these). */
    /* Career Profiler one-at-a-time quiz card (redesigned 8/5/26, 2nd
       pass same day, after real back-and-forth on how validated
       instruments actually present items: O*NET Interest Profiler,
       Holland's Self-Directed Search, PICS/R-FVII picture inventories.
       Two real corrections from the first activity-based version:
       (1) items are no longer grouped/labeled by category while being
       answered -- seeing the category label invites the student to
       reason about the CATEGORY rather than react to the concrete
       activity, quietly reintroducing the bias concrete items were
       meant to avoid (item transparency); (2) every item now gets an
       explicit, forced Yes/No click -- not a checkbox that can be
       silently left unchecked -- matching how every real instrument
       researched forces an answer per item. See
       app/routers/careers.py's docstring for the full reasoning. */
    .profiler-quiz-progress-track {
      width: 100%; height: 8px; background: var(--border); border-radius: 4px;
      overflow: hidden; margin-bottom: 1.5rem;
    }
    .profiler-quiz-progress-fill {
      height: 100%; background: var(--tab-careers, var(--accent));
      transition: width 0.25s ease; width: 0%;
    }
    .profiler-quiz-card { text-align: center; padding: 1.5rem 1rem 1rem; }
    .profiler-quiz-question {
      font-size: 1.25rem; font-weight: 600; line-height: 1.4;
      margin: 0 0 2rem 0; min-height: 3.5rem;
    }
    .profiler-quiz-buttons {
      display: flex; gap: 1rem; justify-content: center; flex-wrap: wrap;
    }
    .profiler-quiz-btn {
      font-size: 1.05rem; font-weight: 600; padding: 0.9rem 2.5rem;
      border-radius: 12px; cursor: pointer; border: 2px solid var(--border);
      background: var(--surface); color: var(--text); min-width: 140px;
      transition: transform 0.1s ease, border-color 0.1s ease;
    }
    .profiler-quiz-btn:hover { transform: scale(1.04); }
    .profiler-quiz-btn.profiler-quiz-btn-yes:hover { border-color: var(--accent); color: var(--accent); }
    .profiler-quiz-btn.profiler-quiz-btn-no:hover { border-color: var(--muted); }
    .profiler-quiz-btn.profiler-quiz-btn-mid:hover { border-color: var(--tab-careers, var(--accent)); }
    /* 3-option graded skill-strength buttons (8/5/26 -- research on
       acquiescence/social-desirability bias found the strengths section
       specifically prone to over-endorsement with plain Yes/No; a
       graded, item-specific choice reduces the reflexive-yes default
       without adding reverse-worded items, which the same research found
       backfire for teens/lower-reading-level respondents). Slightly more
       compact than the 2-button interest variant so 3 fit comfortably. */
    .profiler-quiz-buttons-skill .profiler-quiz-btn {
      font-size: 0.95rem; padding: 0.8rem 1.5rem; min-width: 110px;
    }
    .profiler-quiz-back {
      background: none; border: none; color: var(--muted); font-size: 0.85rem;
      cursor: pointer; margin-top: 1.5rem; text-decoration: underline; padding: 0;
    }
    .profiler-quiz-transition { text-align: center; padding: 2rem 1rem; }
    /* Self-advocacy flashcard (redesigned 8/5/26, real reviewer feedback:
       the app's best feature -- scripts grounded in the child's own real
       accommodation data -- was visually the smallest thing on the page,
       plain italic text with a thin border). A real card treatment now:
       bigger padding, a large quote-mark icon, bolder text -- reads as
       something to actually practice with, not a footnote. */
    /* Interactive tools grouping (8/5/26): collects every hands-on tool
       within a tab (schedule practice, locker practice, etc.) under one
       shared heading, distinct from the plain .transition-content-card
       reference cards above them in the same tab -- a parent/student can
       tell "read this" apart from "try this" at a glance, rather than
       every card in a tab looking identical regardless of purpose.
       Reuses .self-advocacy-flashcard's --primary-light/--accent color
       pairing for the card itself, so there's one consistent "practice
       zone" visual language across the whole Transition Hub, not a
       second one invented just for this. */
    .interactive-tools-group {
      margin-top: 1.5rem;
      padding-top: 1.25rem;
      border-top: 2px dashed var(--border);
    }
    .interactive-tools-heading {
      display: flex;
      align-items: center;
      gap: 0.5rem;
      font-size: 1rem;
      color: var(--accent);
      margin: 0 0 1rem 0;
    }
    .interactive-tool-card {
      background: var(--primary-light);
      border: 1.5px solid var(--accent);
      border-radius: 12px;
      padding: 0 1.4rem;
      margin-bottom: 1rem;
    }
    .interactive-tool-card:last-child {
      margin-bottom: 0;
    }
    .interactive-tool-card > summary {
      cursor: pointer;
      list-style: none;
      padding: 1.1rem 0;
      font-weight: 600;
      font-size: 1rem;
      display: flex;
      align-items: center;
      gap: 0.5rem;
    }
    .interactive-tool-card > summary::-webkit-details-marker { display: none; }
    /* Same chevron affordance as details.item-chip elsewhere in this file --
       one reusable "this expands" signal, not a new one invented per component. */
    .interactive-tool-card > summary::before {
      content: "▸";
      color: var(--accent);
      display: inline-block;
      width: 0.9em;
      transition: transform 0.15s ease;
    }
    .interactive-tool-card[open] > summary::before { transform: rotate(90deg); }
    .interactive-tool-card[open] > summary { border-bottom: 1px solid var(--border); }
    .interactive-tool-card > summary .audience-tag { margin-left: auto; }
    .interactive-tool-card-body { padding: 1rem 0 1.1rem 0; }

    .self-advocacy-flashcard {
      background: var(--primary-light);
      border: 1.5px solid var(--accent);
      border-radius: 12px;
      padding: 1.1rem 1.4rem;
      margin: 0.85rem 0;
      position: relative;
    }
    .self-advocacy-flashcard p {
      margin: 0.35rem 0 0 0;
      font-size: 1.05rem;
      font-weight: 500;
      font-style: italic;
      color: var(--primary-text);
      line-height: 1.5;
    }

    /* Placement-percentage bar (8/10/26) -- a real visual for
       Child.placement_percentage_general_ed, the actual LRE (Least
       Restrictive Environment) percentage from a real IEP. Was plain text
       ("80% of the school day in general education") in 3 separate
       places with zero visual treatment despite being genuinely
       legally-meaningful data -- see placement_bar() macro in
       _placement_bar_macro.html, used by child_detail.html and
       iep_summary.html (both places this number already appeared).
       Colors reuse --primary/--accent, already confirmed print-safe (see
       the print media query's own light-mode color reset above) --
       consistent with how every other colored element on this page
       already behaves under print, not a new print concern this
       introduces. */
    .placement-bar-track {
      height: 14px;
      background: var(--primary-light);
      border-radius: 999px;
      overflow: hidden;
      display: flex;
      margin-top: 0.4rem;
    }
    .placement-bar-fill { background: var(--primary); height: 100%; }
    .placement-bar-rest { background: var(--accent); opacity: 0.35; height: 100%; }
    .placement-bar-legend {
      display: flex;
      justify-content: space-between;
      font-size: 0.75rem;
      color: var(--muted);
      margin-top: 0.35rem;
    }

    /* Pillar breadcrumb (8/5/26) -- was a bare default-styled <a>, the one
       unstyled link on an otherwise fully-styled page. */
    .pillar-breadcrumb {
      display: inline-flex;
      align-items: center;
      gap: 0.3rem;
      color: var(--muted);
      font-size: 0.85rem;
      font-weight: 600;
      text-decoration: none;
      margin: 0 0 1rem 0;
    }
    .pillar-breadcrumb:hover {
      color: var(--primary);
      text-decoration: underline;
    }

    /* Reassurance box (8/5/26) -- Entering Special Ed's emotional-tone intro
       paragraph was a plain .card, visually identical to a data card
       elsewhere on the page, which made it read as cold rather than warm.
       Reuses the same --primary-light/--accent "supportive content"
       language as .self-advocacy-flashcard and .interactive-tool-card,
       rather than a fourth visual treatment for the same underlying idea
       ("this is meant to feel different from a plain reference card"). */
    .pillar-reassurance-box {
      display: flex;
      gap: 1rem;
      align-items: flex-start;
      background: var(--primary-light);
      border: 1.5px solid var(--accent);
      border-radius: 12px;
      padding: 1.25rem 1.5rem;
      margin-bottom: 0.85rem;
    }
    .pillar-reassurance-box .ti {
      font-size: 1.5rem;
      color: var(--accent);
      flex-shrink: 0;
      margin-top: 0.15rem;
    }
    .pillar-reassurance-box p {
      margin: 0;
      color: var(--primary-text);
    }

    /* Prev/next pillar navigation (8/5/26, Casey's ask -- a way to move
       between pillars without going back to the index every time).
       justify-content: space-between so a lone prev-only or next-only
       link (the first/last pillar) still sits at its natural side rather
       than drifting to center or stacking oddly -- the empty <span> the
       macro renders for the missing side keeps the flex layout balanced
       without needing a conditional flex-direction. */
    .pillar-prev-next-nav {
      display: flex;
      justify-content: space-between;
      gap: 1rem;
      margin: 1.5rem 0;
    }
    .pillar-prev-next-link {
      display: flex;
      flex-direction: column;
      text-decoration: none;
      padding: 0.75rem 1.1rem;
      border: 1px solid var(--border);
      border-radius: 10px;
      background: var(--surface);
      max-width: 48%;
      transition: border-color 0.15s ease, background 0.15s ease;
    }
    .pillar-prev-next-link:hover {
      border-color: var(--primary);
      background: var(--primary-light);
    }
    .pillar-next-link {
      text-align: right;
      margin-left: auto;
    }
    .pillar-prev-next-label {
      font-size: 0.75rem;
      font-weight: 600;
      color: var(--muted);
      text-transform: uppercase;
      letter-spacing: 0.03em;
    }
    .pillar-prev-next-title {
      font-weight: 600;
      color: var(--primary-text);
      margin-top: 0.15rem;
    }

    /* Transition Hub index page (redesigned 8/5/26 after Casey flagged
       the original version rendering as bare, unstyled browser links --
       a real oversight, not a style opinion, since these <a> tags had no
       CSS written for them at all). Each pillar's --pillar-accent reuses
       one of the app's existing --tab-* hub colors (already
       contrast-checked with white text elsewhere in this file) rather
       than introducing new, unverified colors. */
    .transition-hub-grid {
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
      gap: 1rem;
      margin-top: 1rem;
    }

    /* Grade-relevant highlighting (9/19/26, Casey's own follow-up to "is
       this too deep for a normal parent" -- all 4 pillars used to render
       with identical visual weight regardless of the child's real grade,
       so a parent had to read all 4 descriptions to find the one that
       applied to them right now. Only applied when .has-recommendation is
       present (recommended_pillar_for_grade() actually resolved a real
       grade) -- opacity alone de-emphasizes the other 3 without hiding
       them, since a family's real needs can still span pillars (e.g. a
       rising 6th grader finishing up 5th-grade IEP work). box-shadow
       reuses the card's own --pillar-accent rather than a new color. */
    .transition-hub-grid.has-recommendation .transition-hub-pillar-card:not(.is-recommended) {
      opacity: 0.6;
    }
    .transition-hub-grid.has-recommendation .transition-hub-pillar-card.is-recommended {
      box-shadow: 0 0 0 2px var(--pillar-accent);
    }

    /* Pillar-page hero nav (added 8/5/26, Casey's own design direction):
       three big icon boxes at the top of every pillar page, reusing the
       exact .transition-hub-pillar-* language above rather than a fourth
       visual pattern. Forced to 3 columns on wider screens (not
       auto-fit's "as many as fit") since this is always exactly 3 boxes,
       every pillar, every time -- stacks to 1 column under 820px so it
       doesn't get uncomfortably narrow before the app's general mobile
       breakpoint kicks in. */
    /* Sticky, shrinking hero nav (second pass, 8/20/26 -- see this
       function's own comment in app.js, initPillarStickyShrink(), for the
       full history of why a first attempt using a SEPARATE compact nav
       element was replaced with this same-day). `position: sticky` from
       page load (not toggled on/off) is what makes this a smooth shrink
       rather than a swap -- the grid is always tracking toward its docked
       position, and JS only ever adds/removes `.compact`, a class that
       triggers a CSS transition on the SAME elements, never a different
       set of elements crossfading in. `top` is set via inline style at
       runtime by the same JS, NOT hardcoded here -- the lesson already
       learned twice on this exact page (5.5rem, then 3.75rem, both
       guessed and both wrong; a real getBoundingClientRect() measurement
       of the folder-tabs bar replaced both, see CHANGELOG.md 8/5/26).
       z-index sits above ordinary content but below .folder-tabs itself
       (z-index: 10), since it needs to dock directly underneath that bar. */
    .pillar-hero-grid {
      display: grid;
      grid-template-columns: repeat(3, 1fr);
      gap: 1rem;
      margin: 1.25rem 0;
      position: sticky;
      z-index: 9;
      background: var(--paper);
      transition: gap 0.25s ease, padding-bottom 0.25s ease;
    }
    .pillar-hero-grid.compact {
      gap: 0.5rem;
      padding-bottom: 0.5rem;
      border-bottom: 1px solid var(--border);
    }
    @media (max-width: 820px) {
      .pillar-hero-grid { grid-template-columns: 1fr; }
      /* Sticky + shrink is a desktop-scale nicety -- on a stacked single
         column, 3 permanently-docked compact rows would eat real vertical
         space on a small screen, the opposite of what a sticky nav is
         for. Static position here means the grid just scrolls away
         normally, same as any other content; JS's own compact-toggle
         logic still runs but has nothing to dock against once position
         isn't sticky, so this is a pure CSS override, no JS change needed. */
      .pillar-hero-grid { position: static; }
    }
    .transition-hub-pillar-card {
      display: flex;
      flex-direction: column;
      gap: 0.75rem;
      background: var(--surface);
      border: 1px solid var(--border);
      border-top: 4px solid var(--pillar-accent);
      border-radius: 12px;
      padding: 1.25rem 1.5rem 1.5rem;
      text-decoration: none;
      color: inherit;
      transition: border-color 0.15s ease, transform 0.15s ease, padding 0.25s ease;
    }
    .transition-hub-pillar-card:hover {
      border-color: var(--pillar-accent);
      transform: translateY(-2px);
    }
    /* Compact state shrinks padding on the same card rather than swapping
       to a different element -- the actual "smooth shrink" Casey asked
       for, not a crossfade. No hover-lift in compact state (a docked nav
       item lifting on hover reads as a glitch, not a nice touch). */
    .pillar-hero-grid.compact .transition-hub-pillar-card {
      padding: 0.5rem 0.9rem;
      gap: 0.5rem;
    }
    .pillar-hero-grid.compact .transition-hub-pillar-card:hover {
      transform: none;
    }
    .transition-hub-pillar-icon {
      width: 2.75rem;
      height: 2.75rem;
      border-radius: 50%;
      background: var(--pillar-accent);
      color: white;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 1.35rem;
      flex: none;
      transition: width 0.25s ease, height 0.25s ease, font-size 0.25s ease;
    }
    .pillar-hero-grid.compact .transition-hub-pillar-icon {
      width: 1.6rem;
      height: 1.6rem;
      font-size: 0.85rem;
    }
    .transition-hub-pillar-card h2 {
      margin: 0 0 0.35rem 0;
      font-size: 1.15rem;
      color: var(--primary-text);
      transition: font-size 0.25s ease, margin 0.25s ease;
    }
    .pillar-hero-grid.compact .transition-hub-pillar-card h2 {
      margin: 0;
      font-size: 0.85rem;
      white-space: nowrap;
      overflow: hidden;
      text-overflow: ellipsis;
    }
    /* Full label shown normally, short label ("What do I need to do?" not
       "...to be successful?") swapped in only once compact -- the long
       version doesn't fit cleanly at shrunk size even with ellipsis.
       Both spans hold real text always (not injected by JS) so there's
       nothing to keep in sync -- CSS alone decides which one is visible. */
    .pillar-hero-grid:not(.compact) .short-label { display: none; }
    .pillar-hero-grid.compact .full-label { display: none; }
    .transition-hub-pillar-card p {
      margin: 0;
      color: var(--muted);
      font-size: 0.9rem;
      max-height: 6rem;
      overflow: hidden;
      opacity: 1;
      transition: max-height 0.25s ease, opacity 0.15s ease, margin 0.25s ease;
    }
    .pillar-hero-grid.compact .transition-hub-pillar-card p {
      max-height: 0;
      opacity: 0;
    }
    .transition-hub-pillar-open {
      margin-top: auto;
      font-family: var(--font-mono);
      font-size: 0.8rem;
      font-weight: 600;
      color: var(--pillar-accent);
      display: flex;
      align-items: center;
      gap: 0.3rem;
    }
    /* "Transition Tools" directory page (added 8/6/26, Casey's ask -- one
       place listing every interactive tool built across all 4 pillars,
       grouped by pillar). A row list, not a card grid like the pillar
       index above -- 17 tools across 4 groups in full cards would be a
       lot of visual weight; a compact row per tool (icon, title,
       audience tag, one-line description, "Open" affordance) scans much
       faster. Reuses the --pillar-accent custom property already
       established by .transition-hub-pillar-card above, set per-group via
       inline style from each group's own real accent_var, so a tool's row
       automatically matches its home pillar's color without a new
       variable scheme. */
    .transition-tools-list { display: flex; flex-direction: column; gap: 0.6rem; margin-top: 0.75rem; }
    .transition-tools-item {
      display: flex;
      align-items: center;
      gap: 0.85rem;
      padding: 0.85rem 1rem;
      border: 1px solid var(--border);
      border-radius: 10px;
      text-decoration: none;
      color: inherit;
      transition: border-color 0.15s ease, background 0.15s ease;
    }
    .transition-tools-item:hover {
      border-color: var(--pillar-accent);
      background: var(--primary-light);
    }
    .transition-tools-item-icon {
      flex-shrink: 0;
      width: 2.25rem;
      height: 2.25rem;
      border-radius: 50%;
      background: var(--pillar-accent);
      color: white;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 1.05rem;
    }
    .transition-tools-item-text { flex: 1; min-width: 0; }
    .transition-tools-item-text strong { display: inline; margin-right: 0.5rem; }
    .transition-tools-item-text p { margin: 0.25rem 0 0 0; color: var(--muted); font-size: 0.85rem; }
    .transition-tools-item-open {
      flex-shrink: 0;
      font-family: var(--font-mono);
      font-size: 0.8rem;
      font-weight: 600;
      color: var(--pillar-accent);
    }
    @media (max-width: 600px) {
      .transition-tools-item { flex-wrap: wrap; }
      .transition-tools-item-open { display: none; }
    }

    /* Shared hub page header (8/5/26): every top-level hub (Guide,
       Advocacy Center, Your Roadmap, Profile) gets the same
       title+description treatment, themed with that hub's own existing
       --tab-* color -- reuses the exact icon-circle language already
       built for the Transition Hub index cards, rather than a third
       visual pattern for what's conceptually the same idea (a themed
       entry point). Guide's plain-card title/description was the model
       Casey pointed to for page ORDER (title block, then tabs) -- this
       replaces the plain-card LOOK specifically, on Guide and every
       other hub, not just Guide. */
    .hub-page-header {
      display: flex;
      gap: 1.1rem;
      align-items: center;
      background: var(--surface);
      border: 1px solid var(--border);
      border-top: 4px solid var(--hub-accent);
      border-radius: 12px;
      padding: 1.5rem 1.75rem;
      margin-bottom: 1.25rem;
    }
    .hub-page-header-icon {
      flex-shrink: 0;
      width: 3.25rem;
      height: 3.25rem;
      border-radius: 50%;
      background: var(--hub-accent);
      color: white;
      display: flex;
      align-items: center;
      justify-content: center;
      font-size: 1.6rem;
    }
    /* Initials variant (child_detail.html only, added 8/11/26) -- 1-2
       uppercase letters need a smaller size and a different, denser
       typeface than a single icon glyph does to sit comfortably in the
       same circle without crowding its edges. */
    .hub-page-header-icon.initials {
      font-size: 1.15rem;
      font-family: var(--font-display);
      font-weight: 700;
      letter-spacing: 0.02em;
    }
    .hub-page-header-text h1 {
      margin: 0 0 0.35rem 0;
    }
    .hub-page-header-text p {
      margin: 0;
      color: var(--muted);
      font-size: 0.92rem;
      line-height: 1.5;
    }

    /* Folder tabs: the app's signature nav -- one tab per hub (profile,
       transition, careers, resume), color-coded like tabs in a binder so
       a parent learns "green = the child's page, gold = transition" the
       same way they'd learn colored folder tabs. The active tab sits
       raised and full-color; the others recede, tinted and pushed back,
       the way an unopened folder tab looks behind the one you're in. */
    /* position: sticky lives on .scroll-fade-wrap-sticky (the wrapper),
       NOT here, as of 9/20/26 -- see that rule's own comment for why: the
       9/20/26 scroll-fade-hint wrapper (_child_nav.html) put .folder-tabs
       inside a plain <div class="scroll-fade-wrap">, and a sticky
       element's containing block is its nearest block-level ancestor, not
       just its nearest SCROLLING ancestor -- confirmed live (real
       getBoundingClientRect() measurements, not just spec-reading) that
       wrapping a sticky element in an auto-height div whose height
       shrink-wraps exactly to that child leaves no positioning slack for
       it to actually stick, even though getComputedStyle still reports
       `position: sticky` faithfully. The whole top-level nav silently
       stopped sticking on every page in the app the moment that wrapper
       shipped -- caught from a parent's real phone screenshot showing the
       pillar-hero-grid nav (app.js's initPillarStickyShrink(), which docks
       relative to THIS bar's real height) floating with a large dead gap
       above it, because it was docking correctly against a bar that had
       silently stopped sticking to anything. */
    .folder-tabs {
      background: var(--paper);
      display: flex;
      align-items: flex-end;
      gap: 0.3rem;
      flex-wrap: wrap;
      padding: 0.75rem 0 0 0;
      margin-bottom: 0.75rem;
      border-bottom: 3px solid var(--border);
    }
    /* Sticky lives here, not on .folder-tabs itself -- see that rule's own
       comment above for the real bug this fixes. Only the wrapper around
       .folder-tabs gets this modifier class (_child_nav.html) -- the other
       two .scroll-fade-wrap usages (around .sub-tabs) were never sticky in
       the first place, so they're deliberately untouched by this. Reset to
       `position: static` below 600px, same as .folder-tabs used to reset
       itself directly -- a wrapped-then-pinned nav is worse than one that
       scrolls away with the page (see that mobile-fix comment further
       down), and now that .scroll-fade-wrap-sticky is the sticky element,
       IT is what needs the reset, not .folder-tabs (which no longer has
       any position of its own to reset). */
    .scroll-fade-wrap-sticky {
      position: sticky;
      top: 0;
      z-index: 10;
    }
    @media (max-width: 600px) {
      .scroll-fade-wrap-sticky { position: static; }
    }
    .folder-tabs .tab {
      position: relative;
      display: inline-block;
      padding: 0.5rem 1rem 0.6rem;
      border-radius: 10px 10px 0 0;
      font-family: var(--font-mono);
      font-size: 0.8rem;
      font-weight: 600;
      letter-spacing: 0.01em;
      text-decoration: none;
      white-space: nowrap;
      /* Ceiling + ellipsis (8/16/26, cold-start QA smaller items): the
         profile tab renders the child's name + "'s Page", and even with
         the new 100-char server clamp (MAX_CHILD_NAME_CHARS,
         app/text_limits.py) a maximal legal name would otherwise push
         every other tab off the row. 22rem fits every realistic name
         untouched; the full name is always visible on the page's own
         heading. NOTE: no curly braces in this comment on purpose --
         the regression suite matches this rule's body with a
         brace-terminated regex. */
      max-width: 22rem;
      overflow: hidden;
      text-overflow: ellipsis;
      vertical-align: bottom;
      color: white;
      opacity: 0.55;
      transform: translateY(4px);
      transition: opacity 0.15s ease, transform 0.15s ease;
    }
    .folder-tabs .tab:hover { opacity: 0.85; }
    .folder-tabs .tab.active {
      opacity: 1;
      transform: translateY(0);
      margin-bottom: -3px;
      padding-bottom: calc(0.6rem + 3px);
    }
    .folder-tabs .tab-profile { background: var(--tab-profile); }
    .folder-tabs .tab-transition { background: var(--tab-transition); }
    .folder-tabs .tab-careers { background: var(--tab-careers); }
    .folder-tabs .tab-resume { background: var(--tab-resume); }
    .folder-tabs .tab-advocacy { background: var(--tab-advocacy); }
    .folder-tabs .tab-guide { background: var(--tab-guide); }
    /* Child switcher (backlog #7): sits at the far end of the folder-tabs
       row, deliberately quiet/neutral rather than color-coded like the
       hub tabs -- it's a wayfinding control, not another hub. Only ever
       rendered for 2+ children (see _child_nav.html). */
    .folder-tabs .child-switcher {
      margin-left: auto;
      align-self: center;
      display: flex;
      align-items: center;
      gap: 0.4rem;
      color: var(--muted);
    }
    .folder-tabs .child-switcher select {
      font-family: var(--font-mono);
      font-size: 0.8rem;
      font-weight: 600;
      padding: 0.35rem 1.5rem 0.35rem 0.7rem;
      border-radius: 20px;
      border: 1px solid var(--border-strong);
      background: var(--paper);
      color: var(--text);
      cursor: pointer;
    }
    .folder-tabs .child-switcher select:hover { border-color: var(--primary); }
    /* Sub-tabs: second-row nav for tools nested under Your Roadmap
       (Careers, Resume) -- smaller and quieter than the top-level folder
       tabs, tinted with the transition tab's own gold so it reads as
       "still inside the gold folder," not a sibling of it. */
    .sub-tabs {
      display: flex;
      gap: 0.4rem;
      flex-wrap: wrap;
      padding: 0.6rem 0 0.75rem;
      margin-bottom: 0.5rem;
      border-bottom: 1px solid var(--border);
    }
    .sub-tabs a {
      padding: 0.3rem 0.75rem;
      border-radius: 20px;
      font-family: var(--font-mono);
      font-size: 0.78rem;
      font-weight: 600;
      text-decoration: none;
      background: var(--primary-light);
      color: var(--primary-text);
      white-space: nowrap;
    }
    /* Bug fix (Compliance Audit build, 8/1/26): this used to be a single
       unscoped `.sub-tabs a.active` rule hardcoded to --tab-transition, so
       ANY sub-tab nav's active item showed transition's gold regardless of
       which top-level tab it actually belonged to. Caught while adding the
       Compliance Audit sub-tabs -- now scoped per page so each sub-nav uses
       its own tab's color. */
    .sub-tabs-transition a.active { background: var(--tab-transition); color: white; }
    .sub-tabs-advocacy a.active { background: var(--tab-advocacy); color: white; }
    /* Advocacy sub-nav grouping (8/16/26, QA look-and-flow review): the
       Advocacy Center's 11 sub-tabs used to be one flat, equal-weight row
       with no visual hierarchy -- everything scanned the same regardless
       of how related two destinations actually were. Rather than inventing
       a new taxonomy, this reuses the four-card grouping the Overview page
       (advocacy_dashboard.html) already established and Casey already
       signed off on: Concerns / Your Team / Build My Case / Take Action,
       plus Citations and Calendar as their own standalone destinations
       (exactly as Overview treats them -- two single-purpose cards, not
       folded into the four clusters). Deliberately NOT stacked group
       labels or boxed clusters -- both would either force the strip back
       to wrapping multiple rows (undoing the 8/15/26 mobile fix that
       specifically replaced wrapping with a single horizontally-scrolling
       row) or add visual weight to what should stay a lightweight,
       glanceable tab strip. Instead: a plain (non-link, non-interactive)
       divider span between groups, sized and margined to read as a
       paragraph break -- felt through rhythm and spacing rather than
       announcing itself. */
    .sub-tabs-divider {
      flex-shrink: 0;
      align-self: center;
      width: 1px;
      height: 1.1rem;
      background: var(--border);
      margin: 0 0.35rem;
    }
    [data-theme="dark"] .sub-tabs-divider { background: var(--border-strong); }
    /* Mobile nav fix (8/15/26, external audit item 39): below 600px, both
       the folder tabs (top-level: Profile/Transition/Careers/Resume/
       Advocacy/Guide) and the advocacy/transition sub-tabs used to WRAP --
       the folder tabs to 3 ragged rows, still `position: sticky` (so that
       wrapped block stayed pinned and ate screen real estate permanently),
       and the sub-tabs to as many as 5 more rows on top of that. Together
       these could take up roughly a third of a real phone screen before any
       actual page content appeared. Below 600px: drop the sticky
       positioning (a wrapped-then-pinned nav is worse than a nav that
       scrolls away with the page), and switch both from wrap to a single
       horizontally-scrollable row -- the same pattern a phone's own app
       switcher/tab bar uses, and one horizontal row is far more scannable
       than several ragged wrapped ones. Tap targets get a 44px minimum
       height, matching the header fix above. */
    @media (max-width: 600px) {
      .folder-tabs {
        flex-wrap: nowrap;
        overflow-x: auto;
        -webkit-overflow-scrolling: touch;
        scrollbar-width: thin;
      }
      .folder-tabs .tab {
        display: inline-flex;
        align-items: center;
        min-height: 44px;
        flex-shrink: 0;
      }
      /* Child switcher (9/12/26 mobile pass): the "44px minimum" comment
         right above this block covers .folder-tabs .tab, but the switcher
         select sitting at the far end of the same row (families with 2+
         children on IEPs -- a real, regularly-used control, not an edge
         case) was never included -- confirmed live at 37x30px, the same
         undersized class of target the checkbox and theme-toggle fixes
         this same pass already addressed. flex-shrink: 0 for the same
         reason the tabs themselves need it: without it, an overflowing
         flex row silently squeezes every child before scrolling kicks in. */
      .folder-tabs .child-switcher select { min-height: 44px; flex-shrink: 0; }
      .sub-tabs { flex-wrap: nowrap; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: thin; }
      .sub-tabs a {
        display: inline-flex;
        align-items: center;
        min-height: 44px;
        flex-shrink: 0;
      }
      /* Scroll-more hint, take 2 (9/20/26, fresh-eyes mobile walkthrough --
         supersedes the 9/11/26 attempt just below this comment used to
         occupy). That version added a trailing pseudo-element directly on
         the nav itself, with `position: sticky; right: 0`, on the theory
         that sticky would pin a fade gradient to the visible right edge
         throughout the scroll.
         Measured on a real 390px viewport and it never rendered at all on
         first paint: sticky only ever HOLDS an element back from
         scrolling further in one direction once it's already visible --
         it can't pull an element that starts outside the viewport (this
         pseudo-element, being the LAST flex item after 600px of tabs in a
         342px view, starts at roughly x=572, well past the visible 342px)
         into view before any scrolling happens. So the one moment this
         hint most needed to show -- a first-time parent looking at the
         unscrolled tab row -- was exactly the moment it didn't. Replaced
         with a JS-driven version: initScrollFadeHints() (app.js) measures
         each `.scroll-fade-wrap`'s real scrollLeft/scrollWidth/clientWidth
         and toggles `.can-scroll-left`/`.can-scroll-right` on the WRAPPER
         (a plain non-scrolling block around the nav, added in
         _child_nav.html) rather than trying to make the scrolling flex
         container's own pseudo-element defy its own clipping. The wrapper
         approach fixes this reliably because the fade lives outside the
         scrollport entirely -- it's a `position: absolute` overlay on a
         `position: relative` wrapper, not a sticky item competing with
         the same overflow-x: auto that's clipping it. */
      .scroll-fade-wrap { position: relative; }
      .scroll-fade-wrap::before, .scroll-fade-wrap::after {
        content: "";
        position: absolute;
        top: 0;
        bottom: 0;
        width: 28px;
        pointer-events: none;
        opacity: 0;
        transition: opacity 0.15s;
        z-index: 1;
      }
      .scroll-fade-wrap::before {
        left: 0;
        background: linear-gradient(to left, transparent, var(--paper) 20px);
      }
      .scroll-fade-wrap::after {
        right: 0;
        background: linear-gradient(to right, transparent, var(--paper) 20px);
      }
      .scroll-fade-wrap.can-scroll-left::before { opacity: 1; }
      .scroll-fade-wrap.can-scroll-right::after { opacity: 1; }
      /* Resume Builder's "Move up"/"Move down" reorder buttons (9/12/26
         mobile pass): icon-only, 31x25px, repeated twice per resume entry
         (11 entries in the fresh-eyes test fixture = 22 of these on one
         page) -- confirmed live via getBoundingClientRect(). Worse than a
         typical undersized target: reordering is inherently a REPEATED
         precision tap (moving an item several spots means tapping the same
         tiny button several times in a row), and "Move up"/"Move down" sit
         directly next to each other with only an 8px gap, next to a third
         "Edit" toggle right after -- exactly the setup where an undersized
         target causes a mis-tap, not just a slow one. */
      .quiet-action-link-reorder { min-height: 44px; min-width: 44px; justify-content: center; }
      /* Team & Contacts' "Copy email"/"Copy phone number" buttons (found
         during a full mobile walkthrough, 9/12/26): icon-only, 30x19px,
         two per contact -- exactly the page a parent is most likely to
         actually use ON their phone, right before calling or emailing
         someone on the IEP team. */
      .copy-btn { min-height: 44px; min-width: 44px; justify-content: center; }
    }
    /* Phone tab bar for the four top-level hubs (9/23/26, first-time-parent
       walkthrough, phone view -- see _child_nav.html's own comment). Kept
       as its own block AFTER the 8/15/26 mobile block above on purpose: it
       overrides that block's nowrap + overflow-x only for .folder-tabs,
       leaving .sub-tabs (8 and 13 entries -- genuinely too many to fit) on
       the scroll-fade pattern. Measured before: at 375px, Advocacy Center
       and Guide started fully off-screen. Now all four share the row: icon
       above a short label, like a phone's own tab bar, each still 44px+
       tall. The child switcher (2+ children) moves to its own right-aligned
       line above the tabs instead of sitting at the far, off-screen end of
       the strip. */
    .folder-tabs .tab-label-short { display: none; }
    @media (max-width: 600px) {
      .folder-tabs {
        flex-wrap: wrap;
        overflow-x: visible;
        gap: 0.25rem;
      }
      .folder-tabs .tab {
        flex: 1 1 0;
        min-width: 0;
        max-width: none;
        flex-direction: column;
        justify-content: center;
        gap: 0.15rem;
        padding: 0.45rem 0.2rem 0.5rem;
        font-size: 0.7rem;
        text-align: center;
      }
      .folder-tabs .tab.active { padding-bottom: calc(0.5rem + 3px); }
      .folder-tabs .tab .ti { font-size: 1.15rem; }
      .folder-tabs .tab-label-long { display: none; }
      .folder-tabs .tab-label-short {
        display: block;
        max-width: 100%;
        overflow: hidden;
        text-overflow: ellipsis;
      }
      .folder-tabs .child-switcher {
        order: -1;
        flex: 0 0 100%;
        justify-content: flex-end;
        margin: 0 0 0.35rem;
      }
      /* Sized to its content, not the global phone-width form-control
         rule -- at 100% it pushed its own switch icon off the left edge. */
      .folder-tabs .child-switcher select { width: auto; max-width: 75%; }
    }
    /* Section groups: each major area of the page (Timeline, Documents,
       etc.) is a native <details> so a long page reads as a few
       collapsible sections instead of one undifferentiated stack of
       cards -- same no-JS pattern the add-forms already use, just at the
       page-section scale. Jumping to one via the in-page nav (#documents
       etc.) opens it automatically -- see the small script at the end of
       this file.

       All of them sit inside one continuous white .section-list frame --
       bordering each other with a divider between, rounded corners only
       at the very top and bottom -- rather than each being its own
       separate floating card with a gap. */
    .section-list {
      background: var(--surface);
      border: 1px solid var(--border);
      border-radius: 12px;
      overflow: hidden;
    }
    details.section-group {
      margin: 0;
      border-bottom: 1px solid var(--border);
      scroll-margin-top: 5.5rem; /* keeps the sticky folder-tabs from covering the heading when jumped to */
    }
    .section-list details.section-group:last-child { border-bottom: none; }
    details.section-group > .section-header {
      display: flex;
      align-items: center;
      flex-wrap: wrap;
      gap: 0.5rem;
      font-size: 1.05rem;
      font-weight: 700;
      font-family: var(--font-display);
      color: var(--primary-text);
      margin: 0;
      padding: 1rem 1.5rem;
      cursor: pointer;
      list-style: none;
      transition: background-color 0.1s ease, box-shadow 0.1s ease;
      box-shadow: inset 3px 0 0 transparent;
    }
    details.section-group > .section-header:hover {
      background: var(--primary-light);
      box-shadow: inset 3px 0 0 var(--border-strong);
    }
    details.section-group[open] > .section-header {
      border-bottom: 2px solid var(--primary-light);
    }
    /* First piece of content right after the header gets breathing room
       below the divider; every direct-child card/year-group gets inset
       side margins so it reads as content framed within the row, not
       flush with the shared container edge. */
    details.section-group > .section-header + * { margin-top: 1.25rem; }
    details.section-group > .card,
    details.section-group > .year-group {
      margin-left: 1.5rem;
      margin-right: 1.5rem;
    }
    details.section-group > .year-group:last-child { margin-bottom: 1.5rem; }
    /* The content wrapper each section already puts its body in is a
       nested .card -- against the new white section-group frame that
       would be a white box inside an identical white box, so give it a
       faint paper tint instead (covers cards one level down, and cards
       nested inside a year-group subgroup one level further, e.g. inside
       the Goals/Services/Accommodations year-groups on this page). This
       also reinforces the "pages inside a folder"
       metaphor the rest of the app already leans on. */
    details.section-group .card {
      background: var(--paper);
    }
    details.section-group > .section-header::-webkit-details-marker { display: none; }
    details.section-group > .section-header::before {
      content: "▸";
      display: inline-block;
      color: var(--accent);
      transition: transform 0.15s ease;
    }
    details.section-group[open] > .section-header::before { transform: rotate(90deg); }
    /* Tabler icons used in nav links, section headers, folder tabs, and
       the year-group sub-summaries -- sized to sit on the text baseline
       rather than floating above/below it. */
    .section-header .ti, .year-group > summary .ti, .quick-nav a .ti,
    .folder-tabs .tab .ti, .sub-tabs a .ti, .nav-links a .ti {
      font-size: 1.05em;
      vertical-align: -0.1em;
    }
    /* Collapsible add-forms: keeps rarely-touched data-entry forms out of
       the way of the actual information, without needing any JS. */
    details.add-form { margin-top: 1rem; }
    details.add-form summary {
      cursor: pointer;
      color: var(--primary-text);
      font-weight: 600;
      font-size: 0.9rem;
      padding: 0.5rem 0;
      list-style: none;
    }
    details.add-form summary::-webkit-details-marker { display: none; }
    details.add-form summary::before { content: "+ "; }
    details.add-form[open] summary::before { content: "\2212 "; }
    details.add-form form { margin-top: 0.5rem; }
    /* Archiving older school years out of the way (documents, meeting
       notes): same collapsible mechanism as .add-form above, but without
       its "+ "/"− " prefix, which reads oddly in front of a year label
       rather than an action like "Add a goal". */
    details.year-group {
      margin-top: 0.75rem;
      border: 1px solid var(--border);
      border-radius: 6px;
      padding: 0 0.75rem;
      overflow: hidden;
    }
    /* Visual polish (8/10/26, one of 3 app-wide ideas scoped after Casey
       asked what could visually improve the whole app) -- was a bare,
       black-text summary with no color/weight at all, a real step down
       from the deadline timeline's own polish. Deliberately does NOT
       reuse the timeline's urgency colors (red/amber/green) here --
       checked what these groups actually represent first, and it's
       school years, which have no "urgent vs. fine" meaning to
       borrow; coloring one year red and another green would imply a
       problem that isn't there. Same LEVEL of visual care instead,
       without the mismatched meaning: a single consistent tinted
       background (reusing --primary-light, the same treatment already
       used elsewhere for this app's own branding, not a new color
       introduced just for this) plus a real icon.
       margin: 0 -0.75rem cancels out the parent's own horizontal
       padding just for the summary itself, so the tinted background can
       span edge-to-edge -- deliberately NOT changing the parent's
       padding itself, which would have shifted every OTHER piece of
       nested content's spacing (documents, goal items, service items,
       etc. all still get their original inset). overflow: hidden on the
       parent clips the tint to the rounded corners, same technique
       details.bucket already uses elsewhere in this file for exactly
       this reason. */
    details.year-group summary {
      cursor: pointer;
      font-weight: 600;
      padding: 0.6rem 0.75rem;
      margin: 0 -0.75rem;
      list-style: none;
      background: var(--primary-light);
      color: var(--primary-text);
      display: flex;
      align-items: center;
      gap: 0.5rem;
    }
    details.year-group summary::-webkit-details-marker { display: none; }
    details.year-group[open] summary { border-bottom: 1px solid var(--border); }
    details.year-group > *:not(summary) { margin-top: 0.75rem; }
    details.year-group > *:last-child { margin-bottom: 0.75rem; }
    /* Print support: used by the resume builder so "Print to PDF" from the
       browser produces a clean document -- no header/nav, no forms/buttons
       (anything the parent shouldn't need once they're just printing),
       full-width content, no card borders/shadows. Add class="no-print" to
       any element that shouldn't appear in the printed output. */
    @media print {
      /* Printing should always be a light, ink-friendly document regardless
         of which theme is active on screen -- otherwise dark mode's light
         text colors would render nearly invisible on white printed paper.
         Confirmed this was a real bug (not just theoretical) via Playwright
         print-media emulation with data-theme="dark" set: body background
         correctly reset to white, but text stayed the pale dark-mode color.
         Redeclaring the full light-mode variable set here wins over
         [data-theme="dark"]'s declarations at equal specificity, since this
         block comes later in the stylesheet and only applies under print.
         Rebrand (8/19/26): this block was missed in the original rebrand
         session -- still had the pre-rebrand green/gold/plum/brown values
         until caught here while doing the hub-palette redesign. A parent
         printing their Evidence Binder, resume, or IEP summary would have
         gotten the OLD colors on paper indefinitely, out of sync with
         everything on screen, since print output is the one place this
         :root block's own values win regardless of what's live elsewhere
         in the file. */
      :root {
        --paper: #F6F3EC;
        --surface: #FFFFFF;
        --text: #2A2E29;
        --muted: #6E6A5C;
        --border: #DDD7C9;
        --border-strong: #C7BCA0;
        --muted-light: #EDEAE0;
        --primary: #182D62;
        --primary-text: #182D62;
        --primary-light: #E0E4EE;
        --accent: #96650F;
        --accent-light: #F3E7D2;
        --info: #3B6E8C;
        --info-light: #DEE9EF;
        --danger: #962C34;
        --danger-bg: #EADBD6;
        --warning: #A8791F;
        --warning-bg: #fdf1de;
        --pwn: #5B294B;
        --pwn-bg: #F3E4EA;
        --plan504: #2D5D32;
        --plan504-bg: #E1EADD;
        --month: #7A4530;
        --month-bg: #F0E4D9;
      }
      header, form, .no-print { display: none !important; }
      body { background: white; }
      main { max-width: 100%; margin: 0; padding: 0; }
      .card { border: none; padding: 0; margin-bottom: 1rem; background: none !important; }

      /* Content-bearing collapsibles are forced open via the beforeprint JS
         handler above; add-forms (data entry, not content) are hidden
         outright instead -- printing an empty "Add a progress note" form
         has no reason to exist on paper. */
      details.add-form { display: none !important; }
      /* A resume section with nothing added to it yet (Casey's report,
         9/4/26: "that section doesn't need to be on the resume") --
         everything INSIDE an empty section (the empty-state message, the
         add-form) is already no-print/a form and hidden by the rules
         above, but the section's own <h2> and card wrapper weren't,
         leaving a bare heading with nothing under it on the printed
         page. Only applies to a section with zero entries -- one WITH
         entries keeps rendering normally regardless of this rule, same
         as every other .resume-section. */
      .resume-section-empty { display: none !important; }
      /* The custom ▸ / +− disclosure markers only mean something when the
         page is interactive; on paper every section is already "open", so
         the arrow icon is just visual noise next to every heading. */
      details.section-group > .section-header::before,
      details.year-group summary::before { content: none !important; }
      details.section-group > .section-header,
      details.year-group summary { cursor: default; }

      /* Controls that only make sense on a live page -- a filter dropdown,
         an ephemeral "item deleted, undo?" banner, a row of action buttons
         (edit/export/print controls, both current uses of .button-row) --
         have nothing to do on a printed record. */
      .filter-row, .undo-banner, .button-row { display: none !important; }

      /* Ink-friendly: badges/tags/callouts lean on background color to
         carry status (overdue red, upcoming amber, etc.), which either
         wastes toner on a color printer or disappears into pale gray on a
         B&W one -- fall back to a border + bold text so the distinction
         survives without depending on color reproduction. */
      .badge, .tag, .callout-warning, .callout-success, .disclaimer {
        background: none !important;
        border: 1px solid currentColor;
      }
      .badge.overdue, .badge.upcoming { font-weight: 700; }
      .child-list-item, .snapshot-doc-card { background: none !important; }

      /* Avoid a card/row getting visually sliced across a page break. */
      .card, .child-list-item, .snapshot-doc-card { break-inside: avoid; }

      /* Internal app links (download/view/jump-to-section) are meaningless
         on paper -- no href is shown, there's nothing to click -- so they
         should read as plain text, not blue-and-underlined. */
      a { color: inherit; text-decoration: none; }
    }
    /* Resume look/format themes -- selected on the resume builder page,
       applied only within .resume-content so the rest of the app (nav,
       forms, other pages) is unaffected. Each theme has its own signature
       structural device, not just a palette swap. */

    /* Classic: traditional letterpress-resume convention -- centered name,
       a thick-then-thin double rule beneath it, small-caps section labels.
       Ink-on-paper, minimal color, built for formal applications. */
    .resume-classic { font-family: Georgia, 'Times New Roman', serif; color: #1a1a1a; }
    .resume-classic .resume-header { text-align: center; }
    .resume-classic .resume-header h1 {
      font-size: 1.9rem; letter-spacing: 0.06em; text-transform: uppercase; margin-bottom: 0.6rem;
      border-bottom: 3px solid #1a1a1a; padding-bottom: 0.6rem; position: relative;
    }
    .resume-classic .resume-header h1::after {
      content: ""; display: block; width: 100%; border-bottom: 1px solid #1a1a1a; margin-top: 3px;
    }
    .resume-classic .resume-objective { font-style: italic; color: #333; }
    .resume-classic .card { border-radius: 0; box-shadow: none; }
    .resume-classic h2 {
      color: #1a1a1a; text-transform: uppercase; font-size: 0.9rem; letter-spacing: 0.12em;
      border-bottom: 1px solid #1a1a1a; padding-bottom: 0.35rem; font-weight: 700;
    }
    .resume-classic .resume-entry-title { font-size: 1.02rem; }
    .resume-classic .resume-entry-meta { font-style: italic; }

    /* Modern: a bold colored "spine" running down the header and echoed as
       a left border on every entry -- one confident structural signature
       rather than scattered color. Left-aligned, strong weight contrast. */
    .resume-modern .resume-header {
      border-left: 6px solid var(--primary); padding-left: 1.25rem; margin-left: -0.25rem;
    }
    .resume-modern .resume-header h1 { color: var(--primary); font-size: 2.1rem; font-weight: 800; letter-spacing: -0.01em; }
    .resume-modern .resume-objective { font-size: 1.05rem; color: var(--text); }
    .resume-modern h2 {
      text-transform: uppercase; letter-spacing: 0.08em; font-size: 0.95rem; font-weight: 800;
      border-bottom: 3px solid var(--primary-light); padding-bottom: 0.4rem; display: inline-block;
    }
    .resume-modern .resume-entry { border-left: 3px solid var(--primary-light); padding-left: 0.9rem; margin-bottom: 0.5rem; }
    .resume-modern .resume-entry-title { font-weight: 700; }

    /* Compact: utilitarian and dense -- section labels run into a rule that
       fills the remaining width, tight spacing throughout. Built for a
       student with a lot to list who wants it to fit on one page. */
    .resume-compact { font-size: 0.92rem; }
    .resume-compact .card { padding: 1rem 1.25rem; margin-bottom: 0.75rem; }
    .resume-compact .resume-header h1 { font-size: 1.4rem; margin-bottom: 0.2rem; }
    .resume-compact .resume-objective { font-size: 0.9rem; margin: 0.2rem 0; }
    .resume-compact h2 {
      display: flex; align-items: center; gap: 0.6rem; font-size: 0.85rem;
      text-transform: uppercase; letter-spacing: 0.06em; white-space: nowrap;
    }
    .resume-compact h2::after { content: ""; flex: 1; border-bottom: 1px solid var(--border); }
    .resume-compact .resume-entry { padding: 0.4rem 0; }
    .resume-compact .resume-entry-desc { font-size: 0.88rem; margin-top: 0.15rem !important; }

    /* Friendly: approachable first-resume-ever warmth -- a soft rounded
       color band behind the name, pill-shaped section labels. Still
       professional, just less intimidating for a teenager's first draft. */
    .resume-friendly .resume-header {
      background: var(--primary-light); border-radius: 16px; padding: 1.25rem 1.5rem; border: none;
    }
    .resume-friendly .resume-header h1 { color: var(--primary); font-size: 1.9rem; }
    .resume-friendly .card { border-radius: 16px; }
    .resume-friendly h2 {
      display: inline-block; background: var(--primary); color: white; border-radius: 999px;
      padding: 0.3rem 1rem; font-size: 0.85rem; letter-spacing: 0.03em;
    }
    .resume-friendly .resume-entry {
      background: var(--primary-light); border-radius: 10px; padding: 0.75rem 1rem; margin-bottom: 0.6rem;
    }

    /* Simple / Large Print: accessibility as the design language itself --
       oversized everything, pure black-on-white, structure carried by
       thick borders rather than color, so nothing depends on color vision
       or small print to be understood. */
    .resume-large_print { font-size: 1.25rem; line-height: 1.8; }
    .resume-large_print .resume-header h1 { font-size: 2.1rem; }
    .resume-large_print h2 { font-size: 1.4rem; color: #000; font-weight: 800; }
    .resume-large_print .card { background: #fff; border: 3px solid #000; border-radius: 4px; }
    .resume-large_print .resume-entry { border-bottom: 2px solid #000; padding-bottom: 0.75rem; margin-bottom: 0.75rem; }
    .resume-large_print .resume-entry:last-child { border-bottom: none; }

    /* Bold and Sunset (added 9/4/26, Casey's ask for "a couple options
       with a little color in them") -- deliberately their OWN hues
       (teal/terracotta), not --primary/--accent: those two are IEP
       Navigator's own brand colors, and re-skinning Modern/Friendly in
       the app's own palette wouldn't give a student a genuinely different
       option, just a re-tinted one. Both hues are WCAG-verified for
       white text at this exact shade (contrast-checked by hand against
       white: teal ~5.5:1, terracotta ~5.2:1, both clear AA passes for
       normal-size text) since a resume theme is exactly the kind of
       thing nobody re-checks contrast on after picking a color that
       "looks fine." */

    /* Bold: a solid full-bleed color header band (name in white,
       centered) -- the one theme whose signature device is a SATURATED
       fill rather than a light tint, unlike Friendly's soft band below
       it. Section headers and entry accents reuse the same hue so the
       color reads as one deliberate choice, not a rainbow. */
    .resume-bold { --resume-bold-color: #0f766e; }
    .resume-bold .resume-header {
      background: var(--resume-bold-color); border-radius: 0; border: none;
      padding: 1.5rem 1.75rem; text-align: center; margin: -1px -1px 0 -1px;
    }
    .resume-bold .resume-header h1 { color: #fff; font-size: 2rem; }
    .resume-bold .resume-objective { color: #fff; opacity: 0.92; }
    .resume-bold .resume-contact-line { color: #fff; opacity: 0.85; }
    .resume-bold h2 {
      color: var(--resume-bold-color); font-weight: 800; font-size: 1rem;
      text-transform: uppercase; letter-spacing: 0.04em;
      border-bottom: 2px solid var(--resume-bold-color); padding-bottom: 0.3rem;
    }
    .resume-bold .resume-entry { border-left: 3px solid var(--resume-bold-color); padding-left: 0.9rem; margin-bottom: 0.5rem; }
    .resume-bold .resume-entry-title { font-weight: 700; }

    /* Sunset: warm terracotta, signaled by a double accent bar under the
       name (echoes Classic's double-rule convention, but in color) and
       soft peach entry cards -- a genuinely different structural device
       and a genuinely different hue from Friendly, not a re-tint of it. */
    .resume-sunset { --resume-sunset-color: #c2410c; }
    .resume-sunset .resume-header { text-align: center; border: none; }
    .resume-sunset .resume-header h1 {
      color: var(--resume-sunset-color); font-size: 2rem; margin-bottom: 0.5rem;
    }
    .resume-sunset .resume-header h1::after {
      content: ""; display: block; width: 90px; margin: 0.5rem auto 0 auto;
      border-bottom: 4px solid var(--resume-sunset-color); border-radius: 2px;
    }
    .resume-sunset .resume-objective { color: var(--resume-sunset-color); font-style: italic; }
    .resume-sunset h2 {
      display: inline-block; color: var(--resume-sunset-color); font-weight: 700;
      font-size: 0.95rem; border-bottom: 2px solid #fed7aa; padding-bottom: 0.3rem;
    }
    .resume-sunset .resume-header { background: #fff; }
    .resume-sunset .resume-section { background: #fff7ed; border-color: #fed7aa; }
    .resume-sunset .resume-entry { background: #fff; border-radius: 10px; padding: 0.75rem 1rem; margin-bottom: 0.6rem; border: 1px solid #fed7aa; }
    .resume-sunset .resume-entry-meta { color: var(--resume-sunset-color); opacity: 0.8; }

    /* Small header contact line (email/phone, 9/4/26) -- deliberately
       plain/muted, not styled per-theme like the rest of .resume-header:
       every theme already has its own header treatment above, and this is
       meant to read as a quiet detail under the name in all of them, not
       another thing each theme has to restyle. */
    .resume-contact-line { color: var(--muted); font-size: 0.85rem; margin: 0.1rem 0 0.5rem 0; }

    /* "Shrink the text if needed" (9/4/26) -- deliberately independent of
       the theme rules above (RESUME_TEXT_SIZES in app/routers/resume.py),
       so a parent can keep whichever "Look" they picked and still fit a
       long resume on one printed page. Uses CSS `zoom` rather than
       converting every theme rule above from rem to a scalable unit: zoom
       scales the whole subtree (including things set in rem, which an
       outer font-size override can't touch) and is honored by print in
       every Chromium-based browser, which covers this app's actual
       testers -- see CLAUDE.md's browser-tooling notes. Known gap: zoom
       has patchy non-Chromium support (older Firefox/Safari), where this
       silently does nothing rather than erroring -- an acceptable
       degrade for a "nice to have" density control, not something to
       block on cross-browser perfection for.  */
    .resume-text-compact { zoom: 0.9; }
    .resume-text-small { zoom: 0.8; }

    /* Visual QoL (medium batch): resume theme thumbnail picker, replacing
       the plain <select> so a parent/student can see roughly what each
       "look" does before picking it, instead of picking blind by name
       alone. Each thumbnail is a small abstract mockup (bars standing in
       for the name/section/body text) styled with that theme's actual
       signature device from the .resume-* rules above -- not a real
       rendered mini-resume (too heavy for 5 tiny previews), but built from
       the same visual language so it's a fair preview, not a guess. */
    .theme-thumb-grid { display: flex; gap: 0.6rem; flex-wrap: wrap; }
    .theme-thumb {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 0.3rem;
      background: none;
      border: 2px solid var(--border);
      border-radius: 10px;
      padding: 0.45rem 0.45rem 0.5rem;
      cursor: pointer;
      width: 78px;
      font-family: inherit;
      color: var(--text);
    }
    .theme-thumb:hover { border-color: var(--border-strong); }
    .theme-thumb.selected { border-color: var(--accent); box-shadow: 0 0 0 2px var(--accent-light); }
    .theme-thumb-label {
      font-size: 0.68rem;
      color: var(--muted);
      text-align: center;
      line-height: 1.2;
    }
    .theme-thumb.selected .theme-thumb-label { color: var(--accent); font-weight: 600; }
    .theme-thumb.selected .theme-thumb-label .ti-check { font-size: 0.7rem; }
    .theme-thumb-preview {
      width: 60px;
      height: 74px;
      background: #fff;
      border: 1px solid var(--border);
      border-radius: 4px;
      display: flex;
      flex-direction: column;
      align-items: center;
      padding: 7px 6px;
      box-sizing: border-box;
      gap: 3px;
      overflow: hidden;
    }
    .theme-thumb-preview .thumb-name { display: block; height: 5px; width: 60%; background: var(--text); border-radius: 1px; }
    .theme-thumb-preview .thumb-section { display: block; height: 4px; width: 38%; background: var(--muted); border-radius: 1px; margin-top: 4px; }
    .theme-thumb-preview .thumb-line { display: block; height: 2.5px; width: 90%; background: var(--border-strong); border-radius: 1px; margin-top: 2px; }
    .theme-thumb-preview .thumb-line.short { width: 60%; }

    /* Classic thumb: centered, black-on-white, double-rule under the name --
       mirrors .resume-classic's centered small-caps convention above. */
    .theme-thumb-preview.theme-thumb-classic { align-items: center; }
    .theme-thumb-preview.theme-thumb-classic .thumb-name {
      background: #1a1a1a; border-bottom: 1px solid #1a1a1a; padding-bottom: 3px; width: 70%;
    }
    .theme-thumb-preview.theme-thumb-classic .thumb-section { background: #1a1a1a; }

    /* Modern thumb: colored left spine + primary-colored name, left-aligned --
       mirrors .resume-modern's border-left header treatment above. */
    .theme-thumb-preview.theme-thumb-modern {
      align-items: flex-start; border-left: 3px solid var(--primary); padding-left: 6px;
    }
    .theme-thumb-preview.theme-thumb-modern .thumb-name { background: var(--primary); }
    .theme-thumb-preview.theme-thumb-modern .thumb-section { background: var(--primary-light); border-bottom: 2px solid var(--primary-light); padding-bottom: 2px; }

    /* Compact thumb: tighter gaps and thinner bars -- mirrors
       .resume-compact's dense spacing above. */
    .theme-thumb-preview.theme-thumb-compact { gap: 1.5px; padding: 6px; }
    .theme-thumb-preview.theme-thumb-compact .thumb-name { height: 4px; }
    .theme-thumb-preview.theme-thumb-compact .thumb-line { height: 2px; margin-top: 1.5px; }

    /* Friendly thumb: rounded pill header band + pill section label --
       mirrors .resume-friendly's rounded-color treatment above. */
    .theme-thumb-preview.theme-thumb-friendly { align-items: center; }
    .theme-thumb-preview.theme-thumb-friendly .thumb-name {
      background: var(--primary-light); border-radius: 6px; padding: 3px 8px; height: 6px; box-sizing: content-box; width: auto;
    }
    .theme-thumb-preview.theme-thumb-friendly .thumb-section {
      background: var(--primary); border-radius: 999px; height: 5px; width: 32%;
    }

    /* Large Print thumb: thick black border + bold oversized bars --
       mirrors .resume-large_print's high-contrast accessibility-first
       treatment above (deliberately the one thumb that ignores the app's
       own color palette, same as the real theme does). */
    .theme-thumb-preview.theme-thumb-large_print { border: 2px solid #000; }
    .theme-thumb-preview.theme-thumb-large_print .thumb-name { background: #000; height: 7px; }
    .theme-thumb-preview.theme-thumb-large_print .thumb-section { background: #000; height: 5px; }
    .theme-thumb-preview.theme-thumb-large_print .thumb-line { background: #333; height: 3.5px; }

    /* Bold thumb: solid-fill colored band -- mirrors .resume-bold's
       saturated full-bleed header treatment above. */
    .theme-thumb-preview.theme-thumb-bold {
      align-items: center; background: #0f766e; padding-top: 8px;
    }
    .theme-thumb-preview.theme-thumb-bold .thumb-name { background: #fff; width: 55%; }
    .theme-thumb-preview.theme-thumb-bold .thumb-section { background: #0f766e; margin-top: 6px; }
    .theme-thumb-preview.theme-thumb-bold .thumb-line { background: var(--border-strong); }

    /* Sunset thumb: warm double accent bar under the name -- mirrors
       .resume-sunset's colored double-rule signature above. */
    .theme-thumb-preview.theme-thumb-sunset { align-items: center; }
    .theme-thumb-preview.theme-thumb-sunset .thumb-name { background: #c2410c; width: 55%; }
    .theme-thumb-preview.theme-thumb-sunset .thumb-section {
      background: none; border-bottom: 3px solid #fdba74; width: 30%; height: 0; margin-top: 5px;
    }

    /* Shown on buttons/links that trigger a slow AI call (translate,
       auto-fill, need-to-know extraction, resume polish, career plan
       generation) -- see attachSlowActionHandlers() below. Gives immediate
       feedback that something is happening, since these can take up to
       30 seconds with nothing else on the page changing in the meantime.
       Deliberately uses `currentColor` rather than a fixed color: every
       current trigger is a `.btn`/`<button>` (white text on a pine-green or
       gold background), so this already renders on-palette rather than a
       generic browser-default gray -- confirmed during the visual QoL pass
       (7/31/26). Keep it `currentColor`-based rather than hardcoding white:
       that's what makes it automatically correct if this is ever used on a
       differently-colored element later, and what would make it adapt
       correctly under a future dark-mode pass too. */
    .slow-action-spinner {
      display: inline-block;
      width: 0.9em;
      height: 0.9em;
      border: 2px solid currentColor;
      border-right-color: transparent;
      border-radius: 50%;
      animation: slow-action-spin 0.75s linear infinite;
      margin-right: 0.5rem;
      vertical-align: -0.15em;
    }
    @keyframes slow-action-spin {
      to { transform: rotate(360deg); }
    }
    /* Inline, tap-to-define jargon (backlog #17). Deliberately subtle --
       a dotted underline, not a colored/bold treatment that would compete
       with real emphasis (bold facts, tags) already on the same page.
       cursor:help signals "hover for more" on desktop; tabIndex + keydown
       handling (see the JS above) makes it keyboard-reachable too. */
    .jargon-term {
      border-bottom: 1px dotted var(--muted);
      cursor: help;
    }
    .jargon-term:hover, .jargon-term:focus {
      border-bottom-color: var(--primary-text);
    }
    /* item 43: the old rule set outline:none here with nothing to replace
       it -- that made keyboard-only navigation invisible on every jargon
       term (a link-styled, click/keyboard-activatable span), since the
       dotted-underline color change alone isn't enough of a signal when
       tabbing through a page. Keep a real focus ring, just themed to match
       the rest of the app instead of the browser default. */
    .jargon-term:focus-visible {
      outline: 2px solid var(--primary-text);
      outline-offset: 2px;
    }
    .jargon-popover {
      position: absolute;
      z-index: 60;
      display: none;
      max-width: 260px;
      background: var(--surface);
      border: 1px solid var(--border-strong);
      border-radius: 8px;
      padding: 0.65rem 0.85rem;
      font-size: 0.82rem;
      line-height: 1.4;
      box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
    }
    .jargon-popover.is-open { display: block; }
    .jargon-popover strong { color: var(--primary-text); font-family: var(--font-display); }
    .jargon-popover p { margin: 0.35rem 0 0; color: var(--text); }
    @media print {
      .jargon-term { border-bottom: none; }
      .jargon-popover { display: none !important; }
    }

/* Plans page free-vs-paid columns (8/16/26, QA look-and-flow review: the
   /upgrade page used to show a paying-curious parent TWO BARE PRICE CARDS
   with no itemization anywhere of what the paid tier actually unlocks --
   and a trial user saw only "you currently have full access," with no
   picture of what changes at expiry. These two columns are that picture,
   shown to everyone: free users deciding, trial users understanding what
   Oct-15-style expiry actually changes, and paid users confirming what
   they're paying for. Real classes rather than the landing page's inline
   table styles -- this is app chrome now, not one-off marketing markup.) */
.plans-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  gap: 1rem;
  margin: 1.25rem 0;
  align-items: start;
}
.plans-col {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 1.25rem 1.5rem;
}
/* The paid column gets the same "this one's the accent" treatment the
   transition-hub pillar cards use: a colored top border, nothing louder. */
.plans-col-paid { border-top: 4px solid var(--accent); }
.plans-col-free { border-top: 4px solid var(--primary); }
.plans-col h2 { margin-top: 0; font-size: 1.15rem; }
.plans-col-tagline {
  color: var(--muted);
  font-size: 0.85rem;
  margin-top: -0.4rem;
}
.plans-col ul {
  list-style: none;
  padding: 0;
  margin: 0.75rem 0 0;
}
.plans-col li {
  padding: 0.45rem 0 0.45rem 1.6rem;
  position: relative;
  font-size: 0.92rem;
  border-bottom: 1px solid var(--muted-light);
}
.plans-col li:last-child { border-bottom: none; }
.plans-col li > i.ti {
  position: absolute;
  left: 0;
  top: 0.55rem;
}
.plans-col-free li > i.ti { color: var(--primary-text); }
.plans-col-paid li > i.ti { color: var(--accent); }
.plans-col li .plans-li-note {
  display: block;
  color: var(--muted);
  font-size: 0.8rem;
}
/* Low-pressure lead-in above the price cards for parents on an invited
   trial (8/16/26, QA batch: public pricing -- trial users used to see no
   prices at all until expiry). Muted on purpose: it names the option, it
   doesn't sell it. */
.plans-trial-pricing-note {
  color: var(--muted);
  font-size: 0.9rem;
  margin-bottom: 0.25rem;
}
.plans-price-row {
  display: flex;
  gap: 1rem;
  flex-wrap: wrap;
  margin: 1rem 0;
}
.plans-price-card {
  flex: 1;
  min-width: 200px;
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 1rem 1.25rem;
  text-align: center;
}
.plans-price-card .plans-price {
  font-size: 1.6rem;
  font-weight: bold;
  font-family: var(--font-display);
}
.plans-price-card .plans-price-unit {
  font-size: 0.9rem;
  font-weight: normal;
  color: var(--muted);
}
/* "Best value" chip on the annual card -- mono like every other structural
   tag in this app, accent-tinted rather than a new hue. */
.plans-best-value {
  display: inline-block;
  background: var(--accent-light);
  color: var(--accent);
  font-family: var(--font-mono);
  font-size: 0.7rem;
  font-weight: 700;
  padding: 0.15rem 0.6rem;
  border-radius: 999px;
  margin-bottom: 0.4rem;
}
