/* ============================================================
   CULTURILY — GLOBAL DESIGN SYSTEM
   Design tokens · Reset · Typography · Utilities · Animations
   ============================================================ */

/* ── Vendored fonts (local — no CDN) ─────────────────────────
   §ED430 · Jake's FINAL ruling: fact titles and vault questions are Fraunces
   400; all body text is Hanken Grotesk 400. Cormorant Garamond and Montserrat
   are retired — their @font-face blocks are gone and no rule names them.
   Governing principle §ED398: "Nohemi is Culturily speaking, Hanken is the
   reader's world." The card interiors were set in the body face in the
   prototype BECAUSE they are previews of the app; this makes the app match.

   BOTH FACES ARE SPLIT BY unicode-range, AND THAT SPLIT IS LOAD-BEARING.
   Measured this session with fontTools: the previously shipped
   montserrat.woff2 (232 cps) and cormorant-garamond.woff2 (229 cps) BOTH
   lack c/r/s/z/e-caron, u-ring, n/t/d-caron — every Czech accented letter
   outside Latin-1. On a product that is half Czech, every caron has been
   falling back to a system serif MID-WORD since those files were vendored.
   The -ext face below closes U+0100-017F and is why this migration ships two
   files per family instead of one. An English-only reader never downloads
   the -ext file: the browser fetches a unicode-range face only when a glyph
   in that range is actually used.

   Not re-subset. Both families arrive already subset upstream (Latin /
   Latin-Ext, SIL OFL — subsetting is permitted for both, unlike Nohemi per
   §ED397/§ED402). Re-subsetting would risk dropping the very Czech glyphs
   proved load-bearing above, for a few KB on a file the English reader
   never requests.

   Both are VARIABLE (wght 100-900, one file per subset), so the shipped
   weight range costs no extra bytes. §ED382's seven near-identical Hanken
   weights are not reproduced: 400 is the only weight this stylesheet asks
   Hanken for as a reading face. The declared range is the axis's true range
   — the retired Fraunces block under-declared it as "300 600", which
   silently clamped any request above 600. */
@font-face {
  font-family: 'Hanken Grotesk';
  src: url('/assets/fonts/hanken-grotesk.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
  font-style: normal;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Hanken Grotesk';
  src: url('/assets/fonts/hanken-grotesk-ext.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
  font-style: normal;
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

@font-face {
  font-family: 'Fraunces';
  src: url('/assets/fonts/fraunces.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
  font-style: normal;
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Fraunces';
  src: url('/assets/fonts/fraunces-ext.woff2') format('woff2');
  font-weight: 100 900;
  font-display: swap;
  font-style: normal;
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

/* ── Design Tokens ────────────────────────────────────────── */
:root {

  /* Background layers — concrete dusk graphite (VISUAL-IDENTITY-RULING §9.1,
     Wave A). Lifted from pure #0e0e0e — releases the "solid cartoonish
     black," elevation-by-luminance mechanism unchanged. */
  --bg:        #100E0C;
  --surface:   #1D1D1D;
  --surface-2: #242424;
  --surface-3: #2C2C2C;
  --surface-4: #343434;

  /* Global accent (lime) — UI chrome, XP bar, CTA buttons */
  --accent:           #CCFF00;
  --accent-hover:     #b8e600;
  --accent-dim:       rgba(204, 255, 0, 0.10);
  --accent-dim-hover: rgba(204, 255, 0, 0.18);
  /* §9.1: verified 15.40:1 on --accent-full — the ink used for text-on-lime
     in both themes now matches the light theme's --text-primary. */
  --accent-text:      #161616;
  /* Premium Polish §8/§9: the full-brightness lime moment, rationed to the
     primary CTA fill + the streak flame — everywhere else uses --accent (dark)
     / --accent-ink (light). Same value in both themes (dark: = --accent). */
  --accent-full:      #CCFF00;

  /* Culture accent — overridden per culture via /css/cultures/*.css */
  --culture-accent:      #CCFF00;
  --culture-accent-2:    #CCFF00;
  --culture-accent-dim:  rgba(204, 255, 0, 0.10);
  --culture-accent-text: #161616;

  /* Text — §9.1 solid-value ladder (replaces the alpha-based mechanism;
     solid = predictable contrast). */
  --text-primary:  #F4F4F4;
  --text-muted:    #C9C9C9;
  --text-subtle:   #9E9E9E;
  --text-disabled: rgba(244, 244, 244, 0.38);
  /* D1 safety alias — briefing.css references --text-secondary, which was never
     defined (silent fallback to inherited color). Migrate call sites to
     --text-muted directly in Phase B; this alias just stops the bleed now. */
  --text-secondary: var(--text-muted);

  /* Semantic colours */
  --success:     #4ade80;
  --success-dim: rgba(74, 222, 128, 0.12);
  /* §9.1: re-ruled from #FF5A48, which measured 4.04:1 on --surface-4 —
     under AA. #FF7A6E passes 4.90:1. */
  --error:       #FF7A6E;
  --error-dim:   rgba(255, 122, 110, 0.12);
  --warning:     #fbbf24;
  --warning-dim: rgba(251, 191, 36, 0.12);
  --info:        #60a5fa;
  --info-dim:    rgba(96, 165, 250, 0.12);

  /* Resource semantics — formalizing colors that already exist scattered as
     literals across chips/badges (hearts, gems, streak flame, freeze, medals).
     Each has one clear job: a specific game resource or rank tier, never a
     generic accent. */
  --streak:      #ff8c42;
  --streak-dim:  rgba(255, 140, 66, 0.14);
  --gems:        #34d4f0;
  --gems-dim:    rgba(52, 212, 240, 0.12);
  --hearts:      #ff6b6b;
  --hearts-dim:  rgba(255, 107, 107, 0.12);
  --freeze:      #88CCFF;
  --freeze-dim:  rgba(136, 204, 255, 0.12);
  --gold:        #FFD700;
  --gold-dim:    rgba(255, 215, 0, 0.12);
  --silver:      #B8C4CB;
  --bronze:      #CD7F32;

  /* Ink pattern — text/icon-glyph variant of accent/error/info. In dark mode
     it's identical to the base token (full-saturation reads fine as text on
     black); in light mode it diverges to a deepened, readable-as-text shade
     while the base token stays for fills/borders/badges. This single pattern
     replaces ~30 scattered html.theme-light per-selector color overrides. */
  --accent-ink: var(--accent);
  --error-ink:  var(--error);
  --info-ink:   var(--info);

  /* Gradients + the sanctioned 3D lip */
  --accent-grad:   linear-gradient(90deg, #CCFF00, #99FF00);
  --accent-grad-v: linear-gradient(180deg, #CCFF00, #99FF00);
  --accent-lip:    #9bcc00;

  /* Card system — promoted from tonight's app.css pass so every surface can
     share it. --card-accent/--card-accent-dim default to the global accent
     and are overridden per-card (e.g. per-culture) where relevant. */
  --card-accent:     var(--accent);
  --card-accent-dim: var(--accent-dim);

  --shadow-card:       0 6px 20px rgba(0, 0, 0, 0.28);
  --shadow-card-hover: 0 10px 26px rgba(0, 0, 0, 0.34);
  --shadow-card-press: 0 3px 12px rgba(0, 0, 0, 0.24);
  /* The Daily's signature lime-glow hero shadow, promoted verbatim. Usage
     rule: at most ONE hero-shadowed element per screen. */
  --shadow-hero:       0 -3px 24px -10px rgba(204, 255, 0, 0.32), 0 7px 26px -16px rgba(204, 255, 0, 0.12), 0 18px 50px rgba(0, 0, 0, 0.5);
  --shadow-hero-hover: 0 -3px 30px -8px  rgba(204, 255, 0, 0.40), 0 9px 28px -16px rgba(204, 255, 0, 0.14), 0 24px 56px rgba(0, 0, 0, 0.55);

  /* Recessed surface — for wells the UI sits INSIDE (progress tracks, locked
     tiles, inset strips), as opposed to --surface-N which is raised/floating. */
  --surface-well: #101010;
  /* §9.1 NEW — section bands / structural slabs, dark equivalent of the
     light theme's --surface-band. */
  --surface-band: #1A1A1A;

  /* Borders */
  --border:        rgba(244, 244, 244, 0.08);
  --border-strong: rgba(244, 244, 244, 0.16);
  --border-accent: rgba(204, 255, 0, 0.35);

  /* Spacing scale (4 px base) */
  --sp-1:  4px;
  --sp-2:  8px;
  --sp-3:  12px;
  --sp-4:  16px;
  --sp-5:  20px;
  --sp-6:  24px;
  --sp-8:  32px;
  --sp-10: 40px;
  --sp-12: 48px;
  --sp-16: 64px;
  --sp-20: 80px;

  /* Border radius */
  --r-sm:   6px;
  --r-md:   12px;
  --r-lg:   16px;
  --r-xl:   24px;
  --r-2xl:  32px;
  --r-full: 9999px;
  /* The only sanctioned off-scale radii — card and tile components. */
  --r-card: 22px;
  --r-tile: 20px;

  /* Typefaces */
  /* §ED430 · Fraunces carries fact titles and vault questions; Hanken
     Grotesk carries every reading surface. The display role stays a
     SERIF so the existing serif/sans hierarchy survives the swap intact
     — collapsing --font-display onto the body face would have been a
     layout change, which this ruling does not authorise. */
  --font-display: 'Fraunces', Georgia, serif;
  --font-body:    'Hanken Grotesk', system-ui, -apple-system, sans-serif;
  --font-numeric: 'Hanken Grotesk', system-ui, -apple-system, sans-serif;

  /* Type scale */
  --text-xs:   0.75rem;    /* 12 px */
  --text-sm:   0.875rem;   /* 14 px */
  --text-base: 1rem;       /* 16 px */
  --text-lg:   1.125rem;   /* 18 px */
  --text-xl:   1.25rem;    /* 20 px */
  --text-2xl:  1.5rem;     /* 24 px */
  --text-3xl:  1.875rem;   /* 30 px */
  --text-4xl:  2.25rem;    /* 36 px */
  --text-5xl:  3rem;       /* 48 px */
  --text-6xl:  3.75rem;    /* 60 px */

  /* Line heights */
  --leading-none:    1;
  --leading-tight:   1.2;
  --leading-snug:    1.35;
  --leading-normal:  1.5;
  --leading-relaxed: 1.65;

  /* Shadows */
  --shadow-sm:      0 1px 3px rgba(0, 0, 0, 0.50);
  --shadow-md:      0 4px 12px rgba(0, 0, 0, 0.55);
  --shadow-lg:      0 8px 32px rgba(0, 0, 0, 0.65);
  --shadow-xl:      0 16px 48px rgba(0, 0, 0, 0.75);
  --shadow-accent:  0 0 28px rgba(204, 255, 0, 0.28);
  --shadow-culture: 0 0 28px color-mix(in srgb, var(--culture-accent) 30%, transparent);
  --shadow-inset:   inset 0 1px 0 rgba(255, 255, 255, 0.05);
  /* Premium Polish §3/§9 additions */
  --edge-light:     inset 0 1px 0 rgba(204, 255, 0, 0.06);
  --shadow-float:   0 12px 40px rgba(0, 0, 0, 0.5), 0 2px 8px rgba(0, 0, 0, 0.4);
  --scrim:          rgba(14, 14, 14, 0.62);
  /* Daily remaster R5 — glass hygiene: the Daily guess panel's translucent
     surfaces, tokenized so briefing.css no longer hand-writes a theme-light
     override for them. */
  --glass-panel:    rgba(12, 12, 12, 0.46);   /* the guess panel's own "door" glass */
  --glass-tile:     rgba(18, 18, 18, 0.46);   /* the option tiles' glass */

  /* Easing */
  --ease:        cubic-bezier(0.4, 0, 0.2, 1);
  --ease-out:    cubic-bezier(0, 0, 0.2, 1);
  --ease-in:     cubic-bezier(0.4, 0, 1, 1);
  --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);  /* reward pops ONLY — never navigation/tabs */

  /* Named motion vocabulary (research-standard). --ease-decelerate/-accelerate
     mirror --ease-out/-in; kept as semantic aliases. --ease-spring above is the
     only bouncy curve and is reserved for celebrations/level-ups. */
  --ease-standard:   cubic-bezier(0.2, 0, 0, 1);
  --ease-decelerate: cubic-bezier(0, 0, 0.2, 1);
  --ease-accelerate: cubic-bezier(0.4, 0, 1, 1);

  /* Transition durations (existing values kept to avoid re-timing the app) */
  --t-instant: 100ms;
  --t-fast:   120ms;
  --t-base:   200ms;
  --t-slow:   300ms;
  --t-spring: 350ms;
  --t-reveal: 450ms;  /* Premium Polish §1 — BLOOM only */

  /* Z-index scale */
  --z-base:     0;
  --z-raised:   10;
  --z-dropdown: 100;
  --z-sticky:   200;
  --z-overlay:  300;
  --z-modal:    400;
  --z-toast:    500;

  /* Layout constants */
  --app-width:    480px;   /* game screens — phone-like on desktop */
  --wide-width:   960px;   /* landing page full-bleed sections */

  /* Safe-area insets — FROZEN snapshots, not live env(). The inline <script>
     that's the very first thing in every page's <head> reads
     env(safe-area-inset-top/bottom) ONCE via a probe element and sets
     --safe-top-px / --safe-bottom-px as static px values on :root before any
     stylesheet applies. Root cause this works around: WKWebView can resolve
     the real safe-area inset AFTER first paint on a cold app launch — any CSS
     rule reading env() directly re-layouts the instant that resolution lands,
     which reads as the fixed bottom nav (or any env()-padded element) jumping
     once. That never reproduces in a browser/simulator preview, where the
     inset is already correct at paint time. Binding to a frozen custom
     property instead of live env() means there is no longer a live CSS
     binding that CAN shift an already-rendered element mid-session — the bug
     class is removed structurally, regardless of exactly when WKWebView
     finishes resolving the real value. var(...,0px) fallback covers any page
     that (by omission) never ran the probe script, and web browsers where
     --safe-*-px is simply never set. DO NOT reference env(safe-area-inset-*)
     directly anywhere else in the app — always go through --safe-top /
     --safe-bottom below (or --nav-bottom-h, which already bakes --safe-bottom
     in) so every consumer shares the one frozen snapshot. */
  --safe-top-px:    0px;   /* overwritten by the head probe script, if it ran */
  --safe-bottom-px: 0px;
  --safe-top:    var(--safe-top-px, 0px);
  --safe-bottom: var(--safe-bottom-px, 0px);

  /* Bottom-nav height = 64px bar + the iOS home-indicator inset. Baking the inset
     into the var means the nav grows BELOW the indicator (the 64px icon area stays
     full, instead of env() eating into a fixed 64px border-box and cramping it),
     AND every content reservation that uses var(--nav-bottom-h) clears the taller
     nav automatically. Resolves to 64px on web (inset 0) — unchanged off-device. */
  --nav-bottom-h: calc(64px + var(--safe-bottom));
  --top-bar-h:    60px;
}

/* ── Premium touch feel ───────────────────────────────────── */
/* Kill the grey iOS tap flash everywhere, and the 300ms tap delay /
   double-tap-zoom on interactive elements. */
* { -webkit-tap-highlight-color: transparent; }
button, a, [role="button"], .tappable {
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
}

/* ── [hidden] must actually hide ──────────────────────────────
   The UA default is `[hidden] { display: none }`, which ANY later `display`
   rule beats on specificity — and .empty-state/.btn set display:flex /
   inline-flex globally. §ED465: vault.html had no local override, so its
   loading, empty, and error states (plus a "Show more" button) all sat on
   screen at once. calendar.css, record.css, and collection.html each
   patched this locally already (ops.css too) — declared once here instead,
   so it can't be missed on the next page and the local patches become
   redundant (removed in this same change). */
[hidden] { display: none !important; }

/* ── Kill the white flash between page navigations ────────── */
/* Render-blocking, theme-aware: paints the app background under the page so
   navigation never flashes white. overscroll-behavior stops the rubber-band:
   it must be on html (the scrolling root) — body alone doesn't contain the
   document-level bounce, so on mobile the over-scroll revealed a black void
   past the fixed bottom nav. */
html { background: var(--bg); overscroll-behavior-y: none; }
body { overscroll-behavior: none; }

/* Cross-document View Transitions — REMOVED (nav-jump root cause, round 3).
   This opt-in was never actually the thing two prior "fixes" touched — both
   only changed .bottom-nav's view-transition-name (whether the nav gets its
   own named transition group vs. falling into the root's default group).
   Neither disabled cross-document View Transitions itself, so the jump
   persisted regardless of grouping.

   The real mechanism: with this opt-in active, EVERY navigation crossfades a
   RASTER SNAPSHOT of the outgoing page into a raster snapshot of the incoming
   page (::view-transition-old/new(root), a straight opacity blend of two flat
   images — see the removed keyframes below). The bottom nav is fixed and
   rebuilt fresh per page load (js/bottom-nav.js) with the correct tab already
   marked is-active at first paint — nothing on a single page ever animates
   position (transition:none + transform:none!important throughout). But the
   active tab's popped circle sits at a DIFFERENT x-position on every page
   (whichever tab is active there). Cross-dissolving two snapshots that both
   contain the same circle chrome in TWO DIFFERENT PLACES reads as a jump —
   old-position circle fading out while new-position circle fades in,
   simultaneously, both partially visible mid-blend — even though no element
   ever animates top/left/transform. This is inherent to root-snapshot
   crossfade for any fixed chrome whose content differs across pages; it
   cannot be fixed by naming/unnaming the element.

   The comment this replaced also assumed WKWebView "degrades to instant
   navigation" without cross-document View Transitions support — false on
   modern iOS (18+ ships this), which is exactly why the jump only showed up
   "on real device": js/transitions.js's own SUPPORTS_VT check (same-document
   API, used as a proxy) was ALSO true there, so the manual #page-veil fallback
   was deliberately skipped, leaving the raw crossfade as the ONLY transition
   with nothing masking it. Fix: kill the opt-in entirely; js/transitions.js
   now always runs the manual veil (solid-bg fade-through — content is never
   visible mid-transition, so there is no window for a crossfade artifact). */

/* ── Inputs: never below 16px (iOS zooms on focus otherwise) ── */
input, textarea, select { font-size: 16px !important; }

/* §ED177 — in the shell, let form text follow Dynamic Type too, or a
   reader on Larger Text gets grown prose and 16px inputs. `max()` keeps
   the anti-zoom floor intact: 1rem is the system size under the is-app
   root rule, so this only ever rounds UP from 16px, never below it.
   Higher specificity than the bare selector above, so it wins between
   two !important declarations. */
html.is-app input,
html.is-app textarea,
html.is-app select { font-size: max(16px, 1rem) !important; }

/* ── Light mode overrides ─────────────────────────────────── */
/*
 * Toggle by adding class "theme-light" to <html>.
 * The inline <script> in every page's <head> applies this before
 * first paint to prevent flash of wrong theme.
 * Accent is toned down in light mode (#99CC00) — less fluorescent
 * against the warm cream background.
 */
html.theme-light {
  /* VISUAL-IDENTITY-RULING §9.1 (Wave A) — "concrete daylight." Supersedes
     the tan/cream founder set below with an achromatic concrete-neutral
     system; every text-on-surface pair here was verified by execution
     (WCAG relative-luminance math). Elevation law INVERTS in light: white
     (--surface) is the ceiling, nesting descends toward the canvas —
     mirror of dark's ascending ladder, same tokens/markup. */
  --bg:        #F5F3EE;
  --surface:   #FFFFFF;
  --surface-2: #FAFAFA;
  --surface-3: #F4F4F4;
  --surface-4: #EFEFEF;
  --surface-well: #EBEBEB;
  --surface-band: #E8E8E8;

  --text-primary:  #161616;
  --text-muted:    #404040;
  /* Re-ruled from §3b's #6B6B6B, which measures 4.35:1 on --surface-band
     and 4.47:1 on --surface-well — AA fails on the deeper new surfaces.
     #616161 passes 4.5:1 on every light surface. */
  --text-subtle:   #616161;
  --text-disabled: rgba(22, 22, 22, 0.38);

  /* Achromatic hairlines replace the old olive-tinted borders */
  --border:        rgba(22, 22, 22, 0.08);
  --border-strong: rgba(22, 22, 22, 0.16);
  --border-accent: rgba(204, 255, 0, 0.35);

  /* The #99CC00 tone-down RETIRES — two tiers only: full-brightness fill
     (#CCFF00, same as dark) and the darkened --accent-ink text/glyph tier. */
  --accent:            #CCFF00;
  --accent-hover:      #b8e600;
  /* Quiet olive-of-lime washes, built off --accent-ink rather than the
     retired toned accent. */
  --accent-dim:        rgba(88, 111, 0, 0.08);
  --accent-dim-hover:  rgba(88, 111, 0, 0.14);
  --accent-lip:        #7fa800;
  --accent-full:       #CCFF00;

  /* Ink variants — text/icon-glyph color, deepened for readability on white
     (the base accent/error/info tokens stay for fills/borders/badges). */
  /* Re-ruled from §3a's #5f7d00, which measures 4.75:1 on white but 3.88:1
     on --surface-band — fails below --surface-2. #586F00 passes AA on every
     light surface (5.69:1 on white). */
  --accent-ink: #586F00;
  --error-ink:  #B3261E;
  --info-ink:   #1A56C4;

  /* Semantic light variants — §9.1 AA-verified values */
  --success:     #127035;
  --success-dim: rgba(18, 112, 53, 0.12);
  --error:       #B3261E;
  --error-dim:   rgba(179, 38, 30, 0.12);
  --warning:     #8A5A00;
  --warning-dim: rgba(138, 90, 0, 0.12);
  --info:        #1A56C4;
  --info-dim:    rgba(26, 86, 196, 0.12);

  /* Achromatic shadows — the olive-tinted set retires with the cream. */
  --shadow-sm:     0 1px 3px rgba(22, 22, 22, 0.08);
  --shadow-md:     0 4px 12px rgba(22, 22, 22, 0.12);
  --shadow-lg:     0 8px 32px rgba(22, 22, 22, 0.14);
  --shadow-xl:     0 16px 48px rgba(22, 22, 22, 0.18);
  --shadow-inset:  inset 0 1px 0 rgba(22, 22, 22, 0.04);

  --shadow-card:       0 6px 20px rgba(22, 22, 22, 0.16);
  --shadow-card-hover: 0 10px 26px rgba(22, 22, 22, 0.19);
  --shadow-card-press: 0 3px 12px rgba(22, 22, 22, 0.14);
  /* Lime glow layered over an achromatic ambient shadow — the light-mode
     analogue of the dark hero glow, lime hue unified with --accent. */
  --shadow-hero:       0 -3px 24px -10px rgba(204, 255, 0, 0.30), 0 7px 26px -16px rgba(204, 255, 0, 0.10), 0 18px 50px rgba(22, 22, 22, 0.20);
  --shadow-hero-hover: 0 -3px 30px -8px  rgba(204, 255, 0, 0.38), 0 9px 28px -16px rgba(204, 255, 0, 0.12), 0 24px 56px rgba(22, 22, 22, 0.22);

  /* Premium Polish §3/§9 additions — light-theme values */
  --edge-light:   inset 0 1px 0 rgba(255, 255, 255, 0.55);
  --shadow-float: 0 10px 30px rgba(22, 22, 22, 0.16), 0 2px 6px rgba(22, 22, 22, 0.10);
  --scrim:        rgba(22, 22, 22, 0.35);
  /* Daily remaster R5 — glass hygiene, light-theme values (see :root for
     context). White-tinted now the cream canvas is gone. */
  --glass-panel:  rgba(255, 255, 255, 0.72);
  --glass-tile:   rgba(255, 255, 255, 0.55);
}

/* §9.1/§9.3 Wave A item 4 — --shadow-accent demotes out of web chrome (the
   lime glow at scale is banned in web §3a; the app keeps its reward glow
   until its own post-launch sweep). Token-level scope only — no selector
   touched; every current/future consumer of var(--shadow-accent) on web
   inherits `none` for free. Must follow html.theme-light in source order:
   equal specificity, so the later rule wins for html:not(.is-app).theme-light. */
html:not(.is-app) {
  --shadow-accent: none;
}

/* Smooth transition when theme changes */
html.theme-light *,
html.theme-light *::before,
html.theme-light *::after {
  transition-property: background-color, border-color, color, box-shadow;
  transition-duration: 200ms;
  transition-timing-function: ease;
}

/* ── Reset ────────────────────────────────────────────────── */
*,
*::before,
*::after {
  box-sizing: border-box;
  margin: 0;
  padding: 0;
}

html {
  /* No fixed font-size / text-size-adjust here on purpose: both used to
     override the OS/browser's own accessibility text-size preference.
     On DESKTOP and ANDROID that is sufficient — the UA default root size
     is itself derived from the browser's minimum/default font-size
     setting, so every rem below inherits the preference for free. All
     pages set width=device-width in their viewport meta, so mobile
     browsers' auto text-inflation heuristic (the thing
     text-size-adjust:100% used to guard against) doesn't kick in here
     regardless.

     §ED177 — THE iOS HALF OF THAT CLAIM WAS FALSE AND IS NOW MEASURED.
     iOS does NOT derive the web root size from Dynamic Type. Probed in
     Mobile Safari on a booted iPhone 17 (iOS 26.5) with
     `simctl ui <udid> content_size ...` driving the real system setting:

                              default      accessibility-XXXL
       root html font-size      16px            16px
       font-size: 1rem          16px            16px
       font: -apple-system-body 16px            53px

     The root is PINNED at 16px across the entire Dynamic Type range, so
     a rem scale is completely inert to it — a reader with Larger Text
     enabled got nothing, on every page, which is exactly the symptom.
     `-apple-system-body` is the ONLY channel that moves. See the is-app
     rule below. */
  scroll-behavior: smooth;
}

/* ── Dynamic Type (§ED177) ────────────────────────────────────
   Adopt the reader's SYSTEM text size as the root, so the whole
   token scale (--text-xs … --text-6xl, all rem) scales with it and
   there is no private in-app control to discover. The `font`
   shorthand is the only syntax that accepts the keyword, and it
   resets family/weight/style/line-height, so those are restored on
   the next line — only the SIZE is taken from the system.

   SCOPED TO html.is-app ON PURPOSE. `-apple-system-body` also
   resolves on DESKTOP Safari, where it is the macOS system body size
   (13px) and has nothing to do with Dynamic Type — unscoped, this
   rule would shrink the entire web surface to a 13px root in one
   browser and leave every other browser at 16px. .is-app is baked
   into every shipped page by build-www.sh (§ED115) and stamped at
   runtime in the shell, so it marks precisely the surface where the
   keyword means what we want it to mean. The web is untouched.

   COST, stated: the ordinary Text Size slider spans 14–23px, i.e.
   0.875×–1.44× — layout absorbs this. The Accessibility Larger Text
   range runs to 53px (3.3×); there prose grows and the px-frozen
   chrome does not (see below), which is the intended split. */
html.is-app {
  font: -apple-system-body;
  font-family: var(--font-body);
  line-height: var(--leading-normal);
}

/* ── Reading size control (§ED179) ────────────────────────────
   The in-app control is a MULTIPLIER on the rule above, never a
   replacement for it. --text-scale is the declared contract:
   1 = exactly the system size, and the applier writes
   `font-size: <measured system px> * --text-scale` inline on
   <html>, so the reader's device setting is always the base.

   WHY THE MULTIPLICATION IS NOT IN THIS FILE, stated so nobody
   "tidies" it into CSS: on the ROOT element both `rem` and `%`
   resolve against font-size's INITIAL value, not against the
   `font: -apple-system-body` size — §ED177 measured that the
   initial value stays 16px at every Dynamic Type setting. So
   `html { font-size: calc(1rem * var(--text-scale)) }` would
   compute 16px x k and OVERRIDE the system setting instead of
   scaling with it — an absolute control, undoing §ED177 for
   every reader who touched it. CSS has no syntax that reads the
   keyword's computed size back. The applier therefore measures
   with getComputedStyle after the stylesheets have parsed and
   before first paint; see the block in settings.html <head>.

   WHAT IT MOVES: the root, hence every rem — the whole
   --text-xs…--text-6xl ladder below and briefing.css's rem
   literals (the reading surface).
   WHAT IT CANNOT MOVE: anything frozen in px. The tab bar's
   item/icon/label geometry (§ED167), --app-width, --app-tabbar-max
   and the safe-area snapshot are all px, so the nav is
   dimensionally identical at every step; and the vw middle term
   of the display clamps below stays viewport-driven by design. */
:root {
  --text-scale: 1;
}

body {
  background-color: var(--bg);
  color: var(--text-primary);
  font-family: var(--font-body);
  font-size: var(--text-base);
  line-height: var(--leading-normal);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  min-height: 100dvh;
  overflow-x: hidden;
}

img,
video,
svg {
  display: block;
  max-width: 100%;
}

a {
  color: inherit;
  text-decoration: none;
}

/* Accessible focus ring for keyboard users */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
  outline: 2px solid var(--accent-ink);
  outline-offset: 2px;
}

button {
  cursor: pointer;
  border: none;
  background: none;
  font: inherit;
  color: inherit;
}

input,
select,
textarea {
  font: inherit;
}

ul,
ol {
  list-style: none;
}

h1, h2, h3, h4, h5, h6 {
  font-family: var(--font-display);
  line-height: var(--leading-tight);
  color: var(--text-primary);
  font-weight: 400;
}

p {
  color: var(--text-muted);
  line-height: var(--leading-relaxed);
}

/* ── Typography scale ─────────────────────────────────────── */

.display {
  font-family: var(--font-display);
  font-size: clamp(var(--text-4xl), 7vw, var(--text-6xl));
  font-weight: 300;
  line-height: var(--leading-none);
  letter-spacing: -0.025em;
}

h1, .h1 {
  font-size: clamp(var(--text-3xl), 5vw, var(--text-4xl));
  font-weight: 300;
  letter-spacing: -0.015em;
}

h2, .h2 {
  font-size: clamp(var(--text-2xl), 3.5vw, var(--text-3xl));
  font-weight: 400;
  letter-spacing: -0.01em;
}

h3, .h3 {
  font-size: var(--text-xl);
  font-weight: 400;
}

h4, .h4 {
  font-family: var(--font-body);
  font-size: var(--text-lg);
  font-weight: 600;
}

.body-lg {
  font-size: var(--text-lg);
  line-height: var(--leading-relaxed);
}

.body {
  font-size: var(--text-base);
  line-height: var(--leading-normal);
}

.body-sm {
  font-size: var(--text-sm);
  line-height: var(--leading-normal);
}

.caption {
  font-size: var(--text-xs);
  line-height: var(--leading-snug);
  color: var(--text-subtle);
}

/* Small-caps label above a heading */
.label {
  font-family: var(--font-body);
  font-size: var(--text-xs);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.09em;
  color: var(--text-subtle);
}

/* ── Colour utilities ─────────────────────────────────────── */
.text-primary   { color: var(--text-primary); }
.text-muted     { color: var(--text-muted); }
.text-subtle    { color: var(--text-subtle); }
.text-disabled  { color: var(--text-disabled); }
.text-accent    { color: var(--accent); }
.text-culture   { color: var(--culture-accent); }
.text-success   { color: var(--success); }
.text-error     { color: var(--error); }
.text-warning   { color: var(--warning); }

/* ── Layout helpers ───────────────────────────────────────── */

/* Constrain to app width with centred padding */
.container {
  width: 100%;
  max-width: var(--app-width);
  margin-inline: auto;
  padding-inline: var(--sp-4);
}

/* Landing/marketing wide container */
.container-wide {
  width: 100%;
  max-width: var(--wide-width);
  margin-inline: auto;
  padding-inline: var(--sp-6);
}

/* Page wrapper — accounts for top bar + bottom nav */
.page-content {
  padding-top: calc(var(--top-bar-h) + var(--sp-4));
  padding-bottom: calc(var(--nav-bottom-h) + var(--sp-6));
}

/* Page wrapper for auth/onboarding — no nav bars */
.page-bare {
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

.flex            { display: flex; }
.flex-col        { display: flex; flex-direction: column; }
.items-center    { align-items: center; }
.items-start     { align-items: flex-start; }
.items-end       { align-items: flex-end; }
.justify-center  { justify-content: center; }
.justify-between { justify-content: space-between; }
.justify-end     { justify-content: flex-end; }
.flex-wrap       { flex-wrap: wrap; }
.flex-1          { flex: 1; }
.flex-shrink-0   { flex-shrink: 0; }

.gap-1  { gap: var(--sp-1); }
.gap-2  { gap: var(--sp-2); }
.gap-3  { gap: var(--sp-3); }
.gap-4  { gap: var(--sp-4); }
.gap-6  { gap: var(--sp-6); }
.gap-8  { gap: var(--sp-8); }

.grid  { display: grid; }
.w-full { width: 100%; }
.h-full { height: 100%; }
.min-h-screen { min-height: 100dvh; }

/* Visually hidden but available to screen readers / crawlers (standard sr-only). */
.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;
}

/* Spacing shorthands */
.mt-1 { margin-top: var(--sp-1); }
.mt-2 { margin-top: var(--sp-2); }
.mt-3 { margin-top: var(--sp-3); }
.mt-4 { margin-top: var(--sp-4); }
.mt-6 { margin-top: var(--sp-6); }
.mt-8 { margin-top: var(--sp-8); }
.mb-2 { margin-bottom: var(--sp-2); }
.mb-4 { margin-bottom: var(--sp-4); }
.mb-6 { margin-bottom: var(--sp-6); }
.mb-8 { margin-bottom: var(--sp-8); }

/* ── Typography utilities ─────────────────────────────────── */
.text-center { text-align: center; }
.text-left   { text-align: left; }
.text-right  { text-align: right; }
.font-bold     { font-weight: 700; }
.font-semibold { font-weight: 600; }
.font-medium   { font-weight: 500; }

.truncate {
  overflow: hidden;
  white-space: nowrap;
  text-overflow: ellipsis;
}

.line-clamp-2 {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* ── Visibility ───────────────────────────────────────────── */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}

.hidden    { display: none !important; }
.invisible { visibility: hidden; }

/* ── Borders, surfaces ────────────────────────────────────── */
.bg-surface   { background: var(--surface); }
.bg-surface-2 { background: var(--surface-2); }
.bg-surface-3 { background: var(--surface-3); }
.border        { border: 1px solid var(--border); }
.border-strong { border: 1px solid var(--border-strong); }
.border-accent { border: 1px solid var(--border-accent); }
.rounded-sm    { border-radius: var(--r-sm); }
.rounded-md    { border-radius: var(--r-md); }
.rounded-lg    { border-radius: var(--r-lg); }
.rounded-xl    { border-radius: var(--r-xl); }
.rounded-full  { border-radius: var(--r-full); }

/* ── Dividers ─────────────────────────────────────────────── */
.divider {
  width: 100%;
  height: 1px;
  background: var(--border);
  flex-shrink: 0;
}

/* "— or —" separator on auth pages */
.divider-label {
  display: flex;
  align-items: center;
  gap: var(--sp-3);
  color: var(--text-subtle);
  font-size: var(--text-sm);
}
.divider-label::before,
.divider-label::after {
  content: '';
  flex: 1;
  height: 1px;
  background: var(--border);
}

/* ── Site chrome — web-only header/footer (WO-WEB-4b) ────────
   Minimal wordmark + Log in + "Read today's edition" masthead, and a quiet
   footer, shared across reachable web pages that otherwise have no way back
   into the site (about/note/standards). Fixed dark (--bg) chrome bar on
   purpose — distinct from any page's own light/cream editorial register
   below it, so it reads as one persistent app-wide toolbar rather than
   part of a given page's content.
   WEB-ONLY: gated on html:not(.is-app) — the native shell has its own
   chrome (bottom nav etc.) and must never see this. See briefing.html's
   CAPABILITY GATE script for how html.is-app gets stamped; pages that ship
   this chrome carry the same script in <head>.
   Generated onto pages by scripts/inject-site-chrome.js — edit the
   template there, not the markup in individual HTML files. */
html.is-app .site-header,
html.is-app .site-footer { display: none; }
/* The margin below compensates for the fixed header's height (see
   ".site-header + main" below) — zero it out too when the header itself
   is hidden, or native would be left with a blank gap at the top. */
html.is-app .site-header + main { margin-top: 0; }

/* position:fixed, not sticky — matches the app's own .top-bar convention
   (css/components.css) rather than CSS position:sticky. This app's pages
   make BODY the scrolling element (overflow-y:auto; html/window itself
   never scrolls — see the "kill white flash" rule near the top of this
   file), which is an unusual containing-block setup for position:sticky.
   fixed sidesteps it entirely and is what the rest of the app already
   relies on for persistent chrome. */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: var(--z-sticky);
  height: 64px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-4);
  padding: 0 var(--sp-6);
  background: var(--bg);
  border-bottom: 1px solid var(--border);
}
/* Compensates for .site-header's fixed height/out-of-flow removal — the
   page's own top padding (.ab-page etc.) still applies on top of this.
   Targets the <main> scripts/inject-site-chrome.js always places right
   after the header. */
.site-header + main { margin-top: 64px; }
.site-header-brand {
  font-family: 'Fraunces', Georgia, serif;
  font-size: 1.15rem;
  font-weight: 500;
  color: var(--text-primary);
  text-decoration: none;
  letter-spacing: -0.01em;
}
.site-header-nav {
  display: flex;
  align-items: center;
  gap: var(--sp-5);
}
.site-header-login {
  font-family: var(--font-body);
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--text-muted);
  text-decoration: none;
  min-height: 44px;
  display: inline-flex;
  align-items: center;
  transition: color 140ms ease;
}
.site-header-login:hover { color: var(--text-primary); }
/* Green-button faint shadow (VISUAL-IDENTITY-RULING.md §3a, ratified
   2026-07-17) — ported verbatim from .lv2-cta (css/landing-v2.css), the one
   place it's actually built: separation, not a 3D lip, not a glow-at-scale,
   achromatic. Also fixes color: var(--accent-text) was var(--bg), which on
   this component's only consumers (all hardcoded theme-light) resolved to
   near-white text on lime — functionally invisible, not just "too bright". */
.site-header-cta {
  font-family: var(--font-body);
  font-size: 0.85rem;
  font-weight: 700;
  color: var(--accent-text);
  background: var(--accent);
  text-decoration: none;
  padding: 0 var(--sp-5);
  min-height: 40px;
  display: inline-flex;
  align-items: center;
  border-radius: var(--r-full);
  white-space: nowrap;
  box-shadow: 0 1px 2px rgba(22, 22, 22, 0.08), 0 3px 12px rgba(22, 22, 22, 0.14);
  transition: background 140ms ease, box-shadow 140ms ease;
}
.site-header-cta:hover {
  background: var(--accent-hover);
  box-shadow: 0 1px 2px rgba(22, 22, 22, 0.08), 0 4px 16px rgba(22, 22, 22, 0.16);
}

@media (max-width: 480px) {
  .site-header { height: 56px; padding: 0 var(--sp-4); }
  .site-header + main { margin-top: 56px; }
  .site-header-brand { font-size: 1.05rem; }
  .site-header-nav { gap: var(--sp-3); }
  .site-header-login { font-size: 0.78rem; }
  .site-header-cta { font-size: 0.78rem; padding: 0 var(--sp-4); min-height: 36px; }
}

/* ── App auth chip — quiet sign-in/out control for the reading pages that carry
   no other auth chrome (briefing/archive/collection; settings.html/profile.html
   already have their own in-page sign-out, checklist #14). Baked by
   scripts/inject-bottom-nav.js — the injector already targeting exactly these
   app pages, unlike .site-header/.site-footer above (web-only marketing chrome,
   hidden under html.is-app). This chip must show in the native shell too, since
   these ARE the app's native home pages. Normal document flow (NOT fixed) —
   sits as the first line inside .bf-wrap, above each page's own header, so it
   never has to fight that header's own layout (briefing's culture pill + right-
   aligned calendar trigger, archive/collection's centered wordmark) for the same
   corner. Filled at runtime by js/auth-chrome.js. Same quiet text-link language
   as .site-header-login / the landing masthead's .lv2-signin. */
.app-auth-chip {
  display: flex;
  justify-content: flex-end;
  min-height: 24px;
  margin: 0 0 8px;
}
.app-auth-link {
  font-family: var(--font-body);
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--text-muted);
  background: none;
  border: none;
  text-decoration: none;
  padding: 4px 2px;
  min-height: 32px;
  display: inline-flex;
  align-items: center;
  cursor: pointer;
  transition: color 140ms ease;
}
.app-auth-link:hover { color: var(--text-primary); }
.app-auth-link:disabled { opacity: 0.6; cursor: default; }

/* ── App top nav — DESKTOP-ONLY replacement for the mobile bottom-nav pill
   (world-sweep components pass, 2026-07-21). The bottom pill (components.css
   .bottom-nav) is app-shaped chrome that has no business floating over a
   desktop reading layout; ≥900px (same breakpoint briefing.css's own
   "DESKTOP LAYER (WO-WEB-2)" already hides .bottom-nav at) it's replaced by
   this fixed top bar instead — same wordmark-left/nav-right language as
   .site-header above, so the world stays visually coherent, just scoped to
   the signed-in app pages (briefing/archive/collection/profile/settings)
   rather than the logged-out marketing surface. Baked onto those five pages
   by scripts/inject-bottom-nav.js (the injector already targeting exactly
   that set), filled at runtime by js/top-nav.js. Hidden under html.is-app —
   the native shell keeps its own bottom-tab chrome, never this. */
html.is-app .app-topnav { display: none; }
.app-topnav {
  display: none;
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: var(--z-sticky);
  height: 64px;
  box-sizing: border-box;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-4);
  padding: 0 var(--sp-6);
  background: var(--bg);
  border-bottom: 1px solid var(--border);
}
.app-topnav-brand {
  font-family: 'Fraunces', Georgia, serif;
  font-size: 1.15rem;
  font-weight: 500;
  color: var(--text-primary);
  text-decoration: none;
  letter-spacing: -0.01em;
}
.app-topnav-nav {
  display: flex;
  align-items: center;
  gap: var(--sp-5);
}
.app-topnav-link {
  font-family: var(--font-body);
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--text-muted);
  background: none;
  border: none;
  text-decoration: none;
  min-height: 44px;
  display: inline-flex;
  align-items: center;
  cursor: pointer;
  transition: color 140ms ease;
}
.app-topnav-link:hover { color: var(--text-primary); }
.app-topnav-link:disabled { opacity: 0.6; cursor: default; }

@media (min-width: 900px) {
  .app-topnav { display: flex; }
  /* The chip already covers sign-in/out on briefing/archive/collection at
     mobile widths (components.css note above) — redundant once the top nav
     is showing its own Log out control, so it steps aside rather than
     duplicating the control right next to it. */
  .app-auth-chip { display: none; }
}

.site-footer {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-10) var(--sp-6) var(--sp-8);
  background: var(--bg);
  border-top: 1px solid var(--border);
  align-self: stretch;
  width: 100%;
  box-sizing: border-box;
}
.site-footer-brand {
  font-family: 'Fraunces', Georgia, serif;
  font-size: 1rem;
  color: var(--text-muted);
  text-decoration: none;
}
.site-footer-links {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--sp-5);
}
.site-footer-links a {
  font-family: var(--font-body);
  font-size: 0.78rem;
  color: var(--text-subtle);
  text-decoration: none;
  min-height: 44px;
  display: inline-flex;
  align-items: center;
  transition: color 140ms ease;
}
.site-footer-links a:hover { color: var(--text-primary); }
.site-footer-copy {
  font-family: var(--font-body);
  font-size: 0.72rem;
  color: var(--text-disabled);
  margin: 0;
}

/* ── Scrollbar (webkit) ───────────────────────────────────── */
::-webkit-scrollbar       { width: 4px; height: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb {
  background: var(--surface-4);
  border-radius: var(--r-full);
}
::-webkit-scrollbar-thumb:hover { background: var(--border-strong); }

/* ── Selection colour ─────────────────────────────────────── */
::selection {
  background: var(--accent-dim);
  color: var(--text-primary);
}

/* ─────────────────────────────────────────────────────────── */
/* ANIMATIONS                                                   */
/* ─────────────────────────────────────────────────────────── */

@keyframes fadeIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}

@keyframes fadeOut {
  from { opacity: 1; }
  to   { opacity: 0; }
}

@keyframes slideUp {
  from { opacity: 0; transform: translateY(16px); }
  to   { opacity: 1; transform: translateY(0); }
}

@keyframes slideDown {
  from { opacity: 0; transform: translateY(-16px); }
  to   { opacity: 1; transform: translateY(0); }
}

@keyframes slideInRight {
  from { opacity: 0; transform: translateX(24px); }
  to   { opacity: 1; transform: translateX(0); }
}

@keyframes slideInLeft {
  from { opacity: 0; transform: translateX(-24px); }
  to   { opacity: 1; transform: translateX(0); }
}

/* Correct-answer springy pop */
@keyframes pop {
  0%   { transform: scale(1); }
  35%  { transform: scale(1.10); }
  65%  { transform: scale(0.97); }
  85%  { transform: scale(1.03); }
  100% { transform: scale(1); }
}

/* Wrong-answer horizontal shake */
@keyframes shake {
  0%,  100% { transform: translateX(0); }
  15%        { transform: translateX(-7px); }
  30%        { transform: translateX(7px); }
  45%        { transform: translateX(-5px); }
  60%        { transform: translateX(5px); }
  75%        { transform: translateX(-3px); }
  90%        { transform: translateX(3px); }
}

/* Streak fire pulse */
@keyframes pulse {
  0%, 100% { transform: scale(1);    opacity: 1; }
  50%       { transform: scale(1.15); opacity: 0.85; }
}

/* Skeleton loading shimmer */
@keyframes shimmer {
  0%   { background-position: -600px 0; }
  100% { background-position:  600px 0; }
}

/* Toast notification slide-in from right */
/* Toast drop-in (reward kit): subtle drop from the top + slight scale. */
@keyframes toastIn {
  0%   { opacity: 0; transform: translateY(-14px) scale(0.96); }
  60%  { opacity: 1; }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}

/* Celebration moments (reward kit): hero bursts in, ring pulses, stats rise. */
@keyframes heroBurst {
  0%   { opacity: 0; transform: scale(0.4) rotate(-12deg); }
  55%  { opacity: 1; transform: scale(1.12) rotate(4deg); }
  100% { opacity: 1; transform: scale(1) rotate(0deg); }
}
@keyframes ringPulse {
  0%   { transform: scale(0.7); opacity: 0.7; }
  100% { transform: scale(1.5); opacity: 0; }
}
@keyframes statRise {
  0%   { opacity: 0; transform: translateY(14px); }
  100% { opacity: 1; transform: translateY(0); }
}

@keyframes toastOut {
  from { opacity: 1; transform: translateX(0)    scale(1); }
  to   { opacity: 0; transform: translateX(24px) scale(0.95); }
}

/* Modal entrance */
@keyframes modalIn {
  from { opacity: 0; transform: translateY(24px) scale(0.97); }
  to   { opacity: 1; transform: translateY(0)    scale(1); }
}

/* Stars pop-in (delay each star via JS class or nth-child) */
@keyframes starPop {
  0%   { transform: scale(0) rotate(-20deg); opacity: 0; }
  60%  { transform: scale(1.25) rotate(5deg); opacity: 1; }
  80%  { transform: scale(0.92) rotate(-2deg); }
  100% { transform: scale(1) rotate(0); opacity: 1; }
}

/* XP number glow on count-up */
@keyframes xpGlow {
  0%, 100% { text-shadow: none; }
  50%       { text-shadow: 0 0 20px var(--accent); }
}

/* Level-up radiating ring */
@keyframes ring {
  0%   { transform: scale(0.6); opacity: 0.9; }
  100% { transform: scale(2.6); opacity: 0; }
}

/* Floating confetti particle (applied via JS) */
@keyframes confettiFall {
  0%   { transform: translateY(-20px) rotate(0deg);  opacity: 1; }
  100% { transform: translateY(130vh) rotate(720deg); opacity: 0; }
}

/* Loading spinner */
@keyframes spin {
  to { transform: rotate(360deg); }
}

/* Empty-state illustration gentle float */
@keyframes float {
  0%, 100% { transform: translateY(0); }
  50%       { transform: translateY(-8px); }
}

/* ── Animation utility classes ────────────────────────────── */
.animate-fade-in    { animation: fadeIn    var(--t-base)   var(--ease-out)    both; }
.animate-slide-up   { animation: slideUp   var(--t-slow)   var(--ease-out)    both; }
.animate-slide-down { animation: slideDown var(--t-slow)   var(--ease-out)    both; }
.animate-pop        { animation: pop       var(--t-spring) var(--ease-spring) both; }
.animate-shake      { animation: shake     400ms           var(--ease)        both; }
.animate-pulse      { animation: pulse     1.4s            ease-in-out infinite; }
.animate-spin       { animation: spin      800ms           linear      infinite; }
.animate-float      { animation: float     3s              ease-in-out infinite; }
.animate-xp-glow    { animation: xpGlow    1.2s            ease-in-out; }

/* Stagger helpers — apply to list children */
.stagger-1 { animation-delay: 60ms;  }
.stagger-2 { animation-delay: 120ms; }
.stagger-3 { animation-delay: 180ms; }
.stagger-4 { animation-delay: 240ms; }
.stagger-5 { animation-delay: 300ms; }

/* ── Premium motion primitives (T0, docs/PREMIUM-POLISH-SPEC.md §1) ────────
   Four named primitives, used everywhere — screens speak ONLY these instead
   of picking freely from the keyframe library above.
   Hard rules:
   - Bottom nav and tab switches stay motion-free (existing deliberate
     decision — keep).
   - transform/opacity only, never height/layout (rule c perf posture).
   - BLOOM is rationed to genuine reward moments — at most once per user
     action. If something wants a spring and isn't a reward, it's TURN.
   - Reduced motion: the `*, *::before, *::after` kill below already matches
     BLOOM's glow (animated via opacity on a ::after pseudo-element), so no
     extra override is needed here. ─────────────────────────────────────── */

/* RISE — screen/section entrance. Reuses --t-spring (350ms — the token name
   predates this spec; the duration, not the name, is what §1 asks for). */
@keyframes mRise {
  from { opacity: 0; transform: translateY(12px); }
  to   { opacity: 1; transform: translateY(0); }
}
.m-rise { animation: mRise var(--t-spring) var(--ease-out) both; }

/* RISE stagger — direct children marked [data-stagger], 40ms steps, capped
   at 5 (the 5th child and any beyond it enter together, no further delay). */
.m-rise > [data-stagger]           { animation: mRise var(--t-spring) var(--ease-out) both; }
.m-rise > [data-stagger]:nth-child(1) { animation-delay: 0ms; }
.m-rise > [data-stagger]:nth-child(2) { animation-delay: 40ms; }
.m-rise > [data-stagger]:nth-child(3) { animation-delay: 80ms; }
.m-rise > [data-stagger]:nth-child(4) { animation-delay: 120ms; }
.m-rise > [data-stagger]:nth-child(n+5) { animation-delay: 160ms; }

/* TURN — in-place content swap (guess reveal, tab-panel swap, calendar day
   change, toast replace). Reuses --t-fast (120ms) / --t-base (200ms). */
@keyframes mTurnOut { from { opacity: 1; } to { opacity: 0; } }
@keyframes mTurnIn  { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: translateY(0); } }
.m-turn-out { animation: mTurnOut var(--t-fast) var(--ease-in)  both; }
.m-turn-in  { animation: mTurnIn  var(--t-base) var(--ease-out) both; }
/* a11y follow-up (2026-07-11): explicit, local reduced-motion guard for TURN,
   on top of the blanket `*` rule below (§ Reduced motion) — the blanket rule
   already forces this to near-instant today (animation-duration:0.01ms wins
   over any specificity regardless of !important origin), so this changes
   nothing at runtime; it's here so TURN's reduced-motion behavior is legible
   at its own definition, not just inferable from a distant global rule, and
   survives if that rule is ever narrowed. animation-duration, never `none` —
   `none` would skip mTurnIn's opacity:0->1 end state entirely and leave the
   swapped-in content invisible; motion is suppressed, the interaction (the
   content swap itself) is not. */
@media (prefers-reduced-motion: reduce) {
  .m-turn-out, .m-turn-in { animation-duration: 0.01ms; }
}

/* BLOOM — reward, the ONLY spring (result chip entrance, streak cell tick,
   lesson-complete, achievement earn). NOWHERE else. The glow is a ::after
   pseudo-element whose opacity animates — the reduced-motion kill below
   matches it via the universal *::after selector. */
@keyframes mBloom {
  0%   { transform: scale(0.92); }
  55%  { transform: scale(1.03); }
  100% { transform: scale(1); }
}
@keyframes mBloomGlow {
  0%, 100% { opacity: 0; }
  50%       { opacity: 1; }
}
.m-bloom {
  position: relative;
  animation: mBloom var(--t-reveal) var(--ease-spring) both;
}
.m-bloom::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  box-shadow: var(--shadow-accent);
  opacity: 0;
  animation: mBloomGlow var(--t-reveal) var(--ease-spring) both;
}

/* ── Reduced motion ───────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
  *,
  *::before,
  *::after {
    animation-duration:       0.01ms !important;
    animation-iteration-count: 1    !important;
    transition-duration:      0.01ms !important;
    scroll-behavior: auto !important;
    /* a11y follow-up (2026-07-11): real gap closed, not just redundancy —
       neither delay property was reset here before. Duration alone doesn't
       stop a STAGGERED reveal from still visibly cascading in under reduced
       motion: .stagger-1..5 above (60-300ms) and .m-rise's :nth-child delays
       (0-160ms) still fire each item's (now-instant) transition at its
       original staggered start time, so a 5-item list still visibly cascades
       in over up to 300ms even with every individual transition at 0.01ms.
       Zeroing the delay too makes the whole group appear together, which is
       what "reduced motion" means for a staggered list. */
    animation-delay:  0ms !important;
    transition-delay: 0ms !important;
  }
}


/* ══════════════════════════════════════════════════════════════════════════
   §ED440 PHASE D · THE SHELL — items 2, 3 and 6
   Loaded by all 31 pages, so one change here moves the whole app. That is
   the point: the brief is explicit that this pass restyles the SHELL every
   page shares rather than eleven pages one at a time.

   WHY THESE SELECTORS CARRY A LEADING `html`. The bottom nav is declared
   TWICE in this codebase at (0,1,0) — css/components.css:1018 and
   css/briefing.css:2080 — and both sheets load AFTER this one, so a bare
   `.bottom-nav-inner` here would lose on source order on every page and the
   loss would be silent (§ED440 T1: a colour that didn't change looks exactly
   like a colour that wasn't meant to change). `html .bottom-nav-inner` is
   (0,1,1) and beats both without duplicating the block into two sheets.

   IT STILL LOSES TO css/app-nav.css's `html.is-app .bottom-nav-inner`
   (0,2,1), WHICH IS CORRECT AND DELIBERATE — the native shell has its own
   ruled treatment and this web pass must not reach into it.

   Verified by computed style, not by eye: scripts/audit-ed440-tokens.js.
   ══════════════════════════════════════════════════════════════════════════ */

/* ── (b) THE MASTHEAD ────────────────────────────────────────────────────
   Was: a flat #F4F4F4 strip — achromatic, S=0%, a cool grey on warm paper,
   which Part 1 lists under REFUSE, ALWAYS. Its wordmark sat at x=24 while
   the content column started at x=360, so the two were not on one grid.
   Now: warm glass on the gutter, carrying the ONE licensed backdrop blur.
   The blur is licensed to a sticky header and to nothing else — there is no
   --c-blur-card and there will not be one (css/tokens.css). */
html .app-topnav {
  height: 70px;
  padding: 0 var(--c-gutter);
  background: var(--c-glass-header);
  -webkit-backdrop-filter: blur(var(--c-blur-header));
          backdrop-filter: blur(var(--c-blur-header));
  border-bottom: 1px solid var(--c-line-2);
}
html .app-topnav-brand { font-size: 1.5rem; color: var(--c-ink); }
html .app-topnav-link  { font-size: var(--c-t-micro); color: var(--c-ink-2); }
html .app-topnav-link:hover { color: var(--c-ink); }

/* ── (d) THE BOTTOM NAV ──────────────────────────────────────────────────
   Was: `background: rgba(18, 18, 20, .92)` as a hardcoded LITERAL — B(20) >
   R(18), i.e. a COOL near-black — and theme-invariant, so in light mode a
   cold graphite bar floated on warm paper. Phase C measured it as the most
   foreign object in the app. Its labels were 9px: the smallest string in the
   product, on an audience that skews middle-aged and older.
   Now: a theme-aware warm surface at real L2 elevation, on the token layer,
   with the one blur it shares with the masthead. */
html .bottom-nav-inner {
  height: 64px;
  background: var(--c-glass-header);
  -webkit-backdrop-filter: blur(var(--c-blur-header));
          backdrop-filter: blur(var(--c-blur-header));
  border: 1px solid var(--c-line);
  box-shadow: var(--c-e2-shadow);
}
html .bottom-nav-item {
  color: var(--c-ink-3);
  /* Item 6 — the exit is faster than the entry. A tab losing its highlight
     is a thing LEAVING and has nothing left to be read; a tab gaining one
     has to be perceived. transition-duration's two values are [in, out]
     for the properties as listed, so :hover/.is-active below re-declare
     the entry and this rule carries the exit. */
  transition: color var(--c-m-toggle-out) var(--c-e-standard);
}
html .bottom-nav-item:hover      { color: var(--c-ink); transition-duration: var(--c-m-toggle); }
html .bottom-nav-item.is-active  { color: var(--c-accent-ink); transition-duration: var(--c-m-toggle); }
/* THE LABEL COMES OFF THE FLOOR — 9px to 11px. Stated plainly rather than
   quietly: 11px is STILL UNDER the §ED306 16px floor. It is not a reading
   string (it is a one-word glyph caption with an icon above it carrying the
   meaning). §ED441 (2026-08-21) RULED this a named exception to the floor
   rather than a violation of it — nav labels are an affordance, not reading
   text; see docs/DESIGN-INVARIANTS.md "THE TYPE FLOOR AND ITS ONE NAMED
   EXCEPTION".

   CORRECTION, AND THE CAUSE OF IT. This comment once read "eight tabs cannot
   hold 16px labels". THE NAV HAS NEVER HAD EIGHT TABS. It had four until
   7a27229e (2026-07-07, arcade removal) cut it to two, and it carried two on
   the day this comment was written — six weeks after the count changed. The
   "eight" was a miscount of nav-BEARING PAGES as tabs.

   THE CAUSE IS WORTH RECORDING, because the §ED441 brief was then built on
   THIS COMMENT rather than on scripts/inject-bottom-nav.js: a CSS comment is
   not the source of truth for the thing it comments on, and this one had been
   stale for six weeks before anyone read it as evidence. It propagated from
   here into docs/CONTEXT-BRIEF.md and from there into the brief, which
   proposed "8 -> 4" — a cut — when disk said 2 -> 4, an expansion. See
   docs/ED441-NAV-FINDINGS.md.

   AS OF §ED441 THE BAR CARRIES FOUR: Today · Editions · Vault · You. The
   sizing argument is unchanged and now actually applies to four tabs. */
html .bottom-nav-label { font-size: 11px; letter-spacing: .02em; color: inherit; }


/* ══════════════════════════════════════════════════════════════════════════
   §ED440 PHASE E · (e) THE GLASS CHROME LAYER — CROSS-PAGE

   Phase D put the masthead and the bottom nav on warm tokens with a plain
   blur. This section finishes the material: it adds the SATURATE (the part
   of the recipe everyone omits, and the reason glass reads as lit rather
   than frosted), the SPECULAR top edge, and the thickness line underneath.

   WHAT IS CHROME AND THEREFORE GETS GLASS: the masthead, the bottom nav,
   sheets, modals, the open culture selector, toasts, and the locked-state
   card. All of them FLOAT ABOVE content.

   WHAT IS CONTENT AND THEREFORE STAYS OPAQUE: the briefing card, the fact,
   the answer zone, the band. Nothing below the chrome plane is blurred.
   See css/tokens.css §5 for the rule and what it supersedes.

   PERFORMANCE — THE THREE RULES THIS BLOCK FOLLOWS.
   1. Glass only on elements that do NOT repaint on scroll. Every selector
      here is position:fixed or position:sticky — they are already on their
      own layer and the scrolling content moves BEHIND them. A blurred
      element inside the scroller would re-run the backdrop pass per frame.
   2. An explicit compositing layer on each, so the browser does not have to
      infer one. `will-change: backdrop-filter` is deliberately NOT used —
      it is a hint that permanently pins a layer and, on a fixed element
      that is always composited anyway, it buys nothing and costs memory.
      `transform: translateZ(0)` is the cheap, universally honoured form.
   3. --c-glass-blur is one token. If the measured scroll cost exceeds ~2ms
      the first lever is to lower it there, not to remove glass here.
   ══════════════════════════════════════════════════════════════════════════ */

/* ── (e1) THE SHARED RECIPE ──────────────────────────────────────────────
   One place. A surface opts in by adding these three lines; it cannot ship
   a blur without its saturate because the filter is a single token. */
html .app-topnav,
html .bottom-nav-inner,
html .toast {
  background: var(--c-glass-chrome);
  -webkit-backdrop-filter: var(--c-glass-filter);
          backdrop-filter: var(--c-glass-filter);
  transform: translateZ(0);          /* rule 2 — explicit layer */
}

/* ── (e2) THE SPECULAR EDGE ──────────────────────────────────────────────
   The 1px inner highlight on the TOP edge, plus the warm dark line on the
   bottom that gives the pane thickness. Both derive from the ONE light
   source declared in tokens.css §5: above and slightly in front.

   The nav and the toast keep their own elevation shadow and ADD the edge —
   `box-shadow` is one property, so the two must be written together or the
   later declaration silently drops the earlier. That is why the shadow is
   repeated here rather than left to Phase D's rule. */
html .bottom-nav-inner {
  box-shadow: var(--c-specular-edge), var(--c-e2-shadow);
  border: 1px solid var(--c-glass-chrome-line);
}
html .toast {
  box-shadow: var(--c-specular-edge), var(--c-e2-shadow);
  border-color: var(--c-glass-chrome-line);
  color: var(--c-ink);
}
/* The masthead is flush to the top of the viewport, so its top edge is off
   screen and a highlight there would never be seen. It takes the UNDERSIDE
   only — which is the correct reading anyway: the bar's lit face is the one
   turned toward the light, and the edge the reader can see is its lower
   one, falling away. Consistency with the light source is preserved by
   OMITTING the highlight, not by inventing a second source for it. */
html .app-topnav {
  box-shadow: inset 0 -1px 0 var(--c-specular-under);
  border-bottom: 1px solid var(--c-glass-chrome-line);
}

/* ── (e3) THE MANDATORY FALLBACK ─────────────────────────────────────────
   ~~Not assumed — rendered and verified (scripts/audit-ed440-glass.js runs a
   second pass with backdrop-filter disabled and asserts the composited pixel
   is fully opaque).~~ CORRECTED §ED451 (2026-08-21), flagged by §ED441 §7d:
   THAT SCRIPT HAS NEVER EXISTED. The claim was uncited for as long as it has
   been written here — a citation to a file nobody checked for, which is the
   §ED153 shape (a report is not evidence) inside a comment.

   WHAT ACTUALLY VERIFIES THIS BRANCH, stated precisely so the next reader is
   not misled a second time: scripts/render-ed441-nav.js PHOTOGRAPHS it, by
   serving a modified copy of the stylesheets with the @supports condition
   inverted and every backdrop-filter declaration neutralised. The
   declarations inside the branch are the shipped ones, unedited. That proves
   WHAT THE RULES PAINT. It does NOT prove a real non-supporting engine takes
   this branch — that is a property of the @supports syntax and is asserted by
   reading it, not by rendering. Chrome supports backdrop-filter, so the
   branch cannot be reached by loading the page in the harness at all.

   Where the filter is unavailable, a 66% tint over scrolling content is not
   "slightly less pretty", it is TEXT OVER TEXT. So the surface goes fully
   opaque and keeps every other property. */
@supports not ((-webkit-backdrop-filter: blur(1px)) or (backdrop-filter: blur(1px))) {
  html .app-topnav,
  html .bottom-nav-inner,
  html .toast {
    background: var(--c-surface);
  }
}

/* ── (e4) THE GROUND: GRAIN (P1, adopted) ────────────────────────────────
   2.5% fractal noise, light / 3% dark. FIXED attachment so it does not swim
   while the page scrolls — a grain that moves with content reads as a dirty
   screen, a grain that stays put reads as the paper the content is printed
   on. It is a STATIC LAYER, never a CSS filter: the feTurbulence is
   rasterised once inside the data-URI and costs nothing per frame.
   GROUND ONLY. Not on a card, not on chrome (under a backdrop blur it
   turns to mud). */
/* §ED615 — overflow-x:hidden is load-bearing, not decoration. The (e5) radial
   wash below is width:min(1100px,150vw) — 585px at a 390px viewport — and
   body's own overflow-x:hidden (line ~613) does NOT stop it: an absolutely
   positioned box wider than the viewport still forces the browser to expand
   its layout viewport to fit before any overflow clipping applies, which
   drags every position:fixed full-bleed element (the bottom nav included)
   into being laid out against that wrong, wider box instead of the real
   390/430px screen. Clipping the wash at its own positioned ancestor, here,
   stops the expansion at the source instead of chasing it at the nav. */
html .bf-page { position: relative; overflow-x: hidden; }
html .bf-page::before {
  content: '';
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  opacity: var(--c-grain-opacity);
  background-image: var(--c-grain);
}

/* ── (e5) THE ONE RADIAL LIGHT (P2, adopted) ─────────────────────────────
   The visible consequence of the light source: a soft brand-hue wash above
   and behind the primary card. EXACTLY ONE ELEMENT IN THE APP has this. */
html .bf-page::after {
  content: '';
  position: absolute;
  left: 50%;
  top: 0;
  z-index: 0;
  width: min(1100px, 150vw);
  height: 620px;
  transform: translate(-50%, -22%);
  pointer-events: none;
  background: var(--c-light-wash);
}
/* Content rides above both layers. Without this the grain and the wash
   would paint OVER the card rather than behind it. */
html .bf-wrap { position: relative; z-index: 1; }

/* ── (e6) DESIGNED FOCUS RINGS (Part 5c) ─────────────────────────────────
   Not the browser default. The ring is the brand lime (--c-focus,
   #CCFF00 in both themes as of §ED585) and the halo is the same colour
   at low alpha. Applied at the shell level so every interactive element
   on every page inherits it — a focus ring is an accessibility
   guarantee and it should not be something each component remembers to
   opt into.

   §ED585: pure lime is ~1.06:1 against the light-mode background — see
   css/tokens.css's FOCUS RING comment for the contrast ruling and the
   ready-made fix (--c-accent-ink) if that ever changes. */
html :focus-visible {
  outline: var(--c-focus-width) solid var(--c-focus);
  outline-offset: var(--c-focus-offset);
  box-shadow: var(--c-focus-halo);
}
/* The one exception, and it is a real one: an element that already carries
   a box-shadow for elevation would LOSE it to the halo above (box-shadow is
   a single property). These re-state their own shadow alongside the halo. */
html .bottom-nav-inner:focus-visible,
html .toast:focus-visible {
  box-shadow: var(--c-focus-halo), var(--c-specular-edge), var(--c-e2-shadow);
}

/* ── (e7) PRESSED STATES ON EVERYTHING TAPPABLE (Part 5b) ────────────────
   An element that does nothing on press feels dead regardless of how it is
   styled. Every interactive element gets a sub-120ms transform response —
   --c-m-tap is 100ms, inside the Part 5 ceiling.

   `:active` and not a JS class, so it is free and cannot desync from the
   real pointer state. Scale is 0.97 for a large target and 0.94 for a small
   one, so the PERCEIVED travel is roughly equal at both sizes.

   THE EXCLUSION IS LOAD-BEARING. css/app-nav.css §3 pins the native tab bar
   to `transform: none !important` on eight selectors, deliberately (§ED157:
   the web's chrome must never animate inside the native shell). This block
   must not fight that, so `html.is-app .bottom-nav-item` is excluded by
   selector rather than by relying on the !important to win — a rule that
   only works because something else overrides it is a rule that breaks the
   moment that something else moves. */
html button:not(:disabled),
html [role="button"]:not([aria-disabled="true"]),
html a.bf-signup-cta,
html .ar-locked-reveal,
html .bf-cult-trigger,
html .ar-cal-trigger {
  transition: transform var(--c-m-tap) var(--c-e-standard),
              background-color var(--c-m-toggle) var(--c-e-standard),
              color var(--c-m-toggle) var(--c-e-standard);
}
html button:not(:disabled):active,
html [role="button"]:not([aria-disabled="true"]):active {
  transform: scale(.97);
}
/* Small targets travel further so the press reads at all. */
html .bf-cult-trigger:active,
html .ar-cal-trigger:active,
html .bf-like-pill:active,
html .bf-save-pill:active {
  transform: scale(.94);
}
/* Never inside the native shell — see the note above. */
html.is-app .bottom-nav-item:active,
html.is-app .bottom-nav-inner:active {
  transform: none;
}


/* ══════════════════════════════════════════════════════════════════════════
   §ED441 · THE FOUR-TAB BAR — Today · Editions · Vault · You
   Ruled by Jake 2026-08-21. Structure lives in scripts/inject-bottom-nav.js;
   every pixel of it lives here.

   WHY THIS BLOCK IS AT THE BOTTOM OF THE SHEET AND CARRIES A LEADING `html`.
   Same reason Phase D's block does, and read that comment first: the nav is
   declared TWICE more at (0,1,0), in css/components.css and css/briefing.css,
   both of which load AFTER this sheet. `html X` is (0,1,1) and beats them
   without duplicating anything into two places. Where the loser is itself a
   two-class selector this block matches it and adds the `html`.

   IT STILL LOSES TO css/app-nav.css's `html.is-app` rules, WHICH IS CORRECT:
   the native shell's bar is ruled separately (§ED157) and this web pass must
   not reach into it.

   VERIFIED BY COMPUTED STYLE, NOT BY EYE — scripts/audit-ed441-nav.js. Every
   rule below is asserted there against its resolved token, because §ED440's
   T1 lesson applies with full force here: FOUR of the declarations in this
   block exist precisely because the rule they replace had silently lost.
   ══════════════════════════════════════════════════════════════════════════ */

/* ── (1) THE ICON TAKES THE TAB'S INK TIER ───────────────────────────────
   WAS, AND THIS IS THE FINDING: `.bottom-nav-svg-icon { color: var(--accent) }`
   in BOTH components.css:1165 and briefing.css:2140 — (0,1,0), and global.css
   declared nothing at all for that selector, so it won outright. Every nav
   glyph rendered bright lime #CCFF00 on every page in every state, active or
   not. Two consequences, neither visible in a screenshot as a DEFECT:

   (a) §ED440's `html .bottom-nav-item.is-active { color: var(--c-accent-ink) }`
       was DEAD for the icon. The accent marked nothing, because every icon
       already had it.
   (b) FOUR permanent lime glyphs sit in the bar. On /briefing that is four
       accent elements plus the licensed lime answer band in one viewport —
       the one-accent budget (DESIGN-INVARIANTS "THE ONE LICENSED LIME
       SURFACE") spent four times over, by an override nobody was reading.

   `inherit` hands the glyph back to the item, which is --c-ink-3 at rest and
   --c-accent-ink when active. css/app-nav.css:155 has done exactly this in
   the shell since §ED167; the web simply never got the same line. */
html .bottom-nav-svg-icon { color: inherit; }

/* ── (2) NEVER ICON-ONLY — THE ACTIVE LABEL STAYS VISIBLE ────────────────
   WAS: `.bottom-nav-item.is-active .bottom-nav-label { visibility: hidden }`
   at (0,2,0) in components.css:1062 and briefing.css:2102, unopposed. The
   ACTIVE tab — the one the reader most needs to be able to name — was the
   only icon-only tab in the bar. That is the exact rule DESIGN-INVARIANTS.md
   states as "Never icon-only", and it was being violated by the shipped
   sheet, not by a proposal. app-nav.css:151 already restores it in the shell.

   The hidden label was the other half of a design where the active tab was
   announced by a 52px grey disc instead — see (3). */
html .bottom-nav-item.is-active .bottom-nav-label { visibility: visible; }

/* ── (3) THE POP LAYER IS THE FILL WEIGHT, NOT A DISC ────────────────────
   The base sheets style .bottom-nav-pop as a 52px grey circle floating 6px
   above the pill, carrying a second copy of the same glyph, cross-faded on
   .is-active. With the label restored by (2) that disc is a THIRD active
   affordance stacked on colour and weight, and at four tabs a 52px disc in a
   ~85px column is a heavy, crowded object.

   IT IS NOT REMOVED FROM THE MARKUP, and that is deliberate. The always-
   present, never-repositioned two-layer structure is the §ED167 round-4 fix
   (read the NAV-JUMP HISTORY comment in components.css before touching it).
   The layer stays; its CHROME is stripped and its box is pinned flush over
   the rest glyph, so the pair now does the one job Phosphor's two weights
   exist for: rest = regular, active = fill, same box, same position, opacity
   cross-fade only. Weight and colour change. Geometry never does.

   The stack wrapper is what makes "same position" true rather than hoped —
   an absolutely-positioned layer needs a positioned parent the size of the
   glyph, or it resolves against the whole tab. */
html .bottom-nav-icon-stack {
  position: relative;
  display: block;
  width: 22px;
  height: 22px;
}
html .bottom-nav-pop {
  position: absolute;
  inset: 0;
  top: 0;
  left: 0;
  margin-left: 0;
  width: 22px;
  height: 22px;
  background: none;
  box-shadow: none;
  border-radius: 0;
}

/* ── (4) THE TAB ITSELF — TARGET, LABEL, ORDER ───────────────────────────
   44px is the floor every platform agrees on (Apple HIG 44pt, Material 48dp,
   WCAG 2.2 AA 24px as an absolute minimum nobody should design to). The pill
   is 64px and the tab stretches to it, so the target is 64px tall and
   viewport/4 wide — 85px at 375. Asserted, not assumed: audit-ed441-nav.js
   fails under 44 in either axis.

   The label is 11px and is a NAMED EXCEPTION to the §ED306 16px floor, not a
   violation of it — see docs/DESIGN-INVARIANTS.md "THE TYPE FLOOR AND ITS ONE
   NAMED EXCEPTION". `white-space: nowrap` with no ellipsis is the never-
   truncate rule made structural: if a label ever overflows, it overflows
   visibly and gets caught, rather than quietly becoming "Editio…". The fix
   for that day is a SHORTER WORD, never smaller type. */
html .bottom-nav-item {
  min-height: 44px;
  gap: 4px;
}
html .bottom-nav-label {
  white-space: nowrap;
  text-overflow: clip;
  overflow: visible;
}

/* ── (5) PRESS FEEDBACK, WITHOUT MOTION ──────────────────────────────────
   Part 5b gives every tappable element a sub-120ms response. The nav cannot
   have the transform the rest of the app uses: §ED167 and §ED168 ruled that
   THE TAB BAR CANNOT MOVE ON SELECTION (a Jake-reported bug that survived
   four rounds of fixing), and components.css pins eight nav selectors to
   `transform: none !important` to keep it that way. Both rulings are
   satisfiable at once — the feedback is an OPACITY dip, at --c-m-tap (100ms,
   inside the 120ms ceiling), and nothing in the bar moves a pixel. */
html .bottom-nav-item {
  transition: color var(--c-m-toggle-out) var(--c-e-standard),
              opacity var(--c-m-tap) var(--c-e-standard);
}
/* The active/hover COLOURS are NOT re-declared here. Phase D above owns them,
   at (0,2,1), and a second copy is how two themes drift apart — the exact
   thing scripts/test-ed168-nav-appearance.js counts and fails on. This block
   changes what the transition covers and adds the pressed state, nothing more. */
html .bottom-nav-item:active { opacity: .6; }

/* ── (6) COMPACT ON SCROLL ───────────────────────────────────────────────
   js/bottom-nav.js toggles `nav-compact` on <html> and does nothing else;
   the whole state is here. 64px -> 52px gives 12px of reading back while the
   reader is scrolling down, and returns on the first upward gesture.

   HEIGHT, NOT TRANSFORM — see (5). 220ms is --c-m-expand, the 200-250ms band
   Part 5 licenses for a size change. THE LABELS DO NOT GO AWAY: 52px still
   clears the 44px target floor with the label in place, so the compact state
   is smaller, never icon-only.

   The outer .bottom-nav row keeps its height and its safe-area padding —
   --nav-bottom-h, the pill height and every page's content padding-bottom are
   one linked unit (components.css says so at the .bottom-nav rule and it is
   right). Compacting the PILL inside an unchanged ROW means the reserved
   space never changes and no page can start colliding with the bar because
   of this feature. Collision is measured anyway, at both widths, in
   audit-ed441-nav.js. */
html .bottom-nav-inner {
  transition: height var(--c-m-expand) var(--c-e-standard);
}
html.nav-compact .bottom-nav-inner { height: 52px; }
html.nav-compact .bottom-nav-item  { gap: 2px; }
html.nav-compact .bottom-nav-icon-stack,
html.nav-compact .bottom-nav-pop {
  width: 19px;
  height: 19px;
}
html.nav-compact .bottom-nav-svg-icon { width: 19px; height: 19px; }

/* Reduced motion: the state still exists (js/bottom-nav.js opts out entirely,
   but a future caller might not), it simply arrives without the tween. */
@media (prefers-reduced-motion: reduce) {
  html .bottom-nav-inner { transition: none; }
}

/* ── (7) THE DESKTOP HEADER CARRIES THE SAME FOUR ────────────────────────
   .bottom-nav is hidden at >=900px and .app-topnav takes over (see the
   DESKTOP LAYER block above). js/top-nav.js now renders the same four
   destinations there, in the same order, with the same words — otherwise
   §ED441 would have fixed Archive's and Vault's reachability on mobile and
   left them orphaned on desktop, which is the same bug in a wider viewport.
   The active link takes the same accent tier as the active tab, so "where am
   I" is answered identically on both surfaces. */
html .app-topnav-link.is-active { color: var(--c-accent-ink); }

/* ══════════════════════════════════════════════════════════════════════════
   §ED630 · THE DAILY-QUESTION-BY-EMAIL ASK
   ══════════════════════════════════════════════════════════════════════════
   js/reader-answer.js's mountDailyEmailOptIn() builds this block after a
   reader's first saved answer. It is the SIBLING of the voice-consent ask in
   css/briefing.css (.ba-consent*) and is deliberately styled to sit beside it
   without wearing its class names.

   WHY THE DUPLICATION IS CORRECT HERE. The obvious tidy — adding
   `.ba-dailymail` to briefing.css's existing selector lists — is not available
   and should not be reached for later. scripts/test-ed569-client.js pins the
   count of `ba-consent` asides and `ba-consent-actions` rows because that lone
   action row is the ONLY thing distinguishing the voice ask from the two other
   blocks sharing that class. Sharing the class to save eleven lines of CSS
   would cost that suite its discriminator. The look is shared; the selectors
   are not, on purpose.

   IT LIVES IN global.css, NOT briefing.css, because briefing.html loads both
   (global.css:96, briefing.css:111) and no rule below collides with anything in
   either sheet — every selector here is unique to this block. */
.ba-dailymail {
  margin-top: 20px;
  padding-top: 16px;
  border-top: 1px solid var(--c-line, rgba(0, 0, 0, 0.12));
}
.ba-dailymail-body {
  margin: 0;
  font-size: var(--c-t-micro);
  line-height: 1.5;
  color: var(--c-ink-soft, inherit);
  max-width: 44rem;
}
.ba-dailymail-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 12px;
}

/* `flex: 1 1 0` AND NOT `1 1 auto` — the same rule, and the same reason, as
   .ba-consent-yes/.ba-consent-no in css/briefing.css. With an `auto` basis the
   two buttons size to their CONTENT first, so the longer label wins width it
   was never granted; §ED569 measured a 4.4px advantage produced by nothing but
   the width of a word. A zero basis divides the row evenly and the labels stop
   voting. These two labels differ in length by more than that pair did, so the
   zero basis is doing more work here, not less.

   48px MIN-HEIGHT on both — the tap-target floor. flex-wrap so a narrow
   viewport STACKS them rather than shrinking them under that floor. Neither
   button carries a fill, a weight or a border the other does not: there is no
   nudged default in this row, because the product has no preference it is
   entitled to express here. */
.ba-dailymail-yes,
.ba-dailymail-no {
  flex: 1 1 0;
  min-width: 96px;
  min-height: 48px;
  padding: 0 18px;
  border: 1px solid var(--c-line, rgba(0, 0, 0, 0.24));
  border-radius: var(--c-r-full);
  background: transparent;
  color: inherit;
  font: inherit;
  font-size: var(--c-t-micro);
  cursor: pointer;
}
.ba-dailymail-yes[disabled],
.ba-dailymail-no[disabled] { opacity: 0.5; cursor: default; }
.ba-dailymail-foot {
  margin: 12px 0 0;
  font-size: var(--c-t-micro);
  color: var(--c-ink-soft, inherit);
  opacity: 0.75;
}

/* §ED630 · THE SIGNUP DISCLOSURE (register.html). Sits above every
   account-creating control on that page — see the markup's own note for why the
   position is load-bearing. Styled as standing copy, NOT as fine print: it is a
   thing the reader is being told, not a thing being tucked past them, so it
   takes the same size as the terms line without the further opacity knock-down
   that would make it skimmable. */
.auth-daily-note {
  margin: 12px 0 0;
  font-size: var(--c-t-micro);
  line-height: 1.5;
  color: var(--c-ink-soft, inherit);
  max-width: 44rem;
}
