    /* ── Self-hosted fonts (9 Aug 2026) ──────────────────────────────
       Replaces fonts.googleapis.com/fonts.gstatic.com — serving from Google's
       CDN sends every user's IP to Google (GDPR exposure, LG München I 2022).
       Files live in assets/fonts/, "latin" subset only (covers Danish æ/ø/å —
       they fall within the Latin-1 range U+0000–00FF that subset ships).
       Weights/styles below are exactly what the app uses; do not add a weight
       here without also adding its file to assets/fonts/ and sw.js's precache. */
    @font-face {
      font-family: 'Cormorant Garamond';
      font-style: normal;
      font-weight: 300;
      font-display: swap;
      src: url('assets/fonts/cormorant-garamond-latin-300-normal.woff2') format('woff2');
    }
    @font-face {
      font-family: 'Cormorant Garamond';
      font-style: italic;
      font-weight: 300;
      font-display: swap;
      src: url('assets/fonts/cormorant-garamond-latin-300-italic.woff2') format('woff2');
    }
    @font-face {
      font-family: 'Cormorant Garamond';
      font-style: normal;
      font-weight: 400;
      font-display: swap;
      src: url('assets/fonts/cormorant-garamond-latin-400-normal.woff2') format('woff2');
    }
    @font-face {
      font-family: 'Cormorant Garamond';
      font-style: italic;
      font-weight: 400;
      font-display: swap;
      src: url('assets/fonts/cormorant-garamond-latin-400-italic.woff2') format('woff2');
    }
    @font-face {
      font-family: 'Cormorant Garamond';
      font-style: normal;
      font-weight: 600;
      font-display: swap;
      src: url('assets/fonts/cormorant-garamond-latin-600-normal.woff2') format('woff2');
    }
    @font-face {
      font-family: 'Cormorant Garamond';
      font-style: italic;
      font-weight: 600;
      font-display: swap;
      src: url('assets/fonts/cormorant-garamond-latin-600-italic.woff2') format('woff2');
    }
    @font-face {
      font-family: 'Marcellus';
      font-style: normal;
      font-weight: 400;
      font-display: swap;
      src: url('assets/fonts/marcellus-latin-400-normal.woff2') format('woff2');
    }
    @font-face {
      font-family: 'Jost';
      font-style: normal;
      font-weight: 300;
      font-display: swap;
      src: url('assets/fonts/jost-latin-300-normal.woff2') format('woff2');
    }
    @font-face {
      font-family: 'Jost';
      font-style: normal;
      font-weight: 400;
      font-display: swap;
      src: url('assets/fonts/jost-latin-400-normal.woff2') format('woff2');
    }
    @font-face {
      font-family: 'Jost';
      font-style: normal;
      font-weight: 500;
      font-display: swap;
      src: url('assets/fonts/jost-latin-500-normal.woff2') format('woff2');
    }
    @font-face {
      font-family: 'Jost';
      font-style: normal;
      font-weight: 600;
      font-display: swap;
      src: url('assets/fonts/jost-latin-600-normal.woff2') format('woff2');
    }
    @font-face {
      font-family: 'Lora';
      font-style: normal;
      font-weight: 400;
      font-display: swap;
      src: url('assets/fonts/lora-latin-400-normal.woff2') format('woff2');
    }
    @font-face {
      font-family: 'Lora';
      font-style: italic;
      font-weight: 400;
      font-display: swap;
      src: url('assets/fonts/lora-latin-400-italic.woff2') format('woff2');
    }
    @font-face {
      font-family: 'Lora';
      font-style: normal;
      font-weight: 500;
      font-display: swap;
      src: url('assets/fonts/lora-latin-500-normal.woff2') format('woff2');
    }
    @font-face {
      font-family: 'Lora';
      font-style: italic;
      font-weight: 500;
      font-display: swap;
      src: url('assets/fonts/lora-latin-500-italic.woff2') format('woff2');
    }

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

    :root {
      --white:        #FFFFFF;
      --white-soft:   #FCFBF8; /* softer off-white, same tone as .reading-card — Aug 2026 */
      --bg:           #F8F9FA;
      --surface:      #F0F2F5;
      --border:       #E0E4EA;
      --text:         #1A1D21;
      --text-2:       #6B7280;
      --text-3:       #9CA3AF;
      --accent:       #3D6F96;
      --accent-light: #D6E8F5;
      --success:      #4E7E63;
      --error:        #B83232;
      /* Tint/hairline pair for --error, same idea as --accent-wash for
         --accent. Only for elements calling out something destructive or
         actionable (e.g. .settings-import-warn) — an informational aside
         should not borrow error red just to stand out; see
         .settings-card-info. */
      --error-wash:     rgba(184, 50, 50, 0.06);
      --error-hairline: rgba(184, 50, 50, 0.22);
      /* Growth green (5 Sep 2026) — the ONLY green in the palette, and it
         exists for one thing: the flashcard's plant meter. Not --success
         (#4E7E63), which means "this action worked" and is used for toasts and
         confirmations; a word's progress is a state, not an outcome, and
         borrowing success green would make every card look like a completed
         task. Don't spread this to other components without deciding it is a
         palette colour rather than a component one. */
      --leaf:         #5E8C4A;
      /* The mastery bloom. Pink, and the only pink in the palette — it is the
         one reward state in the app, and it has to be unmistakable against
         three greens. Its centre stays --gold-press, so the flower reads as a
         flower and the brand gold is still in it. */
      --bloom:        #D2789C;
      --leaf-empty:   #CBD5CE; /* ungrown slot — a desaturated tint of --leaf */
      /* A word below zero: wrong answers dug a hole. Dry brown, deliberately
         quieter than --leaf and nowhere near --bloom, which is the one reward
         colour and must stay unmistakable. The wilted leaf is also FLIPPED
         (see _LEAF_FLIP in vocab.js) so it reads at 13px even where the hue
         washes out over a light patch of wallpaper. */
      --leaf-wilt:    #A08155;
      --gold:         #D9A441; /* brand amber (logo) — small accents */
      --gold-tint:    #F7E5B0; /* soft saved-word highlight (warm tint of --gold) */
      --gold-press:   #EFD083; /* highlight press state */

      --font-ui:   'Jost', system-ui, sans-serif;
      --font-mark: 'Cormorant Garamond', Georgia, serif;
      /* Home word-of-the-day only (Aug 2026) — Marcellus is a single-weight
         display face; it faux-bolds at the 500/600 weights and wasn't
         tested below ~20px, both used elsewhere under --font-mark. Scoped
         to .wp-word (56px) only. Do not swap --font-mark itself to this —
         see DESIGN.md's design language. */
      --font-word-home: 'Marcellus', Georgia, serif;
      --font-body: 'Lora', Georgia, serif;

      --r-sm: 6px;
      --r-md: 14px;
      --r-lg: 22px;
      /* Button-family radii (Aug 2026). Deliberately separate from --r-sm:
         that token is still correct for containers, panels and inputs
         (.vocab-context-card, .popup-sentence-block, .settings-import-warn,
         .add-word-input). At button scale 6px read
         as a clipped corner rather than a soft one. --r-btn is for filled
         or labelled action buttons; --r-pill is for icon buttons and chips.
         Do not re-point the container sites at these. */
      --r-btn:  13px;
      --r-pill: 99px;
      /* Resting tint for icon buttons and chips. Replaces the grey
         --border hairline, which read as "disabled" rather than "tappable".
         Kept far lighter than --accent-light so the .active / .playing
         states (which use --accent-light + an --accent border) stay
         clearly distinguishable from rest. */
      --accent-wash: rgba(61, 111, 150, 0.07);
      --accent-wash-press: rgba(61, 111, 150, 0.16);
      /* Hairline to pair with --accent-wash, same idea as
         --error-hairline for --error. On a white card a --border
         hairline reads as disabled; this one reads as tappable. */
      --accent-hairline: rgba(61, 111, 150, 0.22);

    }

    html, body {
      height: 100%;
      background: var(--bg);
      font-family: var(--font-ui);
      color: var(--text);
      -webkit-font-smoothing: antialiased;
      -moz-osx-font-smoothing: grayscale;
      overflow: hidden;
    }

    /* ── Phone shell (desktop only) ──────────────────────────────── */
    .shell {
      width: 100%;
      max-width: 390px;
      /* NOT 100dvh, and not svh either (19 Aug 2026). In the installed Android
         app, returning from the Google sign-in redirect leaves dvh stuck at the
         "toolbar hidden" height: measured on device, the shell drew 836px into a
         780px window, pushing .bottom-nav off the bottom edge until the app was
         resumed. Chrome reported the window correctly throughout — innerHeight,
         clientHeight and visualViewport all said 780; only the dvh unit was
         stale (known Chromium bug, and it covers svh too). 100% resolves through
         html/body to documentElement's height, which stayed correct in both
         states. Do not "modernise" this back to a viewport unit. */
      height: 100%;
      margin: 0 auto;
      /* Flat white base. The colour behind all five screens now comes from
         .shell-bg (the blurred wallpaper echo) which sits at z-index:-1 —
         above this background, below the in-flow screens. Removing the
         .shell-bg element restores a plain white app with no other edits.

         Historical note: this was a white -> --accent-light gradient, then
         briefly a hue sampled from the wallpaper. The blurred echo replaced
         both — do not reinstate a second colour mechanism alongside it. */
      background: var(--white);
      display: flex;
      flex-direction: column;
      position: relative;
      /* REQUIRED for .shell-bg to be visible. position:relative with z-index:auto
         does NOT create a stacking context, so .shell-bg's z-index:-1 escaped to
         the root and painted behind the opaque `html, body { background: var(--bg) }`
         — the echo was invisible on every screen. isolate makes .shell the
         stacking context that contains it. Does not affect position:fixed
         descendants (only transform/filter/will-change create a containing block
         for those), so the fixed status dropdown panel is unaffected. */
      isolation: isolate;
      overflow: hidden;
    }

    @media (min-width: 500px) {
      body { display: flex; align-items: center; justify-content: center; }
      .shell {
        height: min(844px, 95dvh);
        border-radius: 44px;
        box-shadow:
          0 0 0 1px rgba(0,0,0,.08),
          0 32px 80px rgba(0,0,0,.18),
          0 8px 24px rgba(0,0,0,.1);
        overflow: hidden;
      }
    }

    /* ── Safe area top ───────────────────────────────────────────────
       The old value was `env(safe-area-inset-top, 44px)`, which gave NO top
       space in the common case. A fallback in env() only applies when the
       variable is unsupported — every modern browser supports it and simply
       reports 0px when there is no notch (a normal Safari/Chrome tab, desktop,
       or any non-standalone view). So the 44px never applied there and the
       header sat flush against the top edge.

       calc() instead of a fallback: 16px of air always, plus the notch inset on
       top of it when installed to the home screen — content should not butt
       against the notch either. */
    .safe-top {
      height: calc(env(safe-area-inset-top, 0px) + 16px);
      flex-shrink: 0;
    }

    /* ── Bottom navigation bar ───────────────────────────────────── */
    .bottom-nav {
      position: relative;        /* anchors the sliding indicator */
      display: flex;
      align-items: stretch;
      /* Frosted glass rather than solid white, so the wallpaper runs behind the
         nav instead of stopping at it. Matches the home action buttons, which
         use the same treatment. A solid --border top line read as a hard grey
         rule against the image, hence the white hairline.
         Alpha lowered 0.72 -> 0.45 -> 0.36 (Aug 2026) for more transparency;
         the backdrop-filter blur still keeps labels/icons readable over the
         wallpaper, this just lets more of the image's colour show through.

         0.36 is close to the floor. The nav's default label colour #7C8894 was
         tuned against a backdrop of 180-216/255, and Home — which has no veil —
         is the binding case: a mid-tone wallpaper not dark enough to trigger
         wp-nav-dark composites to ~177 at this alpha, already at the edge of
         that band. Going much below 0.36 means re-tuning the label colour, not
         just turning a knob. */
      background: rgba(255,255,255,0.36);
      -webkit-backdrop-filter: blur(18px) saturate(1.4);
      backdrop-filter: blur(18px) saturate(1.4);
      border-top: 1px solid rgba(255,255,255,0.4);
      box-shadow: 0 -1px 12px rgba(26,29,33,0.06);
      /* Same fix as .safe-top: the inset reports 0px in a browser tab, so the
         labels sat flush against the bottom edge. 10px base always, plus the
         home-indicator inset when installed. */
      padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 10px);
      flex-shrink: 0;
      z-index: 50;
    }
    /* The nav keeps this glass on EVERY screen. An opaque-white variant for the
       veiled screens shipped briefly (cd1d786) and was reverted the same day:
       it fixed the coloured-band complaint by separating the nav rather than
       connecting it, and the graded veil (see .shell-bg::after) solves the same
       problem by removing the seam instead. Do not reintroduce a per-screen nav
       background — the ramp only works if the nav is the rich end of it. */

    /* Single indicator bar that SLIDES horizontally to the active tab (JS sets transform) */
    .nav-indicator {
      position: absolute;
      top: 0;
      left: 0;
      width: 60px;
      height: 2px;
      border-radius: 0 0 3px 3px;
      background: var(--accent);
      opacity: 0;                /* shown once positioned by JS */
      transition: transform .32s cubic-bezier(.4,0,.2,1), opacity .2s;
      pointer-events: none;
    }
    .nav-tab {
      position: relative;
      flex: 1;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      gap: 1px;
      padding: 9px 4px 8px;
      cursor: pointer;
      border: none;
      background: none;
      /* NOT var(--text-3) (#9CA3AF) — that's tuned for a plain white
         background. The nav glass is only 45% white, so even wallpapers
         syncWallpaperTone() correctly classifies as "light" still composite
         to a medium-light backdrop (measured ~180-216/255 across the
         current set, never near true white), and --text-3 sits too close
         to that range to read (Aug 2026, user-reported contrast complaint
         on wallpapers 1/2/4/8 — all correctly classified light, so this
         was a base-colour problem, not a misclassification). #4B5157 was
         tried first (too dark), then #64707D (not elegant enough); this is
         the current, lighter revision — see DECISIONS.md for the full
         history, including a halo-based approach that was tried and
         reverted at the user's request. Darkening the default (rather than
         adding a third "medium" tone class) is what keeps the fix automatic
         for future wallpapers too, instead of tuning per image. */
      color: #7C8894;
      -webkit-tap-highlight-color: transparent;
      transition: color .18s;
    }
    .nav-tab.active { color: var(--accent); }
    /* Icon gives a little pop when its tab becomes active */
    .nav-tab svg { flex-shrink: 0; transition: transform .18s ease; }
    .nav-tab.active svg { animation: navPop .32s ease; }
    @keyframes navPop {
      0%   { transform: scale(1); }
      45%  { transform: scale(1.18); }
      100% { transform: scale(1); }
    }
    .nav-tab-label {
      display: inline-block;
      font-family: var(--font-ui);
      font-size: 10px;
      font-weight: 500;
      letter-spacing: 0.02em;
      transition: font-weight .15s;
    }
    .nav-tab.active .nav-tab-label { font-weight: 600; }
    /* Outline icon by default; swap to the filled variant on the active tab */
    .nav-ico-fill { display: none; }
    .nav-tab.active .nav-ico-line { display: none; }
    .nav-tab.active .nav-ico-fill { display: block; }

    /* Dark-wallpaper variant (Aug 2026) — toggled by core.js's
       syncWallpaperTone() when the image behind the nav reads as dark.
       .nav-tab's default color: #64707D and .active's var(--accent)
       (#3D6F96) were tuned against light/medium images; against a
       genuinely dark one, seen through the nav's 0.45-alpha glass, both
       still lose contrast. Also darkens the glass itself rather than just
       relying on lighter text, since a dark image through 45%-white glass
       still reads as fairly light and would fight lightened text.

       SCOPED TO .on-home ONLY (narrowed from .on-home, .wp-sharp — 7 Aug 2026).
       Everything above describes the nav sitting on the RAW painting, true on
       Home and, until this change, the two loading screens as well. On a
       veiled screen the nav's backdrop is wallpaper -> ~0.45-0.36 veil -> 0.45
       white glass, which composites to roughly 185-200/255 no matter how dark
       the image is (even a near-black one lands ~184) — squarely inside the
       180-216 band the default #7C8894 was tuned against, so the contrast
       problem this class exists to solve does not occur there. Applying it
       anyway on a veiled screen dropped that strip to ~90/255 beneath a page
       veiled to ~215 — a dark bar on a white screen (user-reported on device,
       Aug 2026) — which is why the scope excludes veiled screens at all.

       .wp-sharp screens (OCR/story loading) are unveiled, same as Home, so the
       same contrast argument for including them still holds in principle. They
       were dropped anyway: the loading screens are brief and reached mid-flow
       from a content screen, so the nav flipping color as soon as loading
       starts and flipping back a few seconds later read as a jarring flicker
       rather than a readability aid (user feedback, 7 Aug 2026). Accepted
       trade-off: the nav keeps its normal (veiled-screen) color through
       loading and can lose contrast against a genuinely dark, unveiled
       wallpaper for that brief window. Revisit only if that becomes a real
       complaint, not by reflex — the wp-sharp inclusion above was deliberate
       once already (cd1d786 revert) for exactly the contrast reason being
       traded away here.

       syncWallpaperTone() is deliberately left alone: it still samples the raw
       image and still sets the class on every screen (including .wp-sharp).
       The class is simply inert on .wp-sharp now, same as it already was on
       veiled screens. Making the sampler screen-aware would couple it to
       layout for no gain. */
    .shell.wp-nav-dark.on-home .bottom-nav {
      background: rgba(18,20,24,0.42);
      border-top-color: rgba(255,255,255,0.16);
    }
    .shell.wp-nav-dark.on-home .nav-tab { color: rgba(244,241,234,0.85); }
    .shell.wp-nav-dark.on-home .nav-tab.active { color: #9FCBEC; }

    /* ════════════════════════════════════════════════════════════════
       HOME SCREEN
    ════════════════════════════════════════════════════════════════ */
    #homeScreen {
      flex: 1;
      display: flex;
      flex-direction: column;
      overflow: hidden;
    }

    /* Hero */
    .hero {
      flex: 1;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      padding: 40px 36px;
      gap: 16px;
    }
    .hero-icon {
      width: 72px;
      height: 72px;
      border-radius: 20px;
      background: var(--surface);
      display: flex;
      align-items: center;
      justify-content: center;
      margin-bottom: 8px;
    }
    .hero-icon img { width: 100%; height: 100%; padding: 3px; box-sizing: border-box; }
    .hero-title {
      font-family: var(--font-mark);
      font-size: 26px;
      font-weight: 300;
      letter-spacing: -0.02em;
      color: var(--text);
      text-align: center;
      line-height: 1.15;
    }
    .hero-sub {
      font-size: 14px;
      font-weight: 300;
      color: var(--text-2);
      text-align: center;
      line-height: 1.65;
      max-width: 260px;
    }

    /* API keys — collapsed by default, toggled by key button */


    /* Action buttons */
    .actions {
      padding: 0 24px 20px;
      display: flex;
      flex-direction: column;
      gap: 10px;
    }

    /* Primary: filled accent */
    .btn-primary {
      width: 100%;
      min-height: 56px;          /* ≥ 44 pt touch target */
      border: none;
      border-radius: var(--r-md);
      background: var(--accent);
      color: var(--white);
      font-family: var(--font-ui);
      font-size: 15px;
      font-weight: 500;
      letter-spacing: 0.03em;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 10px;
      transition: opacity .15s, transform .1s;
      -webkit-tap-highlight-color: transparent;
    }
    .btn-primary:active { opacity: .85; transform: scale(.98); }

    /* First use of :disabled on .btn-primary (13 Sep 2026 — the Vocab list's
       "Start deep learn" with an empty filter). On the shared class, not that
       one caller: a disabled primary button should read the same everywhere.
       opacity + cursor is the app's existing disabled convention (see
       .vocab-regen-btn, .popup-save-btn). The :active cancel is not optional —
       some engines still match :active on a disabled button, which animates a
       press that does nothing. */
    .btn-primary:disabled { opacity: .45; cursor: default; }
    .btn-primary:disabled:active { opacity: .45; transform: none; }

    /* Secondary: outlined */
    .btn-secondary {
      width: 100%;
      min-height: 56px;
      border: 1px solid var(--border);
      border-radius: var(--r-md);
      background: var(--white-soft);
      color: var(--text-2);
      font-family: var(--font-ui);
      font-size: 15px;
      font-weight: 400;
      letter-spacing: 0.02em;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 10px;
      transition: background .15s, transform .1s;
      -webkit-tap-highlight-color: transparent;
    }
    .btn-secondary:active { background: var(--surface); transform: scale(.98); }

    .btn-or {
      text-align: center;
      font-size: 12px;
      color: var(--text-3);
      font-family: var(--font-ui);
      letter-spacing: 0.04em;
    }

    /* Home button colours — these were inline style attributes in index.html
       until the wallpaper test needed to override them. Scoped to the
       non-wallpaper case so removing .has-wallpaper restores the original. */
    #homeScreen:not(.has-wallpaper) .btn-scan {
      background: var(--accent); border-color: var(--accent); color: var(--white);
    }
    #homeScreen:not(.has-wallpaper) .btn-add {
      background: #FBF3DA; border-color: var(--gold-tint); color: #B8923A;
    }
    .lbl-short { display: none; }


    /* ══ WALLPAPER LAYER (spans the whole shell, nav included) ═══════
       ONE image element for the entire app. It is full-strength on Home and a
       transparent echo on the other four screens — driven purely by the
       .on-home class that setActiveTab() puts on .shell.

       This replaced an earlier two-element design (a sharp image inside
       #homeScreen plus a separate echo behind the shell). #homeScreen is a flex
       item that stops above the nav, so its image could never run behind the
       nav and the two layers met in a visible seam there. One shell-level layer
       removes the seam AND the duplication — do not reintroduce a second image.

       How much of the image shows on content screens is set ENTIRELY by the
       .shell-bg::after veil below — the img itself has no opacity. An earlier
       comment here advertised a --wp-echo-opacity :root variable; it was never
       defined and referring to it did nothing.

       z-index:-1 is load-bearing: it paints above .shell's own background but
       below .shell's in-flow children, so all five screens sit on top without
       needing a z-index of their own. A value of 0 would paint ABOVE the
       screens (positioned z-index:0 beats non-positioned in-flow content) and
       hide the entire app. */
    .shell-bg {
      position: absolute;
      inset: 0;
      z-index: -1;
      overflow: hidden;
      pointer-events: none;
      /* Offline fallback (30 Jul 2026): reuses the original pre-wallpaper
         white -> --accent-light gradient (see historical note above). Sits
         behind #shellWallpaper and is invisible whenever the image loads
         normally — it only shows if the img fails to load (e.g. offline and
         not yet cached by the service worker), via the onerror handler on
         the img in index.html that hides it. Not a second always-on colour
         mechanism alongside the echo — only a failure-mode fallback. */
      background: linear-gradient(to bottom, var(--white) 0%, var(--white) 55%, var(--accent-light) 100%);
    }
    .shell-bg img {
      width: 100%;
      height: 100%;
      object-fit: cover;
      object-position: 50% 35%;
      /* Sharp, not blurred — the point is that you recognise the day's
         painting. A heavy blur read as an anonymous colour wash.
         Opacity alone does the softening: composited against .shell's white
         background it produces a pale wash of the actual image, so there is
         no separate veil layer and no scale() to hide blur edges.
         A 4px blur takes the hard edges off fine brushwork (which otherwise
         fights body text) while leaving the composition legible. */
      /* Always full strength and SHARP by default. The pale echo on content
         screens comes from the veil below, NOT from dimming this image — that
         is what lets the nav show the real painting on every screen. */
      filter: none;
      transition: filter .42s ease, transform .42s ease;
    }
    /* Softened only on the content screens, where fine brushwork sits directly
       under body text. Home must stay sharp — the whole point there is that you
       see the painting. Do not move this blur onto .shell-bg img itself. */
    .shell:not(.on-home) .shell-bg img {
      filter: blur(4px);
      transform: scale(1.02);   /* covers the 4px blur's soft border */
    }
    /* Exception: the two loading moments — OCR and story generation — borrow
       Home's sharp treatment instead of the usual content-screen blur. Neither
       has body text on screen while the fox animation is up, so the readability
       reason for blurring doesn't apply.

       Set by _syncWallpaperSharp() in scan.js from WHICH SCREEN IS VISIBLE.
       It replaced a .wp-ocr-loading/.wp-story-loading pair that was toggled for
       the duration of each fetch; because these rules are shell-wide, that also
       un-blurred Vocab/Quiz/Story if you navigated away mid-generation. Do not
       go back to a lifecycle-scoped class.

       .wp-sharp, not .on-home, so Home-only rules below (hero, actions, the
       wallpaper word) don't also fire on the loading screens. */
    .shell.wp-sharp .shell-bg img {
      filter: none;
      transform: none;
    }

    /* Home entrance: wallpaper starts very slightly zoomed in and settles to
       full scale every time you navigate to Home. Triggered by
       _tapBounceEl() in scan.js's setActiveTab (isHome branch), which
       removes+reflows+re-adds the class so it restarts on every arrival, not
       just the first mount. Scoped to the class, not .on-home itself, so it
       plays once per arrival rather than persisting for the whole time
       you're on Home. Same 1.5s/1.03 treatment on every arrival, cold boot
       included — no separate duration or scale for a first visit. */
    .shell-bg img.wp-zoom-in {
      animation: wpZoomSettle 1.5s cubic-bezier(0.22, 1, 0.36, 1);
    }
    @keyframes wpZoomSettle {
      0%   { transform: scale(1.03); }
      100% { transform: scale(1); }
    }

    /* No Home scrim. Tried twice and removed both times: the word's own white
       text-shadow halo carries its legibility, and any veil over the painting
       works against the point of showing it. If it is ever wanted again it goes
       here as .shell-bg::before (::after is the content veil below), NOT as an
       element inside #homeScreen — that screen starts below .safe-top, so a
       scrim in there cannot reach the top of the image and leaves a seam. */

    /* The veil that turns the image into a background on content screens.
       Full-bleed (nav included) and graded top-to-bottom, so the wallpaper
       gains colour as the screen descends.

       It used to be a flat 0.78 stopping at bottom: var(--nav-h), which left
       the nav over a full-strength painting directly beneath a washed-out
       screen. That is a hard tonal step at exactly the nav's top edge, and the
       nav read as an unconnected coloured band (user-reported, Aug 2026). A
       continuous ramp has no landmark to form a seam at, so the nav simply
       becomes the richest end of it.

       WHY IT HOLDS BEFORE IT EASES, rather than ramping evenly from the top:
       the veil is only load-bearing where text meets the wallpaper directly.
       All real content already sits on opaque white (.reading-card,
       .vocab-card, .quiz-type-card — note .text-meta is INSIDE .reading-card,
       so it is protected too), but a few small grey labels do not: the vocab
       word count in .vocab-header-left up top, and on the Quiz setup screen
       "Quiz on", .quiz-setup-word-count and Reset, which sit mid-screen. Those
       are the binding constraint on the plateau — lower it and they are what
       goes thin first. Holding the plateau flat through the top half costs
       nothing visually (that region is cards regardless) and keeps those
       labels on a consistent backdrop. The extra colour is spent in the last
       third, where nothing needs protecting. An even ramp was previewed and
       rejected for thinning the veil under exactly those labels.

       PLATEAU LOWERED 0.78 -> 0.72 (Aug 2026), on request, to let more of the
       painting through on content screens. Settled by eye, not by rule: 0.62
       was prepared and stepped back from before deploy, 0.70 shipped and was
       nudged back up. Treat 0.72 as tuned, not arbitrary. Those four labels
       are what to check if it is ever taken lower — three are on the Quiz
       setup screen, mid-screen, where the ramp is still near plateau.

       THE BOTTOM STOP DELIBERATELY DID NOT MOVE. 0.36 is what the nav's own
       glass (also 0.36) and its #7C8894 labels were tuned against; dropping it
       with the rest would have darkened the nav's backdrop and undone that.
       So the ramp is now shallower, not shifted. Note the plateau no longer
       equals the 0.78 used by .read-summary-overlay and its peers — those were
       originally chosen to match it and are now independent.

       Stops are percentages of shell height,
       deliberately NOT keyed to the nav's height: relating the ramp to the nav
       would reintroduce the landmark this removes. That left --nav-h with no
       consumer, so it and syncNavHeight() in core.js were deleted. */
    .shell-bg::after {
      content: '';
      position: absolute;
      inset: 0;
      background: linear-gradient(
        to bottom,
        rgba(255, 255, 255, 0.74) 0%,
        rgba(255, 255, 255, 0.72) 52%,
        rgba(255, 255, 255, 0.60) 72%,
        rgba(255, 255, 255, 0.36) 100%
      );
      transition: opacity .42s ease;
    }
    /* Home: no veil at all — the full painting, edge to edge. */
    .shell.on-home .shell-bg::after { opacity: 0; }
    /* Same exception as the blur rule above — no veil on the two loading
       screens either, so the wallpaper reads as sharp AND unwashed together. */
    .shell.wp-sharp .shell-bg::after { opacity: 0; }


    /* ══ WALLPAPER HOME SCREEN (July 2026 concept test) ═══════════════
       Everything here is scoped under #homeScreen.has-wallpaper. Removing
       that one class from index.html reverts the home screen completely. */

    /* background:none lets the shell-level wallpaper show through this screen. */
    #homeScreen.has-wallpaper { position: relative; background: none; }

    /* .home-wp-scrim (an element inside this screen) was removed July 2026.
       There is no scrim on Home at all now. Do not add a full-bleed overlay in
       here — see the note by .shell-bg::after. */

    /* Header and content sit above the wallpaper */
    #homeScreen.has-wallpaper .home-header,
    #homeScreen.has-wallpaper .hero,
    #homeScreen.has-wallpaper .actions { position: relative; z-index: 2; }

    /* Home's header row: decorative flag on the left, Settings gear on the
       right (swapped 14 Aug 2026 — the flag used to be the sole control and
       opened Settings itself; the gear does that job now). Deliberately the
       same fixed 46px as .text-header / .vocab-header / .story-header and
       the quiz header, so the content start line is identical on every tab.
       Transparent — the wallpaper shows through, so reserving the row costs
       nothing visually. */
    .home-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      height: 46px;
      /* Top/bottom now match the other four headers (5px / 11px, 30 Jul 2026).
         With symmetric 0/0 padding the flag centered across the full 46px row,
         landing ~3px lower than the icons on Read/Vocab/Story/Quiz, which sit
         in a 30px zone starting 5px down. Same split here lines them up. */
      padding: 5px 20px 11px;   /* 20px sides on all five headers — keep them in step */
      flex-shrink: 0;
    }

    /* No glass plate and no white border — the ring of white around the flag
       read as a thick frame. The flag sits directly on the wallpaper; a
       soft dark shadow does the legibility work instead of a light backing,
       and a hairline inside the artwork keeps its top-left corner defined
       against pale sky. Purely decorative now (14 Aug 2026) — no button,
       no touch target, no tap handler. */
    .home-flag-icon {
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .home-flag-icon svg {
      display: block;
      border-radius: 2.5px;
      box-shadow: 0 1px 6px rgba(26,29,33,0.28), 0 0 0 0.5px rgba(255,255,255,0.55);
    }

    /* Settings gear (added 14 Aug 2026, took over the flag's old job).
       Its glyph isn't a rectangle like the flag, so box-shadow (which
       follows the bounding box) would draw a visible grey box behind the
       teeth. Uses drop-shadow instead, which follows the icon's alpha
       silhouette — same white-ring + dark-shadow recipe as .wp-gloss above,
       chosen because a fixed light/dark shadow pair reads on any wallpaper
       without the per-image luminance sampling the flag needs
       (syncWallpaperTone() in core.js does not target this button). */
    .home-settings-btn {
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 6px;
      margin: -6px;            /* keeps the 44pt touch area without pushing the icon inward */
      border: none;
      background: none;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: opacity .15s, transform .1s;
    }
    .home-settings-btn:active { opacity: .7; transform: scale(.92); }
    .home-settings-btn svg {
      display: block;
      filter:
        drop-shadow(0 0 1px rgba(255,255,255,0.95))
        drop-shadow(0 0 2.5px rgba(255,255,255,0.85))
        drop-shadow(0 1px 3px rgba(26,29,33,0.35));
    }

    /* Hide the original hero content in wallpaper mode */
    #homeScreen.has-wallpaper .hero-icon,
    #homeScreen.has-wallpaper .hero-title,
    #homeScreen.has-wallpaper .hero-sub { display: none; }
    #homeScreen:not(.has-wallpaper) .wp-word-block { display: none; }

    /* Word of the day — sits high, not centred. Both test images put their
       subject in the lower half (the child on the path, the field), and a
       vertically centred word landed straight on top of it. High placement
       also reads as a caption on a painting rather than a label over it. */
    #homeScreen.has-wallpaper .hero {
      /* .home-header above provides the top offset now, so this is just
         breathing room below it. */
      padding: 14px 28px 0;
      gap: 0;
      justify-content: flex-start;
      /* Positioning context for the review line below, which is centred in
         this box rather than stacked under the gloss. */
      position: relative;
    }
    .wp-word-block { text-align: center; }
    .wp-word {
      font-family: var(--font-word-home);
      font-size: 56px;
      font-weight: 400;
      line-height: 1.05;
      letter-spacing: -0.015em;
      /* Warm off-white, not pure #fff (Aug 2026) — pure white read as too
         stark. Slightly duller so it sits more naturally against a painted
         image instead of looking like plain UI white. */
      color: #F4F1EA;
      /* Subtle drop shadow, not a halo (Aug 2026) — plain white with no
         shadow lost letter edges wherever they crossed a bright patch of
         image (confirmed on the forest wallpaper: "S"/"k" disappeared into
         sunlit leaves). A small, low-opacity, one-direction shadow pins
         down edges without ringing the word the way the earlier
         white/dark halo did (that read as a photo caption and was
         rejected). Keep this subtle — if it starts looking like a halo
         again, it's drifted from the intent. */
      text-shadow: 0 1px 4px rgba(0,0,0,0.45), 0 0 12px rgba(0,0,0,0.25);
    }
    /* Gloss needed a darker blue and more weight than --accent — at 11px over
       foliage the standard accent disappeared into the greens.
       A tight near-0-blur ring was added (Aug 2026) on top of the original
       soft glow — the soft glow alone is a wide, low-density wash, so on a
       background close in hue to the text (blue text on Natur's blue sky)
       there was nothing sharply outlining each letter, only a diffuse glow
       the same hue family could partly absorb. The tight ring acts like a
       thin white text-stroke right at the glyph edge, which reads as an
       outline regardless of background hue. Kept alongside the wider layers
       so it still glows softly on high-contrast backgrounds too. */
    .wp-gloss {
      margin-top: 11px;
      font-family: var(--font-ui);
      font-size: 11px;
      font-weight: 600;
      letter-spacing: 0.24em;
      text-transform: uppercase;
      color: #2C5678;
      text-shadow:
        0 0 2px rgba(255,255,255,1),
        0 0 5px rgba(255,255,255,1),
        0 0 10px rgba(255,255,255,1),
        0 0 22px rgba(255,255,255,0.95),
        0 0 40px rgba(255,255,255,0.7);
    }

    /* Review line — sits under the gloss, inside the hero. Hidden entirely
       when nothing is in `mistakes`, so there is no empty state.

       It borrows .wp-gloss's WHITE-HALO recipe, not .wp-word's dark drop
       shadow, and that is deliberate: at this size the drop shadow is not
       enough on a pale wallpaper, and the halo is the treatment already
       proven to hold small text over any image on this screen. Which is also
       why this element needs no tone class — wp-actions-dark only covers
       `.actions button`, and extending it here would be a fourth tone class
       for a problem the halo already solves. See DESIGN.md -> Wallpaper. */
    .home-review-line {
      margin: 22px auto 0;   /* in-flow fallback: no wallpaper, no absolute */
      display: flex;
      align-items: baseline;
      justify-content: center;
      gap: 7px;
      padding: 6px 10px;
      background: none;
      border: 0;
      cursor: pointer;
      /* Android Chrome paints a translucent rounded box over the whole button
         on tap and holds it through the 150ms screen fade, so it reads as a
         shade flashing behind the line on the way out. Set per element in this
         file rather than globally — every other tappable does the same. The
         :active opacity below is the intended feedback. */
      -webkit-tap-highlight-color: transparent;
      font-family: var(--font-ui);
      font-size: 13px;
      font-weight: 500;
      color: #2C5678;
      text-shadow:
        0 0 2px rgba(255,255,255,1),
        0 0 5px rgba(255,255,255,1),
        0 0 10px rgba(255,255,255,1),
        0 0 22px rgba(255,255,255,0.95),
        0 0 40px rgba(255,255,255,0.7);
    }
    /* #B8923A, not --gold: --gold (#D9A441) is a fill colour, and as text at
       this size it sits under the readability mark on a light wallpaper even
       with the halo. This darker gold is the one already used for gold TEXT
       elsewhere in the file. */
    .home-review-line .hrl-count {
      font-family: var(--font-mark);
      font-size: 18px;
      font-weight: 600;
      color: #B8923A;
    }
    .home-review-line .hrl-arrow {
      font-size: 15px;
      opacity: 0.65;
    }
    /* Checked-in state. The tick reuses .hrl-count's slot, so it needs the UI
       font (Cormorant's glyph is a thin flourish at this size) and a size that
       optically matches the 18px numeral rather than the same number. */
    .home-review-line.hrl-done .hrl-count {
      font-family: var(--font-ui);
      font-size: 14px;
      font-weight: 600;
    }
    .home-review-line .hrl-arrow[hidden] { display: none; }
    .home-review-line:active { opacity: 0.6; }
    /* Checked in: the line is a statement for the rest of the day, so it drops
       the pointer. Colour is deliberately NOT dimmed — the UA does not grey a
       button with an explicit color, and fading it would read as "unavailable"
       when the truth is "finished". */
    .home-review-line:disabled { cursor: default; }
    .home-review-line:disabled:active { opacity: 1; }
    /* Required, not tidiness: the author `display: flex` above beats the UA
       stylesheet's [hidden] { display: none }, so without this the line would
       still render while hidden. */
    .home-review-line[hidden] { display: none; }

    /* Sits 60px above the Scan / Add Word row, not stacked under the gloss
       (5 Sep 2026). The word of the day deliberately sits HIGH (see the .hero
       rule above — both test images put their subject in the lower half), so
       anchoring this to the bottom keeps the two far apart and groups it with
       the actions, which is what it is.
       `bottom` works because .hero has no bottom padding, so its padding box
       ends exactly where .actions begins — change that padding and this gap
       changes with it.
       Out of flow, so it also cannot push the bottom nav on a short phone —
       that was a live risk while it was a stacked row.
       Scoped to .has-wallpaper: on the offline gradient fallback there is no
       painting to sit on and the in-flow margin above is the right layout. */
    #homeScreen.has-wallpaper .home-review-line {
      position: absolute;
      bottom: 60px;
      left: 50%;
      transform: translateX(-50%);
      margin: 0;
      white-space: nowrap;
    }


    /* Bottom action row — 2 across (Scan, Add Word), translucent white over
       the image. Was 3 across before the Gallery button was removed from
       Home (22 Aug 2026); flex: 1 1 0 on the buttons needed no change. */
    #homeScreen.has-wallpaper .actions {
      flex-direction: row;
      gap: 10px;
      /* No safe-area inset here: the bottom nav sits between this row and the
         screen edge and already carries the inset, so adding it again pushed the
         buttons ~34px too far up on notched devices. 20px sides to match the
         headers. */
      padding: 0 20px 18px;
    }
    #homeScreen.has-wallpaper .btn-or { display: none; }
    #homeScreen.has-wallpaper .lbl-long { display: none; }
    #homeScreen.has-wallpaper .lbl-short { display: block; }

    #homeScreen.has-wallpaper .actions button {
      flex: 1 1 0;
      min-width: 0;
      min-height: 74px;
      flex-direction: column;
      gap: 7px;
      padding: 12px 4px;
      border-radius: 16px;
      background: rgba(255,255,255,0.66);
      -webkit-backdrop-filter: blur(14px) saturate(1.4);
      backdrop-filter: blur(14px) saturate(1.4);
      border: 1px solid rgba(255,255,255,0.7);
      box-shadow: 0 4px 18px rgba(26,29,33,0.10);
      color: var(--accent);
      font-size: 12px;
      font-weight: 500;
      letter-spacing: 0.02em;
    }
    #homeScreen.has-wallpaper .actions button:active {
      background: rgba(255,255,255,0.86);
      transform: scale(.97);
    }
    /* Keep the gold identity on Add Word, muted to survive the glass */
    #homeScreen.has-wallpaper .btn-add { color: #A8822F; }

    /* Dark-wallpaper variant (Aug 2026) — see the matching .wp-nav-dark
       comment above; same problem (mid-tone --accent text on translucent
       white glass over a dark image), same fix (darken the glass, lighten
       the text). Selector needs the #homeScreen prefix to out-specificity
       the base rule above it. */
    .shell.wp-actions-dark #homeScreen.has-wallpaper .actions button {
      background: rgba(26,29,33,0.58);
      border-color: rgba(255,255,255,0.22);
      color: #EAF3FA;
    }
    .shell.wp-actions-dark #homeScreen.has-wallpaper .actions button:active {
      background: rgba(26,29,33,0.78);
    }
    .shell.wp-actions-dark #homeScreen.has-wallpaper .btn-add { color: #F0C97A; }

    /* Dark-wallpaper variant for the flag — the icon's own fill colours
       (red/white) hold contrast regardless of background, so only the
       shadow needs to change: a light halo instead of a dark drop shadow,
       or the flag's top-left corner disappears into a dark background the
       same way the original dark-shadow-on-dark-image problem showed up
       elsewhere. */
    .shell.wp-flag-dark .home-flag-icon svg {
      box-shadow: 0 1px 6px rgba(0,0,0,0.5), 0 0 0 0.75px rgba(255,255,255,0.8);
    }

    /* Hidden file input (#cameraInput's rule went with the element, 23 Aug 2026) */
    #galleryInput { display: none; }

    /* Footer */
    .footer {
      padding-bottom: 12px;
      text-align: center;
    }
    /* ════════════════════════════════════════════════════════════════
       LOADING SCREEN
    ════════════════════════════════════════════════════════════════ */
    #loadingScreen {
      display: none;
      flex: 1;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      /* No background of its own — it's an in-flow screen like the other five
         (display is toggled, it does not overlay), so letting .shell-bg show
         through keeps the blurred wallpaper continuous across the scan -> OCR
         -> Read transition. It previously repeated .shell's white/accent-light
         gradient; an opaque fill here would punch a hole in the echo. */
      gap: 0;
      padding: 40px 36px;
    }

    .loading-logo {
      width: 68px;
      height: 68px;
      margin-bottom: 24px;
    }

    /* Sits directly on the raw, un-veiled wallpaper (.wp-sharp) — a fixed
       --text colour lost contrast on darker wallpapers. An adaptive
       per-image swap (syncWallpaperTone(), Aug 2026) was tried and reverted
       the same day — same reasoning as "word/gloss deliberately not
       covered" below: layering classification on top of a shadow/halo that
       already has to hold contrast on its own is the conflict that was
       reverted once before. Fixed dark text + a strong, always-on white
       halo instead — one look, no sampling, no dark/light class. */
    .loading-message {
      font-family: var(--font-mark);
      font-size: 28px;
      font-weight: 400;
      letter-spacing: -0.01em;
      color: #2E2A22;
      text-shadow: 0 0 3px rgba(255,255,255,1), 0 0 6px rgba(255,255,255,0.7), 0 0 14px rgba(255,255,255,0.5);
      margin-bottom: 24px;
    }

    /* ── Fox loader ───────────────────────────────────────────────── */
    .fox-stage {
      position: relative;
      width: 280px;
      height: 65px;
      overflow: visible;
      margin-bottom: 14px;
    }
    .fox-pos {
      position: absolute;
      bottom: 8px;
      left: 0;
      animation: foxMove 5s linear infinite;
    }
    .fox-dir {
      width: 50px;
      transform-origin: 50% 50%;
      animation: foxDir 5s linear infinite;
    }
    .fox-svg {
      display: block;
      width: 50px;
      height: 38px;
      overflow: visible;
    }

    /* Journey path — encodes the non-linear zigzag choreography */
    @keyframes foxMove {
      0%   { transform: translate(0px,    0px);   }
      6%   { transform: translate(22px,  -14px);  }  /* bounce up */
      10%  { transform: translate(48px,   0px);   }  /* bounce down */
      14%  { transform: translate(62px,  -10px);  }  /* bounce up */
      18%  { transform: translate(75px,   0px);   }  /* curious arrival */
      22%  { transform: translate(78px,  -8px);   }  /* curious head-bob up */
      25%  { transform: translate(78px,   0px);   }  /* bob down, still paused */
      28%  { transform: translate(80px,  -5px);   }  /* second small bob */
      30%  { transform: translate(82px,   0px);   }  /* resume */
      36%  { transform: translate(118px, -5px);   }  /* building speed */
      40%  { transform: translate(145px, -4px);   animation-timing-function: cubic-bezier(0.2,0,0.8,0); }
      43%  { transform: translate(156px, -32px);  animation-timing-function: cubic-bezier(0.34,1.56,0.64,1); } /* JUMP PEAK */
      47%  { transform: translate(163px,  7px);   }  /* overshoot land */
      50%  { transform: translate(165px,  0px);   }  /* settle */
      54%  { transform: translate(173px,  5px);   }  /* sleepy dip */
      58%  { transform: translate(180px, -2px);   }
      62%  { transform: translate(187px,  5px);   }  /* stumble dip */
      66%  { transform: translate(195px,  0px);   }  /* reached far end */
      68%  { transform: translate(195px, -6px);   }  /* little hop before turn */
      70%  { transform: translate(195px,  0px);   }  /* landed — dir flips here */
      75%  { transform: translate(163px, -12px);  }  /* trot left, bounce */
      82%  { transform: translate(98px,   0px);   }
      87%  { transform: translate(60px,  -18px);  }  /* higher detour */
      91%  { transform: translate(28px,  -6px);   }
      96%  { transform: translate(6px,    0px);   }
      100% { transform: translate(0px,    0px);   }  /* home */
    }

    /* Direction flip — instant snap at 70% to avoid squash-through-zero */
    @keyframes foxDir {
      0%, 69.9% { transform: scaleX(1);  }
      70%, 99.9% { transform: scaleX(-1); }
      100%       { transform: scaleX(1);  }
    }

    /* Tail wag */
    .fox-tail-grp {
      transform-box: fill-box;
      transform-origin: 42% 95%;
      animation: foxTailWag 0.42s ease-in-out infinite alternate;
    }
    @keyframes foxTailWag {
      from { transform: rotate(-12deg); }
      to   { transform: rotate(14deg);  }
    }

    /* Leg walk — alternating translateY pairs */
    .fox-leg { transform-box: fill-box; }
    .fox-fl, .fox-br { animation: foxLegA 0.28s ease-in-out infinite alternate; }
    .fox-fr, .fox-bl { animation: foxLegB 0.28s ease-in-out infinite alternate; }
    @keyframes foxLegA {
      from { transform: translateY(0px);  }
      to   { transform: translateY(-5px); }
    }
    @keyframes foxLegB {
      from { transform: translateY(-5px); }
      to   { transform: translateY(0px);  }
    }

    /* Head tilt (curious pause 18–30%) */
    .fox-head-grp {
      transform-box: fill-box;
      transform-origin: 50% 92%;
      animation: foxHead 5s linear infinite;
    }
    @keyframes foxHead {
      0%, 17%  { transform: rotate(0deg);   }
      21%, 27% { transform: rotate(22deg);  }  /* curious tilt */
      31%      { transform: rotate(0deg);   }
      100%     { transform: rotate(0deg);   }
    }

    /* Eye expressions — curious squint / surprised wide / sleepy droop */
    .fox-eyes-grp {
      transform-box: fill-box;
      transform-origin: 50% 50%;
      animation: foxEyes 5s linear infinite;
    }
    @keyframes foxEyes {
      0%, 17%  { transform: scaleY(1);    }  /* normal */
      19%, 29% { transform: scaleY(0.6);  }  /* curious squint */
      31%, 41% { transform: scaleY(1);    }  /* normal */
      43%, 47% { transform: scaleY(1.35); }  /* wide / surprised */
      49%      { transform: scaleY(1);    }
      52%, 65% { transform: scaleY(0.4);  }  /* sleepy droop */
      67%, 100%{ transform: scaleY(1);    }  /* normal */
    }

    /* Body squash & stretch around the jump landing */
    .fox-body-grp {
      transform-box: fill-box;
      transform-origin: 50% 50%;
      animation: foxBodySquash 5s linear infinite;
    }
    @keyframes foxBodySquash {
      0%, 41%  { transform: scaleX(1)    scaleY(1);    }
      43%, 44% { transform: scaleX(0.82) scaleY(1.2);  }  /* stretch in air */
      47%, 48% { transform: scaleX(1.25) scaleY(0.72); }  /* squash on land */
      50%, 100%{ transform: scaleX(1)    scaleY(1);    }
    }

    .loading-hint {
      font-size: 11px;
      font-weight: 300;
      color: var(--text-3);
      letter-spacing: 0.1em;
    }

    /* ════════════════════════════════════════════════════════════════
       RESULT / ERROR TOAST
    ════════════════════════════════════════════════════════════════ */
    #toast {
      position: absolute;
      bottom: calc(env(safe-area-inset-bottom, 0px) + 20px);
      left: 20px;
      right: 20px;
      padding: 16px 18px;
      border-radius: var(--r-md);
      background: var(--text);
      color: var(--white);
      font-size: 13px;
      font-weight: 300;
      line-height: 1.6;
      opacity: 0;
      transform: translateY(12px);
      transition: opacity .3s, transform .3s;
      pointer-events: none;
      z-index: 99;
    }
    #toast.show {
      opacity: 1;
      transform: translateY(0);
    }
    #toast.compact { padding: 10px 16px; }
    #toast.error { background: var(--error); }
    /* Only the sign-in/sign-out toast uses this (auth.js's _authToast).
       Bottom-anchored, it sat inside .bottom-nav's own footprint since that
       message always fires right after a full-page reload, when the nav is
       guaranteed visible. Centering it — same look and position as
       #offlineNotice below — sidesteps the nav bar entirely instead of just
       clearing it. */
    #toast.centered {
      position: fixed;
      top: 50%;
      bottom: auto;
      max-width: 350px;
      margin: 0 auto;
      transform: translateY(-50%) scale(0.96);
    }
    #toast.centered.show {
      transform: translateY(-50%) scale(1);
    }
    #toast strong {
      display: block;
      font-weight: 600;
      font-size: 14px;
      margin-bottom: 4px;
    }

    /* Centered, standalone — only used for the offline notice. Same visual
       language as #toast (dark pill, white text) but positioned in the
       middle of the screen instead of bottom-anchored, and its own element
       so #toast's position stays untouched for every other message. */
    #offlineNotice {
      position: fixed;
      top: 50%;
      left: 20px;
      right: 20px;
      max-width: 350px;
      margin: 0 auto;
      padding: 16px 18px;
      border-radius: var(--r-md);
      background: var(--error);
      color: var(--white);
      font-size: 13px;
      font-weight: 300;
      line-height: 1.6;
      text-align: center;
      opacity: 0;
      transform: translateY(-50%) scale(0.96);
      transition: opacity .3s, transform .3s;
      pointer-events: none;
      z-index: 999;
    }
    #offlineNotice.show {
      opacity: 1;
      transform: translateY(-50%) scale(1);
    }


    /* ── SVG icon helpers ─────────────────────────────────────────── */
    .icon-camera { stroke: currentColor; fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; }

    /* ════════════════════════════════════════════════════════════════
       VOCAB SCREEN  (Phase 4)
    ════════════════════════════════════════════════════════════════ */
    #vocabScreen {
      display: none;
      flex-direction: column;
      flex: 1;
      min-height: 0;
      overflow: hidden;
    }

    /* Filter + sort block, collapsed by default (July 2026) to give the list
       ~90px back. Plain display toggle rather than a height animation: the
       block contains two position:fixed dropdown panels, and animating a
       clipped ancestor makes them detach mid-transition. */
    /* Always on screen in list view (Aug 2026 — it used to be collapsed behind
       a header toggle, which is now deleted). Hidden in card view so the
       flashcard, which is centred in the space left over, keeps the whole
       screen; that also retires the absolute-overlay hack this rule used to
       need to stop the card being pushed down. */
    .vocab-filter-zone { display: block; }
    #vocabScreen.cards-active .vocab-filter-zone { display: none; }

    /* Item can be disabled (deck has fewer than 4 eligible words) — first
       real use of :disabled on this shared row style. */
    .quiz-td-item:disabled { opacity: .45; cursor: default; }
    .quiz-td-item svg { flex-shrink: 0; }

    /* Study-mode toggle (3 Sep 2026) — reuses .vocab-view-btn /
       .vocab-view-btn-labelled wholesale (the Deep learn/Fast scan pill) for
       shape, sizing and the label style; .vocab-mode-btn only adds the
       show/hide rule, card-view only (Recall/Choice has no effect in list
       view, so the control isn't there to use). Briefly tried "always
       visible, greyed out outside card view" on 4 Sep 2026 and reverted the
       same day — explicitly not wanted; hidden entirely is correct.
       The `button.` prefix on both rules below is load-bearing, not style:
       .vocab-view-btn's own `display: inline-flex` (further down this file)
       is a single-class selector with the SAME specificity as a bare
       `.vocab-mode-btn { display: none; }` would have, and it comes later in
       the file, so it was silently winning the cascade and the button never
       actually hid in list view. Adding the element type breaks the tie in
       this rule's favour without needing !important or an id selector. */
    /* Card-view only. The WRAP is what hides, not the button: the button and
       its panel must stay inside .quiz-time-dropdown-wrap for the outside-click
       handler to recognise them, so hiding the button alone would leave a live
       panel with no trigger. The element type still breaks the specificity tie
       against .vocab-view-btn's own `display: inline-flex` further down. */
    /* position: static, overriding .quiz-time-dropdown-wrap's `relative`.
       LOAD-BEARING, and the reason is not layout: placeHintPill() (core.js)
       positions a hint pill and its caret from target.offsetLeft/offsetTop,
       which are measured against the target's OFFSETPARENT. A positioned wrap
       becomes that offsetParent, so #vocabModeTrigger measured ~0/0 inside it
       and the `studymode` pill and its arrow landed at the far left of the
       header, pointing at nothing. Static hands the offsetParent back to
       .vocab-header-normal, which is the host placeHintPill is given.
       The panel does not need a positioned ancestor — it is position: fixed and
       placed from viewport coordinates by _vocabDropOpen(). The sort and time
       wraps keep `relative`; only this instance opts out. */
    div.vocab-mode-wrap { display: none; position: static; }
    #vocabScreen.cards-active div.vocab-mode-wrap { display: inline-flex; align-items: center; }
    /* Three icons now, swapped with toggleAttribute('hidden') rather than a
       two-way class flip — a class pair cannot express three states. The UA's
       [hidden] rule is HTML-scoped, so an SVG carrying the attribute still
       paints without this; same trap and fix as #vocabViewBtn svg[hidden]. */
    #vocabModeTrigger svg[hidden] { display: none; }
    #vocabModeTrigger .qdrop-chev { color: var(--text-3); margin-left: 1px; }
    #vocabModeTrigger.open .qdrop-chev { transform: rotate(180deg); }
    /* The unavailable Choice row: dimmed and inert, no explanatory line. */
    .quiz-td-item:disabled { opacity: .55; cursor: default; }
    .quiz-td-item:disabled:hover { background: transparent; }
    /* Opts OUT of the shared 160px floor. That floor exists for the sort and
       time panels, whose longest rows are "Last 2 weeks" and "A → Z"; these
       three are one short word each, so the floor left a band of dead space
       down the right of every row. A fixed-position flex column shrink-wraps
       to its content, so 0 means "as wide as the widest row" — no width to
       keep in step with the labels if they are ever reworded. */
    /* TWO classes, not one. `.vocab-mode-panel` alone is the same specificity
       as `.quiz-time-dropdown-panel`, whose min-width: 160px sits ~4600 lines
       further down this file and therefore won on source order — the override
       did nothing and every row kept a band of dead space. Same trap CLAUDE.md
       records for .quiz-segmented instance overrides.
       The 160px floor is for the sort and time panels ("Last 2 weeks", "A → Z");
       these three rows are one short word each, so this panel opts out and a
       fixed-position flex column shrink-wraps to its widest row — no number to
       keep in step if the labels are ever reworded. */
    .quiz-time-dropdown-panel.vocab-mode-panel { min-width: 0; }

    /* Vocab header */
    .vocab-top-zone {
      /* Blue wash removed — banded across the wallpaper like the other headers. */
      min-height: 46px;   /* was 57px, matched to the collapsed .vocab-header */
      flex-shrink: 0;
      position: relative;   /* anchors the card-view filter-zone overlay above */
    }
    .vocab-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      /* Top reduced to 5px (30 Jul 2026); bottom takes the difference (11px)
         so the row stays the fixed 46px height: 5 + 30px button + 11 = 46. */
      padding: 5px 20px 11px;
      /* Was 64px with a title. Collapsed to the height of the buttons alone
         (headings removed July 2026). Still a FIXED height, for the original
         reason: a button's padding must not be able to shift the row. */
      height: 46px;
      flex-shrink: 0;
    }
    .vocab-header-left {
      display: flex;
      align-items: center;
      gap: 10px;
    }


    /* Filter pill bar */
    /* Holds the segmented status filter. `position: relative` anchors
       #vocabFilterTooltip, which is placed from a segment's offsetLeft. */
    .vocab-filter-bar {
      display: flex;
      padding: 8px 16px 4px;
      flex-shrink: 0;
      position: relative;
    }
    /* Row beneath the filter pills: Order-by + Recent-focus dropdowns on the
       left, Reset on the right. Briefly moved to the header (22 Aug 2026),
       moved back the same day. Dropdowns reuse the quiz dropdown component
       classes for identical styling. */
    .vocab-reset-row {
      display: flex;
      align-items: center;
      gap: 8px;
      padding: 2px 16px 6px;
      flex-shrink: 0;
    }
    .vocab-dropdowns {
      display: flex;
      align-items: center;
      gap: 18px;
      flex: 1;          /* fills the row so the dropdowns hug left, Reset hugs right */
      min-width: 0;
    }
    .vocab-dropdowns .quiz-time-dropdown-wrap { flex: 0 0 auto; min-width: 0; }
    /* Quiet, borderless triggers (no box, no leading icon) so the pill row above
       stays the primary control. Scoped to .vocab-dropdowns — Quiz unaffected. */
    .vocab-dropdowns .quiz-time-dropdown-btn {
      width: auto;
      height: 26px;
      padding: 0;
      gap: 5px;
      border: none;
      background: transparent;
      color: var(--text-2);
      font-size: 12px;
    }
    .vocab-dropdowns .quiz-time-dropdown-btn.active {
      background: transparent;
      border: none;
      color: var(--accent);
    }
    .vocab-dropdowns .quiz-time-dropdown-btn .qdrop-chev { margin-left: 0; color: var(--text-3); }
    .vocab-dropdowns .quiz-time-dropdown-btn.active .qdrop-chev { color: var(--accent); }
    .vocab-reset-right {
      display: flex;
      align-items: center;
      gap: 8px;
      flex-shrink: 0;
    }
    .vocab-word-count {
      font-family: var(--font-ui);
      font-size: 11px;
      color: var(--text-3);
      white-space: nowrap;
      margin-left: auto;   /* push to the right end of the filter row */
      align-self: center;
    }
    .vocab-filter-tooltip {
      position: absolute;
      top: calc(100% + 8px);
      background: #2a3a4a;
      color: #fff;
      font-size: 11px;
      padding: 5px 10px;
      border-radius: 6px;
      white-space: nowrap;
      pointer-events: none;
      opacity: 0;
      transform: translateY(-4px);
      transition: opacity 0.22s ease, transform 0.22s ease;
      z-index: 20;
    }
    .vocab-filter-tooltip::before {
      content: '';
      position: absolute;
      bottom: 100%;
      left: 14px;
      border: 5px solid transparent;
      border-bottom-color: #2a3a4a;
    }
    .vocab-filter-tooltip.visible {
      opacity: 1;
      transform: translateY(0);
    }
    /* Vocab status filter — the shared .quiz-segmented control (Aug 2026,
       replacing four outlined pills incl. All). Two overrides, both because
       this instance sits on the wallpaper rather than on a white screen:
       a frosted track instead of a solid --white-soft slab, and the selected
       pill in the status's own colour instead of --accent, since amber / red /
       green are what those three words mean everywhere else in the app.
       There is no All segment — 'all' is the state with nothing selected. */
    /* Written as .quiz-segmented.vocab-filter-seg, not .vocab-filter-seg
       alone: the base rule lives ~3800 lines further down this file, so at
       equal specificity it wins on source order and the track stayed solid
       white. Keep both classes in the selector. */
    .quiz-segmented.vocab-filter-seg {
      background: rgba(255, 255, 255, 0.42);
      backdrop-filter: blur(6px);
      -webkit-backdrop-filter: blur(6px);
    }
    /* Darker than --text-2: the resting label sits on the frosted track over a
       photo, not on white. */
    .vocab-filter-seg .quiz-seg-btn { color: #41556B; }
    .vocab-filter-seg .quiz-seg-btn.active::before {
      background: var(--seg-fill);
      box-shadow: 0 1px 3px rgba(0, 0, 0, .18);
    }
    /* ── Counts above the labels (10 Sep 2026) ───────────────────────
       Each segment shows how many words are in its bucket, stacked over the
       label. Written from _syncVocabFilterCounts() in vocab.js.

       SCOPED TO .vocab-filter-seg ON PURPOSE. The base .quiz-seg-btn is 32px
       in every state, and its own comment warns that the resulting 36px track
       matches .quiz-time-dropdown-btn beside it — a height that varies has
       silently broken that alignment before. Stacking needs ~44px, so this
       taller version is confined to the vocab filter, which sits alone on its
       row. The Quiz screen's Difficulty control shares .quiz-segmented and is
       deliberately untouched: do NOT lift these rules to the base class.

       `.quiz-seg-btn span` sets display:inline-block at (0,1,1) specificity,
       which beats a bare .vfs-stack — hence the element in this selector. */
    .vocab-filter-seg .quiz-seg-btn { height: 44px; }
    .vocab-filter-seg .quiz-seg-btn span.vfs-stack {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 1px;
      line-height: 1.2;
      /* 12px — the same size .quiz-seg-btn uses everywhere else, and the same
         as the "Newest" / "All time" dropdown triggers sitting directly below
         this control, so the whole filter zone is set at one size. Settled
         after 10.5 -> 12 -> 11 -> 12 (10 Sep 2026). Space is NOT the
         constraint at any of these sizes — measured, the longest label
         ("Mastered") is 56px inside a ~104px segment and the stack is 32px
         inside the 44px track — so pick this number for how it reads, never to
         make it fit. */
      font-size: 12px;
    }
    .vocab-filter-seg .vfs-stack b {
      /* The SAME size as the label (14px, then 12px-over-11px, now equal —
         10 Sep 2026). Size does no separating work at all here: the 600 weight
         and the darker colour are the only things marking the count apart from
         the word beneath it. Deliberate — the pair reads as one two-line item
         rather than a number with a caption. If the count ever stops
         registering, raise THIS size before touching the weight. */
      font-size: 12px;
      font-weight: 600;
      /* Tabular figures: the counts change as you answer, and proportional
         digits make the labels under them twitch sideways on every change. */
      font-variant-numeric: tabular-nums;
    }
    .vocab-list-wrap { position: relative; flex: 1; min-height: 0; display: flex; flex-direction: column; }
    /* Scrollable list */
    /* No surface at all (Aug 2026): the words sit directly on the screen
       wallpaper. A panel behind them was tried first and read as a long white
       card that buried the wallpaper on the one screen where you spend the
       most time. Rows are separated by a white hairline and nothing else.
       Because the rows are transparent, nothing may be positioned BEHIND one —
       that is what killed swipe-to-delete (see vocab.js). */
    .vocab-list {
      flex: 1;
      min-height: 0;
      overflow-y: auto;
      -webkit-overflow-scrolling: touch;
      /* No horizontal padding: the inset lives on the rows instead, so a
         selected word's highlight can run the full width of the screen rather
         than sitting in from both edges like a card. */
      padding: 6px 0 12px;
      display: flex;
      flex-direction: column;
      gap: 0;
    }

    /* Empty state */
    /* Layout, icon and type come from the shared empty-state rule (~line 3209).
       The side inset is this screen's own: .vocab-list carries no horizontal
       padding (the rows provide their own), so the message would otherwise run
       to both edges. */
    .vocab-empty { padding-left: 32px; padding-right: 32px; }

    /* Divider ABOVE each row but the first, so the list has no rule hanging
       under its last word. White rather than --border: the line sits on the
       wallpaper, not on a white surface, and a grey hairline reads as dirt
       there. Suppressed around an open word, which draws its own block. */
    /* Drawn as a pseudo-element, NOT border-top with a margin: a margin on the
       card indents the row itself, which knocked every word but the first out
       of alignment. This insets the rule while the row stays full-bleed. */
    .vocab-card { position: relative; }
    .vocab-card + .vocab-card::before {
      content: '';
      position: absolute;
      top: 0;
      left: 20px;
      right: 20px;
      /* 0.75px, down from 1px, via 0.5px (22 Aug 2026) — renders as a
         hairline on high-DPR phone screens (the target device), may round
         up to 1px on a low-DPR/desktop display. */
      height: 0.75px;
      /* 0.15, down from 0.22 then 0.5 (22 Aug 2026) — full-strength read as
         a ruled line, too mechanical for a list sitting on a photo. Soft
         enough to separate rows without drawing the eye. */
      background: rgba(255, 255, 255, 0.15);
    }
    /* No rule against the selected block, above or below it. */
    .vocab-card.open::before,
    .vocab-card.open + .vocab-card::before {
      display: none;
    }
    .vocab-card-row {
      background: transparent;
      display: flex;
      flex-direction: column;
      /* ~48px rows (22 Aug 2026, up from ~42px) — 42px still read as dense.
         The horizontal inset lives here rather than on .vocab-list, so the
         open word's highlight is full-bleed while the text still lines up
         with the rest of the screen. */
      padding: 10px 16px 10px 20px;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      user-select: none;
    }
    /* NO :active fill on the row. It had a white press flash, which meant a tap
       that opens a word showed two colours in a row — white under the finger,
       then the blue band a moment later. The band IS the feedback, and it
       arrives on the same tap, so a press state on top of it is one state too
       many. Do not add one back. */

    /* Open word — one tinted band behind the row and its detail, the only
       surface in the list. Painted on .vocab-card itself rather than on the two
       children, so the two parts cannot fall out of alignment. */
    .vocab-card.open {
      background: var(--accent-light);
    }
    /* "This is the word you were just studying" — set for one animation cycle
       by _revealInList() when returning from the flashcard deck. Deliberately
       the same --accent-light as an open row, fading out: it says "here" and
       then gets out of the way, without expanding the row. */
    .vocab-card-flash {
      animation: vocabCardFlash 1.6s ease-out;
    }
    @keyframes vocabCardFlash {
      0%   { background: var(--accent-light); }
      55%  { background: var(--accent-light); }
      100% { background: transparent; }
    }

    /* The collapsed row — the Danish word alone since Aug 2026. The audio
       button, status dot, pos chip and translation all moved out (translation
       and pos into the detail panel below, the rest deleted). */
    .vocab-row1 {
      display: flex;
      align-items: center;
      justify-content: space-between;
      /* Height comes from the 28px Remove icon inside it; 28 + 6px padding top
         and bottom = a 40px row, the smallest reliable tap target. Do not
         shrink further to fit more words on screen. */
      min-height: 28px;
    }
    /* Groups the select-mode checkbox with the word so they sit together. */
    .vocab-row1-left {
      display: flex;
      align-items: center;
      gap: 10px;
      min-width: 0;
    }
    /* --font-mark (Cormorant Garamond), back from the Jost experiment
       (22 Aug 2026, same day). 18px/600, against explicit instruction —
       flagged before applying: Cormorant's fine strokes were documented
       earlier today to close up and read muddy below ~20px, and 18px is
       under that floor. Weight is 600 because Cormorant is only
       self-hosted at 300/400/600 (see @font-face block up top) — no 500
       file exists, so a 500 or 550 request silently falls back to the
       nearest loaded weight rather than rendering as requested. If it
       reads muddy live, the fix is size up, not another typeface swap. */
    .vocab-card-word {
      display: inline-block;
      font-family: var(--font-mark);
      font-size: 18px;
      font-weight: 600;
      letter-spacing: 0.003em;
      color: var(--text);
      white-space: nowrap;
      line-height: 1.15;
    }
    /* Remove icon — visible only on the opened row (22 Aug 2026, reversing
       the "stays on every row" decision from earlier the same day). Hidden
       by default, shown only inside .vocab-card.open. Grey at rest, not
       red: red only on press, where it belongs. Deletion is immediate and
       has no undo. */
    .vocab-row-remove {
      display: none;
      width: 28px;
      height: 28px;
      flex-shrink: 0;
      border: none;
      background: none;
      padding: 0;
      /* --text-2, not --text-3: this icon sits on the wallpaper rather than on
         a white surface, and the lighter grey all but vanished there. */
      color: var(--text-2);
      align-items: center;
      justify-content: center;
      border-radius: var(--r-pill);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: color .12s;
    }
    .vocab-card.open .vocab-row-remove,
    #vocabList.all-expanded .vocab-row-remove { display: flex; }
    .vocab-row-remove:active { color: var(--error); }

    /* Detail panel's single row: pos chip + translation + status picker. Still
       uses .vocab-card-pos-tag below, which quiz.js's mistake row shares. */
    .vocab-detail-meta {
      display: flex;
      align-items: center;
      gap: 8px;
      min-width: 0;
    }
    /* Wraps rather than truncates — a translation is the point of the row, so
       losing its tail to an ellipsis on a 360px screen is the wrong trade. The
       row grows instead. */
    .vocab-detail-english {
      font-family: var(--font-ui);
      font-size: 13.5px;
      color: #2E4F68;   /* darker than --text-2, which greys out on the tint */
      line-height: 1.35;
      flex: 1;
      min-width: 0;
    }
    /* The chip's normal fill is --accent-light, which IS the open-row tint —
       it would disappear. White keeps it readable without a second colour. */
    .vocab-card.open .vocab-card-pos-tag {
      background: var(--white);
      border-color: var(--white);
    }
    .vocab-card-pos-tag {
      font-family: var(--font-ui);
      font-size: 9px;
      font-weight: 600;
      letter-spacing: 0.07em;
      text-transform: uppercase;
      background: var(--accent-light);
      color: var(--accent);
      border-radius: 4px;
      padding: 2px 6px;
      border: 1px solid var(--accent-light);
      flex-shrink: 0;
      white-space: nowrap;
    }
    /* (.vocab-card-english and the .vocab-speak-btn family removed Aug 2026 —
       the collapsed row no longer carries a translation or an audio button.
       The flashcard uses .sentence-speak-btn, not these.) */

    /* Expanded detail panel. No border-top: it shares the row's tint, so the
       two read as one block and a divider between them would cut it in half. */
    .vocab-card-detail {
      display: none;
      padding: 0 20px 8px;
    }
    .vocab-card.open .vocab-card-detail { display: block; }
    /* Expand-all mode (22 Aug 2026): every row's detail shows via this
       list-level class, not by adding .open to every .vocab-card — .open
       also paints the accent-light "selected word" tint and suppresses the
       hairline above/below it, neither of which should apply to every row
       at once. Dividers and the plain (untinted) row background stay as
       normal; only the detail panel's visibility changes. */
    #vocabList.all-expanded .vocab-card-detail { display: block; }

    /* Micro-section label — "pronunciation", "example" */


    /* Shared context card — sentence + translation + grammar note */
    .vocab-context-card {
      background: #EBF4FB;
      border: 1px solid var(--border);
      border-radius: var(--r-sm);
      padding: 10px 12px;
      margin-bottom: 12px;
    }
    /* Sentence block inside context card — no background of its own */
    .vocab-sentence-block {
      margin-bottom: 0;
    }
    .vocab-sentence-da-row {
      display: flex;
      align-items: flex-start;
      gap: 7px;
    }
    .vocab-sentence-da {
      flex: 1;
      font-family: var(--font-body);
      font-size: 13.5px;
      color: var(--text);
      line-height: 1.6;
      margin: 0;
    }
    .vocab-sentence-da strong { font-weight: 700; }
    .vocab-sentence-en {
      font-family: var(--font-ui);
      font-size: 12px;
      color: var(--text-2);
      font-style: italic;
      line-height: 1.5;
      margin: 5px 0 0;
    }

    /* Grammar note toggle inside context card */
    .vocab-toggle-pair {
      margin-top: 8px;
      border-top: 1px solid var(--border);
      padding-top: 6px;
    }
    /* Memory-hook fold leads the detail, so no top divider */
    .vocab-mnemonic-pair {
      margin-top: 0;
      border-top: none;
      padding-top: 0;
      margin-bottom: 10px;
    }
    .vocab-toggle-pills {
      display: flex;
      gap: 6px;
    }
    .vocab-grammar-tab {
      display: inline-flex;
      align-items: center;
      gap: 5px;
      background: none;
      border: none;
      border-radius: 0;
      /* The whole row is the tap target, not just the label: full width, and
         12px of padding cancelled by a -12px margin takes it from 15px to ~39px
         tall with nothing on screen moving. Keep the padding and margin equal. */
      padding: 12px 0;
      margin: -12px 0;
      width: 100%;
      font-family: var(--font-ui);
      font-size: 10px;
      font-weight: 500;
      text-transform: uppercase;
      letter-spacing: 0.06em;
      color: var(--text-3);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .vocab-grammar-tab svg {
      transition: transform .2s ease;
      flex-shrink: 0;
    }
    .vocab-gram-content {
      max-height: 0;
      overflow: hidden;
      transition: max-height .2s ease, padding-top .2s ease;
      font-family: var(--font-ui);
      font-size: 12px;
      color: var(--text-2);
      line-height: 1.5;
      padding-left: 2px;
      padding-top: 0;
    }
    .vocab-toggle-pair.grammar-open .vocab-gram-content {
      max-height: 300px;
      padding-top: 5px;
    }
    /* 600 is the heaviest Jost weight actually loaded (see index.html font
       link) — using 700 here would force the browser to synthesize a faux
       bold instead of rendering a real weight. */
    .vocab-gram-content strong { font-weight: 600; color: var(--text); }
    .vocab-toggle-pair.grammar-open .vocab-gram-pill svg {
      transform: rotate(180deg);
    }

    /* Common phrases section — plain list after context card */
    .vocab-phrases {
      margin-bottom: 10px;
    }
    .vocab-phrases-label {
      font-family: var(--font-ui);
      font-size: 10px;
      font-weight: 700;
      letter-spacing: 0.07em;
      text-transform: uppercase;
      color: var(--text-3);
      margin-bottom: 5px;
    }
    .vocab-phrase-row {
      display: flex;
      align-items: baseline;
      gap: 5px;
      padding: 3px 0;
      border-bottom: 1px solid var(--border);
      font-family: var(--font-ui);
      font-size: 12px;
    }
    .vocab-phrase-row.last { border-bottom: none; }
    .vocab-phrase-da { color: var(--text); font-weight: 500; }
    .vocab-phrase-at { color: var(--text-3); font-weight: 400; }
    .vocab-phrase-sep { color: var(--text-3); }
    .vocab-phrase-en { color: var(--text-2); }
    .vocab-phrases-error {
      font-family: var(--font-ui);
      font-size: 13px;
      color: var(--error);
      margin-bottom: 10px;
    }
    .vocab-phrases-error a { color: var(--error); text-decoration: underline; }
    .vocab-phrases-loading {
      display: flex;
      flex-direction: column;
      gap: 6px;
      margin-bottom: 10px;
    }
    .vocab-phrases-shimmer {
      display: block;
      height: 12px;
      border-radius: 4px;
      background: linear-gradient(90deg, var(--border) 25%, var(--surface) 50%, var(--border) 75%);
      background-size: 200% 100%;
      animation: shimmer 1.4s infinite;
    }
    .vocab-phrases-shimmer:nth-child(1) { width: 70%; }
    .vocab-phrases-shimmer:nth-child(2) { width: 55%; }
    .vocab-phrases-shimmer:nth-child(3) { width: 65%; }
    @keyframes shimmer { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } }

    /* Small inline audio button used next to example sentences */
    .sentence-speak-btn {
      width: 32px;
      height: 32px;
      flex-shrink: 0;
      border-radius: var(--r-pill);
      border: none;
      background: none;
      color: var(--text-3);
      display: flex;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .12s, color .12s, border-color .12s;
    }
    .sentence-speak-btn:active { background: var(--accent-light); color: var(--accent); border-color: var(--accent); }
    .sentence-speak-btn.speaking {
      background: var(--accent-light);
      border-color: var(--accent);
      color: var(--accent);
      animation: speakPulse .8s ease-in-out infinite;
    }

    /* Popup content layout — meta row + sentence block */
    .popup-meta-row {
      display: flex;
      align-items: baseline;
      gap: 7px;
      padding-bottom: 8px;
    }
    /* POS chip — unified with the vocab solid-chip look (.vocab-card-pos-tag) */
    .popup-pos-tag {
      font-family: var(--font-ui);
      font-size: 9px;
      font-weight: 600;
      letter-spacing: 0.07em;
      text-transform: uppercase;
      background: var(--accent-light);
      color: var(--accent);
      border: 1px solid var(--accent-light);
      border-radius: 4px;
      padding: 2px 6px;
      white-space: nowrap;
      flex-shrink: 0;
    }

    .popup-word-transl {
      font-family: var(--font-ui);
      font-size: 14px;
      font-weight: 500;
      color: var(--text-2);
      line-height: 1.4;
    }
    .popup-sentence-label {
      font-family: var(--font-ui);
      font-size: 10px;
      font-weight: 600;
      letter-spacing: 0.06em;
      text-transform: uppercase;
      color: var(--text-3);
      margin-bottom: 5px;
    }
    .popup-sentence-block {
      background: var(--surface);
      border-radius: var(--r-sm);
      padding: 9px 10px;
      margin-top: 2px;
    }
    .popup-sentence-da-row {
      display: flex;
      align-items: flex-start;
      gap: 7px;
    }
    .popup-sentence-da {
      flex: 1;
      font-family: var(--font-body);
      font-size: 13px;
      color: var(--text);
      line-height: 1.55;
    }
    .popup-sentence-da strong { font-weight: 700; }
    .popup-sentence-en {
      font-family: var(--font-ui);
      font-size: 11.5px;
      color: var(--text-2);
      line-height: 1.5;
      margin-top: 5px;
      font-style: italic;
    }

    /* Toggle is floated right and stays put; the note always shrinks its
       box around it (overflow:hidden on the note establishes a BFC, which
       is what makes text wrap around a float instead of under/over it).
       Collapsed adds nowrap+ellipsis for a single truncated line; expanded
       just drops those two so the text wraps onto further lines below. */
    .popup-grammar-note-wrap {
      padding-bottom: 8px;
    }
    .popup-grammar-toggle {
      float: right;
      margin-left: 5px;
      font-family: var(--font-ui);
      font-size: 11px;
      color: var(--accent);
      cursor: pointer;
      user-select: none;
    }
    .popup-grammar-note {
      font-family: var(--font-ui);
      font-size: 12px;
      color: var(--text-2);
      line-height: 1.5;
      overflow: hidden;
      white-space: nowrap;
      text-overflow: ellipsis;
    }
    .popup-grammar-note-wrap.expanded .popup-grammar-note {
      white-space: normal;
      text-overflow: clip;
    }

    .vocab-regen-btn {
      display: inline-flex;
      align-items: center;
      gap: 5px;
      background: none;
      border: 1px solid var(--border);
      border-radius: var(--r-pill);
      color: var(--text-3);
      font-family: var(--font-ui);
      font-size: 12px;
      padding: 5px 10px;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: color .12s, border-color .12s;
    }
    .vocab-regen-btn:active { color: var(--accent); border-color: var(--accent); }
    .vocab-regen-btn:disabled { opacity: .45; cursor: default; }

    /* Image / ordbog lookup buttons — icon-only, same row as status segment */
    .vocab-lookup-btn {
      display: inline-flex;
      align-items: center;
      gap: 5px;
      background: none;
      border: 1px solid var(--border);
      border-radius: var(--r-pill);
      color: var(--text-3);
      font-family: var(--font-ui);
      font-size: 12px;
      padding: 5px 10px;
      cursor: pointer;
      flex-shrink: 0;
      -webkit-tap-highlight-color: transparent;
      transition: color .12s, border-color .12s;
    }
    .vocab-lookup-btn:active { color: var(--accent); border-color: var(--accent); }
    .vocab-lookup-btn.images { background: var(--accent); color: var(--white); border-color: var(--accent); }
    .vocab-lookup-btn.images:active { opacity: .85; }
    .vocab-lookup-btn.ordbog { background: var(--accent-light); color: var(--accent); border-color: var(--accent-light); }
    .vocab-lookup-btn.ordbog:active { opacity: .85; }

    /* (.vocab-status-dot removed Aug 2026 — the collapsed row is the Danish
       word alone. Status is shown by the dropdown below, in the detail panel.
       quiz.js's mistake row draws its own dot inline via _statusDotHtml.) */

    /* Status picker — the shared .quiz-segmented track (Aug 2026, replacing a
       custom dropdown). Only ONE rule is added on top of the shared control:
       the selected pill takes the status's own colour instead of --accent,
       because amber / red / green carry the meaning here. --seg-fill is set
       inline per segment from _statusDotColors, so the colours have a single
       source; the shared .quiz-seg-btn.active::before rule still supplies the
       shape, inset and shadow. Do not copy .quiz-seg-btn to style this. */
    .quiz-seg-btn.vocab-status-seg.active::before {
      background: var(--seg-fill);
      box-shadow: 0 1px 3px rgba(0,0,0,.18);
    }
    /* On the flashcard back the track needs its own fill. .quiz-segmented's
       default is --white-soft, which is exactly the flashcard's own surface —
       the track vanished and the three segments read as three loose buttons
       instead of one control. --surface is the same relationship the quiz has,
       where the track sits on a --bg row. */
    .flashcard-detail .vocab-status-seg-track {
      margin-top: 10px;
      background: var(--surface);
    }


    /* Pagination bar */
    /* ── Vocab action bar (13 Sep 2026) ──────────────────────────────
       One primary button above the bottom nav, in both list and card view —
       the deck in one, the way back in the other. It replaced the header pill
       (Deep learn / Fast scan), which sat beside Add Word and read as a
       setting rather than the main thing you do on this screen.
       Everything about its size, type and press animation comes from the
       shared bottom-action-bar rule it is listed in (search
       .read-empty-actions) plus .btn-primary itself — this rule adds only the
       one thing that is genuinely local: position: relative, because the
       `flashcards` hint pill is appended here and positioned against the
       button inside it. Do NOT re-add a size or font-size here; that is what
       made this button the odd one out on its first day. */
    /* The UA's [hidden] { display: none } is scoped to HTML elements, so an SVG
       carrying the attribute still paints — all three of #vocabViewBtn's icons
       showed at once until this rule existed (13 Sep 2026). Scoped to the
       button, not to whatever container it currently sits in, so moving it
       again cannot silently unhide all three. Same trap, and the same fix, as
       .home-review-line .hrl-arrow[hidden] above. */
    #vocabViewBtn svg[hidden] { display: none; }

    .vocab-pagination {
      display: none; /* shown by JS when needed */
      align-items: center;
      justify-content: center;
      gap: 10px;
      padding: 6px 16px 6px;
      flex-shrink: 0;
    }
    .vocab-pagination.visible { display: flex; }
    .vocab-page-btn {
      min-width: 36px;
      min-height: 36px;
      border-radius: var(--r-pill);
      border: 1px solid transparent;
      background: var(--accent-wash);
      color: var(--accent);
      display: flex;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .12s;
    }
    .vocab-page-btn:active { background: var(--accent-light); }
    .vocab-page-btn:disabled { color: var(--text-3); cursor: default; background: none; }
    .vocab-page-label {
      font-family: var(--font-ui);
      font-size: 11px;
      color: var(--text-2);
      min-width: 44px;
      text-align: center;
    }

    /* ── List / Cards view toggle (single header button) ─────────── */
    /* Matches .vocab-add-btn exactly, plus a fixed width so the label swap
       (Flashcard / List View) doesn't resize it. */
    /* Icon-only, matching .vocab-add-btn at 30x30. Was a
       96px text pill — the widest item in the busiest header row. */
    .vocab-view-btn {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 30px;
      height: 30px;
      padding: 0;
      border: 1px solid transparent;
      border-radius: var(--r-pill);
      background: var(--accent-wash);
      color: var(--accent);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .15s, color .15s, border-color .15s;
    }
    /* Modifier for the ONE labelled instance (#vocabViewBtn). The Stories
       screen shares .vocab-view-btn and must stay a 30px circle, so the width
       change lives here rather than on the base class. */
    .vocab-view-btn-labelled {
      width: auto;
      gap: 6px;
      padding: 0 11px;
    }
    /* Visually hidden — still the accessible text where the button carries no
       visible label (#storiesViewBtn). */
    .vocab-view-label-sr {
      position: absolute;
      width: 1px;
      height: 1px;
      overflow: hidden;
      clip: rect(0 0 0 0);
      white-space: nowrap;
    }
    /* Visible since Aug 2026 — "Deep learn" / "Fast scan", written by
       _updateViewBtn(). Also still the accessible text. */
    .vocab-view-label {
      font-family: var(--font-ui);
      font-size: 12px;
      font-weight: 500;
      letter-spacing: 0.01em;
      white-space: nowrap;
    }
    /* Show the icon for the view you would switch TO. .cards-mode is toggled on
       the button by _updateViewBtn(), mirroring the label it replaced. */
    .vocab-view-ico { display: none; }
    .vocab-view-btn .ico-cards { display: block; }
    .vocab-view-btn.cards-mode .ico-cards { display: none; }
    .vocab-view-btn.cards-mode .ico-list  { display: block; }
    .vocab-view-btn:active { background: var(--accent-wash-press); }

    /* Now wraps the two view icons rather than an emoji. Kept as the same
       element/id because _tapBounce('vocabViewSparkle') animates it on toggle. */
    .vocab-view-sparkle {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      line-height: 0;
    }

    /* ── Shared tap-bounce feedback (any icon, any screen) ──────────────
       Apply `.tap-bounce` via _tapBounce(elId) in JS (vocab.js) to replay.
       Element must be `display:inline-block` (or flex/svg) for transform to apply.
       `_tapBounceEl` strips the class again on `animationend` — these classes
       are never meant to persist on an element. Leaving one in place makes the
       bounce replay on every screen switch, since a display:none -> visible
       toggle restarts any CSS animation. */
    .tap-bounce {
      animation: tapBounce .5s ease;
    }
    /* Gentler/longer variant for icons that play on navigation rather than
       on tap (Read/Story empty-state icons, quiz setup) — same motion,
       slower pace.

       THE RULE: tap -> .tap-bounce (.5s); arrival at a screen -> this (.9s).
       Two variants, no third. A `.tap-bounce-5s` (a 5s sequence of bounce,
       wiggle, pulse, settle) existed for #readGoToScanIcon alone and was
       removed Aug 2026 — it ran alongside #readEmptyIcon's .9s bounce on the
       same arrival, so two icons a few px apart animated at completely
       different tempos and for 5x different durations. A 5s Home variant was
       tried and removed on 31 Aug 2026 for a different reason: once the hint
       pill carries the words, the motion only has to point, and .9s is enough.
       Home's two icons share this one class and start in the same frame so
       they read as a single gesture — never give them separate timings. */
    .tap-bounce-slow {
      animation: tapBounce .9s ease;
    }
    @keyframes tapBounce {
      0%   { transform: scale(1)    rotate(0deg); }
      30%  { transform: scale(1.4)  rotate(-15deg); }
      55%  { transform: scale(0.9)  rotate(10deg); }
      100% { transform: scale(1)    rotate(0deg); }
    }
    /* Generic "grow then settle" tap feedback. Named for the vocab-list Danish
       word bump it was written for; that bump was removed Aug 2026, but five
       callers still use the class by name — vocab filter pills (vocab.js),
       quiz segmented buttons + story-length filter (quiz.js, two sites),
       bottom-nav tab labels (scan.js), and saved-story rows (story.js).
       Grep before renaming; the class name is passed as a string, so a rename
       fails silently rather than erroring. */
    .tap-word-grow {
      animation: wordGrow .35s ease;
    }
    @keyframes wordGrow {
      0%   { transform: scale(1); }
      40%  { transform: scale(1.2); }
      100% { transform: scale(1); }
    }

    /* Header row. Was one of a pair that swapped when multi-select was active;
       the select toolbar and everything behind it went in Aug 2026, so this is
       now simply the header's layout. */
    .vocab-header-normal {
      display: flex;
      align-items: center;
      justify-content: space-between;
      width: 100%;
      /* Host for the `flashcards` hint pill, below the Deep learn button. */
      position: relative;
    }


    /* ── Flashcard study view ───────────────────────────────────── */
    .vocab-cards {
      flex: 1;
      min-height: 0;
      display: none;             /* shown by setVocabView('cards') */
      flex-direction: column;
      padding: 10px 16px 14px;
    }
    .vocab-cards.active { display: flex; }

    .flashcard-progress {
      position: absolute;
      top: 14px;
      left: 16px;
      font-family: var(--font-ui);
      font-size: 11.5px;
      letter-spacing: 0.12em;
      color: var(--text-3);
    }

    /* Check-in readout (12 Sep 2026) — the drill's corner badge. A filling
       ring plus the count, in place of the plain "3 / 12" a single pass shows.
       The ring is what makes the number read as a target rather than a
       position: a drill counts words FINISHED, so it starts at 0, and 0 with no
       ring looked like a bug. Built in _progressHtml() in vocab.js. */
    .flashcard-progress.fc-checkin {
      display: inline-flex;
      align-items: center;
      gap: 5px;
      padding: 2px 8px 2px 3px;
      border-radius: var(--r-pill);
      background: var(--accent-wash);
      color: var(--accent);
      font-size: 11.5px;
      font-weight: 600;
      letter-spacing: 0;   /* the plain counter tracks at .12em; digits in a pill do not */
    }
    .flashcard-progress.fc-checkin svg { display: block; flex-shrink: 0; }
    .fc-ring-bg { fill: none; stroke: rgba(61, 111, 150, 0.18); stroke-width: 2.5; }
    .fc-ring-fg {
      fill: none;
      stroke: var(--accent);
      stroke-width: 2.5;
      stroke-linecap: round;
      /* Start the arc at 12 o'clock. transform-origin on the SVG element needs
         the % form here, not the unitless one. */
      transform: rotate(-90deg);
      transform-origin: 50% 50%;
      transition: stroke-dashoffset .3s ease;
    }
    @media (prefers-reduced-motion: reduce) { .fc-ring-fg { transition: none; } }

    /* ── Plant meter (5 Sep 2026) ────────────────────────────────────
       Three slots on the card front, mirroring .flashcard-progress across the
       top edge. Display only: derived from status + streak, nothing new is
       stored.
       Three pictures, not one meter (9 Sep 2026). Below zero: ONE large fallen
       brown leaf — To learn. 0-3: that many green leaves in three slots — To
       review, the only bucket where a number means anything. 4: one large pink
       bloom — Mastered. Both ends collapse to a single glyph because both are
       states rather than positions in a range. See DECISIONS.md.
       A single leaf, not a leafy sprout: at 14px anything with a stem and two
       leaves turns to mud. */
    .fc-plants {
      position: absolute;
      top: 14px;
      right: 16px;
      display: flex;
      gap: 3px;
      align-items: center;
      /* Above .flashcard-tap-nav, which spans the card's full height — without
         this the right-hand nav strip sits over the meter. It is not
         interactive, so pointer-events stay off and the tap still navigates. */
      z-index: 1;
      pointer-events: none;
    }
    /* Sizing lives on the CONTAINER, never on the glyph classes — the vocab
       list reuses the same SVGs at its own size (see .vocab-plants). */
    .fc-plants svg { width: 14px; height: 14px; display: block; }
    /* Mastered drops the meter for a single large bloom. 20px holds roughly the
       weight of the four 14px glyphs it replaces, so the corner does not
       visibly lighten when you page onto a mastered card. */
    .fc-plants.is-mastered svg,
    .fc-plants.is-fallen svg { width: 20px; height: 20px; }

    /* ── The same meter in the vocab list ────────────────────────────
       Uniform 13px, INCLUDING the bloom: the card enlarges its flower because
       it is alone in a corner, but rows sit in a column and an outsized glyph
       on the mastered ones would make the list look ragged.
       Right-aligned, just left of the Remove button. `.vocab-row1` is
       space-between with what used to be two children; `margin-left: auto`
       keeps this pinned right now that there are three, instead of letting
       space-between park it in the middle. */
    .vocab-plants {
      display: inline-flex;
      gap: 2px;
      align-items: center;
      flex-shrink: 0;
      margin-left: auto;
      margin-right: 10px;
    }
    .vocab-plants svg { width: 13px; height: 13px; display: block; }
    /* The list has no surface — rows sit on the veiled wallpaper — so the
       ungrown outline needs more weight here than on the card's white face,
       or it disappears over a light patch of image. */
    .vocab-plants .fc-leaf-off { stroke: #AFBCB4; stroke-width: 1.6; }

    /* Animations fire only on a CHANGE, never on a plain card render — the
       classes are added by _syncPlants() after comparing against data-n, which
       renderVocabCards() writes with the card. Transform-only (plus opacity) so
       they run on the compositor and cannot reflow the card mid-answer. */
    .fc-plants svg { transform-origin: 50% 85%; }   /* leaves grow from the base */

    /* One leaf earned: springs up from nothing, with a small overshoot. */
    @keyframes fcGrow {
      0%   { transform: scale(.2) translateY(4px); opacity: 0; }
      60%  { transform: scale(1.18) translateY(0);  opacity: 1; }
      100% { transform: scale(1); }
    }
    .fc-plants .fc-grew { animation: fcGrow .34s cubic-bezier(.34,1.4,.5,1) both; }

    /* Everything lost: the row sags and fades rather than vanishing, so the
       cost registers. Applied to the whole meter — the leaves it is mourning
       have already been replaced by outlines in the markup. */
    @keyframes fcDrop {
      0%   { transform: translateY(0) rotate(0);      opacity: 1; }
      45%  { transform: translateY(3px) rotate(-7deg); opacity: .35; }
      100% { transform: translateY(0) rotate(0);      opacity: 1; }
    }
    .fc-plants.is-dropping svg { animation: fcDrop .42s ease both; }

    /* Mastery: the bloom opens. Longer and showier than a leaf — it is the one
       moment in the meter worth marking. */
    @keyframes fcBloom {
      0%   { transform: scale(.25) rotate(-45deg); opacity: 0; }
      55%  { transform: scale(1.25) rotate(8deg);  opacity: 1; }
      100% { transform: scale(1) rotate(0); }
    }
    .fc-plants .fc-bloomed { animation: fcBloom .5s cubic-bezier(.3,1.35,.5,1) both; }

    @media (prefers-reduced-motion: reduce) {
      .fc-plants .fc-grew,
      .fc-plants.is-dropping svg,
      .fc-plants .fc-bloomed { animation: none; }
    }
    .fc-leaf-on  { fill: var(--leaf); }
    .fc-leaf-off { fill: none; stroke: var(--leaf-empty); stroke-width: 1.4; }
    .fc-leaf-wilt { fill: var(--leaf-wilt); }
    /* Petals pink, eye gold. --gold-press is the existing lighter tint of
       --gold, reused as the flower's centre. */
    .fc-bloom-on  { fill: var(--bloom); }
    .fc-bloom-eye { fill: var(--gold-press); }

    .flashcard-stage {
      position: relative;
      flex: 1;
      min-height: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      perspective: 1700px;
      padding: 4px 4px 6px;
    }

    /* Tap-to-navigate over the blank margin OUTSIDE the card, between the stage
       edge and the deck. No z-index (stays at the stage's default paint order),
       and placed BEFORE .flashcard-deck in source — so the deck always paints
       on top and stays tappable regardless of how wide this strip is. On
       narrow screens where the deck is flush against the stage edge, the deck
       simply covers this entirely and it does nothing there — safe by
       construction, not by computing the exact gap width. There's no visible
       icon here (removed) — swipe and this invisible zone are the only way
       to navigate. */
    .flashcard-outer-nav {
      position: absolute;
      top: 0;
      bottom: 0;
      width: 56px;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .flashcard-outer-nav.left  { left: 0; }
    .flashcard-outer-nav.right { right: 0; }

    /* Deck — sizing box that holds the flip card + the peeking stack behind it */
    .flashcard-deck {
      position: relative;
      width: 100%;
      max-width: 318px;
      height: 100%;
      /* 525px, raised from 500px on 29 Aug 2026 — the old cap left a dead band
         on tall phones and cost the back face visible detail lines.
         No margin-top: the stage already centres the deck, so any margin here
         just pushes the card below centre (it did, by 16px). */
      max-height: 525px;
      transform-style: preserve-3d;        /* pass stage perspective through to the card */
      animation: flashIn .42s ease both;   /* entry animation lives here, NOT on .flashcard,
                                              so it never overrides the flip transform */
    }
    .deck-layer {
      position: absolute;
      left: 50%;
      transform: translateX(-50%);
      height: 100%;
      background: var(--white-soft);
      border: 1px solid #ECEEF3;
      border-radius: 22px;
      z-index: 0;
    }
    .deck-1 { top: 8px;  width: 92%; box-shadow: 0 8px 20px rgba(38,62,86,0.06); }
    .deck-2 { top: 16px; width: 84%; opacity: .55; box-shadow: 0 6px 16px rgba(38,62,86,0.05); }

    .flashcard {
      position: absolute;
      inset: 0;
      z-index: 2;
      transform-style: preserve-3d;
      transition: transform .55s cubic-bezier(.2,.7,.2,1);
      -webkit-tap-highlight-color: transparent;
    }
    .flashcard-face { -webkit-tap-highlight-color: transparent; }
    .flashcard.flipped { transform: rotateY(180deg); }
    @keyframes flashIn {
      from { opacity: 0; transform: translateY(10px) scale(.975); }
      to   { opacity: 1; transform: translateY(0) scale(1); }
    }

    .flashcard-face {
      position: absolute;
      inset: 0;
      backface-visibility: hidden;
      -webkit-backface-visibility: hidden;
      /* White base + a bottom-edge gradient band, full width, painted behind
         all children (background is always behind content). This is what
         lets .flashcard-tap-nav and .flashcard-flip-zone stay backgroundless
         and still show the gradient edge-to-edge, including under the
         rounded corners (clipped by this element's own overflow:hidden).
         The gradient fades back to white over its last ~1% (a thin rim at
         the card's outer edge) so the deck-layer slivers peeking out behind
         the card (.deck-1/.deck-2) still read as separate stacked pages —
         a flush accent-tint edge made the stack illusion disappear. */
      background-color: var(--white-soft);
      /* 55% of --accent-light since 10 Sep 2026, was the token at full
         strength. The band marks the tap area for See answer / Next; once the
         option tiles moved down to sit flush on it, two blue washes met at the
         same edge and the bottom of the card read as heavy. Lighter here, not
         smaller: the band's 72px is deliberately taller than the 48px tap zone
         so the shading fades in ABOVE the control rather than starting at its
         edge. Literal rgb, not the token, because a var() cannot carry alpha —
         if --accent-light moves, move this with it. */
      background-image: linear-gradient(to bottom, transparent, rgba(214, 232, 245, 0.55) 99%, var(--white) 100%);
      background-repeat: no-repeat;
      background-size: 100% 72px;
      background-position: bottom;
      border: 1px solid #ECEEF3;
      border-radius: 22px;
      box-shadow: 0 12px 34px rgba(38,62,86,0.12), 0 2px 8px rgba(38,62,86,0.06);
      display: flex;
      flex-direction: column;
      overflow: hidden;
    }
    .flashcard-front {
      align-items: center;
      /* flex-start, not center (3 Sep 2026): the word block used to be
         centred in whatever space was left above the reserved bottom band,
         so it sat at a different height in Recall (96px reserved) than in
         Choice (232px, more rows). Anchoring to the top instead pins it at
         the same spot in both modes, regardless of how many option rows sit
         below — the two faces are meant to read as one family. */
      justify-content: flex-start;
      gap: 16px;
      /* Top (110px) gives the word clearance below .flashcard-progress
         (top:14px) instead of sitting flush under it — 30px read as jammed
         against the top edge once flex-start stopped centring it; 48px,
         40px, 65px, 80px, then 100px were tried first and came back too
         tight (3 Sep 2026). left/right (24px) matches
         .flashcard-back-scroll's 24px so both faces share the same inner
         margins. */
      /* Top space is a SHRINKABLE SPACER (::after below), not padding — see
         there. Bottom padding is EXACTLY .flashcard-flip-zone-front's band
         height and nothing more — the rows sit flush on top of it. 24px of
         gap (the figure inherited from their old `bottom: 96px`), then 12px,
         then none, then the band itself went 72 -> 60 -> 48 -> back to 60
         (10 Sep 2026, six passes the same day). 48px was tried and reversed:
         it sits right at the ~44px tap-target minimum and read as cramped, so
         60px is the settled value, not a waypoint on the way lower.
         Moving the control out of the card entirely was considered and not
         taken (previewed as `Claude outputs/flip_placement.html`, option D):
         that version only wins when the stage has spare height below the
         525px card cap — on a shorter phone the card just shrinks to make
         room and the rows gain nothing, while the change costs JS and flip-
         flow risk. Revisit that preview before changing this again.
         **KEEP THIS EQUAL TO .flashcard-flip-zone-front's height.** They are
         one measurement written twice; a mismatch either opens a gap the rows
         used to have or drops them ONTO the band, which is a full-width tap
         target — that overlap costs taps, not looks. To move the rows further
         down, shrink the BAND (both faces) and follow it here. 48px is the
         practical floor for the band: below that the flip control gets fiddly
         on a phone. */
      padding: 0 24px 60px;
      text-align: center;
    }
    /* The old `padding-top: 110px` in one shrinkable piece. Padding cannot
       shrink, so on a card shorter than the 525px deck cap the fixed 110px
       stayed while the option rows grew upward into it — measured on 10 Sep
       2026 as the word/audio row and the options overlapping by ~19px at a
       480px card and ~58px at 440px. As a flex spacer the same space simply
       gives way when the card is short, and nothing overlaps at any height.
       72px + the column's own 16px gap == 88px of top space, down from 110px
       on 10 Sep 2026. Read the history before changing this again: 110px was
       itself landed on 3 Sep after 48, 40, 65, 80 and 100 all came back too
       tight. What changed is the bottom half of the card — the option block
       went from three underline rows (~134px) to a 2x2 grid of tiles (201px),
       so the same 110px that read as balanced then reads as a gap above a
       crowded card now. If the tiles ever shrink back, revisit this with them.
       The high `flex-shrink` is what orders the giving-up. Shrink is
       proportional, not sequential, so a modest factor (20 was tried) still
       took a few px off the rows on the very first pixel of shortfall and
       clipped them. At 200 this absorbs effectively all of it, and only once
       it bottoms out at 0 does flexbox redistribute what is left to the rows —
       which is the order that matters: the top of the card is empty space, the
       rows are the thing you tap. Measured floor: a card of 445px or taller
       needs no shrinking from the rows at all (was 403px before the 2x2 grid
       grew the block to 201px and this spacer gained its 20px minimum).

       ::after with `order: -1`, NOT ::before — ::before on this element is
       already taken by the accent/gold top stripe further down this file, and
       an element gets only one of each. `order: -1` puts this ahead of every
       real child, which is where a top spacer has to be. */
    .flashcard-front::after {
      content: '';
      order: -1;
      flex: 0 200 72px;
      /* Not 0: .flashcard-progress sits absolutely at top:14px and its text
         ends around 28px down, so a spacer that collapses all the way lets the
         headword ride up underneath it — measured at -12px on a 400px card.
         20px + the column's 16px gap keeps the word clear of it at every
         height, and the rows give up those last 20px instead. Two things
         overlapping is worse than one row clipping. */
      min-height: 20px;
    }
    /* Tap-to-navigate strips — cover the same 24px left/right padding margin
       reserved on both faces (front's own padding above; .flashcard-back-scroll's
       padding on the back), so they exactly fill the blank space and never sit
       over real content. First child in each face, so real content — which is
       later in source and painted on top — still wins any accidental overlap.
       This, plus .flashcard-outer-nav (blank margin outside the card) and
       swipe, are now the ONLY ways to navigate — the visible arrow buttons
       were removed. */
    .flashcard-tap-nav {
      position: absolute;
      top: 0;
      bottom: 0;
      /* 24px, narrowed from 32px on 29 Aug 2026 to give the back-face text more
         width. Anything thinner stops being a reliable thumb target — swipe and
         the outer margin strips are the fallback, but this is the main one. */
      width: 24px;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .flashcard-tap-nav.left  { left: 0; }
    .flashcard-tap-nav.right { right: 0; }

    /* ── Direction hint (13 Sep 2026) ────────────────────────────────
       A faint chevron inside each tap strip. The strips have always been
       there; nothing about the layout moves, they are just no longer
       invisible. Deliberately NOT the arrow buttons removed earlier: no
       surface, no shadow, no border — a mark at 30%, not a control.
       Drawn with two borders on a rotated square rather than an SVG or a
       data URI, so it inherits currentColor, stays crisp at any density and
       costs no markup. The nav strips carry no other content, so ::after is
       free.
       Inside the card, not outside it: the blank margin beside the card is
       whatever the screen has left over — 5px on a 360px phone, 40px on a
       430px one — so anything placed there lands somewhere different on
       every device, or on the card itself. This is positioned against the
       card, so it is identical everywhere. */
    .flashcard-tap-nav { color: var(--text-3); }
    .flashcard-tap-nav::after {
      content: '';
      position: absolute;
      top: 50%;
      left: 50%;
      width: 8px;
      height: 8px;
      border-right: 1.6px solid currentColor;
      border-bottom: 1.6px solid currentColor;
      opacity: .3;
      transition: opacity .18s ease;
    }
    .flashcard-tap-nav.left::after  { transform: translate(-35%, -50%) rotate(135deg); }
    .flashcard-tap-nav.right::after { transform: translate(-65%, -50%) rotate(-45deg); }
    /* First card: there is nothing behind it, so the mark goes rather than
       lying. The strip itself stays — cardNav() already no-ops there, and
       removing the target would change the tap area mid-deck. */
    .flashcard-tap-nav.nav-off::after { display: none; }
    /* A check-in card you have not answered: forward is blocked (see cardNav).
       Faded rather than hidden, because the way forward still exists — you
       just have to answer first. A flat mark can be dim and still read as
       deliberate; an object with a shadow reads as broken. */
    .flashcard-tap-nav.nav-dim::after { opacity: .09; }
    /* The card's top stripe. On .flashcard-face, NOT .flashcard-front (14 Sep
       2026): it belongs to the card, not to one side of it. It was front-only
       while the back could be reached solely by flipping, where the bar
       disappearing mid-turn read as part of the flip. Display mode renders the
       back as the only face, and a card with no stripe at the top read as
       unfinished beside the other two modes.
       Absolutely positioned, so it stays out of the face's flex column; the
       face's own overflow:hidden clips it to the 22px radius.
       This also applies to the end-of-pass card, which is a .flashcard-face by
       construction — deliberate, it is one of the deck's cards. */
    .flashcard-face::before {
      content: '';
      position: absolute;
      top: 0; left: 0; right: 0;
      height: 4px;
      background: linear-gradient(90deg, var(--accent), var(--gold));
      opacity: .9;
      z-index: 1;
    }
    .flashcard-back {
      transform: rotateY(180deg);
      padding: 0;
    }
    /* Display mode renders the back as the ONLY face, so its 180deg flip has
       nothing to flip against and would show the card mirrored. flipCard()
       returns early in this mode for the same reason — do not remove one
       without the other. */
    .flashcard.display-mode .flashcard-back { transform: none; }

    .flashcard-word {
      font-family: var(--font-mark);
      font-size: 42px;
      font-weight: 500;
      line-height: 1.1;
      color: var(--text);
      word-break: break-word;
      /* Left-aligned (3 Sep 2026), unlike everything else on the front
         (.flashcard-front centres its flex column) — align-self plus a full
         width is what it takes to break just this one child out of that
         centring; text-align alone would not, since a flex item's box is
         only as wide as its content. */
      align-self: flex-start;
      width: 100%;
      text-align: left;
    }
    .flashcard-audio-row {
      display: inline-flex;
      align-items: center;
      gap: 10px;
      /* Left-aligned under the left-aligned word (3 Sep 2026) — same
         align-self trick as .flashcard-word, for the same reason. */
      align-self: flex-start;
    }
    .flashcard-audio-label {
      font-family: var(--font-ui);
      font-size: 12px;
      font-style: italic;
      color: var(--text-3);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .flashcard-audio {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 46px;
      height: 46px;
      border-radius: 50%;
      border: 1px solid var(--accent-light);
      background: var(--white-soft);
      color: var(--accent);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .15s, transform .1s;
    }
    .flashcard-audio:active { background: var(--accent-light); transform: scale(.94); }
    .flashcard-audio.speaking { background: var(--accent); color: var(--white); border-color: var(--accent); }

    /* Bottom-of-front option rows — shared by BOTH modes (3 Sep 2026): Recall's
       3 grade rows (Forget / Look familiar / Remember) and Choice's up to 3
       meaning rows. One row style, so the two modes read as the same family
       — only the row count and what a tap does differ. Pinned with
       position:absolute, out of the flex column's flow entirely (see
       .flashcard-front above, which no longer reserves space for this).
       bottom:96px (nudged up from 72px, 3 Sep 2026) — leaves a gap above
       .flashcard-flip-zone-front's 72px band instead of sitting flush on it.
       A one-line prompt label sits above the rows (5 Sep 2026), always
       visible — "Pick the meaning" (Choice) / "How well do you know this?"
       (Recall), set in vocab.js. A same-day pass tried moving the rows
       themselves to a 3-across card grid (reusing the old Listen & Pick
       button treatment); reverted after seeing it live — see DECISIONS.md
       "Flashcard Choice mode" for both attempts. Rows stay underlined
       fields, not boxes. */
    .flashcard-options {
      /* In the flex column since 10 Sep 2026, no longer `position: absolute;
         bottom: 96px`. `margin-top: auto` puts it at the bottom of the
         content box exactly as the old offset did, but now the CARD decides
         how much room there is instead of the rule assuming a 525px one.
         Side padding is 0 because .flashcard-front's own 24px now applies —
         the old 22px was this block's only inset when it sat outside the
         flow. */
      width: 100%;
      margin-top: auto;
      padding: 4px 0 0;
      /* Layout (grid, columns, gaps) is set in the tile block further down —
         BOTH modes are tiles since 10 Sep 2026, so it is one definition, not
         a base flex column plus a per-mode override that could disagree. */
      /* No max-height any more (10 Sep 2026). The old 185px cap — and the
         short-card overlap warning that sat here — both existed only because
         an absolutely positioned block cannot know how much room it has, so
         the height had to be guessed and then policed. In the flex column the
         card bounds this block directly. `overflow: hidden` stays as the last
         resort: on a card too short even after the top spacer has collapsed,
         a row clips at the edge rather than drawing over the word above it. */
      overflow: hidden;
    }
    .flashcard-options-label {
      /* Centred (13 Sep 2026). Left-aligned, it hung off the left edge of a
         block whose every other element — the headword, the tiles, the flip
         hint — is centred on the card's axis. */
      text-align: center;
      font-family: var(--font-ui);
      font-size: 11.5px;
      color: var(--text-3);
      margin-bottom: 8px;
    }
    /* Shared behaviour only. Everything visual — fill, radius, padding,
       alignment — lives in the tile block further down, which both modes use
       (10 Sep 2026, when Recall's underline rows became tiles too). The old
       hairline-underline declarations were deleted rather than overridden, so
       there is no dead rule left to mislead the next reader. */
    .flashcard-option-btn {
      display: flex;
      align-items: center;
      border: none;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .flashcard-option-btn:active { transform: scale(.98); }
    /* 20px, up from 14px (10 Sep 2026): it sits ABOVE the label in a Recall
       tile now, not beside it in a row, so it carries the tile rather than
       decorating a line of text. */
    .flashcard-option-emoji { font-size: 20px; line-height: 1; flex-shrink: 0; }
    .flashcard-option-text {
      font-family: var(--font-ui);
      font-weight: 500;
      color: var(--text);
      line-height: 1.3;
      /* Clamp rather than wrap freely — the front never scrolls, so an
         unbounded wrap would push the option block into the word above it.
         The line count is set per mode in the tile block below. */
      display: -webkit-box;
      -webkit-box-orient: vertical;
      overflow: hidden;
    }
    /* Light tint, not a thin coloured underline (5 Sep 2026) — the hairline
       version was hard to read at a glance and relied on color alone, a real
       accessibility problem for red/green colorblindness. Went through a
       solid full-colour fill first (white text) — worked, but read as
       inelegant/heavy; shown side by side with lighter alternatives
       (`Claude outputs/pill_style_preview.html`) and settled on
       the tint colours (#EAF3EE/#FDF0F0) the Listen & Pick buttons used
       (that quiz was removed 5 Sep 2026) instead of their full strength, so this reuses an existing in-app palette rather than
       inventing one. Choice's correct/wrong also get a ✓/✗ glyph before the
       text (`.choice-mode .flashcard-option-btn.correct/.wrong`, scoped to
       Choice only) — a second signal beyond colour, since Choice has no
       icon of its own. Recall's Forget/Remember already carry their own
       emoji, so they keep the tint/border alone; adding the glyph there too
       gave those two rows a redundant double icon while "Look familiar" had
       just the one (5 Sep 2026, caught in review). .picked matches the same
       tint weight with --accent-wash/--accent instead of green/red, no
       glyph either way — it isn't a right/wrong answer, just picked. */
    /* correct/wrong/picked used to switch to a full 1.5px border + rounded
       corners + 6px/8px padding (a "pill"), which changed each row's box
       size the moment you answered — everything below it visibly shifted
       (5 Sep 2026 report). Fixed by keeping the exact same box as the idle
       state (border-bottom only, same 0/2px/7px padding) and recoloring
       that hairline + adding the tint instead. Box size never changes on
       tap, so nothing shifts; the colour, background tint and ✓/✗ glyph
       still carry the signal. */
    .flashcard-option-btn.correct .flashcard-option-text { color: var(--success); }
    .choice-mode .flashcard-option-btn.correct .flashcard-option-text::before { content: "✓  "; }
    .flashcard-option-btn.wrong { animation: listenWrongShake .5s ease; }
    .flashcard-option-btn.wrong .flashcard-option-text { color: var(--error); }
    .choice-mode .flashcard-option-btn.wrong .flashcard-option-text::before { content: "✗  "; }
    /* One-shot pop on the option the user actually tapped (`fc-pop`, added in
       flashChoiceTap). Deliberately NOT hung off `.correct`: that class also
       lands on the revealed right answer after a wrong pick, and on Recall's
       Remember row, where _syncFlashGradeBtns() re-applies it on every repaint
       — either would replay the animation for a tap that never happened.
       Scale only, so the row's box never changes size and nothing below it
       shifts (the same rule the correct/wrong tints follow, 5 Sep 2026). */
    @keyframes fcOptionPop {
      0%   { transform: scale(1); }
      45%  { transform: scale(1.06); }
      100% { transform: scale(1); }
    }
    .flashcard-option-btn.fc-pop { animation: fcOptionPop .34s cubic-bezier(.34,1.4,.5,1); }
    @media (prefers-reduced-motion: reduce) {
      .flashcard-option-btn.fc-pop,
      .flashcard-option-btn.wrong { animation: none; }
    }
    /* Recall's neutral "Familiar" — the same gradient shape as correct/wrong
       so all three answered states are one family, in accent blue rather than
       green or red because it is a pick, not a verdict. */
    .flashcard-option-btn.picked {
      --c1: rgba(61, 111, 150, 0.16);
      --c2: rgba(61, 111, 150, 0.03);
      background-color: transparent;
      background-image: linear-gradient(135deg, var(--c1), var(--c2));
    }
    .flashcard-option-btn.picked .flashcard-option-text { color: var(--accent); }
    /* ── Choice mode: 2x2 gradient tiles (10 Sep 2026) ───────────────
       Four options in a two-column grid, filled with a light diagonal
       gradient and NO border. Scoped to `.choice-mode` throughout, so Recall
       keeps the underline rows above: its three grades are a scale (Forget ->
       Look familiar -> Remember) and a scale has to read in one direction,
       which a 2x2 block does not. Choice's four options have no order at all,
       so a grid suits it and a column does not — that difference is the whole
       reason the two modes stop sharing a layout here.

       Chosen from a six-way HTML preview (`Claude outputs/choice_2x2_previews.html`)
       covering four gradient directions, a flat tint and today's rows. The
       135deg diagonal won: deepest at top-left where the text starts, so a
       block of four tiles still has a reading direction.

       This is the THIRD attempt at boxing these options — stacked bordered
       boxes (3 Sep) and a 3-across bordered card grid (5 Sep) were both built
       and reverted as too heavy. What is different here is that nothing is
       drawn: no border, no shadow, and the fill fades to nearly nothing at the
       far corner, so a tile has no hard edge anywhere. If this one is called
       back too, the honest conclusion is that the card does not want boxes at
       any weight, and the underline rows are the answer — see DECISIONS.md.

       Colour lives in --c1/--c2 and the gradient shape is written once, so
       correct/wrong reuse the identical shape and can never drift from the
       idle state. */
    .flashcard-options {
      display: grid;
      grid-template-columns: 1fr 1fr;
      /* Explicit rows, not implicit ones: the label row sizes to its text and
         the two tile rows share the rest equally, so a row holding a one-line
         meaning is the same height as the row above it holding a two-line one.
         With `grid-auto-rows: 1fr` instead, the label row would stretch to
         match the tiles and open a gap under the prompt. */
      grid-template-rows: auto 1fr 1fr;
      /* Uneven on purpose (10 Sep 2026): crowding in a 2x2 block is vertical —
         the tile above and the tile below are the ones that touch — so the
         room goes into the row gap. Widening the columns instead would only
         narrow the tiles and cost text room, which is already the tight
         dimension here. The 18px also lands between the label and the first
         row, which reads fine and is not worth a rule to undo. */
      row-gap: 18px;
      column-gap: 10px;
    }
    .flashcard-options-label { grid-column: 1 / -1; margin-bottom: 1px; }
    .flashcard-option-btn {
      /* Blue, not grey (10 Sep 2026). The first pass faded --accent (#3D6F96,
         a muted slate) at ~11% alpha, and at that alpha over the card's warm
         off-white face (#FCFBF8) the hue washed out and the tile read grey.
         Fixed by building from --accent-light instead — the palette's actual
         light blue, used at full strength as the near stop — rather than by
         raising the alpha on the dark one, which only makes a grey darker.
         Same colour the card's own bottom band already fades into, so the
         tiles and the card edge are the one blue, not two.

         Held at 55% of --accent-light rather than the token itself (75% first,
         softened 10 Sep 2026): at full strength the tiles read as four filled
         chips competing with the headword, and even at 75% four of them in a
         grid felt crowded — weight reads as crowding just as much as spacing
         does, so the fix was both, a wider row gap and a lighter fill. Written
         as the token's literal rgb because a var() cannot carry its own alpha
         — if --accent-light ever moves, this pair moves with it by hand. */
      --c1: rgba(214, 232, 245, 0.55);
      --c2: rgba(214, 232, 245, 0.06);
      border-bottom: none;
      border-radius: var(--r-btn);
      /* Roomier than the rows' 4px/2px: a tile is a filled shape, so its text
         needs slack on all four sides rather than just a hairline to sit on.
         12px vertical, raised from 9px on request (10 Sep 2026) — a ~73px tile
         against the old ~67px. This is the ceiling, not a preference: the block
         lands at 182px against .flashcard-options' 185px cap, so anything
         taller has to raise that cap, and raising it grows the block UPWARD
         into the word/audio block (see the short-card note there). */
      padding: 12px 11px;
      /* Centred, both ways (10 Sep 2026). The rows above are left-aligned
         fields sitting on a hairline, where a ragged left edge would be the
         only thing lining them up; a tile is a shape with its own middle, and
         four of them in a grid read as a block rather than a list. Recall
         keeps left alignment — its rows lead with an emoji, which has to stay
         in a column for the three grades to scan as a scale. */
      justify-content: center;
      text-align: center;
      /* The three-line reserve moved here from the text (10 Sep 2026): a
         min-height on the TEXT made the span itself three lines tall, so a
         one-line meaning sat pinned to the top of it and `align-items: center`
         had nothing left to centre. On the button, the span stays its natural
         height and gets centred inside the reserve.
         `font-size` is set here rather than on the span so this `em` and the
         text's are the same one — border-box, so the 24px of vertical padding
         is part of the figure. */
      font-size: 12.5px;
      min-height: calc(1.3em * 3 + 24px);
      background-color: transparent;
      background-image: linear-gradient(135deg, var(--c1), var(--c2));
      transition: transform .1s;
    }
    /* 12.5px, not the rows' 13px, and THREE clamped lines instead of two: a
       tile is half the width, so the same meaning has half the room. Measured,
       not guessed — at 13px/2 lines a real seeded meaning ("cosiness, warm
       togetherness") already ellipsised in a 131px tile, and every meaning in
       the worst-case set did. At 12.5px/3 lines the grid still lands ~163px
       against the 185px cap, so the third line costs nothing that was being
       used. If a future change adds a fifth option or a second label line,
       re-measure this before assuming there is still room. */
    /* Inherits the button's 12.5px. The three-line reserve is the button's
       min-height, not this element's — see there. Every tile reserves its full
       three lines whether the meaning needs them or not: without the reserve
       the grid changed height card to card (four one-word meanings gave a
       squat block, one long meaning a tall one) and the rows visibly jumped
       while paging through a deck. */
    .flashcard-option-text {
      font-size: inherit;
      -webkit-line-clamp: 3;
    }
    /* `.opt-pos` has had NO rule at all since the Listen quiz was deleted with
       its stylesheet block (5 Sep 2026) — the abbreviation has been rendering
       at full text size and colour, running straight into the meaning. Invisible
       at full row width; not at half. */
    .opt-pos {
      font-size: 10.5px;
      font-weight: 400;
      color: var(--text-3);
      text-transform: uppercase;
      letter-spacing: 0.06em;
    }
    /* Answered states: same gradient, different stops. `background-color` is
       reset explicitly because the shared .correct/.wrong rules above set the
       `background` SHORTHAND, which would otherwise leave their flat tint
       sitting underneath this gradient and double the weight. */
    .flashcard-option-btn.correct {
      --c1: #DCEDE4; --c2: #F8FCFA;
      background-color: transparent;
      background-image: linear-gradient(135deg, var(--c1), var(--c2));
    }
    .flashcard-option-btn.wrong {
      --c1: #FADFDF; --c2: #FEFAFA;
      background-color: transparent;
      background-image: linear-gradient(135deg, var(--c1), var(--c2));
    }
    .flashcard-option-btn.correct .opt-pos,
    .flashcard-option-btn.wrong   .opt-pos { color: inherit; opacity: .7; }

    /* ── Recall mode: three tiles in a row (10 Sep 2026) ─────────────
       The three grades use the same tile as Choice — same fill, radius,
       padding, centring — differing only in count (3 across, one row) and in
       stacking the emoji ABOVE the label instead of beside it.

       The emoji has to go on top: at three across a tile is ~85px wide, and an
       inline emoji leaves ~55px for the word, which truncates "Remember".
       Previewed as `Claude outputs/recall_tiles.html`.

       KNOWN TRADE, accepted: the three grades are a SCALE (Forget -> Familiar
       -> Remember), and a column reads as ordered more strongly than a row.
       That is why they were a column until now (see the note in the Choice
       block above, written the same day). If the order ever stops reading,
       this is the change that cost it — not a mystery to re-investigate. */
    .flashcard-front:not(.choice-mode) .flashcard-options {
      grid-template-columns: repeat(3, 1fr);
      grid-template-rows: auto 1fr;   /* label, then the single tile row */
    }
    .flashcard-front:not(.choice-mode) .flashcard-option-btn {
      flex-direction: column;
      gap: 5px;
      /* Two lines, not Choice's three: "Look familiar" was shortened to
         "Familiar" so every label now fits in one or two, and a third line of
         reserve would only add empty tile. 22px covers the emoji above. */
      min-height: calc(1.3em * 2 + 24px + 22px);
    }
    .flashcard-front:not(.choice-mode) .flashcard-option-text {
      -webkit-line-clamp: 2;
    }

    .flashcard-option-btn:disabled { cursor: default; }
    .flashcard-option-btn:disabled:not(.correct):not(.wrong) { opacity: .5; }

    /* Back face */
    .flashcard-back-scroll {
      flex: 1;
      min-height: 0;
      overflow-y: auto;
      -webkit-overflow-scrolling: touch;
      padding: 14px 24px 10px;   /* side margins doubling as tappable strips to flip the card;
                                    10px bottom — the 72px flip zone below already
                                    supplies the visual breathing room */
      display: block;   /* block flow so the shared vocab detail margins apply */
    }
    /* First row: word + (POS + translation), with an icon-only Remove on the right.
       It is a SIBLING of .flashcard-back-scroll, not inside it — a fixed head above
       the scrolling detail, so the word / POS / translation stay visible on long
       cards. Deliberately not position:sticky inside the scroller: that version
       shipped 29 Aug 2026 and needed a negative top margin to sit flush, which
       pulled the first detail block up under the bar (the memory hook was hidden
       at rest) and let content paint in the strip above it. Keep its 24px side
       padding equal to .flashcard-back-scroll's — the .flashcard-tap-nav strips
       cover exactly that margin. */
    .flashcard-back-head {
      flex-shrink: 0;
      display: flex;
      align-items: flex-start;
      justify-content: space-between;
      gap: 10px;
      /* Top is 54px, not the original 22px (14 Sep 2026): the card's progress
         readout now renders on this face too, absolutely positioned at
         top:14px exactly as on the front. 22px put it straight over the
         headword; 40px cleared it but by about 10px, which read as cramped.
         54px leaves the row roughly its own height in clear space below it.
         Two couplings, neither visible from here:
           * that row's `top` (14px) — if it moves, this moves with it;
           * the row's line-height — this is 14px + the row + breathing room,
             so a bigger progress font eats the gap rather than pushing down. */
      padding: 54px 24px 10px;
      border-bottom: 1px solid var(--border);
    }
    .flashcard-back-headmain {
      display: flex;
      flex-direction: column;
      align-items: flex-start;
      gap: 7px;
      min-width: 0;
    }
    .flashcard-back-darow {
      display: flex;
      align-items: center;
      gap: 10px;
    }
    /* Card back: example sentence uses the full row; audio button sits inline
       at the end of the sentence (list view keeps its side-by-side layout). */
    .flashcard-detail .vocab-sentence-da-row { display: block; }
    .flashcard-detail .vocab-sentence-da { display: inline; }
    .flashcard-detail .vocab-sentence-da-row .sentence-speak-btn {
      display: inline-flex;
      vertical-align: middle;
      margin-left: 6px;
    }
    .flashcard-back-meta {
      display: flex;
      align-items: center;
      gap: 8px;
      flex-wrap: wrap;
    }
    .flashcard-remove-btn {
      flex-shrink: 0;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 32px;
      height: 32px;
      border-radius: 50%;
      border: 1px solid var(--border);
      background: none;
      color: var(--text-3);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .15s, color .15s, border-color .15s, transform .1s;
    }
    .flashcard-remove-btn:active {
      background: var(--surface);
      color: var(--error);
      border-color: var(--error);
      transform: scale(.94);
    }
    .flashcard-back-da {
      font-family: var(--font-mark);
      font-size: 24px;
      font-weight: 500;
      color: var(--text);
      line-height: 1.15;
    }
    .flashcard-back-en {
      font-family: var(--font-ui);
      font-size: 13px;
      font-weight: 400;
      color: var(--text-2);
      line-height: 1.3;
    }
    .flashcard-back-pos {
      align-self: flex-start;
      background: var(--accent-light);
      color: var(--accent);
      font-family: var(--font-ui);
      font-size: 11px;
      font-weight: 500;
      padding: 3px 11px;
      border-radius: 99px;
    }
    /* The card back's detail block reuses the expanded list view's classes
       (.vocab-mnemonic, .vocab-context-card, .vocab-phrases, .vocab-lookup-btn,
       .vocab-regen-btn, .vocab-delete-btn) so fonts and spacing match exactly. */

    /* .flashcard-nav-btn is still used by story.js's .stories-card-nav (Saved
       Stories card view) — keep the button style even though vocab's own
       below-card nav row is gone (navigation is now swipe + tap zones only,
       no visible arrow buttons). */
    .flashcard-nav-btn {
      width: 46px;
      height: 46px;
      border-radius: 50%;
      border: 1px solid var(--border);
      background: var(--white-soft);
      color: var(--accent);
      display: inline-flex;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .15s, transform .1s;
    }
    .flashcard-nav-btn:active { background: var(--accent-light); transform: scale(.94); }

    /* In-card flip control — replaces the old standalone Flip button outside the
       card AND the old whole-card tap-to-flip. .flashcard-flip-zone is the tap
       target (the WHOLE bottom band, not just the icon); .flashcard-flip-hint
       inside it is purely the small visual icon, unchanged in size/look. The
       two zones are deliberately identical — same left/right inset (24px),
       same height (60px, was 72px until 10 Sep 2026), same bottom offset (0)
       — so the icon sits at the same spot on both faces and the card doesn't
       visually "jump" on flip. Change one height and you must change the
       other, plus .flashcard-front's bottom padding, which reserves exactly
       the front band. */
    .flashcard-flip-zone {
      display: flex;
      align-items: center;
      justify-content: center;
      margin: 0;
      padding: 0;
      border: none;
      /* No background here — the gradient now lives on .flashcard-face
         (see its comment) so it spans full card width, not just this
         24px-inset zone. Keeping this transparent lets the face's
         background show through. */
      background: none;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    /* Went through two earlier looks the same day — a plain 28px grey circle
       icon, then a pill/button with a background — before landing here on a
       third HTML preview round (`Claude outputs/see_answer_next_style_previews.html`,
       option C): plain bold text, no box at all. One shared class for both
       "See answer" and "Next" so they can't drift apart again; a plain
       `<svg>` after the text (Next only) sits fine in the flex row without
       its own rule. */
    .flashcard-flip-hint {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      gap: 5px;
      font-family: var(--font-ui);
      font-size: 12.5px;
      font-weight: 400;
      color: var(--text-2);
      opacity: 1;
      transition: opacity .15s, color .15s;
    }
    .flashcard-flip-zone:active .flashcard-flip-hint { opacity: .6; }
    /* Locked until the card is answered (5 Sep 2026) — a real `disabled`
       button (see vocab.js), not just a dimmed look, so it also blocks
       keyboard activation, not only clicks. Muted grey + lower opacity so
       "not available yet" reads differently from "available, tap me". */
    .flashcard-flip-zone-front:disabled { cursor: default; }
    .flashcard-flip-zone-front:disabled .flashcard-flip-hint {
      color: var(--text-3);
      opacity: .4;
    }
    /* Front: .flashcard-options (grade or choice rows) is itself pinned with
       position:absolute; bottom:72px — it's already out of the flex column's
       flow. The zone fills exactly the free band below it (0–72px from the
       bottom); the word/audio-row above is anchored to the top of the card
       (see .flashcard-front) so it never reaches down into this reserve,
       regardless of mode. */
    .flashcard-flip-zone-front {
      position: absolute;
      left: 24px;
      right: 24px;
      bottom: 0;
      /* 60px, was 72px (10 Sep 2026) — shrunk to move the option rows down,
         since .flashcard-front's bottom padding tracks this exactly. 48px was
         tried the same day and reversed: it sits right at the ~44px tap-target
         minimum, and this band is the only way to reach the back face's Next
         as well. Keep in step with .flashcard-flip-zone-back. */
      height: 60px;
    }
    /* Back: a normal flex item at the end of the column (same as any other
       piece of back-face content), so .flashcard-back-scroll (flex:1) simply
       shrinks to leave it room — no manual padding to keep in sync. width +
       margin:auto (not padding) actually shrinks the box to match the front
       zone's real left:24/right:24 bounds — padding alone would leave the
       clickable box full-width and overlapping .flashcard-tap-nav's side
       columns underneath it. */
    .flashcard-flip-zone-back {
      flex-shrink: 0;
      width: calc(100% - 48px);
      margin: 0 auto;
      /* Matches the front's 60px — see the shared comment above. If these two
         drift, "See answer" and "Next" land at different heights and the card
         jumps when it flips. */
      height: 60px;
    }

    /* ── Empty states, one rule for three screens (12 Sep 2026) ─────
       Read, the vocabulary list and the flashcard deck all show the same
       thing: a muted icon, a short title, a reason. They had drifted into
       three gaps (12/10/12), two icon opacities (.3/.35) and three icon
       sizes (48x40, 48x48, 40x40), so the same message sat at a different
       height on each screen.

       Written as one selector list rather than a new shared class, so no
       markup changes anywhere: the three screens keep the class names their
       HTML and JS already use. ADD A FOURTH EMPTY STATE TO THESE SELECTORS
       rather than writing its own copy.

       Centred, not top-anchored. The three content areas start at different
       heights — the list sits under a 96px filter bar that card view hides,
       and Read has no bar at all — so no single offset from the top can put
       them in the same place. Centring makes each one sit in the middle of
       whatever space it has, which is what reads as consistent when you see
       one screen at a time, and it survives a longer header or a short phone.

       Icon box fixed to 40px tall with width:auto, so each glyph keeps its own
       aspect ratio while every title lands on the same line. Read's is 48x40
       and unchanged; the list's drops from 48. */
    .read-empty-body,
    .vocab-empty,
    .flashcard-empty {
      flex: 1;
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      gap: 12px;
      text-align: center;
      color: var(--text-3);
      font-family: var(--font-ui);
      font-size: 14px;
    }
    .read-empty-body svg,
    .vocab-empty svg,
    .flashcard-empty svg {
      height: 40px;
      width: auto;
      opacity: .3;
      flex-shrink: 0;
    }
    .read-empty-title,
    .vocab-empty-title,
    .flashcard-empty .fce-title {
      font-family: var(--font-ui);
      font-size: 15px;
      font-weight: 500;
      color: var(--text-2);
    }
    .read-empty-sub,
    .vocab-empty-sub,
    .flashcard-empty .fce-sub {
      font-family: var(--font-ui);
      font-size: 13px;
      line-height: 1.5;
      max-width: 240px;
      color: var(--text-3);
    }


    /* ── End-of-pass card (5 Sep 2026) ──────────────────────────────
       Shown in the card stage instead of wrapping silently from the last
       word back to the first. Deliberately card-shaped — same --white-soft
       surface, radius and shadow as .flashcard-front — so it reads as the
       deck ending rather than as an error or an empty state. No gold top
       border: that belongs to a real word card. */
    /* Layout only. Everything visual — surface, gradient band, border, 22px
       radius, shadow, overflow, absolute inset:0 — comes from .flashcard-face,
       which this element also carries, and the 318/525 sizing from its
       .flashcard-deck parent. Nothing here duplicates a card measurement, so
       the two cannot drift apart. .flashcard-front's own padding does not
       apply: that class is not used here. */
    .flashcard-done {
      align-items: center;
      justify-content: center;
      text-align: center;
      padding: 28px 24px;
      font-family: var(--font-ui);
    }
    .fc-done-tick {
      width: 46px;
      height: 46px;
      border-radius: 50%;
      border: 2px solid var(--gold);
      color: var(--gold);
      display: flex;
      align-items: center;
      justify-content: center;
    }
    .fc-done-title {
      font-family: var(--font-mark);
      font-weight: 600;
      font-size: 23px;
      color: var(--text);
      margin-top: 16px;
    }
    .fc-done-sub {
      font-size: 13px;
      font-weight: 300;
      color: var(--text-2);
      margin-top: 8px;
      line-height: 1.55;
      max-width: 200px;
    }
    .fc-done-btns {
      margin-top: 26px;
      display: flex;
      flex-direction: column;
      gap: 10px;
      width: 100%;
      max-width: 200px;
    }
    .fc-done-b1, .fc-done-b2 {
      border-radius: 12px;
      padding: 12px;
      font-family: var(--font-ui);
      font-size: 13.5px;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .fc-done-b1 {
      background: var(--accent);
      color: var(--white);
      border: 0;
      font-weight: 500;
    }
    .fc-done-b2 {
      background: none;
      color: var(--text-2);
      border: 1px solid var(--border);
    }
    .fc-done-b1:active, .fc-done-b2:active { opacity: .65; }
    /* The check-in ending's quiet exit (13 Sep 2026). A link, not a third
       .fc-done-b* — that card already offers two real choices, and a third
       boxed button would read as a third thing to weigh rather than the way
       out. Sits inside .fc-done-btns, so the column's 10px gap already
       separates it; the small margin is the extra breathing room that keeps it
       from reading as part of the button stack.
       The FILTER deck does not use this: there the exit is .fc-done-b1. */
    .fc-done-link {
      margin-top: 4px;
      background: none;
      border: 0;
      padding: 4px;
      font-family: var(--font-ui);
      font-size: 12.5px;
      color: var(--accent);
      text-decoration: underline;
      text-underline-offset: 3px;
      text-decoration-thickness: 1px;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .fc-done-link:active { opacity: .65; }


    /* ── Add-word button in vocab header ────────────────────────── */
    /* Icon-only, round — matches .vocab-add-btn right next
       to it so the two read as a consistent pair. */
    .vocab-add-btn {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 30px;
      height: 30px;
      padding: 0;
      background: var(--accent-wash);
      border: 1px solid transparent;
      border-radius: var(--r-pill);
      color: var(--accent);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .15s, color .15s, border-color .15s;
    }
    .vocab-add-btn:active { background: var(--accent-wash-press); }

    /* Reset — text pill in the filter row (back here 22 Aug 2026, after a
       same-day detour as a header icon — see DECISIONS.md). */
    /* Icon-only, quiet (22 Aug 2026, revised) — dropped both the "Reset"
       label AND the Chrome-tier filled-circle look (tried first, rejected:
       "don't like the icon style there"). Matches the quiet, borderless
       dropdown triggers (.vocab-dropdowns .quiz-time-dropdown-btn) sitting
       right next to it in the same row instead — no box at rest, a plain
       --text-2 glyph, --surface press flash reserved via border-radius.
       Disabled dims via opacity, not a colour swap (kept from the header
       detour — a colour/fill swap read as a plain white circle there). */
    .vocab-top-btn {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 26px;
      height: 26px;
      padding: 0;
      background: none;
      border: none;
      border-radius: var(--r-pill);
      color: var(--text-2);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .15s, color .15s;
    }
    .vocab-top-btn:active:not(:disabled) { background: var(--surface); }
    .vocab-top-btn:disabled {
      opacity: .35;
      cursor: default;
    }

    /* Expand-all toggle (22 Aug 2026) — same Chrome-tier shape as its
       neighbours; list-view only, same as Reset. */
    .vocab-expand-all-btn {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 30px;
      height: 30px;
      padding: 0;
      background: var(--accent-wash);
      border: 1px solid transparent;
      border-radius: var(--r-pill);
      color: var(--accent);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .15s, color .15s, border-color .15s;
    }
    .vocab-expand-all-btn:active { background: var(--accent-wash-press); }
    #vocabScreen.cards-active .vocab-expand-all-btn { display: none; }
    /* Either/or glyph swap (22 Aug 2026, revised — was a fill-based .active,
       rejected) — same family as .vocab-view-btn's cards/list icons: shows
       what tapping DOES, never a fill. Open eye at rest ("tap to reveal
       all"); closed eye once expanded ("tap to close them back up"),
       toggled via .expanded on the button in toggleVocabExpandAll(). */
    .vocab-expand-all-ico { display: none; }
    .vocab-expand-all-btn .ico-eye-open { display: block; }
    .vocab-expand-all-btn.expanded .ico-eye-open { display: none; }
    .vocab-expand-all-btn.expanded .ico-eye-closed { display: block; }

    /* ════════════════════════════════════════════════════════════════
       ADD WORD MODAL  (Phase 4 — manual entry)
    ════════════════════════════════════════════════════════════════ */
    .add-word-overlay {
      display: none;
      position: absolute;
      inset: 0;
      background: rgba(0,0,0,.45);
      z-index: 80;
      align-items: flex-end;
    }
    .add-word-overlay.open { display: flex; }

    .add-word-sheet {
      width: 100%;
      background: var(--white-soft);
      border-radius: var(--r-lg) var(--r-lg) 0 0;
      /* calc, not a fallback: env() reports 0px (not the fallback) when there
         is no inset, so this sheet sat flush on the screen edge in a browser
         tab. See .safe-top for the full explanation. */
      padding: 0 0 calc(env(safe-area-inset-bottom, 0px) + 16px);
      max-height: 90dvh;
      overflow-y: auto;
      -webkit-overflow-scrolling: touch;
      transform: translateY(100%);
      transition: transform .28s cubic-bezier(.22,1,.36,1);
    }
    .add-word-overlay.open .add-word-sheet { transform: translateY(0); }

    .add-word-handle {
      width: 36px; height: 4px;
      background: var(--border);
      border-radius: 99px;
      margin: 12px auto 0;
    }
    .add-word-sheet-header {
      display: flex;
      align-items: center;
      padding: 14px 20px 12px;
      border-bottom: 1px solid var(--border);
    }
    .add-word-sheet-close {
      margin-left: auto;
      min-width: 44px; min-height: 44px;
      border: none; background: none;
      font-size: 18px; line-height: 1;
      color: var(--text-3);
      cursor: pointer;
      border-radius: var(--r-pill);
      -webkit-tap-highlight-color: transparent;
    }
    .add-word-sheet-close:active { background: var(--bg); }
    /* ── The popup title (one rule, every popup) ──────────────────────
       Two-tone: plain first word, accent italic second. Used by the Read-from-
       Link and Add Word sheets, the stories sheet, the quiz start dialog and
       the stories chooser. It was duplicated per popup and had drifted — the
       chooser sat at 18px with no tracking and no accent, which is what made
       it read as a different app (27 Aug 2026). Change it here, once.
       The .add-word-sheet-title name is historical; it is the app's popup
       title, not an add-word one. */
    .add-word-sheet-title,
    .stories-sheet-title,
    .stories-chooser-title {
      font-family: var(--font-mark);
      font-size: 19px;
      font-weight: 300;
      letter-spacing: 0.03em;
      color: var(--text);
    }
    .add-word-sheet-title span,
    .stories-sheet-title span,
    .stories-chooser-title span { color: var(--accent); font-style: italic; }

    /* Bottom 6px is the last layer before #addWordActionRow's own 6px margin
       and the sheet's 16px safe-area inset stack up below the action row —
       see #addWordActionRow's comment. */
    .add-word-body { padding: 18px 20px 6px; }

    .add-word-label {
      font-family: var(--font-ui);
      font-size: 11px;
      font-weight: 600;
      letter-spacing: 0.14em;
      text-transform: uppercase;
      color: var(--text-3);
      margin-bottom: 7px;
    }
    .add-word-row {
      display: flex;
      gap: 8px;
      margin-bottom: 10px;
    }
    /* Wraps just #linkInput so the × sits inside the field without affecting
       .add-word-input elsewhere (the manual-add word/phrase input). */
    .link-input-wrap { position: relative; flex: 1; display: flex; }
    .link-input { padding-right: 34px !important; }
    .link-input-clear {
      display: none;
      position: absolute;
      right: 6px; top: 50%;
      transform: translateY(-50%);
      width: 26px; height: 26px;
      border: none; border-radius: 50%;
      background: var(--bg);
      color: var(--text-3);
      align-items: center; justify-content: center;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .link-input-clear.visible { display: flex; }
    .link-input-clear:active { background: var(--border); }

    .add-word-input {
      flex: 1;
      height: 46px;
      border: 1.5px solid var(--border);
      border-radius: var(--r-sm);
      background: var(--white-soft);
      font-family: var(--font-mark);
      font-size: 18px;
      font-weight: 300;
      color: var(--text);
      padding: 0 13px;
      outline: none;
      transition: border-color .15s;
      -webkit-appearance: none;
    }
    .add-word-input:focus { border-color: var(--accent); }
    .add-word-input::placeholder {
      font-family: var(--font-ui);
      font-size: 14px;
      font-weight: 400;
      color: var(--text-3);
    }
    .add-word-lookup-btn {
      height: 46px;
      padding: 0 16px;
      border: none;
      border-radius: var(--r-btn);
      background: var(--accent);
      color: var(--white);
      font-family: var(--font-ui);
      font-size: 14px;
      font-weight: 400;
      letter-spacing: 0.02em;
      cursor: pointer;
      white-space: nowrap;
      -webkit-tap-highlight-color: transparent;
      transition: opacity .15s;
    }
    .add-word-lookup-btn:active { opacity: .78; }
    .add-word-lookup-btn:disabled { opacity: .45; cursor: default; }
    .add-word-lookup-icon { vertical-align: -2px; margin-right: 5px; }

    /* Status / error message */
    .add-word-tip {
      font-family: var(--font-ui);
      font-size: 11px;
      line-height: 1.5;
      color: var(--text-2);
      margin: 8px 2px 0;
    }
    .add-word-status {
      font-family: var(--font-ui);
      font-size: 13px;
      color: var(--text-3);
      text-align: center;
      padding: 2px 0;
      min-height: 0;
    }
    .add-word-status.error { color: var(--error); }

    /* Meaning picker (manual add) — shown only for a word with 2+ storable
       senses. Deliberately NOT .quiz-segmented: that control is for short,
       equal-width labels, and these rows carry a word-class chip plus a
       full-length gloss ("a light, a candle") that a segment would truncate.
       The exclusive-choice convention is still honoured — one row selected at a
       time, selection shown by border + wash, same accent pair as everywhere
       else. Row fill is --accent-wash (the resting icon-button tint), never
       --accent-light, so it can't be mistaken for a filled/active button. */
    .add-word-senses {
      display: none;
      /* Equal above and below. The picker is a block between the input and the
         preview card, so it needs the same air on both sides — 2px underneath
         left the card looking stuck to the last row. Not put on
         .add-word-preview instead: that card also renders with no picker above
         it, and it would then carry a gap belonging to something absent. */
      margin: 12px 0;
    }
    .add-word-senses.visible { display: block; }
    .add-word-senses-label {
      font-family: var(--font-ui);
      font-size: 11px;
      font-weight: 600;
      letter-spacing: 0.14em;
      text-transform: uppercase;
      color: var(--text-3);
      margin-bottom: 7px;
    }
    .add-word-sense {
      display: flex;
      align-items: center;
      gap: 9px;
      width: 100%;
      text-align: left;
      /* 1.5px to match .add-word-input's border, which sits directly above it —
         a 1px row next to a 1.5px field reads as a rendering slip. */
      border: 1.5px solid var(--border);
      border-radius: var(--r-sm);
      background: var(--white-soft);
      padding: 11px 12px;
      margin-bottom: 6px;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: border-color .15s, background .15s;
    }
    .add-word-sense:last-child  { margin-bottom: 0; }
    .add-word-sense:active      { background: var(--bg); }
    /* Locked = the rows have arrived but the first card is still generating.
       Readable, plainly not yet actionable. Deliberately NOT the 0.5 that
       .popup-save-btn:disabled uses: these rows are the thing the user is meant
       to be reading during the wait, so they stay legible rather than reading as
       switched off. The rows are also `disabled` in markup — this is the visible
       half of a state the DOM already enforces. */
    .add-word-senses.locked .add-word-sense { opacity: .72; cursor: default; }
    .add-word-senses.locked .add-word-sense:active { background: var(--white-soft); }
    .add-word-sense.on          { border-color: var(--accent); background: var(--accent-wash); }
    .add-word-sense-mean {
      font-family: var(--font-ui);
      font-size: 13.5px;
      color: var(--text);
      line-height: 1.35;
    }
    /* Ticked row only. Kept in the layout at all times (opacity, not display) so
       selecting a row can't change its width and shuffle the text beside it. */
    .add-word-sense-check {
      margin-left: auto;
      flex-shrink: 0;
      color: var(--accent);
      opacity: 0;
      transition: opacity .15s;
    }
    .add-word-sense.on .add-word-sense-check { opacity: 1; }

    /* Preview card — matches vocab card styling */
    .add-word-preview {
      display: none;
      background: var(--bg);
      border: 1px solid var(--border);
      border-radius: var(--r-md);
      padding: 16px;
      margin-bottom: 8px;
      /* Card-height lock (Aug 2026). vocab.js's _lockAddWordCardHeight() sets
         min-height via inline style once a card has landed for this word, so
         switching senses or hitting Regenerate can't shrink the sheet down to
         a spinner and back. The transition is what makes growing the lock (a
         taller card following a shorter one) and the eventual reset on a new
         Look up read as a smooth resize rather than a snap. */
      transition: min-height .18s ease;
    }
    .add-word-preview.visible {
      display: flex;
      flex-direction: column;
      /* Centers content shorter than the current lock (a loading spinner, or a
         shorter card than the tallest one seen) instead of pinning it to the
         top with dead space below. */
      justify-content: center;
    }

    /* Notification overlay inside add-word card — mirrors #popupNotif */
    #addWordNotif {
      display: none;
      position: absolute;
      bottom: 0; left: 0; right: 0;
      padding: 10px 14px;
      background: var(--text);
      color: var(--white);
      font-family: var(--font-ui);
      font-size: 13px;
      font-weight: 300;
      line-height: 1.5;
      text-align: center;
    }
    #addWordNotif.show { display: block; }

    #addWordActionRow {
      display: none;
      gap: 8px;
      /* Was 20px — sized to leave room for the standalone remove-link row that
         used to follow. That row is gone (remove is now the icon button inside
         this row), so the only space left below is .add-word-body's own 6px
         bottom padding plus the sheet's 16px safe-area inset — see
         .add-word-body's comment. This margin just adds a small, deliberate
         breathing gap on top of those, not room for another row. */
      margin-bottom: 6px;
      padding: 4px 0 0;
    }
    #addWordActionRow.visible { display: flex; }


    /* ════════════════════════════════════════════════════════════════
       STORY TEXT — shared by every story surface (the generated draft and
       saved stories both render through the stories sheet).
    ════════════════════════════════════════════════════════════════ */



    /* Regenerate button — header right side, hidden until a story exists */

    /* Scrollable story body */

    /* Empty / prompt state */
    .read-empty-body-wrap {
      flex: 1;
      /* Flex column so the shared empty-state rule's `flex: 1` + centring have
         a box to centre in — as a plain block the body had no height to work
         against and sat at the top. */
      display: flex;
      flex-direction: column;
      overflow: hidden;
      padding: 20px 20px 0;
    }
    /* Layout and type come from the shared empty-state rule (~line 3209).
       Only .read-empty-actions is Read's own. */
    /* The app's bottom action bar: one primary button, a full-width rule above
       it, sitting directly on top of the bottom nav. 48px rather than
       .btn-primary's usual 56px, because a bar stacked over the nav has to
       give the screen above it room.
       Read's empty state is the only user again (13 Sep 2026): the Vocab
       screen shared it for part of a day, then its button went back to the
       header because the bar cost 77px on a screen that exists to list words.
       ADD A SCREEN TO THESE SELECTORS rather than writing a second copy — the
       Vocab bar spent a day at 46px/14px and visibly did not match. */
    .read-empty-actions {
      padding: 14px 24px;
      border-top: 1px solid var(--border);
      flex-shrink: 0;
    }
    /* No padding compensation here since 13 Sep 2026. It used to carry 10px to
       match the Vocab screen's own bottom bar line for line; Vocab has no
       bottom bar at all now, so there is nothing left to match. The block
       simply centres in the space it has; the parent's 10px/14px padding
       leaves it 2px high, which is not visible. */
    .read-empty-actions .btn-primary {
      min-height: 48px;
      height: 48px;
    }

    /* Highlighted vocabulary words in Danish story */
    .story-word {
      font-weight: bold;
      color: var(--accent);
    }


    /* Word chips used below the story */
    .story-chip {
      font-family: var(--font-ui);
      font-size: 12px;
      background: var(--accent-light);
      color: var(--accent);
      border-radius: 99px;
      padding: 3px 10px;
      font-weight: 500;
    }

    /* Loading shimmer inside body */
    .story-loading-screen {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      min-height: 100%;
      padding: 40px 36px;
      box-sizing: border-box;
    }
    .story-loading-logo {
      width: 68px;
      height: 68px;
      margin-bottom: 24px;
    }

    /* Bottom action bar */

    /* Secondary "Generate Any" button — outlined variant */

    /* ════════════════════════════════════════════════════════════════
       STORY WORD PICKER  (Phase 5 — select words)
    ════════════════════════════════════════════════════════════════ */




    /* ════════════════════════════════════════════════════════════════
       STORIES SHEET — the only story surface (Story screen removed 23 Aug
       2026). Opened from the third card on the quiz setup screen.
    ════════════════════════════════════════════════════════════════ */

    /* Inline English translation — mirrors read screen's para-translation style */
    .story-en-inline {
      margin-top: 12px;
      padding-left: 12px;
      border-left: 2px solid var(--accent);
    }
    /* Shared with Read screen's paragraph translation (see .para-translation-text
       below) — both mark a saved-vocab word inside an English translation the
       same way: bold + accent blue. Keep unscoped so it applies in both places. */
    .para-translation-text b { color: var(--accent); font-weight: 600; }
    .story-report-row {
      text-align: center;
      margin-top: 14px;
    }
    /* A <button>, not an <a> (26 Aug 2026): it opens the in-app report box,
       and mailto is now only the signed-out fallback inside that box. The
       first three declarations are the button reset — without them a UA
       button border and grey fill appear under a link that should look like
       one. */
    .story-report-link {
      border: none;
      background: none;
      padding: 0;
      font-family: var(--font-ui);
      font-size: 11px;
      color: var(--text-3);
      text-decoration: underline;
      text-underline-offset: 2px;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    /* Full width, because it is the only button in its row — .confirm-cancel-btn
       is otherwise `flex: 1` inside a two-button .confirm-actions row, which
       does nothing outside a flex parent. */
    .story-report-close {
      display: block;
      width: 100%;
      margin-top: 12px;
    }

    /* Stories sheet overlay */
    .stories-sheet-overlay {
      display: none;
      position: absolute;
      inset: 0;
      background: rgba(0,0,0,.45);
      z-index: 80;
      align-items: flex-end;
    }
    .stories-sheet-overlay.open { display: flex; }

    .stories-sheet {
      width: 100%;
      /* Same bottom-up gradient as .shell (this sheet is its own opaque
         surface, so it needs the gradient applied directly rather than
         showing the shell's through it). */
      background: linear-gradient(to bottom, var(--white) 0%, var(--white) 55%, var(--accent-light) 100%);
      border-radius: var(--r-lg) var(--r-lg) 0 0;
      padding-bottom: calc(env(safe-area-inset-bottom, 0px) + 16px);  /* see .safe-top */
      height: 92dvh;
      display: flex;
      flex-direction: column;
      transform: translateY(100%);
      transition: transform .28s cubic-bezier(.22,1,.36,1);
    }
    .stories-sheet-overlay.open .stories-sheet { transform: translateY(0); }

    .stories-sheet-handle {
      width: 36px; height: 4px;
      background: var(--border);
      border-radius: 99px;
      margin: 12px auto 0;
      flex-shrink: 0;
    }
    .stories-sheet-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 12px 20px 12px;
      border-bottom: 1px solid var(--border);
      flex-shrink: 0;
    }
    .stories-sheet-close {
      min-width: 44px; min-height: 44px;
      border: none; background: none;
      font-size: 18px; line-height: 1;
      color: var(--text-3);
      cursor: pointer;
      border-radius: var(--r-pill);
      -webkit-tap-highlight-color: transparent;
    }
    .stories-sheet-close:active { background: var(--bg); }

    /* Scrollable list — cards with gap */
    .stories-list {
      flex: 1;
      overflow-y: auto;
      -webkit-overflow-scrolling: touch;
      display: flex;
      flex-direction: column;
      gap: 8px;
      padding: 12px 16px;
    }

    /* Saved-stories card view — mirrors the flashcard look (deck stack, soft
       shadow, accent→gold top edge), one story per card, no flip. */
    .stories-cards {
      flex: 1;
      min-height: 0;
      display: flex;
      flex-direction: column;
      padding: 12px 16px 16px;
    }
    .stories-card-stage {
      position: relative;
      flex: 1;
      min-height: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 6px 18px;   /* side margins = tap zones to change story */
    }
    /* Tap the left/right margins to go to the previous / next story */
    .stories-tapzone {
      position: absolute;
      top: 0;
      bottom: 0;
      width: 18px;
      border: none;
      background: none;
      padding: 0;
      cursor: pointer;
      z-index: 3;
      -webkit-tap-highlight-color: transparent;
    }
    .stories-tapzone.left { left: 0; }
    .stories-tapzone.right { right: 0; }
    .stories-card-deck {
      position: relative;
      width: 100%;
      max-width: 360px;
      height: 100%;
      animation: flashIn .42s ease both;
    }
    .stories-card {
      position: absolute;
      inset: 0;
      z-index: 2;
      background: var(--white-soft);
      border: 1px solid #ECEEF3;
      border-radius: 22px;
      box-shadow: 0 12px 34px rgba(38,62,86,0.12), 0 2px 8px rgba(38,62,86,0.06);
      display: flex;
      flex-direction: column;
      overflow: hidden;
    }
    .stories-card::before {
      content: '';
      position: absolute;
      top: 0; left: 0; right: 0;
      height: 4px;
      background: linear-gradient(90deg, var(--accent), var(--gold));
      opacity: .9;
      z-index: 1;
    }
    .stories-card-topbar {
      flex-shrink: 0;
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 12px 14px 8px;
    }
    .stories-card-progress {
      font-family: var(--font-ui);
      font-size: 11.5px;
      letter-spacing: 0.12em;
      color: var(--text-3);
    }
    .stories-card-scroll {
      flex: 1;
      min-height: 0;
      overflow-y: auto;
      -webkit-overflow-scrolling: touch;
      padding: 4px 20px 22px;
    }
    .stories-card-titlerow {
      display: flex;
      align-items: center;
      justify-content: flex-start;
      gap: 10px;
      margin-bottom: 12px;
    }
    .stories-card-title {
      font-family: var(--font-mark);
      font-size: 20px;
      font-weight: 400;
      color: var(--text);
    }
    .stories-card-titlerow .stories-play-btn { flex-shrink: 0; }
    /* Card view: no divider lines above the chips (list view keeps them) */
    .stories-card .stories-expanded-chips {
      border-top: none;
      padding-top: 0;
      margin-top: 14px;
    }
    .stories-card-nav {
      flex-shrink: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 24px;
      padding-top: 14px;
    }

    /* Empty state */
    .stories-empty {
      padding: 40px 24px;
      text-align: center;
      font-family: var(--font-ui);
      font-size: 13px;
      color: var(--text-3);
      line-height: 1.6;
    }

    /* Card item */
    .stories-item {
      background: var(--white-soft);
      border: 1px solid #ECEEF3;
      border-radius: var(--r-md);
      overflow: hidden;
      flex-shrink: 0;          /* overflow:hidden zeroes flex min-height; this stops the card being squeezed/clipped */
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      user-select: none;
    }
    /* "This is the story you were just viewing in card view" — set for one
       animation cycle by _revealInStoriesList() when returning to list.
       Mirrors vocab.js's .vocab-card-flash. Targets .stories-item-collapsed
       (not .stories-item) because that inner element holds the row's actual
       background; ends on its normal #F2F7FB tint rather than transparent. */
    .stories-item-flash .stories-item-collapsed {
      animation: storiesItemFlash 1.6s ease-out;
    }
    @keyframes storiesItemFlash {
      0%   { background: var(--accent-light); }
      55%  { background: var(--accent-light); }
      100% { background: #F2F7FB; }
    }

    /* Collapsed row — plain header, flex column. Always the whole story of
       the row now: tapping anywhere on it opens the card view (26 Aug 2026;
       there is no more inline "open" state to color differently). */
    .stories-item-collapsed {
      display: flex;
      flex-direction: column;
      padding: 11px 14px;
      background: #F2F7FB;        /* subtle blue tint — soft identity without the wall-of-blue */
      transition: background .12s;
    }
    .stories-item:active .stories-item-collapsed { background: var(--bg); }
    .stories-item-title-row {
      display: flex;
      /* center, not flex-start: the row now carries a 28px round action
         button (.stories-card-corner) alongside the title, not just text
         and a small chevron. */
      align-items: center;
      justify-content: space-between;
      gap: 8px;
    }
    .stories-item-name {
      font-family: var(--font-mark);
      font-size: 16px;
      font-weight: 600;
      color: var(--text);
      line-height: 1.3;
      flex: 1;
      min-width: 0;
    }
    .stories-item-chips {
      display: flex;
      flex-wrap: wrap;
      gap: 4px;
      margin-top: 6px;
    }
    .stories-collapse-chip {
      font-family: var(--font-ui);
      font-size: 11px;
      font-style: italic;
      color: var(--accent);
      background: rgba(61,111,150,.1);
      border-radius: 99px;
      padding: 2px 7px;
    }
    /* Story audio play button */
    .stories-play-btn {
      display: flex;
      align-items: center;
      justify-content: center;
      width: 30px;
      height: 30px;
      border-radius: var(--r-pill);
      border: 1px solid transparent;
      background: var(--accent-wash);
      color: var(--accent);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .15s, color .15s, border-color .15s;
    }
    .stories-play-btn.playing {
      background: var(--accent-light);
      border-color: var(--accent);
      color: var(--accent);
    }
    .stories-play-btn svg { display: block; }

    /* Story text inside expanded saved story */
    .stories-expanded-text {
      margin-top: 12px;
      font-family: var(--font-body);
      font-size: 15px;
      line-height: 1.75;
      color: var(--text);
    }

    /* Word chips in expanded view (reuses .story-words-used + .story-chip) */
    .stories-expanded-chips {
      display: flex;
      flex-wrap: wrap;
      gap: 6px;
      margin-top: 14px;
      padding-top: 12px;
      border-top: 1px solid var(--border);
    }

    /* ════════════════════════════════════════════════════════════════
       CAMERA OVERLAY
    ════════════════════════════════════════════════════════════════ */
    #cameraOverlay {
      display: none;
      position: absolute;
      inset: 0;
      background: #000;
      flex-direction: column;
      align-items: center;
      justify-content: flex-end;
      z-index: 200;
    }
    #cameraOverlay.active { display: flex; }

    #cameraVideo {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      object-fit: cover;
    }

    /* Viewfinder corner marks */
    .viewfinder {
      position: absolute;
      top: 50%;
      left: 50%;
      transform: translate(-50%, -58%);
      width: min(72vw, 260px);
      aspect-ratio: 3/4;
      pointer-events: none;
    }
    .vf-corner {
      position: absolute;
      width: 22px;
      height: 22px;
      border-color: rgba(255,255,255,.75);
      border-style: solid;
    }
    .vf-tl { top:0; left:0;  border-width: 2px 0 0 2px; }
    .vf-tr { top:0; right:0; border-width: 2px 2px 0 0; }
    .vf-bl { bottom:0; left:0;  border-width: 0 0 2px 2px; }
    .vf-br { bottom:0; right:0; border-width: 0 2px 2px 0; }

    /* Controls bar at the bottom */
    .cam-controls {
      position: relative;
      z-index: 1;
      width: 100%;
      padding: 24px 32px calc(env(safe-area-inset-bottom, 0px) + 24px);
      display: flex;
      align-items: center;
      justify-content: space-between;
      background: linear-gradient(transparent, rgba(0,0,0,.55));
    }

    /* Close button */
    /* Cancel/exit button (bottom-right of camera controls) */
    .cam-cancel {
      width: 44px;
      height: 44px;
      border: none;
      background: rgba(255,255,255,.15);
      border-radius: 50%;
      color: #fff;
      font-size: 20px;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      backdrop-filter: blur(6px);
      -webkit-backdrop-filter: blur(6px);
      -webkit-tap-highlight-color: transparent;
    }

    /* Shutter button */
    .cam-shutter {
      width: 72px;
      height: 72px;
      border-radius: 50%;
      border: 3px solid rgba(255,255,255,.85);
      background: rgba(255,255,255,.18);
      backdrop-filter: blur(6px);
      -webkit-backdrop-filter: blur(6px);
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      transition: transform .1s, background .15s;
    }
    .cam-shutter:active { transform: scale(.91); background: rgba(255,255,255,.35); }
    .cam-shutter-inner {
      width: 52px;
      height: 52px;
      border-radius: 50%;
      background: #fff;
    }

    /* Gallery picker (bottom-left of camera controls) */
    .cam-gallery {
      width: 44px;
      height: 44px;
      border: none;
      background: rgba(255,255,255,.15);
      border-radius: 50%;
      color: #fff;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      -webkit-tap-highlight-color: transparent;
    }
    .cam-gallery svg { width:22px; height:22px; fill:none; stroke:#fff; stroke-width:1.8; stroke-linecap:round; stroke-linejoin:round; }

    /* Zoom level selector (0.6× / 1× / 3×) */
    /* Framing hint — top of the overlay, clear of the viewfinder. Same pill
       language as .cam-zoom so it belongs to the camera, but positioned away
       from the control stack because it is read, not tapped. safe-area keeps
       it below the notch. */
    .cam-hint {
      position: absolute;
      top: calc(env(safe-area-inset-top, 0px) + 18px);
      left: 50%;
      transform: translateX(-50%);
      z-index: 2;
      display: flex;
      align-items: center;
      gap: 7px;
      max-width: calc(100% - 40px);
      padding: 7px 14px;
      background: rgba(0,0,0,.42);
      border-radius: 999px;
      backdrop-filter: blur(6px);
      -webkit-backdrop-filter: blur(6px);
      color: rgba(255,255,255,.92);
      font-family: var(--font-ui);
      /* 13px, matching #toast — the app's other overlay-message surface. The
         zoom pills beside this are 12px, but those are control labels you aim
         at, not a sentence you read. 11.5px was too small to take in at a
         glance while framing a shot. */
      font-size: 13px;
      font-weight: 500;
      line-height: 1.35;
      text-align: center;
      pointer-events: none;
    }
    .cam-hint svg { flex: 0 0 auto; }

    .cam-zoom {
      position: relative;
      z-index: 1;
      display: flex;
      gap: 6px;
      padding: 5px;
      margin-bottom: 14px;
      background: rgba(0,0,0,.4);
      border-radius: 999px;
      backdrop-filter: blur(6px);
      -webkit-backdrop-filter: blur(6px);
    }
    .cam-zoom-btn {
      min-width: 42px;
      padding: 6px 10px;
      border: none;
      border-radius: 999px;
      background: transparent;
      color: rgba(255,255,255,.82);
      font-family: var(--font-ui);
      font-size: 12px;
      font-weight: 600;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .12s, color .12s;
    }
    .cam-zoom-btn.active { background: rgba(255,255,255,.92); color: #111; }

    /* Hidden canvas for frame capture */
    #cameraCanvas { display: none; }

    /* ════════════════════════════════════════════════════════════════
       TEXT SCREEN  (Phase 2)
    ════════════════════════════════════════════════════════════════ */
    #textScreen {
      display: none;
      flex-direction: column;
      flex: 1;
      overflow: hidden;
      /* No explicit background (was var(--white), which fully covered the
         .shell gradient) — transparent, same as the other main screens, so
         the shell's bottom-up gradient shows through here too. */
    }

    /* Top bar */
    .text-header {
      display: flex;
      align-items: center;
      /* flex-end now the title is gone, so the buttons float top-right like
         the other screens rather than sitting alone on the left. */
      justify-content: flex-end;
      gap: 10px;
      /* Top reduced to 5px (30 Jul 2026); bottom takes the difference (11px)
         so the row stays the fixed 46px height: 5 + 30px button + 11 = 46. */
      padding: 5px 20px 11px;
      height: 46px;          /* collapsed with the title; see .vocab-header */
      /* Blue wash removed — banded across the wallpaper. */
      flex-shrink: 0;
    }
    /* Small content-type label, left side of the Read/Story header rows (31 Jul
       2026). Distinct from the screen headings removed in July 2026
       (.text-header-title / .story-title, since deleted) — those named the screen; this names what's currently loaded,
       and only appears once real content exists (a scan is loaded / a story
       has just been generated), never on the empty or setup states. Kept
       deliberately quiet (small, --text-2) so it doesn't reintroduce the
       heading weight that was removed elsewhere. */
    .reading-screen-label {
      font-family: var(--font-ui);
      font-size: 11px;
      font-weight: 500;
      letter-spacing: 0.01em;
      color: var(--text-2);
      white-space: nowrap;
    }
    /* Right-aligned action pills (AI Summary + Scan again) */
    .text-header-actions {
      margin-left: auto;
      display: flex;
      align-items: center;
      /* 14px, matching the vocab header's icon row. Was 7px — too tight once
         the buttons' ::after tap areas expanded 6px per side; see "Header
         icon tap targets". Keep both rows on 14px. */
      gap: 14px;
      flex-wrap: nowrap;
      justify-content: flex-end;
    }
    /* Icon-only, 30x30 circle (30 Jul 2026) — matches .vocab-view-btn /
       .vocab-add-btn. Was a text pill ("Again"); shared
       by #textScanAgainBtn (Read) AND #storySavedBtn (Story, "Saved" text
       also dropped) since both use this class. */
    .text-scan-again {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 30px;
      height: 30px;
      padding: 0;
      background: var(--accent-wash);
      border: 1px solid transparent;
      border-radius: var(--r-pill);
      color: var(--accent);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      /* Matches the other four header circles; `color` was missing here. */
      transition: background .15s, color .15s, border-color .15s;
    }
    .text-scan-again:active { background: var(--accent-wash-press); }
    /* AI Summary toggle — mirrors .story-toggle-btn. Icon-only, 30x30 circle
       (30 Jul 2026) — the "EN" text is gone; the sparkle icon plus the
       .active colour swap (below) now carry the state on their own. */
    .text-summary-btn {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      width: 30px;
      height: 30px;
      padding: 0;
      background: var(--accent-wash);
      border: 1px solid transparent;
      border-radius: var(--r-pill);
      color: var(--accent);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .15s, color .15s, border-color .15s;
    }
    .text-summary-btn.active {
      background: var(--accent-light);
      border-color: var(--accent);
      color: var(--accent);
    }
    /* Was the only header icon with no press feedback — it felt dead on tap
       while the summary fetched. See "Header icon states" below. */
    .text-summary-btn:active { background: var(--accent-wash-press); }

    /* ── Header icon states — one vocabulary, three families (Aug 2026) ─
       The six header circles had drifted into expressing "this is currently
       on" three different ways, and two of the rules did nothing at all.
       The unified rule:

       1. ON / OFF TOGGLE — "this control is changing what you see right now"
          → --accent-light fill + --accent border (+ --accent text).
          Members: .text-summary-btn.active (summary showing).
                   (.vocab-filter-toggle.open was the other member until the
                   vocab filter toggle was deleted in Aug 2026.)

       2. EITHER / OR SWITCH — two peer views, neither is "on"
          → swap which glyph renders. NEVER a fill.
          Members: .vocab-view-btn / #storiesViewBtn (.cards-mode),
                   .vocab-expand-all-btn (.expanded — open/closed eye).
          **Do not "fix" these into family 1.** A fill would assert that
          cards mode is active and list mode is not, but they are equals —
          the icon names the view you would switch TO, which is the whole
          signal. Adding a fill would make it ambiguous which view you're in.
          `.vocab-expand-all-btn` tried a fill first and reverted the same
          day for the same reason.

       3. ONE-SHOT ACTION — fires and returns, nothing stays lit
          → --accent-wash-press on :active only.
          Members: .text-scan-again (scan again / saved stories),
                   .vocab-add-btn.

       Families 1 and 2 also get family 3's press wash; the press state is
       universal, the persistent state is what differs.

       The border is why all six carry `border: 1px solid transparent` at
       rest — family 1 sets border-color, so a missing border would resize
       the button by 2px the moment it turned on. */

    /* ── Header icon tap targets (Aug 2026) ───────────────────────────
       The six 30px header circles are well under the ~44px touch-target
       guidance, which made them easy to thumb-miss. Rather than grow the
       BUTTON — which would push the five 46px header rows out of step,
       invalidate every hand-tuned badge offset (.story-saved-count) and
       force all eight glyphs to be re-normalised —
       the visible circle stays exactly 30px and an invisible ::after
       extends only the CLICKABLE area to ~42 x 43px.

       The insets are asymmetric on purpose and are NOT free numbers: the
       header is 46px with 5px above the button and 11px below, so -5px top
       / -8px bottom is the most vertical room available without spilling
       past the row edge into #storyScreen's overflow:hidden (which already
       clips .story-saved-count at -4px). Horizontal -6px pairs with the
       14px flex gaps between icons to leave a 2px dead strip between
       neighbours — a tap landing between two icons should miss, not fire
       the wrong action (these headers put delete next to add).

       Change the header padding or the icon gaps and these must be
       recomputed, not guessed.

       (The select-mode cancel button was also in this list until Aug 2026,
       when multi-select was removed.) */
    .vocab-view-btn,
    .vocab-add-btn,
    .vocab-expand-all-btn,
    .text-scan-again,
    .text-summary-btn {
      position: relative;
    }
    .vocab-view-btn::after,
    .vocab-add-btn::after,
    .vocab-expand-all-btn::after,
    .text-scan-again::after,
    .text-summary-btn::after {
      content: '';
      position: absolute;
      top: -5px;
      bottom: -8px;
      left: -6px;
      right: -6px;
    }

    /* Reading content wrapper — sizes the window, no longer the scroll
       container (5 Aug 2026, was 31 Jul 2026 - Aug 2026). .reading-card
       now fills this wrapper's height exactly (see .reading-card's
       height:100%) and scrolls its own overflow internally, so the card
       is a fixed book-reader viewport instead of growing past the window
       and letting the whole screen scroll around it. flex-direction:column
       is declared here (not just left to the JS-set inline `display:flex`
       in scan.js) so .reading-card stretches to the full available width —
       a row-direction flex container would instead shrink it to its
       content's width. */
    .text-content-wrap {
      flex: 1;
      flex-direction: column;
      overflow: hidden;
      min-height: 0;
      padding: 10px 16px calc(16px + env(safe-area-inset-bottom, 0px));
    }

    /* AI summary (31 Jul 2026) — a normal-flow sibling now, NOT an absolute
       overlay. It used to be position:absolute;inset:0, which sized it to
       whatever height the Danish text happened to be — a shorter summary
       left a card full of blank space below it. Being in normal flow, the
       card (which has no explicit height) just sizes to whatever's actually
       visible. No padding of its own — .reading-card already supplies it,
       same as .text-body. The rule below hides the Danish text + footer
       while this is showing (they're normal siblings after it in the DOM). */
    .read-summary-overlay { display: none; }
    .read-summary-overlay.visible { display: block; }
    .read-summary-overlay.visible ~ .text-body,
    .read-summary-overlay.visible ~ .text-meta {
      display: none;
    }
    /* Shared heading typography — .text-heading (extracted text) and the AI
       summary's section titles must always render identically (31 Jul 2026,
       explicit user rule, after repeatedly drifting apart on weight/color/
       letter-spacing across several edits). One rule, not a comment telling
       two separate rules to match — enforced, not merely documented. Only
       spacing (margin) differs between the two contexts and stays split out
       below. If either side ever needs a genuinely different look, split
       this rule deliberately — don't quietly let one drift.
       Switched to Jost + accent blue (31 Jul 2026, deliberate reversal of
       the earlier "blue reserved for chrome only" call — see DECISIONS.md).
       font-family now var(--font-ui), not var(--font-body), so the heading
       reads as a distinct sans-serif label against the serif body text
       instead of competing on weight alone within the same typeface. */
    .text-heading,
    .read-summary-label,
    .read-summary-points-label {
      font-family: var(--font-ui);
      font-size: 16px;
      font-weight: 500;
      color: var(--accent);
      letter-spacing: 0.02em;
      word-break: break-word;
    }
    .read-summary-label,
    .read-summary-points-label {
      margin-top: 8px;
      margin-bottom: 8px;
    }
    .read-summary-points-label { margin-top: 24px; }
    /* Body typography shared with .text-para (31 Jul 2026, same enforced-not-
       documented rule as the headings above) — only margin-bottom differs:
       the summary is a single flowing block (0), extracted text separates
       multiple paragraphs (28px, see .text-para below). */
    .read-summary-text { margin-bottom: 0; }
    /* Key points match the extracted-text main bullets */
    .read-summary-points { list-style: none; padding: 0; margin: 0; }
    /* Typography shared with .text-para / .text-bullet-list above — only
       the position/padding/margin here are list-specific. */
    .read-summary-points li {
      position: relative;
      padding-left: 16px;
      margin-bottom: 8px;
    }
    .read-summary-points li:last-child { margin-bottom: 0; }
    .read-summary-points li::before {
      content: '';
      position: absolute;
      left: 0;
      top: 10px;
      width: 5px;
      height: 5px;
      border-radius: 50%;
      /* Neutral, matched to .bullet-dot (31 Jul 2026) — was var(--accent),
         a stray mismatch left over from the extracted-text bullet color
         cleanup. */
      background: var(--text-3);
    }
    /* Simple text loader (no fox) + error state.
       min-height:100% removed (31 Jul 2026) — dead since the summary became
       a normal-flow element (see .read-summary-overlay): its parent
       (.reading-card) has no definite height for a percentage to resolve
       against, so it was a no-op. Fixed padding gives the loader breathing
       room instead. */
    .read-summary-loading {
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 40px 0;
      font-family: var(--font-mark);
      font-size: 22px;
      font-weight: 300;
      color: var(--text-2);
      animation: readSummaryPulse 1.4s ease-in-out infinite;
    }
    @keyframes readSummaryPulse { 0%,100% { opacity: .45; } 50% { opacity: 1; } }
    .read-summary-error {
      font-family: var(--font-ui);
      font-size: 13px;
      color: var(--error);
      padding: 8px 0;
    }

    /* Reading area — not its own scroll box; it just flows inside
       .reading-card-scroll, which is (see that rule). Padding lives on
       .reading-card-scroll (31 Jul 2026, moved there 5 Aug 2026 with the
       rest of the scroll layer) so Read and Story share one inner-spacing
       definition. */
    .text-body {
      padding: 0;
    }

    /* Shared "card on top of wallpaper" surface for the Read screen and the
       generated-story block (31 Jul 2026). Plain solid background (not a
       translucent veil like .shell-bg::after) so body text always reads
       against flat white, regardless of the wallpaper underneath.
       overflow:hidden clips children to the radius — this element itself
       never scrolls; see .reading-card-scroll below for why.

       Fixed to the window (5 Aug 2026). height:100% resolves against
       .text-content-wrap / .story-body's content box — both are flex:1
       inside a column whose total height traces back to .shell's height,
       so this is viewport-relative on every device, not a fixed px guess. */
    .reading-card {
      position: relative;
      background: var(--white-soft);
      border-radius: 18px;
      box-shadow: 0 4px 20px rgba(20, 30, 40, 0.10);
      overflow: hidden;
      height: 100%;
      /* Root-cause fix (31 Jul 2026): a flex item with overflow != visible
         gets an automatic minimum size of 0 (CSS Flexbox spec 4.5), which
         shrank this to fit the screen and clipped the rest of the text
         before height:100% existed to give it an explicit size. Kept as
         a second guard now that height is fixed rather than content-based. */
      flex-shrink: 0;
    }

    /* Scroll layer, split out from .reading-card itself (5 Aug 2026). A
       native scrollbar on an element with border-radius is NOT clipped to
       that radius by the browser — it's drawn as a plain rectangle, so it
       visibly squared off the card's rounded top/bottom-right corners.
       The fix is the standard one: the OUTER element (.reading-card) clips
       to the radius via overflow:hidden and never scrolls; this INNER
       element does the actual overflow-y:auto scrolling, sized to fill
       it exactly (height:100%) so its square-cornered scrollbar sits
       inside the outer clip and never crosses the rounded edge.
       Padding lives HERE, not on .text-body / .story-text (unchanged
       from before the split — Read's extracted text and Story's generated
       text still get identical inner spacing from the card border). */
    .reading-card-scroll {
      height: 100%;
      overflow-y: auto;
      -webkit-overflow-scrolling: touch;
      padding: 12px 24px 40px;
      /* Explicit (not relying on the browser default) so a block appended
         below the fold during streaming OCR can't silently yank the scroll
         position — the browser keeps whatever's on screen anchored while
         new content grows the scrollable area beneath it. */
      overflow-anchor: auto;
    }

    /* Shared text block base */
    .text-para,
    .text-bullet-list,
    .read-summary-text,
    .read-summary-points li {
      font-family: var(--font-body);
      font-size: 16px;
      line-height: 1.75;
      color: var(--text);
      word-break: break-word;
    }

    /* Dictionary headword line — the headword and its inflection list, split
       off the example sentence by _splitDictEntry() in read.js. Muted and a
       size down so the example sentence is what the eye lands on, matching how
       the book prints it. Only ever rendered for a scanned dictionary entry;
       an ordinary paragraph cannot reach this class. */
    .text-dict-head {
      font-size: 14px;
      color: var(--text-2);
      line-height: 1.6;
    }

    /* Paragraph block */
    .text-para {
      margin-bottom: 28px;        /* clear visual gap between paragraphs */
    }
    .text-para:last-child { margin-bottom: 0; }

    /* First-word bold removed (31 Jul 2026) — decorative only, no
       functional meaning, and competed with .word-saved gold as a second
       unrelated emphasis signal. Paragraph gap (28px) already marks the
       start of a new paragraph. */

    /* Inline paragraph-translation icon (after last word) */
    .para-translate-btn {
      display: inline-flex;
      align-items: center;
      justify-content: center;
      vertical-align: middle;
      width: 24px;
      height: 24px;
      margin-left: 2px;
      padding: 0;
      border: none;
      border-radius: 6px;
      background: none;
      color: var(--text-3);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .15s, color .15s;
    }
    .para-translate-btn:active { background: var(--accent-light); }
    .para-translate-btn.active { color: var(--accent); background: var(--accent-light); }

    /* Inline English translation beneath the paragraph */
    .para-translation { display: none; }
    .para-translation.open {
      display: block;
      margin-top: 12px;
      padding-left: 12px;
      border-left: 2px solid var(--accent);
    }
    .para-translation-text {
      font-family: var(--font-ui);
      font-size: 14px;
      line-height: 1.6;
      color: var(--text-2);
    }
    .para-translation-loading {
      font-family: var(--font-ui);
      font-size: 13px;
      color: var(--text-3);
      animation: readSummaryPulse 1.4s ease-in-out infinite;
    }
    .para-translation-error {
      font-family: var(--font-ui);
      font-size: 13px;
      color: var(--error);
    }

    /* Common phrases surfaced alongside a paragraph translation (see
       fetchParaTranslation / _renderParaPhrases in read.js). Each row has its
       own inline Save/Remove button — see _toggleParaPhraseSave. */
    .para-phrases {
      margin-top: 10px;
      padding-top: 10px;
      border-top: 1px solid var(--border);
    }
    .para-phrases-label {
      font-family: var(--font-ui);
      font-size: 11px;
      color: var(--text-3);
      text-transform: uppercase;
      letter-spacing: .04em;
      margin-bottom: 6px;
    }
    .para-phrase-item {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 8px;
      width: 100%;
      padding: 5px 0;
    }
    .para-phrase-text {
      display: flex;
      align-items: baseline;
      flex-wrap: wrap;
      gap: 6px;
      min-width: 0;
    }
    .para-phrase-da {
      font-family: var(--font-ui);
      font-size: 13px;
      font-weight: 600;
      color: var(--accent);
    }
    .para-phrase-en {
      font-family: var(--font-ui);
      font-size: 13px;
      color: var(--text-3);
    }
    /* Same Save/Remove color convention as .popup-save-btn: solid accent for
       the positive action, light-blue accent-light for remove — muted and
       non-alarming rather than a loud red. See DESIGN.md's CSS class reference. */
    .para-phrase-save-btn {
      flex-shrink: 0;
      padding: 4px 10px;
      border: none;
      border-radius: 12px;
      background: var(--accent);
      font-family: var(--font-ui);
      font-size: 12px;
      font-weight: 600;
      color: var(--white);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: opacity .15s;
    }
    .para-phrase-save-btn.remove {
      background: var(--accent-light);
      color: var(--accent);
    }
    .para-phrase-save-btn:active { opacity: .82; }

    /* Bullet list wrapper */
    .text-bullet-list {
      margin-bottom: 28px;
      display: flex;
      flex-direction: column;
      gap: 8px;
    }
    .text-bullet-list:last-child { margin-bottom: 0; }

    /* Individual bullet row. align-items was `baseline` (31 Jul 2026 fix):
       for a block element with no text, the browser's baseline is its own
       bottom margin edge, so the dot pinned near the text baseline instead
       of centering in the line — it read as sitting low. flex-start +
       an explicit centered margin-top on the dot fixes this properly. */
    .text-bullet-item {
      display: flex;
      align-items: flex-start;
      gap: 11px;
    }
    .bullet-dot {
      flex-shrink: 0;
      width: 5px;
      height: 5px;
      border-radius: 50%;
      /* Neutral, not --accent (31 Jul 2026) — a bullet dot is structural
         furniture, not a point of interest; blue is reserved for
         interactive chrome so it doesn't compete with .word-saved gold
         inside the reading text. */
      background: var(--text-3);
      /* Centre a 5px dot in the 28px line box (16px font-size × 1.75
         line-height): (28 - 5) / 2 = 11.5, rounded to 12px. */
      margin-top: 12px;
    }
    .bullet-text {
      flex: 1;
    }
    /* Sub-bullet: indented under the parent text and marked with a quiet dash,
       so the two levels read as a clean hierarchy rather than two stray dots. */
    .text-subbullet-item {
      padding-left: 24px;
      margin-top: -3px;     /* group tightly beneath its parent bullet */
    }
    .subbullet-dot {
      width: 8px;
      height: 1.5px;
      border-radius: 1px;
      background: var(--text-3);
      /* Same 28px line box as .bullet-dot, centred for a 1.5px dash:
         (28 - 1.5) / 2 = 13.25, rounded to 13px. */
      margin-top: 13px;
    }

    /* Heading spacing only — shared typography (font/size/weight/color/
       letter-spacing) lives in the merged rule above, with
       .read-summary-label/.read-summary-points-label. Don't redeclare it
       here; that's the duplication that caused this session's drift. */
    .text-heading {
      margin-bottom: 8px;             /* tight gap before the paragraph it introduces */
      margin-top: 8px;
    }
    /* When a heading follows a paragraph, give it breathing room above */
    .text-para + .text-heading,
    .text-bullet-list + .text-heading {
      margin-top: 24px;
    }

    /* Progressive-render entrance (Aug 2026). Applied by read.js's
       _renderBlocks() to each top-level block (.text-heading / .text-para /
       .text-bullet-list) at the moment it's appended, whether that's a normal
       full render or a block streamed in during OCR. Opacity-only fade — an
       earlier version also slid the block up 8px on entry; dropped after
       user feedback that the vertical shift read as distracting rather than
       "folding in." Fade alone still reads as arriving gently.
       animation-delay is set inline per block (see read.js) so a burst of
       blocks arriving close together in the same stream chunk cascades in
       one after another instead of all snapping in on the same frame. */
    @keyframes textBlockEnter {
      from { opacity: 0; }
      to   { opacity: 1; }
    }
    .text-block-enter {
      animation: textBlockEnter .32s ease-out both;
    }
    @media (prefers-reduced-motion: reduce) {
      .text-block-enter { animation: none; }
    }

    /* ── Tappable word spans ─────────────────────────────────────── */
    .word-tap {
      display: inline;
      cursor: pointer;
      border-radius: 3px;
      /* Invisible vertical padding enlarges the tap area toward 44pt */
      padding: 3px 0;
      transition: background .1s;
      -webkit-tap-highlight-color: transparent;
    }
    .word-tap:active { background: var(--accent-light); }

    /* Saved-word highlight — warm tint of the brand gold (--gold), not lemon */
    .word-saved {
      background: var(--gold-tint);
      border-radius: 3px;
      padding: 1px 2px;
    }
    .word-saved:active { background: var(--gold-press); }

    /* Active-lookup highlight — the word currently open in the translation popup.
       --accent-light (light blue) signals "this is what you're looking up right now",
       separate from .word-saved's gold ("this is in your vocabulary"). Compound
       selector (2 classes) so it reliably outranks .word-saved's specificity when a
       saved word is tapped, rather than depending on sheet order. Applied/cleared in
       onWordTap / hidePopup (read.js) — not persisted, cleared the moment the popup closes. */
    .word-tap.word-active {
      background: var(--accent-light);
      border-radius: 3px;
      padding: 1px 2px;
    }

    /* ── Hint pill (SHARED: Read and Home) ───────────────────────────
       One definition, two screens: the Read tap hint (below the hinted word,
       caret up) and the Home scan hint (above the Scan button, caret down,
       .hint-pill-down). Deliberately not two near-identical blocks — they
       would drift apart the first time one colour changed. Only the caret
       edge and the anchoring differ.
       One word pulses in the active-lookup colour with a small label under
       it, once per device, then never again (read.js, LS_TAP_HINT). Both are
       removed on a timer rather than 'animationend': under reduced motion the
       animation never runs and an animationend-only cleanup would strand the
       label on screen. The label is a sibling of the word span, never a child
       — see the note in _maybeShowTapHint(). */
    @keyframes tap-hint-pulse {
      0%, 5%    { background: transparent; }
      10%, 24%  { background: var(--accent-light); }
      32%       { background: transparent; }
      38%, 52%  { background: var(--accent-light); }
      60%       { background: transparent; }
      66%, 80%  { background: var(--accent-light); }
      90%, 100% { background: transparent; }
    }
    .word-tap.word-hint {
      /* Matches HINT_SHOW_MS / the pill keyframes — one showing, one length.
         At 7s the highlight outlived the pill it belonged to. */
      animation: tap-hint-pulse 5s ease-in-out 1;
      border-radius: 3px;
      padding: 1px 2px;
    }
    /* Fade IN only, and short. The label has no lifetime of its own any more
       (it is dismissed by tapping a word, not by a timer), so it must not run
       a fade-out — an out-and-back cycle on the label while the word pulses on
       its own 7 s rhythm read as two things blinking out of step. `both` holds
       the end state so it simply stays put once it has arrived. */
    /* Appear, flash, leave — one keyframe over 5s rather than a fade-in plus a
       separate exit. The flashes are what make a small pill register on a busy
       screen. Ending at opacity 0 means the element is already invisible when
       the matching timeout in makeHintPill() removes it, so there is no snap.
       Keep this duration and that timeout in step.
       This governs ONE showing. How many days hints appear at all is a
       separate question, answered by hintActive() in core.js. */
    @keyframes hint-pill-in {
      0%   { opacity: 0;   transform: translate(-50%, -4px); }
      7%   { opacity: 1;   transform: translate(-50%, 0); }
      33%  { opacity: .45; }
      59%  { opacity: 1; }
      85%  { opacity: .45; }
      100% { opacity: 0;   transform: translate(-50%, -4px); }
    }

    /* Read's pills PERSIST. Same entrance and the same slow pulse, but it ends
       at full opacity instead of fading away: a page of Danish is the one
       screen a reader settles into and looks away from, so a hint that removes
       itself after five seconds is the easiest of all of them to miss. These
       come down when the user taps a word, when the next scan replaces the
       page, or on the ×. makeHintPill skips its removal timer for these. */
    @keyframes hint-pill-hold {
      0%   { opacity: 0;   transform: translate(-50%, -4px); }
      7%   { opacity: 1;   transform: translate(-50%, 0); }
      33%  { opacity: .45; }
      59%  { opacity: 1; }
      85%  { opacity: .45; }
      100% { opacity: 1; }
    }
    .hint-pill-persist { animation: hint-pill-hold 5s ease-in-out both; }
    .hint-pill {
      position: absolute;
      /* A pill hangs OUTSIDE its host — below the Deep learn button, above the
         Save and Start buttons — so it lands on top of whatever comes next in
         the DOM. Without this it paints behind: on Vocab the filter bar with
         the Review/Mistakes/Mastered control drew straight over it. Kept well
         under #translationPopup's 60 so a pill can never cover the popup. */
      z-index: 20;
      transform: translateX(-50%);
      white-space: nowrap;
      display: inline-flex;
      align-items: center;
      gap: 5px;
      /* Fixed height, so every pill in the app is exactly the same size
         wherever it appears. Left to shrink-wrap they differed by a pixel or
         two: the emoji comes from the platform's font and does not share the
         text font's metrics, so a pill's height depended on which glyph it
         carried and which device drew it. The height plus centring replaces
         vertical padding. */
      height: 26px;
      line-height: 1;
      /* Never intercepts the tap it is asking for. */
      pointer-events: none;
      font-family: var(--font-ui);
      font-size: 12.5px;
      font-weight: 400;
      letter-spacing: 0.02em;
      /* OPAQUE, and it has to stay that way: the label floats over body text,
         so any alpha lets the sentence underneath read straight through it.
         Five fills were tried before this one — --accent-wash (transparent,
         the text read through it), solid black and solid --accent (both too
         heavy), the flattened wash #EDF2F6 (so close to the card that the
         pill washed out among the words), and --accent-light (clear, but the
         same colour as the word it points at).
         WARM, deliberately: every blue on this screen is already spoken for
         (--accent-light is the active-lookup highlight, --accent the links),
         so a warm pill is the only fill that separates the hint from the text
         it sits on. It does sit in the --gold family, which elsewhere means
         "saved word" — the tint here is much paler than --gold-tint and it
         is a bordered pill, not a highlight behind running text, so the two
         do not read as the same thing. Text is a dark amber for contrast:
         --gold itself on this tint is far under the readability mark. */
      color: var(--white);
      background: var(--text);
      /* No border. It was doing a job only the pale fills needed — with a dark
         fill the pill separates itself from every surface it floats over:
         reading card, painting, glass button, flashcard. Four pale fills were
         tried and rejected on the way here (--accent-wash, a flattened
         #EDF2F6, --accent-light, and this warm gold), each either washing out
         against the card or colliding with a meaning the app already gives
         that colour. */
      border: none;
      border-radius: var(--r-pill);
      padding: 0 12px;
      /* No shadow: the original had none, and a shadow on a tint this soft
         reads as a smudge rather than a raised pill. */
      animation: hint-pill-in 5s ease-in-out both;
    }
    /* The emoji is the app's only one, and it ignores `color` — it comes from
       the platform's emoji font, so it renders slightly differently on every
       device. Sized and line-height'd on its own because those platform fonts
       do not share the text font's metrics: left alone it rides high and
       stretches the pill taller than the text needs. */
    .hint-pill-icon {
      font-size: 12px;
      line-height: 1;
      flex: none;
    }

    /* Dismiss. The pill itself is pointer-events:none so it never swallows the
       tap it is asking for — this child turns them back on for itself alone.
       Deliberately a span, not a button: Home's pill lives INSIDE the Scan
       button and a button inside a button is invalid HTML. The pill is
       aria-hidden, so nothing here is reachable by keyboard or screen reader
       — correct for a decorative hint, and the reason a real button buys
       nothing. Negative margins keep the touch target near 28px without
       making the pill taller. */
    .hint-pill-x {
      pointer-events: auto;
      cursor: pointer;
      flex: none;
      margin: -8px -10px -8px -1px;
      padding: 8px 10px 8px 6px;
      font-size: 14px;
      line-height: 1;
      opacity: .55;
      -webkit-tap-highlight-color: transparent;
    }
    .hint-pill-x:active { opacity: 1; }

    /* Caret pointing up at the word this label belongs to.
       A ROTATED SQUARE, not the two-triangle trick used by .popup-caret. That
       trick works there because the popup has no visible border on the edge
       the caret sits on; here the pill's own 1.5px top border runs straight
       across the base of the arrow, so the outline visibly broke at both
       joins. This square carries the border on two adjacent sides — which
       become the arrow's two edges once rotated — and its opaque fill covers
       the length of the pill's top border underneath it, so the outline reads
       as one continuous shape. Fill and border must match the pill's exactly. */
    .hint-pill::before {
      content: '';
      position: absolute;
      top: -5px;
      /* Set by _showTapHint() in px. The fallback centres it, which is right
         whenever the pill has not had to be pushed away from a card edge. */
      left: var(--caret-x, 50%);
      margin-left: -4.5px;
      width: 9px;
      height: 9px;
      background: var(--text);
      border-left: 1.5px solid var(--text);
      border-top: 1.5px solid var(--text);
      border-top-left-radius: 2px;
      transform: rotate(45deg);
    }

    /* Caret on the BOTTOM edge, for a pill sitting above what it points at
       (Home). Same square, same trick — the two bordered sides swap to the
       pair facing down, and the fill covers the pill's bottom border. */
    .hint-pill-down::before {
      top: auto;
      bottom: -5px;
      border-left: none;
      border-top: none;
      border-right: 1.5px solid var(--text);
      border-bottom: 1.5px solid var(--text);
      border-top-left-radius: 0;
      border-bottom-right-radius: 2px;
    }

    /* Home's pills hang above whichever action button they point at, measured
       by placeHintPill against .actions — which is already position:relative
       (see the .has-wallpaper block above). Nothing else is needed here. */
    @media (prefers-reduced-motion: reduce) {
      /* No motion, but the lesson still has to land: a static tint and a
         label that is simply present until the timer removes it. */
      .word-tap.word-hint { animation: none; background: var(--accent-light); }
      /* .hint-pill-persist is listed explicitly rather than relying on this
         block sitting later in the file — it carries its own animation, and
         one reorder would silently restore the motion here. */
      .hint-pill,
      .hint-pill.hint-pill-persist { animation: none; }
    }

    /* ════════════════════════════════════════════════════════════════
       TRANSLATION POP-UP  (Phase 3)
    ════════════════════════════════════════════════════════════════ */
    #translationPopup {
      display: none;
      position: absolute;
      left: 16px;
      right: 16px;
      background: var(--white-soft);
      border: 1px solid var(--border);
      border-radius: var(--r-md);
      box-shadow: 0 8px 32px rgba(0,0,0,.14), 0 2px 8px rgba(0,0,0,.07);
      z-index: 60;
      overflow: hidden;
    }
    #translationPopup.visible { display: block; }

    /* Small caret pointing up toward the tapped word */
    .popup-caret {
      position: absolute;
      top: -7px;
      width: 14px;
      height: 7px;
      overflow: visible;
      pointer-events: none;
    }
    .popup-caret::before,
    .popup-caret::after {
      content: '';
      position: absolute;
      left: 50%;
      transform: translateX(-50%);
      border-left: 7px solid transparent;
      border-right: 7px solid transparent;
    }
    .popup-caret::before { top: -1px; border-bottom: 7px solid var(--border); }
    .popup-caret::after  { top: 0;    border-bottom: 7px solid var(--white); }

    /* Popup body */
    .popup-body { padding: 12px 12px 10px; }

    .popup-card {
      background: var(--bg);
      border: 1px solid var(--border);
      border-radius: var(--r-md);
      padding: 14px;
      position: relative;
      overflow: hidden;
    }

    .popup-danish {
      font-family: var(--font-mark);
      font-size: 22px;
      font-weight: 600;
      color: var(--text);
      margin-bottom: 0;
      line-height: 1.2;
    }

    .popup-loading {
      display: flex;
      flex-direction: column;
      align-items: center;
      padding: 2px 0 0;
    }
    .popup-fox-message {
      font-family: var(--font-mark);
      font-size: 15px;
      font-weight: 300;
      letter-spacing: -0.01em;
      color: var(--text);
      margin-bottom: 10px;
    }
    .popup-loading .fox-stage {
      transform: scale(0.6);
      transform-origin: top center;
      margin-bottom: -24px;
    }
    .popup-error {
      font-family: var(--font-ui);
      font-size: 13px;
      color: var(--error);
    }

    /* Notification overlay — floats over the card bottom, no height impact */
    #popupNotif {
      display: none;
      position: absolute;
      bottom: 0;
      left: 0;
      right: 0;
      padding: 10px 14px;
      background: var(--text);
      color: var(--white);
      font-family: var(--font-ui);
      font-size: 13px;
      font-weight: 300;
      line-height: 1.5;
      text-align: center;
    }
    #popupNotif.show { display: block; }

    /* Action row */
    .popup-actions {
      display: flex;
      gap: 8px;
      padding: 0 12px 12px;
      /* Host for the `save` hint pill, which hangs above the Save button. */
      position: relative;
    }
    /* Remove is the rarer, destructive action. It used to sit below the primary
       button as its own text-link row, shown/hidden with visibility; moved
       (Aug 2026) into the action row itself, between Save/Update and
       Regenerate, as an icon button that is now ALWAYS present — same
       disabled-until-resolved pattern as .popup-regen-btn/.popup-save-btn,
       rather than the old visibility toggle. See DECISIONS.md.
       Styled as .popup-regen-btn's twin — same 44px/--r-btn sizing, same
       wash-background-plus-tinted-icon look at rest (so it doesn't read as a
       blank/disabled button against the card) — but in --error instead of
       --accent, and with a real border rather than regen's transparent
       placeholder one, since this is the destructive control. */
    .popup-remove-icon-btn {
      min-width: 44px;
      min-height: 44px;
      border: 1px solid var(--error-hairline);
      border-radius: var(--r-btn);
      background: var(--error-wash);
      color: var(--error);
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      -webkit-tap-highlight-color: transparent;
      transition: background .12s, color .12s, border-color .12s;
    }
    .popup-remove-icon-btn:active    { background: var(--error-hairline); border-color: var(--error); }
    .popup-remove-icon-btn:disabled  { opacity: .4; cursor: default; }
    .popup-save-btn {
      flex: 1;
      min-height: 44px;
      border: none;
      border-radius: var(--r-btn);
      background: var(--accent);
      color: var(--white);
      font-family: var(--font-ui);
      font-size: 14px;
      font-weight: 400;
      letter-spacing: 0.02em;
      cursor: pointer;
      transition: opacity .15s;
      -webkit-tap-highlight-color: transparent;
    }
    .popup-save-btn:active:not(.remove)   { opacity: .82; }
    .popup-save-btn:disabled { opacity: .5; cursor: default; }
    .popup-save-btn.remove {
      background: var(--accent-light);
      border: none;
      color: var(--accent);
    }
    .popup-save-btn.remove:active { opacity: .82; }
    .popup-save-icon { vertical-align: -2px; margin-right: 6px; }

    .popup-close-btn {
      min-width: 44px;
      min-height: 44px;
      border: 1px solid transparent;
      border-radius: var(--r-pill);
      background: none;
      color: var(--text-3);
      font-size: 17px;
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      -webkit-tap-highlight-color: transparent;
      transition: background .12s;
    }
    .popup-close-btn:active { background: var(--surface); }

    .popup-regen-btn {
      min-width: 44px;
      min-height: 44px;
      border: 1px solid transparent;
      border-radius: var(--r-btn);
      background: var(--accent-wash);
      color: var(--accent);
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      -webkit-tap-highlight-color: transparent;
      transition: background .12s, color .12s;
    }
    .popup-regen-btn:active { background: var(--accent-light); color: var(--accent); }
    .popup-regen-btn:disabled { opacity: .4; cursor: default; }

    /* Phonetic transcription below the word */

    /* Speaker / replay button */
    .popup-speak-btn {
      min-width: 44px;
      min-height: 44px;
      border: none;
      border-radius: var(--r-pill);
      background: none;
      color: var(--accent);
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      -webkit-tap-highlight-color: transparent;
      transition: background .12s;
      flex-shrink: 0;
    }
    .popup-speak-btn:active { background: var(--accent-light); }
    .popup-speak-btn svg { width: 18px; height: 18px; }

    /* Pulsing ring while speaking */
    @keyframes speakPulse {
      0%   { transform: scale(1);    opacity: .6; }
      50%  { transform: scale(1.18); opacity: 1;  }
      100% { transform: scale(1);    opacity: .6; }
    }
    .popup-speak-btn.speaking {
      background: var(--accent-light);
      border-color: var(--accent);
      animation: speakPulse .8s ease-in-out infinite;
    }

    /* ════════════════════════════════════════════════════════════════
       QUIZ SCREEN  (Phase 6) — Duolingo-style matching pairs
    ════════════════════════════════════════════════════════════════ */
    #quizScreen {
      display: none;
      flex-direction: column;
      flex: 1;
      overflow: hidden;
      /* No explicit background (was var(--white), which fully covered the
         .shell gradient) — transparent, same as the other 4 main screens,
         so the shell's bottom-up gradient shows through here too. */
    }

    /* Header */
    .quiz-header {
      display: flex;
      flex-direction: column;
      /* Top reduced to 5px (30 Jul 2026), matching the other four headers.
         Bottom takes the difference: 5 + the row's 28px min-height + 13 = 46px,
         matching .home-header, .text-header, .vocab-header and .story-header
         exactly. Keep these five in step — an uneven value shifts the content
         start line between tabs. */
      padding: 5px 20px 13px;
      /* Blue wash removed — banded across the wallpaper like the other headers. */
      flex-shrink: 0;
    }
    .quiz-header-row {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 10px;
      /* MUST stay fixed. On the setup page every child is now empty or hidden
         (no heading, no timer, no exit button), so without this the row would
         collapse to 0 while round pages — which still show 'Round 1 / 3' —
         stay ~28px tall. The quiz card is vertically centred in
         .quiz-body-inner, so it would visibly jump the moment a quiz starts.
         Same class of bug as the quiz filter-block grid (CLAUDE.md index). */
      min-height: 28px;
    }
    .quiz-header-left {
      display: flex;
      align-items: center;
      gap: 10px;
    }
    .quiz-header-right {
      display: flex;
      align-items: center;
      gap: 8px;
      flex-shrink: 0;
    }
    .quiz-header-row + .quiz-progress-bar {
      margin-top: 12px;
    }
    .quiz-exit-btn {
      background: none; border: 1px solid transparent;
      border-radius: var(--r-pill);
      cursor: pointer; color: var(--accent);
      font-size: 22px;
      line-height: 1;
      min-width: 44px; min-height: 44px;
      padding: 2px 6px 2px 0;
      -webkit-tap-highlight-color: transparent;
    }
    .quiz-exit-btn:active { background: var(--surface); }
    .quiz-round-label {
      font-family: var(--font-mark);
      font-size: 22px;
      font-weight: 300;
      letter-spacing: 0.03em;
      color: var(--text);
      width: fit-content;
    }
    .quiz-round-label:has(span) {
      border-left: 3px solid var(--accent);
      padding-left: 10px;
    }
    .quiz-round-label span { font-style: italic; color: var(--accent); }
    .quiz-mistakes {
      font-family: var(--font-ui);
      font-size: 13px; font-weight: 500;
      color: var(--text-3);
      min-width: 40px; text-align: right;
      margin-left: auto;
    }
    .quiz-timer {
      font-family: var(--font-ui);
      font-size: 13px; font-weight: 600;
      color: var(--text-2);
      min-width: 44px; text-align: right;
      transition: color .2s, font-size .2s, transform .2s;
      display: inline-block;
    }
    .quiz-timer.urgent {
      font-size: 22px; font-weight: 700;
      color: #E24B4A;
      animation: timerShake .35s ease-in-out infinite, timerFlash .5s ease-in-out infinite;
    }
    @keyframes timerShake {
      0%,100% { transform: translateX(0) rotate(0deg); }
      20%      { transform: translateX(-3px) rotate(-4deg); }
      40%      { transform: translateX(3px) rotate(4deg); }
      60%      { transform: translateX(-2px) rotate(-2deg); }
      80%      { transform: translateX(2px) rotate(2deg); }
    }
    @keyframes timerFlash {
      0%,100% { color: #E24B4A; }
      50%      { color: #F09595; }
    }
    /* Confetti canvas */
    #quizConfettiCanvas {
      position: fixed;
      top: 0; left: 0;
      width: 100%; height: 100%;
      pointer-events: none;
      z-index: 9999;
    }

    .quiz-progress-bar {
      height: 5px; background: var(--surface);
      border-radius: 3px; overflow: hidden;
    }
    .quiz-progress-fill {
      height: 100%; background: var(--accent);
      border-radius: 3px; transition: width .4s ease;
    }

    /* Pre-quiz setup screen */
    .quiz-setup {
      display: flex;
      flex-direction: column;
      align-items: stretch;
      justify-content: flex-start;
      gap: 36px;
      min-height: 100%;
      /* padding-top applies to both quiz types (Match/Listen) since
         .quiz-setup isn't type-scoped — do not type-scope it, or the setup
         screen starts at a different line per quiz type. */
      padding: 0 20px 16px;
    }
    /* One quiz-type card per row, all same size */
    .quiz-type-grid {
      display: flex;
      flex-direction: column;
      gap: 40px;
      margin-top: -24px;
    }
    /* ── Play tab landing (13 Sep 2026) ──────────────────────────────
       Two tiles instead of two settings-style rows. The rows were a menu; the
       tab needed a place. Everything here is SCOPED TO .quiz-type-grid on
       purpose: .quiz-type-card is shared with the stories chooser's rows
       (.stories-chooser-card), which must stay a list — restyling the base
       class would silently redesign that sheet too. See the COUPLING note in
       index.html above .stories-chooser-list.

       Tiles are a FIXED height rather than flex:1. Stretching them to fill
       swallowed every spare pixel, so no amount of gutter or gap could make
       the page breathe — the height was the thing doing the crowding. */
    .quiz-type-grid {
      flex: 1;
      display: flex;
      flex-direction: column;
      justify-content: center;
      gap: 28px;
      /* NO horizontal padding: .quiz-body (20px) and .quiz-setup (20px) already
         stack two gutters before this element, and a third took the tiles down
         to 262px on a 390px screen. Measured, not guessed.
         Asymmetric vertical padding (13 Sep 2026) biases the centred pair
         DOWNWARD, away from the title. Padding rather than justify-content:
         flex-start with a fixed offset, because centring is what stops the
         tiles colliding with the Play bar on a short phone — the offset would
         have to be tuned per screen height, and it would be wrong on one.
         70px, not 96: at 96 a 667px phone (iPhone SE) left only 16px between
         the lower tile and the Play bar. 70 keeps 40px there and still moves
         the pair 25px clear of the title on a normal phone. Measured at 667,
         844 and 932. */
      padding: 70px 0 20px;
    }
    .quiz-type-grid .quiz-type-card {
      position: relative;
      /* 100, down from 168 -> 150 -> 128 over 13 Sep 2026. The air belongs
         BETWEEN the cards (28px gap) and above them (70px), not inside them.
         This is at the content floor: icon 20 + name 25 + a two-line
         description 35 + 22px padding top and bottom is already 124, so the
         padding is trimmed to 16 here and the icon overlaps the slack at the
         top. Going below 100 clips the second description line. */
      height: 100px;
      padding: 16px 22px;
      /* Icon beside the text, not floating above it (13 Sep 2026). At 168px a
         corner icon and a bottom-anchored label read as a poster; at 100px
         they just read as two things that missed each other. A row puts the
         icon where it belongs — in front of the words it labels. */
      flex-direction: row;
      align-items: center;
      gap: 18px;
      border-color: transparent;
      /* Same blue the flashcard tiles and the card's bottom band use, so the
         two screens are one palette rather than two. */
      background: linear-gradient(150deg, rgba(214, 232, 245, .62), rgba(214, 232, 245, .10));
    }
    /* One fill for both tiles (13 Sep 2026). They were blue and gold for a
       day — the idea being that a game and a story are different KINDS of
       thing — but two hues on one small screen read as two products rather
       than one page. The icons and names carry the difference; the colour
       carries the family. Gold was tried at --gold (too loud at tile size)
       and at --gold-tint before being dropped altogether. */
    /* Selected keeps its own fill — the base rule swaps the background for a
       flat --accent-light, which would drop the gradient. Only the border
       marks the choice here. */
    /* Selection is the BORDER only. The base rule swaps the background for a
       flat --accent-light and turns the name and icon blue — on the gold
       Stories tile that repainted the whole thing, so picking it made the two
       tiles identical and lost the reason they are different colours.
       Each tile therefore restates its own fill, name and icon colour here. */
    /* Selection is the BORDER only. The base .quiz-type-card.selected rule
       repaints the background flat --accent-light and turns the name and icon
       accent-blue; at tile size that swapped the whole surface, so the tiles
       jumped rather than being marked. Restated here rather than removed from
       the base rule, which the stories chooser's list rows still use. */
    .quiz-type-grid .quiz-type-card.selected {
      border-color: var(--accent);
      background: linear-gradient(150deg, rgba(214, 232, 245, .62), rgba(214, 232, 245, .10));
    }
    .quiz-type-grid .quiz-type-card.selected .quiz-type-card-name { color: var(--text); }
    .quiz-type-grid .quiz-type-card.selected .quiz-type-card-icon { color: #43678A; }
    .quiz-type-grid .quiz-type-card-icon {
      flex-shrink: 0;
      color: #43678A;
      opacity: .55;
    }
    .quiz-type-grid .quiz-type-card-body {
      display: flex;
      flex-direction: column;
      gap: 3px;
      text-align: left;
    }
    .quiz-type-grid .quiz-type-card-name {
      font-family: var(--font-mark);
      font-size: 18px;
      font-weight: 600;
      color: var(--text);
    }
    .quiz-type-grid .quiz-type-card-desc {
      font-size: 12px;
      line-height: 1.45;
      color: var(--text-2);
    }
    /* The title that replaced "PICK A QUIZ". Sized like the app's other
       section titles, not like the end card's — this heads a screen. */
    /* 6px, down from 14, down from 34 (13 Sep 2026). The title sat a third of
       the way down a nearly empty screen; the tiles are centred below it, so
       pulling the heading up costs nothing and stops the page reading as
       top-heavy. Do not go below ~4px — .quiz-setup has no padding-top, so
       this value is the entire clearance under the header. */
    .quiz-setup-head { text-align: center; padding: 6px 24px 6px; }
    .quiz-setup-title {
      font-family: var(--font-mark);
      font-size: 22px;
      font-weight: 600;
      color: var(--text);
    }
    .quiz-setup-sub {
      font-family: var(--font-ui);
      font-size: 12.5px;
      color: var(--text-3);
      margin-top: 5px;
    }

    .quiz-type-card {
      display: flex;
      flex-direction: row;
      align-items: center;
      gap: 18px;
      padding: 16px 18px;
      border: 1.5px solid var(--border);
      border-radius: var(--r-lg);
      background: var(--white-soft);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: border-color .15s, background .15s;
      text-align: left;
    }
    .quiz-type-card:active { background: var(--surface); }
    .quiz-type-card.selected {
      border-color: var(--accent);
      background: var(--accent-light);
    }
    /* Placeholder card (23 Aug 2026) — the old story-listen quiz type was
       removed; this card sits here disabled until a new quiz replaces it. */
    .quiz-type-card-icon {
      color: var(--text-3);
      transition: color .15s;
      flex-shrink: 0;
    }
    .quiz-type-card.selected .quiz-type-card-icon { color: var(--accent); }
    .quiz-type-card-body {
      display: flex;
      flex-direction: column;
      gap: 5px;
    }
    .quiz-type-card-name {
      font-family: var(--font-mark);
      font-size: 17px;
      font-weight: 600;
      color: var(--text);
      line-height: 1.2;
    }
    .quiz-type-card.selected .quiz-type-card-name { color: var(--accent); }
    .quiz-type-card-desc {
      font-family: var(--font-ui);
      font-size: 12px;
      color: var(--text-3);
      line-height: 1.4;
    }
    /* Filter + word count + button section.
       Grid kept (rather than plain flex) so a second filter block can be
       added back here later without restructuring — see quizTypeCardStory
       placeholder above. */
    .quiz-setup-filter-block {
      display: grid;
      grid-template-columns: 1fr;
    }
    #quizWordFilter {
      grid-area: 1 / 1;
      align-self: start;
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 8px;
      width: 100%;
    }
    .quiz-setup-filter-label {
      font-family: var(--font-ui);
      font-size: 10px;
      font-weight: 600;
      letter-spacing: 0.13em;
      text-transform: uppercase;
      color: var(--text-3);
      text-align: center;
    }
    /* Difficulty segmented control (Easy / Medium / Hard) — Match quiz only.
       Lives in #quizFilterOverlay (23 Aug 2026) directly below the word/time
       filter block, no longer inside .quiz-setup's 36px-gap flex column —
       margin-top is plain spacing here, not a correction for that gap. */
    /* .quiz-word-count-section (Words in quiz) shares this rule on purpose —
       same shape (label + .quiz-segmented, 16px top margin), always visible
       rather than display-toggled. Edit once, both move. */
    .quiz-difficulty-section,
    .quiz-word-count-section {
      display: flex;
      flex-direction: column;
      align-items: stretch;
      gap: 8px;
      margin-top: 16px;
    }
    /* Padded track + individually-rounded segments (Aug 2026) — replaced a
       flush 3-cell grid with hard 1.5px divider lines between segments. The
       dividers read as a plain table rather than one control, and the
       36px/no-padding sizing felt cramped next to the rest of the setup
       screen. Segments now float inside a --surface track with a 3px gap,
       so there is space around each one instead of an edge-to-edge line. */
    /* Flex, not a fixed-column grid — reused for both the 3-segment
       Difficulty control and the 2-segment Story-length filter, and a fixed
       "1fr 1fr 1fr" grid would leave a dead third column on the 2-segment
       one. flex:1 on each .quiz-seg-btn divides the track evenly regardless
       of how many segments it has. */
    .quiz-segmented {
      display: flex;
      width: 100%;
      gap: 2px;
      padding: 2px;
      /* Outline around the whole track (25 Aug 2026), not per-segment dividers
         — the 1.5px divider-line version above was tried and removed for
         reading like a plain table. Same border color/weight as the app's
         other pill controls (.quiz-time-dropdown-btn), so it reads as one
         family rather than a one-off. */
      border: 1.5px solid var(--border);
      border-radius: var(--r-btn);
      background: var(--white-soft);
    }
    .quiz-seg-btn {
      flex: 1;
      /* 32px + 2px padding top/bottom on .quiz-segmented = 36px total, in
         EVERY state, matching .quiz-time-dropdown-btn's 36px. The selected
         segment's inset pill is drawn by ::before, not by a margin on the
         button — a margin on .active made the track 6px taller whenever a
         segment was selected, a size that changes with state, which silently
         broke the filter-block height match on the Story filter. Never give
         this button a margin, border or padding that only one state carries. */
      position: relative;
      height: 32px;
      border: none;
      /* Inner radius kept a few px below the track's var(--r-btn) so the
         corners nest cleanly at 2px padding. */
      border-radius: 7px;
      background: transparent;
      /* Explicit flex-centering, not reliance on <button>'s default
         text-align — the UA default isn't consistently centred across
         browsers once padding/margin come into play, which was leaving the
         label off-centre in the resting (unselected) state specifically. */
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 0;
      font-family: var(--font-ui);
      font-size: 12px;
      font-weight: 500;
      color: var(--text-2);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .15s, color .15s, box-shadow .15s;
    }
    /* The fill insets within its cell rather than filling it edge to edge —
       the tap target stays the full cell, only the visible pill shrinks.
       Drawn on ::before (inset 3px) so the inset costs no layout height;
       source order matters — .active comes after :active, so a selected
       segment stays blue while pressed, as before. */
    .quiz-seg-btn::before {
      content: '';
      position: absolute;
      inset: 3px;
      border-radius: 7px;
      transition: background .15s, box-shadow .15s;
    }
    .quiz-seg-btn:active::before { background: rgba(0,0,0,.05); }
    .quiz-seg-btn.active::before {
      background: var(--accent);
      box-shadow: 0 1px 3px rgba(61,111,150,.25);
    }
    .quiz-seg-btn.active {
      color: var(--white);
      font-weight: 600;
    }
    .quiz-seg-btn span { display: inline-block; }
    /* Label sits above the ::before pill */
    .quiz-seg-btn span { position: relative; z-index: 1; }
    /* Full width, not a side-by-side grid (25 Aug 2026) — the word filter is
       a 3-segment control, which needs full width to keep "Mistake"/"Master"
       readable. This used to also stack a time-filter segmented control
       below it; that filter was removed entirely the same day (see
       DECISIONS.md), leaving .quiz-setup-pills with a single child, but kept
       as flex/column rather than collapsed back to a plain div in case
       another filter row joins it later. */
    .quiz-setup-pills {
      display: flex;
      flex-direction: column;
      gap: 8px;
      width: 100%;
    }
    /* Reset on its own right-aligned row. The explicit height is deliberate:
       a filter row holding only the absolutely-positioned word count would
       otherwise collapse to zero. */
    /* Sits between the controls and Start Quiz in #quizFilterOverlay. It was
       absolutely centred inside a .quiz-reset-row that also right-aligned the
       Reset button — two positioning systems in one 30px strip, which a long
       count could overlap. Both rows are gone (27 Aug 2026): Reset moved up
       beside the "Quiz on" label, this is a plain centred line. */
    .quiz-setup-word-count {
      font-family: var(--font-ui);
      font-size: 11px;
      color: var(--text-3);
      text-align: center;
      margin: 16px 0 10px;
      pointer-events: none;
    }
    .quiz-setup-word-count strong { color: var(--text-2); }
    .quiz-start-bar {
      padding: 14px 24px;
      border-top: 1px solid var(--border);
      flex-shrink: 0;
      /* Host for the `quiz` hint pill, above the Start Quiz button. */
      position: relative;
    }
    .quiz-setup-start-btn {
      width: 100%;
      height: 48px;
      margin-top: 0;
      border-radius: var(--r-btn);
      border: none;
      background: var(--accent);
      color: var(--white);
      font-family: var(--font-ui);
      font-size: 15px;
      /* 400, not 600 — DESIGN.md's filled-button rule. This button had been
         missed by that pass (fixed 27 Aug 2026). */
      font-weight: 400;
      letter-spacing: 0.02em;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: opacity .15s, transform .1s;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 10px;
    }
    .quiz-setup-start-btn:active { opacity: .85; transform: scale(.98); }
    /* Newly added — custom time dropdown */
    .quiz-filter-reset-btn { display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: 12px; font-weight: 500; color: var(--border); background: none; border: none; padding: 0 4px; height: 30px; cursor: default; pointer-events: none; }
    .quiz-filter-reset-btn.active { color: var(--text-2); cursor: pointer; pointer-events: auto; }
    .quiz-time-dropdown-wrap { position: relative; min-width: 0; }
    /* height 36px matches .quiz-segmented (32px button + 2px track padding).
       The vocab-header copies override this to 26px — see .vocab-dropdowns. */
    .quiz-time-dropdown-btn { width: 100%; height: 36px; display: flex; align-items: center; justify-content: flex-start; font-family: var(--font-ui); font-size: 12px; color: var(--text); background: var(--white-soft); border: 1.5px solid var(--border); border-radius: var(--r-pill); padding: 0 12px; cursor: pointer; gap: 7px; min-width: 0; -webkit-tap-highlight-color: transparent; }
    .quiz-time-dropdown-btn.active { background: var(--accent-light); color: var(--accent); border-color: var(--accent); }
    .quiz-time-dropdown-btn span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
    .quiz-time-dropdown-btn .qdrop-chev { margin-left: auto; flex-shrink: 0; transition: transform .2s; }
    .quiz-time-dropdown-btn.open .qdrop-chev { transform: rotate(180deg); }
    .quiz-time-dropdown-panel { position: fixed; z-index: 300; background: var(--white-soft); border: 1px solid var(--border); border-radius: var(--r-btn); box-shadow: 0 4px 16px rgba(0,0,0,.12); min-width: 160px; display: none; flex-direction: column; padding: 4px; }
    .quiz-time-dropdown-panel.open { display: flex; }
    .quiz-td-item { display: flex; align-items: center; gap: 7px; padding: 6px 8px; border-radius: 6px; font-size: 12px; font-family: var(--font-ui); color: var(--text-2); background: transparent; border: none; cursor: pointer; text-align: left; width: 100%; -webkit-tap-highlight-color: transparent; }
    .quiz-td-item:hover { background: var(--bg); }
    .quiz-td-item.active { background: var(--accent-light); color: var(--accent); font-weight: 600; }
    .quiz-td-item.active .quiz-td-radio { border-color: var(--accent); background: var(--accent); }
    .quiz-td-item.active .quiz-td-radio::after { background: #fff; }
    .quiz-td-radio { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; border: 1.5px solid var(--border); background: transparent; display: inline-flex; align-items: center; justify-content: center; transition: border-color .15s, background .15s; }
    .quiz-td-radio::after { content: ''; width: 5px; height: 5px; border-radius: 50%; background: transparent; }

    /* ── Listen & Pick quiz ─────────────────────────────────────── */
    .listen-quiz {
      display: flex;
      flex-direction: column;
      align-items: center;
      gap: 14px;
      min-height: 100%;
      width: 100%;
      padding: 8px 0 8px;
    }
    .listen-q-label {
      font-family: var(--font-ui);
      font-size: 11px;
      font-weight: 600;
      letter-spacing: 0.15em;
      text-transform: uppercase;
      color: var(--text-3);
      text-align: center;
    }
    /* Compact circular play button */
    .listen-play-btn {
      width: 54px;
      height: 54px;
      border-radius: 50%;
      border: 2px solid var(--accent);
      background: var(--accent-light);
      color: var(--accent);
      cursor: pointer;
      display: flex;
      align-items: center;
      justify-content: center;
      -webkit-tap-highlight-color: transparent;
      transition: background .15s, transform .12s;
      flex-shrink: 0;
    }
    .listen-play-btn:active { background: var(--accent); color: var(--white); transform: scale(.93); }
    /* Compact variant for story quiz — ~2 cm */
    .listen-play-btn.sl-play-btn {
      width: 48px;
      height: 48px;
      border-width: 2px;
    }
    .listen-play-btn.speaking {
      background: var(--accent);
      color: var(--white);
      animation: speakPulse .8s ease-in-out infinite;
    }
    /* Shared shake, kept when Listen & Pick was removed (5 Sep 2026): the
       flashcard's Choice mode and the Stories quiz both still use it. It lived
       in the deleted block only because that is where it was first written. */
    @keyframes listenWrongShake {
      0%,100% { transform: translateX(0); }
      20%     { transform: translateX(-6px); }
      40%     { transform: translateX(6px); }
      60%     { transform: translateX(-4px); }
      80%     { transform: translateX(4px); }
    }
    .listen-instruction {
      font-family: var(--font-ui);
      font-size: 13px;
      color: var(--text-2);
      text-align: center;
    }
    /* 2×2 option grid */
    /* ── Listen Story & Pick ─────────────────────────────────────────── */
    /* 4×2 word button grid */
    .sl-word-grid {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 10px;
      width: 100%;
    }
    .sl-word-btn {
      display: flex;
      flex-direction: column;
      align-items: center;
      justify-content: center;
      gap: 4px;
      min-height: 64px;
      border-radius: var(--r-md);
      border: 2px solid var(--border);
      background: var(--white-soft);
      cursor: pointer;
      padding: 10px 8px;
      -webkit-tap-highlight-color: transparent;
      transition: border-color .12s, background .12s;
    }
    .sl-word-btn:active:not(:disabled) { background: var(--surface); border-color: var(--accent); }
    .sl-word-danish {
      font-family: var(--font-mark);
      font-size: 19px;
      font-weight: 600;
      color: var(--text);
      line-height: 1.2;
    }
    .sl-word-en {
      font-size: 11px;
      color: var(--text-3);
      font-style: italic;
      display: none;
    }
    .sl-word-btn.correct {
      border-color: var(--success);
      background: #EAF3EE;
    }
    .sl-word-btn.correct .sl-word-danish { color: var(--success); }
    .sl-word-btn.correct .sl-word-en { display: block; color: var(--success); }
    .sl-word-btn.revealed .sl-word-en { display: block; }
    .sl-word-btn.wrong {
      border-color: var(--error);
      background: #FDF0F0;
      animation: listenWrongShake .5s ease;
    }
    .sl-word-btn:disabled { cursor: default; }
    .sl-word-btn:disabled:not(.correct):not(.wrong) { opacity: .45; }
    /* Story reveal — shown after all correct words found */
    .sl-story-reveal {
      width: 100%;
      border-top: 1px solid var(--border);
      padding-top: 16px;
      margin-top: 4px;
      display: flex;
      flex-direction: column;
      gap: 14px;
    }
    .sl-reveal-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
    }
    .sl-reveal-label {
      font-family: var(--font-ui);
      font-size: 10px;
      font-weight: 700;
      letter-spacing: .08em;
      text-transform: uppercase;
      color: var(--success);
    }
    /* EN toggle on the reveal — was .stories-en-btn until the stories sheet
       moved to .para-translate-btn; renamed, this quiz is its only user. */
    .sl-en-btn {
      display: flex;
      align-items: center;
      gap: 5px;
      background: none;
      border: 1px solid var(--border);
      border-radius: 99px;
      padding: 5px 12px;
      font-family: var(--font-ui);
      font-size: 12px;
      font-weight: 500;
      color: var(--text-2);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
      transition: background .15s, color .15s;
    }
    .sl-en-btn.active {
      background: var(--accent-light);
      border-color: var(--accent);
      color: var(--accent);
    }
    .sl-story-text {
      font-family: var(--font-body);
      font-size: 14px;
      line-height: 1.75;
      color: var(--text);
    }
    .sl-story-text .story-word b,
    .sl-story-text b { color: var(--accent); }

    /* Scrollable body */
    .quiz-body {
      flex: 1; overflow-y: auto;
      -webkit-overflow-scrolling: touch;
      padding: 0 20px;
    }
    /* Inner wrapper handles centering — separate from scroll container */
    .quiz-body-inner {
      min-height: 100%;
      display: flex; flex-direction: column;
      align-items: stretch;
      justify-content: center;
      gap: 12px;
      padding: 8px 0;
      box-sizing: border-box;
    }
    .quiz-body-inner.top { justify-content: flex-start; }
    /* Setup screen only — the 8px is wanted above a running quiz's card but
       reads as dead space above "Pick a Quiz", which already sits under an
       empty 46px header band. Scoped by class rather than dropped from the
       shared rule so the quiz pages keep their breathing room.
       The negative margin borrows 12px back from that empty header band. It
       is a deliberate trade: the setup content sits 12px higher, and drops
       by the same 12px when a quiz starts and the header fills with
       "Round 1 / 3". Do not grow it much further without re-checking that
       jump — .quiz-header-row's min-height exists to stop exactly this. */
    .quiz-body-inner.quiz-setup-body { padding-top: 0; margin-top: -12px; }
    .quiz-body-inner.quiz-match-body { position: relative; justify-content: flex-start; padding-top: 100px; }
    .quiz-body-inner.quiz-match-body .quiz-round-hint { position: absolute; top: 8px; left: 0; right: 0; }

    .quiz-round-hint {
      font-family: var(--font-ui);
      font-size: 11px; font-weight: 600;
      letter-spacing: 0.15em; text-transform: uppercase;
      color: var(--text-3); text-align: center;
    }

    /* Two-column matching grid — interleaved so each row pair shares height */
    .quiz-match-grid {
      display: grid;
      grid-template-columns: 1fr 1fr;
      gap: 12px;
    }

    /* Matching pill buttons. min-height/padding bumped (28 Aug 2026) to fit
       the English side's added part-of-speech tag without feeling cramped —
       both columns share this rule, so the Danish side grows to match. */
    .quiz-pill {
      width: 100%;
      min-height: 68px;
      padding: 14px 12px;
      border: 1.5px solid var(--border);
      border-radius: var(--r-md);
      background: var(--white-soft);
      font-family: var(--font-ui);
      font-size: 13px;
      font-weight: 400;
      color: var(--text-2);
      cursor: pointer;
      text-align: center;
      line-height: 1.4;
      word-break: break-word;
      display: flex; align-items: center; justify-content: center;
      transition: background .12s, border-color .15s, color .12s, transform .1s, opacity .2s;
      -webkit-tap-highlight-color: transparent;
    }
    /* Danish column — font must persist across all states */
    .quiz-pill.quiz-pill-left,
    .quiz-pill.quiz-pill-left.selected,
    .quiz-pill.quiz-pill-left.matched,
    .quiz-pill.quiz-pill-left.wrong {
      font-family: var(--font-mark);
      font-size: 18px;
      font-weight: 600;
      font-style: normal;
      color: #000;
    }
    .quiz-pill:active:not(:disabled):not(.matched) { transform: scale(.96); }
    .quiz-pill.selected {
      background: var(--accent-light);
      border-color: var(--accent);
      color: var(--accent);
      transform: scale(1.03);
    }
    .quiz-pill.matched {
      background: #DCFCE7;
      border-color: #4E7E63;
      color: #166534;
      opacity: .65;
      pointer-events: none;
    }
    .quiz-pill.wrong {
      background: #FEE2E2;
      border-color: #B83232;
      color: #991B1B;
    }
    .quiz-pill:disabled { cursor: default; }

    /* Not enough words */
    .quiz-empty {
      flex: 1; display: flex; flex-direction: column;
      align-items: center; justify-content: center;
      gap: 12px; text-align: center; color: var(--text-3);
      padding: 20px; min-height: 300px;
    }
    .quiz-empty svg { opacity: .3; }
    .quiz-empty-title { font-size: 15px; font-weight: 500; color: var(--text-2); }
    .quiz-empty-sub   { font-size: 13px; line-height: 1.5; max-width: 240px; }

    /* Summary */
    .quiz-summary {
      display: flex; flex-direction: column;
      align-items: center; justify-content: center;
      gap: 10px; padding: 16px 0 8px; text-align: center;
    }
    .quiz-stars { font-size: 38px; letter-spacing: 6px; margin-bottom: 4px; }
    .quiz-summary-title {
      font-family: var(--font-mark);
      font-size: 22px; font-weight: 500; color: var(--text);
    }
    .quiz-summary-score {
      font-family: var(--font-ui);
      font-size: 46px; font-weight: 700;
      color: var(--accent); line-height: 1;
    }
    .quiz-summary-sub {
      font-family: var(--font-ui);
      font-size: 14px; color: var(--text-2);
      line-height: 1.5; margin-bottom: 8px;
    }
    .quiz-summary-actions {
      display: flex; flex-direction: column;
      gap: 10px; width: 100%; margin-top: 4px;
    }

    /* Wrong-words review section */
    .quiz-wrong-section {
      width: 100%; margin-top: 8px;
      border-top: 1px solid var(--border);
      padding-top: 16px; text-align: left;
    }
    .quiz-wrong-heading {
      font-family: var(--font-ui);
      font-size: 13px; font-weight: 400;
      color: var(--text-2); margin-bottom: 10px;
    }
    .quiz-wrong-list {
      display: flex; flex-direction: column; gap: 8px;
      margin-bottom: 12px;
    }
    .quiz-wrong-row {
      display: flex; flex-direction: column;
      padding: 10px 14px;
      background: var(--bg);
      border: 1px solid var(--border);
      border-left: 3px solid #E57373;
      border-radius: var(--r-md);
      font-family: var(--font-ui);
    }
    .quiz-wrong-top { display: flex; align-items: flex-start; gap: 10px; }
    .quiz-wrong-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
    .quiz-wrong-danish {
      font-size: 15px; font-weight: 600; color: var(--text);
    }
    .quiz-wrong-english {
      font-size: 13px; color: var(--text-2); line-height: 1.4;
    }
    .quiz-wrong-top.expandable { cursor: pointer; -webkit-tap-highlight-color: transparent; }
    .quiz-wrong-chevron { flex-shrink: 0; color: var(--text-3); transition: transform .2s; }
    .quiz-wrong-row.open .quiz-wrong-chevron { transform: rotate(90deg); }
    .quiz-wrong-sentence { overflow: hidden; max-height: 0; transition: max-height .25s ease; }
    .quiz-wrong-row.open .quiz-wrong-sentence { max-height: 260px; }
    .quiz-wrong-status-row { padding: 10px 0 2px; }
    .quiz-wrong-sentence-inner {
      display: flex; align-items: flex-start; gap: 6px;
      /* padding-top matches .quiz-wrong-status-row's 10px top padding below
         (28 Aug 2026) — was 7px, reading as closer to the divider line above
         than to the status buttons below. */
      margin-top: 7px; padding-top: 10px;
      border-top: 1px solid var(--border);
    }
    .quiz-wrong-sentence-text {
      font-size: 12px; color: var(--text-2); font-style: italic;
      line-height: 1.4;
    }
    .quiz-wrong-sentence-trans {
      font-size: 11px; color: var(--text-3); font-style: normal;
      line-height: 1.4;
    }
    .qw-sent-audio-btn {
      width: 32px; height: 32px; border-radius: var(--r-pill);
      border: none; background: none; color: var(--text-3);
      display: flex; align-items: center; justify-content: center;
      cursor: pointer; transition: color .12s;
      flex-shrink: 0;
      -webkit-tap-highlight-color: transparent;
    }
    .qw-audio-btn {
      width: 32px; height: 32px; border-radius: var(--r-pill);
      border: none; background: none; color: var(--accent);
      display: flex; align-items: center; justify-content: center;
      cursor: pointer; transition: background .12s;
      -webkit-tap-highlight-color: transparent;
      flex-shrink: 0;
    }
    .qw-audio-btn.speaking, .qw-sent-audio-btn.speaking {
      background: var(--accent-light);
      animation: speakPulse .8s ease-in-out infinite;
    }

    /* Vocab count badge on bottom nav icon */
    .nav-vocab-badge {
      position: absolute;
      top: -5px;
      right: -8px;
      background: var(--accent);
      color: var(--white);
      font-family: var(--font-ui);
      font-size: 9px;
      font-weight: 700;
      border-radius: 99px;
      min-width: 15px;
      height: 15px;
      display: inline-flex;
      align-items: center;
      justify-content: center;
      padding: 0 3px;
      pointer-events: none;
    }

    /* Page count chip. margin-top added (31 Jul 2026) — .text-para:last-child
       has margin-bottom:0, and this block previously had only padding-top
       (which sits BELOW the border-top rule), so the rule sat flush against
       the last line of body text with zero gap above it. margin-top now
       gives the rule its own breathing room; padding-top is unchanged and
       still spaces the label below the rule.
       Downsized 40px → 20px (31 Jul 2026, user feedback: the whole row felt
       too large) — kept top/bottom equal per the earlier symmetry fix.
       margin-bottom:-20px pulls the row up into the card's own 40px bottom
       padding so the effective gap below matches the new 20px above,
       without touching .reading-card's padding (shared with Story). */
    .text-meta {
      flex-shrink: 0;
      margin-top: 20px;
      margin-bottom: -26px;
      padding: 14px 0 0;
      border-top: 1px solid var(--border);
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 6px;
    }
    .text-meta-label {
      font-size: 11px;
      color: var(--text-3);
      /* Cancels the trailing letter-spacing (below) added after the last
         character, which otherwise widens the box on the right only and
         makes the centered flex row look visually left-shifted (31 Jul 2026). */
      margin-right: -0.06em;
      font-weight: 300;
      letter-spacing: 0.06em;
    }

    /* ── Settings modal ───────────────────────────────────────────── */
    /* align-items: flex-start (not center, 14 Aug 2026) — the cards below
       collapse/expand, and a vertically-centred flex container recentres
       the whole modal every time the content height changes, so the top
       edge visibly jumped up and down as a card opened. Anchoring the top
       instead means only the bottom edge moves; the modal now only ever
       grows downward.

       display:none/flex swapped for opacity+pointer-events (14 Aug 2026) —
       display can't transition, and the open animation needs one. The
       overlay now stays in flow at all times; pointer-events:none keeps it
       unclickable while closed. visibility does the same job for the
       keyboard: it's swapped instantly on open (so the animation can play
       immediately) but delayed on close until the fade-out finishes,
       otherwise Export/Import etc. would be tabbable while invisible. */
    .settings-overlay {
      position: absolute;
      inset: 0;
      z-index: 200;
      display: flex;
      align-items: flex-start;
      justify-content: center;
      padding: 64px 24px 24px;
      background: rgba(0,0,0,0);
      pointer-events: none;
      visibility: hidden;
      transition: background .18s ease, visibility 0s linear .18s;
    }
    .settings-overlay.open {
      background: rgba(0,0,0,.45);
      pointer-events: auto;
      visibility: visible;
      transition: background .18s ease, visibility 0s linear 0s;
    }
    /* Open animation (14 Aug 2026) — grows out of the gear button's corner
       rather than fading in in place, so tapping the gear reads as the
       modal's origin instead of the modal just appearing. transform-origin
       is a fixed "top right", not measured from the gear's actual
       coordinates — the gear sits in that general corner of the header on
       every screen size this app supports, and a fixed origin is far
       simpler than computing one in JS for a difference that's only
       visible for ~200ms. Three options (this one, a top-edge slide, and a
       centred pop) were mocked up and sent to the user; this was chosen
       specifically for feeling most connected to the gear tap. */
    /* ── Quiz start dialog (27 Aug 2026) ──────────────────────────────
       #quizFilterOverlay stopped being a bottom sheet on the .add-word-*
       classes and became .confirm-overlay/.confirm-modal, the same frame as
       the stories chooser — one popup family instead of two, and a card that
       is simply shorter for Listen & Pick (Difficulty toggles display now,
       not visibility; nothing below it has to stay put in a centred card).
       Everything inside is still the existing quiz controls. */
    .quiz-filter-modal { padding: 20px 18px 18px; }
    .quiz-filter-title {
      display: block;
      text-align: center;
      margin-bottom: 16px;
    }
    /* #quizWordFilter centres its children on the old sheet layout. */
    .quiz-filter-modal #quizWordFilter { align-items: stretch; }
    /* Both labels left-align. Only "Quiz on" would have done so on its own —
       it is a flex item beside Reset and shrinks to its text; "Difficulty"
       spans the card and would keep .quiz-setup-filter-label's centring. */
    .quiz-filter-modal .quiz-setup-filter-label { text-align: left; }
    /* Label left, Reset right — .quiz-setup-filter-label is centre-aligned by
       default, which is meaningless once it is a flex item. */
    .quiz-filter-head {
      display: flex;
      align-items: baseline;
      justify-content: space-between;
      gap: 10px;
    }

    .settings-modal {
      transform-origin: top right;
      transform: scale(.55) translate(10%, -18%);
      opacity: 0;
      transition: transform .22s cubic-bezier(.2,.9,.3,1.1), opacity .16s ease;
    }
    .settings-overlay.open .settings-modal {
      transform: scale(1) translate(0, 0);
      opacity: 1;
    }

    /* ── Generic confirm dialog (replaces native confirm()) ──────────── */
    .confirm-overlay {
      display: none;
      position: absolute;
      inset: 0;
      background: rgba(0,0,0,.45);
      z-index: 250;
      align-items: center;
      justify-content: center;
      /* 40px sides = .quiz-body's 20px + .quiz-setup's 20px, so the modal lines
         up with the quiz-type cards. See .confirm-modal. */
      padding: 24px 40px;
    }
    .confirm-overlay.open { display: flex; }
    .confirm-modal {
      background: var(--white-soft);
      border-radius: var(--r-lg);
      width: 100%;
      /* No max-width, deliberately (27 Aug 2026). The popup's width is set by
         .confirm-overlay's 40px side padding, which is the same 20 + 20 the
         quiz screen's own content sits inside (.quiz-body + .quiz-setup) — so
         a popup is exactly as wide as the quiz-type cards behind it, at every
         screen size, without a number to keep in sync. .shell caps at 390px,
         so this can never run away. One width for the whole family: stories
         chooser, quiz start dialog, story report, confirm dialogs. Add no
         per-popup max-width. */
      box-shadow: 0 8px 32px rgba(0,0,0,.15);
      padding: 22px 20px 18px;
    }
    .confirm-message {
      font-family: var(--font-ui);
      font-size: 15px;
      color: var(--text);
      text-align: center;
      line-height: 1.4;
      margin-bottom: 18px;
    }
    /* An action inside a confirm message, not a link out of it. Only user is
       "Export a backup first" in the delete-account dialog, which runs the
       export and leaves the dialog open. A <button> rather than an <a>: it
       performs an action and navigates nowhere. */
    .confirm-inline-link {
      border: none;
      background: none;
      padding: 0;
      font: inherit;
      color: var(--accent);
      font-weight: 600;
      text-decoration: underline;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .confirm-inline-link:disabled { color: var(--text-2); text-decoration: none; cursor: default; }
    .confirm-inline-done { color: var(--text-2); font-weight: 600; }
    .confirm-actions { display: flex; gap: 10px; }
    .confirm-cancel-btn, .confirm-delete-btn {
      flex: 1;
      padding: 11px;
      border-radius: var(--r-md);
      font-family: var(--font-ui);
      font-size: 14px;
      font-weight: 600;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .confirm-cancel-btn {
      border: 1.5px solid var(--border);
      background: none;
      color: var(--text-2);
    }
    .confirm-delete-btn {
      border: none;
      background: var(--accent);
      color: #fff;
    }

    /* ── Stories chooser (25 Aug 2026; rows restyled 27 Aug 2026) ──────
       Frame is .confirm-overlay/.confirm-modal. The three rows are
       .quiz-type-card — the same component as the quiz-setup cards the popup
       opens from — so only the <button> resets and the disabled state are
       local. The bespoke .stories-chooser-btn (emoji label + description,
       --r-btn corners, Jost 14px) was deleted: it shared no shape, radius or
       typeface with the screen behind it. See DECISIONS.md.
       .stories-chooser-title / -sub stay: the story report dialog uses them. */
    /* Type comes from the shared popup-title rule above; only the centring
       is local (the sheets left-align theirs beside a close button). */
    .stories-chooser-title { text-align: center; }
    .stories-chooser-sub {
      font-family: var(--font-ui);
      font-size: 12.5px;
      color: var(--text-2);
      text-align: center;
      margin: 2px 0 16px;
    }
    /* Scoped to the chooser: the report dialog keeps its -sub line, which
       carries the gap there. */
    .stories-chooser-modal .stories-chooser-title { margin-bottom: 14px; }
    .stories-chooser-list { display: flex; flex-direction: column; gap: 10px; }
    .stories-chooser-card {
      width: 100%;
      /* Tinted, not white (27 Aug 2026): white rows on the modal's --white-soft
         left the grey hairline as the only edge, and read as flat rather than
         tappable — the same complaint that moved the icon buttons onto
         --accent-wash. See DESIGN.md. */
      background: var(--accent-wash);
      border-color: var(--accent-hairline);
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    /* Every row here opens something, so every row carries the chevron the
       Stories door card uses. */
    .stories-chooser-card .quiz-type-card-chev {
      margin-left: auto;
      flex-shrink: 0;
      color: var(--text-3);
    }
    /* Beats .quiz-type-card:active (equal specificity) on source order only —
       that rule is ~900 lines above. Keep these below it. */
    .stories-chooser-card:active {
      border-color: var(--accent);
      background: var(--accent-light);
    }
    /* Saved + Listen Quiz grey out with no saved stories (openStoriesChooser). */
    .stories-chooser-card:disabled { opacity: .42; cursor: default; }
    .stories-chooser-card:disabled:active {
      border-color: var(--accent-hairline);
      background: var(--accent-wash);
    }
    .settings-modal {
      background: var(--white-soft);
      border-radius: var(--r-lg);
      width: 100%;
      max-width: 340px;
      box-shadow: 0 8px 32px rgba(0,0,0,.15);
      overflow: hidden;
    }
    .settings-header {
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 16px 20px;
      border-bottom: 1px solid var(--border);
    }
    .settings-title {
      font-family: var(--font-ui);
      font-size: 16px;
      font-weight: 500;
      color: var(--text);
    }
    .settings-close-btn {
      background: none;
      border: none;
      padding: 4px;
      cursor: pointer;
      color: var(--text-3);
      display: flex;
      align-items: center;
      -webkit-tap-highlight-color: transparent;
    }
    .settings-body {
      padding: 16px;
      display: flex;
      flex-direction: column;
      gap: 12px;
    }
    /* Each topic (Backup & Restore, Storage, About AI Content) is its own
       collapsible card (14 Aug 2026). Cards replaced one flat column with
       no divider between sections (the AI note read as if it belonged to
       Storage); collapsing then replaced always-expanded cards, which
       pushed the modal quite tall on a small phone. Options were mocked up
       and sent to the user before each change. All three start closed —
       toggled by JS (toggleSettingsCard() in core.js), not by <details>,
       so the chevron rotation and the grid-based height animation below
       can be driven off one shared "open" class. */
    .settings-card {
      background: var(--white);
      border: 1px solid var(--border);
      border-radius: var(--r-md);
      overflow: hidden;
    }
    /* About AI Content — same text colours as the other two cards; the red
       icon alone marks it as worth reading (14 Aug 2026, user request).
       Note this puts red on two things on this screen (this icon and
       .settings-import-warn below) — .settings-import-warn is still the
       only one that means "this action is destructive"; this one means
       "read me", not "warning". Revisit if that reads as confusing. */
    .settings-card-info .settings-card-mark { color: var(--error); }
    /* Fixed 46px whether open or closed, so the three collapsed rows line
       up evenly — the ask that drove this rewrite. */
    .settings-card-toggle {
      all: unset;
      box-sizing: border-box;
      display: flex;
      align-items: center;
      gap: 10px;
      width: 100%;
      height: 46px;
      padding: 0 14px;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .settings-card-mark { flex-shrink: 0; color: var(--accent); }
    .settings-card-label {
      flex: 1;
      text-align: left;
      font-family: var(--font-ui);
      font-size: 13px;
      font-weight: 600;
      color: var(--text);
    }
    /* Account sign-in state, visible while the card is collapsed. Grey until
       the session check finishes — red at that moment would fire on every
       launch and correct itself a beat later, which teaches people to ignore
       it. Sized to match the word-status dots in the Vocab list so the app
       has one idea of what a status dot looks like. */
    .settings-card-dot {
      display: inline-block;
      width: 8px;
      height: 8px;
      border-radius: 50%;
      margin-left: 5px;
      vertical-align: 1px;
      background: var(--text-3);
    }
    /* The state in words, beside the dot. Grey and lighter than the label:
       the dot is the glance, this is the confirmation, and two coloured things
       competing in one header would read as an alert. */
    /* The dash is a separator, not part of the title — it inherits the label's
       bold dark styling otherwise and reads as "Account-" rather than as a
       divider between the name and its state. */
    .settings-card-sep {
      font-weight: 400;
      color: var(--text-3);
    }
    .settings-card-state {
      font-size: 12px;
      font-weight: 400;
      color: var(--text-3);
    }
    .settings-card-dot.is-signed-in  { background: var(--success); }
    .settings-card-dot.is-signed-out { background: var(--error); }
    .settings-card-chev {
      flex-shrink: 0;
      color: var(--text-3);
      transition: transform .18s ease;
    }
    .settings-card.open .settings-card-chev { transform: rotate(180deg); }
    /* grid-template-rows 0fr -> 1fr animates height without knowing the
       content's actual height up front (a plain height/max-height
       transition would need that, or would jump). .settings-card-panel-inner
       carries the padding and clips overflow while the row is animating;
       the panel itself carries none, or the padding would show even at
       zero height. */
    .settings-card-panel {
      display: grid;
      grid-template-rows: 0fr;
      transition: grid-template-rows .2s ease;
    }
    .settings-card.open .settings-card-panel { grid-template-rows: 1fr; }
    .settings-card-panel-inner {
      overflow: hidden;
      display: flex;
      flex-direction: column;
      gap: 10px;
      padding: 0 14px;
    }
    .settings-card.open .settings-card-panel-inner { padding: 0 14px 16px; }
    .settings-desc {
      font-family: var(--font-ui);
      font-size: 13px;
      color: var(--text-2);
      line-height: 1.5;
      margin: 0;
    }
    /* Foot of the Settings list — the only in-app route to the privacy policy. */
    .settings-legal {
      font-family: var(--font-ui);
      font-size: 12px;
      text-align: center;
      margin: 4px 0 0;
    }
    .settings-legal a {
      color: var(--text-2);
      text-decoration: underline;
      text-underline-offset: 2px;
    }
    .settings-action-btn {
      width: 100%;
      padding: 12px;
      border-radius: var(--r-md);
      font-family: var(--font-ui);
      font-size: 14px;
      font-weight: 500;
      cursor: pointer;
      border: 1.5px solid var(--accent);
      color: var(--accent);
      background: var(--accent-light);
      transition: background .15s, color .15s;
      -webkit-tap-highlight-color: transparent;
      text-align: center;
    }
    .settings-action-btn:active {
      background: var(--accent);
      color: var(--white);
    }
    /* ── Send Feedback card (Aug 2026) ──────────────────────────────
       The segmented control, the description, the Send button and the error
       line are all existing shared classes (.quiz-segmented, .settings-desc,
       .settings-action-btn, .settings-import-warn). Only the textarea and its
       meta row are new — there was no multi-line input anywhere in the app
       before this. */
    /* .settings-card-panel-inner is already a flex column, but these wrappers
       sit between it and the rows, so they need their own — otherwise the rows
       stack with no gap. Toggled to 'flex'/'none' by _fbRender(), never to ''. */
    .feedback-block {
      display: flex;
      flex-direction: column;
      gap: 10px;
    }
    /* Borrows .add-word-input's border, radius, surface and focus colour so the
       app has one idea of what a text field looks like — but not its font: that
       one is var(--font-mark) at 18px because it holds a single Danish word for
       display. This holds a paragraph of the user's own prose, which is UI text.
       resize:vertical, so a long report can be seen while writing without the
       user being able to drag it wider than the card. */
    .feedback-input {
      width: 100%;
      min-height: 104px;
      resize: vertical;
      border: 1.5px solid var(--border);
      border-radius: var(--r-sm);
      background: var(--white-soft);
      font-family: var(--font-ui);
      font-size: 14px;
      font-weight: 400;
      line-height: 1.5;
      color: var(--text);
      padding: 10px 12px;
      outline: none;
      -webkit-appearance: none;
      transition: border-color .15s;
    }
    .feedback-input:focus { border-color: var(--accent); }
    .feedback-input::placeholder { color: var(--text-3); }
    /* Negative top margin pulls this against the textarea it describes, out of
       the 10px row rhythm — it is a caption on the field above, not a row of
       its own. */
    .feedback-meta {
      display: flex;
      align-items: baseline;
      gap: 8px;
      margin-top: -2px;
    }
    /* Disclosure of exactly what is attached, so nothing is collected out of
       sight. Small and grey because it is a footnote, but it must stay legible
       — do not drop it below 11px. */
    .feedback-context {
      flex: 1;
      font-family: var(--font-ui);
      font-size: 11px;
      color: var(--text-3);
      line-height: 1.4;
      margin: 0;
    }
    /* Hidden until 1700 characters (feedback.js) — a counter on a two-line bug
       report is noise. */
    .feedback-count {
      flex-shrink: 0;
      font-family: var(--font-ui);
      font-size: 11px;
      color: var(--text-3);
    }
    /* The success line. Green, and the only green text in Settings — the
       failure case already has .settings-import-warn's red, and a send that
       reported success in the same grey as the disclosure footnote below it
       would be missed. */
    .feedback-ok {
      font-family: var(--font-ui);
      font-size: 13px;
      color: var(--success);
      line-height: 1.5;
      margin: 0;
    }

    /* Account deletion — deliberately NOT a .settings-action-btn (20 Aug 2026).
       It was one: a red full-width button, identical in size and shape to Sign
       out 10px above it. One thumb covers both, so aiming for Sign out could
       open the delete dialog. Colour alone was doing all the work of saying
       "this one is different", and colour does not shrink a tap target.

       The row carries the divider as border-top rather than a separate <hr>,
       so hiding the row when signed out takes the line with it.
       **Keep the row and the link as two elements**: the border must span the
       card, the tap target must not. */
    .settings-danger-row {
      border-top: 1px solid var(--border);
      padding-top: 12px;
      text-align: center;
    }
    /* Type scale borrowed from .settings-legal — the quiet foot-of-screen
       voice, which is the register this belongs in. Red, because the action
       is still destructive; small, because it should not compete with the
       buttons above. inline-flex so the warning glyph sits on the text
       baseline without a vertical-align nudge — and so the tap target is the
       icon plus the words, nothing wider. */
    .settings-danger-link {
      display: inline-flex;
      align-items: center;
      gap: 6px;
      background: none;
      border: 0;
      padding: 4px 6px;
      font-family: var(--font-ui);
      font-size: 12px;
      color: var(--error);
      text-decoration: underline;
      text-underline-offset: 2px;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .settings-danger-link:active { opacity: .55; }
    /* Red: this warns about an irreversible destructive action (import
       wipes all local data) — kept louder than the quiet About-AI card
       below it, which is informational rather than a warning (EU AI Act
       Art. 50 — LEGAL.md §5). --error is reserved for this element alone. */
    .settings-import-warn {
      display: flex;
      align-items: flex-start;
      gap: 7px;
      margin: 0;
      padding: 10px 12px;
      background: var(--error-wash);
      border: 1px solid var(--error-hairline);
      border-radius: var(--r-sm);
      font-family: var(--font-ui);
      font-size: 12px;
      color: var(--error);
      line-height: 1.5;
      text-align: left;
    }

    /* ── Stories sheet: New button, loading, draft card (23 Aug 2026) ──
       Added when the Story screen was deleted and this sheet became the only
       story surface. See DECISIONS.md. */

    /* "New" — the generate button, in the sheet header next to the view
       toggle. It is the only primary action in the sheet, hence the filled
       accent treatment against the toggle's plain outline. */
    .stories-new-btn {
      display: inline-flex;
      align-items: center;
      gap: 5px;
      padding: 6px 11px;
      border: 1px solid var(--accent);
      border-radius: var(--r-pill);
      background: var(--accent);
      color: var(--white);
      font-family: var(--font-ui);
      font-size: 12px;
      font-weight: 500;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .stories-new-btn:active:not(:disabled) { opacity: .85; }
    .stories-new-btn:disabled {
      background: var(--surface);
      border-color: var(--border);
      color: var(--text-3);
      font-weight: 400;
      cursor: default;
    }

    /* Generation happens inside the sheet, so the fox replaces the deck rather
       than taking over a screen. Centred in the same box the cards use. */
    .stories-loading {
      flex: 1;
      min-height: 0;
      display: flex;
      align-items: center;
      justify-content: center;
      padding: 12px 16px 16px;
    }
    /* The bottom margin is doing real work: .loading-hint carries no margin of
       its own (it is shared with the OCR loader, which has nothing above it),
       so without this the word chips sit right on top of "It may take a
       moment". Space it here, not on .loading-hint. */
    .story-loading-chips {
      display: flex;
      flex-wrap: wrap;
      gap: 6px;
      justify-content: center;
      max-width: 260px;
      margin: 14px auto 18px;
    }

    /* A generated story is a DRAFT until saved: gold edge instead of the
       accent→gold gradient, so "not yet yours" reads at a glance. */
    .stories-card.is-draft { border-color: var(--gold); }
    .stories-card.is-draft::before { background: var(--gold); }
    .stories-card-draft-badge {
      font-family: var(--font-ui);
      font-size: 10.5px;
      font-weight: 600;
      letter-spacing: .04em;
      color: #8A6516;
      background: var(--gold-tint);
      border-radius: var(--r-pill);
      padding: 2px 8px;
    }

    /* One corner, one action: the bin on a saved story, Save on a draft.
       Deliberately the same slot and the same size — the card's state is what
       changes, not the layout. */
    .stories-card-corner {
      width: 28px;
      height: 28px;
      flex-shrink: 0;
      border: none;
      border-radius: var(--r-pill);
      background: transparent;
      color: var(--text-3);
      display: inline-flex;
      align-items: center;
      justify-content: center;
      cursor: pointer;
      -webkit-tap-highlight-color: transparent;
    }
    .stories-card-corner:active { background: var(--error-wash); color: var(--error); }
    .stories-card-corner.save {
      background: var(--gold-tint);
      color: #8A6516;
    }
    .stories-card-corner.save:active { background: var(--gold-press); color: #6E4E0F; }

    .stories-empty-title {
      font-family: var(--font-ui);
      font-size: 15px;
      font-weight: 500;
      color: var(--text-2);
      margin: 10px 0 6px;
    }
    .stories-empty svg { opacity: .35; }

    /* ── The Stories tile (23 Aug 2026; stopped being a "door" 13 Sep 2026)
       It used to open its chooser on tap and never take .selected, with a
       chevron saying "this goes somewhere". It now selects like the Match tile
       and the Play button proceeds, so the chevron is gone and the class is
       only a hook for what little is still specific to it. */
    .quiz-type-card-door:active { background: var(--accent-wash); }
    .quiz-story-count {
      display: inline-block;
      min-width: 18px;
      padding: 1px 6px;
      margin-left: 6px;
      border-radius: var(--r-pill);
      background: var(--accent-light);
      color: var(--accent);
      font-family: var(--font-ui);
      font-size: 10.5px;
      font-weight: 700;
      line-height: 16px;
      text-align: center;
      vertical-align: 1px;
    }

    /* ── Stories sheet limit note ─────────────────────────────────── */
    .stories-sheet-limit-note {
      font-family: var(--font-ui);
      font-size: 11px;
      color: var(--text-3);
      margin-top: 3px;
    }
    /* At the 30-story cap, saving overwrites the oldest story — the red
       matches the app's other --error usage, not a new color. */
    .stories-sheet-limit-note.at-limit {
      color: var(--error);
    }


    /* ── Screen transitions ────────────────────────────────────────── */
    @keyframes screenFadeIn {
      from { opacity: 0; }
      to   { opacity: 1; }
    }
    .screen-fade-in {
      animation: screenFadeIn 250ms ease forwards;
    }
    @keyframes screenFadeOut {
      from { opacity: 1; }
      to   { opacity: 0; }
    }
    .screen-fade-out {
      animation: screenFadeOut 150ms ease forwards;
      pointer-events: none;
    }

    /* ══ FIRST-RUN INTRO (Sep 2026) ═══════════════════════════════════
       One-time welcome screen, modelled on Granola's signed-out landing:
       full-bleed photo, wordmark at the top, one large serif line, the
       actions at the bottom. Two slides cross-fade — see the markup and
       the inline script at the end of .shell in index.html.

       It is opaque and covers .shell entirely, bottom nav included, so
       nothing underneath needs hiding while it is up. */
    .intro {
      position: absolute;
      inset: 0;
      z-index: 60;
      display: flex;
      flex-direction: column;
      /* Only ever seen for the moment before the first photo decodes;
         taken from the dark end of that photo so there is no white flash. */
      background: #1B4A5A;
      transition: opacity .3s ease;
    }
    .intro.leaving { opacity: 0; pointer-events: none; }
    /* REQUIRED. The markup carries `hidden`, and the browser's own
       [hidden] { display: none } is a UA rule with lower specificity than
       the class rule above — so display:flex wins and the screen paints for
       everyone, including users who have already dismissed it. When that
       happened the inline script had already returned at its localStorage
       check, so the visible screen had no timer and no introGo(): slides
       frozen and both buttons dead. Shipped exactly that way once.
       Never give .intro a display value without keeping this line. */
    .intro[hidden] { display: none; }

    /* 50% 35% is NOT a free choice — it must stay identical to the
       .shell-bg img rule, because the first slide is the same file Home
       shows on day 1 (INTRO in index.html). Any difference here makes the
       photo jump at the moment the intro screen dissolves. On a 390x844
       shell the crop is a no-op (image and shell share an aspect ratio);
       it only bites on other phone shapes, which is exactly where an
       untested mismatch would show up. */
    .intro-bg {
      position: absolute;
      inset: 0;
      width: 100%;
      height: 100%;
      object-fit: cover;
      object-position: 50% 35%;
      opacity: 0;
      transition: opacity 1.2s ease;
    }
    .intro-bg.on { opacity: 1; }

    /* Legibility pass for the bottom third, doing two jobs at once: white
       pills over the warm end of a gradient are weak, and the streetlight
       photo has a pole running the full height of the frame that would
       otherwise show through the gaps between the buttons.
       Keep it soft — a heavier fade turns the photo into a backdrop and
       loses the reason for choosing these images. */
    .intro::after {
      content: '';
      position: absolute;
      left: 0; right: 0; bottom: 0;
      height: 46%;
      pointer-events: none;
      background: linear-gradient(to bottom,
                    rgba(255,255,255,0) 0%,
                    rgba(255,255,255,0.60) 100%);
    }

    /* z-index:1 on the three content rows puts them above ::after, which
       paints above the images purely on source order. */
    /* 44px, not the 26px this shipped with (1 Sep 2026). The mark is a lens
       with "Aa" inside it plus a sparkle — at 26px all of that collapsed
       into a smudge. It is not a single-glyph mark like the one this screen
       was modelled on, so it cannot be worn small; 40px is about the floor
       at which the "Aa" resolves. */
    .intro-brand {
      position: relative;
      z-index: 1;
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 10px;
      padding: calc(env(safe-area-inset-top, 0px) + 26px) 20px 0;
    }
    /* The mark's outer ring is navy (#2A4F6E) and its glass fill is near
       white, so it loses one edge or the other depending on the slide — the
       same problem the wordmark has, and it takes the same answer. A drop
       shadow, not a colour change: the mark is brand artwork and must not be
       recoloured per background. */
    .intro-brand img {
      width: 44px;
      height: 44px;
      filter: drop-shadow(0 1px 5px rgba(0, 0, 0, 0.35));
    }
    /* Same colour + shadow as .wp-word on Home, deliberately. Dark ink was
       the first attempt and it worked on the two pale slides, then vanished
       into the top of the moon photo, where the sky is nearly black. The
       wordmark sits at the very top of the frame, which is the darkest band
       of every dusk gradient, so it cannot be tuned per photo the way the
       centred headline can. This treatment is the one already proven across
       all fifteen Home wallpapers, pale and dark alike — and it makes the
       wordmark and Home's word-of-the-day read as the same object. */
    .intro-brand span {
      font-family: var(--font-mark);
      font-size: 27px;
      font-weight: 600;
      color: #F4F1EA;
      text-shadow: 0 1px 4px rgba(0,0,0,0.45), 0 0 12px rgba(0,0,0,0.25);
    }

    /* A grid, not a stack of blocks: both lines occupy the same cell so
       the swap is a true cross-fade and neither line moves. */
    /* Biased BELOW centre, not centred (1 Sep 2026). Every slide puts its
       subject in the upper half, and the headline is centred in a box that
       grows in both directions — so on a narrow screen, where the copy wraps
       to three lines instead of two, the extra line grows upward into the
       balloon. Verified at 320x720: the balloon landed between "any" and
       "Danish". The padding pushes the block down so extra lines grow into
       empty sky instead. It is asymmetric on purpose; do not "fix" it back
       to place-items: center. */
    .intro-lines {
      position: relative;
      z-index: 1;
      flex: 1;
      display: grid;
      place-items: center;
      padding: 7% 26px 0;
    }
    .intro-line {
      grid-area: 1 / 1;
      margin: 0;
      text-align: center;
      font-family: var(--font-mark);
      /* Clamped, not fixed at 34px (1 Sep 2026). The headline is centred in
         a box that grows both ways, so a third wrapped line grows UPWARD into
         the photo's subject. At a flat 34px on a 320px-wide screen the copy
         went to three lines and the balloon landed between "any" and
         "Danish". Scaling with the viewport keeps every line in this set to
         two lines from 320px up, which is what actually prevents the
         collision — the 7% bias on .intro-lines above only softens it.
         34px is the cap, so nothing changes at 390px and wider. */
      font-size: clamp(28px, 8.8vw, 34px);
      line-height: 1.18;
      font-weight: 500;
      color: #12222E;
      opacity: 0;
      transition: opacity .9s ease;
    }
    .intro-line.on { opacity: 1; }

    .intro-actions {
      position: relative;
      z-index: 1;
      display: flex;
      flex-direction: column;
      gap: 8px;
      padding: 0 20px calc(env(safe-area-inset-bottom, 0px) + 20px);
    }
    .intro-btn-google {
      display: flex;
      align-items: center;
      justify-content: center;
      gap: 10px;
      width: 100%;
      min-height: 54px;
      border: none;
      border-radius: var(--r-pill);
      background: var(--white);
      color: var(--text);
      font-family: var(--font-ui);
      font-size: 16px;
      font-weight: 500;
      box-shadow: 0 2px 10px rgba(0,0,0,0.10);
      cursor: pointer;
    }
    .intro-btn-google:active { background: #F1F1F1; }
    .intro-btn-skip {
      min-height: 44px;
      border: none;
      background: none;
      cursor: pointer;
      font-family: var(--font-ui);
      font-size: 14px;
      color: #24404F;
    }
    .intro-legal {
      margin: 2px 0 0;
      text-align: center;
      font-family: var(--font-ui);
      font-size: 12px;
      line-height: 1.45;
      color: #3A5361;
    }
    .intro-legal a { color: inherit; }
