
/* Light is the default palette; dark overrides via prefers-color-scheme
   below. This restores automatic system-based switching -- Duke had this
   turned off permanently on 2026-07-14 after it felt like it "reverted to
   bright" unexpectedly during the day, but asked on 2026-07-15 to bring
   automatic switching back now that a matching warm-light palette exists
   (built for nihaolingo.com's public homepage, reused here so both apps
   look like one product in either theme). */
:root {
  /* Tells the browser which theme to paint native controls in. Without it the
     UA draws checkboxes, scrollbars, autofill and date pickers in light mode
     on top of the dark palette -- the Level Check consent box was a stark
     white square on a dark card because of this (Duke, 2026-07-29). */
  color-scheme: light;
  /* ── TYPE SYSTEM — the only place a font family is named ──────────────
     Identical to the public homepage (cal-diy/homepage/index.html): serif
     display + Inter body. The app used to run two competing systems --
     Fredoka/Nunito for the test-taking flow, Source Serif/Archivo for the
     dashboards -- so a student saw two different typefaces on one screen
     (Duke, 2026-07-31). One system now, everywhere.

     --font-sans was Archivo until 2026-07-31: Archivo has no Cyrillic in
     Google Fonts at all, so every Russian string on the site (which is
     most of them, for this audience) was silently falling through to
     whatever sans the visitor's OS shipped -- the exact per-device drift
     the Noto Sans SC fix above exists to prevent, just for the other half
     of the app's text. Inter covers Latin + Cyrillic in one family, so
     Latin and Russian UI text now render identically everywhere.

     Rule for new CSS: never write a family name below this block. Use a
     token. If a token is wrong for your case, fix the token, not the rule.
       --font-display  headings, wordmark, scores, stamps, badges
       --font-sans     body copy, UI, labels, buttons, pinyin
       --font-hanzi    Chinese in UI/answers (sans)
       --font-hanzi-serif  Chinese set as display (cards, proverbs)
       --font-mono     code / IDs                                       */
  --font-display: 'Source Serif 4', 'Noto Serif SC', Georgia, serif;
  --font-sans: 'Inter', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, "Noto Sans SC", "Noto Sans", sans-serif;
  --font-hanzi: 'Noto Sans SC', "PingFang SC", "Microsoft YaHei", -apple-system, sans-serif;
  --font-hanzi-serif: 'Noto Serif SC', 'Source Serif 4', Georgia, serif;
  --font-mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  /* ── TYPE SCALE ──────────────────────────────────────────────────────
     Added 2026-08-05, after Duke: "in only two scrolls the font style
     changes drastically, such mismatches are everywhere."

     He was right, and the cause was not the families -- those were fine
     and had been fine since 2026-07-31. The cause was that the families
     were the ONLY thing tokenised. Size, weight and tracking were still
     decided rule by rule, 663 times, so two elements doing the same job
     landed on different values by accident. The state of it when this
     block was written:

       77 distinct font sizes, 14 of them between 13px and 16px
          (13.1, 13.3, 13.4, 13.6, 13.8, 14.1, 14.4, 14.7, ...) --
          steps of 0.2px, which nobody chose and nobody can see, but
          which guarantee that nothing ever lines up with anything.
       11 sizes and 3 families for ONE device, the caps overline.
        7 sizes and 2 families for the round avatar initial.
        4 sizes and 2 families for a form label.

     A size is not a free choice any more. Pick the ROLE the text plays
     and take the role's size with it. If no role fits, the honest fix is
     a new role here, not a new number in a component.                  */
  --t-eyebrow: 0.72rem;   /* 11.5px -- caps overline, the "HSK"/"WORDS" device */
  --t-caption: 0.8rem;    /* 12.8px -- hints, fine print, meta, badges         */
  --t-label: 0.85rem;     /* 13.6px -- form labels and other chrome            */
  --t-body-sm: 0.9rem;    /* 14.4px -- secondary prose, list subtitles         */
  --t-body: 1rem;         /* 16px   -- prose                                   */
  --t-lead: 1.15rem;      /* 18.4px -- the one line a figure-led screen gets   */
  --t-title-sm: 1.05rem;  /* 16.8px -- row and list-item titles                */
  --t-title: 1.35rem;     /* 21.6px -- card and panel titles                   */
  --t-title-lg: clamp(1.5rem, 3.4vw, 1.9rem); /* 24-30px -- screen titles.
       Fluid rather than fixed because the components that joined this role
       each carried their own @media shrink for phones (1.6rem here, 1.7rem
       there, 1.85rem somewhere else). One clamp replaces all of them and
       cannot drift out of step with the others.                          */
  --t-hero: clamp(1.75rem, 4vw, 2.5rem); /* page heroes                        */

  --primary: #ff8563;
  --primary-dark: #ff6b4a;
  --primary-light: #ffe3d6;
  --green: #4ad991;
  --green-dark: #2fa46c;
  --green-light: #dcf7e9;
  --gold: #f0b429;
  --gold-dark: #c98f1c;
  --gold-light: #fdf1cf;
  --bg: #fefaf6;
  --card: #ffffff;
  --text: #1d1712;
  --muted: #8a7862;
  --border: #ede1d3;
  --accent: #ff8177;
  --accent-light: #ffe0dc;
  /* Darkened accent for white-on-red badges and struck-answer text -- the
     sibling of --green-dark / --primary-dark. */
  --accent-dark: #e5564a;
  --danger-wash: #fde8e6;
  --danger-wash-hover: #fbdad7;
  --surface-2: #f7f0e6;
  --surface-2-hover: #efe4d4;
  --shadow: 0 10px 30px rgba(60, 40, 20, 0.12);
  --grid-line: rgba(255, 133, 99, 0.06);
  --coral-glow: rgba(255, 133, 99, 0.12);
  --stage-bg: #fdf6ec;
  --zi-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%20100%20100%22%3E%3Crect%20x%3D%222%22%20y%3D%222%22%20width%3D%2296%22%20height%3D%2296%22%20fill%3D%22none%22%20stroke%3D%22white%22%20stroke-width%3D%224%22/%3E%3Cline%20x1%3D%2250%22%20y1%3D%222%22%20x2%3D%2250%22%20y2%3D%2298%22%20stroke%3D%22white%22%20stroke-width%3D%222.5%22%20stroke-dasharray%3D%226%206%22/%3E%3Cline%20x1%3D%222%22%20y1%3D%2250%22%20x2%3D%2298%22%20y2%3D%2250%22%20stroke%3D%22white%22%20stroke-width%3D%222.5%22%20stroke-dasharray%3D%226%206%22/%3E%3C/svg%3E");
  --stamp-mask: url("data:image/svg+xml,%3Csvg%20xmlns%3D%22http%3A//www.w3.org/2000/svg%22%20viewBox%3D%220%200%20200%20200%22%3E%3Ccircle%20cx%3D%22100%22%20cy%3D%22100%22%20r%3D%2294%22%20fill%3D%22none%22%20stroke%3D%22white%22%20stroke-width%3D%225%22/%3E%3Ccircle%20cx%3D%22100%22%20cy%3D%22100%22%20r%3D%2280%22%20fill%3D%22none%22%20stroke%3D%22white%22%20stroke-width%3D%222%22%20stroke-dasharray%3D%223%205%22/%3E%3C/svg%3E");
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    color-scheme: dark;
    --primary: #ff8563;
    --primary-dark: #ff6b4a;
    --primary-light: #402a22;
    --green: #4ad991;
    --green-dark: #2fa46c;
    --green-light: #1c3226;
    --gold: #f0b429;
    --gold-dark: #c98f1c;
    --gold-light: #3a2c12;
    --bg: #1d1712;
    --card: #27201a;
    --text: #f3e9dd;
    --muted: #b3a08c;
    --border: #3a2f26;
    --accent: #ff8177;
    --accent-light: #402420;
    --accent-dark: #ff9d95;
    --danger-wash: #402420;
    --danger-wash-hover: #4d2a26;
    --surface-2: #2c241d;
    --surface-2-hover: #362c23;
    --shadow: 0 10px 34px rgba(0, 0, 0, 0.4);
    --grid-line: rgba(255, 232, 209, 0.13);
    --coral-glow: rgba(255, 133, 99, 0.14);
    --stage-bg: #140f0b;
  }
}

/* ---------- Explicit "Dark" override (Settings > Appearance) ----------
   Unconditional mirrors of every @media (prefers-color-scheme: dark) rule
   above, so picking Dark in Settings forces the palette even when the OS
   itself is in light mode. Generated from those blocks -- keep declarations
   in sync if you edit one side by hand, or better, edit both together. */
:root[data-theme="dark"] {
    color-scheme: dark;
    --primary: #ff8563;
    --primary-dark: #ff6b4a;
    --primary-light: #402a22;
    --green: #4ad991;
    --green-dark: #2fa46c;
    --green-light: #1c3226;
    --gold: #f0b429;
    --gold-dark: #c98f1c;
    --gold-light: #3a2c12;
    --bg: #1d1712;
    --card: #27201a;
    --text: #f3e9dd;
    --muted: #b3a08c;
    --border: #3a2f26;
    --accent: #ff8177;
    --accent-light: #402420;
    --accent-dark: #ff9d95;
    --danger-wash: #402420;
    --danger-wash-hover: #4d2a26;
    --surface-2: #2c241d;
    --surface-2-hover: #362c23;
    --shadow: 0 10px 34px rgba(0, 0, 0, 0.4);
    --grid-line: rgba(255, 232, 209, 0.13);
    --coral-glow: rgba(255, 133, 99, 0.14);
    --stage-bg: #140f0b;
  }
:root[data-theme="dark"] .pr-stamp .pr-rays { --pr-ray: rgba(240, 180, 41, 0.06); }
:root[data-theme="dark"] {
    --pr-grid-soft: rgba(255, 232, 209, 0.032); --pr-vignette: rgba(0, 0, 0, 0.62);
    /* On the dark stage the problem inverts: the gold has to come up, not
       down, and the quiet text with it. */
    --pr-eyebrow: #f3c04a; --pr-quiet: #b7a68f;
  }
:root[data-theme="dark"] .pr-fin-glow, :root[data-theme="dark"] .pr-glow {
    background:
      radial-gradient(52% 42% at 50% 34%, rgba(240, 180, 41, 0.13) 0%, rgba(240, 180, 41, 0) 70%),
      radial-gradient(64% 54% at 50% 62%, rgba(225, 139, 112, 0.1) 0%, rgba(225, 139, 112, 0) 72%);
  }
:root[data-theme="dark"] .pr-fin-tier { background-image: linear-gradient(168deg, var(--text) 8%, #b08a5f 96%); }
:root[data-theme="dark"] .pr-artifact::before { border-color: rgba(240, 180, 41, 0.16); }
:root[data-theme="dark"] .pr-artifact {
    background: linear-gradient(155deg, #453729 0%, #372c20 54%, #2b2118 100%);
    border-color: rgba(240, 180, 41, 0.34);
    box-shadow:
      0 1px 0 rgba(255, 225, 175, 0.14) inset,
      0 -1px 0 rgba(0, 0, 0, 0.3) inset,
      var(--sx, 0px) var(--sy, 26px) 50px -18px rgba(0, 0, 0, 0.7),
      var(--sx, 0px) 6px 14px -8px rgba(0, 0, 0, 0.45);
  }
:root[data-theme="dark"] .pr-artifact-gloss {
    background: radial-gradient(180px circle at var(--mx, 50%) var(--my, 0%),
                                rgba(255, 226, 178, 0.13), transparent 62%);
  }
:root[data-theme="dark"] .pr-artifact-sheen::before {
    background: linear-gradient(90deg, rgba(255, 231, 190, 0) 0%,
                                       rgba(255, 231, 190, 0.2) 50%,
                                       rgba(255, 231, 190, 0) 100%);
  }
:root[data-theme="dark"] .pr-artifact-chop { border-color: var(--gold); color: var(--gold); opacity: 0.45; }
:root[data-theme="dark"] .pr-deck-on .pr-stamp { filter: drop-shadow(0 14px 22px rgba(0, 0, 0, 0.5)); }
:root[data-theme="dark"] .pr-deck-on .pr-badges .confidence { background: rgba(255, 255, 255, 0.05); }
:root[data-theme="dark"] .pr-deck-on .pr-wheel-wrap::before {
    background: linear-gradient(155deg, #453729 0%, #372c20 54%, #2b2118 100%);
    border-color: rgba(240, 180, 41, 0.32);
    box-shadow:
      0 1px 0 rgba(255, 225, 175, 0.12) inset,
      0 -1px 0 rgba(0, 0, 0, 0.3) inset,
      0 26px 50px -20px rgba(0, 0, 0, 0.68);
  }
:root[data-theme="dark"] .pr-deck-on .pr-wheel-wrap::after { border-color: rgba(240, 180, 41, 0.16); }
:root[data-theme="dark"] .pr-deck-on .pr-wheel-icon, :root[data-theme="dark"] .pr-deck-on .pr-wheel-center {
    background: linear-gradient(165deg, #4a3b2c 0%, #3a2e22 100%);
    box-shadow: 0 3px 10px rgba(0, 0, 0, 0.45);
  }
:root[data-theme="dark"] .pr-deck-on .pr-wheel-tracks path { fill: rgba(255, 235, 200, 0.09); }
:root[data-theme="dark"] .pr-deck-on .pr-wheel-rings circle { stroke: rgba(255, 235, 200, 0.14); }
:root[data-theme="dark"] .pr-deck-on .pr-unlock-strip {
    background: linear-gradient(165deg, #4a3b2c 0%, #3a2e22 100%);
    border-color: rgba(240, 180, 41, 0.3);
    box-shadow: 0 14px 30px -18px rgba(0, 0, 0, 0.7);
  }
:root[data-theme="dark"] .mt-card { box-shadow: 0 10px 26px -20px rgba(0, 0, 0, 0.8); }
:root[data-theme="dark"] .mt-card:hover { box-shadow: 0 18px 34px -22px rgba(0, 0, 0, 0.9); }
:root[data-theme="dark"] .qr-pip.ok { background: rgba(74, 217, 145, 0.14); }
:root[data-theme="dark"] .qr-pip.bad { background: rgba(217, 141, 120, 0.14); }
:root[data-theme="dark"] .qr-pip.open { background: rgba(240, 180, 41, 0.14); }
:root[data-theme="dark"] .qr-q.is-ok   .qr-band { background: rgba(74, 217, 145, 0.12); }
:root[data-theme="dark"] .qr-q.is-bad  .qr-band { background: rgba(217, 141, 120, 0.13); }
:root[data-theme="dark"] .qr-q.is-open .qr-band { background: rgba(240, 180, 41, 0.12); }
:root[data-theme="dark"] .qr-panel.is-ok, :root[data-theme="dark"] .qr-panel.is-key { background: rgba(74, 217, 145, 0.11); }
:root[data-theme="dark"] .qr-panel.is-bad { background: rgba(217, 141, 120, 0.12); }
:root[data-theme="dark"] .qr-note { background: rgba(240, 180, 41, 0.1); }
:root[data-theme="dark"] .qr-pair { background: rgba(255, 255, 255, 0.06); }
:root[data-theme="dark"] .qr-note { background: rgba(240, 180, 41, 0.1); }
:root[data-theme="dark"] .qr-q { box-shadow: 0 10px 26px -22px rgba(0, 0, 0, 0.8); }
:root[data-theme="dark"] .st-step[data-celebrate] .st-step-t { color: var(--green); }
:root[data-theme="dark"] .st-step[data-celebrate] .st-step-dot { background: var(--green-light); color: var(--green); }

* { box-sizing: border-box; }

/* Form controls do NOT inherit the page font on their own -- the browser
   gives every button, input, select and textarea its own UA font (Arial in
   Chrome). Any control whose own rule forgets `font-family: inherit` silently
   drops out of the type system, which is how .nc-password-toggle ended up as
   the one Arial element on the settings page. Opting them all in here means a
   new control is in the system by default instead of by remembering.
   Deliberately family-only: inheriting font-size here too would resize every
   control that currently relies on the UA default, which is a layout change,
   not a font fix. */
button, input, select, textarea, optgroup {
  font-family: inherit;
}

body {
  margin: 0;
  font-family: var(--font-sans);
  color: var(--text);
  line-height: 1.5;
  min-height: 100vh;
  /* Same practice-grid texture as the test-taking stage, just quieter -- so
     the admin dashboard reads as the same product instead of a plain
     leftover default underneath a redesigned test-taking flow. */
  background:
    repeating-linear-gradient(90deg, var(--grid-line) 0 1px, transparent 1px 56px),
    repeating-linear-gradient(0deg, var(--grid-line) 0 1px, transparent 1px 56px),
    var(--bg);
  background-attachment: fixed;
}

/* Test-taking + result screens get a designed stage instead of a flat page:
   a soft dark tone, an ambient practice-grid texture at a bigger scale than
   the card's own, and the form centered instead of pinned to the top. */
body.test-stage {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 40px 16px;
  background:
    radial-gradient(ellipse 900px 500px at 50% 0%, var(--coral-glow), transparent 65%),
    repeating-linear-gradient(90deg, var(--grid-line) 0 1px, transparent 1px 56px),
    repeating-linear-gradient(0deg, var(--grid-line) 0 1px, transparent 1px 56px),
    var(--stage-bg);
  background-attachment: fixed;
}

/* The 田字格 grid stays. It came off for one build when the cards dissolved,
   on the Level Check's precedent -- wrong call here: the practice grid is the
   product's main theme, not incidental page texture (Duke, 2026-07-31). The
   card's own finer 34px grid stays off, so what shows is one grid rather than
   the two stacked layers the frames used to create. */

body.test-stage .container {
  padding: 0;
  margin: 0;
  width: 100%;
}

/* ---- Take page with the shared nav bar (logged-in students) ----
   test-stage centres the page as a flex row, which would set the bar down
   *beside* the card rather than above it. Rather than fight that with auto
   margins -- the progress strip and the card are two siblings that have to
   stay together while the pair centres -- the bar is taken out of flow and
   pinned to the top edge, leaving the container free to centre its content
   the way it always did. The bar is transparent, so the stage still shows
   through behind it. Scoped to .take-has-nav: an anonymous visitor on a
   public test link gets the original centred card, untouched. */
body.test-stage.take-has-nav {
  display: block;
  padding: 0;
}

body.test-stage.take-has-nav .container {
  position: relative;
  max-width: none;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  /* Top clears the pinned bar (generous enough that the bar still has air
     under it when it wraps to two rows on a narrow phone). Bottom is bigger
     than top ON PURPOSE: with the old 96/32 split the flex centring computed
     its middle 32px BELOW the middle of the screen, and a block that reads
     as centred wants to sit slightly above true centre besides (optical
     centring) -- the intro sat visibly low (Duke, 2026-07-31). 152px puts
     short content ~28px above geometric centre. */
  padding: 96px 16px 152px;
}

/* Phones scroll the take pages anyway, so the optical-centring allowance
   would only add dead scroll below the Start button. */
@media (max-width: 720px) {
  body.test-stage.take-has-nav .container { padding-bottom: 32px; }
}

body.test-stage.take-has-nav .container > .nc-app-bar {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  padding-left: 16px;
  padding-right: 16px;
}

/* The width cap the 792px container used to supply, now that it is full-bleed.
   Applied to the form rather than to .page-stage, because the form also holds
   the Back / 1-of-5 / Next row -- capping only the stage left that row spanning
   the whole viewport with the buttons pinned to the screen edges. */
body.test-stage.take-has-nav #testForm,
body.test-stage.take-has-nav .progress-strip,
body.test-stage.take-has-nav .card.thanks,
body.test-stage.take-has-nav .card.thanks-flashcards {
  width: 100%;
  max-width: 792px;
  margin-left: auto;
  margin-right: auto;
}

body.recall-stage {
  /* The intro is a full-bleed screen with its own header; test-stage's
     40px/16px body padding pushed that header down from the top edge and
     inset it from the sides, so it read as floating mid-page instead of
     being the nav bar. */
  padding: 0;
}
body.recall-stage .container {
  padding: 0;
  margin: 0;
  width: 100%;
  max-width: none;
}

/* NihaoCard app screens: the sticky top bar must span the viewport and sit
   flush with the top edge -- the default 760px padded .container boxed it
   into a floating island. Content centers itself via .nc-app. */
body.nc-app-stage .container {
  padding: 0;
  margin: 0;
  width: 100%;
  max-width: none;
}

/* ── WHICH FAMILY GOES WHERE ──────────────────────────────────────────
   Duke, 2026-08-02: the two families were being handed out rule by rule,
   so identical things drifted apart -- one .st-step-t row title came out
   serif while the row above it was Inter, and the account chip spelled
   the student's name in a different face from the greeting two lines
   under it. It read as "random fonts" even though both faces were ours.

   The split is by ROLE, and since 2026-08-03 it is ONE SENTENCE:

     serif (--font-display)   what the product SAYS
     Inter (--font-sans)      what you DO, and everything supporting it

   Say it out loud before reaching for a family. If the text is the app
   talking -- a heading, a tagline or eyebrow, a card/row/section title,
   the wordmark -- it is serif. If it is something the student operates
   or reads past -- a button, a form label, an input, a link, a unit, a
   caption, a badge, body prose, pinyin -- it is Inter.

   The one thing that is neither: FIGURES. A score, a stamp, a big
   numeral, the A/B/C/D letter on a quiz option -- those are the product
   showing you a result, so they take the serif even inside a control.
   Figures serif, words Inter. That is the whole system.

   One boundary worth naming, because the two look alike and are not:
   a QUESTION PROMPT is content (the test asking something), so it stays
   serif -- .question-label, .question-prompt, the name field on a test
   intro. A FORM LABEL is chrome (Email, Password, Pinyin), so it is
   Inter. That is why the test-taking flow and the NihaoCard forms label
   their fields in different faces on purpose.

   What changed on 2026-08-03, and why (Duke): every button and CTA used
   to be serif, on a "serif leads" reading of a call to action. In
   practice that made the submit button the only functional element on a
   login screen set in the display face, wedged between Inter field
   labels and Inter links -- it read as a font someone forgot to set. Nav
   links are the last functional thing still in serif; they are left
   alone for now, deliberately, not by oversight.

   ── THE TRAP THAT KEEPS CATCHING US ─────────────────────────────────
   Font-family INHERITS, so a rule aimed at a control does not stop at
   the control's own label. Option pickers and segmented toggles are
   <button>s whose text all lives in child spans, and those spans take
   whatever the button was given unless they say otherwise.

   Two consequences worth stating flat out:

   (1) NOTHING BELOW 0.8rem IS SERIF, except hanzi and the .nc-info-btn
       glyph. Source Serif 4's detail disappears at that size and stops
       reading as the same family -- it just reads as "a different font".
       Small text wants Inter and has to SAY so, because inheriting is
       what put it in serif to begin with.

   (2) SAY WHICH FAMILY YOU MEAN INSIDE A CONTROL. This is how "5 / min"
       ended up in two faces on one tile of the first-run time picker
       while "month 1" two rows below -- same size, same job -- was
       Inter. Same bug sat in the Settings picker. The numerals are
       figures and declare serif; the unit, the word standing in the
       numeral's slot ("No limit") and any hint text declare Inter.

   The check is mechanical, so run it instead of eyeballing:
   `scripts/font_audit.js`, pasted into the browser console on any page,
   reports every serif-under-0.8rem and every serif CTA. A page that
   follows the rule prints "clean". */
/* :where() so this is a DEFAULT and not an override. Written as a plain
   element selector it scores (0,0,1), which is fine, but its stage-scoped
   twin further down scored (0,1,2) and quietly beat every component class
   that had deliberately chosen otherwise -- see the note there. Both are
   zero-specificity now, so any rule with a class wins, which is what a
   default is supposed to mean. */
:where(h1, h2, h3, h4, h5, h6) {
  font-family: var(--font-display);
  font-weight: 600;
}

/* Controls are Inter (Duke, 2026-08-03). They used to be serif, on the
   old "serif leads" reading of a CTA -- but that made the submit button
   the ONLY functional thing on a login screen set in the display face,
   sitting between Inter field labels and Inter links, and it read as a
   font someone forgot to set rather than as emphasis.

   Naming them is still required even though <body> is already Inter: the
   UA gives every control its own font (see the `inherit` rule above), and
   a lot of this app's CTAs are <a class="btn">, not <button>. */
button, .btn, a.btn, input[type="submit"], input[type="button"] {
  font-family: var(--font-sans);
}

/* ════════════════════════════════════════════════════════════════════
   TYPE ROLES -- the single place a piece of text is told how to look
   ════════════════════════════════════════════════════════════════════
   Added 2026-08-05. Read the TYPE SCALE note in :root first.

   HOW THIS WORKS. Each block below is one ROLE. It owns four properties
   and nothing else:

       font-family   font-size   font-weight   letter-spacing

   A component joins a role by adding its selector to the role's list.
   It then keeps its own margin, colour, animation and layout -- the
   things that really are local -- and it must NOT set any of those four
   properties itself. If it does, it has left the system and it will
   drift again, because that is exactly how we got here.

   WHY A SELECTOR LIST RATHER THAN A CLASS IN THE MARKUP. Both work, and
   `.t-eyebrow` etc. are declared here so new markup can just use them.
   But retrofitting a class onto forty templates is a big diff with
   nothing to show for it, and it would have to be right in every
   template at once. Joining by selector converts a component in one
   line, in one file, and the audit can prove it landed.

   THE CONTRADICTION THIS RESOLVES. The old note said "a heading, a
   tagline or eyebrow ... is serif" and, forty lines later, "NOTHING
   BELOW 0.8rem IS SERIF". An eyebrow is 0.72rem. Both rules were right
   on their own and together they were unfollowable, so the answer came
   out differently every time somebody needed one -- which is why the
   app shipped eleven eyebrows in three families. Settled here: the caps
   overline is SANS (it is a device, not a voice, and it lives under the
   serif floor). The serif ITALIC kicker is a different thing and keeps
   its own role below, rather than being quietly merged into it. */

/* Caps overline. The small wide-tracked line that labels the thing under
   it -- "HSK", "WORDS", "10-15 MINUTES · FREE", "YOUR RESULT". Was
   0.58/0.62/0.64/0.66/0.68/0.70/0.72/0.74/0.78rem at 0.05-0.22em
   tracking in weights 700 and 800, all of it the same device. */
.t-eyebrow,
.start-eyebrow,
.ti-eyebrow,
.pq-overline,
.sr-note-label,
.lvl-eyebrow,
.pr-fin-eyebrow,
.pr-overline,
.pr-skills-title,
.qr-eyebrow,
.qr-panel-label,
.qr-note-label,
.lc-report-eyebrow,
.lc-meta,
.mt-group-label,
.signin-eyebrow,
.signin-field label,
.nc-settings-section-title {
  font-family: var(--font-sans);
  font-size: var(--t-eyebrow);
  font-weight: 700;
  letter-spacing: 0.16em;
  text-transform: uppercase;
}

/* Serif italic kicker. Genuinely NOT the eyebrow above: no caps, no
   tracking to speak of, accent-coloured, and it reads as the product
   speaking rather than as a label. Two of these exist and they were
   already consistent with each other -- named here so the next person
   can tell the two devices apart instead of guessing. */
.t-kicker,
.err-zi-cap,
.recall-eyebrow,
.nc-vip-eyebrow {
  font-family: var(--font-display);
  font-size: var(--t-body);
  font-style: italic;
  letter-spacing: 0.07em;
}

/* Form label -- chrome. Inter, per the role rule: this is something the
   student operates, not something the product says. .field-label was
   the worst offender in the app: serif everywhere, but sans inside the
   Level Check via .lc-series .field-label, so the SAME label rendered
   in two faces depending on which page you met it on. */
.t-label,
.field-label,
.login-form label,
.composer-label,
.nc-settings-row-label,
.nc-section-label {
  font-family: var(--font-sans);
  font-size: var(--t-label);
  font-weight: 600;
  letter-spacing: 0;
}

/* Caption -- hints, fine print, units, timestamps, anything read past. */
.t-caption,
.pr-fin-note,
.pr-fin-fineprint p,
.nc-time-unit,
.nci-time-unit,
.nc-example-nudge,
.mt-note,
.qr-score-text i {
  font-family: var(--font-sans);
  font-size: var(--t-caption);
  font-weight: 400;
  letter-spacing: 0;
}

/* Body prose. */
.t-body,
.err-message,
.lc-body {
  font-family: var(--font-sans);
  font-size: var(--t-body);
  font-weight: 400;
  letter-spacing: 0;
}

/* Secondary prose -- a card's subtitle, the line under a score, anything
   that supports the thing above it rather than being read on its own. */
.t-body-sm,
.qr-open,
.qr-lead,
.mt-sub {
  font-family: var(--font-sans);
  font-size: var(--t-body-sm);
  font-weight: 400;
  letter-spacing: 0;
}

/* Lead -- the one line a figure-led screen gets. Bigger than body
   because it is the statement, not a footnote to the picture above it. */
.t-lead {
  font-family: var(--font-sans);
  font-size: var(--t-lead);
  font-weight: 400;
  letter-spacing: 0;
}

/* ---- Titles. Serif, because a title is the product talking. --------
   Four tiers, and four is the budget. The app had thirty-eight heading
   classes spread over twenty-three sizes between 16px and 40px, which
   is not four tiers with variation -- it is no tiers at all. */

/* Row and list-item titles -- the smallest thing that is still a title. */
.t-title-sm,
.st-step-t,
.st-tests-latest-t,
.stu-row-name,
.rv-sub-name,
.dash-quick-title,
.dash-panel-head h2,
.tst-title,
.nc-empty-state h3,
.mt-title,
.qr-q-title,
.lvl-card-head h2,
.nc-sub-head h4,
.nc-mod-body > h4,
.block-title,
.nc-modal-stats h3,
.asg-name,
.rv-student,
.st-door-name {
  font-family: var(--font-display);
  font-size: var(--t-title-sm);
  font-weight: 600;
  letter-spacing: -0.005em;
}

/* Card and panel titles. */
.t-title,
.err-heading,
.test-card-title,
.section-title,
.nc-deck-head h2,
.nc-modal-heading,
.nc-form-heading,
.nc-spot-tip h4,
.nc-mod > summary h3,
.nc-legal-content h2,
.login-header .login-greet {
  font-family: var(--font-display);
  font-size: var(--t-title);
  font-weight: 600;
  letter-spacing: -0.012em;
}

/* Screen titles -- the heading a whole panel or step sits under. */
.t-title-lg,
.test-title,
.pinyin-hero-title,
.nc-vip-heading,
.nc-stats-heading,
.nc-hero-due h2,
.nc-hero-done h2,
.nc-review-waiting h2,
.nc-legal-content h1,
.mt-empty h2,
.lvl-empty h1,
.qr-test-name,
.nci-copy h2 {
  font-family: var(--font-display);
  font-size: var(--t-title-lg);
  font-weight: 600;
  letter-spacing: -0.018em;
}

/* Page heroes -- one per screen, the first thing you read. */
.t-hero,
.lc-title,
.lvl-hero-title,
.recall-headline,
.dash-hello,
.signin-greet {
  font-family: var(--font-display);
  font-size: var(--t-hero);
  font-weight: 600;
  letter-spacing: -0.022em;
}

/* ---- Figures -------------------------------------------------------
   A score, a level, a word count, a big numeral. The product SHOWING a
   result rather than talking, so it takes the serif even inside a
   control -- the one exception to "controls are Inter", and the audit
   knows about it.

   Size stays local here, deliberately: a figure is sized by the box it
   sits in (a dial, a stamp, a certificate), not by a text scale. What
   the role fixes is that they all share a face, a weight and -- new --
   tabular figures, so a number does not change width as it counts. */
.t-figure,
.err-code,
.pr-fin-level,
.placement-word-count-num,
.lc-report-roll,
.lc-report-words,
.nc-mode-num,
.nc-tile-number,
.result-stamp .score,
.dash-stat-num,
.stu-kpi-num,
.lvl-stat-n,
.nc-time-value,
.nci-time-value,
.asg-ring-num,
.qr-q-num,
.nci-bar-value,
.rv-score,
.nc-group-stat .n,
.st-streak-n b,
.pr-dial-pct,
.score-ring b {
  font-family: var(--font-display);
  font-weight: 700;
  letter-spacing: -0.02em;
  font-variant-numeric: lining-nums tabular-nums;
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: 0.001ms !important; transition-duration: 0.001ms !important; }
}

.container {
  max-width: 760px;
  margin: 0 auto;
  padding: 24px 16px;
}

/* Admin dashboard pages (Tests, Students, Requests, Level Check results) all
   include the admin nav bar and hold wide data tables -- the 760px width
   above was sized for the single-card test-taking flow and crushed those
   tables' Student/Test columns into wrapping one word (or one CJK character)
   per line, no matter how much real screen width was free. Widen just the
   pages that have the admin nav, via :has() rather than a body class, so
   nothing needs to change in every admin template.

   Scoped to `body:has()`, not `.container:has()`: since 2026-08-03 the bar is
   full-bleed and renders in base.html's topbar block, i.e. as the container's
   *sibling*, so a descendant test no longer matches it.

   The content stays a centred 1200 column even though the bar above it is
   full-bleed -- that is exactly a normal nav bar (full-width strip, contained
   page under it). A brief widen to 1560 stretched the tables too and read as
   enlarging the whole site rather than the bar (Duke, 2026-08-03); reverted. */
body:has(.admin-nav) .container {
  max-width: 1200px;
  padding-top: 22px;
}

/* Login */
.login-wrapper {
  min-height: 90vh;
  display: flex;
  align-items: center;
  justify-content: center;
}

.login-card {
  background: var(--card);
  border-radius: 20px;
  padding: 36px 32px;
  width: 100%;
  max-width: 420px;
  box-shadow: var(--shadow);
}

.login-header {
  text-align: center;
  margin-bottom: 24px;
}

.login-header h1 {
  margin: 0 0 6px;
  font-size: 1.8rem;
  color: var(--primary);
}

.login-header p {
  color: var(--muted);
  margin: 0;
}

.login-error {
  background: var(--danger-wash);
  color: var(--accent);
  padding: 12px 14px;
  border-radius: 10px;
  margin-bottom: 16px;
  font-weight: 500;
  font-size: 0.95rem;
}

.login-form .form-field {
  margin-bottom: 16px;
}

.login-form label {
  display: block;
  margin-bottom: 6px;
}

.login-form input {
  width: 100%;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: 10px;
  font-size: 1rem;
  font-family: inherit;
  /* Without an explicit background/color, this rule alone left the password
     field showing the browser's own default white input styling -- the
     username field only looked right because the separate global
     `input[type="text"]` rule elsewhere happens to set both, and that
     selector doesn't match type="password". Self-contained now instead of
     relying on that overlap. -webkit-appearance resets Safari's stubborn
     default chrome on password fields specifically. */
  background: var(--card);
  color: var(--text);
  -webkit-appearance: none;
  appearance: none;
}

.login-form input:focus {
  outline: none;
  border-color: var(--primary);
}

.login-submit {
  width: 100%;
  background: var(--primary);
  color: white;
  border: none;
  border-radius: 12px;
  padding: 14px;
  font-size: 1.05rem;
  font-weight: 600;
  cursor: pointer;
  margin-top: 8px;
  transition: background 0.15s, transform 0.15s ease, box-shadow 0.15s ease;
}

.login-submit:hover { background: var(--primary-dark); }

/* ---- Login redesign 2026-08-04 -------------------------------------------
   The console's front door. It used to say "NihaoLingo Tests / Teacher login"
   -- from when this was only the test-forms admin. It is Duke & Stasya's whole
   teaching console now, so it wears their names (the same mark as the admin
   bar, set large as the hero), a TEACH tag, and a warm greeting. A soft coral
   glow sits behind the card; a coral->gold accent caps it. */
body.login-stage .login-wrapper { position: relative; }
body.login-stage .login-wrapper::before {
  content: "";
  position: absolute; z-index: 0;
  width: min(600px, 92vw); aspect-ratio: 1;
  left: 50%; top: 50%; transform: translate(-50%, -54%);
  background: radial-gradient(circle, var(--coral-glow), transparent 68%);
  pointer-events: none;
}
body.login-stage .login-card {
  position: relative; z-index: 1;
  overflow: hidden;
  padding-top: 40px;
}
.login-accent {
  position: absolute; top: 0; left: 0; right: 0; height: 5px;
  background: linear-gradient(90deg, var(--primary), var(--gold));
}
.login-mark {
  display: block; text-align: center;
  font-size: 2.15rem; line-height: 1;
}
.login-tag {
  display: inline-block; margin-top: 11px;
  font-family: var(--font-sans);
  font-size: 0.64rem; font-weight: 800;
  letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--primary-dark);
  background: var(--primary-light);
  padding: 3px 10px; border-radius: 999px;
}
.login-header .login-greet {
  margin: 18px 0 3px; text-align: center;
  color: var(--text);
}
.login-wave {
  display: inline-block;
  transform-origin: 70% 80%;
  animation: dash-wave 2.6s ease-in-out 0.5s 2;
}
.login-sub {
  text-align: center; color: var(--muted);
  margin: 0; font-size: 0.95rem;
}
.login-submit {
  display: flex; align-items: center; justify-content: center; gap: 8px;
}
.login-submit:hover {
  transform: translateY(-1px);
  box-shadow: 0 10px 22px -12px var(--primary-dark);
}
@media (prefers-reduced-motion: reduce) {
  .login-wave { animation: none; }
  .login-submit:hover { transform: none; }
}

/* Header */
.header {
  text-align: center;
  margin-bottom: 32px;
}

.header h1 {
  margin: 0 0 8px;
  font-size: 1.75rem;
  color: var(--primary);
}

.header .subtitle {
  margin: 4px 0 0;
  color: var(--muted);
  font-size: 1rem;
}

/* ---------- Test start screen ---------- */
.start-top {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  margin-bottom: 20px;
}

.start-eyebrow {
  color: var(--muted);
}

.start-top .stamp {
  width: 60px;
  height: 60px;
  color: var(--primary);
}

/* The seal carries the test's kind (作业 / HOMEWORK). The two lines are sized
   independently because the English words vary a lot in length -- "TEST" and
   "HOMEWORK" have to sit in the same circle -- so the Chinese keeps a
   readable size while the English takes whatever is left. Sized in em so the
   pair scales with whichever seal it is: 60px in the Level Check hero, up to
   128px on the test intro. */
.stamp .stamp-zh {
  display: block;
  font-size: 0.62rem;
}

.stamp .stamp-en {
  display: block;
  font-size: 0.4rem;
  font-weight: 700;
  letter-spacing: 0.04em;
}

/* The intro's chop is roughly twice the size, so its type scales with it. */
.ti-stamp .stamp-zh { font-size: clamp(0.85rem, 1.5vw, 1.15rem); }
.ti-stamp .stamp-en { font-size: clamp(0.5rem, 0.85vw, 0.64rem); }

.test-title {
  line-height: 1.2;
  margin: 0 0 6px;
  color: var(--text);
}

.test-subtitle { font-family: var(--font-display); font-size: 1rem; color: var(--primary-dark); font-weight: 700; margin: 0 0 4px; }
.test-desc { font-size: 0.95rem; color: var(--muted); margin: 0 0 26px; }

/* The take page's identity row -- avatar, display name, saved-tick, on one
   line -- lived here until 2026-08-02. It went through three passes (an
   eyebrow reading "ВЫ ПРОХОДИТЕ ТЕСТ КАК", then a pill, then a bare row) all
   trying to make it sit right, and the answer turned out to be that it should
   not have been on the page at all: the nav bar's account chip, directly
   above it, already showed the same avatar and the same name (Duke). The
   saved-state confirmation was the only part carrying information the nav bar
   lacked, and it is a .ti-fact chip now -- see the test-intro section. */

.dashboard-add-box { margin: 0 0 20px; }
.dashboard-add-status { font-size: 0.9rem; color: var(--green); font-weight: 600; margin: 0; }
.dashboard-add-prompt { font-size: 0.88rem; color: var(--muted); margin: 0; }
.dashboard-add-prompt a { color: var(--primary-dark); font-weight: 700; text-decoration: none; }
.dashboard-add-prompt a:hover { text-decoration: underline; }
.dashboard-add-btn { padding: 8px 16px; font-size: 0.85rem; }

.field-label {
  display: block;
  margin-bottom: 8px;
  color: var(--text);
}

.start-row {
  margin-top: 28px;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
}

.q-count { font-size: 0.85rem; color: var(--muted); font-weight: 700; }

/* ==========================================================================
   Test intro -- the title page (2026-07-31)
   --------------------------------------------------------------------------
   With the card gone this screen was still shaped like the card's contents:
   a 1.9rem heading in a 792px column, the chop shoved to the far right, and
   air with nothing in it (Duke: "our structure and design shouldn't be so
   limited and tight ... make it premium as usual").

   It is a spread now. Two columns on a wide screen -- the chop as an object
   on the left, the title block on the right -- and everything arrives in
   sequence: the seal stamps down, its ink ring spreads, then the eyebrow,
   title, name and action rise under it. The global prefers-reduced-motion
   rule near the top of this file flattens all of it to ~0s.
   ========================================================================== */
/* Deliberately the same 792px column as the questions. Widening only this
   page was the obvious move and it is wrong twice: the page-flip stacks the
   intro and the first question absolutely on top of each other, so different
   widths make the turn jump sideways, and the room is not actually what was
   missing -- scale and composition were. */
.test-intro { width: 100%; }

/* One centred stack: chop, then title, then who/actions, all on a single
   vertical axis. The previous seal-beside-title spread was geometrically
   centred as a box but never LOOKED centred -- the title is short while the
   identity chip row is wide, so the visual mass sat left of centre, and the
   body hanging under only the text column left a void beneath the seal that
   read as everything being "mis aligned and mis positioned" (Duke,
   2026-07-31, third pass on this screen). A symmetric stack has no such
   judgement calls: every element centres against the same axis whatever the
   title length or login state. */
.ti-grid {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
}

/* ---- The chop ---- */
.ti-sealwrap {
  position: relative;
  margin-bottom: 34px;
  /* Its own stacking context, so the ink ring can sit under the seal without
     escaping into the text below. */
  display: grid;
  place-items: center;
}

.test-intro .stamp.ti-stamp {
  width: clamp(92px, 11vw, 128px);
  height: clamp(92px, 11vw, 128px);
  color: var(--primary);
  /* Low origin, so the rock below reads as the chop pivoting on the paper
     rather than spinning around its own middle. */
  transform-origin: 62% 88%;
  animation:
    tiSealPress 0.8s cubic-bezier(0.22, 1, 0.36, 1) 0.12s backwards,
    tiSealFloat 7s ease-in-out 1.2s infinite;
}

/* Rocked on, not dropped in.
   This started as a copy of the Level Check's result slam -- scale 2.4 down,
   overshoot, hard shockwave ring -- on the theory that reusing the move made
   the two read as one product. Duke saw it and said the opposite: "we have
   the same with level test already." He's right, and the two moments are not
   the same moment. That stamp is the ceremony at the end of a reveal; this
   one is a label on a homework sheet. So it does what a hand actually does
   with a Chinese seal -- press, rock to spread the ink, lift -- and the ink
   sharpens from a wet blur instead of arriving from off-screen. */
@keyframes tiSealPress {
  0%   { opacity: 0; transform: rotate(9deg) scale(1.1);    filter: blur(3px); }
  38%  { opacity: 1; }
  72%  {             transform: rotate(-11deg) scale(0.99); filter: blur(0); }
  100% {             transform: rotate(-8deg) scale(1);     filter: blur(0); }
}

/* Idle drift, so the page is never completely dead. Deliberately tiny -- a
   chop that visibly bobs reads as a sticker, not as ink on paper. */
@keyframes tiSealFloat {
  0%, 100% { transform: rotate(-8deg) translateY(0); }
  50%      { transform: rotate(-7.4deg) translateY(-4px); }
}

/* Ink soaking into the paper under the chop. A soft bloom, not the hard
   expanding ring the Level Check's slam throws off -- that ring is a
   shockwave, which belongs to an impact, and this is a press. */
.ti-seal-wave {
  position: absolute;
  width: clamp(92px, 11vw, 128px);
  height: clamp(92px, 11vw, 128px);
  border-radius: 50%;
  background: radial-gradient(circle, var(--coral-glow) 0%, transparent 68%);
  opacity: 0;
  pointer-events: none;
  animation: tiInkBloom 1.15s ease-out 0.36s backwards;
}

@keyframes tiInkBloom {
  0%   { opacity: 0;    transform: scale(0.55); }
  30%  { opacity: 0.95; }
  100% { opacity: 0;    transform: scale(1.6); }
}

/* ---- The title block ---- */
.ti-eyebrow {
  margin: 0 0 10px;
  color: var(--primary-dark);
  animation: tiRise 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.3s backwards;
}

.test-intro .ti-title {
  /* Display scale. The old 1.9rem was sized for a card, not a title page. */
  font-size: clamp(2rem, 4.4vw, 3rem);
  line-height: 1.08;
  letter-spacing: -0.02em;
  margin: 0;
  animation: tiRise 0.65s cubic-bezier(0.16, 1, 0.3, 1) 0.38s backwards;
}

.test-intro .ti-sub { margin: 14px 0 0; animation: tiRise 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.46s backwards; }
.test-intro .ti-desc { margin: 10px auto 0; max-width: 52ch; animation: tiRise 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.52s backwards; }

/* The body centres its children on the same axis as everything above it. */
.ti-body {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-top: 26px;
}

.test-intro .ti-who {
  margin: 0;
  animation: tiRise 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.58s backwards;
}

/* The anonymous branch is a labelled input, not a chip -- a sane measure,
   centred as a box, with the label back on the input's own left edge (a
   centred label floating over a wide input reads as another misalignment). */
.test-intro .ti-namefield {
  max-width: 420px;
  width: 100%;
  text-align: left;
}

@keyframes tiRise {
  from { opacity: 0; transform: translateY(14px); }
}

/* ---- What you're about to sit ----
   Shared with the Level Check intro, which uses these same .ti-* classes
   (2026-08-05) rather than keeping a near-identical copy of them. */
.ti-facts {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px;
  list-style: none;
  margin: 18px 0 0;
  padding: 0;
  animation: tiRise 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.62s backwards;
}

/* For a logged-in student there is nothing above the chips inside .ti-body,
   so the gap under the title is .ti-body's alone -- without this the two
   margins stack and the row drifts away from the heading it belongs to. */
.ti-facts:first-child { margin-top: 0; }

.ti-fact {
  font-family: var(--font-display);
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 7px 14px 7px 11px;
  border-radius: 999px;
  background: var(--surface-2);
  border: 1px solid var(--border);
  color: var(--muted);
  font-size: 0.82rem;
  font-weight: 700;
  line-height: 1.2;
}

.ti-fact svg { color: var(--primary); flex: none; }

/* The saved-to-your-dashboard chip. Green rather than coral because it is a
   confirmation of something that already happened, not a fact about the test
   sitting next to it -- and because that tick was green in the identity row
   this replaced, where students have already seen it. */
.ti-fact-saved { color: var(--green-dark); }
.ti-fact-saved svg { color: currentColor; }

/* ---- Action ----
   Nothing shares this row. Anything set beside the button centres the pair
   instead of the button, which puts the one thing the eye is aiming at off
   the axis every element above it sits on. */
.test-intro .ti-actions {
  margin-top: 30px;
  display: flex;
  justify-content: center;
  animation: tiRise 0.6s cubic-bezier(0.16, 1, 0.3, 1) 0.7s backwards;
}

.test-intro .ti-start {
  font-size: 1.05rem;
  padding: 14px 30px;
  border-radius: 999px;
  box-shadow: 0 10px 26px var(--coral-glow);
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.18s ease, background 0.15s ease;
}

.test-intro .ti-start:hover {
  transform: translateY(-2px);
  box-shadow: 0 16px 34px var(--coral-glow);
}

.test-intro .ti-start:active { transform: translateY(0); }

/* ---- Phones ----
   The stack already reads right on a small screen -- same centred axis, chop
   first -- it just gets tighter so the whole thing fits one viewport. */
@media (max-width: 720px) {
  .ti-sealwrap { margin-bottom: 24px; }
  .ti-body { margin-top: 22px; }
  .ti-facts { margin-top: 14px; }
  .test-intro .ti-actions { margin-top: 24px; }
}

.top-bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 24px;
  gap: 12px;
  flex-wrap: wrap;
}

.top-bar h1 {
  margin: 0;
  font-size: 1.5rem;
  color: var(--text);
}

.top-bar .user {
  color: var(--muted);
  font-size: 0.95rem;
}

/* ---------- The teacher bar (every /teach page) ----------------------------
   Markup: templates/_admin_nav.html. Rebuilt 2026-08-03 as a real app bar.

   What went away with it, so nobody reinstates the workarounds:
     - `padding-right: 104px` under 1400px and `margin-top: 34px` under 767px.
       Both existed to dodge base.html's position:fixed RU/EN pill, which the
       bar could not see because it was not in the bar. The pill is inside the
       bar now, in flow, and admin templates blank the fixed one.
     - `.admin-nav-link`/`.admin-nav-spacer`/`.admin-nav-user`. The tabs, the
       account chip and the log-out row are separate objects now; they were
       one undifferentiated row of links before, which is why "Log out" looked
       exactly like "Lessons".
   ------------------------------------------------------------------------ */
.admin-nav {
  position: sticky;
  top: 0;
  z-index: 45;
  border-bottom: 1px solid var(--border);
  /* Translucent so the page's practice-grid texture keeps running under the
     bar instead of being cut off by an opaque strip -- the same call Duke
     made on the student dashboard (no solid bar). Opaque fallback first for
     browsers without color-mix, or the text sits on the raw scrolling page. */
  background: var(--bg);
  background: color-mix(in srgb, var(--bg) 82%, transparent);
  -webkit-backdrop-filter: saturate(1.5) blur(12px);
  backdrop-filter: saturate(1.5) blur(12px);
}

/* Full-bleed on purpose (Duke, 2026-08-03): the bar's contents used to cap at
   the 1200px content column and centre, which on a wide monitor left the
   brand marooned mid-screen and read as a centred banner, not a nav bar. The
   row now runs the whole window with just breathing room at the edges -- brand
   pinned hard left, account hard right, the way a real app bar sits. It no
   longer lines up with the content column below (that stays centred); a
   full-bleed bar over contained content is the standard pattern and the bar's
   own background band keeps it reading as one piece. */
.admin-nav-inner {
  padding: 12px clamp(20px, 3vw, 48px);
  display: flex;
  align-items: center;
  gap: 22px;
}

.admin-nav-sprite { position: absolute; width: 0; height: 0; overflow: hidden; }

/* This app underlines links on hover globally. Everything in the bar is a
   styled control (pill, chip, tab, menu row), so the underline reads as a
   glitch on all of them. */
.admin-nav a:hover { text-decoration: none; }

/* ---- Brand ---- */
.admin-brand {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  flex-shrink: 0;
  color: var(--text);
  text-decoration: none;
}
.admin-brand:hover { text-decoration: none; }

/* Duke & Stasya wordmark. Same display serif as the NihaoLingo mark it
   replaced (the "same font style" brief). Three parts so each can carry its
   own colour and motion: Duke in ink, the ampersand and Stasya in coral. */
.admin-wordmark {
  font-family: var(--font-display);
  font-size: 1.24rem;
  font-weight: 600;
  letter-spacing: 0.01em;
  line-height: 1;
  white-space: nowrap;
}
.aw-duke, .aw-stasya {
  display: inline-block;
  transition: transform 0.25s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.aw-duke { color: var(--text); }
.aw-stasya { color: var(--primary); }

/* The & is the mark's focal point: a small twin-thump heartbeat, coral,
   slightly lighter weight so it reads as a connector rather than a third
   name. transform-box/origin keep the scale centred on the glyph. */
.aw-amp {
  display: inline-block;
  margin: 0 2px;
  color: var(--primary);
  font-weight: 500;
  transform-origin: center;
  animation: aw-beat 3.4s ease-in-out infinite;
}
@keyframes aw-beat {
  0%, 44%, 100% { transform: scale(1); }
  12% { transform: scale(1.22); }
  22% { transform: scale(1.04); }
  32% { transform: scale(1.16); }
}

/* A pale highlight drifts through "Stasya" every few seconds -- alive, but
   quiet enough to sit behind three tables of data without nagging. Gated on
   @supports because the text-clip trick makes the letters transparent, and
   without the clip that would erase the name; the base rule above keeps it a
   solid coral wherever the trick is unavailable. Frozen at 0% the highlight
   sits off to the right, so a reduced-motion viewer gets solid coral too. */
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  .aw-stasya {
    background-image: linear-gradient(100deg,
      var(--primary) 0%, var(--primary) 38%, #ffd9c9 50%, var(--primary) 62%, var(--primary) 100%);
    background-size: 260% 100%;
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
    animation: aw-shimmer 5s ease-in-out infinite;
  }
}
@keyframes aw-shimmer {
  0%, 100% { background-position: 130% 0; }
  50% { background-position: -30% 0; }
}

/* Hover is the game-feel payoff Duke asked for: the names lean apart, the
   heart races. Cheap, transform-only, and it settles right back. */
.admin-brand:hover .aw-duke { transform: translateX(-2px); }
.admin-brand:hover .aw-stasya { transform: translateX(2px); }
.admin-brand:hover .aw-amp { animation-duration: 0.9s; }
/* Says which of the two apps at nihaolingo.com you are looking at. The
   student side wears the same wordmark, so without this the teacher console
   and a student's dashboard open with identical headers. */
.admin-brand-tag {
  font-family: var(--font-sans);
  font-size: 0.62rem;
  font-weight: 800;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--primary-dark);
  background: var(--primary-light);
  border-radius: 6px;
  padding: 3px 6px;
  line-height: 1;
  white-space: nowrap;
}

/* ---- Tabs ---- */
.admin-tabs {
  display: flex;
  align-items: center;
  gap: 2px;
  flex: 1;
  min-width: 0;
  /* Long labels (RU: "Запросы для Claude") get a scrollable strip rather than
     a second wrapped row -- a bar that changes height as the language changes
     shifts every page under it. */
  overflow-x: auto;
  scrollbar-width: none;
}
.admin-tabs::-webkit-scrollbar { display: none; }

.admin-tab {
  font-family: var(--font-display);
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 8px 13px;
  border-radius: 999px;
  font-weight: 600;
  font-size: 0.92rem;
  color: var(--muted);
  text-decoration: none;
  white-space: nowrap;
  transition: color 0.15s ease, background 0.15s ease, transform 0.15s ease;
}
.admin-tab-ico { width: 16px; height: 16px; flex-shrink: 0; opacity: 0.8; }

.admin-tab:hover {
  color: var(--text);
  text-decoration: none;
  background: var(--surface-2);
  transform: translateY(-1px);
}
.admin-tab:active { transform: translateY(0); }

.admin-tab.active {
  color: var(--primary-dark);
  background: var(--primary-light);
  font-weight: 700;
  /* Reads as a raised key rather than a flat colour swap: hairline rim plus a
     short coral drop. */
  box-shadow: inset 0 0 0 1px rgba(255, 133, 99, 0.35), 0 2px 8px var(--coral-glow);
}
.admin-tab.active .admin-tab-ico { opacity: 1; }
.admin-tab.active:hover { transform: none; }

.admin-nav-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 18px;
  height: 18px;
  padding: 0 5px;
  border-radius: 999px;
  background: var(--primary);
  color: #fff;
  font-family: var(--font-sans);
  font-size: 0.7rem;
  font-weight: 800;
}

/* ---- Right cluster: language + account ---- */
.admin-nav-right {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-shrink: 0;
}

/* Chip and menu are the student bar's components (.nc-user-menu). Only the
   two rows this menu has and the student bar's does not are styled here. */
.nc-user-pop-head {
  display: block;
  padding: 6px 14px 8px;
  margin-bottom: 4px;
  border-bottom: 1px solid var(--border);
  color: var(--muted);
  font-family: var(--font-sans);
  font-size: 0.75rem;
  font-weight: 500;
  white-space: nowrap;
}
.nc-user-pop-head strong { color: var(--text); font-weight: 700; }
.nc-user-pop a.nc-user-pop-out { color: var(--primary-dark); }
.nc-user-pop a.nc-user-pop-out:hover { background: var(--danger-wash); }

/* Laptop widths. In Russian the five labels measure ~667px on their own
   ("Запросы для Claude" alone is ~190px), so brand + tabs + account needs
   ~1160px to sit in one comfortable row; English fits in far less. Below that
   the tag goes and the tabs tighten, and whatever still doesn't fit scrolls
   -- with a fade so the cut edge reads as "more this way" rather than as a
   clipped bar. app.js scrolls the current tab into view on load, so the lit
   tab is never the one hiding off the edge. */
@media (max-width: 1160px) {
  .admin-brand-tag { display: none; }
  .admin-nav-inner { gap: 10px; }
  .admin-tab { padding: 8px 10px; gap: 6px; font-size: 0.88rem; }
}

/* Edge fades on the scrolling tab strip. Class-gated rather than media-gated
   because whether it overflows depends on the language (RU labels run ~40%
   wider than EN), and which edge to fade depends on the scroll position --
   app.js maintains .can-left / .can-right. A fixed mask instead dimmed the
   tab you had just scrolled to whenever you reached the end of the strip. */
.admin-tabs.can-right {
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
  mask-image: linear-gradient(to right, #000 calc(100% - 28px), transparent);
}
.admin-tabs.can-left {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 28px);
  mask-image: linear-gradient(to right, transparent, #000 28px);
}
.admin-tabs.can-left.can-right {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent);
  mask-image: linear-gradient(to right, transparent, #000 28px, #000 calc(100% - 28px), transparent);
}
@media (max-width: 720px) {
  /* Phone: brand + account on the top row, tabs on their own scrolling row
     underneath. The old bar wrapped its links into an unpredictable pile
     here, which is what put a random link under the floating pill. */
  .admin-nav-inner { flex-wrap: wrap; padding: 8px 12px; gap: 8px 12px; }
  .admin-tabs { order: 3; flex-basis: 100%; padding-bottom: 2px; }
  .admin-nav-right { margin-left: auto; }
  .admin-brand .recall-wordmark { font-size: 1.05rem; }
}

.card {
  background: var(--card);
  border-radius: 16px;
  padding: 24px;
  margin-bottom: 20px;
  box-shadow: var(--shadow);
}

/* ---- The test card dissolve (2026-07-31) ----
   Same move as the Level Check reveal, for the same reason. Duke, on that
   one: "other slides have like a modal and makes the whole design so ugly";
   on this one: "when we removed the modal, like the frames, the design
   instantly became better -- we can try the same with test forms."

   A question was a white panel with a border and a shadow, floating in the
   middle of an already-designed warm stage -- a dialog box on a page. It also
   meant two grids on top of each other: the stage's 56px texture behind, the
   card's own 34px texture in front. The chrome and the inner grid go; the
   question and its answer options become the objects on an open page, which
   is what they always were.

   Covers the whole run end to end -- the intro card, every question, and the
   result the student lands on. Doing only the questions would have reproduced
   the exact fault the Level Check had: open pages, then a white dialog at the
   finish.

   NOT the Level Check's own question card (.pq-card): it has its own recent
   treatment and Duke hasn't asked for it. Admin screens and the teacher's
   response-review pages keep their plain cards too. */
body.test-stage .page.card,
body.test-stage .card.thanks,
body.test-stage .card.thanks-email,
body.test-stage .card.thanks-flashcards {
  background: transparent;
  box-shadow: none;
  /* No side padding to work against: the content column is the page's own. */
  padding: 0;
}

/* "Where should we send your result?" -- the ask a student with no account
   gets on the results screen (2026-08-05). Deliberately not a card: this page
   dissolved its panels above for a reason, and a bordered box around an email
   field is exactly the dialog-on-a-page look that went. A hairline and a
   centred line of copy is enough to make it a separate thought. */
body.test-stage .card.thanks-email {
  max-width: 792px; margin: 26px auto 0; text-align: center;
  /* .card's own box has to go, not just its fill: the dissolve rule above
     clears background and shadow but leaves the 1px outline behind. */
  /* radius too, or .card's 16px curls the ends of the hairline into an arc. */
  border: 0; border-top: 1px solid var(--border); border-radius: 0; padding-top: 24px;
}
.thanks-email h3 { margin: 0 0 6px; font-size: var(--t-title-sm); font-family: var(--font-display); }
.thanks-email .te-body { margin: 0 auto 16px; max-width: 44ch; color: var(--muted);
  font-size: var(--t-body-sm); line-height: 1.55; }
.thanks-email .te-form { display: flex; gap: 8px; justify-content: center; flex-wrap: wrap; }
.thanks-email .te-form input {
  flex: 0 1 280px; padding: 12px 16px; border-radius: 12px; font-size: var(--t-body);
  border: 1.5px solid var(--border); background: var(--card); color: var(--text);
  font-family: inherit; transition: border-color .15s ease, box-shadow .15s ease;
}
.thanks-email .te-form input:focus { outline: none; border-color: var(--primary);
  box-shadow: 0 0 0 4px var(--primary-light); }
.thanks-email .te-form .btn { flex: 0 0 auto; }
.thanks-email .te-done { margin: 0; font-weight: 700; color: var(--green-dark);
  font-size: var(--t-body-sm); }
.thanks-email .te-bad { margin: 10px 0 0; color: var(--accent); font-weight: 700;
  font-size: var(--t-caption); }

body.test-stage .page.card .test-frame::before,
body.test-stage .page.card::before,
body.test-stage .card.thanks::before,
body.test-stage .card.thanks-email::before,
body.test-stage .card.thanks-flashcards::before { display: none; }

/* The card's radius clipped anything that reached past its edge. With the
   card gone there is nothing to clip against, but a stray decorative element
   must still never widen the document. That was handled by clipping the card
   sideways (overflow-x:clip) -- but the section title sits flush in the card's
   top-left corner (the card has no padding, by design), so the clip line ran
   straight through its first glyph. "词" lost its left radical on phones, and
   on some Android browsers -- which treat overflow-x:clip as clipping BOTH
   axes -- it lost the tops of the characters too.

   So the question and intro cards are simply not clipped: nothing on them
   overflows horizontally to begin with. The only decorative layer is
   .test-frame::before, which is inset:0 and cannot widen the document
   (verified: scrollWidth == viewport width at mobile). Only the result and
   thanks screens, which have bursts and glows that genuinely reach past the
   column, keep the sideways clip -- and they carry no top-flush heading, so
   the both-axes-clip quirk does not bite them. placement-result is excluded
   because it manages its own overflow further down (see .placement-result). */
body.test-stage .page.card.test-frame:not(.placement-result) {
  overflow: visible;
}
body.test-stage .card.thanks,
body.test-stage .card.thanks-flashcards {
  overflow: visible;
  overflow-x: clip;
  overflow-clip-margin: 6px;
}

/* Practice-grid texture, reserved for the test-taking flow (start/question/
   result) so admin screens keep their plain card look. */
.test-frame {
  position: relative;
  overflow: hidden;
}

.test-frame::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  opacity: 0.6;
  background-image:
    repeating-linear-gradient(90deg, var(--grid-line) 0 1px, transparent 1px 34px),
    repeating-linear-gradient(0deg, var(--grid-line) 0 1px, transparent 1px 34px);
  -webkit-mask-image: radial-gradient(ellipse 100% 55% at 50% 0%, black, transparent 75%);
  mask-image: radial-gradient(ellipse 100% 55% at 50% 0%, black, transparent 75%);
}

.test-frame > * { position: relative; }

/* Pinyin layout. Deliberately NOT using native <ruby>/<rt> -- across several
   rounds it proved unreliable: consecutive ruby elements collided with no
   inter-syllable spacing, and separately, resizing/boxing the ruby base for
   the answer-option treatment broke the browser's positioning of <rt>
   entirely (rendered overlapping the character instead of above it). Plain
   flexbox (.zi = column of .zi-py over .zi-han) is fully predictable instead.
   Base rule stays compact (titles, table cells, anywhere space is tight);
   the bigger practice-grid (田字格) cell treatment on .zi-han is opt-in via
   .opt-content/.option-label, used only where there's room: answer options,
   both in the live test and the admin response review. */
.zi {
  display: inline-flex;
  flex-direction: column;
  align-items: center;
  vertical-align: bottom;
  font-size: 1.5em;
  line-height: 1.15;
  margin: 0 0.02em;
}

.zi-han {
  font-weight: 500;
  font-family: var(--font-hanzi);
}

.zi-py {
  font-family: var(--font-sans);
  font-size: 0.52em;
  font-weight: 800;
  color: var(--primary-dark);
  line-height: 1.15;
  text-align: center;
  letter-spacing: 0.01em;
  margin-bottom: 0.18em;
  white-space: nowrap;
}

/* Per-test hide-pinyin mode (tests.hide_pinyin, HSK 4+): the take page hides
   every pinyin line so characters are read cold. Scoped to .test-stage so the
   admin response-review pages keep pinyin regardless of the flag. */
.test-stage.hide-pinyin .zi-py {
  display: none;
}

/* Practice-grid (田字格) treatment for short answer options / review labels
   where the single-character box reads clearly. */
.opt-content .zi-han,
.option-label .zi-han {
  width: auto;
  height: auto;
  border: none;
  background: transparent;
  border-radius: 0;
  font-size: 1em;
  line-height: 1.4;
  padding: 0 1px;
}

.opt-content .zi-han::before,
.option-label .zi-han::before { display: none; }

.opt-content .zi-py,
.option-label .zi-py {
  font-size: 0.56em;
  margin-bottom: 0.15em;
}

/* Answer options are the main thing a student reads on this screen -- both
   the character and its pinyin need to read clearly at arm's length, not
   just be technically present. Font-size set on the *container*, not on
   .zi, so any plain (non-pinyin) punctuation in the same option scales with
   it instead of rendering at the ambient body size. Kept a notch smaller
   than earlier drafts (was 2rem/1.85rem/1.9rem) and with pinyin closer in
   proportion to the hanzi (was 0.45em, i.e. under half the character's
   height) -- oversized hanzi paired with tiny pinyin read as unbalanced
   rather than "clear". */
.opt-content { font-size: 1.65rem; }
.opt-full .opt-content { font-size: 1.55rem; }
.option-label { font-size: 1.55rem; }
.opt-content .zi,
.option-label .zi { font-size: 1em; }

/* ...but only the Chinese ones. An option that is a Russian (or English)
   phrase carries no .zi at all, and set at hanzi size it filled the card edge
   to edge and read like a children's app rather than an exam. It is prose --
   so it gets prose treatment: sans, reading size, left-aligned, free to wrap.
   :has() is the whole test here, and a browser too old for it simply keeps
   the previous single size rather than shrinking any Chinese. */
.opt-content:not(:has(.zi)),
.opt-full .opt-content:not(:has(.zi)) {
  font-family: var(--font-sans);
  font-size: 1.02rem;
  font-weight: 500;
  line-height: 1.45;
  letter-spacing: -0.005em;
  text-align: left;
  text-wrap: pretty;
  align-items: center;
  justify-content: flex-start;
}

/* Search and create bar */
.search-create-bar {
  display: flex;
  gap: 12px;
  margin-bottom: 20px;
  align-items: stretch;
}

.search-create-bar input {
  flex: 1;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: 12px;
  font-size: 1rem;
}

.search-create-bar input:focus {
  outline: none;
  border-color: var(--primary);
}

.search-create-bar .btn {
  padding: 12px 20px;
  white-space: nowrap;
}

/* Test cards */
.test-card {
  transition: transform 0.15s, box-shadow 0.15s;
}

.test-card:hover {
  transform: translateY(-2px);
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.1);
}

.test-card-title {
  margin-bottom: 4px;
}

.test-card-meta {
  color: var(--muted);
  margin: 0 0 8px;
  font-size: 0.95rem;
}

.test-card-desc {
  color: var(--text);
  margin: 0 0 14px;
  font-size: 0.95rem;
}

.test-card-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  align-items: center;
  /* Without a width hint, a table cell auto-sizes a wrapping flex child down
     to its single widest item, so every chip lands on its own line no matter
     how much page width is actually free -- min-width gives the row enough
     room to place two chips per line instead. */
  min-width: 230px;
}

/* Small pill-shaped chips, not loose colored text links -- the old bare-link
   style (just colored text stacked with a 16px gap) reads as a vertical list
   of unrelated links once a table column narrows even slightly. A bounded
   chip with its own background reads as one coherent button group instead,
   and wraps two-per-line rather than one-per-line at the same width. */
.action-link {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 6px 12px;
  border-radius: 999px;
  background: var(--surface-2);
  border: 1px solid var(--border);
  color: var(--text);
  font-weight: 600;
  font-size: 0.83rem;
  text-decoration: none;
  white-space: nowrap;
  transition: background 0.15s, border-color 0.15s, color 0.15s;
}

.action-link:hover {
  text-decoration: none;
  background: var(--surface-2-hover);
  border-color: var(--primary);
  color: var(--primary);
}

.action-link.secondary { color: var(--muted); }

.flash-success { color: var(--green); font-weight: 600; }
.flash-error { color: var(--accent); font-weight: 600; }

.linked-badge {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 999px;
  font-size: 0.8em;
  font-weight: 600;
  margin-right: 6px;
}
.linked-badge.yes { background: var(--green); color: white; }
.linked-badge.no { background: var(--surface-2-hover); color: var(--muted); }

.action-link.danger {
  color: var(--accent);
  border-color: transparent;
}

.action-link.danger:hover {
  background: var(--danger-wash);
  border-color: var(--accent);
}

/* `font: inherit` was here to strip the UA's button font, but it is a
   shorthand -- it also wiped .action-link's own 0.83rem/600, so a
   <button> chip rendered a size bigger and a weight lighter than the
   <a> chip sitting right beside it in the same row of actions. The
   family is handled by .action-link now, so the shorthand can go. */
.action-link.as-link {
  cursor: pointer;
}

/* A plain data-cell link (e.g. a student's name linking to their profile) --
   not an action, so it shouldn't look like a button chip. */
.link-plain {
  color: var(--primary);
  font-weight: 600;
  text-decoration: none;
}

.link-plain:hover { text-decoration: underline; }

/* ---------- Mason's request queue (requests.html) ----------
   A wide multi-column table didn't fit this content -- a free-text
   description can run to a full paragraph, which either crushed every other
   column into a tall skinny strip or forced the whole table wider than the
   viewport. Card-per-request instead: each field gets the width it actually
   needs, the description reads as a normal paragraph, actions get a full-
   width row to lay out in instead of a cramped table cell. */
.request-list {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.request-card { margin-bottom: 0; }

.request-card-top {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 6px;
}

.request-card-student {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  font-size: 1.1rem;
}

.request-card-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  color: var(--muted);
  font-size: 0.88rem;
  margin-bottom: 12px;
}

.request-card-dot { opacity: 0.5; }

.request-card-desc {
  margin: 0 0 16px;
  color: var(--text);
  line-height: 1.6;
}

/* ---------- Student topic breakdown (student_detail.html) ---------- */
.topic-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 12px;
  font-size: 0.92rem;
  margin-bottom: 5px;
}

.topic-name { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }

.topic-category-badge {
  display: inline-flex;
  padding: 2px 9px;
  border-radius: 999px;
  background: var(--primary-light);
  color: var(--primary-dark);
  font-weight: 700;
  font-size: 0.78rem;
  white-space: nowrap;
}

.topic-specific { color: var(--text); }

.topic-lowconf {
  color: var(--muted);
  font-size: 0.78rem;
  font-style: italic;
}

.topic-score { font-weight: 700; white-space: nowrap; }

.topic-bar-track {
  background: var(--surface-2);
  border-radius: 6px;
  height: 8px;
  overflow: hidden;
}

.topic-bar-fill { height: 100%; }
.topic-bar-fill.good { background: var(--green); }
.topic-bar-fill.mid { background: var(--gold); }
.topic-bar-fill.low { background: var(--accent); }
/* Low-confidence (n<3) bars stay neutral gray regardless of pct -- a colored
   full/empty bar from a single data point reads as a verdict it hasn't
   earned yet. */
.topic-bar-fill.low-confidence { background: var(--muted); opacity: 0.5; }

/* ---------- Performance breakdown (student_detail: modes + topics) --------
   Rebuilt 2026-08-03. First pass was full-width bars under each label -- a
   100% topic drew a stripe clear across the card, which looked stretched and
   odd (Duke: "the bars are so weirdly long"). Each row is now ONE line: the
   label, then a short fixed-width meter and a soft score pill grouped hard
   right, so the bar is a compact gauge instead of a wall-to-wall band.
   These are student-page-only classes; the shared .topic-* set above is left
   alone so flashcards_stats and placement_block don't move. */
.perf-list { display: flex; flex-direction: column; gap: 15px; }
.perf-item { display: flex; align-items: center; gap: 14px; }
.perf-label {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  font-size: 0.95rem;
}
.perf-name { color: var(--text); font-weight: 600; }
.perf-note {
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  color: var(--muted);
  background: var(--surface-2);
  padding: 2px 8px;
  border-radius: 999px;
  white-space: nowrap;
}

/* Short gauge, not a full-width bar. Fixed width so every row's meter lines up
   in a column the eye can scan down. */
.perf-meter {
  flex-shrink: 0;
  width: 132px;
  height: 6px;
  border-radius: 999px;
  background: var(--surface-2);
  overflow: hidden;
}
.perf-fill {
  display: block;
  height: 100%;
  border-radius: 999px;
  transform-origin: left center;
  animation: perf-grow 0.6s cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
@keyframes perf-grow { from { transform: scaleX(0); } to { transform: scaleX(1); } }
.perf-fill.good { background: var(--green); }
.perf-fill.mid  { background: var(--gold); }
.perf-fill.low  { background: var(--accent); }
/* A single 100%/0% data point is provisional, not a verdict -- a faint hatch
   rather than a solid bar says so without a paragraph of explanation. */
.perf-fill.low-confidence {
  background: repeating-linear-gradient(45deg,
    var(--muted) 0, var(--muted) 3.5px, transparent 3.5px, transparent 7px);
  opacity: 0.5;
}

/* Fixed width + centred text so the pills form a tidy right-hand column
   rather than jostling by content length. */
.perf-score {
  flex-shrink: 0;
  width: 92px;
  text-align: center;
  font-weight: 700;
  font-size: 0.82rem;
  white-space: nowrap;
  padding: 3px 0;
  border-radius: 999px;
  font-variant-numeric: tabular-nums;
}
.perf-score.good { background: var(--green-light); color: var(--green-dark); }
.perf-score.mid  { background: var(--gold-light); color: var(--gold-dark); }
.perf-score.low  { background: var(--accent-light); color: var(--accent); }
.perf-score.low-confidence { background: var(--surface-2); color: var(--muted); }

/* Phones: the label needs the width more than the gauge does. Drop the meter
   and keep the pill, which carries the number anyway. */
@media (max-width: 520px) {
  .perf-meter { display: none; }
}

.trend-badge {
  display: inline-flex;
  align-items: center;
  padding: 2px 10px;
  border-radius: 999px;
  font-weight: 700;
  font-size: 0.82rem;
  margin-left: 8px;
}

.trend-badge.trend-up { background: var(--green-light); color: var(--green-dark); }
.trend-badge.trend-down { background: var(--accent-light); color: var(--accent); }
.trend-badge.trend-flat { background: var(--surface-2); color: var(--muted); }

.empty {
  text-align: center;
  color: var(--muted);
  padding: 48px 24px;
}

.empty h3 {
  margin: 0 0 8px;
  color: var(--text);
}

.empty p { margin: 0; }

/* Buttons */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 10px;
  padding: 12px 20px;
  /* Buttons don't inherit font by default -- without this every .btn on the
     site rendered in the browser's own Arial, visibly off from the page
     around it (Duke spotted it on the Level Check, 2026-07-30). */
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  text-decoration: none;
  transition: transform 0.1s, background 0.15s;
}

/* .btn sets an explicit display, which beats the browser's own
   `[hidden] { display: none }` -- so without this a button hidden from JS
   stays fully visible. That is how the deck's Back button showed up on the
   first slide, where there is nothing to go back to (2026-07-31); the unlock
   sheet needed the same rule for the same reason. */
.btn[hidden] { display: none; }

.btn:hover { transform: translateY(-1px); }
.btn:disabled {
  opacity: 0.5;
  cursor: not-allowed;
  transform: none;
}

.btn-primary { background: var(--primary); color: white; }
.btn-primary:hover { background: var(--primary-dark); }
.btn-secondary { background: var(--surface-2); color: var(--text); }
.btn-secondary:hover { background: var(--surface-2-hover); }
.btn-danger { background: var(--danger-wash); color: var(--accent); }
.btn-danger:hover { background: var(--danger-wash-hover); }

/* A smaller .btn for secondary in-card actions (e.g. Unlink), so a minor
   control isn't the same visual weight as a page's primary CTA. */
.btn-sm { padding: 8px 15px; font-size: 0.85rem; border-radius: 9px; }

/* Never existed until now, despite the take page's Submit button carrying the
   class since it was written -- the last, irreversible action of the whole
   test rendered as an unstyled grey slab. Green rather than the coral every
   other "next" button uses: it is the finish, not another step. */
.btn-success { background: var(--green-dark); color: #fff; }
.btn-success:hover { background: var(--green); color: var(--text); }

/* Section titles and questions */
/* A section heading is a chapter marker, not the loudest thing on the page --
   the question under it is. It used to be 1.65rem/800 over a 2px coral rule
   spanning the column, which out-shouted the question by a wide margin. */
.section-title {
  margin: 0 0 18px;
  padding-bottom: 10px;
  color: var(--text);
  border-bottom: 1px solid var(--border);
  line-height: 1.3;
}

.section-title .chinese {
  color: var(--primary-dark);
  font-weight: 800;
}

.section-title .separator {
  margin: 0 10px;
  color: var(--muted);
  font-weight: 400;
  font-size: 0.85em;
  vertical-align: middle;
}

.section-title .translation {
  color: var(--muted);
  font-weight: 600;
  font-size: 0.85em;
  vertical-align: middle;
}

.section-desc {
  margin: 0 0 24px;
  padding: 10px 0;
}

.section-desc .chinese {
  color: var(--text);
  margin-bottom: 6px;
  font-size: 1.05rem;
  font-weight: 500;
}

.section-desc .translation {
  color: var(--muted);
  font-size: 0.95rem;
}

.question { margin-bottom: 28px; }
.question:last-child { margin-bottom: 0; }

/* Unanswered question. Was a filled rounded block of --danger-wash behind the
   whole question, which Duke cut on sight: "we don't really need that red
   colour block behind... it's ugly". A filled panel is also the one thing this
   screen spent a release removing (see "the test card dissolve" above) -- it
   put a dialog back on an open page. A rule in the margin says the same thing
   without introducing a surface; the shake below is what actually gets noticed
   anyway, and this is just the trace it leaves behind. */
.question.error {
  border-left: 3px solid var(--accent);
  padding-left: 16px;
  margin-left: -19px; /* keeps the text on the same column as every other page */
}

/* Narrower than this and there is no gutter left to hang the rule in -- at
   792px + 16px padding it would sit against (or past) the screen edge. Below
   the breakpoint the rule indents the question instead of hanging beside it. */
@media (max-width: 860px) {
  .question.error { margin-left: 0; padding-left: 13px; }
}

/* Unanswered required question: the red wash alone is a passive, easy-to-miss
   signal -- students press "next" again and nothing appears to happen. The
   answers themselves now jiggle, which is both where the eye already is and
   where the missing action belongs. app.js restarts the class on every failed
   press, so a second press shakes again rather than sitting still.

   Whole containers vs. their children: matching shakes as one .match-grid
   because its connector lines are an SVG positioned over the grid -- shaking
   the items individually would tear the lines off them. Options and ordering
   chips shake one by one, staggered, so it reads as a ripple of panic instead
   of a rigid block sliding sideways. */
@keyframes optJiggle {
  0%   { transform: translateX(0)      rotate(0); }
  12%  { transform: translateX(-9px)   rotate(-1.1deg); }
  25%  { transform: translateX(8px)    rotate(0.9deg); }
  38%  { transform: translateX(-6px)   rotate(-0.7deg); }
  51%  { transform: translateX(5px)    rotate(0.55deg); }
  64%  { transform: translateX(-3.5px) rotate(-0.35deg); }
  77%  { transform: translateX(2px)    rotate(0.2deg); }
  89%  { transform: translateX(-1px)   rotate(-0.1deg); }
  100% { transform: translateX(0)      rotate(0); }
}

.opt-grid.is-shaking .opt,
.order-bank.is-shaking .order-chip,
.match-grid.is-shaking,
input[type="text"].is-shaking {
  animation: optJiggle 0.5s cubic-bezier(0.36, 0.07, 0.19, 0.97) both;
}

.opt-grid.is-shaking .opt,
.order-bank.is-shaking .order-chip,
input[type="text"].is-shaking {
  border-color: var(--accent);
}

.opt-grid.is-shaking > *:nth-child(2),
.order-bank.is-shaking > *:nth-child(2) { animation-delay: 0.04s; }
.opt-grid.is-shaking > *:nth-child(3),
.order-bank.is-shaking > *:nth-child(3) { animation-delay: 0.08s; }
.opt-grid.is-shaking > *:nth-child(4),
.order-bank.is-shaking > *:nth-child(4) { animation-delay: 0.12s; }
.opt-grid.is-shaking > *:nth-child(n+5),
.order-bank.is-shaking > *:nth-child(n+5) { animation-delay: 0.16s; }

/* Motion-sensitive students still get the red borders, just no movement. */
@media (prefers-reduced-motion: reduce) {
  .opt-grid.is-shaking .opt,
  .order-bank.is-shaking .order-chip,
  .match-grid.is-shaking,
  input[type="text"].is-shaking {
    animation: none;
  }
}

/* The question has to out-rank its answer options -- otherwise the eye lands
   on the answers first and the actual question reads as an afterthought. That
   used to be done with a tinted, bordered box, which stopped making sense once
   the card around it dissolved: a panel inside a page that had deliberately
   stopped being a panel, with the answer cards as a *third* stacked surface.
   Rank now comes from type -- serif, large, near-black, against options that
   are set in sans at reading size -- so the question is the only thing on the
   page that looks like a headline and nothing has to be boxed to be seen. */
.question-label {
  display: block;
  margin-bottom: 22px;
}

.question-label-main {
  display: block;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 1.55rem;
  line-height: 1.45;
  color: var(--text);
  letter-spacing: -0.015em;
  text-wrap: pretty;
}

.question-label-translation {
  display: block;
  margin-top: 6px;
  font-size: 1rem;
  font-weight: 500;
  color: var(--muted);
  line-height: 1.5;
}

.question-prompt {
  display: block;
  margin-bottom: 20px;
}

.question-prompt-main {
  font-family: var(--font-display);
  display: block;
  font-weight: 600;
  font-size: 1.2rem;
  color: var(--text);
  line-height: 1.5;
}

.question-prompt-translation {
  display: block;
  margin-top: 4px;
  font-size: 0.98rem;
  font-weight: 500;
  color: var(--muted);
}

/* The number is a label on the question, not part of the sentence: sans (the
   supporting face) at a size that reads as a marker, so the Chinese keeps the
   whole of the serif headline to itself. */
.question-label .qn {
  display: block;
  margin-bottom: 7px;
  color: var(--primary);
  font-family: var(--font-sans);
  font-size: 0.78rem;
  font-weight: 700;
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.12em;
}

.required {
  color: var(--accent);
  font-size: 0.65em;
  vertical-align: super;
  margin-left: 3px;
  opacity: 0.85;
}

.options {
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.option {
  display: flex;
  align-items: center;
  padding: 14px 16px;
  border: 1px solid var(--border);
  border-radius: 12px;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
  font-size: 1.2rem;
}

.option:hover {
  background: var(--surface-2);
  border-color: var(--primary);
}

.option input[type="radio"] {
  margin-right: 14px;
  width: 20px;
  height: 20px;
  accent-color: var(--primary);
  flex-shrink: 0;
}

.option-label { flex: 1; }

/* ---------- Live test-taking answer cards ----------
   Sized for the answer, not for the box. The previous version padded every
   option to 18px and set all of them at hanzi size, so a four-option Russian
   question became four full-width slabs of 1.55rem text -- Duke: "the answer
   fields are so big, doesn't feel premium or aesthetic at all". A single
   character still needs to be large to be legible; a Russian phrase read at
   that size looks like a children's app. See .opt-content below, which is
   where the two cases finally part company.

   Each option also carries an A/B/C/D marker, which is what HSK itself puts
   on the page: it gives the row a fixed left anchor to scan down, and it is a
   better place to show "this one is chosen" than a tick floating in a corner. */
.opt-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  counter-reset: opt;
}

.opt {
  /* flex-grow: 0 -- a lone option wrapping onto its own row (odd option
     count) must not stretch to fill the whole row; it should stay the
     same width as its siblings, leaving the row half-empty instead. */
  flex: 0 1 calc(50% - 5px);
  min-width: 150px;
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 13px 16px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--card);
  cursor: pointer;
  position: relative;
  transition: border-color 0.16s ease, background 0.16s ease,
              box-shadow 0.16s ease, transform 0.12s ease;
  text-align: left;
}

/* The A/B/C/D marker. Counter rather than markup, so every question type that
   reuses .opt-grid gets it without touching the templates. */
.opt::before {
  counter-increment: opt;
  content: counter(opt, upper-alpha);
  flex: none;
  width: 26px;
  height: 26px;
  display: flex;
  align-items: center;
  justify-content: center;
  /* A circle, not a rounded square: the square marker read as a raw box
     jumping out on select (Duke, 2026-08-03), and a round badge matches the
     avatar/step marks used everywhere else in the app. */
  border-radius: 50%;
  background: var(--surface-2);
  color: var(--muted);
  font-family: var(--font-sans);
  font-size: 0.78rem;
  font-weight: 700;
  line-height: 1;
  transition: background 0.16s ease, color 0.16s ease;
}

.opt:hover {
  border-color: var(--primary);
  box-shadow: 0 6px 18px var(--coral-glow);
  transform: translateY(-1px);
}

.opt:active { transform: translateY(0); }

/* Two prose answers side by side stop fitting somewhere around here and start
   breaking mid-phrase ("быть / уверенным"). The first fix was to give each
   prose answer the full row -- but a one-word answer like "временный" then
   sat in a bar that was half empty (Duke, 2026-08-03: "the bar is too big,
   50% of the field is empty"). So instead of stretching each answer to the
   full width, let it hug its own text: the grid becomes a left-aligned column
   of pills, each only as wide as its content. Short answers get short bars; a
   long phrase can still grow to the full column width and wrap at spaces --
   which is all the full-row rule was ever protecting against (a narrow 50%
   column was what broke phrases mid-word, not wrapping as such).
   .opt-prose comes from the template rather than :has(): the obvious selector
   here nests one :has() inside another, which the spec forbids -- it parses as
   invalid and the whole rule is silently dropped. The single :has() on the
   grid below is fine (option focus styling already relies on :has support);
   without it, prose options still hug their content, just flowing in a
   wrapping row rather than a column -- an acceptable fallback. */
@media (max-width: 640px) {
  .opt-grid:has(.opt-prose) {
    flex-direction: column;
    align-items: flex-start;
    gap: 8px;
  }
  .opt-grid .opt.opt-prose {
    flex: 0 0 auto;
    width: auto;
    max-width: 100%;
    padding: 11px 18px;
  }
}

.opt .opt-radio {
  position: absolute;
  opacity: 0;
  pointer-events: none;
  /* Collapsed to zero so no browser draws a focus outline square around the
     invisible native control when the label is clicked (the "square that shows
     out" Duke saw -- Safari paints that ring even at opacity 0). The card
     carries the focus ring instead, via :has() below. */
  width: 0;
  height: 0;
  margin: 0;
}
.opt .opt-radio:focus { outline: none; }
/* Keyboard focus lands on the card itself, so arrow-keying through options is
   still visible without the raw native ring. */
.opt:has(.opt-radio:focus-visible) {
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--coral-glow);
}

.opt-content {
  display: inline-flex;
  flex-wrap: wrap;
  align-items: flex-end;
  justify-content: center;
  gap: 0.03em;
  min-width: 0;
  max-width: 100%;
}

.opt-full .opt-content {
  justify-content: flex-start;
}

/* Passage text should read like a normal paragraph, not a grid of boxes. */
.passage-text .zi {
  font-size: 1.1em;
  margin: 0 1px 0.55em;
  line-height: 1.6;
}

.passage-text .zi-han::before { display: none; }

.passage-text {
  line-height: 2.8;
  font-size: 1.1rem;
  margin-bottom: 16px;
}

.passage-text br {
  display: block;
  content: "";
  margin-bottom: 1.1em;
}

.passage-text p {
  margin: 0 0 14px;
}

.passage-text p:last-child { margin-bottom: 0; }

/* Reading-comprehension sub-questions: keep their answer options the same
   comfortable, consistent size as other multiple-choice questions. */
.passage-block .opt-grid { width: 100%; }

.passage-block .question-label {
  margin-bottom: 14px;
}

.passage-block .opt {
  flex-basis: 100%;
}

.passage-block .opt-content { font-size: 1.5rem; justify-content: flex-start; }
/* ...and the same prose exception as everywhere else -- this selector would
   otherwise out-specify it and put reading-comprehension answers back at
   hanzi size. */
.passage-block .opt-content:not(:has(.zi)) { font-size: 1.02rem; }

/* Chosen: the marker fills in. The old treatment was a coral disc with a tick
   pinned to the top-right corner -- a second, unrelated object on a card that
   now has a marker of its own doing the same job in the place the eye already
   scans. */
.opt.selected-answer {
  border-color: var(--primary);
  background: var(--primary-light);
  box-shadow: 0 6px 18px var(--coral-glow);
  /* Choosing an answer should feel like pressing something, not like a colour
     changing. The card takes the tap and springs back -- the overshoot in the
     curve is the whole point of it. */
  animation: optPick 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}

@keyframes optPick {
  0%   { transform: scale(0.985); }
  45%  { transform: scale(1.018) translateY(-2px); }
  100% { transform: none; }
}

/* An answer tapped while the page is still dealing itself out: the tap wins.
   Without this the deal's rule out-specifies the pick, the card carries on
   arriving as if nothing had been pressed, and the spring plays late -- at the
   moment the deal class comes off. */
.page.dealing .opt-grid > .opt.selected-answer {
  animation: optPick 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}

@media (prefers-reduced-motion: reduce) {
  .opt.selected-answer { animation: none; }
}

.opt.selected-answer::before {
  background: var(--primary);
  color: #fff;
}

.opt-full {
  flex-basis: 100%;
  min-width: 0;
}

.opt-full .opt-content {
  display: block;
  max-width: 100%;
}

/* A typed answer is the answer card for its question -- it should carry the
   same weight as the .opt cards other questions get, not the site's ambient
   form-field styling. */
.question > input[type="text"] {
  padding: 16px 18px;
  font-size: 1.15rem;
  border-radius: 12px;
  transition: border-color 0.16s ease, box-shadow 0.16s ease;
}

.question > input[type="text"]:focus {
  box-shadow: 0 6px 18px var(--coral-glow);
}

/* ---------- True/False (reuses .opt-grid/.opt for layout + selection) ---------- */
/* No A/B marker here: the two options are 对 and 错: they label themselves,
   and a letter in front of them would only be a second thing to read. */
.tf-grid .opt {
  justify-content: center;
  padding: 18px 14px;
}

.tf-grid .opt::before { display: none; }

.tf-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
}

.tf-char {
  font-family: var(--font-hanzi);
  font-size: 2.2rem;
  font-weight: 600;
}

.tf-content small {
  font-family: var(--font-sans);
  font-size: 0.82rem;
  font-weight: 800;
  color: var(--muted);
}

.opt.selected-answer .tf-content small { color: var(--primary-dark); }

/* ---------- Ordering (word/sentence scramble) ---------- */
.order-bank {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 4px;
}

.order-chip {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 14px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--card);
  color: var(--text);
  font-family: inherit;
  cursor: pointer;
  font-size: 1.7rem;
  transition: border-color 0.16s ease, background 0.16s ease,
              box-shadow 0.16s ease, transform 0.12s ease;
}

.order-chip:hover {
  border-color: var(--primary);
  box-shadow: 0 6px 18px var(--coral-glow);
  transform: translateY(-1px);
}

.order-chip:active { transform: translateY(0); }

.order-chip.picked {
  border-color: var(--primary);
  background: var(--primary-light);
}

.order-chip .zi { margin: 0; font-size: 1em; }

.seq-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--primary);
  color: var(--card);
  /* Sans, everywhere. This is 0.7rem, under the serif floor, so the
     display face has no detail left to show at this size and just reads
     as "a different font" -- which is the rule the type block states.
     It used to be serif here and sans on the Level Check (via a
     stage-scoped override that is now gone), so the same badge had two
     faces depending on the page. */
  font-family: var(--font-sans);
  font-weight: 700;
  font-size: 0.7rem;
  padding: 0 4px;
}

.order-chip:not(.picked) .seq-badge { display: none; }

/* ---------- Matching (tap left, then its right match) ---------- */
/* Grid + subgrid, not two independent flex columns: the left and right
   lists are shuffled independently (see app.js) and their text lengths
   differ (short hanzi vs. longer Russian phrases), so if each column sizes
   its own boxes from its own content, one long right-side entry makes that
   row taller only on the right -- every row below it then drifts out of
   sync with its left-column counterpart. Subgrid lets each column share the
   parent's row tracks (so a row's height is the max of both sides) while
   still auto-placing/reordering its own children independently via the JS
   `order` shuffle -- a single flat grid would apply `order` across both
   columns at once and scramble the left side too. */
.match-grid {
  position: relative;
  display: grid;
  grid-template-columns: 1fr 1fr;
  column-gap: 48px;
  margin-bottom: 4px;
}

.match-lines {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
  overflow: visible;
  z-index: 1;
}

.match-col {
  display: grid;
  grid-template-rows: subgrid;
  grid-row: 1 / -1;
  row-gap: 10px;
  min-width: 0;
  position: relative;
  z-index: 2;
}

.match-item {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  row-gap: 2px;
  padding: 12px 10px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--card);
  color: var(--text);
  font-family: inherit;
  cursor: pointer;
  text-align: center;
  min-height: 72px;
  transition: background 0.15s, border-color 0.15s;
}

/* Long (3+ character) words shrink slightly so they don't wrap inside a
   half-width column and end up visibly taller than the other boxes. */
.match-item[data-side="left"] { font-size: 1.5rem; }
.match-item[data-side="left"] .zi { margin: 0; font-size: 1em; }

.match-right-label {
  font-family: var(--font-sans);
  font-size: 1rem;
  font-weight: 700;
  color: var(--text);
}

.match-item:hover { border-color: var(--primary); box-shadow: 0 6px 18px var(--coral-glow); }
.match-item.active { border-color: var(--primary); background: var(--primary-light); }
.match-item.matched {
  border-color: var(--green);
  background: var(--green-light);
  cursor: default;
}
.match-item.matched .match-right-label { color: var(--green-dark); }

/* ---------- Reading passage ---------- */
.passage-block { display: block; }

.passage-card {
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 18px 20px;
  margin-bottom: 22px;
}

/* Listening passages with no visible text are just the audio: boxing a lone
   player inside a big empty surface-2 card is what made it look unfinished
   (Duke, 2026-08-03). Drop the box and let the player -- now a finished card
   in its own right -- stand on its own. */
.passage-card.passage-audio-only {
  background: none;
  border: none;
  padding: 0;
  margin-bottom: 18px;
}
.passage-card.passage-audio-only .audio-player { margin: 0; }

.passage-title {
  margin: 0 0 10px;
  font-size: 1.2rem;
  color: var(--primary-dark);
}

.passage-text {
  line-height: 2.4;
  font-size: 1.05rem;
}

.passage-translation {
  margin: 12px 0 0;
  padding-top: 12px;
  border-top: 1px dashed var(--border);
  color: var(--muted);
  font-size: 0.92rem;
}

/* ---------- Cloze (multi-blank passage, HSK reading-fill style) ----------
   The passage renders once with circled-number markers (①②③...) baked
   right into its pairs at the blank positions; the actual answer choices for
   each blank live in a separate numbered list below rather than inline
   dropdowns-in-flowing-text, which stays readable at small screen widths and
   matches how HSK reading Part 1 actually lays the numbered blanks + their
   options out (passage once, then "56. A ... B ... C ... D ..." below it). */
.cloze-blanks {
  display: flex;
  flex-direction: column;
  gap: 18px;
}

.cloze-blank {
  display: flex;
  align-items: flex-start;
  gap: 12px;
}

.cloze-blank-num {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 26px;
  height: 26px;
  margin-top: 2px;
  border-radius: 50%;
  background: var(--primary);
  color: #fff;
  font-weight: 800;
  font-size: 0.85rem;
}

.cloze-blank .opt-grid { flex: 1; }

/* Inline blank marker baked into the passage text itself (render.py turns
   each circled-digit char ①②③... into this span) -- a single underscore
   sitting on the normal text baseline like any other inline character
   (not the two-row pinyin/character stack used elsewhere in the app,
   which put the number in the elevated pinyin slot and read as "floating"
   above the sentence). The number is layered directly onto the underscore
   via absolute positioning, so it reads as inside the blank. Deliberately
   NOT the "zi-py" class anywhere here -- that class is hidden outright on
   hide_pinyin tests, and this number isn't pinyin, it's the blank's
   identity (matches the numbered answer list below), so it must stay
   visible either way. */
.cloze-inline-blank {
  position: relative;
  display: inline-block;
  min-width: 1.5em;
  margin: 0 0.15em;
  border-bottom: 3px solid var(--primary);
  vertical-align: baseline;
}

.cloze-inline-blank-num {
  display: block;
  text-align: center;
  font-family: var(--font-sans);
  font-size: 0.6rem;
  line-height: 1;
  font-weight: 900;
  color: var(--primary);
  transform: translateY(-2px);
}

/* ---------- Listening audio (added 2026-07-14, restyled 2026-07-14) ----------
   audio_url is an optional add-on field any question type can carry
   (multiple_choice for listening-comprehension MC, fill_blank/text for
   dictation, passage for a full listening passage). The server always
   renders a plain native <audio controls> element (.listening-audio) -- a
   real, working, accessible fallback. app.js then progressively enhances it
   into this themed player (hides native controls, builds a custom play
   button + seek bar + time readout matching the app's coral/dark palette) so
   it doesn't look like a bare OS media widget dropped into an otherwise
   fully custom-designed form. If JS fails to run for any reason, the native
   controls are simply never hidden and the audio is still fully usable. */
/* A finished, self-contained player: a card (not the flat surface-2 wash it
   used to sit in) with a soft coral lift, so it reads as a deliberate control
   rather than an unstyled bar dropped on the page (Duke, 2026-08-03: "the
   audio just sticks out... undone and raw"). */
.audio-player {
  display: flex;
  align-items: center;
  gap: 14px;
  width: 100%;
  max-width: 440px;
  margin: 10px 0 18px;
  padding: 11px 16px 11px 11px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--card);
  box-shadow: 0 4px 16px var(--coral-glow);
}

.audio-play-btn {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 42px;
  height: 42px;
  border: none;
  border-radius: 50%;
  background: var(--primary);
  color: #fff;
  cursor: pointer;
  box-shadow: 0 2px 8px var(--coral-glow);
  transition: background 0.15s, transform 0.1s;
}

.audio-play-btn:hover { background: var(--primary-dark); }
.audio-play-btn:active { transform: scale(0.94); }

.audio-play-btn svg { width: 16px; height: 16px; }
/* Play icon nudged half a pixel right so the triangle looks visually
   centered in its circle (a symmetric triangle sits left-heavy otherwise). */
.audio-play-btn .icon-play { margin-left: 2px; }

.audio-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 6px; }

.audio-seek-track {
  position: relative;
  height: 6px;
  border-radius: 999px;
  background: var(--border);
  cursor: pointer;
}

.audio-seek-fill {
  position: absolute;
  inset: 0;
  width: 0%;
  border-radius: 999px;
  background: var(--primary);
  pointer-events: none;
}

.audio-time {
  font-size: 0.78rem;
  font-weight: 600;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

.listening-audio.js-enhanced {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.passage-block > .question {
  padding-top: 18px;
  border-top: 1px solid var(--border);
}

.passage-block > .question:first-of-type { border-top: none; padding-top: 0; }

/* type="email" added 2026-07-15 after the Level Check email field shipped
   with a white browser-default background -- same root cause as the login
   password field bug: each input *type* needs its own explicit selector,
   nothing here inherits automatically across types. */
input[type="text"], input[type="email"], textarea {
  width: 100%;
  padding: 12px;
  border: 1px solid var(--border);
  border-radius: 10px;
  font-size: 1rem;
  font-family: inherit;
  background: var(--card);
  color: var(--text);
}

input[type="text"]:focus, input[type="email"]:focus, textarea:focus {
  outline: none;
  border-color: var(--primary);
}

/* Form fields */
.form-field { margin-bottom: 16px; }
.form-field label {
  display: block;
  font-weight: 600;
  margin-bottom: 6px;
}

/* ---------- Custom select (replaces the raw OS/Safari dropdown look) ----------
   The native <select> stays in the DOM (visually hidden, not display:none-only
   for a form -- but hidden controls still submit fine) so form submission needs
   zero changes; app.js just builds a styled trigger+listbox in front of it and
   keeps them in sync both ways. */
.custom-select { position: relative; }

.custom-select-native {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
  pointer-events: none;
}

.custom-select-trigger {
  width: 100%;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--card);
  color: var(--text);
  font-size: 1rem;
  font-family: inherit;
  cursor: pointer;
  text-align: left;
  transition: border-color 0.15s;
}

.custom-select-trigger:hover { border-color: var(--primary); }

.custom-select.open .custom-select-trigger {
  border-color: var(--primary);
  border-bottom-left-radius: 4px;
  border-bottom-right-radius: 4px;
}

.custom-select-trigger .chevron {
  flex-shrink: 0;
  width: 10px;
  height: 10px;
  border-right: 2px solid var(--muted);
  border-bottom: 2px solid var(--muted);
  transform: rotate(45deg);
  margin-top: -4px;
  transition: transform 0.15s;
}

.custom-select.open .custom-select-trigger .chevron {
  transform: rotate(-135deg);
  margin-top: 4px;
}

.custom-select-menu {
  position: absolute;
  left: 0;
  right: 0;
  top: 100%;
  z-index: 30;
  margin-top: -1px;
  background: var(--card);
  border: 1px solid var(--primary);
  border-top: none;
  border-radius: 0 0 12px 12px;
  box-shadow: var(--shadow);
  max-height: 280px;
  overflow-y: auto;
  display: none;
}

.custom-select.open .custom-select-menu { display: block; }

.custom-select-option {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 10px 14px;
  font-size: 0.95rem;
  color: var(--text);
  cursor: pointer;
}

.custom-select-option:hover,
.custom-select-option.hovered { background: var(--surface-2); }

.custom-select-option.selected {
  color: var(--primary);
  font-weight: 700;
}

.submit-btn {
  background: var(--primary);
  color: white;
  border: none;
  border-radius: 12px;
  padding: 14px 28px;
  font-size: 1.05rem;
  font-weight: 600;
  cursor: pointer;
  display: block;
  margin: 32px auto 0;
}

.submit-btn:hover { background: var(--primary-dark); }

/* Progress strip -- one cell per question, filled as you go, instead of a
   generic gradient bar. Cells are rendered server-side (one per question). */
.progress-strip {
  display: flex;
  gap: 5px;
  margin-bottom: 20px;
}

.progress-strip .cell {
  flex: 1;
  height: 7px;
  border-radius: 3px;
  background: var(--grid-line);
  transition: background 0.2s ease;
}

.progress-strip .cell.is-done { background: var(--primary); }
.progress-strip .cell.is-current { background: var(--gold); }

/* ---------- Level Check (adaptive placement test, added 2026-07-14) ---------- */
.placement-intro { text-align: left; }

/* First/last name side by side on the Level Check start form. Same idea as
   .nc-field-row on the auth pages, but this form wraps its inputs in
   .question rather than .nc-field, so it needs its own rule instead of
   reusing that one. Collapses to stacked on narrow screens -- two half-width
   name fields on a phone are worse than two full-width ones. */
.placement-field-row { display: flex; gap: 12px; }
.placement-field-row > .question { flex: 1; min-width: 0; }
@media (max-width: 520px) {
  .placement-field-row { display: block; }
}

.placement-steps {
  display: flex;
  flex-direction: column;
  gap: 14px;
  margin: 20px 0 24px;
  padding: 16px 18px;
  background: var(--surface-2);
  border-radius: 14px;
}

.placement-step {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  font-size: 0.92rem;
  line-height: 1.5;
}

.placement-step-num {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--primary);
  color: #fff;
  font-weight: 800;
  font-size: 0.8rem;
}

/* Vocabulary checklist -- CSS-only checkbox-chip toggle, no JS needed. */
.vocab-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
  gap: 10px;
  margin: 20px 0;
}

.vocab-chip input[type="checkbox"] {
  position: absolute;
  width: 1px;
  height: 1px;
  opacity: 0;
}

.vocab-chip-content {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  padding: 14px 8px;
  border: 1.5px solid var(--border);
  border-radius: 12px;
  background: var(--card);
  cursor: pointer;
  text-align: center;
  transition: background 0.15s, border-color 0.15s, color 0.15s;
}

.vocab-chip input:checked + .vocab-chip-content {
  background: var(--primary);
  border-color: var(--primary);
  color: #fff;
}

.vocab-chip-py {
  font-size: 0.7rem;
  font-weight: 700;
  color: var(--primary-dark);
}

.vocab-chip input:checked + .vocab-chip-content .vocab-chip-py { color: rgba(255,255,255,0.85); }

.vocab-chip-han {
  font-size: 1.3rem;
  font-weight: 500;
  font-family: var(--font-hanzi);
}

/* Result page */
/* The headline number stands on the page like a display figure, not inside a
   beige admin slab (Duke, 2026-07-31). On the result page the digits are
   drums: JS swaps the plain number for one strip of 0-9 per digit, and each
   strip rolls to rest -- right-most drums spinning longest, slot-machine
   style. With JS off or reduced motion the plain number simply stays. */
.placement-word-count {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  margin: 22px 0 8px;
}

.placement-word-count-num {
  font-size: 3.2rem;
  color: var(--primary);
  line-height: 1;
  --odo-h: 1.06em;
}

.pr-wordcount .placement-word-count-num {
  display: inline-flex; align-items: baseline;
}
.pr-odo-prefix { align-self: center; opacity: 0.55; margin-right: 1px; }
/* The thousands separator between two drums. Aligned to the bottom of the
   digit row, not the baseline of a rolling strip, so it doesn't ride up. */
.pr-odo-sep { display: inline-block; height: var(--odo-h); line-height: var(--odo-h); }
.pr-odo-drum {
  display: inline-block; height: var(--odo-h); overflow: hidden;
}
.pr-odo-strip {
  display: block;
  transition: transform 1s cubic-bezier(0.16, 1, 0.3, 1);
  transition-delay: 0.25s;
}
.pr-odo-strip span {
  display: block; height: var(--odo-h); line-height: var(--odo-h);
  font-variant-numeric: tabular-nums;
}

.placement-word-count-label {
  color: var(--muted);
  font-size: 0.9rem;
  text-align: center;
  margin-top: 6px;
}

@media (max-width: 480px) {
  .placement-word-count-num { font-size: 2.6rem; }
}

/* ---------- Level Check type ----------
   REMOVED 2026-08-05, and worth saying why, because it looked harmless.

   This block used to set the face by HTML TAG and scope it to the page:
   `body.placement-stage h1, h2, h3 { font-family: display }`. Two things
   went wrong with that, and both of them shipped.

   1. It decided a font by tag, not by role. Anything that happened to be
      an <h3> came out serif whether or not it was a heading, so the face
      depended on which element the template reached for -- and .pr-skills-
      title, a caps label, came out in the display serif because of it.

   2. It scored (0,1,2), which beats a plain component class. So the only
      way for a component to say "no, I am a label" was to write ITSELF a
      stage-scoped twin at the same specificity, and two of those existed
      (.pr-skills-title, .seq-badge). That is how .seq-badge ended up
      SERIF in the test-taking flow and SANS in the Level Check -- one
      badge, two faces, decided by which URL you were on. It is the exact
      "mismatches everywhere" Duke reported on 2026-08-05.

   Roles handle all of it now, page-independently. The stage keeps only
   its body default; everything else belongs to whatever role it plays.
   Chinese still resolves through --font-hanzi / --font-hanzi-serif --
   neither Source Serif 4 nor Inter covers hanzi. */
body.placement-stage { font-family: var(--font-sans); }
body.placement-stage .result-stamp .score .of,
body.placement-stage .stamp span {
  font-family: var(--font-display);
  font-weight: 700;
}

/* ---------- Level Check result screen (rebuilt 2026-07-27) ----------
   The old screen was three stacked blocks with no moment of arrival:
   stamp, then a number, then bare bars. This gives it one reveal and a
   single clear next step. */
/* The card is a certificate: a gold outer rule and an inner hairline frame,
   like a diploma -- this is a credential, and it should look like one worth
   screenshotting. The shake is the stamp's impact travelling through the
   paper. */
/* One hairline gold edge, not two. The inner ::after rule was meant to read
   as a diploma mat but on a white card it just looked like a doubled border
   -- a rendering mistake rather than a flourish (Duke, 2026-07-31).
   Portrait-capped as well: at full container width the card was a ~1030px
   slab wrapped around a ~250px column of centred content, so most of it was
   empty white. A certificate should be about as wide as its contents. */
.placement-result {
  text-align: center;
  max-width: 620px;
  margin-left: auto;
  margin-right: auto;
  border: 1px solid rgba(201, 143, 28, 0.3);
  animation: prSlamShake 0.32s ease-out 0.6s;
}
@keyframes prSlamShake {
  0%, 100% { transform: translate(0, 0); }
  30%      { transform: translate(0, 4px); }
  55%      { transform: translate(-2px, -1px); }
  78%      { transform: translate(2px, 1px); }
}
/* z-index:0 makes the hero its own stacking context, so the rays' negative
   z-index tucks them behind the stamp without falling behind the card. */
.pr-hero { position: relative; z-index: 0; }
/* Slow-turning sunburst behind the badge -- the "something big happened"
   glow. Masked so it dissolves well before the card edges. */
.pr-stamp .pr-rays {
  position: absolute; inset: -150px; z-index: -1;
  /* Load-bearing. The rays are a 372px box reaching 150px past the stamp on
     every side, which lands squarely on top of the slide's Next button --
     and z-index: -1 only changes what PAINTS in front, not what receives the
     click. Without this the whole deck was unadvanceable (Duke, 2026-07-31):
     Next looked normal, painted on top, and silently ate every tap. Any
     decorative layer that can overhang a control needs this. */
  pointer-events: none;
  border-radius: 50%;
  /* Sparse, faint spokes -- an aura, not a circus tent. The first dark-mode
     cut at 0.14 alpha with fat 9deg spokes swallowed the whole card. */
  background: repeating-conic-gradient(from 0deg,
    var(--pr-ray, rgba(240, 180, 41, 0.09)) 0deg 5deg,
    rgba(240, 180, 41, 0) 5deg 24deg);
  -webkit-mask-image: radial-gradient(circle, black 12%, transparent 58%);
  mask-image: radial-gradient(circle, black 12%, transparent 58%);
  animation: prRaysTurn 48s linear infinite, prRaysIn 0.8s ease-out 0.5s backwards;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pr-stamp .pr-rays { --pr-ray: rgba(240, 180, 41, 0.06); }
}
@keyframes prRaysTurn { to { transform: rotate(360deg); } }
@keyframes prRaysIn { from { opacity: 0; } }
/* A few gold motes drifting in the hero air. pointer-events: none for the
   same reason as the rays above -- they are absolutely positioned in the
   hero and must never intercept a tap. */
.pr-sparks { pointer-events: none; }
.pr-sparks i {
  position: absolute; width: 6px; height: 6px; border-radius: 50%;
  background: var(--gold);
  animation: prSparkFloat 4.6s ease-in-out infinite alternate;
}
.pr-sparks i:nth-child(1) { left: 12%; top: 34%; animation-delay: -0.6s; }
.pr-sparks i:nth-child(2) { left: 22%; top: 68%; width: 4px; height: 4px; animation-delay: -2.1s; }
.pr-sparks i:nth-child(3) { left: 31%; top: 22%; width: 5px; height: 5px; animation-delay: -3.4s; }
.pr-sparks i:nth-child(4) { right: 13%; top: 40%; animation-delay: -1.5s; }
.pr-sparks i:nth-child(5) { right: 24%; top: 72%; width: 4px; height: 4px; animation-delay: -3.9s; }
.pr-sparks i:nth-child(6) { right: 30%; top: 18%; width: 5px; height: 5px; animation-delay: -2.7s; }
@keyframes prSparkFloat {
  from { transform: translateY(5px);  opacity: 0.2; }
  to   { transform: translateY(-9px); opacity: 0.75; }
}
.pr-overline {
  margin: 4px 0 2px;
  color: var(--muted);
  animation: prRise 0.45s ease-out 0.12s backwards;
}
/* The stamp SLAMS: drops from above the page's plane, over-rotates a touch
   past its resting -8deg tilt, and settles -- a passport stamp coming down.
   Scoped under .pr-hero to outrank .result-stamp's default popIn. */
.pr-hero .pr-stamp {
  animation: prStampSlam 0.5s cubic-bezier(0.22, 1.1, 0.35, 1) 0.22s backwards;
}
@keyframes prStampSlam {
  0%   { opacity: 0; transform: scale(2.4) rotate(5deg); }
  55%  { opacity: 1; transform: scale(0.94) rotate(-10deg); }
  76%  { transform: scale(1.05) rotate(-7deg); }
  100% { transform: scale(1) rotate(-8deg); }
}
/* Shockwave ring expanding from the rim at the moment of impact. */
.pr-stamp .pr-stamp-wave {
  position: absolute; inset: -6px; border-radius: 50%;
  border: 3px solid var(--gold);
  opacity: 0; pointer-events: none;
  animation: prStampWave 0.7s ease-out 0.56s;
}
@keyframes prStampWave {
  0%   { opacity: 0.85; transform: scale(0.68); }
  100% { opacity: 0;    transform: scale(1.8); }
}
/* The burst fires around the stamp from points spread across the badge, not
   one corner -- timed to the slam's impact, not the page load. */
.pr-burst { position: absolute; inset: 0; pointer-events: none; overflow: visible; z-index: 2; }
.pr-burst i {
  position: absolute; top: 42%; left: 50%;
  width: 7px; height: 7px; border-radius: 2px;
  opacity: 0; background: var(--gold);
  animation: prConfetti 1.15s cubic-bezier(0.2, 0.7, 0.3, 1) 0.58s backwards;
}
.pr-burst i:nth-child(3n) { background: var(--primary); }
.pr-burst i:nth-child(3n+1) { background: var(--green); }
.pr-burst i:nth-child(1)  { --dx: -120px; --dy: -46px; --r: -140deg; animation-delay: 0.58s; }
.pr-burst i:nth-child(2)  { --dx:  118px; --dy: -52px; --r:  150deg; animation-delay: 0.64s; }
.pr-burst i:nth-child(3)  { --dx:  -86px; --dy: -96px; --r:  -70deg; animation-delay: 0.61s; }
.pr-burst i:nth-child(4)  { --dx:   92px; --dy: -92px; --r:   80deg; animation-delay: 0.69s; }
.pr-burst i:nth-child(5)  { --dx:  -34px; --dy: -118px; --r: -30deg; animation-delay: 0.66s; }
.pr-burst i:nth-child(6)  { --dx:   40px; --dy: -122px; --r:  40deg; animation-delay: 0.60s; }
.pr-burst i:nth-child(7)  { --dx: -140px; --dy:   26px; --r: 170deg; animation-delay: 0.72s; }
.pr-burst i:nth-child(8)  { --dx:  144px; --dy:   18px; --r:-165deg; animation-delay: 0.63s; }
.pr-burst i:nth-child(9)  { --dx:  -74px; --dy:   74px; --r:  95deg; animation-delay: 0.76s; }
.pr-burst i:nth-child(10) { --dx:   80px; --dy:   68px; --r: -95deg; animation-delay: 0.68s; }
.pr-burst i:nth-child(11) { --dx:  -16px; --dy:   96px; --r:  25deg; animation-delay: 0.79s; }
.pr-burst i:nth-child(12) { --dx:   22px; --dy:  100px; --r: -25deg; animation-delay: 0.74s; }
@keyframes prConfetti {
  0%   { opacity: 0; transform: translate(-50%, -50%) scale(0.4) rotate(0deg); }
  15%  { opacity: 1; }
  100% { opacity: 0; transform: translate(calc(-50% + var(--dx)), calc(-50% + var(--dy))) scale(1) rotate(var(--r)); }
}
.pr-title {
  margin: 0 0 12px;
  animation: prRise 0.5s ease-out 0.2s backwards;
}
.pr-badges {
  display: flex; flex-wrap: wrap; justify-content: center; align-items: center;
  gap: 8px; margin-bottom: 14px;
  animation: prRise 0.5s ease-out 0.5s backwards;
}
.pr-wordcount { animation: prRise 0.55s ease-out 0.05s backwards; }
@keyframes prRise {
  from { opacity: 0; transform: translateY(10px); }
}

/* ---- By skill: dials, not bars (2026-07-27) ----
   Four full-width tracks stacked down a 1000px card were ~800px of flat
   colour each and read as loading bars. A dial is the same number in a
   fixed 72px footprint, repeats the round HSK stamp above it, and lets the
   four skills sit as a set you compare at a glance instead of a list you
   read top to bottom. */
.pr-skills {
  margin-top: 24px; padding-top: 20px;
  border-top: 1px solid var(--border);
  animation: prRise 0.55s ease-out 0.05s backwards;
}
/* Takes the eyebrow role. The stage-scoped copy of this rule is gone: it
   existed only to out-specify `body.placement-stage h3`, which is also
   gone. A label's face is decided by what it IS, not by which page it
   happens to be sitting on. */
.pr-skills-title {
  margin: 0 0 16px; color: var(--muted);
}
/* Capped rather than spread across the full card, and flex rather than a
   4-column grid: the rotation now has five skills, and a fixed 4-up grid
   dropped the fifth onto its own lonely row hugging the left edge. Wrapped
   flex keeps any leftover row centred (3+2 on phones, 5 across on desktop). */
.pr-skill-grid {
  display: flex; flex-wrap: wrap; justify-content: center;
  gap: 18px 10px;
  max-width: 520px; margin: 0 auto;
}
.pr-skill { display: flex; flex-direction: column; align-items: center; gap: 9px; flex: none; min-width: 84px; }
/* Each tile drops in and lands with a small bounce, one after another --
   coins being dealt onto the table, not a grid fading in. */
.pr-skill-grid .pr-skill {
  animation: prSkillLand 0.55s cubic-bezier(0.2, 1.2, 0.35, 1) backwards;
  animation-delay: calc(0.15s + var(--coin-i, 0) * 0.1s);
}
@keyframes prSkillLand {
  0%   { opacity: 0; transform: translateY(-34px) scale(0.92); }
  70%  { opacity: 1; transform: translateY(4px) scale(1.02); }
  100% { transform: translateY(0) scale(1); }
}
.pr-dial { position: relative; width: 72px; height: 72px; }
.pr-dial svg { width: 100%; height: 100%; transform: rotate(-90deg); }
.pr-dial circle { fill: none; stroke-width: 6; stroke-linecap: round; }
.pr-dial-track { stroke: var(--surface-2); }
/* r=31 -> circumference 194.78. The fill is drawn as one dash that long,
   pulled back by the share still missing, so --pct (a bare number) drives
   both the resting state and the sweep. */
.pr-dial-fill {
  stroke: var(--green);
  stroke-dasharray: 194.78;
  stroke-dashoffset: calc(194.78 * (1 - var(--pct, 0) / 100));
  animation: prDialSweep 0.95s cubic-bezier(0.25, 0.7, 0.3, 1) 0.5s backwards;
}
.pr-skill.mid .pr-dial-fill { stroke: var(--gold); }
.pr-skill.low .pr-dial-fill { stroke: var(--primary); }
@keyframes prDialSweep { from { stroke-dashoffset: 194.78; } }
.pr-dial-pct {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
   font-size: 1.05rem; line-height: 1;
  font-variant-numeric: tabular-nums; color: var(--text);
}
.pr-dial-pct i { font-style: normal; font-size: 0.62em; margin-left: 1px; opacity: 0.55; }
/* Band label in place of a percentage where the sample is too thin to earn one.
   Smaller and uppercase so it reads as a category rather than a value, and so
   the longest word ("сильно", "strong") fits inside the 72px ring. The ring
   itself still fills to the real number -- as a rough shape that is honest;
   it was the printed figure that claimed precision the test hadn't earned. */
.pr-dial-band {
  position: absolute; inset: 0;
  display: flex; align-items: center; justify-content: center;
  font-size: 0.6rem; font-weight: 700; letter-spacing: 0.05em;
  text-transform: uppercase; text-align: center; line-height: 1.1;
  color: var(--text);
}
.pr-skill.good .pr-dial-band, .lvl-skill.good .pr-dial-band { color: var(--green-dark); }
.pr-skill.mid  .pr-dial-band, .lvl-skill.mid  .pr-dial-band { color: var(--gold); }
.pr-skill.low  .pr-dial-band, .lvl-skill.low  .pr-dial-band { color: var(--primary); }
/* A weak skill draws little or no arc, so an empty ring would read as "no
   data" rather than "this is the gap". The number carries the colour. */
.pr-skill.mid .pr-dial-pct { color: var(--gold); }
.pr-skill.low .pr-dial-pct { color: var(--primary); }
/* One line each, full-strength: the mixed one-line/two-line row of muted
   labels read as ragged and washed out (Duke, 2026-07-31). */
.pr-skill-name {
  font-size: 0.85rem; font-weight: 600; color: var(--text); opacity: 0.85;
  text-align: center; line-height: 1.25; white-space: nowrap;
}
/* Six questions is a small sample; saying so costs one line and stops the
   percentage reading as a precise measurement. */
.pr-skill-n {
  margin-top: -4px; font-size: 0.7rem; color: var(--muted);
  opacity: 0.6; font-variant-numeric: tabular-nums;
}

/* ---- Skills wheel (2026-07-31) ----
   Third pass on this panel: 3D coins -> radar -> polar-area wheel. The coins
   said nothing and looked cheap; the radar's SVG text labels sat outside the
   web in a size and style that read as foreign to the site (both Duke's
   calls). This is a pie-shaped chart with equal slices whose RADIUS carries
   the score, drawn over a faint full-radius track so short wedges read as low
   rather than missing. Geometry from placement_wheel() in app.py.

   There is no text in the chart at all: each wedge has a line-icon on an
   overlay layer, and the skill's name and value appear in an HTML tooltip on
   hover, focus or tap -- in the page's real fonts, which is the whole point. */
.pr-wheel-wrap {
  position: relative;
  width: 100%; max-width: 300px; margin: 0 auto;
  animation: prRise 0.5s ease-out 0.05s backwards;
}
.pr-wheel { display: block; width: 100%; height: auto; overflow: visible; }

.pr-wheel-rings circle {
  fill: none; stroke: var(--border); stroke-width: 1;
  transform-origin: center;
  animation: prWheelRing 0.5s ease-out backwards;
}
.pr-wheel-rings circle:nth-child(1) { animation-delay: 0.08s; }
.pr-wheel-rings circle:nth-child(2) { animation-delay: 0.14s; }
.pr-wheel-rings circle:nth-child(3) { animation-delay: 0.20s; }
.pr-wheel-rings circle:nth-child(4) { animation-delay: 0.26s; }
@keyframes prWheelRing { from { opacity: 0; transform: scale(0.7); } }

/* The maximum a wedge could reach. */
.pr-wheel-tracks path {
  fill: var(--surface-2); opacity: 0.85;
  animation: prWheelFade 0.45s ease-out backwards;
  animation-delay: calc(0.2s + var(--i, 0) * 0.05s);
}
@keyframes prWheelFade { from { opacity: 0; } }

/* The measured wedges grow out of the centre one after another. */
.pr-wedge {
  transform-origin: 50% 50%;
  animation: prWedgeGrow 0.6s cubic-bezier(0.2, 1.1, 0.3, 1) backwards;
  animation-delay: calc(0.42s + var(--i, 0) * 0.1s);
}
@keyframes prWedgeGrow { from { opacity: 0; transform: scale(0.2); } }
.pr-wedge.band-good { fill: var(--green); }
.pr-wedge.band-mid  { fill: var(--gold); }
.pr-wedge.band-low  { fill: var(--primary); }

/* Locked: one wedge-shaped sweep turning over the empty tracks.
   Deliberately NOT one of the band colours. In coral it was the same fill a
   real low-scoring wedge uses, and caught mid-turn it looked exactly like a
   measured result on a chart that is supposed to be showing none (Duke,
   2026-07-31, reading it as "the indicators were shown"). A neutral warm
   grey belongs to no band, and the pulse says "scanning". */
.pr-wheel-sweep path { fill: var(--muted); opacity: 0.16; }
.pr-wheel-sweep {
  animation: prWheelSweep 3.4s linear infinite 0.4s,
             prWheelSweepPulse 3.4s ease-in-out infinite 0.4s;
}
@keyframes prWheelSweepPulse {
  0%, 100% { opacity: 0.55; }
  50%      { opacity: 1; }
}
@keyframes prWheelSweep { to { transform: rotate(360deg); } }

/* Icon layer. Buttons are positioned by percentage of the wrapper, which maps
   straight onto the SVG's square viewBox, so they track the chart at any size. */
.pr-wheel-icons { position: absolute; inset: 0; }
.pr-wheel-icon {
  position: absolute; transform: translate(-50%, -50%);
  width: 34px; height: 34px; padding: 0;
  display: grid; place-items: center;
  appearance: none; cursor: pointer;
  border: 1px solid var(--border); border-radius: 50%;
  background: var(--card); color: var(--muted);
  transition: transform 0.15s, border-color 0.15s, color 0.15s, box-shadow 0.15s;
  animation: prIconPop 0.4s cubic-bezier(0.3, 1.6, 0.5, 1) backwards;
  animation-delay: calc(0.55s + var(--i, 0) * 0.08s);
}
@keyframes prIconPop {
  from { opacity: 0; transform: translate(-50%, -50%) scale(0.4); }
}
.pr-wheel-icon svg { width: 17px; height: 17px; }
.pr-wheel-icon:hover, .pr-wheel-icon:focus-visible, .pr-wheel-icon.is-active {
  transform: translate(-50%, -50%) scale(1.14);
  color: var(--text); border-color: var(--primary);
  box-shadow: 0 4px 14px var(--coral-glow);
  outline: none;
}
/* Unlocked icons carry their band's colour, so the ring of icons is readable
   as a summary even before anything is hovered. */
.pr-wheel-icon.band-good { color: var(--green-dark); border-color: var(--green); }
.pr-wheel-icon.band-mid  { color: var(--gold-dark);  border-color: var(--gold); }
.pr-wheel-icon.band-low  { color: var(--primary-dark); border-color: var(--primary); }

/* Centre disc: envelope while locked, the level once unlocked. */
.pr-wheel-center {
  position: absolute; top: 50%; left: 50%;
  width: 44px; height: 44px; margin: -22px 0 0 -22px;
  display: grid; place-items: center;
  border-radius: 50%;
  background: var(--card); color: var(--primary);
  box-shadow: 0 2px 12px rgba(120, 76, 3, 0.16);
  animation: prIconPop 0.45s cubic-bezier(0.3, 1.6, 0.5, 1) 0.5s backwards;
}
.pr-wheel-center svg { width: 20px; height: 20px; }
/* Unlocked centre: a tiny "HSK" over the level, because a bare numeral in the
   middle of the wheel just read as a stray digit. */
.pr-wheel-center b {
  font-family: var(--font-sans); font-weight: 700;
  font-size: 0.44rem; letter-spacing: 0.11em; text-transform: uppercase;
  color: var(--muted); line-height: 1; display: block; margin-bottom: 1px;
}
.pr-wheel-center i {
  font-style: normal; display: block;
  font-family: var(--font-display); font-weight: 700;
  font-size: 0.95rem; color: var(--text); line-height: 1;
}

/* The disclosure window. */
.pr-wheel-tip {
  position: absolute; z-index: 3; pointer-events: none;
  display: flex; flex-direction: column; gap: 1px;
  padding: 7px 11px; border-radius: 10px;
  background: var(--text); color: var(--bg);
  box-shadow: 0 6px 20px rgba(28, 20, 14, 0.28);
  white-space: nowrap;
  animation: prTipIn 0.16s ease-out;
}
.pr-wheel-tip[hidden] { display: none; }
@keyframes prTipIn { from { opacity: 0; transform: translateY(3px); } }
.pr-tip-name { font-size: 0.8rem; font-weight: 700; }
.pr-tip-value { font-size: 0.74rem; opacity: 0.75; }

/* The always-visible explanation of the locked wheel, and a second door into
   the unlock sheet. Gold-washed so it reads as part of the treasure, not a
   warning. */
.pr-unlock-strip {
  display: flex; align-items: center; gap: 14px;
  width: 100%; max-width: 440px; margin: 22px auto 0;
  padding: 13px 18px;
  border: 1px solid var(--gold);
  background: var(--gold-light);
  border-radius: 16px;
  font: inherit; color: inherit; text-align: left; cursor: pointer;
  transition: transform 0.15s, box-shadow 0.15s;
  animation: prRise 0.55s ease-out 0.75s backwards;
}
.pr-unlock-strip:hover {
  transform: translateY(-2px);
  box-shadow: 0 8px 20px rgba(201, 143, 28, 0.28);
}
.pr-strip-icon {
  flex: none; width: 40px; height: 40px;
  display: grid; place-items: center;
  border-radius: 50%; background: var(--gold); color: #fff;
}
.pr-strip-icon svg { width: 21px; height: 21px; }
.pr-strip-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.pr-strip-text strong {
  font-family: var(--font-display); font-weight: 600;
  font-size: 1.02rem; line-height: 1.3;
}
.pr-strip-text span { font-size: 0.82rem; color: var(--muted); line-height: 1.35; overflow-wrap: anywhere; }
.pr-strip-chevron { margin-left: auto; flex: none; color: var(--gold-dark); font-weight: 700; }

/* ---- The guided deck (2026-07-31) ----
   Slides are plain stacked sections until JS adds pr-deck-on; then one shows
   at a time and Next walks through them. display:none -> block replays each
   slide's own entrance animations, which is the whole trick: every screen
   performs as it arrives. */
.pr-deck-on .pr-slide { display: none; }
/* A floor, not a fixed height: enough that the slides don't visibly jump size
   as you step through, without stranding a tall empty gap under the short
   ones. 440px left a dead white field below every slide.
   Lowered 350 -> 230 (Duke, flagging the words-known slide): centering a
   ~200px-tall block of title+number+buttons inside a 350px floor split the
   150px surplus into a gap above the title AND a gap below the buttons, so
   the whole content looked like it was floating in empty space rather than
   sitting on the card. The stamp slide and the skill wheel are both already
   taller than 230px on their own, so this floor never touches them -- it
   only tightens the one slide that was short enough to notice. */
.pr-deck-on .pr-slide.active {
  display: flex; flex-direction: column; justify-content: center;
  min-height: 230px;
  animation: prSlideIn 0.4s ease-out;
}
@keyframes prSlideIn {
  from { opacity: 0; transform: translateY(12px); }
}
/* Back sits beside Next, quieter than it, and is simply absent on the first
   slide (the controller hides it) so the row still centres on one button.
   Redone 2026-07-30: Back and Next used to be two same-size pills (a
   bordered ghost next to a solid primary) sitting flush against each other --
   which reads as two competing choices, not "the one thing to do, plus a way
   back" (Duke, flagging the certificate slide's three stuck-together
   buttons). Now Next carries all the visual weight and Back is a small round
   icon, offset to the left rather than matched against it. */
.pr-slide-nav {
  margin-top: 28px;
  display: flex; align-items: center; justify-content: center; gap: 14px;
}
.pr-slide-nav-final { margin-top: 20px; }
/* Icon-only back affordance, shared by every slide including the finale (see
   .pr-finale below for its position there). Round rather than pill-shaped so
   it never reads as a second button of the same family as Next/the CTA. */
.pr-back-btn {
  display: inline-flex; align-items: center; justify-content: center;
  flex: none;
  /* Buttons don't inherit the page font -- same trap as .btn above. No text
     in here today, but the moment one appears it would land in Arial. */
  font: inherit;
  width: 42px; height: 42px;
  border-radius: 50%;
  border: 1px solid var(--border);
  background: none; color: var(--muted);
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.pr-back-btn svg { width: 18px; height: 18px; }
.pr-back-btn:hover { color: var(--text); border-color: var(--muted); background: var(--surface-2); }
.pr-back-btn[hidden] { display: none; }
/* Section dividers made sense when these blocks were stacked down one page.
   On their own slide they draw a rule across the top of otherwise empty
   space, which reads as a stray line. */
.pr-deck-on .pr-skills { border-top: 0; padding-top: 0; }
body.placement-stage .pr-slide-overline { margin: 0 0 10px; }
/* Dots only exist as a deck: without JS there is nothing to step through. */
.pr-dots { display: none; justify-content: center; gap: 7px; margin-top: 20px; }
.pr-deck-on .pr-dots { display: flex; }
.pr-dots i {
  width: 7px; height: 7px; border-radius: 50%;
  background: var(--border);
  transition: background 0.2s, transform 0.2s;
}
.pr-dots i.on { background: var(--primary); transform: scale(1.3); }
/* ---- Closing certificate panel ----
   Rebuilt 2026-07-30. The gold-sealed panel it replaced was a small pale box
   sitting inside the deck's big white card -- a box inside a box, with dead
   white above and below and no element on screen big enough to be a headline
   (Duke: "doesn't present"). The shape now is an editorial page carrying one
   physical object:

     eyebrow -> the level as display type -> the artifact -> the ask -> fine print

   The deck card itself dissolves under this slide (.pr-on-final), so nothing
   is nested inside anything. */

/* ---- The card dissolve ----
   Was finale-only until 2026-07-31: the certificate sat on an open page while
   the three slides before it were still a white panel with a border floating
   in the middle of a warm background -- a dialog box, next to a designed page
   (Duke: "other slides have like a modal and makes the whole design so
   ugly"). So the chrome now goes the moment the deck takes over, and every
   slide is the same editorial surface. The grid texture goes with it -- a
   grid on an otherwise open page reads as a broken background, not texture.

   Still tied to pr-deck-on, i.e. to JS: with scripting off the slides stack
   down one page and a card is exactly the right container for them. */
.placement-result {
  transition: max-width 0.5s ease;
}
.placement-result.pr-deck-on {
  max-width: 620px;
  background: transparent;
  border-color: transparent;
  box-shadow: none;
  /* Vertically open, so each slide's glow spills above and below its content
     the way light should. Horizontally clipped, because nothing decorative
     may push the document wider than the screen -- see the note further down
     about the sunburst behind the stamp. */
  overflow-x: clip;
  overflow-y: visible;
}
/* The finale is the one slide that needs more width than the column: the
   headline is display type and the certificate sits under it. */
.placement-result.pr-deck-on.pr-on-final { max-width: 720px; }
.test-frame.placement-result.pr-deck-on::before { display: none; }

/* ---- The stage, under the whole deck ----
   With the card gone the page's own practice grid is suddenly the loudest
   thing on screen -- it was only ever meant to be texture peeking out around
   a card, and full-bleed behind an object it reads as graph paper. So the
   grid drops back and a vignette closes in, which is what puts the content in
   a pool of light instead of on a wall. Set once on <body> by the deck
   controller (it used to switch on and off at the finale, which made the
   whole page visibly change temperature mid-reveal). */
:root { --pr-grid-soft: rgba(255, 133, 99, 0.03); --pr-vignette: rgba(104, 66, 28, 0.13); }
/* Eyebrow gold, and the quiet-text tone under it. --gold-dark is a fill
   colour: at eyebrow size (11px, tracked out to 0.18em) it sits at about
   2.5:1 against the warm page and the label all but vanishes -- "BY SKILL"
   was barely visible (Duke, 2026-07-31). These are the same golds read one
   stop deeper, which clears 5:1 and still reads as gold, not brown. */
:root { --pr-eyebrow: #8f5d08; --pr-quiet: #6f5c46; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --pr-grid-soft: rgba(255, 232, 209, 0.032); --pr-vignette: rgba(0, 0, 0, 0.62);
    /* On the dark stage the problem inverts: the gold has to come up, not
       down, and the quiet text with it. */
    --pr-eyebrow: #f3c04a; --pr-quiet: #b7a68f;
  }
}
body.test-stage { transition: background 0.5s ease; }
body.test-stage.pr-stage-lit {
  background:
    radial-gradient(ellipse 780px 540px at 50% 36%, var(--coral-glow), transparent 66%),
    radial-gradient(ellipse 1500px 1000px at 50% 42%, transparent 34%, var(--pr-vignette) 100%),
    repeating-linear-gradient(90deg, var(--pr-grid-soft) 0 1px, transparent 1px 56px),
    repeating-linear-gradient(0deg, var(--pr-grid-soft) 0 1px, transparent 1px 56px),
    var(--stage-bg);
  background-attachment: fixed;
  /* Each slide's glow is wider than the column it lights, and with the card's
     overflow gone there is nothing else stopping it reaching past the screen
     on a phone. clip, not hidden: hidden on <body> makes it a scroll
     container and breaks anything sticky on the page. */
  overflow-x: clip;
}

/* ---- The finale page ---- */
/* z-index: 0 makes this its own stacking context so the glow at z-index: -1
   tucks behind the content without falling out of the slide entirely. */
.pr-finale { position: relative; z-index: 0; }

/* The warm light the content sits in -- shared by every slide since
   2026-07-31, the finale's included. Wide and very soft: it should register
   as the page being lit from above, never as a visible shape. */
.pr-fin-glow, .pr-glow {
  position: absolute; z-index: -1;
  left: 50%; top: -6%;
  width: 130%; height: 116%;
  transform: translateX(-50%);
  pointer-events: none;
  background:
    radial-gradient(52% 42% at 50% 34%, rgba(240, 180, 41, 0.16) 0%, rgba(240, 180, 41, 0) 70%),
    radial-gradient(64% 54% at 50% 62%, var(--coral-glow) 0%, rgba(207, 116, 88, 0) 72%);
  animation: prFade 1.2s ease-out 0.2s backwards;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pr-fin-glow, :root:not([data-theme="light"]) .pr-glow {
    background:
      radial-gradient(52% 42% at 50% 34%, rgba(240, 180, 41, 0.13) 0%, rgba(240, 180, 41, 0) 70%),
      radial-gradient(64% 54% at 50% 62%, rgba(225, 139, 112, 0.1) 0%, rgba(225, 139, 112, 0) 72%);
  }
}

.pr-fin-eyebrow {
  margin: 0 0 6px;
  color: var(--pr-eyebrow);
  animation: prRise 0.5s ease-out 0.05s backwards;
}

/* ---- The headline ----
   The level is the only thing on this page allowed to be loud. Masked reveal
   rather than a fade: the type rises out of a hidden edge, which is the move
   that separates "animated" from "designed". */
.pr-fin-level {
  margin: 0;
  display: flex; align-items: baseline; justify-content: center;
  gap: 0.16em;
  font-size: clamp(3rem, 11vw, 5rem);
  line-height: 0.95; 
}
/* overflow: clip, not hidden -- hidden on an inline-flex child would create a
   scroll container and drag the baseline off. The padding/margin pair gives
   the descender room the mask would otherwise cut. */
.pr-fin-mask {
  display: inline-block; overflow: clip;
  padding-bottom: 0.12em; margin-bottom: -0.12em;
}
.pr-fin-mask > * {
  display: inline-block;
  animation: prMaskRise 0.85s cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
.pr-fin-mask:nth-of-type(1) > * { animation-delay: 0.14s; }
.pr-fin-mask:nth-of-type(2) > * { animation-delay: 0.24s; }
@keyframes prMaskRise {
  from { transform: translateY(105%); }
}
/* "HSK" is the unit, the tier is the number. Setting them at one size made
   neither of them the headline. */
.pr-fin-hsk {
  font-size: 0.4em; font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--muted);
}
.pr-fin-tier {
  background: linear-gradient(168deg, var(--text) 8%, #6b4a2c 96%);
  -webkit-background-clip: text; background-clip: text;
  color: transparent;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pr-fin-tier { background-image: linear-gradient(168deg, var(--text) 8%, #b08a5f 96%); }
}
.pr-fin-prev {
  margin: 10px 0 0;
  font-family: var(--font-sans);
  font-size: 0.82rem; color: var(--muted);
  animation: prRise 0.5s ease-out 0.5s backwards;
}

/* ---- The artifact ----
   The stage owns the perspective and the entrance; the card owns the tilt.
   Keeping those on two elements is what stops the entrance animation and the
   pointer transform from overwriting each other. */
.pr-artifact-stage {
  perspective: 1100px;
  width: 302px; max-width: 100%;
  margin: 42px auto 0;
  animation: prArtifactIn 0.85s cubic-bezier(0.18, 0.9, 0.28, 1) 0.34s backwards;
}
@keyframes prArtifactIn {
  from { opacity: 0; transform: translateY(26px) scale(0.94); }
}
.pr-artifact {
  position: relative;
  display: flex; flex-direction: column; align-items: center;
  padding: 36px 24px 20px;
  border-radius: 22px;
  /* Resting tilt: a card lying on a surface, seen slightly from above. */
  transform: rotateX(var(--rx, 7deg)) rotateY(var(--ry, -2deg));
  transform-style: preserve-3d;
  transition: transform 0.5s cubic-bezier(0.2, 0.8, 0.3, 1),
              box-shadow 0.5s cubic-bezier(0.2, 0.8, 0.3, 1);
  /* Paper stock: a warm cream face with the light falling from the top-left,
     a hairline gold edge, and a bright inner rim so the edge catches light. */
  background:
    linear-gradient(155deg, #fffdf9 0%, #fdf4e4 52%, #f8e8cf 100%);
  border: 1px solid rgba(201, 143, 28, 0.34);
  box-shadow:
    0 1px 0 rgba(255, 255, 255, 0.9) inset,
    0 -1px 0 rgba(201, 143, 28, 0.14) inset,
    var(--sx, 0px) var(--sy, 26px) 46px -18px rgba(94, 58, 20, 0.42),
    var(--sx, 0px) 6px 14px -8px rgba(94, 58, 20, 0.2);
}
/* While the pointer is on it the card snaps to the cursor rather than easing,
   or the tilt lags behind the hand and feels like video, not glass. */
.pr-artifact-stage.is-tilting .pr-artifact { transition: box-shadow 0.5s ease; }
.pr-artifact-stage.is-tilting .pr-artifact .pr-artifact-gloss { opacity: 1; }
/* The mat: a hairline set in from the edge, the way a printed certificate is
   ruled. On the old white-on-white card this read as a doubled border and was
   removed; against a dimmed stage, with a real edge and a real shadow, it
   reads as what it is. */
.pr-artifact::before {
  content: "";
  position: absolute; inset: 7px; z-index: 1;
  border-radius: 16px; pointer-events: none;
  border: 1px solid rgba(201, 143, 28, 0.24);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pr-artifact::before { border-color: rgba(240, 180, 41, 0.16); }
  :root:not([data-theme="light"]) .pr-artifact {
    background: linear-gradient(155deg, #453729 0%, #372c20 54%, #2b2118 100%);
    border-color: rgba(240, 180, 41, 0.34);
    box-shadow:
      0 1px 0 rgba(255, 225, 175, 0.14) inset,
      0 -1px 0 rgba(0, 0, 0, 0.3) inset,
      var(--sx, 0px) var(--sy, 26px) 50px -18px rgba(0, 0, 0, 0.7),
      var(--sx, 0px) 6px 14px -8px rgba(0, 0, 0, 0.45);
  }
}
/* Pointer-tracked gloss: a soft highlight on the face, as if the card were
   varnished. Sits under the content (the numbers stay readable) and never
   takes a pointer event. */
.pr-artifact-gloss {
  position: absolute; inset: 0; z-index: 0;
  border-radius: inherit; pointer-events: none;
  opacity: 0; transition: opacity 0.35s ease;
  background: radial-gradient(180px circle at var(--mx, 50%) var(--my, 0%),
                              rgba(255, 255, 255, 0.6), transparent 62%);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pr-artifact-gloss {
    background: radial-gradient(180px circle at var(--mx, 50%) var(--my, 0%),
                                rgba(255, 226, 178, 0.13), transparent 62%);
  }
}
/* One sweep of light across the face as it lands -- the "this is the object"
   beat. Plays once; the gloss above takes over from there. */
.pr-artifact-sheen {
  position: absolute; inset: 0; z-index: 1;
  border-radius: inherit; pointer-events: none; overflow: clip;
}
.pr-artifact-sheen::before {
  content: "";
  position: absolute; top: -60%; bottom: -60%; left: -70%; width: 55%;
  transform: skewX(-18deg);
  background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%,
                                     rgba(255, 255, 255, 0.72) 50%,
                                     rgba(255, 255, 255, 0) 100%);
  animation: prSheenSweep 1.1s cubic-bezier(0.4, 0, 0.2, 1) 0.95s backwards;
  opacity: 0;
}
@keyframes prSheenSweep {
  0%   { opacity: 0; transform: translateX(0) skewX(-18deg); }
  12%  { opacity: 1; }
  88%  { opacity: 1; }
  100% { opacity: 0; transform: translateX(360%) skewX(-18deg); }
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pr-artifact-sheen::before {
    background: linear-gradient(90deg, rgba(255, 231, 190, 0) 0%,
                                       rgba(255, 231, 190, 0.2) 50%,
                                       rgba(255, 231, 190, 0) 100%);
  }
}
/* The seal straddles the top edge, the way a wax seal sits on a document, and
   is pushed forward in Z so it lifts off the face when the card tilts. */
.pr-artifact-seal {
  position: absolute; top: -20px; left: 50%; z-index: 2;
  width: 40px; height: 40px; margin-left: -20px;
  display: grid; place-items: center;
  border-radius: 50%;
  background: linear-gradient(160deg, #f7c655 0%, var(--gold) 45%, var(--gold-dark) 100%);
  color: #fff;
  box-shadow: 0 6px 16px rgba(201, 143, 28, 0.45),
              0 1px 0 rgba(255, 255, 255, 0.5) inset;
  transform: translateZ(26px);
  animation: prSealPop 0.6s cubic-bezier(0.3, 1.5, 0.5, 1) 0.55s backwards;
}
.pr-artifact-seal svg { width: 21px; height: 21px; }
@keyframes prSealPop {
  from { opacity: 0; transform: translateZ(26px) scale(0.3) rotate(-40deg); }
}
.pr-artifact-figure {
  position: relative; z-index: 2;
  display: flex; flex-direction: column; align-items: center; gap: 3px;
  transform: translateZ(18px);
}
.pr-artifact-figure b {
  font-family: var(--font-display); font-weight: 700;
  font-size: 2.5rem; line-height: 1; letter-spacing: -0.03em;
  background: linear-gradient(170deg, var(--primary) 0%, #d4552f 100%);
  -webkit-background-clip: text; background-clip: text;
  color: transparent;
}
.pr-artifact-figure i {
  font-style: normal;
  font-family: var(--font-sans);
  font-size: 0.74rem; letter-spacing: 0.02em;
  color: var(--muted);
}
.pr-artifact-rule {
  position: relative; z-index: 2;
  width: 100%; height: 1px; margin: 16px 0 8px;
  background: linear-gradient(90deg, transparent, rgba(201, 143, 28, 0.4), transparent);
}
/* The chop. A first pass put a full-size 汉 watermark behind the figure; at
   any alpha that made it visible it read as a printing smudge rather than a
   mark, because the number sat across its middle. A signature seal in the
   corner is the same idea done properly -- small, deliberate, and it says
   what the document is about without competing with the number. */
.pr-artifact-chop {
  position: absolute; z-index: 2;
  /* Inside the mat, not across it -- at 15/13 it sat exactly on the inner
     hairline's corner and read as a misplacement. */
  right: 18px; bottom: 16px;
  width: 27px; height: 27px;
  display: grid; place-items: center;
  border: 1.5px solid var(--gold-dark);
  border-radius: 5px;
  font-family: var(--font-hanzi-serif);
  font-weight: 600; font-size: 0.86rem; line-height: 1;
  color: var(--gold-dark); opacity: 0.5;
  transform: rotate(-6deg) translateZ(8px);
  user-select: none;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pr-artifact-chop { border-color: var(--gold); color: var(--gold); opacity: 0.45; }
}
/* The signature line. Small and quiet -- it is what turns a stat box into a
   credential, and it stops being that the moment it competes. */
.pr-artifact-mark {
  position: relative; z-index: 2;
  font-family: var(--font-sans);
  font-size: 0.58rem; font-weight: 700;
  letter-spacing: 0.22em; text-transform: uppercase;
  color: var(--gold-dark); opacity: 0.75;
  transform: translateZ(10px);
}

.pr-fin-note {
  margin: 18px 0 0;
   color: var(--muted);
  animation: prRise 0.5s ease-out 0.8s backwards;
}

/* ---- The ask ---- */
.pr-fin-cta {
  margin-top: 32px;
  animation: prRise 0.6s ease-out 0.88s backwards;
}
/* One short line. It earns the click that the bare button alone did not. */
.pr-cta-lead {
  margin: 0 0 16px; font-family: var(--font-display);
  font-size: clamp(1.05rem, 2.4vw, 1.3rem); color: var(--text);
  letter-spacing: -0.01em;
}
/* The primary action gets weight the flat coral rectangle never had: a pill
   with a lit top edge, a coloured shadow that reads as the button hovering
   over the page, and a lift on hover. Shared with the deck's Next button
   since 2026-07-31 -- one button family across the whole reveal, instead of
   three flat rectangles followed by this. */
.pr-cta-go,
.pr-deck-on .pr-slide-nav .btn-primary {
  position: relative; overflow: hidden;
  padding: 15px 30px; border-radius: 999px;
  font-size: 1.02rem; font-weight: 700; letter-spacing: -0.005em;
  background: linear-gradient(180deg, #c6a35a 0%, var(--primary) 45%, var(--primary-dark) 100%);
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.35) inset,
              0 12px 26px -10px rgba(207, 116, 88, 0.75);
  transition: transform 0.24s cubic-bezier(0.2, 0.8, 0.3, 1),
              box-shadow 0.24s ease, background 0.2s ease;
}
.pr-cta-go:hover,
.pr-deck-on .pr-slide-nav .btn-primary:hover {
  background: linear-gradient(180deg, #d3b06a 0%, var(--primary) 45%, var(--primary-dark) 100%);
  transform: translateY(-2px);
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset,
              0 18px 34px -12px rgba(207, 116, 88, 0.85);
}
.pr-cta-go:active,
.pr-deck-on .pr-slide-nav .btn-primary:active { transform: translateY(0); }
/* A light sweep crossing the pill on hover. */
.pr-cta-go::after,
.pr-deck-on .pr-slide-nav .btn-primary::after {
  content: "";
  position: absolute; top: -60%; bottom: -60%; left: -80%; width: 45%;
  transform: skewX(-20deg) translateX(0);
  background: linear-gradient(90deg, rgba(255, 255, 255, 0) 0%,
                                     rgba(255, 255, 255, 0.42) 50%,
                                     rgba(255, 255, 255, 0) 100%);
  transition: transform 0.65s cubic-bezier(0.3, 0, 0.2, 1);
  pointer-events: none;
}
.pr-cta-go:hover::after,
.pr-deck-on .pr-slide-nav .btn-primary:hover::after { transform: skewX(-20deg) translateX(420%); }
/* Stacked, not side by side. Everything else on this page -- eyebrow, level,
   artifact, the ask -- sits on the centre line; a two-button row pushed the
   primary off that line and left the whole composition looking untidy for the
   logged-in reader, who is the only one who gets a second action. */
.pr-fin-cta .pr-actions { flex-direction: column; align-items: center; gap: 2px; }
/* The second action is a path, not an offer: no fill, no border, just text. */
.btn-quiet {
  background: none; color: var(--muted);
  padding: 15px 18px;
  font-weight: 600;
  transition: color 0.18s ease;
}
.btn-quiet:hover { background: none; color: var(--text); transform: none; }

.pr-fin-fineprint {
  max-width: 460px; margin: 26px auto 0;
  animation: prFade 0.6s ease-out 1s backwards;
}
.pr-fin-fineprint p {
  margin: 6px 0 0;
   line-height: 1.45; color: var(--muted);
}

/* Back, on this slide, is a way out and nothing more -- borderless until
   touched, so it never reads as a second offer next to the one action the
   page is actually making. */
.pr-finale .pr-slide-nav-final { margin-top: 24px; }
.pr-finale .pr-back-btn {
  border-color: transparent;
  animation: prFade 0.6s ease-out 1.05s backwards;
}
.pr-finale .pr-back-btn:hover { border-color: var(--border); }

/* Phones: the headline still has to be the headline, but the artifact stops
   tilting (no fine pointer) so it can afford to be a little flatter. */
@media (max-width: 480px) {
  /* Still an object, not a full-width panel: stretched to the screen edges it
     went back to looking like a section of the page. */
  .pr-artifact-stage { width: 288px; margin-top: 26px; }
  .pr-artifact { padding: 28px 20px 16px; transform: none; }
  .pr-fin-cta { margin-top: 26px; }
  .pr-actions { flex-direction: column; align-items: stretch; }
  .pr-cta-go { width: 100%; }
}

/* ---- The first three slides, in the finale's language (2026-07-31) ----
   The finale was rebuilt as an editorial page and the slides leading up to it
   were not, so the reveal changed design halfway through: three flat screens
   inside a white panel, then suddenly an open lit page with display type and
   a physical object on it. Everything below carries the finale's vocabulary
   backwards over the earlier slides -- gold eyebrow, warm light, one object
   per screen with real depth, one pill-shaped action -- so the deck is one
   piece of design that builds, rather than a dialog box with a good ending.

   All of it hangs off .pr-deck-on, so the no-JS stacked page is untouched. */

/* Each slide is its own stacking context, so its glow can sit at z-index: -1
   without falling behind the page. Same trick as .pr-finale. */
.pr-deck-on .pr-slide { position: relative; z-index: 0; }
/* Short slides get a proportionally short glow, which starts to read as a
   visible ellipse rather than as light. A minimum height fixes that without
   touching the finale, which is tall enough that 116% is already bigger. */
.pr-deck-on .pr-slide > .pr-glow { min-height: 420px; }

/* The eyebrow, matching .pr-fin-eyebrow: gold, wider tracking, smaller. The
   grey version read as a form label above the thing it introduced. */
.pr-deck-on .pr-overline {
  color: var(--pr-eyebrow);
  margin: 0 0 14px;
}

/* ---- Slide 1: the stamp ----
   On white the stamp was a flat gold ring printed on the panel. On the open
   page it needs to sit ABOVE the surface like the certificate does, so it
   gets the same warm drop shadow the artifact casts. */
.pr-deck-on .pr-stamp {
  filter: drop-shadow(0 14px 20px rgba(94, 58, 20, 0.16));
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pr-deck-on .pr-stamp { filter: drop-shadow(0 14px 22px rgba(0, 0, 0, 0.5)); }
}
/* The confidence line was a wide grey slab -- at the Russian string's length
   it ran nearly the full width of the card and was the second-loudest thing
   on the screen, under a caveat's worth of meaning. It becomes a small chip
   that lifts off the page: same information, correct volume. */
.pr-deck-on .pr-badges .confidence {
  /* Two of the three strings are two words; the "preliminary estimate" one is
     a full sentence, and unconstrained it drew a 630px bar across the screen
     that was wider than the stamp it was qualifying. Capped, it wraps to two
     lines and stays a chip. */
  max-width: 330px;
  padding: 8px 16px;
  border-radius: 16px;
  font-size: 0.68rem;
  line-height: 1.45;
  letter-spacing: 0.08em;
  border: 1px solid var(--border);
  background: rgba(255, 255, 255, 0.62);
  color: var(--muted);
  box-shadow: 0 2px 10px rgba(94, 58, 20, 0.07);
}
.pr-deck-on .pr-badges .confidence-high {
  border-color: rgba(74, 217, 145, 0.5); color: var(--green-dark);
}
.pr-deck-on .pr-badges .confidence-medium {
  border-color: rgba(240, 180, 41, 0.5); color: var(--gold-dark);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pr-deck-on .pr-badges .confidence { background: rgba(255, 255, 255, 0.05); }
}

/* ---- Slide 2: the word count ----
   Pure display type on an open page, deliberately with no object around it:
   the certificate on the last slide is where this number becomes a thing you
   can hold, and giving it a card here would spend that payoff early. So it
   just gets bigger, and gets the warm shadow that keeps it from looking
   painted flat onto the background. */
.pr-deck-on .pr-wordcount { margin: 6px 0 0; }
.pr-deck-on .pr-wordcount .placement-word-count-num {
  font-size: clamp(3.2rem, 11vw, 4.8rem);
  letter-spacing: -0.03em;
  /* Not a background-clip gradient: the odometer replaces this element's text
     with one child span per digit, and clipping a gradient to text on the
     parent leaves those children painting in transparent -- an invisible
     number. The colour has to be a real colour. */
  color: var(--primary-dark);
  text-shadow: 0 10px 26px rgba(207, 116, 88, 0.22);
}
/* --muted is a 4:1 grey. It is fine as a label beside something, and thin as
   the only line explaining the biggest number on the screen -- one stop
   darker so the caption reads at a glance. */
.pr-deck-on .placement-word-count-label {
  font-family: var(--font-sans);
  font-size: 0.84rem;
  color: var(--pr-quiet);
  margin-top: 12px;
}
.pr-deck-on .pr-strip-text span,
.pr-deck-on .pr-fin-fineprint p,
.pr-deck-on .pr-fin-note,
.pr-deck-on .pr-fin-prev { color: var(--pr-quiet); }

/* ---- Slide 3: the wheel ----
   The chart's own surfaces are near-white, which was fine against a white
   card and disappears against a warm page. So the wheel gets a face: the same
   paper stock as the certificate, cut round, with a gold hairline and a real
   shadow -- a dial lying on the page rather than an SVG floating over it. */
/* The face reaches 22px past the chart on every side, so the wheel needs that
   much clearance again or the disc rides over the eyebrow above it and butts
   into the unlock strip below. Slightly narrower too: with a face around it
   the whole object is bigger than the bare chart was. */
.pr-deck-on .pr-wheel-wrap {
  position: relative; z-index: 0;
  max-width: 264px;
  margin: 34px auto 38px;
}
.pr-deck-on .pr-wheel-wrap::before {
  content: "";
  position: absolute; z-index: -1;
  inset: -22px;
  border-radius: 50%;
  background: linear-gradient(155deg, #fffdf9 0%, #fdf4e4 54%, #f8e8cf 100%);
  border: 1px solid rgba(201, 143, 28, 0.3);
  box-shadow:
    0 1px 0 rgba(255, 255, 255, 0.9) inset,
    0 -1px 0 rgba(201, 143, 28, 0.14) inset,
    0 26px 46px -20px rgba(94, 58, 20, 0.38);
  animation: prFade 0.7s ease-out backwards;
}
/* The mat, ruled inside the rim -- the certificate's hairline, curved. */
.pr-deck-on .pr-wheel-wrap::after {
  content: "";
  position: absolute; z-index: -1;
  inset: -14px;
  border-radius: 50%;
  border: 1px solid rgba(201, 143, 28, 0.2);
  pointer-events: none;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pr-deck-on .pr-wheel-wrap::before {
    background: linear-gradient(155deg, #453729 0%, #372c20 54%, #2b2118 100%);
    border-color: rgba(240, 180, 41, 0.32);
    box-shadow:
      0 1px 0 rgba(255, 225, 175, 0.12) inset,
      0 -1px 0 rgba(0, 0, 0, 0.3) inset,
      0 26px 50px -20px rgba(0, 0, 0, 0.68);
  }
  :root:not([data-theme="light"]) .pr-deck-on .pr-wheel-wrap::after { border-color: rgba(240, 180, 41, 0.16); }
}
/* The icons ride on the dial's face, so they take its stock rather than flat
   white, and cast their own small shadow to sit proud of it. */
.pr-deck-on .pr-wheel-icon {
  background: linear-gradient(165deg, #fffefb 0%, #fdf3e2 100%);
  border-color: rgba(201, 143, 28, 0.28);
  box-shadow: 0 3px 8px rgba(94, 58, 20, 0.14);
}
.pr-deck-on .pr-wheel-center {
  background: linear-gradient(165deg, #fffefb 0%, #fdf3e2 100%);
  box-shadow: 0 3px 12px rgba(94, 58, 20, 0.18),
              0 1px 0 rgba(255, 255, 255, 0.8) inset;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pr-deck-on .pr-wheel-icon, :root:not([data-theme="light"]) .pr-deck-on .pr-wheel-center {
    background: linear-gradient(165deg, #4a3b2c 0%, #3a2e22 100%);
    box-shadow: 0 3px 10px rgba(0, 0, 0, 0.45);
  }
}
/* The chart's own surfaces were picked to read against a white card. On the
   dial's cream face the tracks are within a shade of the surface they sit on
   and the guide rings all but vanish, so a wedge that reaches halfway looks
   like a wedge with nothing behind it. Both get warmed and deepened just
   enough to carry the comparison. */
.pr-deck-on .pr-wheel-tracks path { fill: rgba(201, 143, 28, 0.13); opacity: 1; }
.pr-deck-on .pr-wheel-rings circle { stroke: rgba(201, 143, 28, 0.22); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pr-deck-on .pr-wheel-tracks path { fill: rgba(255, 235, 200, 0.09); }
  :root:not([data-theme="light"]) .pr-deck-on .pr-wheel-rings circle { stroke: rgba(255, 235, 200, 0.14); }
}

/* The unlock strip took its fill from the gold wash, which against a white
   card read as a highlight and against the warm page reads as a notice bar --
   the loudest thing on a slide whose subject is the dial. Same paper stock as
   the dial instead, with the gold kept for the icon and the hairline. */
.pr-deck-on .pr-unlock-strip {
  background: linear-gradient(165deg, #fffefb 0%, #fdf3e2 100%);
  border-color: rgba(201, 143, 28, 0.32);
  box-shadow: 0 14px 28px -18px rgba(94, 58, 20, 0.5);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .pr-deck-on .pr-unlock-strip {
    background: linear-gradient(165deg, #4a3b2c 0%, #3a2e22 100%);
    border-color: rgba(240, 180, 41, 0.3);
    box-shadow: 0 14px 30px -18px rgba(0, 0, 0, 0.7);
  }
}

/* Room to breathe. With no card walls the content can sit lower and wider,
   and the buttons no longer need to clear a panel edge. */
.pr-deck-on .pr-slide-nav { margin-top: 34px; }

/* Nothing decorative may reach past the screen. The card's overflow: hidden
   used to absorb this and no longer does, and the sunburst behind the stamp
   is a 450px box that the slam animation briefly scales to two and a half
   times: on a phone that pushed the document far wider than the viewport and
   the whole page scrolled sideways. The deck clips horizontally (see the
   dissolve rule above); this keeps the ray box small enough on a phone that
   there is nothing much to clip in the first place. */
@media (max-width: 560px) {
  .pr-deck-on .pr-stamp .pr-rays { inset: -70px; }
  .pr-deck-on .pr-slide-nav .btn-primary { padding: 14px 26px; }
  .pr-deck-on .pr-wheel-wrap { max-width: 232px; margin: 28px auto 30px; }
}

/* ---- Unlock sheet ---- */
.pr-sheet { position: fixed; inset: 0; z-index: 90; display: flex;
            align-items: center; justify-content: center; padding: 20px; }
.pr-sheet[hidden] { display: none; }
.pr-sheet-backdrop { position: absolute; inset: 0; background: rgba(28, 20, 14, 0.55);
                     animation: prFade 0.2s ease-out; }
.pr-sheet-card {
  position: relative; z-index: 1;
  width: 100%; max-width: 360px;
  padding: 26px 24px 22px;
  background: var(--card); border-radius: 20px; text-align: center;
  box-shadow: 0 24px 60px -12px rgba(28, 20, 14, 0.45);
  animation: prSheetIn 0.28s cubic-bezier(0.2, 0.8, 0.3, 1);
}
.pr-sheet-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 52px; height: 52px; margin-bottom: 14px;
  border-radius: 50%; background: var(--surface-2); color: var(--primary);
}
.pr-sheet-icon svg { width: 26px; height: 26px; }
.pr-sheet-title { margin: 0 0 8px; font-size: 1.15rem; }
.pr-sheet-body { margin: 0 0 18px; font-size: 0.9rem; line-height: 1.55; color: var(--muted);
                 overflow-wrap: anywhere; }
.pr-sheet-btn { display: block; width: 100%; }
.pr-sheet-btn + .pr-sheet-btn { margin-top: 8px; }
@keyframes prFade { from { opacity: 0; } }
@keyframes prSheetIn { from { opacity: 0; transform: translateY(14px) scale(0.96); } }

/* ---- Live convergence caption (2026-07-30) ----
   Sits under the progress bar during the test. The adaptive length is the
   product's whole claim, so it is shown working rather than hidden behind a
   fake "of 50". Three states, driven by the real standard error of the ability
   estimate. The dot pulses while the estimate is still moving and goes solid
   green once converged, so the state is legible without reading the words. */
/* ---------- Level Check question page (rebuilt 2026-07-30) ----------
   The old page was a stack of same-weight sans text: counter, hint, bar,
   caption, instruction, question, options -- nothing led, nothing matched
   the serif-led homepage a visitor had just come from. This gives the page
   the site's editorial voice (overline + serif number), makes the prompt a
   display moment, and turns the convergence caption into a stepper big
   enough to read. */
.pq-card { position: relative; }
/* Signature ribbon along the card's top edge -- the one piece of colour the
   page carries everywhere, so the card reads branded rather than generic. */
/* ::after, not ::before -- .test-frame::before is already the card's
   practice-grid texture, and sharing the pseudo overwrote the texture and
   inherited its 0.6 opacity + radial mask. No own radius either: the card's
   overflow:hidden clips the strip to the card's corners. */
.pq-card::after {
  content: ""; position: absolute; top: 0; left: 0; right: 0; height: 4px;
  pointer-events: none;
  background: linear-gradient(90deg, var(--primary), var(--gold));
}
.pq-head { margin: 2px 0 14px; animation: pqRise 0.45s ease-out backwards; }
.pq-overline {
  margin: 0 0 7px; color: var(--muted);
}
body.placement-stage h1.pq-number {
  margin: 0; font-size: 1.85rem; line-height: 1.1;
}

.pq-bar-track { margin: 0 0 12px; height: 6px; animation: pqRise 0.45s ease-out 0.05s backwards; }
.pq-bar-fill {
  border-radius: 6px;
  background: linear-gradient(90deg, var(--primary), var(--gold));
  box-shadow: 0 0 8px var(--coral-glow);
}

/* The three measurement stations. One is always lit; done ones are filled.
   Colour follows the state's meaning -- coral while searching, gold while
   narrowing, green once the estimate is tight -- so the row also works as a
   temperature read without the words. */
.pq-steps {
  list-style: none; display: flex; align-items: center; gap: 0;
  margin: 0 0 22px; padding: 0;
}
.pq-step {
  display: flex; align-items: center; gap: 7px;
  font-size: 0.8rem; font-weight: 600; color: var(--muted);
  white-space: nowrap;
}
/* Hairline connectors between stations, not before the first. */
.pq-step + .pq-step { flex: 1; }
.pq-step + .pq-step::before {
  content: ""; flex: 1; height: 1px; margin: 0 10px;
  background: var(--border);
}
.pq-step-dot {
  width: 9px; height: 9px; border-radius: 50%; flex: none;
  border: 2px solid var(--border); background: transparent;
  box-sizing: border-box;
}
.pq-step.is-done { color: var(--green-dark, var(--green)); }
.pq-step.is-done .pq-step-dot { border-color: var(--green); background: var(--green); }
.pq-step.is-active { font-weight: 700; }
.pq-step.is-active .pq-step-dot {
  border-color: currentColor; background: currentColor;
  animation: pqStepPulse 1.6s ease-in-out infinite;
}
.pq-steps.converge-finding    .pq-step.is-active { color: var(--primary-dark); }
.pq-steps.converge-narrowing  .pq-step.is-active { color: var(--gold-dark); }
.pq-steps.converge-confirming .pq-step.is-active { color: var(--green-dark, var(--green)); }
/* Converged: stop the pulse. A dot still blinking after the estimate has
   settled says "working" when the honest signal is "settled". */
.pq-steps.converge-confirming .pq-step.is-active .pq-step-dot { animation: none; }
@keyframes pqStepPulse {
  0%, 100% { box-shadow: 0 0 0 0 currentColor; opacity: 1; }
  50%      { box-shadow: 0 0 0 4px transparent; opacity: 0.55; }
}
/* Phones: the lit station keeps its words, the others become plain dots --
   three full labels don't share a 360px row, and the sequence still reads. */
@media (max-width: 480px) {
  .pq-step:not(.is-active) .pq-step-label { display: none; }
}

.pq-desc { animation: pqRise 0.45s ease-out 0.1s backwards; }

/* The prompt is the page's display moment: centred, framed, and set in the
   site's serif (which cascades to Noto Serif SC for hanzi). Word prompts get
   true display size; sentence prompts stay at reading size. */
.pq-prompt {
  border: 1px solid var(--border);
  border-left: 3px solid var(--primary);
  background:
    radial-gradient(120% 90% at 50% 0%, var(--surface-2) 0%, transparent 75%),
    var(--card);
  border-radius: 16px;
  padding: 22px 20px;
  text-align: center;
  animation: pqRise 0.5s ease-out 0.1s backwards;
}
.pq-prompt-text {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.3rem;
  line-height: 1.65;
  letter-spacing: 0;
}
.pq-prompt-word { padding: 28px 20px; }
.pq-prompt-word .pq-prompt-text { font-size: 2.5rem; line-height: 1.3; }

@keyframes pqRise {
  from { opacity: 0; transform: translateY(8px); }
}

/* Ordering tiles on the placement page pick up the same dealt-in stagger and
   hover lift as the A/B/C/D rows, and the sequence badge is set in the body
   sans rather than the display serif. */
body.placement-stage .order-chip {
  animation: pqRise 0.4s ease-out backwards;
  animation-delay: calc(0.16s + var(--opt-i, 0) * 0.05s);
  transition: background 0.15s, border-color 0.15s, transform 0.15s, box-shadow 0.15s;
}
body.placement-stage .order-chip:hover {
  transform: translateY(-2px);
  box-shadow: 0 6px 16px var(--coral-glow);
}

/* The submit row's button gets a touch more presence on this page -- it is
   the only action on screen. */
.pq-card .btn-primary {
  padding: 13px 28px;
  border-radius: 12px;
  box-shadow: 0 6px 18px var(--coral-glow);
}
.pq-card .btn-primary:hover { box-shadow: 0 8px 22px var(--coral-glow); }

/* ---- "Working out your level" beat ----
   Covers the result for ~1.1s on arrival from the final question. Fixed rather
   than inside the card: the card is already mid-reveal underneath, and the
   point is that the student sees nothing of it until the beat lifts. */
.pr-computing {
  position: fixed; inset: 0; z-index: 80;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 20px; background: var(--bg, #fdf6ec);
  transition: opacity 0.4s ease;
}
.pr-computing.done { opacity: 0; pointer-events: none; }
body.pr-computing-on { overflow: hidden; }
.pr-computing-orbit { position: relative; width: 56px; height: 56px; }
.pr-computing-orbit i {
  position: absolute; top: 50%; left: 50%;
  width: 10px; height: 10px; margin: -5px 0 0 -5px;
  border-radius: 50%; background: var(--primary);
  animation: prOrbit 1.1s linear infinite;
}
.pr-computing-orbit i:nth-child(2) { background: var(--gold);  animation-delay: -0.37s; }
.pr-computing-orbit i:nth-child(3) { background: var(--green); animation-delay: -0.74s; }
@keyframes prOrbit {
  from { transform: rotate(0deg)   translateX(20px) rotate(0deg); }
  to   { transform: rotate(360deg) translateX(20px) rotate(-360deg); }
}
.pr-computing-text {
  margin: 0; padding: 0 24px; text-align: center;
  font-family: var(--font-display); font-size: 1.05rem; color: var(--text);
}

/* Screen-reader-only: used by the result heading, which duplicates the stamp
   visually but still has to exist for structure and assistive tech. */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0;
}

.pr-actions { display: flex; flex-wrap: wrap; justify-content: center; gap: 12px; }

@media (prefers-reduced-motion: reduce) {
  .pr-burst i { display: none; }
  .pr-sparks { display: none; }
  .pr-title, .pr-badges, .pr-wordcount, .pr-skills,
  .pr-dial-fill, .result-stamp,
  .placement-result, .pr-hero .pr-stamp,
  .pr-stamp .pr-rays, .pr-stamp .pr-stamp-wave,
  .pr-skill-grid .pr-skill, .pr-overline,
  .pr-deck-on .pr-slide.active,
  .pr-fin-glow, .pr-fin-eyebrow, .pr-fin-prev, .pr-fin-note,
  .pr-fin-cta, .pr-fin-fineprint,
  .pr-sheet-backdrop, .pr-sheet-card { animation: none; }
  /* The finale keeps its composition and loses its performance: the headline
     is simply there rather than rising out of a mask, and the artifact lies
     flat with no tilt, no sweep and no gloss. The gold edge, the shadow and
     the type hierarchy are the design; the motion was the delivery. */
  .pr-fin-mask > *, .pr-artifact-stage, .pr-artifact-seal { animation: none; }
  .pr-artifact { transform: none; transition: none; }
  .pr-artifact-seal { transform: none; }
  .pr-artifact-figure, .pr-artifact-mark { transform: none; }
  .pr-artifact-chop { transform: rotate(-6deg); }
  .pr-artifact-sheen, .pr-artifact-gloss { display: none; }
  .pr-cta-go, .pr-deck-on .pr-slide-nav .btn-primary { transition: none; }
  .pr-cta-go:hover,
  .pr-deck-on .pr-slide-nav .btn-primary:hover { transform: none; }
  .pr-cta-go::after,
  .pr-deck-on .pr-slide-nav .btn-primary::after { display: none; }
  .placement-result { transition: none; }
  /* The deck's own slides keep the light and the dial face, and lose the way
     they arrive -- same bargain the finale makes just below. */
  .pr-glow, .pr-deck-on .pr-wheel-wrap::before { animation: none; }
  /* The wheel stops building and the locked sweep stops turning, but every
     wedge keeps its final radius -- the shape is the information. The icons
     are excluded from the transform reset because their translate(-50%,-50%)
     is what centres them on their anchor; they only lose the entrance pop. */
  .pr-wheel-wrap, .pr-wheel-rings circle, .pr-wheel-tracks path,
  .pr-wedge, .pr-wheel-sweep, .pr-wheel-center,
  .pr-unlock-strip { animation: none; transform: none; }
  .pr-wheel-icon, .pr-wheel-tip { animation: none; }
  /* The stepper keeps its colour and fill (the actual state signal) and
     loses only the pulse; the question page's entrance rises are skipped so
     nothing on the card moves. The computing beat is skipped outright in JS. */
  .pq-step.is-active .pq-step-dot, .pr-computing-orbit i { animation: none; }
  .pq-head, .pq-bar-track, .pq-desc, .pq-prompt, .placement-opt,
  body.placement-stage .order-chip,
  .placement-opt.selected-answer .pq-opt-letter { animation: none; }
}

/* ---------- Stamp badge (start screen + result screen) ---------- */
.stamp {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  transform: rotate(-8deg);
}

.stamp::before {
  content: "";
  position: absolute;
  inset: 0;
  background: currentColor;
  -webkit-mask-image: var(--stamp-mask);
  mask-image: var(--stamp-mask);
  -webkit-mask-size: 100% 100%;
  mask-size: 100% 100%;
}

.stamp span {
  position: relative;
  font-family: var(--font-display);
  font-weight: 600;
  text-align: center;
  line-height: 1.05;
}

/* ---------- Page transition between questions ---------- */
/* A gentle turn-and-fade, not a hard book-style hinge flip: a small rotateY
   plus a soft sideways drift and scale, eased out smoothly. Center-origin
   (not edge-hinged) is what keeps it feeling like a natural page swap
   instead of a page physically pivoting open.

   The two halves run in sequence, never together. They used to cross-fade
   over the same 420ms, which meant that for most of the transition both
   questions were on screen at ~50% opacity, stacked at different heights --
   the old question's answers reading straight through the new question's text
   (Duke: "there are moments where page gets messed up"). Two paragraphs of
   Chinese laid over each other is not a dissolve, it is a rendering fault, and
   no easing curve fixes it: the fix is that the outgoing page is gone before
   the incoming one starts. app.js drives the handoff; the stage's height is
   what carries continuity across it, so the nav row below doesn't jump when
   a short question follows a long one. */
.page-stage {
  position: relative;
  perspective: 2200px;
}

.page-stage.flipping {
  overflow: visible;
  transition: height 0.3s cubic-bezier(0.33, 1, 0.68, 1);
}

.page-stage .page.flip-stacked {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  margin: 0;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  transform-style: preserve-3d;
  transform-origin: center center;
}

/* Leaving is quick and flat -- it is dead time, and the sooner the page is off
   the screen the sooner the next one can start. It keeps the only directional
   cue in the transition: forward drifts left, Back drifts right. */
.page-stage .page.flip-out-fwd { animation: flipOutFwd 0.14s cubic-bezier(0.4, 0, 1, 1) forwards; }
.page-stage .page.flip-out-back { animation: flipOutBack 0.14s cubic-bezier(0.4, 0, 1, 1) forwards; }

/* Arriving is not a slab sliding in. A whole page moving as one object is the
   cheapest-looking motion there is -- it reads as a slide transition on a
   presentation, however nice the curve. The page *assembles* instead: heading,
   then question, then each answer dealt in after the last (see .dealing below).
   All this rule does is fade the surface the pieces land on, and give app.js a
   single reliable animationend to finish the handoff on. */
.page-stage .page.flip-in-fwd,
.page-stage .page.flip-in-back { animation: pageSurfaceIn 0.26s ease forwards; }

@keyframes flipOutFwd {
  from { transform: translateX(0) scale(1); opacity: 1; }
  to   { transform: translateX(-2%) scale(0.98); opacity: 0; }
}
@keyframes flipOutBack {
  from { transform: translateX(0) scale(1); opacity: 1; }
  to   { transform: translateX(2%) scale(0.98); opacity: 0; }
}
@keyframes pageSurfaceIn {
  from { opacity: 0; }
  to   { opacity: 1; }
}

/* ---- The deal ----
   Every piece rises a little and settles, one after the next, on an expo-out
   curve -- fast at the start, a long quiet landing. Fill mode is `backwards`
   only: a piece holds its start frame through its delay (so nothing flashes
   into place early) and, once done, goes back to being plain styled markup
   with no animation state stuck to it. That is also why .dealing is safe to
   leave on the element; app.js clears and re-adds it to replay. */
@keyframes qDeal {
  from { opacity: 0; transform: translateY(14px) scale(0.99); }
  to   { opacity: 1; transform: none; }
}

.page.dealing .section-title,
.page.dealing .section-desc,
.page.dealing .question-label,
.page.dealing .question-prompt,
.page.dealing .passage-card,
.page.dealing .cloze-blank,
.page.dealing .match-grid,
.page.dealing .listening-audio,
.page.dealing .audio-player,
.page.dealing .question > input[type="text"],
.page.dealing .opt-grid > .opt,
.page.dealing .order-bank > .order-chip {
  animation: qDeal 0.38s cubic-bezier(0.16, 1, 0.3, 1) backwards;
}

/* Three beats: the section heading, then the question, then the answers. */
.page.dealing .section-title,
.page.dealing .section-desc { animation-delay: 0s; }

.page.dealing .question-label,
.page.dealing .question-prompt { animation-delay: 0.05s; }

.page.dealing .passage-card,
.page.dealing .cloze-blank,
.page.dealing .match-grid,
.page.dealing .listening-audio,
.page.dealing .audio-player,
.page.dealing .question > input[type="text"],
.page.dealing .opt-grid > .opt,
.page.dealing .order-bank > .order-chip { animation-delay: 0.11s; }

/* ...and the answers arrive one at a time rather than as a block. */
.page.dealing .opt-grid > .opt:nth-child(2),
.page.dealing .order-bank > .order-chip:nth-child(2),
.page.dealing .cloze-blank:nth-child(2) { animation-delay: 0.16s; }
.page.dealing .opt-grid > .opt:nth-child(3),
.page.dealing .order-bank > .order-chip:nth-child(3),
.page.dealing .cloze-blank:nth-child(3) { animation-delay: 0.21s; }
.page.dealing .opt-grid > .opt:nth-child(4),
.page.dealing .order-bank > .order-chip:nth-child(4),
.page.dealing .cloze-blank:nth-child(4) { animation-delay: 0.26s; }
.page.dealing .opt-grid > .opt:nth-child(n+5),
.page.dealing .order-bank > .order-chip:nth-child(n+5),
.page.dealing .cloze-blank:nth-child(n+5) { animation-delay: 0.31s; }

/* A cloze blank deals in as one unit -- its own options must not deal in on
   top of that, or the two transforms compound and the opacities multiply. */
.page.dealing .cloze-blank .opt { animation: none; }

@media (prefers-reduced-motion: reduce) {
  .page.dealing .section-title,
  .page.dealing .section-desc,
  .page.dealing .question-label,
  .page.dealing .question-prompt,
  .page.dealing .passage-card,
  .page.dealing .cloze-blank,
  .page.dealing .match-grid,
  .page.dealing .listening-audio,
  .page.dealing .audio-player,
  .page.dealing .question > input[type="text"],
  .page.dealing .opt-grid > .opt,
  .page.dealing .order-bank > .order-chip { animation: none; }
}

.page-nav {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-top: 32px;
  gap: 12px;
}

.page-counter {
  color: var(--muted);
  font-weight: 600;
  font-size: 0.82rem;
  letter-spacing: 0.1em;
  font-variant-numeric: tabular-nums;
}

/* Back is an escape hatch, not a choice being offered alongside Next -- a
   filled grey slab gave the two equal weight at the bottom of every question. */
.page-nav .btn-secondary {
  background: none;
  color: var(--muted);
  padding-left: 4px;
  padding-right: 12px;
}

.page-nav .btn-secondary:hover {
  background: none;
  color: var(--text);
}

/* Thanks page */
.thanks {
  text-align: center;
  padding: 48px 24px;
}

.thanks h2 {
  color: var(--primary);
  margin-bottom: 12px;
}

.thanks-flashcards {
  text-align: center;
  padding: 28px 24px;
  margin-top: 16px;
}
.thanks-flashcards h3 { margin: 0 0 14px; }
.flashcard-preview-list {
  list-style: none;
  margin: 0 0 20px;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 8px 14px;
}
.flashcard-preview-list li {
  display: flex;
  align-items: baseline;
  gap: 6px;
  background: var(--surface-2);
  border-radius: 10px;
  padding: 6px 12px;
}
.fc-word { font-weight: 700; font-size: 1.05rem; }
.fc-pinyin { font-size: 0.8rem; color: var(--muted); }
.flashcard-login-prompt { font-size: 0.9rem; color: var(--muted); margin: 0; }
.flashcard-login-prompt a { color: var(--primary-dark); font-weight: 700; text-decoration: none; }
.flashcard-login-prompt a:hover { text-decoration: underline; }

.top-actions {
  display: flex;
  justify-content: center;
  gap: 12px;
  margin-top: 16px;
}

/* Passage */
.passage {
  margin-bottom: 16px;
  line-height: 2.2;
}

.passage-title {
  font-weight: 700;
  margin-bottom: 12px;
  color: var(--primary-dark);
}

.passage-body {
  font-size: 1.05rem;
  line-height: 2.6;
}

/* Selected states */
.selected-answer {
  background: var(--primary-light) !important;
  border-color: var(--primary) !important;
  font-weight: 600;
}

.student-answer {
  background: var(--surface-2);
  border-radius: 10px;
  padding: 12px 14px;
  font-size: 1rem;
  color: var(--text);
  min-height: 44px;
}

/* Admin tables */
.admin-table {
  width: 100%;
  border-collapse: collapse;
  margin-top: 16px;
}

.admin-table th, .admin-table td {
  text-align: left;
  padding: 12px;
  border-bottom: 1px solid var(--border);
}

.admin-table th {
  font-family: var(--font-display);
  color: var(--muted);
  font-weight: 600;
  font-size: 0.9rem;
}

.admin-table tr:last-child td { border-bottom: none; }
.admin-table tr:hover td { background: var(--surface-2); }

/* Below this width a real <table> can't hold this many columns without
   crushing every cell (this is what made a 4-character test title wrap into
   one character per line on a phone) -- so each row becomes its own labeled
   card instead of trying to force the grid to fit. */
@media (max-width: 640px) {
  .admin-table thead { display: none; }
  .admin-table, .admin-table tbody, .admin-table tr, .admin-table td { display: block; width: 100%; }
  .admin-table { margin-top: 12px; }
  .admin-table tr {
    border: 1px solid var(--border);
    border-radius: 14px;
    padding: 4px 14px;
    margin-bottom: 12px;
  }
  .admin-table td {
    border-bottom: 1px solid var(--border);
    padding: 10px 0;
  }
  .admin-table td:last-child { border-bottom: none; }
  .admin-table td[data-label]::before {
    content: attr(data-label);
    display: block;
    font-size: 0.7rem;
    font-weight: 800;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--muted);
    margin-bottom: 3px;
  }
}

.sortable-col {
  cursor: pointer;
  user-select: none;
  white-space: nowrap;
}

.sortable-col::after {
  content: '↕';
  margin-left: 4px;
  opacity: 0.35;
  font-size: 0.8em;
}

.sortable-col.sort-asc::after { content: '↑'; opacity: 1; color: var(--primary); }
.sortable-col.sort-desc::after { content: '↓'; opacity: 1; color: var(--primary); }

.pending-banner {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 12px 16px;
  margin-bottom: 16px;
  border-radius: 12px;
  background: var(--primary-light);
  color: var(--primary-dark);
  font-weight: 700;
  font-size: 0.92rem;
  text-decoration: none;
}

.pending-banner:hover { text-decoration: none; background: var(--surface-2-hover); }

/* Homepage mine/all toggle */
.view-toggle {
  display: inline-flex;
  gap: 2px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 3px;
  margin-bottom: 16px;
}

.view-toggle a {
  padding: 6px 14px;
  border-radius: 999px;
  font-size: 0.9rem;
  font-weight: 600;
  color: var(--muted);
  text-decoration: none;
}

.view-toggle a:hover { color: var(--text); }

.view-toggle a.view-active {
  background: var(--primary);
  color: #fff;
}

/* Count badge riding on a filter tab (requests queue). Quiet by default; the
   pending tab goes coral ('hot') when anything is waiting. On the active tab
   it flips to a translucent white so it stays legible on the primary fill. */
.view-count {
  display: inline-block;
  min-width: 18px;
  margin-left: 5px;
  padding: 1px 6px;
  border-radius: 999px;
  font-size: 0.72rem;
  font-weight: 700;
  line-height: 1.45;
  text-align: center;
  background: var(--surface-2);
  color: var(--muted);
}
.view-count.hot { background: var(--primary-light); color: var(--primary-dark); }
.view-toggle a.view-active .view-count,
.view-toggle a.view-active .view-count.hot {
  background: rgba(255, 255, 255, 0.28);
  color: #fff;
}

/* Editor */
.editor-textarea {
  width: 100%;
  min-height: 400px;
  font-family: var(--font-mono);
  font-size: 0.9rem;
}

/* One-question-per-page */
.question-page {
  display: none;
}

.question-page.active {
  display: block;
  animation: fadeIn 0.25s ease;
}

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

/* Block editor */
.block-editor {
  display: flex;
  flex-direction: column;
  gap: 16px;
}

.editor-block {
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 12px;
  padding: 16px;
  position: relative;
}

.editor-block:hover {
  border-color: var(--primary);
}

.block-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 12px;
}

.block-title {
  color: var(--muted);
}

.block-actions {
  display: flex;
  gap: 8px;
}

.block-actions button {
  background: none;
  border: none;
  cursor: pointer;
  color: var(--muted);
  font-size: 0.9rem;
  padding: 4px 8px;
  border-radius: 6px;
}

.block-actions button:hover {
  background: var(--surface-2-hover);
  color: var(--text);
}

.editor-block input[type="text"], .editor-block textarea {
  margin-bottom: 10px;
}

.options-editor {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 8px;
}

.option-row {
  display: flex;
  gap: 8px;
  align-items: center;
}

.option-row input[type="text"] {
  flex: 1;
  margin-bottom: 0;
}

.option-row input[type="radio"] {
  width: 18px;
  height: 18px;
  accent-color: var(--primary);
}

.option-row .remove-option {
  color: var(--accent);
  cursor: pointer;
  font-weight: 600;
}

.add-block-bar {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  margin-top: 8px;
}

.add-block-btn {
  background: var(--card);
  border: 1px dashed var(--border);
  border-radius: 10px;
  padding: 10px 14px;
  cursor: pointer;
  color: var(--muted);
  font-size: 0.95rem;
  transition: all 0.15s;
}

.add-block-btn:hover {
  border-color: var(--primary);
  color: var(--primary);
}

a { color: var(--primary); text-decoration: none; }
a:hover { text-decoration: underline; }

.btn:active { transform: scale(0.97); }

/* Admin language switcher */
.lang-switch {
  position: fixed;
  top: 12px;
  right: 12px;
  z-index: 50;
  display: flex;
  gap: 2px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 3px;
  box-shadow: var(--shadow);
}

.lang-switch a {
  color: var(--muted);
  font-size: var(--t-caption);
  font-weight: 700;
  padding: 5px 10px;
  border-radius: 999px;
  text-decoration: none;
  letter-spacing: 0.02em;
}

.lang-switch a:hover { color: var(--text); }

.lang-switch a.lang-active {
  background: var(--primary);
  color: #fff;
}

@media (max-width: 480px) {
  .lang-switch { top: 8px; right: 8px; }
}

.hint-text {
  color: var(--muted);
  font-size: 0.88rem;
  margin: 0 0 14px;
}

/* ---------- Chinese text + pinyin composer (editor widget) ---------- */
.composer { margin-bottom: 4px; }

.composer-label {
  display: block;
  margin-bottom: 6px;
}

.composer-input {
  width: 100%;
  padding: 12px 14px;
  border: 1px solid var(--border);
  border-radius: 10px;
  font-size: 1.05rem;
  font-family: inherit;
}

.composer-input:focus {
  outline: none;
  border-color: var(--primary);
}

.composer-preview {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 4px;
  margin-top: 8px;
  min-height: 1px;
}

.composer-chip {
  display: flex;
  flex-direction: column;
  align-items: center;
  background: var(--primary-light);
  border-radius: 10px;
  padding: 6px 6px 4px;
}

.composer-chip-char {
  font-size: 1.2rem;
  font-weight: 600;
  line-height: 1.2;
}

.composer-chip-py {
  width: 3.4em;
  margin-top: 3px;
  border: 1px solid transparent;
  background: color-mix(in srgb, var(--card) 70%, transparent);
  border-radius: 6px;
  text-align: center;
  font-size: 0.78rem;
  font-weight: 600;
  color: var(--primary-dark);
  padding: 2px 0;
}

.composer-chip-py:focus {
  outline: none;
  border-color: var(--primary);
  background: var(--card);
}

.composer-literal {
  align-self: center;
  color: var(--muted);
  font-size: 1rem;
  padding: 0 2px;
  white-space: pre-wrap;
}

/* Correct-answer marking in the option editor */
.option-row {
  align-items: flex-start;
}

.correct-toggle {
  display: flex;
  align-items: center;
  gap: 4px;
  padding: 10px 6px 0 0;
  cursor: pointer;
  font-weight: 700;
  color: var(--muted);
  flex-shrink: 0;
}

.correct-toggle input[type="checkbox"] {
  width: 18px;
  height: 18px;
  accent-color: var(--green);
}

.correct-toggle:has(input:checked) { color: var(--green-dark); }

.option-composer-mount { flex: 1; min-width: 0; }

/* ---------- Scoring & gamified results ---------- */
@keyframes popIn {
  from { opacity: 0; transform: scale(0.4) rotate(-8deg); }
  to { opacity: 1; transform: scale(1) rotate(-8deg); }
}

.result-stamp {
  width: 150px;
  height: 150px;
  margin: 8px auto 22px;
  color: var(--gold);
  animation: popIn 0.55s cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
}

.result-stamp .score {
  font-size: 2.2rem;
  line-height: 1;
  font-variant-numeric: tabular-nums;
}

.result-stamp .score .of {
  font-family: var(--font-sans);
  font-weight: 800;
  font-size: 0.45em;
}

.result-stamp.no-score {
  font-size: 2.6rem;
}

.score-pct {
  color: var(--muted);
  font-weight: 600;
  margin: 0 0 8px;
}

.score-pill {
  display: inline-block;
  font-weight: 700;
  padding: 6px 16px;
  border-radius: 999px;
  margin-top: 8px;
}

.score-pill-sm {
  display: inline-block;
  font-weight: 700;
  padding: 3px 10px;
  border-radius: 999px;
  font-size: 0.88rem;
}

.score-pill-good { background: var(--green-light); color: var(--green-dark); }
.score-pill-mid { background: var(--gold-light); color: var(--gold-dark); }
.score-pill-low { background: var(--accent-light); color: var(--accent); }

.question-correct { border-left: 4px solid var(--green); padding-left: 16px; margin-left: -16px; }
.question-incorrect { border-left: 4px solid var(--accent); padding-left: 16px; margin-left: -16px; }

.grade-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px; height: 22px;
  border-radius: 50%;
  font-size: 0.8rem;
  margin-left: 6px;
  vertical-align: middle;
}

.question-correct .grade-badge { background: var(--green-light); color: var(--green-dark); }
.question-incorrect .grade-badge { background: var(--accent-light); color: var(--accent); }

.option.correct-answer {
  border-color: var(--green) !important;
  background: var(--green-light) !important;
}

.option.wrong-answer {
  border-color: var(--accent) !important;
  background: var(--accent-light) !important;
}

.option.wrong-answer .correct-tag { color: var(--accent); }

.correct-tag {
  margin-left: auto;
  font-size: 0.78rem;
  font-weight: 700;
  color: var(--green-dark);
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

@media (max-width: 480px) {
  .container { padding: 16px 12px; }
  .card { padding: 18px; }
  .search-create-bar { flex-direction: column; }
  .test-card-actions { gap: 12px; }
  .page-nav { flex-wrap: wrap; }
  .page-counter { order: 3; width: 100%; text-align: center; }
  .login-card { padding: 24px 20px; }
  .composer-chip-py { width: 2.8em; }
  .result-stamp { width: 120px; height: 120px; }
  .result-stamp .score { font-size: 1.7rem; }
}

/* ---------- Placement-test-specific answer options (less overflow-prone) ---------- */
.placement-opt-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
  width: 100%;
}

@media (max-width: 480px) {
  .placement-opt-grid {
    grid-template-columns: 1fr;
  }
}

/* A/B/C/D rows like a real exam sheet -- the letter gives each option an
   identity (and echoes real HSK papers), the left alignment reads faster
   than centred fragments, and the stagger makes each new question feel
   dealt rather than dumped. */
.placement-opt {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: 12px;
  padding: 14px 16px;
  border: 1.5px solid var(--border);
  border-radius: 14px;
  background: var(--card);
  cursor: pointer;
  position: relative;
  transition: background 0.15s, border-color 0.15s, transform 0.15s, box-shadow 0.15s;
  text-align: left;
  min-width: 0;
  overflow: hidden;
  animation: pqRise 0.4s ease-out backwards;
  animation-delay: calc(0.16s + var(--opt-i, 0) * 0.06s);
}

.placement-opt:hover {
  border-color: var(--primary);
  transform: translateY(-2px);
  box-shadow: 0 6px 16px var(--coral-glow);
}

.placement-opt .opt-radio {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}

.pq-opt-letter {
  flex: none;
  width: 28px;
  height: 28px;
  display: grid;
  place-items: center;
  border-radius: 9px;
  border: 1.5px solid var(--border);
  background: var(--surface-2);
  color: var(--muted);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.85rem;
  transition: background 0.15s, border-color 0.15s, color 0.15s, transform 0.15s;
}

.placement-opt:hover .pq-opt-letter { border-color: var(--primary); color: var(--primary-dark); }

.placement-opt-content {
  display: block;
  max-width: 100%;
  font-size: 1.05rem;
  line-height: 1.55;
  word-wrap: break-word;
  overflow-wrap: anywhere;
  hyphens: auto;
  color: var(--text);
}

.placement-opt-content .zi,
.placement-opt-content .zi-han {
  font-size: 1.6rem;
  line-height: 1.5;
  vertical-align: middle;
  margin: 0 1px;
}

.placement-opt-content .zi-py {
  font-size: 0.56em;
}

/* Selection state lives in the letter badge: it fills brand-coral with a
   small spring, so the chosen row is unmistakable without a second checkmark
   ornament competing with the letter. */
.placement-opt.selected-answer {
  border-color: var(--primary);
  background: var(--primary-light);
}

.placement-opt.selected-answer .pq-opt-letter {
  border-color: var(--primary);
  background: var(--primary);
  color: #fff;
  animation: pqLetterPop 0.28s cubic-bezier(0.3, 1.6, 0.5, 1);
}

@keyframes pqLetterPop {
  0%  { transform: scale(0.75); }
  60% { transform: scale(1.12); }
}

/* Placement result meta/warnings */
/* Was #fff8e1 on #5d4037 with a #ffe082 border -- hardcoded light-mode hexes
   that rendered as a cream slab dropped into the middle of the dark result
   page (Duke, 2026-07-27). Exactly the bug the confidence chip below had.
   Now it's a quiet inline note in the site's own gold, not a block. */
.placement-warning {
  display: flex;
  align-items: flex-start;
  gap: 8px;
  max-width: 460px;
  margin: 14px auto 0;
  padding: 10px 14px;
  border-radius: 12px;
  background: var(--gold-light);
  color: var(--gold-dark);
  border: 1px solid transparent;
  font-size: 0.86rem;
  line-height: 1.45;
  text-align: left;
}
/* Same notice style, but it opens the card instead of trailing the result --
   swap the top margin for a bottom one so it doesn't hug the phase label. */
.placement-resume-note {
  margin: 0 auto 14px;
}
.placement-warning::before {
  content: "!";
  flex: 0 0 16px;
  height: 16px;
  margin-top: 1px;
  border-radius: 50%;
  background: var(--gold-dark);
  color: var(--card);
  font-size: 0.68rem;
  font-weight: 700;
  line-height: 16px;
  text-align: center;
}

/* Unscoped from the old .placement-meta wrapper, which the rebuilt result
   screen no longer renders -- keeping the descendant selector silently
   dropped the pill shape and left a bare highlight rectangle. */
.confidence {
  display: inline-block;
  padding: 4px 10px;
  border-radius: 20px;
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.03em;
}

/* Confidence chip uses the site's own palette (green/gold/muted) rather
   than the stock emerald/sky/rose it shipped with -- those were hardcoded
   light-mode hexes that read as a foreign blue pill on the dark theme,
   which is nowhere else in this product's palette. */
.confidence-high {
  background: var(--green-light);
  color: var(--green-dark);
}

.confidence-medium {
  background: var(--gold-light);
  color: var(--gold-dark);
}

.confidence-low {
  background: var(--surface-2);
  color: var(--muted);
}

/* ---------- /pinyin: free public pinyin + dictionary lookup ---------- */

/* Full-bleed, exactly like body.nc-app-stage .container and the Level Check
   intro: the shared .nc-app-bar-full has to reach the viewport edges (as it
   does on /study) instead of being boxed into a centred 720px island, which
   left the wordmark floating hundreds of px in from the left on a wide window.
   The 720px reading column now lives on .pinyin-app below. */
.pinyin-page .container {
  padding: 0;
  margin: 0;
  width: 100%;
  max-width: none;
}

/* Two mutually-exclusive views (input vs. result); which one is visible is
   decided server-side via [data-mode] on first load (so it works with no
   JS/shareable URLs), then toggled client-side by app.js afterward without
   a page reload for the "look up again" direction (the "submit a new
   query" direction still needs a real navigation, since new data has to
   come from the server). */
.pinyin-app[data-mode="input"] .pinyin-result-view { display: none; }
.pinyin-app[data-mode="result"] .pinyin-input-view { display: none; }

/* Vertically centers whichever view is active in the middle of the
   viewport -- a short page top-aligned with a lot of empty space below it
   (the old layout) looks fine on a wide desktop window but reads as
   broken/unfinished on a phone, where that empty space is most of the
   screen. Centering as a group (not centering each view separately) is
   what keeps the switch between input and result from jumping. */
.pinyin-app {
  /* The reading column the .container used to supply. Moved here so the nav
     bar above can span the full window while the hero/form/results stay in a
     centred 720px column. Bottom padding keeps content clear of the fixed
     footer. */
  max-width: 720px;
  width: 100%;
  margin: 0 auto;
  padding: 0 20px 56px;
  min-height: 80vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
}

.pinyin-input-view,
.pinyin-result-view {
  width: 100%;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 24px;
  opacity: 1;
  transform: none;
  transition: opacity 0.28s ease, transform 0.28s ease;
}

.pinyin-fade-out {
  opacity: 0;
  transform: translateY(10px) scale(0.985);
}

.pinyin-hero {
  text-align: center;
  padding: 8px 0 4px;
}

.pinyin-hero-title {
  margin: 0 0 8px;
  color: var(--text);
}

.pinyin-hero-sub {
  color: var(--muted);
  font-size: var(--t-body);
  margin: 0 auto 4px;
  max-width: 460px;
}

/* Card hugs the input's own (JS-driven, see app.js) width instead of
   always spanning the full column -- a two-character lookup shouldn't sit
   inside the same wide card as a full sentence. */
.pinyin-form {
  width: fit-content;
  max-width: 100%;
  margin: 0 auto;
  display: flex;
  flex-direction: column;
  gap: 12px;
}

.pinyin-input {
  width: 320px;
  max-width: 100%;
  min-width: 220px;
  border: 2px solid var(--border);
  border-radius: 12px;
  padding: 14px 16px;
  font-family: var(--font-hanzi);
  font-size: 1.3rem;
  line-height: 1.5;
  resize: none;
  overflow: hidden;
  background: var(--bg);
  color: var(--text);
  /* Width/height are set inline by app.js as the user types -- these are
     just the pre-JS/initial-paint fallback. */
  transition: width 0.15s ease;
}

.pinyin-input:focus {
  outline: none;
  border-color: var(--primary);
}

.pinyin-form-row {
  display: flex;
  justify-content: center;
  gap: 10px;
}

.pinyin-submit { min-width: 140px; }
.pinyin-submit:active { transform: scale(0.94); }

/* ---- Result view: just the characters, centered, pinyin directly on
   top of them. This is the whole point of the tool -- everything else
   (definitions) is one tap away, not competing for attention by default. ---- */

.pinyin-result-view {
  /* width/display/flex/align-items/gap inherited from the shared rule
     above -- vertical centering now happens once, on .pinyin-app, so both
     views land in the same spot instead of each computing its own. */
  text-align: center;
  padding: 20px 12px;
}

/* Needs its own stacking context (position + z-index), not just relying on
   the popover's own z-index -- an unpositioned sibling (.pinyin-again-btn)
   paints in normal document-flow order regardless of a *descendant's*
   z-index elsewhere in the tree, which is what let the button render on
   top of an open popover instead of the popover winning. */
.pinyin-focus-text {
  position: relative;
  z-index: 10;
  font-size: 1.05rem;
  line-height: 3;
  max-width: 640px;
}

.pinyin-focus-text .zi {
  font-size: 2.6em;
  margin: 0 0.18em;
}

.pinyin-focus-text .zi-py {
  font-size: 0.5em;
}

.pinyin-plain { word-break: break-word; }

/* Characters pop in one at a time (left to right, via a per-item
   animation-delay set inline by the template) instead of the whole
   sentence appearing at once -- reads as lively/"dealt" rather than a
   static block of text landing on the page. */
@keyframes pinyin-pop-in {
  0% { opacity: 0; transform: translateY(10px) scale(0.55); }
  65% { opacity: 1; transform: translateY(-2px) scale(1.08); }
  100% { opacity: 1; transform: translateY(0) scale(1); }
}

.pinyin-focus-text .pinyin-word-wrap,
.pinyin-focus-text .pinyin-plain {
  display: inline-block;
  animation: pinyin-pop-in 0.5s cubic-bezier(0.34, 1.56, 0.64, 1) both;
}

/* A word with a real dictionary entry: a soft dotted underline under the
   whole word is the only hint that there is more here, so the default view
   stays a clean line of text. The tappable unit is the *character* inside
   it (.pinyin-char), not the word -- one tap opens the writing panel, which
   carries the stroke order plus both the character's and the word's
   meaning. That replaced the old definition-only popover: two overlays
   competing for the same tap was the worse design. */
.pinyin-word-wrap {
  position: relative;
  display: inline-block;
  margin: 0 0.12em;
}

.pinyin-word-wrap.has-entry .zi-han {
  border-bottom: 2px dotted var(--border);
}

.pinyin-char {
  appearance: none;
  border: none;
  background: none;
  padding: 0;
  margin: 0;
  font: inherit;
  color: inherit;
  cursor: pointer;
  border-radius: 10px;
  transition: background 0.15s ease, transform 0.12s ease;
}

.pinyin-char:hover,
.pinyin-char:focus-visible {
  background: var(--primary-light);
  outline: none;
  transform: translateY(-2px);
}

.pinyin-char:hover .zi-han,
.pinyin-char:focus-visible .zi-han {
  border-bottom-color: var(--primary);
}

.pinyin-char:active { transform: scale(0.94); }

.pinyin-hint {
  margin: -8px 0 0;
  font-size: 0.85rem;
  color: var(--muted);
  text-align: center;
  opacity: 0;
  animation: pinyin-fade-up 0.4s ease 0.5s both;
}

/* ---- Copy / practice-sheet chips under the text ---- */

.pinyin-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 10px;
  opacity: 0;
  animation: pinyin-fade-up 0.4s ease 0.55s both;
}

.pinyin-chip {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  padding: 7px 14px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--card);
  color: var(--muted);
  font-family: var(--font-sans);
  font-size: 0.85rem;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease, transform 0.12s ease;
}

.pinyin-chip:hover {
  color: var(--text);
  border-color: var(--primary);
  transform: translateY(-1px);
}

.pinyin-chip:active { transform: scale(0.95); }

.pinyin-chip svg,
.pw-btn svg {
  width: 15px;
  height: 15px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* Copy confirmation without adding a word of UI text: the icon becomes a
   tick and the chip goes green for a moment. */
.pinyin-chip.is-copied {
  color: var(--green-dark);
  border-color: var(--green);
  background: var(--green-light);
}
.pinyin-chip.is-copied svg { display: none; }
.pinyin-chip.is-copied::before { content: '✓'; font-weight: 700; }

/* The one chip in the row that isn't a passive utility (copy, print) but the
   value action -- turn this lookup into a card you'll study. Primary-tinted so
   it reads as "do this", fills in on hover. */
.pinyin-chip-accent {
  color: var(--primary-dark);
  border-color: var(--primary);
  background: var(--primary-light);
}
.pinyin-chip-accent:hover {
  color: #fff;
  background: var(--primary);
  border-color: var(--primary);
}

/* ---- Writing panel (tap a character) ------------------------------------
   Same shape as the flashcard modal (.nc-modal): fixed overlay, blurred
   backdrop, card that rises in. On a phone it becomes a bottom sheet,
   because that is where a thumb is. */

body.pw-locked { overflow: hidden; }

.pw { position: fixed; inset: 0; z-index: 120; }

.pw-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(28, 20, 14, 0.5);
  -webkit-backdrop-filter: blur(7px);
  backdrop-filter: blur(7px);
  opacity: 0;
  transition: opacity 0.25s ease;
}
.pw.open .pw-backdrop { opacity: 1; }

.pw-card {
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -46%) scale(0.96);
  width: min(680px, calc(100vw - 32px));
  max-height: min(88vh, 760px);
  overflow-y: auto;
  background: var(--card);
  border-radius: 22px;
  box-shadow: var(--shadow);
  padding: 26px 28px 24px;
  opacity: 0;
  transition: opacity 0.25s ease, transform 0.3s cubic-bezier(0.34, 1.4, 0.64, 1);
}
.pw.open .pw-card {
  opacity: 1;
  transform: translate(-50%, -50%) scale(1);
}

.pw-close {
  position: absolute;
  top: 14px;
  right: 16px;
  width: 32px;
  height: 32px;
  border: 1px solid var(--border);
  border-radius: 50%;
  background: transparent;
  color: var(--muted);
  font-size: 1.2rem;
  line-height: 1;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease;
}
.pw-close:hover { color: var(--text); border-color: var(--muted); }

.pw-head {
  display: flex;
  align-items: baseline;
  gap: 14px;
  margin-bottom: 18px;
}

.pw-char {
  font-family: var(--font-hanzi-serif);
  font-size: 2.4rem;
  font-weight: 600;
  line-height: 1;
  color: var(--text);
}

.pw-py {
  font-family: var(--font-sans);
  font-size: 0.95rem;
  font-weight: 600;
  color: var(--primary-dark);
}

.pw-stage {
  display: flex;
  gap: 22px;
  align-items: center;
}

/* 田字格: solid box, dashed cross. Drawn with pseudo-elements rather than a
   background image so it stays crisp at any zoom and can be switched off. */
.pw-grid {
  position: relative;
  flex: 0 0 auto;
  width: 260px;
  height: 260px;
  border: 1px solid var(--border);
  border-radius: 14px;
  background: var(--bg);
}
.pw-grid::before,
.pw-grid::after {
  content: '';
  position: absolute;
  border-color: var(--border);
  /* The guide lines sit over the writing surface -- they must never swallow
     a pointer event, or tracing a stroke stops working mid-drag. */
  pointer-events: none;
}
.pw-grid::before { left: 0; right: 0; top: 50%; border-top: 1px dashed var(--border); }
.pw-grid::after { top: 0; bottom: 0; left: 50%; border-left: 1px dashed var(--border); }
.pw-writer { position: relative; z-index: 1; }

.pw-controls {
  display: flex;
  flex-direction: column;
  gap: 10px;
  min-width: 0;
}

.pw-btn {
  display: inline-flex;
  align-items: center;
  gap: 9px;
  padding: 10px 16px;
  border: 1px solid var(--border);
  border-radius: 12px;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-sans);
  font-size: 0.9rem;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
  transition: border-color 0.15s ease, background 0.15s ease, transform 0.12s ease;
}
.pw-btn:hover { border-color: var(--primary); background: var(--primary-light); }
.pw-btn:active { transform: scale(0.96); }

/* Stroke-order diagram: one glyph per stroke, newest picked out in colour. */
.pw-strip {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  margin: 20px 0 4px;
}

.pw-strip-item {
  width: 46px;
  height: 46px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--bg);
}
.pw-strip-old { fill: var(--muted); opacity: 0.5; }
.pw-strip-new { fill: var(--primary-dark); }

.pw-meaning { margin-top: 16px; }

.pw-entry + .pw-entry {
  margin-top: 14px;
  padding-top: 14px;
  border-top: 1px solid var(--border);
}

.pw-entry-head {
  display: flex;
  align-items: baseline;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 6px;
}

.pw-entry-word {
  font-family: var(--font-hanzi);
  font-size: 1.15rem;
  font-weight: 600;
  color: var(--text);
}
.pw-entry-py { font-size: 0.85rem; font-weight: 600; color: var(--primary-dark); }
.pw-entry-label { font-size: 0.75rem; color: var(--muted); text-transform: lowercase; }

.pw-defs {
  margin: 0;
  padding-left: 18px;
  color: var(--text);
  font-size: 0.92rem;
  line-height: 1.5;
}
.pw-defs li { margin-bottom: 3px; }
.pw-def-ru { color: var(--text); }
/* A shortened BKRS entry ends in a tappable ellipsis that expands it. */
.pw-more {
  appearance: none;
  border: none;
  background: none;
  padding: 0 2px;
  margin-left: 2px;
  font: inherit;
  font-weight: 700;
  color: var(--primary-dark);
  cursor: pointer;
}
.pw-more:hover { color: var(--primary); }

.pw-note { color: var(--muted); font-size: 0.9rem; margin: 0; }

@media (max-width: 640px) {
  .pw-card {
    top: auto;
    bottom: 0;
    left: 0;
    transform: translateY(16px);
    width: 100%;
    max-height: 92vh;
    border-radius: 22px 22px 0 0;
    padding: 22px 18px 20px;
  }
  .pw.open .pw-card { transform: translateY(0); }
  .pw-stage { flex-direction: column; gap: 16px; }
  .pw-controls { flex-direction: row; flex-wrap: wrap; justify-content: center; }
  .pw-btn { padding: 9px 13px; font-size: 0.85rem; }
}

@media (prefers-reduced-motion: reduce) {
  .pw-card, .pw-backdrop { transition: none; }
}

/* ---- /pinyin/sheet: printable 田字格 practice sheet (прописи) ------------
   Screen shows the same markup that prints; @media print just drops the
   controls and forces the page to A4. */

.pinyin-sheet-page .ps-bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 12px;
  margin: 8px 0 22px;
}

.ps-bar-right { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }

.ps-back {
  color: var(--muted);
  text-decoration: none;
  font-size: 0.9rem;
  font-weight: 600;
}
.ps-back:hover { color: var(--text); }

.ps-toggle {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 0.88rem;
  color: var(--muted);
  cursor: pointer;
}

.ps-block { margin-bottom: 18px; break-inside: avoid; }

.ps-label { margin-bottom: 4px; height: 1.1em; }
.ps-label-py {
  font-family: var(--font-sans);
  font-size: 0.8rem;
  font-weight: 700;
  color: var(--primary-dark);
  letter-spacing: 0.02em;
}
.ps-sheet.no-pinyin .ps-label { visibility: hidden; }

/* Cells share borders: every cell draws its own top/left, the row closes the
   right/bottom edge -- no double-thickness lines between neighbours. */
.ps-row {
  display: grid;
  grid-template-columns: repeat(10, 1fr);
  border-right: 1px solid var(--sheet-line, #b9a893);
  border-bottom: 1px solid var(--sheet-line, #b9a893);
}

.ps-cell {
  position: relative;
  aspect-ratio: 1 / 1;
  /* So the character can be sized as a share of the cell (see .ps-char):
     the cell is 1/10 of the sheet width on screen and ~18mm on paper, and a
     fixed rem size would be right for only one of those. */
  container-type: inline-size;
  border-top: 1px solid var(--sheet-line, #b9a893);
  border-left: 1px solid var(--sheet-line, #b9a893);
  display: flex;
  align-items: center;
  justify-content: center;
}

.ps-cell::before,
.ps-cell::after {
  content: '';
  position: absolute;
  pointer-events: none;
}
.ps-cell::before { left: 0; right: 0; top: 50%; border-top: 1px dashed var(--sheet-guide, #d8cbba); }
.ps-cell::after { top: 0; bottom: 0; left: 50%; border-left: 1px dashed var(--sheet-guide, #d8cbba); }
.ps-sheet.no-guides .ps-cell::before,
.ps-sheet.no-guides .ps-cell::after { display: none; }

.ps-char {
  font-family: var(--font-hanzi-serif);
  font-size: 2.2rem;      /* fallback for browsers without container queries */
  font-size: 70cqw;
  font-weight: 500;
  color: var(--text);
  line-height: 1;
}
.ps-fade-1 { opacity: 0.45; }
.ps-fade-2 { opacity: 0.28; }
.ps-fade-3 { opacity: 0.15; }

.ps-empty { color: var(--muted); }

@media (max-width: 640px) {
  .ps-char { font-size: 1.5rem; font-size: 70cqw; }
}

@media print {
  /* Print in ink terms, not theme terms: the sheet is meant to be traced
     with a pencil, so it always prints as black on white regardless of the
     student's dark-mode setting. */
  .ps-bar, .lang-switch, .pinyin-footer, .admin-nav, .topbar { display: none !important; }
  body { background: #fff !important; }
  .container { max-width: none; padding: 0; }
  .ps-char { color: #000 !important; }
  .ps-label-py { color: #000 !important; }
  .ps-row, .ps-cell { border-color: #444 !important; }
  .ps-cell::before, .ps-cell::after { border-color: #bbb !important; }
  .ps-block { margin-bottom: 10px; }
  * { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
  @page { size: A4 portrait; margin: 12mm; }
}

/* Fades up after the character stagger has had time to finish, rather
   than appearing simultaneously with the text it's meant to follow. */
@keyframes pinyin-fade-up {
  0% { opacity: 0; transform: translateY(8px); }
  100% { opacity: 1; transform: translateY(0); }
}

.pinyin-again-btn {
  min-width: 160px;
  text-align: center;
  opacity: 0;
  animation: pinyin-fade-up 0.4s ease 0.65s both;
}
.pinyin-again-btn:active { transform: scale(0.94); }

/* Real fixed footer, not just the last thing in the document flow. */
.pinyin-footer {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 20;
  padding: 10px 16px;
  text-align: center;
  font-size: var(--t-eyebrow);
  color: var(--muted);
  background: var(--card);
  border-top: 1px solid var(--border);
}

.pinyin-footer a { color: inherit; text-decoration: underline; }

@media (max-width: 560px) {
  .pinyin-app { min-height: 74vh; }
  .pinyin-focus-text .zi { font-size: 2em; }
}

/* Flashcards (added 2026-07-19) */
.fc-auth-switch { text-align: center; margin-top: 16px; font-size: 0.9rem; }
.fc-dashboard { max-width: 720px; margin: 0 auto; padding: 24px 0; }
.fc-dashboard-header { display: flex; justify-content: space-between; align-items: center; }
.fc-logout { color: var(--muted); font-size: 0.9rem; }
.fc-due-panel { margin: 20px 0; padding: 16px; border-radius: 12px; background: var(--surface-2); }
.fc-actions { margin-bottom: 24px; }
.fc-card-table { width: 100%; border-collapse: collapse; }
.fc-card-table th, .fc-card-table td { text-align: left; padding: 8px 12px; border-bottom: 1px solid var(--border); }
.fc-word { font-size: 1.1rem; font-weight: 600; }
.fc-lookup-form { display: flex; gap: 12px; align-items: flex-end; margin-bottom: 20px; }
.fc-lookup-form .form-field { flex: 1; margin-bottom: 0; }
.fc-review-card { text-align: center; padding: 32px 16px; }
.fc-review-front { font-size: 2rem; margin-bottom: 16px; }
.fc-pinyin { color: var(--muted); }
.fc-example { margin-top: 12px; font-size: 1.1rem; }
.fc-reveal summary { cursor: pointer; color: var(--primary); margin-bottom: 12px; }
.fc-rating-form { display: flex; gap: 8px; justify-content: center; margin-top: 24px; }
.fc-rate-again { background: var(--danger-wash); color: var(--accent); }
.fc-rate-hard { background: var(--surface-2); color: var(--text); }
.fc-rate-good { background: var(--primary); color: white; }
.fc-rate-easy { background: var(--primary-dark); color: white; }
.fc-remaining { color: var(--muted); }
/* NihaoCard intro (added 2026-07-19; renamed from "Recall" same day) --
   full-screen, one-message-at-a-time story experience, replacing an
   earlier three-card feature list. Class names below keep the "recall-"
   prefix from the original build -- purely an internal implementation
   detail, not user-facing, and renaming every class risked more breakage
   than it was worth for a same-day rename. Reuses the .test-stage
   grid+glow background system (see body.test-stage above) for visual
   consistency with the rest of the app's premium "stage" moments
   rather than inventing a second background language. */

.recall-intro {
  position: relative;
  width: 100%;
  min-height: 100vh;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

/* Header is a real flex row in normal document flow -- not three
   independently absolutely-positioned elements each guessing at the
   others' size. That guessing game is exactly what caused Skip and the
   RU/EN pill to overlap on a real phone even though a local screenshot
   check at one specific viewport size looked fine; a flex layout can't
   have that failure mode since the browser lays siblings out for real. */
/* .recall-header is gone: the NihaoCard onboarding screen used to carry its
   own near-copy of the app bar (same shape, 24px padding instead of 22/28,
   a 1.1rem wordmark instead of 1.3rem). It renders from _nav.html now, like
   every other bar in the app. .recall-wordmark below survives because the
   shared bar itself uses it -- it is the wordmark's type, not a header. */
.recall-wordmark {
  font-family: var(--font-display);
  font-size: var(--t-title);
  font-weight: 600;
  letter-spacing: 0.01em;
}
.login-header h1.recall-wordmark-inline {
  font-family: var(--font-display);
  font-weight: 600;
  color: var(--text);
}
.recall-wordmark-accent { color: var(--primary); }

/* .recall-header-actions went with .recall-header -- the shared bar's actions
   row is .nc-app-bar-actions. */

.recall-lang-switch {
  display: flex;
  gap: 2px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 3px;
}
/* button variant (e.g. the Settings language pill, flashcards_settings.html)
   sits alongside the original <a> one (the RU/EN nav pill, which really
   does navigate) -- same look, but a JS click handler instead of a link. */
.recall-lang-switch a,
.recall-lang-switch button {
  color: var(--muted);
  font-size: var(--t-caption);
  font-weight: 700;
  padding: 5px 10px;
  border-radius: 999px;
  text-decoration: none;
  letter-spacing: 0.02em;
  border: none;
  background: none;
  font-family: inherit;
  cursor: pointer;
}
.recall-lang-switch a:hover,
.recall-lang-switch button:hover { color: var(--text); }
.recall-lang-switch a.lang-active,
.recall-lang-switch button.lang-active { background: var(--primary); color: #fff; }

.recall-skip {
  /* Matches .recall-lang-switch's pill treatment instead of sitting next
     to it as bare unstyled text -- reads as one control cluster instead
     of one designed element and one afterthought. visibility (not
     display) is toggled in JS on the last slide so the header's flex
     layout never reflows/shifts when it disappears. */
  background: var(--card);
  border: 1px solid var(--border);
  color: var(--muted);
  font-size: 0.78rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  border-radius: 999px;
  padding: 8px 16px;
  cursor: pointer;
}
.recall-skip:hover { color: var(--text); border-color: var(--muted); }

/* Progress bar is capped to the same reading width as the slide text
   instead of stretching edge-to-edge on wide desktop screens, where a
   full-bleed bar reads as an oversized loading indicator rather than a
   subtle step tracker. */
.recall-progress-wrap {
  padding: 20px 24px 0;
}
.recall-progress {
  display: flex;
  gap: 6px;
  max-width: 480px;
  margin: 0 auto;
}
.recall-progress-seg {
  flex: 1;
  height: 3px;
  border-radius: 3px;
  background: var(--border);
  overflow: hidden;
}
.recall-progress-seg-fill {
  height: 100%;
  width: 0%;
  background: var(--primary);
  transition: width 0.4s ease;
  border-radius: 3px;
}
.recall-progress-seg.is-filled .recall-progress-seg-fill { width: 100%; }

/* Previous approach: slide the whole track horizontally by 100% per
   step, with a separate opacity cross-fade layered on top. The two
   transitions had different durations (0.65s transform vs 0.5s opacity),
   so the incoming slide visually "arrived" at full opacity before its
   motion had actually finished -- that mismatch is what read as "too
   fast"/off, not the duration numbers themselves.

   Replaced with a simpler, more reliable pattern: every slide is
   stacked in the exact same box (position: absolute; inset: 0) and only
   the active one is visible, fading + rising into place a few pixels.
   Only one transition to keep in sync, and it's the calm "content
   settles into place" motion used in most premium onboarding flows
   (Linear, Arc, Stripe's own docs walkthroughs) rather than a
   slide-swoosh, which is much harder to make feel unhurried. */
.recall-track-viewport {
  flex: 1;
  position: relative;
}
.recall-track {
  /* inset: 0 against the positioned viewport above, not height: 100% --
     percentage heights need the containing block's height to be
     "specified explicitly" in the CSS-spec sense, and a flex-grown
     height doesn't reliably count as that across browsers. inset: 0
     sidesteps the ambiguity entirely (same technique .recall-slide
     already uses one level down). */
  position: absolute;
  inset: 0;
}

.recall-slide {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: 40px 24px 60px;
  opacity: 0;
  transform: translateY(14px);
  transition: opacity 0.7s cubic-bezier(0.16, 1, 0.3, 1), transform 0.7s cubic-bezier(0.16, 1, 0.3, 1);
  pointer-events: none;
}
.recall-slide.is-active {
  opacity: 1;
  transform: translateY(0);
  pointer-events: auto;
}

.recall-next-inline {
  margin-top: 36px;
  background: linear-gradient(135deg, var(--primary), var(--primary-dark));
  color: white;
  border: none;
  padding: 14px 36px;
  border-radius: 999px;
  font-size: 1rem;
  font-weight: 600;
  cursor: pointer;
  box-shadow: 0 8px 24px var(--coral-glow);
  transition: transform 0.15s ease;
}
.recall-next-inline:hover { transform: translateY(-1px); }

.recall-eyebrow {
  color: var(--primary);
  margin-bottom: 18px;
}

.recall-headline {
  line-height: 1.25;
  max-width: 620px;
  margin: 0 0 20px;
}
.recall-headline em {
  color: var(--primary);
  font-style: normal;
}

.recall-sub {
  color: var(--muted);
  font-size: var(--t-lead);
  line-height: 1.6;
  max-width: 460px;
  margin: 0;
}

.recall-cta-buttons {
  display: flex;
  gap: 14px;
  justify-content: center;
  margin-top: 36px;
}
.recall-btn {
  padding: 14px 30px;
  border-radius: 999px;
  font-weight: 600;
  font-size: 1rem;
  text-decoration: none;
  display: inline-block;
  transition: transform 0.15s ease, background 0.15s ease;
}
.recall-btn:hover { transform: translateY(-1px); }
.recall-btn-primary {
  background: linear-gradient(135deg, var(--primary), var(--primary-dark));
  color: white;
  box-shadow: 0 8px 24px var(--coral-glow);
}

/* Per-slide diagrams (added 2026-07-19) -- Duke's feedback was that text
   floating alone in a huge dark page didn't feel finished or "designed."
   These are small wordless SVG diagrams that actually illustrate each
   slide's specific claim (the forgetting curve, the recall-strengthens-
   memory moment, the growing-interval review schedule) rather than
   decorative flourishes -- kept textless on purpose so they work in both
   languages with zero extra translation surface. */
.recall-visual { margin-bottom: 24px; }
.recall-visual svg { display: block; }

/* Slide 1's visual: a real flashcard -- same rounded/bordered/shadowed
   surface as the actual review card (.nc-review-card), not an abstract
   diagram -- doing a continuous 3D flip between the hanzi face and its
   meaning, with a small spring/scale pop at the midpoint of each flip.
   Two abstract-line-art attempts at this slide (fading card boxes, then
   a dissolving dot trail) both read as illustration filler rather than
   showing the actual product; a real flashcard flipping in place is a
   one-second demo of what NihaoCard *is*, and reads as premium/fun
   through the spring timing rather than through added ornamentation. */
.recall-flipcard-stage {
  perspective: 900px;
}
.recall-flipcard {
  /* Bank-card proportions (ISO/IEC 7810 ID-1 ratio, ~1.586:1) -- the
     earlier portrait tile read as a squarish icon, not a card. */
  position: relative;
  width: 300px;
  height: 189px;
  transform-style: preserve-3d;
  animation: recall-flip 6s cubic-bezier(0.65, 0, 0.35, 1) infinite;
}
.recall-flipcard-face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  border-radius: 18px;
  border: 1.5px solid var(--border);
  background: var(--card);
  box-shadow: var(--shadow), 0 22px 44px -18px var(--coral-glow);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
}
.recall-flipcard-back { transform: rotateY(180deg); }
.recall-flipcard-hanzi {
  font-family: var(--font-hanzi-serif);
  font-weight: 600;
  font-size: 2.6rem;
}
.recall-flipcard-pinyin { color: var(--primary-dark); font-weight: 600; font-size: 1rem; }
.recall-flipcard-meaning {
  font-family: var(--font-display); font-size: 1.5rem; font-weight: 700; }

/* Glint: a soft diagonal highlight band sweeps across the face, then
   waits off-screen until the next pass -- an intermittent glint (like
   light catching a gem) reads as premium; a continuously-sweeping band
   reads as a loading skeleton instead. Implemented as an animated
   background gradient on the card itself, NOT a transformed ::after
   band: iOS Safari doesn't clip transformed pseudo-element layers to
   the parent's rounded corners (the glint flashed as a bare square),
   and the clip-path workaround clips away the card's glow shadow too.
   A background is clipped by border-radius natively in every browser.
   The oversized background-image (260% wide) plus a 100%->0%
   background-position sweep moves the band fully across and parks it
   off-card between passes. */
.recall-flipcard-face,
.recall-deck-card.recall-deck-top {
  /* Doubled class on the deck selector so this beats .recall-deck-card's
     later `background:` shorthand, which would otherwise reset the
     gradient to none. */
  background-image: linear-gradient(75deg, transparent 42%, rgba(255, 255, 255, 0.3) 50%, rgba(255, 255, 255, 0.1) 55%, transparent 63%);
  background-size: 260% 100%;
  background-repeat: no-repeat;
  background-position: 100% 0;
}
.recall-flipcard-face { animation: recall-shine 6s ease-in-out infinite; }
.recall-flipcard-back { animation-delay: 3s; }

@keyframes recall-shine {
  0%   { background-position: 100% 0; }
  18%  { background-position: 0% 0; }
  100% { background-position: 0% 0; }
}

@keyframes recall-flip {
  0%, 15%   { transform: rotateY(0deg)   scale(1); }
  25%       { transform: rotateY(90deg)  scale(1.08); }
  35%, 65%  { transform: rotateY(180deg) scale(1); }
  75%       { transform: rotateY(270deg) scale(1.08); }
  85%, 100% { transform: rotateY(360deg) scale(1); }
}

.recall-pulse-ring {
  transform-box: fill-box;
  transform-origin: center;
  animation: recall-pulse 1.8s ease-out infinite;
}
@keyframes recall-pulse {
  0% { r: 5; opacity: 0.7; }
  100% { r: 17; opacity: 0; }
}

/* Slide 4's visual: previously four disconnected diagonal segments with
   no visible "moment" -- didn't actually depict the headline/sub's claim
   (a word is shown right before it's forgotten, on a schedule that
   grows). Replaced with a single continuous sawtooth: the memory curve
   decays, gets caught right at its low point (a review, marked with a
   dot), and snaps back up -- with each interval wider than the last.
   pathLength="1" on the path lets stroke-dasharray/dashoffset just use
   0-1 regardless of the curve's real length. The line draws itself,
   holds, then erases (echoing the slide's own subject: learned, held,
   forgotten, then reviewed again) on an infinite loop; each dot flashes
   on at roughly the moment the draw reaches it. */
.recall-sawtooth-path {
  stroke-dasharray: 1;
  stroke-dashoffset: 1;
  animation: recall-draw-erase 5s cubic-bezier(0.45, 0, 0.55, 1) infinite;
}
@keyframes recall-draw-erase {
  0%   { stroke-dashoffset: 1; }
  55%  { stroke-dashoffset: 0; }
  75%  { stroke-dashoffset: 0; }
  100% { stroke-dashoffset: 1; }
}
.recall-sawtooth-dot {
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
  animation: recall-dot-flash 5s ease-in-out infinite;
}
@keyframes recall-dot-flash {
  0%   { opacity: 0; transform: scale(0.5); }
  10%  { opacity: 1; transform: scale(1.15); }
  20%  { opacity: 1; transform: scale(1); }
  70%  { opacity: 1; transform: scale(1); }
  85%  { opacity: 0; transform: scale(1); }
  100% { opacity: 0; transform: scale(1); }
}

/* Slide 6 (CTA): a small fanned deck of flashcards -- the literal "first
   deck" the sub copy invites you to start -- floating gently in place.
   Same card language as slide 1's big flipcard (surface, border, glow
   shadow, bank-card ratio), just smaller and dealt as a hand. Each card
   bobs on its own phase so the fan feels alive rather than mechanical,
   and the front card reuses the recall-shine glint. */
.recall-deck {
  position: relative;
  width: 300px;
  height: 150px;
  /* The rotated side cards' corners dip ~40px below this box, so the
     .recall-visual default 24px gap let them overlap the headline. */
  margin-bottom: 56px;
}
.recall-deck-card {
  position: absolute;
  left: 50%;
  bottom: 0;
  width: 138px;
  height: 87px;
  margin-left: -69px;
  border-radius: 12px;
  border: 1.5px solid var(--border);
  background: var(--card);
  box-shadow: var(--shadow), 0 16px 32px -14px var(--coral-glow);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 3px;
  transform-origin: 50% 130%;
  animation: recall-deck-float 3.4s ease-in-out infinite alternate;
}
.recall-deck-left  { transform: rotate(-18deg) translateX(-92px); animation-delay: -0.9s; }
.recall-deck-right { transform: rotate(18deg) translateX(92px); animation-delay: -2.1s; }
.recall-deck-top {
  /* Front card is the star of the hand -- scale it up to highlight it
     (the below-card transform-origin also lifts it slightly as a side
     effect, which reads as it being drawn out of the fan). Its shine
     comes from the shared background-gradient glint rule next to
     .recall-flipcard-face; the float and shine animations must be
     declared together since `animation` is one property. */
  transform: scale(1.16);
  animation: recall-deck-float 3.4s ease-in-out infinite alternate, recall-shine 5s ease-in-out infinite;
}
/* Bob by nudging bottom rather than transform, so it stacks with each
   card's own fan rotation/offset without duplicating them in keyframes. */
@keyframes recall-deck-float {
  from { bottom: 0; }
  to { bottom: 10px; }
}
.recall-deck-hanzi {
  font-family: var(--font-hanzi-serif);
  font-weight: 600;
  font-size: 1.45rem;
}
.recall-deck-pinyin { color: var(--primary-dark); font-weight: 600; font-size: 0.78rem; }

.recall-demo-pair {
  display: flex;
  gap: 16px;
  margin-bottom: 28px;
}
.recall-demo-card {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 16px 22px;
  min-width: 108px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  box-shadow: var(--shadow);
}
.recall-demo-word {
  font-family: var(--font-display);
  font-size: 1.3rem;
  font-weight: 600;
}
.recall-demo-arrow { color: var(--muted); font-size: 0.85rem; }
.recall-demo-meaning { font-family: var(--font-display); color: var(--muted); font-size: 0.95rem; }

@media (max-width: 560px) {
  .recall-sub { font-size: 1.02rem; }
  .recall-slide { padding: 32px 20px 48px; }
  .recall-cta-buttons { flex-direction: column; width: 100%; max-width: 280px; }
  .recall-flipcard { width: 244px; height: 154px; }
  .recall-flipcard-hanzi { font-size: 2.15rem; }
  .recall-flipcard-meaning { font-size: 1.25rem; }
  /* The fan's rotated side cards reach ~360px edge to edge -- wider than
     a 375px phone. scale keeps the whole composition intact instead of
     re-deriving every offset at a second size. */
  .recall-deck { transform: scale(0.72); margin: -20px 0 36px; }
}

/* NihaoCard auth pages v2 (2026-07-22) -- "private membership" restage.
   The earlier version was a deliberately quiet flat auth card, but next
   to the redesigned marketing intro it read as a sudden drop into
   static-form land. Duke's brief: signing up should feel like opening a
   premium private-bank account. So: same grid+glow stage as the intro
   (body reuses test-stage/recall-stage), a floating "member card" --
   chip, card-number dots, hanzi watermark, periodic shine, slow 3D
   sway -- beside a serif-headed form. "Member" on the card stays
   untranslated like real card chrome (and like the wordmark).
   .nc-field / .nc-auth-btn / .nc-auth-error / .nc-auth-switch base
   styles are shared with the app's other forms, so all premium
   overrides here are scoped under .nc-vip-form. */

.nc-vip-page {
  min-height: 100vh;
  width: 100%;
  display: flex;
  flex-direction: column;
}
.nc-vip-home { text-decoration: none; color: var(--text); }

.nc-vip-center {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 80px;
  padding: 48px 24px 64px;
  flex-wrap: wrap;
}

.nc-vip-card {
  position: relative;
  flex-shrink: 0;
  width: 340px;
  height: 214px;
  border-radius: 20px;
  border: 1.5px solid var(--border);
  background-color: var(--card);
  /* Layer 1 is the same swept glint as the marketing cards (see
     recall-shine); layer 2 is a static soft top-left sheen that makes
     the surface read as brushed metal rather than flat plastic. */
  background-image:
    linear-gradient(75deg, transparent 42%, rgba(255, 255, 255, 0.3) 50%, rgba(255, 255, 255, 0.1) 55%, transparent 63%),
    linear-gradient(135deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0) 55%);
  background-size: 260% 100%, 100% 100%;
  background-repeat: no-repeat;
  background-position: 100% 0, 0 0;
  box-shadow: var(--shadow), 0 30px 60px -22px var(--coral-glow);
  animation: nc-vip-sway 9s ease-in-out infinite, recall-shine 7s ease-in-out infinite;
}
@keyframes nc-vip-sway {
  0%, 100% { transform: perspective(900px) rotateY(-8deg) rotateX(3deg); }
  50%      { transform: perspective(900px) rotateY(8deg) rotateX(-3deg); }
}

/* The card's face is a real flashcard (pinyin over the word, centered),
   not bank-card chrome -- Duke's brief was bank-card *feeling* (weight,
   shine, sway), but the object itself must stay a flashcard. Bottom row
   pairs the wordmark with a small brain icon (memory is the product's
   whole pitch; Duke vetoed an HSK level badge as off-message). */
.nc-vip-card-face {
  position: absolute;
  inset: 0 0 24px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 8px;
}
.nc-vip-card-hanzi {
  font-family: var(--font-hanzi-serif);
  font-weight: 600;
  font-size: 2.9rem;
  line-height: 1.2;
}
.nc-vip-card-pinyin {
  color: var(--primary-dark);
  font-weight: 600;
  font-size: 1.05rem;
}
.nc-vip-card-bottom {
  position: absolute;
  left: 26px;
  right: 26px;
  bottom: 20px;
  display: flex;
  justify-content: space-between;
  align-items: baseline;
}
.nc-vip-card-brand {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: 1.05rem;
}
.nc-vip-card-icon { color: var(--muted); opacity: 0.85; }

.nc-vip-form {
  width: 100%;
  max-width: 380px;
}
.nc-vip-eyebrow {
  color: var(--primary);
  margin: 0 0 10px;
}
.nc-vip-heading {
  line-height: 1.2;
  margin: 0 0 26px;
}
.nc-vip-form .nc-field input {
  border-radius: 12px;
  padding: 13px 15px;
}
.nc-vip-form .nc-field input:hover { border-color: var(--muted); }
.nc-vip-form .nc-auth-btn {
  border-radius: 999px;
  padding: 14px;
  margin-top: 10px;
  background: linear-gradient(135deg, var(--primary), var(--primary-dark));
  box-shadow: 0 8px 24px var(--coral-glow);
  transition: transform 0.15s ease;
}
.nc-vip-form .nc-auth-btn:hover { transform: translateY(-1px); }

@media (max-width: 900px) {
  .nc-vip-center { gap: 44px; }
}
@media (max-width: 560px) {
  .nc-vip-card { width: 300px; height: 189px; }
  .nc-vip-card-hanzi { font-size: 2.5rem; }
  .nc-vip-center { padding-top: 24px; }
}

.nc-auth-error {
  background: var(--danger-wash);
  color: var(--accent);
  padding: 11px 14px;
  border-radius: 10px;
  margin-bottom: 20px;
  font-size: 0.9rem;
}

.nc-verify-banner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  background: var(--surface-2);
  border: 1px solid var(--border);
  color: var(--muted);
  padding: 11px 16px;
  border-radius: 10px;
  margin-bottom: 20px;
  font-size: 0.88rem;
}
.nc-verify-banner button {
  background: var(--card);
  border: 1px solid var(--border);
  color: var(--text);
  padding: 6px 12px;
  border-radius: 8px;
  font-size: 0.85rem;
  cursor: pointer;
  white-space: nowrap;
}
.nc-verify-banner button:hover { background: var(--surface-2-hover); }
/* Success variant: a positive one-time toast (email verified), visually
   distinct from the amber "please verify" action banner. Auto-removed by
   JS; nc-verify-banner-hide is the fade-out it applies just before. */
.nc-verify-banner-ok {
  background: var(--green-light);
  border-color: var(--green);
  color: var(--green-dark);
  transition: opacity 0.4s ease, transform 0.4s ease;
}
.nc-verify-banner-hide { opacity: 0; transform: translateY(-6px); }

.nc-turnstile { margin-bottom: 18px; }

.nc-field { margin-bottom: 18px; }
.nc-field label {
  display: block;
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--muted);
  margin-bottom: 7px;
}
.nc-field input,
.nc-field select {
  width: 100%;
  padding: 12px 14px;
  border-radius: 10px;
  border: 1px solid var(--border);
  background: var(--bg);
  color: var(--text);
  font-size: 0.98rem;
  font-family: inherit;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.nc-field select { cursor: pointer; }
.nc-field input:focus,
.nc-field select:focus {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--coral-glow);
}

.nc-field-row {
  display: flex;
  gap: 12px;
}
.nc-field-row .nc-field { flex: 1; min-width: 0; }

.nc-password-wrap { position: relative; }
.nc-password-wrap input { padding-right: 84px; }
.nc-password-toggle {
  /* Lives inside the password field, so it belongs to the form-control
     cluster, not to the serif-leads set the base button rule applies. */
  font-family: var(--font-sans);
  position: absolute;
  right: 6px;
  top: 50%;
  transform: translateY(-50%);
  background: none;
  border: none;
  color: var(--muted);
  font-size: var(--t-caption);
  font-weight: 600;
  cursor: pointer;
  padding: 6px 8px;
}
.nc-password-toggle:hover { color: var(--primary-dark); }

.nc-password-hint {
  margin: 7px 0 0;
  font-size: 0.8rem;
  min-height: 1.1em;
}
.nc-password-hint-ok { color: var(--green-dark); }
.nc-password-hint-bad { color: var(--accent); }

.nc-field-checkbox { margin-bottom: 18px; }
.nc-checkbox-label {
  display: flex;
  align-items: flex-start;
  gap: 9px;
  font-size: 0.85rem;
  color: var(--muted);
  cursor: pointer;
  line-height: 1.45;
}
.nc-checkbox-label input[type="checkbox"] {
  margin-top: 3px;
  flex-shrink: 0;
  width: 16px;
  height: 16px;
  accent-color: var(--primary);
}
.nc-checkbox-label a { color: var(--primary-dark); text-decoration: underline; }
.nc-checkbox-label a:hover { color: var(--primary); }

.nc-legal-stage { background: var(--bg); min-height: 100vh; }
.nc-legal-page {
  max-width: 720px;
  margin: 0 auto;
  padding: 32px 24px 80px;
}
.nc-legal-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 40px;
}
.nc-legal-content h1 {
  color: var(--text);
  margin: 0 0 6px;
}
.nc-legal-updated {
  color: var(--muted);
  font-size: var(--t-body-sm);
  margin: 0 0 36px;
}
.nc-legal-content h2 {
  color: var(--text);
  margin: 32px 0 10px;
}
.nc-legal-content p {
  color: var(--muted);
  font-size: var(--t-body);
  line-height: 1.7;
  margin: 0 0 14px;
}
.nc-legal-content ul {
  color: var(--muted);
  font-size: var(--t-body);
  line-height: 1.7;
  padding-left: 22px;
  margin: 0 0 14px;
}
.nc-legal-content strong { color: var(--text); }

.nc-auth-btn {
  width: 100%;
  padding: 13px;
  border-radius: 10px;
  border: none;
  background: var(--primary);
  color: #fff;
  font-size: var(--t-body);
  font-weight: 600;
  cursor: pointer;
  margin-top: 6px;
  transition: background 0.15s ease;
}
.nc-auth-btn:hover { background: var(--primary-dark); }

.nc-auth-switch {
  text-align: center;
  margin: 22px 0 0;
  font-size: 0.9rem;
  color: var(--muted);
}
.nc-auth-switch a { color: var(--primary); text-decoration: none; font-weight: 600; }
.nc-auth-switch a:hover { text-decoration: underline; }

@media (max-width: 480px) {
  .nc-auth-card { padding: 32px 24px; }
}

/* ---- NihaoCard app screens: dashboard / review / add-card (2026-07-20).
   Same design language as the intro + auth screens above (warm stage,
   coral primary); mobile-first since students review from their phones.
   Typography is the app-wide token set: serif display headings, Inter
   body. ---- */

body.nc-app-stage, body.recall-stage, body.nc-legal-stage {
  font-family: var(--font-sans);
}
/* Also :where(), and this is the one that was doing damage (2026-08-02).
   At its old specificity of (0,1,2) it outranked every single-class
   component rule, so any heading a component had deliberately set in
   Inter came out serif anyway -- and, worse, the -0.01em came with it.
   The small uppercase section eyebrows (.mt-group-label, the Settings
   headings) are 0.7rem tracked out to 0.16em; they were rendering as
   cramped 11px serif, tracking and all silently overruled. Duke read it
   straight off the screen as "the fonts are wrong".
   This is a default. Components outrank it now. */
:where(body.nc-app-stage, body.recall-stage, body.nc-legal-stage)
:where(h1, h2, h3) {
  font-family: var(--font-display);
  letter-spacing: -0.01em;
}

body.nc-app-stage {
  background:
    radial-gradient(ellipse 900px 500px at 50% 0%, var(--coral-glow), transparent 65%),
    repeating-linear-gradient(90deg, var(--grid-line) 0 1px, transparent 1px 56px),
    repeating-linear-gradient(0deg, var(--grid-line) 0 1px, transparent 1px 56px),
    var(--stage-bg);
  background-attachment: fixed;
}

.nc-app { max-width: 640px; margin: 0 auto; padding: 18px 16px 56px; }
.nc-app [hidden] { display: none !important; }
/* The tests app underlines links on hover globally (line ~2073); NihaoCard
   buttons are styled pills, so that underline reads as a bug here. */
.nc-app a:hover, .nc-app-bar a:hover { text-decoration: none; }

.nc-app-bar {
  display: flex;
  justify-content: space-between;
  align-items: center;
  gap: 12px;
  margin-bottom: 18px;
}
/* Dashboard variant: a full-width top bar pinned to the page's top edge.
   Transparent -- the stage shows through (Duke vetoed the solid bar). */
.nc-app-bar-full {
  padding: 22px 28px 0;
  margin-bottom: 0;
  flex-wrap: wrap;
  position: relative;
  z-index: 2;
}

/* Foldable user chip in the top bar: name + avatar, click to reveal
   account actions (just Log out for now). <details> = zero JS. */
.nc-user-menu { position: relative; }
.nc-user-menu summary {
  list-style: none;
  display: flex;
  align-items: center;
  gap: 8px;
  cursor: pointer;
  padding: 5px 12px 5px 6px;
  border-radius: 999px;
  border: 1.5px solid var(--border);
  background: var(--card);
  font-weight: 700;
  font-size: 0.85rem;
  color: var(--text);
}
.nc-user-menu summary::-webkit-details-marker { display: none; }
.nc-user-menu summary:hover { border-color: var(--muted); }
.nc-user-avatar {
  width: 26px;
  height: 26px;
  border-radius: 50%;
  background: var(--primary-light);
  color: var(--primary-dark);
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.8rem;
  font-weight: 800;
}
/* The chip and its menu are navigation, not supporting print -- and the
   student's name appearing here in Inter while the greeting three lines
   below set the same name in serif was the most obvious mismatch on the
   dashboard (Duke, 2026-08-02). The avatar initial stays out of it: it
   is a geometric mark, and serif caps sit badly inside a 26px circle. */
.nc-user-menu summary, .nc-user-pop a { font-family: var(--font-display); }
.nc-user-avatar { font-family: var(--font-sans); }
/* Photo variant: the uploaded profile picture fills the circle. */
.nc-user-avatar-photo { background: none; overflow: hidden; padding: 0; }
.nc-user-avatar-photo img { width: 100%; height: 100%; object-fit: cover; display: block; }
.nc-user-name { max-width: 110px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Logged-out counterpart to the account chip (.nc-user-menu summary): same
   pill so the corner reads as "your account" in both states, but a neutral
   person glyph where the initial goes, and the label in Inter because "Log
   in" is an action, not a name. Reuses .nc-user-avatar for the circle and
   .nc-user-name for the label, so the phone rule below (hide the text under
   480px) shrinks this to an icon-only circle exactly like the account chip. */
.nc-login-chip {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 5px 14px 5px 6px;
  border-radius: 999px;
  border: 1.5px solid var(--border);
  background: var(--card);
  font-family: var(--font-sans);
  font-weight: 700;
  font-size: 0.85rem;
  color: var(--text);
  text-decoration: none;
  transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease;
}
.nc-login-chip:hover { border-color: var(--muted); background: var(--surface-2); }
.nc-login-chip:active { transform: translateY(1px); }
.nc-login-avatar { background: var(--surface-2); color: var(--muted); }
.nc-login-avatar svg { width: 15px; height: 15px; }
.nc-user-chev { color: var(--muted); transition: transform 0.18s ease; }
.nc-user-menu[open] .nc-user-chev { transform: rotate(180deg); }
.nc-user-pop {
  position: absolute;
  right: 0;
  top: calc(100% + 8px);
  min-width: 150px;
  background: var(--card);
  border: 1.5px solid var(--border);
  border-radius: 14px;
  box-shadow: var(--shadow);
  padding: 6px;
  z-index: 60;
}
.nc-user-pop a {
  display: block;
  padding: 10px 14px;
  border-radius: 10px;
  text-decoration: none;
  color: var(--text);
  font-weight: 600;
  font-size: 0.9rem;
}
.nc-user-pop a:hover { background: var(--surface-2); }
/* The page you're currently on: shown like every other row (we stopped hiding
   it), but marked as the active one -- coral text + a persistent tinted
   background so it reads as "you are here". */
.nc-user-pop a[aria-current="page"] {
  color: var(--primary);
  font-weight: 700;
  background: var(--surface-2);
}
/* "Nihao" in ink, "Lingo" in coral -- the delicate two-tone the public
   marketing page wears. Without this explicit colour the wordmark inherits the
   global `a { color: var(--primary) }`, so BOTH halves rendered coral and the
   mark read heavier/flatter than the homepage's (Duke, 2026-08-04). The accent
   span keeps its own var(--primary). --text flips per theme (ink in light,
   cream in dark), matching the homepage in both. */
.nc-app-wordmark { text-decoration: none; font-size: var(--t-title); color: var(--text); }
.nc-app-bar-actions { display: flex; gap: 10px; align-items: center; }
/* Below 480px (any phone): the account chip drops its name text and
   becomes an avatar-only circle, freeing up enough width for the
   language pill to stay in the header instead of getting buried inside
   the dropdown (Duke, 2026-07-24: a pill control folded into a
   list-style menu "looked awful" -- 2026-07-23's fix solved the wrap by
   hiding the pill; this solves it by shrinking the chip instead). */
@media (max-width: 479px) {
  .nc-user-name { display: none; }
  .nc-user-menu summary { padding: 5px 8px 5px 5px; gap: 4px; }
}
.nc-logout {
  color: var(--muted);
  font-size: 0.82rem;
  font-weight: 600;
  text-decoration: none;
  padding: 6px 12px;
  border-radius: 999px;
  border: 1.5px solid var(--border);
}
.nc-logout:hover { color: var(--text); border-color: var(--muted); }
.nc-back {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  height: 38px;
  border-radius: 50%;
  border: 1.5px solid var(--border);
  background: var(--card);
  color: var(--text);
  text-decoration: none;
  font-size: 1.1rem;
  flex-shrink: 0;
}
.nc-back:hover { border-color: var(--muted); }
.nc-bar-spacer { width: 38px; flex-shrink: 0; }

/* Due / caught-up hero: THE thing on this screen -- a big flashcard-sized
   invitation to review, with the day's numbers folded into one quiet line. */
.nc-hero { border-radius: 26px; padding: 34px 30px 30px; margin-bottom: 30px; }
.nc-hero-due {
  background: linear-gradient(135deg, var(--primary), var(--primary-dark));
  color: #fff;
  box-shadow: var(--shadow);
  display: flex;
  flex-direction: column;
  gap: 30px;
  min-height: 240px;
  justify-content: space-between;
}
.nc-hero-due h2 {
  margin: 0;
  line-height: 1.2;
}
.nc-hero-status { margin: 10px 0 0; opacity: 0.88; font-size: 0.95rem; }
.nc-hero-btn {
  display: block;
  background: #fff;
  color: var(--primary-dark);
  font-weight: 800;
  text-align: center;
  padding: 16px;
  border-radius: 16px;
  text-decoration: none;
  font-size: 1.1rem;
}
.nc-hero-btn:active { transform: scale(0.98); }
.nc-hero-done {
  background: var(--card);
  border: 1.5px solid var(--border);
  text-align: center;
  padding: 44px 30px 36px;
}
.nc-hero-check {
  width: 54px;
  height: 54px;
  border-radius: 50%;
  background: var(--green-light);
  color: var(--green-dark);
  display: flex;
  align-items: center;
  justify-content: center;
  margin: 0 auto 12px;
}
.nc-hero-done h2 {
  margin: 0;
}
.nc-hero-done p { color: var(--muted); margin: 8px 0 0; }

/* Motivational Chinese saying inside the caught-up hero -- finishing your
   reviews earns you a proverb, not a dead screen. */
.nc-motivation {
  margin-top: 22px;
  padding-top: 18px;
  border-top: 1.5px dashed var(--border);
  display: flex;
  flex-direction: column;
  gap: 6px;
}
/* Proverb rendered as <ruby> so each pinyin syllable sits directly over
   its own character (a flat pinyin line drifts out of alignment on the
   longer sayings). rt inherits the serif family otherwise -- pinyin
   everywhere else in the app is sans, so reset it. */
.nc-motivation-hanzi { font-family: var(--font-hanzi-serif); font-weight: 600; font-size: 1.8rem; line-height: 1.9; }
.nc-motivation-hanzi rt {
  font-family: var(--font-sans);
  color: var(--primary-dark);
  font-weight: 600;
  font-size: 0.4em;
  letter-spacing: 0.01em;
}
.nc-motivation-tr { color: var(--muted); font-size: 0.92rem; margin-top: 2px; }

/* Deck list */
.nc-deck-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; gap: 12px; }
.nc-deck-head h2 {
  margin: 0;
}
.nc-add-btn {
  background: var(--green);
  color: #fff;
  font-weight: 700;
  font-size: 0.85rem;
  text-decoration: none;
  padding: 8px 14px;
  border-radius: 999px;
  white-space: nowrap;
  box-shadow: 0 6px 16px -8px var(--green);
}
.nc-add-btn:hover { background: var(--green-dark); }
.nc-card-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 10px; }
.nc-card-row {
  display: flex;
  align-items: center;
  gap: 14px;
  background: var(--card);
  border: 1.5px solid var(--border);
  border-radius: 14px;
  padding: 12px 14px;
}
/* Pinyin sits directly over the hanzi it belongs to, via real <ruby>/<rt>
   per character -- same alignment rule as the proverb ruby in .nc-motivation
   (Duke, 2026-07-24, twice now: a flat pinyin string above the whole word
   only looks aligned by coincidence and drifts on anything but two evenly-
   sized syllables). word_pairs()/wordRubyHtml() do the per-character zip. */
.nc-card-word { display: flex; flex-direction: row; align-items: flex-end; flex-shrink: 0; font-family: var(--font-hanzi-serif); }
.nc-card-hanzi { font-size: 1.45rem; line-height: 1.3; ruby-position: over; }
.nc-card-pinyin {
  font-family: var(--font-sans);
  color: var(--primary-dark);
  font-weight: 600;
  font-size: 0.4em;
  letter-spacing: 0.01em;
  text-align: center;
}
.nc-card-def {
  flex: 1;
  min-width: 0;
  color: var(--muted);
  font-size: 0.88rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.nc-card-source {
  font-size: 0.68rem;
  font-weight: 600;
  padding: 3px 9px;
  border-radius: 999px;
  background: var(--surface-2);
  color: var(--muted);
  white-space: nowrap;
}
.nc-empty { color: var(--muted); }

/* ---------- Stats page (flashcards_stats.html) ---------- */
.nc-stats-heading {   margin: 4px 0 20px; }

/* Settings page: a back control that lives WITH the title, not in the nav bar
   (Duke, 2026-08-06 -- an arrow wedged in front of the wordmark read as part
   of the brand). The round button matches the app's other round controls and
   sits to the left of the "Settings" heading; the heading's own margin moves
   up to this row so spacing above the panel is unchanged. */
.nc-settings-head { display: flex; align-items: center; gap: 14px; margin: 4px 0 20px; }
.nc-settings-head .nc-stats-heading { margin: 0; }
.nc-settings-back { flex-shrink: 0; text-decoration: none; }

/* Submitted-test page: a labelled back to My Tests, sitting above the centred
   result card and aligned to the same 792px width. Labelled (not a bare arrow)
   because this page has no left-hand title for a lone arrow to anchor to. */
.thanks-back {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
  max-width: 792px;
  margin: 0 auto 20px;
  text-decoration: none;
  color: var(--muted);
  font-weight: 600;
}
.thanks-back:hover { color: var(--text); }
.thanks-back-arrow {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 38px;
  height: 38px;
  border-radius: 50%;
  border: 1.5px solid var(--border);
  background: var(--card);
  color: var(--text);
  font-size: 1.1rem;
  flex-shrink: 0;
}
.thanks-back:hover .thanks-back-arrow { border-color: var(--muted); }

/* ---- One module component ------------------------------------------------
   Every module on this page is the same object: a title band -- emblem, name,
   optional headline numbers, chevron -- then a hairline, then its content.
   All of them fold, and all of them open by default except the breakdown at
   the foot (Duke, 2026-08-02: "make it foldable exactly like the full
   breakdown modal ... default they are always extended except full
   breakdown"). The band is what says "this is the title of this module"
   rather than the first line inside it. */
.nc-mod {
  background: var(--card); border: 1px solid var(--border); border-radius: 18px;
  margin-bottom: 14px; overflow: hidden;
}
.nc-mod > summary {
  display: flex; align-items: center; gap: 11px; flex-wrap: wrap;
  padding: 13px 20px; background: var(--surface-2);
  cursor: pointer; list-style: none; user-select: none;
  transition: filter .18s;
}
.nc-mod > summary::-webkit-details-marker { display: none; }
.nc-mod > summary:hover { filter: brightness(0.985); }
/* margin-right:auto, not margin-left:auto on the chevron -- the chevron has
   to sit at the far edge whether or not the band also carries numbers. */
.nc-mod > summary h3 {   margin: 0 auto 0 0; }
/* Only while open: closed, there is nothing underneath for it to divide. */
.nc-mod[open] > summary { border-bottom: 1px solid var(--border); }
.nc-mod > summary .chev {
  width: 16px; height: 16px; color: var(--muted); flex: none; margin-left: 10px;
  transition: transform .28s cubic-bezier(.3, .9, .3, 1);
}
.nc-mod[open] > summary .chev { transform: rotate(180deg); }

.nc-mod-body { padding: 18px 20px; }
.nc-mod-body + .nc-mod-body { border-top: 1px solid var(--border); }
.nc-mod-body > h4 {   margin: 0 0 14px; }
.nc-mod-body > h4 + .sub { font-size: .78rem; color: var(--muted); margin: -12px 0 14px; }

/* The one module that asks for something back is the only one that is
   coloured, so its band is the gradient showing through rather than the
   grey wash the others use. */
.nc-mod.action {
  background: linear-gradient(135deg, var(--primary-light) 0%, var(--gold-light) 130%);
  border-color: transparent;
}
.nc-mod.action > summary { background: transparent; }
.nc-mod.action .nc-emblem { background: var(--card); color: var(--accent); }

/* A plain sub-header inside a module: the band above it is already tinted,
   so this one is only a name and a rule. */
.nc-sub-head {
  display: flex; align-items: center; gap: 11px; flex-wrap: wrap;
  padding: 14px 20px; border-bottom: 1px solid var(--border);
}
.nc-sub-head h4 {   margin: 0 auto 0 0; }

/* The headline numbers a module carries in its band. A rung below the title
   beside them, so the row reads title-then-numbers rather than three things
   of equal weight. */
.nc-group-stats { display: flex; gap: 24px; }
.nc-group-stat { text-align: center; }
.nc-group-stat .n {
    font-size: 1.15rem;
  line-height: 1; color: var(--text); font-variant-numeric: tabular-nums;
}
.nc-group-stat .n .unit { font-size: .85rem; color: var(--muted); }
.nc-group-stat .lbl { font-size: .7rem; font-weight: 700; color: var(--muted); margin-top: 5px; }
/* On a phone the numbers take their own line under the title -- but the
   chevron stays up on the title row, where the tap target is. Ordering it
   after the stats let it wrap onto a third line of its own. */
@media (max-width: 560px) {
  .nc-group-stats { width: 100%; justify-content: space-between; gap: 12px;
    order: 1; margin-top: 4px; }
}

/* Height animates where ::details-content is supported and snaps where it
   isn't; either way <details> keeps working with JS off. */
.nc-explain, .nc-mod { interpolate-size: allow-keywords; }
.nc-explain::details-content, .nc-mod::details-content {
  block-size: 0; overflow: hidden;
  transition: block-size .28s cubic-bezier(.3, .9, .3, 1), content-visibility .28s allow-discrete;
}
.nc-explain[open]::details-content, .nc-mod[open]::details-content { block-size: auto; }

/* Small and quiet: it explains the two words above it, and only for whoever
   doesn't already know them. */
.nc-explain { margin-top: 16px; }
.nc-explain > summary {
  display: inline-block; font-size: .78rem; font-weight: 700;
  color: var(--primary-dark); border-bottom: 1px dashed var(--border);
  cursor: pointer; list-style: none; user-select: none;
}
.nc-explain > summary::-webkit-details-marker { display: none; }
.nc-explain > summary:hover { border-bottom-color: var(--primary); }
.nc-explain p { font-size: .8rem; color: var(--muted); line-height: 1.55; margin: 12px 0 0; }

/* Every proportion bar on the page grows from its left edge on load, the
   same gesture (and curve) as the deck-door journey bar on /study. */
@keyframes ncBarGrow { from { transform: scaleX(0); } }
.nc-mode-fill, .nc-focus-fill, .nc-topic-fill {
  transform-origin: left center;
  animation: ncBarGrow .75s cubic-bezier(.25, .7, .3, 1) .12s backwards;
}
@media (prefers-reduced-motion: reduce) {
  .nc-mode-fill, .nc-focus-fill, .nc-topic-fill { animation: none; }
  .nc-explain::details-content, .nc-mod::details-content,
  .nc-mod > summary .chev { transition: none; }
}

.nc-topic-row { display: flex; align-items: center; gap: 12px; margin-bottom: 11px; }
.nc-topic-row:last-child { margin-bottom: 0; }
.nc-topic-name { font-size: 0.83rem; font-weight: 700; width: 40%; flex-shrink: 0; }
.nc-topic-track { flex: 1; height: 7px; border-radius: 99px; background: var(--surface-2); overflow: hidden; }
.nc-topic-fill { height: 100%; border-radius: 99px; }
.nc-topic-pct { font-size: 0.78rem; font-weight: 800; color: var(--muted); width: 36px; text-align: right; flex-shrink: 0; font-variant-numeric: tabular-nums; }

.nc-heatmap {
  display: grid; grid-template-columns: repeat(14, 1fr); grid-auto-flow: column;
  grid-template-rows: repeat(7, 1fr); gap: 5px; max-width: 470px;
}
/* Shading is shared with the legend below. It used to be scoped to
   .nc-heatmap alone, and the legend is a SIBLING of the grid, not a child --
   so its four swatches inherited no background at all and the scale read
   "Less        More" with nothing in between (2026-08-02 audit). */
.nc-heatmap .cell, .nc-heatmap-legend .cell { background: var(--surface-2); }
.nc-heatmap .cell { width: 100%; aspect-ratio: 1; border-radius: 3px; }
.nc-heatmap .cell.l1, .nc-heatmap-legend .cell.l1 { background: var(--primary-light); }
.nc-heatmap .cell.l2, .nc-heatmap-legend .cell.l2 { background: var(--primary); opacity: 0.55; }
.nc-heatmap .cell.l3, .nc-heatmap-legend .cell.l3 { background: var(--primary); }
/* The rest of the current week. Faint rather than absent, so the last column
   stays a full week and the grid keeps its rectangle. */
.nc-heatmap .cell.future { background: transparent; box-shadow: inset 0 0 0 1px var(--border); }
.nc-heatmap-legend { display: flex; align-items: center; gap: 6px; margin-top: 12px; font-size: 0.72rem; color: var(--muted); font-weight: 700; }
/* Says what span the grid covers, where a heading would have said only that
   the grid is a grid. Pushes the Less..More scale to the far edge. */
.nc-heatmap-legend .cap { margin-right: auto; font-weight: 600; }
@media (max-width: 480px) {
  .nc-heatmap-legend { flex-wrap: wrap; }
  .nc-heatmap-legend .cap { width: 100%; margin: 0 0 6px; }
}
.nc-heatmap-legend .cell { width: 11px; height: 11px; border-radius: 3px; display: inline-block; }

/* ---------- Ecosystem home (home.html) ---------- */
.nc-home-tiles { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }
@media (max-width: 820px) { .nc-home-tiles { grid-template-columns: 1fr; } }

.nc-home-tile {
  position: relative; background: var(--card); border: 1.5px solid var(--border);
  border-radius: 18px; padding: 0 20px 20px; overflow: hidden;
  display: flex; flex-direction: column;
}

/* Each tile opens with a small scene built from the thing it represents --
   a fanned hand of cards, a stack of graded papers, a calendar page --
   rather than a flat icon or a watermark glyph (Duke, 2026-07-24: tiles
   were reading as "two big modals"). Purely decorative, so aria-hidden
   isn't needed on individual shapes; nothing here carries meaning that
   the text below doesn't already state. */
/* Tinted stage strip: the card/paper/calendar shapes are var(--card), and
   so is the tile itself -- on light they separated by drop shadow alone,
   which doesn't read at all on dark (shapes vanished into the tile, only
   their borders showed). Putting the scene on --surface-2 gives them a
   ground to sit against in both themes instead of relying on shadow. */
.nc-tile-visual {
  height: 100px; position: relative; margin: 0 -20px 8px; overflow: hidden;
  background: var(--surface-2); border-bottom: 1px solid var(--border);
}
/* Every shape inside is positioned in px against this fixed-width, centered
   stage rather than against the tile itself -- the tile's width changes with
   the grid (3-up on desktop, full-width on phone) and %-based offsets pulled
   the composition apart at wide sizes. The stage keeps the arrangement
   identical everywhere; only its own centering moves. */
.nc-tile-scene {
  position: absolute; left: 50%; top: 0; width: 200px; height: 100px;
  transform: translateX(-50%);
}

.nc-mini-card {
  position: absolute; width: 58px; height: 76px; border-radius: 9px;
  box-shadow: 0 5px 12px rgba(60, 40, 20, 0.16);
  background: var(--card); border: 1.5px solid var(--border);
}
.nc-mini-card.mc-back { left: 96px; top: 16px; background: var(--primary-light); border-color: transparent; transform: rotate(12deg); }
.nc-mini-card.mc-mid { left: 74px; top: 13px; transform: rotate(4deg); }
.nc-mini-card.mc-front {
  left: 46px; top: 10px; transform: rotate(-8deg);
  display: flex; align-items: center; justify-content: center; z-index: 2;
}
.nc-mini-card .mc-hanzi {
  font-family: var(--font-hanzi-serif); font-size: 1.35rem; font-weight: 600;
  color: var(--primary-dark); line-height: 1.1; text-align: center;
}

.nc-paper-card {
  position: absolute; width: 72px; height: 54px; border-radius: 6px;
  background: var(--card); border: 1.5px solid var(--border); box-shadow: 0 4px 10px rgba(60, 40, 20, 0.1);
}
.nc-paper-card.pc-back { left: 104px; top: 30px; transform: rotate(9deg); }
.nc-paper-card.pc-mid { left: 86px; top: 20px; transform: rotate(-5deg); }
.nc-stamp-badge {
  position: absolute; left: 34px; top: 16px; width: 66px; height: 66px; border-radius: 50%;
  background: var(--gold-light); border: 2.5px solid var(--gold-dark);
  display: flex; align-items: center; justify-content: center;
  font-family: var(--font-display); font-weight: 700; font-size: 1.5rem;
  color: var(--gold-dark); transform: rotate(-7deg); z-index: 2;
}
.nc-stamp-badge::after {
  content: ""; position: absolute; inset: 6px; border-radius: 50%;
  border: 1.5px dashed var(--gold-dark); opacity: 0.45;
}

.nc-cal-page {
  position: absolute; left: 58px; top: 12px; width: 84px;
  background: var(--card); border: 1.5px solid var(--border); border-radius: 10px;
  overflow: hidden; box-shadow: 0 5px 12px rgba(60, 40, 20, 0.14); transform: rotate(-2deg);
}
/* Decorative header band only -- no label text. The tile already says
   "Lesson" right below it; printing it twice read as a mistake. */
.nc-cal-page .cal-top { display: block; background: var(--primary); height: 14px; }
.nc-cal-page .cal-glyph {
  display: block; text-align: center; font-family: var(--font-hanzi-serif);
  font-size: 2rem; font-weight: 600; color: var(--text); padding: 6px 0 10px; line-height: 1;
}

.nc-tile-body { flex: 1; display: flex; flex-direction: column; }
/* wrap: the state pill was being clipped off the right edge of the narrower
   3-up tiles when it sat on one line with the label. */
.nc-tile-head {
  display: flex; align-items: center; justify-content: space-between;
  margin-bottom: 12px; gap: 6px 8px; flex-wrap: wrap;
}
.nc-tile-label { font-size: 0.74rem; font-weight: 800; letter-spacing: 0.03em; color: var(--muted); text-transform: uppercase; }
.nc-state-pill {
  display: inline-flex; align-items: center; gap: 5px; font-size: 0.66rem; font-weight: 800;
  padding: 3px 9px 3px 7px; border-radius: 999px; white-space: nowrap;
}
.nc-state-pill::before { content: ""; width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.nc-state-pill.attention { background: var(--gold-light); color: var(--gold-dark); }
.nc-state-pill.attention::before { background: var(--gold); }
.nc-state-pill.clear { background: var(--green-light); color: var(--green-dark); }
.nc-state-pill.clear::before { background: var(--green); }

.nc-tile-figure { display: flex; align-items: baseline; gap: 9px; margin-bottom: 3px; }
.nc-tile-number {
    font-size: 2.3rem;
  line-height: 1; font-variant-numeric: tabular-nums;
}
.nc-home-tile.flash .nc-tile-number { color: var(--primary-dark); }
.nc-home-tile.tests .nc-tile-number { color: var(--gold-dark); }
.nc-tile-figure-label { font-size: 0.86rem; color: var(--muted); font-weight: 600; }
.nc-tile-book-lead { font-family: var(--font-display); font-weight: 700; font-size: 1.25rem; margin: 0; line-height: 1.3; }
.nc-tile-meta { font-size: 0.78rem; color: var(--muted); margin: 4px 0 16px; min-height: 1.1em; }

.nc-tile-cta {
  display: inline-flex; align-items: center; gap: 6px; text-decoration: none;
  font-weight: 700; font-size: 0.86rem; padding: 9px 18px; border-radius: 999px; color: #fff;
  margin-top: auto; align-self: flex-start; transition: transform 0.15s ease, background 0.15s ease;
}
.nc-tile-cta:hover { transform: translateY(-1px); }
.nc-home-tile.flash .nc-tile-cta { background: var(--primary); }
.nc-home-tile.flash .nc-tile-cta:hover { background: var(--primary-dark); }
.nc-home-tile.tests .nc-tile-cta { background: var(--gold-dark); }
.nc-tile-cta.ghost { background: none; border: 1.5px solid var(--border); color: var(--text); }
.nc-tile-cta.ghost:hover { background: var(--surface-2); }

@media (prefers-reduced-motion: reduce) {
  .nc-tile-cta { transition: none; }
  .nc-tile-cta:hover { transform: none; }
}

/* ---------- Breadcrumb: NihaoLingo / <section> ----------
   The wordmark is the ecosystem and always links to /study. Each sub-page
   names itself beside it, so flashcards reads as one room in the cabinet
   rather than the cabinet itself (Duke, 2026-07-25). Collapses below 480px
   where the header is already tight -- the page's own <h2> says where you
   are anyway. */
/* The bar is justify-content:space-between, so the wordmark, separator and
   section label have to be ONE flex child or they get flung apart across
   the full header width. */
.nc-brand-group { display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.nc-section-sep { color: var(--border); font-weight: 400; }
.nc-section-label {
  color: var(--muted); white-space: nowrap;
}
@media (max-width: 480px) {
  .nc-section-sep, .nc-section-label { display: none; }
}

/* ---------- Illustrated empty state (My tests, and reusable) ---------- */
.nc-empty-state {
  display: flex; flex-direction: column; align-items: center; text-align: center;
  padding: 34px 22px 38px;
  background: var(--card); border: 1.5px solid var(--border); border-radius: 18px;
}
/* Three blank sheets, the topmost dashed -- "a test would sit here". Built
   from the same paper motif as the Tests tile on /study so the two screens
   read as the same product. */
.nc-empty-scene { position: relative; width: 108px; height: 78px; margin-bottom: 18px; }
.nc-empty-paper {
  position: absolute; width: 76px; height: 58px; border-radius: 7px;
  background: var(--surface-2); border: 1.5px solid var(--border);
}
.nc-empty-paper.p3 { left: 30px; top: 14px; transform: rotate(9deg); }
.nc-empty-paper.p2 { left: 16px; top: 7px;  transform: rotate(-5deg); }
.nc-empty-paper.p1 {
  left: 2px; top: 0; background: var(--card);
  border-style: dashed; border-color: var(--muted); opacity: 0.85;
}
.nc-empty-state h3 {   margin: 0 0 6px; }
.nc-empty-state p {
  font-size: 0.88rem; color: var(--muted); margin: 0 0 20px;
  max-width: 42ch; line-height: 1.5;
}
.nc-empty-cta {
  display: inline-flex; align-items: center; gap: 6px; text-decoration: none;
  font-weight: 700; font-size: 0.88rem; padding: 10px 20px; border-radius: 999px;
  background: var(--primary); color: #fff;
  transition: background 0.15s ease, transform 0.15s ease;
}
.nc-empty-cta:hover { background: var(--primary-dark); transform: translateY(-1px); }
@media (prefers-reduced-motion: reduce) {
  .nc-empty-cta { transition: none; }
  .nc-empty-cta:hover { transform: none; }
}

/* ---------- My tests (rebuilt 2026-07-31) ----------
   Was a flat list of grey rows: name, a muted status word, a bare "50%", a
   button -- every element at the same weight, so nothing on the screen told
   a student what to do next (Duke: "there's no design at all here").

   The page now answers three questions in order: is anything waiting on me
   (the summary line), which one (the card that carries the only primary
   action), and how did I do (the score, as an object rather than a cell).
   Built from the same vocabulary as the Level Check result -- gold eyebrow,
   display serif figures, one pill action -- so the student app and the public
   funnel read as one product. */
.mt-app { max-width: 680px; }

/* A single wash of warm light behind the column, so the cards sit on a page
   rather than on a flat panel, over the same practice-grid texture every
   other stage uses -- the grid is the product's theme, not one page's
   choice (see the note above on body.test-stage). Fixed, so neither layer
   slides under a long list. */
body.mt-stage {
  background:
    radial-gradient(ellipse 900px 520px at 50% 0%, var(--coral-glow), transparent 70%),
    repeating-linear-gradient(90deg, var(--grid-line) 0 1px, transparent 1px 56px),
    repeating-linear-gradient(0deg, var(--grid-line) 0 1px, transparent 1px 56px),
    var(--stage-bg);
  background-attachment: fixed;
}

/* ---- No page header (2026-08-03) ----
   There was one until today: a serif "My tests" over a row of count
   chips, with a paper stack to the right of it. Duke: the title is extra
   on a page the nav bar has already named, and once it goes a header
   block holding two chips is a band of empty warm light 200px deep that
   the student has to scroll past to reach the only thing they came for.

   So the list starts at the top of the page and the group headings carry
   what the header knew -- the counts, whose move it is, the paper. The
   first card is now above the fold on a laptop, which it was not.

   Nothing was thrown away: the drift moved out behind the whole window,
   the stack moved down beside the first heading, and the counts kept
   their count-up. */
.mt-app { padding-top: 8px; }

/* ---- The drift ----
   Chinese homework is done on 田字格 -- the practice square with the
   dashed cross that you write a character into -- so that is what is
   behind this page: a slow drift of them, each holding a character out of
   this student's own tests (see _drift_cells in the route).

   Fixed to the window rather than sat inside a header (2026-08-03). Two
   things follow from that. It keeps drifting under a list that scrolls,
   instead of sliding away with a block at the top; and most of what you
   see of it is in the margins either side of the 680px column, because
   the cards are opaque paper laid over it. That is the intended reading:
   a desk with the homework on it.

   Faint on purpose -- the moment a square competes with a card it has
   failed. */
.mt-desk {
  position: fixed; inset: 0; z-index: 0;
  pointer-events: none;
  overflow: hidden;
  /* Feathered so a square is never sliced by a hard edge on its way past
     the window's edge, and cleared off the top 90px so the nav bar -- which
     has no background of its own -- keeps a quiet strip to sit on. Cheap to
     mask: nothing in the subtree is filtered.

     Two layers intersected. A browser that honours mask-image but not
     mask-composite unions them instead, which is the old full-bleed
     behaviour: a fallback, not a break. */
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent),
                      linear-gradient(180deg, transparent 8px, #000 92px);
          mask-image: linear-gradient(90deg, transparent, #000 5%, #000 95%, transparent),
                      linear-gradient(180deg, transparent 8px, #000 92px);
  -webkit-mask-composite: source-in;
          mask-composite: intersect;
  animation: mtDeskIn 1.6s ease-out backwards;
}
@keyframes mtDeskIn { from { opacity: 0; } }

/* One practice square. The cell is the frame; the character sits in it. */
.d-cell {
  position: absolute;
  /* Both percentages of the window, picked in the route: --x a lane across
     it, --y a row down it. --y was a pixel offset either side of a header's
     centre line until the header went (2026-08-03). */
  top: var(--y, 50%); left: var(--x, 0%);
  width: var(--s, 56px); height: var(--s, 56px);
  margin-top: calc(var(--s, 56px) / -2);
  display: grid; place-items: center;
  border: 1px solid var(--mt-cell);
  border-radius: 5px;
  opacity: 0;
  animation: mtDrift var(--dur, 34s) linear infinite;
  animation-delay: var(--delay, 0s);
}
/* The dashed cross. Two children would mean two more elements per square,
   so the frame's own pseudo-elements draw it. */
.d-cell::before, .d-cell::after {
  content: ""; position: absolute; opacity: 0.8;
}
.d-cell::before {
  left: 0; right: 0; top: 50%;
  border-top: 1px dashed var(--mt-cell);
}
.d-cell::after {
  top: 0; bottom: 0; left: 50%;
  border-left: 1px dashed var(--mt-cell);
}
.d-cell b {
  font-family: var(--font-hanzi-serif);
  font-size: calc(var(--s, 56px) * 0.62);
  font-weight: 500; line-height: 1;
  color: var(--mt-ink-faint);
}

/* Across and gently up, turning as it goes -- a sheet of squared paper
   drifting past rather than a marquee. Each square gets its own lane,
   size, speed and phase below, and the negative delays mean the header is
   already mid-drift on arrival instead of starting empty. */
@keyframes mtDrift {
  0%   { opacity: 0; transform: translate(0, 14px) rotate(-8deg); }
  12%  { opacity: 1; }
  82%  { opacity: 1; }
  100% { opacity: 0; transform: translate(190px, -26px) rotate(9deg); }
}
/* Placement is inline, from _drift_cells() in the route -- see the note in
   the template. Nothing to hand-tune here. */

/* 作业 -- "homework" -- is the page's word, and it belongs ON the paper
   rather than ghosted into the background behind it. Two earlier passes
   put it in the wash, once behind the title and once behind the stack;
   both times a half-faded glyph at 11% opacity read as a smudge left in
   by accident. As the top sheet's heading it is legible, it says what the
   sheet is, and it turns an abstract white rectangle into a worksheet. */
.mt-sheet-zi {
  font-family: var(--font-hanzi-serif);
  font-size: 0.8rem; font-weight: 700; line-height: 1;
  color: var(--mt-ink); text-align: center;
  padding-bottom: 6px; margin-bottom: 1px;
  border-bottom: 1px solid var(--border);
}

/* ---- State, spoken by the ink rather than by a wash ----
   The old lobes carried this: gold-led always, with a small second lobe
   saying whose move it was. The squares carry it now -- their rule and
   the character inside them warm up when the student owes work, cool to
   green when the desk is clear. Kept very low in contrast: it is a hint
   under a title, and the chips beside it say the same thing in words.

   --mt-ink is the 作业 written on the paper stack, which is a real label
   and so sits at full strength; --mt-ink-faint and --mt-cell are the
   drifting squares behind everything.

   Set on .mt-app rather than on the header, which no longer exists
   (2026-08-03) -- both the drift and the stack are inside it. */
.mt-app[data-state="todo"] {
  --mt-ink: rgba(186, 108, 56, 0.85);
  --mt-cell: rgba(214, 138, 74, 0.3);
  --mt-ink-faint: rgba(206, 122, 64, 0.26);
}
.mt-app[data-state="grading"] {
  --mt-ink: rgba(170, 124, 44, 0.85);
  --mt-cell: rgba(198, 156, 60, 0.3);
  --mt-ink-faint: rgba(190, 146, 52, 0.26);
}
.mt-app[data-state="clear"] {
  --mt-ink: rgba(72, 140, 104, 0.85);
  --mt-cell: rgba(108, 178, 138, 0.3);
  --mt-ink-faint: rgba(88, 160, 120, 0.26);
}
/* Nothing ever assigned. Neutral: see the note in the template on why this
   is not the same state as a desk that has been cleared. */
.mt-app[data-state="none"] {
  --mt-ink: rgba(170, 124, 44, 0.85);
  --mt-cell: rgba(196, 156, 78, 0.26);
  --mt-ink-faint: rgba(188, 148, 72, 0.22);
}
/* Past due is the one state allowed to run warm on its own account. */
.mt-app[data-late] {
  --mt-cell: rgba(224, 116, 82, 0.34);
  --mt-ink-faint: rgba(216, 106, 74, 0.3);
}
/* On the dark canvas the squares have to be lighter than the page, not
   darker, or they simply vanish. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .mt-app {
    --mt-cell: rgba(255, 198, 148, 0.16);
    --mt-ink-faint: rgba(255, 206, 158, 0.15);
  }
}
:root[data-theme="dark"] .mt-app {
  --mt-cell: rgba(255, 198, 148, 0.16);
  --mt-ink-faint: rgba(255, 206, 158, 0.15);
}

/* ---- The group heading ----
   Two things happened to this on 2026-08-03. It stopped being conditional
   -- it used to appear only when a second group existed, which meant the
   commonest case, a student with nothing but homework to do, saw an
   unlabelled list -- and it took over the counts from the header block
   that was removed above it.

   The count comes here rather than into a chip row of its own because
   this is where it is actually load-bearing: it is the size of the group
   it labels. Putting the figure beside the word also sidesteps rather
   than solves Russian's three plural forms -- "Сейчас 3", never "3
   задания".

   The first heading also carries the paper stack, at its right. */
.mt-group-head {
  position: relative; z-index: 1;
  display: flex; align-items: center; justify-content: space-between;
  gap: 16px;
  margin: 0 0 12px;
}
/* Only the heading that holds the stack is as tall as one. A heading with
   nothing but its label stays a thin eyebrow. */
.mt-group-head:has(.mt-stack) { min-height: 78px; }
.mt-group-label {
  margin: 0;
  display: flex; align-items: center; gap: 9px;
  color: var(--pr-eyebrow);
}
.mt-group-n {
  display: inline-grid; place-items: center;
  min-width: 25px; height: 25px; padding: 0 7px;
  border-radius: 999px;
  font-family: var(--font-display); font-weight: 700;
  font-size: 0.95rem; line-height: 1;
  /* The eyebrow's tracking is for spaced capitals and would push a single
     digit off its own centre. */
  letter-spacing: 0; text-transform: none;
  font-variant-numeric: tabular-nums;
  animation: mtChipIn 0.55s cubic-bezier(0.2, 0.9, 0.3, 1) 0.06s backwards;
}
@keyframes mtChipIn { from { opacity: 0; transform: translateY(8px); } }
/* Coloured by what the group is: whose move it is for open work, green
   once the work has come back. */
.mt-group-n.s-todo    { background: var(--primary-light); color: var(--primary-dark); }
.mt-group-n.s-grading { background: var(--gold-light);    color: var(--gold-dark); }
.mt-group-n.s-done    { background: var(--green-light);   color: var(--green-dark); }
.mt-group-n.s-level   { background: var(--gold-light);    color: var(--gold-dark); }

/* Sitting the Level Check again is the only thing a whole group can be
   asked to do, so it hangs on the heading. Text, not a pill: the pills on
   this page mean "this is waiting for you", and a retake never is. */
.mt-retake {
  flex: none;
  font-family: var(--font-display);
  font-size: 0.82rem; font-weight: 700; text-decoration: none;
  color: var(--gold-dark); white-space: nowrap;
  letter-spacing: 0; text-transform: none;
  transition: color 0.15s ease;
}
.mt-retake::after { content: " →"; }
.mt-retake:hover { color: var(--text); }

/* ---- The stack ----
   One sheet per piece of open work, up to four -- past that it stops
   counting and simply looks deep, since five overlapping sheets is a
   smudge and the figure beside it spells the number out anyway.

   Sized down a fifth on 2026-08-03: it used to sit beside a 1.9rem title
   in a header of its own, and it now sits at the end of a heading row, so
   it has to be an object in that row rather than the reason the row is
   tall.

   The fan lives on the independent `rotate`/`translate` properties rather
   than in `transform`, which leaves `transform` free for the idle sway.
   Without that split the two fought and the sway won, freezing the fan. */
.mt-stack {
  position: relative; z-index: 1; flex: none;
  width: 96px; height: 78px;
}
.mt-sheet {
  --k: calc(var(--i) - (var(--n) - 1) / 2);
  position: absolute; left: 50%; top: 50%;
  width: 57px; height: 70px; margin: -35px 0 0 -28px;
  border-radius: 8px;
  background: var(--card);
  border: 1px solid var(--border);
  box-shadow: 0 10px 22px -14px rgba(94, 58, 20, 0.6);
  display: flex; flex-direction: column; justify-content: center; gap: 6px;
  padding: 0 10px;
  translate: calc(var(--k) * 7px * var(--fan, 1)) calc(var(--i) * -2.5px);
  rotate: calc(var(--k) * 8deg * var(--fan, 1));
  transition: translate 0.42s cubic-bezier(0.2, 0.9, 0.3, 1),
              rotate 0.42s cubic-bezier(0.2, 0.9, 0.3, 1),
              scale 0.42s cubic-bezier(0.2, 0.9, 0.3, 1),
              box-shadow 0.42s ease;
  /* Dealt onto the desk one at a time. The animation drives translate and
     rotate, so its implicit end state is whatever the fan above resolves
     to -- no duplicated numbers to keep in sync. */
  animation: mtDeal 0.6s cubic-bezier(0.2, 0.9, 0.3, 1) backwards,
             mtSway 7s ease-in-out infinite alternate;
  animation-delay: calc(var(--i) * 0.1s + 0.12s), calc(var(--i) * -1.7s);
}
@keyframes mtDeal {
  from { opacity: 0; translate: 78px -24px; rotate: 26deg; }
}
@keyframes mtSway {
  from { transform: translateY(1.5px) rotate(-1.1deg); }
  to   { transform: translateY(-3px) rotate(1.1deg); }
}
/* Ruled lines, on the top sheet only -- a worksheet rather than a blank
   rectangle, the same detail that rescued the empty state's paper. */
.mt-sheet > i {
  display: block; height: 4px; border-radius: 3px;
  background: var(--surface-2);
}
/* nth-of-type, not nth-child: the 作业 heading is the sheet's first child
   and would otherwise take slot 1 and knock every rule off the paper. */
.mt-sheet > i:nth-of-type(1) { width: 100%; }
.mt-sheet > i:nth-of-type(2) { width: 68%; }
.mt-sheet > i:nth-of-type(3) { width: 86%; }
/* Reach for the pile and it opens toward you, the top sheet lifting clear
   of the rest -- the same "drawn from the deck" language as the card pile
   on /flashcards. The whole heading row is the target, not the 96px of
   paper: the row is what the eye reads as one thing. */
.mt-group-head:hover .mt-sheet { --fan: 1.85; }
.mt-group-head:hover .mt-sheet:last-child {
  --fan: 2.15;
  scale: 1.06;
  box-shadow: 0 18px 30px -16px rgba(94, 58, 20, 0.7);
}

/* ---- Nothing outstanding ----
   The sheet is stamped and put away. A seal is the right object for it:
   it is what marks a thing finished on paper, it is Chinese, and it gives
   the cleared state a reward instead of an absence. */
.mt-stack.is-clear .mt-sheet { --fan: 0; rotate: -3deg; }
.mt-group-head:hover .mt-stack.is-clear .mt-sheet { rotate: -3deg; scale: 1.05; }
.mt-seal {
  position: absolute; right: -11px; bottom: -9px;
  width: 40px; height: 40px; border-radius: 50%;
  display: grid; place-items: center;
  color: #d9503f;
  border: 2.5px solid currentColor;
  box-shadow: 0 0 0 2px var(--card), inset 0 0 0 2px rgba(217, 80, 63, 0.25);
  background: rgba(255, 244, 240, 0.9);
  opacity: 0.92;
  rotate: -13deg;
  animation: mtStamp 0.62s cubic-bezier(0.3, 1.5, 0.5, 1) 0.42s backwards;
}
.mt-seal em {
  font-style: normal; font-family: var(--font-hanzi-serif);
  font-size: 1.15rem; font-weight: 700; line-height: 1;
}
@keyframes mtStamp {
  0%   { opacity: 0; scale: 2.5; rotate: 8deg; }
  55%  { opacity: 0.92; scale: 0.9; rotate: -16deg; }
  100% { opacity: 0.92; scale: 1; rotate: -13deg; }
}
/* The press: one ring thrown off as the seal lands. */
.mt-seal::after {
  content: ""; position: absolute; inset: -4px;
  border-radius: 50%; border: 2px solid currentColor;
  animation: mtStampRing 0.75s ease-out 0.62s backwards;
  opacity: 0;
}
@keyframes mtStampRing {
  0%   { opacity: 0.65; scale: 1; }
  100% { opacity: 0; scale: 2.1; }
}

/* Phones: the stack keeps its place at the end of the heading row but
   shrinks again. Dropping it there was tempting and wrong -- on the
   screen most of these students actually use, it is the only thing on the
   page that is made rather than listed. */
@media (max-width: 560px) {
  /* Twenty-six squares behind a 375px column is a wall, not a drift. Two
     in every three go, and the survivors travel less far. Sizes still
     come from the route, so the remainder keep their variety. */
  .d-cell:not(:nth-child(3n+1)) { display: none; }
  @keyframes mtDrift {
    0%   { opacity: 0; transform: translate(0, 10px) rotate(-7deg); }
    12%  { opacity: 1; }
    82%  { opacity: 1; }
    100% { opacity: 0; transform: translate(96px, -18px) rotate(8deg); }
  }
  /* The stack sits at the top right of the page and the account chip is
     directly above it, so on a phone -- where the two are the same few
     millimetres apart -- the list needs a little more air above it than
     8px, or the paper reads as hanging off the avatar. */
  .mt-app { padding-top: 18px; }
  .mt-group-head:has(.mt-stack) { min-height: 68px; }
  .mt-stack { width: 82px; height: 68px; }
  .mt-sheet { width: 50px; height: 61px; margin: -30px 0 0 -25px; padding: 0 9px; gap: 5px; }
  .mt-sheet-zi { font-size: 0.72rem; padding-bottom: 5px; }
  .mt-seal { width: 34px; height: 34px; right: -9px; bottom: -8px; }
  .mt-seal em { font-size: 1rem; }
}

.mt-group + .mt-group { margin-top: 30px; }
.mt-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 12px; }

/* ---- Swipe-to-delete frame (2026-07-31, mt-swipe.js) ----
   .mt-row is the clipping mask + the thing that slides away on delete;
   .mt-card is the layer mt-swipe.js drags to reveal .mt-swipe-delete
   sitting underneath it. Touch only -- no mouse equivalent invented. */
.mt-row {
  position: relative;
  border-radius: 18px;
  overflow: hidden;
  /* The recessed tray the delete button floats on -- without this the
     84px reveal strip was just raw page background (the practice grid)
     showing through, which is what read as stiff: a flat rectangle
     bolted flush to a rounded card with no depth of its own. */
  background: var(--surface-2);
}
.mt-row-gone {
  transition: transform 0.25s ease, opacity 0.25s ease;
  transform: translateX(-100%);
  opacity: 0;
}
/* Inset from the tray on all sides so it reads as a floating pill rather
   than a panel bolted to the card -- same gradient-plus-lift language as
   .mt-go, just sized down and recolored for "danger" instead of "go". */
.mt-swipe-delete {
  position: absolute;
  inset: 8px 8px 8px auto;
  width: 66px;
  border-radius: 16px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 5px;
  border: 0;
  background: linear-gradient(180deg, #ff9273 0%, var(--accent) 55%, #e2493c 100%);
  color: #fff;
  font-family: var(--font-sans);
  font-size: 0.66rem;
  font-weight: 700;
  cursor: pointer;
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.3) inset,
              0 10px 18px -8px rgba(220, 70, 55, 0.65);
  transition: transform 0.15s cubic-bezier(0.2, 0.8, 0.3, 1), box-shadow 0.15s ease;
}
.mt-swipe-delete:active {
  transform: scale(0.93);
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.3) inset,
              0 4px 10px -6px rgba(220, 70, 55, 0.6);
}
.mt-swipe-delete svg { width: 19px; height: 19px; }

/* ---- The card ---- */
.mt-card {
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 16px;
  padding: 16px 18px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 18px;
  box-shadow: 0 1px 2px rgba(94, 58, 20, 0.04), 0 10px 26px -20px rgba(94, 58, 20, 0.5);
  transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.3, 1),
              box-shadow 0.18s ease, border-color 0.18s ease;
  animation: mtCardIn 0.45s cubic-bezier(0.2, 0.9, 0.3, 1) backwards;
  touch-action: pan-y;
}
.mt-row.is-open .mt-card { transform: translateX(-84px); }
.mt-list li:nth-child(1) .mt-card { animation-delay: 0.02s; }
.mt-list li:nth-child(2) .mt-card { animation-delay: 0.08s; }
.mt-list li:nth-child(3) .mt-card { animation-delay: 0.14s; }
.mt-list li:nth-child(n+4) .mt-card { animation-delay: 0.2s; }
@keyframes mtCardIn { from { opacity: 0; transform: translateY(10px); } }
.mt-card:hover {
  transform: translateY(-2px);
  border-color: rgba(201, 143, 28, 0.34);
  box-shadow: 0 1px 2px rgba(94, 58, 20, 0.05), 0 18px 34px -22px rgba(94, 58, 20, 0.62);
}
/* The one card that needs the student gets a warm edge; nothing else does. */
.mt-card.mt-todo { border-left: 3px solid var(--primary); padding-left: 16px; }
.mt-card.is-overdue { border-left-color: var(--accent); }

/* ---- Left column: what state this is, as one object ---- */
.mt-medal { position: relative; flex: none; width: 52px; height: 52px; display: grid; place-items: center; }

/* ---- A result gets one moment ----
   The first time this browser sees a given submission released, the card
   lifts and throws confetti out of the ring. Once, keyed on the
   submission id (see the script on the page): a burst on every visit
   would be wallpaper within a week, and the student would stop reading it
   as "this just came back from the teacher". */
.mt-card.is-fresh {
  animation: mtFreshPop 0.85s cubic-bezier(0.2, 0.9, 0.3, 1);
}
@keyframes mtFreshPop {
  0%   { transform: translateY(0) scale(1); }
  28%  { transform: translateY(-5px) scale(1.018); }
  100% { transform: translateY(0) scale(1); }
}
.mt-burst { position: absolute; left: 50%; top: 50%; width: 0; height: 0; z-index: 2; }
.mt-burst i {
  position: absolute; left: -3px; top: -3px;
  width: 6px; height: 6px; border-radius: 2px;
  opacity: 0;
  background: var(--gold);
}
.mt-burst i:nth-child(3n+1) { background: var(--primary); }
.mt-burst i:nth-child(3n+2) { background: var(--green); }
.mt-burst i:nth-child(1)  { --a: 0deg; }
.mt-burst i:nth-child(2)  { --a: 30deg; }
.mt-burst i:nth-child(3)  { --a: 60deg; }
.mt-burst i:nth-child(4)  { --a: 90deg; }
.mt-burst i:nth-child(5)  { --a: 120deg; }
.mt-burst i:nth-child(6)  { --a: 150deg; }
.mt-burst i:nth-child(7)  { --a: 180deg; }
.mt-burst i:nth-child(8)  { --a: 210deg; }
.mt-burst i:nth-child(9)  { --a: 240deg; }
.mt-burst i:nth-child(10) { --a: 270deg; }
.mt-burst i:nth-child(11) { --a: 300deg; }
.mt-burst i:nth-child(12) { --a: 330deg; }
.mt-card.is-fresh .mt-burst i {
  animation: mtBurst 0.95s cubic-bezier(0.16, 0.8, 0.3, 1) backwards;
}
.mt-card.is-fresh .mt-burst i:nth-child(even) { animation-duration: 1.15s; }
@keyframes mtBurst {
  0%   { opacity: 0; transform: rotate(var(--a)) translateY(-6px) scale(0.4); }
  18%  { opacity: 1; }
  100% { opacity: 0; transform: rotate(var(--a)) translateY(-48px) scale(0.8); }
}
.mt-glyph {
  width: 46px; height: 46px; display: grid; place-items: center;
  border-radius: 14px;
  background: var(--surface-2); color: var(--muted);
}
.mt-glyph svg { width: 22px; height: 22px; }
.mt-todo .mt-glyph { background: var(--primary-light); color: var(--primary-dark); }
.mt-grading .mt-glyph { background: var(--gold-light); color: var(--gold-dark); }

/* The score, ringed. conic-gradient draws the arc from --pct, a disc punches
   the middle out, and the figure sits on top in the page's display serif --
   a mark should look like a mark, not like a table cell. Shared: the tests
   list and the student's answer review show the same object, so a mark looks
   identical wherever a student meets it. */
.score-ring {
  position: relative;
  width: 52px; height: 52px; border-radius: 50%;
  display: grid; place-items: center;
  background: conic-gradient(var(--mt-band) calc(var(--pct, 0) * 1%),
                             var(--surface-2) 0);
}
.score-ring::before {
  content: ""; position: absolute; inset: 4px;
  border-radius: 50%; background: var(--card);
}
.score-ring b {
  position: relative;
  font-size: 0.98rem; line-height: 1; color: var(--text);
}
.score-ring b i { font-style: normal; font-size: 0.6em; color: var(--muted); margin-left: 0.5px; }
/* ---- What the Level Check returns instead of a mark ----
   A band, not a percentage, so it gets a medal rather than a ring: same
   52px slot, same "the result is an object" idea as .score-ring, but read
   as a standing rather than as a score out of ten. Gold throughout --
   this is the HSK ladder, which is gold everywhere else in the app. */
.mt-hsk {
  width: 52px; height: 52px; border-radius: 50%;
  display: grid; place-items: center; gap: 1px;
  background: var(--gold-light);
  border: 2px solid var(--gold);
  color: var(--gold-dark);
  animation: mtChipIn 0.5s cubic-bezier(0.2, 0.9, 0.3, 1) 0.15s backwards;
}
.mt-hsk i {
  font-style: normal; font-family: var(--font-sans);
  font-size: 0.5rem; font-weight: 700; letter-spacing: 0.1em;
  line-height: 1; opacity: 0.75;
}
.mt-hsk b {
  font-family: var(--font-display); font-weight: 700;
  /* 1.05rem is set by "7-9", the widest band there is -- it clears the
     disc's 48px of inside with room to spare, and a single digit at the
     same size still reads as the figure it is. */
  font-size: 1.05rem; line-height: 1; letter-spacing: -0.02em;
}

.score-ring.band-good { --mt-band: var(--green); }
.score-ring.band-mid  { --mt-band: var(--gold); }
.score-ring.band-low  { --mt-band: var(--primary); }

/* Registered so the arc can be animated at all -- an unregistered custom
   property flips from one value to the next with nothing in between. The
   sweep only runs on this list, where a mark is one of several things
   arriving at once; the answer-review page shows a single ring as the
   subject of the whole screen and has its own entrance already. */
@property --pct { syntax: '<number>'; inherits: false; initial-value: 0; }
.mt-card .score-ring {
  animation: mtRingSweep 0.95s cubic-bezier(0.2, 0.9, 0.3, 1) 0.2s backwards;
}
@keyframes mtRingSweep { from { --pct: 0; } }

/* ---- Middle: what the test actually is ---- */
.mt-body { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.mt-title {
   line-height: 1.25; color: var(--text);
  overflow-wrap: anywhere;
}
/* Test titles carry their own translation and the old row threw it away, so
   a Russian student read a line of hanzi and nothing else. */
.mt-sub {
   color: var(--muted); line-height: 1.35;
}
.mt-meta {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  margin-top: 6px;
}
.mt-tag {
  font-family: var(--font-sans);
  font-size: 0.68rem; font-weight: 700;
  letter-spacing: 0.04em;
  padding: 4px 9px; border-radius: 999px;
  white-space: nowrap;
}
.tag-todo    { background: var(--primary-light); color: var(--primary-dark); }
.tag-grading { background: var(--gold-light); color: var(--gold-dark); }
.tag-done    { background: var(--green-light); color: var(--green-dark); }
.tag-due     { background: var(--surface-2); color: var(--muted); }
.tag-overdue { background: var(--danger-wash); color: var(--accent); }
.mt-note {
   color: var(--muted); min-width: 0;
}

/* ---- Right: one action, and only where there is one ----
   'grading' has no button on purpose: the result is not the student's yet,
   and a button there would promise something it can't do. */
.mt-go {
  flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  padding: 11px 22px; border-radius: 999px;
  font-size: 0.9rem; font-weight: 700; text-decoration: none;
  color: #fff; white-space: nowrap;
  background: linear-gradient(180deg, #ff9273 0%, var(--primary) 45%, var(--primary-dark) 100%);
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.32) inset,
              0 10px 20px -10px rgba(255, 107, 74, 0.8);
  transition: transform 0.18s cubic-bezier(0.2, 0.8, 0.3, 1), box-shadow 0.18s ease;
}
.mt-go:hover {
  transform: translateY(-1px);
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset,
              0 16px 28px -12px rgba(255, 107, 74, 0.9);
}
.mt-go:active { transform: translateY(0); }
/* A finished test is a place to go back to, not an offer -- text, not a pill. */
.mt-open {
  flex: none;
  font-family: var(--font-display);
  font-size: 0.88rem; font-weight: 700; text-decoration: none;
  color: var(--primary-dark); white-space: nowrap;
  padding: 8px 4px;
  transition: color 0.15s ease;
}
.mt-open::after { content: " →"; }
.mt-open:hover { color: var(--text); }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .mt-card { box-shadow: 0 10px 26px -20px rgba(0, 0, 0, 0.8); }
  :root:not([data-theme="light"]) .mt-card:hover { box-shadow: 0 18px 34px -22px rgba(0, 0, 0, 0.9); }
}

/* ---- Nothing assigned ----
   The normal state for most students most of the time, so it is built as a
   screen in its own right: three sheets waiting under a warm light, what
   this page is for, and the one thing they can actually do right now. */
.mt-empty {
  display: flex; flex-direction: column; align-items: center; text-align: center;
  padding: 18px 24px 44px;
}

/* ---- Where it sits ----
   It used to be laid out on the assumption that it was the whole page, so it
   simply started under the bar and let the page end wherever it ended. Then
   the Level Check list arrived below it (2026-08-03) and the assumption broke
   both ways: with a level history the composition was pinned to the top of a
   full page, and with none -- a student who has never sat one, which is most
   new accounts -- it was a small centred block stranded against the top edge
   of an otherwise empty window. Duke: "the no test part would literally be
   placed on the upper part of the page instead of center of the page."

   So the column claims the window, and the empty state takes whatever is left
   over after the Level Check list and centres itself in it. One rule covers
   both cases: with no level history "what's left over" is the whole window,
   and with one it is the space above the list. data-state="none" is exactly
   the no-assignments case (see the template), so nothing here touches a page
   that actually has tests on it. */
body.mt-stage .container {
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  flex-direction: column;
}

body.mt-stage .container > .mt-app { flex: 1 1 auto; }

.mt-app[data-state="none"] {
  display: flex;
  flex-direction: column;
}

.mt-app[data-state="none"] .mt-empty {
  /* grow, never shrink: a long level history pushes this back to its natural
     height rather than crushing it. */
  flex: 1 0 auto;
  justify-content: center;
  padding-bottom: 28px;
}

/* Nothing to centre a heading against here any more: with no assignments
   there are no groups, so this composition is the whole page and the
   drift is simply behind it. */
.mt-empty-scene {
  position: relative; width: 150px; height: 120px; margin: 6px 0 26px;
}
.mt-empty-glow {
  position: absolute; inset: -40% -30%;
  background: radial-gradient(circle at 50% 45%, var(--coral-glow), transparent 68%);
  animation: prFade 1s ease-out backwards;
}
.mt-paper {
  position: absolute; width: 92px; height: 108px;
  border-radius: 10px;
  background: var(--card);
  border: 1px solid var(--border);
  box-shadow: 0 10px 22px -14px rgba(94, 58, 20, 0.55);
  animation: mtPaperIn 0.6s cubic-bezier(0.2, 0.9, 0.3, 1) backwards;
}
@keyframes mtPaperIn { from { opacity: 0; transform: translateY(14px) rotate(0deg); } }
.mt-paper.p3 { left: 46px; top: 10px; transform: rotate(9deg);  animation-delay: 0.02s; }
.mt-paper.p2 { left: 30px; top: 5px;  transform: rotate(-5deg); animation-delay: 0.1s; }
.mt-paper.p1 {
  left: 14px; top: 0; transform: rotate(1.5deg);
  animation-delay: 0.18s;
  display: flex; flex-direction: column; justify-content: center; gap: 9px;
  padding: 0 16px;
}
/* Ruled lines on the top sheet: it reads as a worksheet rather than a blank
   rectangle, which is what stopped the old empty state looking like an error. */
.mt-paper.p1 i {
  display: block; height: 5px; border-radius: 3px;
  background: var(--surface-2);
}
.mt-paper.p1 i:nth-child(1) { width: 100%; }
.mt-paper.p1 i:nth-child(2) { width: 72%; }
.mt-paper.p1 i:nth-child(3) { width: 86%; }
.mt-empty h2 {
  margin: 0 0 8px;
}
.mt-empty p {
  margin: 0 0 24px; max-width: 40ch;
  font-family: var(--font-sans);
  font-size: 0.92rem; line-height: 1.55; color: var(--pr-quiet);
}
.mt-empty-cta {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 13px 26px; border-radius: 999px;
  font-size: 0.95rem; font-weight: 700; text-decoration: none; color: #fff;
  background: linear-gradient(180deg, #ff9273 0%, var(--primary) 45%, var(--primary-dark) 100%);
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.32) inset,
              0 12px 24px -10px rgba(255, 107, 74, 0.75);
  transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.3, 1), box-shadow 0.2s ease;
}
.mt-empty-cta:hover {
  transform: translateY(-2px);
  box-shadow: 0 1px 0 rgba(255, 255, 255, 0.4) inset,
              0 18px 32px -12px rgba(255, 107, 74, 0.85);
}
.mt-empty-cta:active { transform: translateY(0); }

/* Phones: the action goes full width under the card rather than squeezing the
   title into a two-word column. */
@media (max-width: 520px) {
  .mt-card {
    grid-template-columns: auto minmax(0, 1fr);
    row-gap: 12px;
    /* Top-aligned once the text wraps to three or four lines -- vertically
       centred, the medallion drifted away from the title it belongs to. */
    align-items: start;
  }
  .mt-medal { margin-top: 1px; }
  .mt-go, .mt-open { grid-column: 1 / -1; text-align: center; }
  .mt-go { padding: 12px 20px; }
  .mt-open { padding: 4px; }
}

@media (prefers-reduced-motion: reduce) {
  .mt-card, .mt-paper, .mt-empty-glow { animation: none; }
  .mt-card, .mt-go, .mt-empty-cta, .mt-open { transition: none; }
  .mt-card:hover, .mt-go:hover, .mt-empty-cta:hover { transform: none; }
  /* The page keeps its composition and loses all of its motion: the
     drift, the deal, the sway, the seal press, the sweeps. The stack
     still fans on hover, because that is a response to the reader rather
     than something moving at them. */
  .mt-desk, .mt-group-n, .mt-sheet, .mt-seal, .mt-seal::after,
  .mt-card .score-ring, .mt-card.is-fresh, .mt-card.is-fresh .mt-burst i { animation: none; }
  /* The squares stay -- they are the page's texture, not an effect -- but
     they stand still, spread across their lanes at full opacity instead of
     fading through a cycle they can no longer run. */
  .d-cell { animation: none; opacity: 1; }
}

/* ---------- Settings page (flashcards_settings.html) ----------
   One panel, not a card per section -- rows of label-left/control-right
   separated by hairlines, the same "settings list" pattern claude.ai's own
   settings use, rather than three disconnected sign-up-style forms
   (Duke, 2026-08-01: the old per-section cards read as "form soup"). */
.nc-settings-panel {
  background: var(--card); border: 1.5px solid var(--border); border-radius: 16px;
  padding: 0 22px; max-width: 560px;
}
.nc-settings-section { padding: 22px 0; }
.nc-settings-section + .nc-settings-section { border-top: 1px solid var(--border); }
.nc-settings-section-title {
  color: var(--muted); margin: 0 0 6px;
}
.nc-settings-err { font-size: 0.85rem; font-weight: 700; color: var(--accent); margin: 0 0 10px; }

/* ---- Language, leading the page (2026-08-02) ----
   The one setting a lost student needs is the one she cannot find by
   reading, so nothing here depends on reading: a globe marks the section,
   and each option is written in its own language. See the note in
   flashcards_settings.html. */
.nc-settings-lang { position: relative; }
.nc-settings-section-title { display: flex; align-items: center; gap: 7px; }
.nc-lang-globe { width: 15px; height: 15px; flex: none; color: var(--primary-dark); }

.nc-lang-choice { display: flex; gap: 10px; margin-top: 12px; flex-wrap: wrap; }
/* Wide enough to be a target rather than a toggle, and sized alike so
   neither language looks like the default one. */
.nc-lang-opt {
  flex: 1 1 0; min-width: 132px;
  display: flex; align-items: center; gap: 9px;
  padding: 13px 16px;
  border-radius: 14px;
  border: 1.5px solid var(--border);
  background: var(--card);
  color: var(--text);
  font-size: 1rem; font-weight: 600;
  text-align: left; cursor: pointer;
  transition: border-color 0.16s ease, background 0.16s ease,
              transform 0.16s cubic-bezier(0.2, 0.8, 0.3, 1);
}
.nc-lang-opt:hover { border-color: var(--primary); transform: translateY(-1px); }
.nc-lang-opt:active { transform: translateY(0); }
.nc-lang-name { flex: 1 1 auto; min-width: 0; }
/* The two-letter code is the same signal as the globe: recognisable
   without knowing either language, and it matches the code shown beside
   Settings in the account menu, so the two read as the same control. */
.nc-lang-code {
  flex: none;
  font-family: var(--font-sans);
  font-size: var(--t-eyebrow); font-weight: 700; letter-spacing: 0.08em;
  color: var(--muted);
  padding: 3px 7px; border-radius: 999px; background: var(--surface-2);
}
.nc-lang-check { width: 17px; height: 17px; flex: none; opacity: 0; color: var(--primary-dark); }
.nc-lang-opt.lang-active {
  border-color: var(--primary);
  background: var(--primary-light);
}
.nc-lang-opt.lang-active .nc-lang-check { opacity: 1; }
.nc-lang-opt.lang-active .nc-lang-code { background: rgba(255, 255, 255, 0.65); color: var(--primary-dark); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .nc-lang-opt.lang-active .nc-lang-code { background: rgba(0, 0, 0, 0.28); }
}
:root[data-theme="dark"] .nc-lang-opt.lang-active .nc-lang-code { background: rgba(0, 0, 0, 0.28); }
/* The saved tick for this section sits under the buttons, not inline. */
.nc-settings-lang .nc-settings-flag { display: inline-flex; margin-top: 10px; }
@media (prefers-reduced-motion: reduce) {
  .nc-lang-opt { transition: none; }
  .nc-lang-opt:hover { transform: none; }
}

/* (A globe + language code briefly lived on the account menu's Settings
   row. Removed 2026-08-02: in a list of five plain words it read as
   clutter. The globe on the Settings page carries the signposting.) */

/* Row: label fixed-width on the left, control fills the rest on the right --
   stacks to label-above-control on phones (below) so typing stays roomy. */
.nc-settings-row {
  display: flex; align-items: center; gap: 20px;
  padding: 14px 0; border-bottom: 1px solid var(--border);
}
.nc-settings-row-last { border-bottom: none; padding-bottom: 4px; }
.nc-settings-row-label {
  flex: 0 0 150px;   color: var(--text);
}
.nc-settings-row-input {
  flex: 1; min-width: 0;
  padding: 9px 12px; border-radius: 8px; border: 1px solid var(--border);
  background: var(--bg); color: var(--text); font-size: 0.92rem; font-family: inherit;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
input.nc-settings-row-input:focus {
  outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--coral-glow);
}
.nc-settings-row-value { flex: 1; min-width: 0; text-align: right; color: var(--muted); font-size: 0.92rem; }

/* Inline "Saved" (or error) flash next to a row's control -- the auto-save
   confirmation, in place of the old page-reload + banner. Green fades out
   on its own; the red (required-field) variant stays until fixed. */
/* Save confirmation: a clear green "saved" pill (with a check) that pops in
   rather than a faint line of text, so an auto-saved change is unmistakable. */
.nc-settings-flag {
  flex-shrink: 0; margin-left: auto;
  display: inline-flex; align-items: center; vertical-align: middle;
  font-size: 0.74rem; font-weight: 800; color: var(--green-dark);
  background: var(--green-light); border-radius: 999px; padding: 4px 11px;
  opacity: 0; transform: translateY(3px) scale(0.94);
  transition: opacity 0.2s ease, transform 0.2s ease; white-space: nowrap;
}
.nc-settings-flag:not(.is-error)::before { content: "✓"; font-weight: 900; margin-right: 5px; }
.nc-settings-flag.show { opacity: 1; transform: none; }
.nc-settings-flag.is-error { color: var(--accent); background: var(--danger-wash); }

/* A quiet action, not a form submit -- opens the password or email modal. */
.nc-settings-secondary-btn {
  border: 1px solid var(--border); background: var(--bg); color: var(--text);
  font-size: 0.88rem; font-weight: 600;
  padding: 9px 18px; border-radius: 999px; cursor: pointer;
  transition: border-color 0.15s ease, color 0.15s ease;
}
.nc-settings-secondary-btn:hover { border-color: var(--primary); color: var(--primary-dark); }

/* Sits under the Email row while a change is awaiting its confirm-link
   click -- server-rendered from pending_email so it survives a reload,
   unlike the transient .nc-settings-flag flashes above. */
.nc-settings-pending {
  margin: -6px 0 14px; font-size: 0.82rem; color: var(--gold-dark);
}

/* ============================================================
   Settings, restructured 2026-08-06: an identity block with an
   uploadable photo, per-field inline editing, a notification
   switch, and one sticky Save bar that commits the lot.
   ============================================================ */

/* --- Identity: photo + name as one header --- */
.nc-identity {
  display: flex; align-items: center; gap: 16px;
  padding: 4px 0 20px; border-bottom: 1px solid var(--border); margin-bottom: 4px;
}
/* The circular media that actually holds the photo -- overflow:hidden lives
   HERE, on its own element, so it clips the image to a circle WITHOUT also
   clipping the camera badge (which sits on the button, one level up). */
.nc-set-avatar {
  width: 64px; height: 64px; border-radius: 50%; overflow: hidden;
  background: var(--primary-light); color: var(--primary-dark);
  display: flex; align-items: center; justify-content: center;
}
.nc-set-avatar img { width: 100%; height: 100%; object-fit: cover; display: block; }
.nc-set-avatar-initial { font-family: var(--font-sans); font-size: 1.7rem; font-weight: 800; }
/* The avatar IS the upload control. The button doesn't clip, so the badge
   shows in full; it lifts on hover to read as clickable. */
.nc-set-avatar-btn {
  position: relative; flex: none; padding: 0; border: none; background: none; cursor: pointer;
  border-radius: 50%; box-shadow: 0 2px 10px rgba(0, 0, 0, 0.06);
  transition: box-shadow 0.16s ease, transform 0.16s ease;
}
.nc-set-avatar-btn:hover { transform: translateY(-1px); box-shadow: 0 4px 16px rgba(0, 0, 0, 0.14); }
.nc-set-avatar-btn:focus-visible { outline: 2px solid var(--primary); outline-offset: 3px; }
.nc-set-avatar-cam {
  position: absolute; right: 0; bottom: 0; width: 24px; height: 24px;
  border-radius: 50%; background: var(--primary); color: #fff;
  display: flex; align-items: center; justify-content: center;
  border: 2.5px solid var(--card); box-shadow: 0 1px 4px rgba(0, 0, 0, 0.22);
}
.nc-set-avatar-cam svg { width: 13px; height: 13px; }

.nc-identity-main { flex: 1; min-width: 0; }
/* name takes the slack so the Edit lands on the same right-hand action column
   as Change / Edit in the rows below -- that column is what makes the block
   read as aligned rather than scattered. */
.nc-identity-read { display: flex; align-items: center; gap: 12px; }
.nc-identity-name {
  flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-family: var(--font-display); font-size: 1.3rem; font-weight: 600; color: var(--text);
}
.nc-identity-name:empty::before { content: "—"; color: var(--muted); }
.nc-identity-edit { display: flex; gap: 12px; flex-wrap: wrap; }
.nc-identity-edit-field { flex: 1 1 140px; min-width: 0; display: flex; flex-direction: column; gap: 4px; }
.nc-identity-edit-field label { font-size: 0.78rem; font-weight: 600; color: var(--muted); }
.nc-identity-edit-field input {
  padding: 9px 12px; border-radius: 9px; border: 1px solid var(--border);
  background: var(--bg); color: var(--text); font-size: 0.95rem; font-family: inherit;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.nc-identity-edit-field input:focus {
  outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--coral-glow);
}

/* A quiet text-link action, lighter than the pill button beside it. */
.nc-set-linkbtn {
  border: none; background: none; padding: 4px 2px; cursor: pointer;
  font-size: 0.84rem; font-weight: 600; color: var(--muted); font-family: inherit;
  text-decoration: underline; text-underline-offset: 2px;
}
.nc-set-linkbtn:hover { color: var(--accent); }
/* "Email saved" flash on its own line under the row, so it never sits inside
   the flex row and pushes the Change button off the shared action column. */
.nc-settings-flag-line { margin: -4px 0 10px auto; }

/* --- Photo modal (chooser + cropper) --- */
.nc-photo-card { max-width: 380px; }
.nc-photo-current {
  width: 132px; height: 132px; border-radius: 50%; margin: 6px auto 14px;
  background: var(--primary-light); color: var(--primary-dark); overflow: hidden;
  display: flex; align-items: center; justify-content: center;
}
.nc-photo-current img { width: 100%; height: 100%; object-fit: cover; }
.nc-photo-current .nc-set-avatar-initial { font-size: 3rem; }
.nc-photo-actions { display: flex; align-items: center; justify-content: flex-end; gap: 14px; }
.nc-crop-stage {
  position: relative; width: 280px; height: 280px; margin: 4px auto 16px;
  border-radius: 12px; overflow: hidden; background: var(--surface-2); touch-action: none;
}
#cropCanvas { display: block; cursor: grab; }
#cropCanvas:active { cursor: grabbing; }
/* Circular guide: a translucent scrim everywhere except the centre disc,
   drawn as one big ring via border, so the crop area is unmistakable. */
.nc-crop-ring {
  position: absolute; inset: 0; margin: auto; width: 280px; height: 280px; border-radius: 50%;
  box-shadow: 0 0 0 999px rgba(0, 0, 0, 0.34); pointer-events: none;
}
.nc-crop-zoom { display: flex; align-items: center; gap: 10px; color: var(--muted); }
.nc-crop-zoom svg { width: 16px; height: 16px; flex: none; }
.nc-crop-zoom input[type="range"] { flex: 1; accent-color: var(--primary); }
.nc-crop-hint { margin: 8px 0 0; font-size: 0.8rem; color: var(--muted); text-align: center; }

/* --- Notifications modal: one row per email type --- */
.nc-notify-list { display: flex; flex-direction: column; }
.nc-notify-item {
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
  padding: 14px 0; border-bottom: 1px solid var(--border);
}
.nc-notify-item:last-child { border-bottom: none; }
.nc-notify-item .nc-set-field-label { display: block; }
/* The always-sent row is informational, not a control: dimmed, with a lock. */
.nc-notify-always { opacity: 0.85; }
.nc-notify-locked {
  flex: none; width: 34px; height: 34px; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  background: var(--surface-2); color: var(--muted);
}
.nc-notify-locked svg { width: 16px; height: 16px; }

/* --- Editable field row: value read-only until the pencil opens it --- */
.nc-set-field {
  display: flex; align-items: center; gap: 16px;
  padding: 13px 0; border-bottom: 1px solid var(--border);
}
.nc-set-field:last-child { border-bottom: none; }
.nc-set-field-label { flex: 0 0 150px; color: var(--text); font-weight: 600; font-size: 0.92rem; }
.nc-set-field-value {
  flex: 1; min-width: 0; text-align: left; color: var(--text);
  font-size: 0.95rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Empty optional fields read as "Not set" rather than a blank gap. */
.nc-set-field-value:empty::before { content: attr(data-empty); opacity: 0.7; font-style: italic; }
.nc-set-field-input {
  flex: 1; min-width: 0;
  padding: 9px 12px; border-radius: 9px; border: 1px solid var(--border);
  background: var(--bg); color: var(--text); font-size: 0.95rem; font-family: inherit;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.nc-set-field-input:focus {
  outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--coral-glow);
}
/* The pencil / Change action. Sits at the right edge, hides while editing. */
.nc-set-edit-btn {
  flex: none; display: inline-flex; align-items: center; gap: 6px;
  border: none; background: none; cursor: pointer; font-family: inherit;
  color: var(--primary-dark); font-size: 0.85rem; font-weight: 600;
  padding: 6px 8px; border-radius: 8px; transition: background 0.14s ease;
}
.nc-set-edit-btn svg { width: 15px; height: 15px; }
.nc-set-edit-btn:hover { background: var(--primary-light); }
/* The action hugs its own label so the hover fill wraps exactly the icon +
   text, not an empty 96px box. flex:1 on the value already pins every button
   to the row's right edge, so they share that edge without a forced width. */
.nc-set-field .nc-set-edit-btn { flex: none; }
.nc-set-field.is-editing .nc-set-field-value,
.nc-set-field.is-editing .nc-set-edit-btn { display: none; }

/* --- Notification toggle --- */
.nc-set-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 18px; }
.nc-set-toggle-text { min-width: 0; }
.nc-set-toggle-text .nc-set-field-label { display: block; }
.nc-set-field-hint { margin: 3px 0 0; font-size: 0.82rem; color: var(--muted); }
.nc-switch {
  flex: none; width: 48px; height: 28px; border-radius: 999px; cursor: pointer;
  border: none; padding: 0; position: relative; background: var(--surface-2);
  transition: background 0.2s ease;
}
.nc-switch.is-on { background: var(--primary); }
.nc-switch-knob {
  position: absolute; top: 3px; left: 3px; width: 22px; height: 22px;
  border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.25);
  transition: transform 0.2s cubic-bezier(0.2, 0.8, 0.3, 1);
}
.nc-switch.is-on .nc-switch-knob { transform: translateX(20px); }

/* --- Sticky Save bar: one commit for every staged change --- */
.nc-save-bar {
  position: fixed; left: 50%; bottom: 22px; transform: translate(-50%, 24px);
  z-index: 60; display: flex; align-items: center; gap: 20px;
  max-width: 560px; width: calc(100% - 32px);
  padding: 12px 14px 12px 22px; border-radius: 16px;
  background: var(--card); border: 1.5px solid var(--border);
  box-shadow: 0 12px 34px rgba(0, 0, 0, 0.18);
  opacity: 0; pointer-events: none;
  transition: opacity 0.24s ease, transform 0.28s cubic-bezier(0.2, 0.8, 0.3, 1);
}
.nc-save-bar.show { opacity: 1; transform: translate(-50%, 0); pointer-events: auto; }
.nc-save-bar-msg { flex: 1; min-width: 0; font-size: 0.9rem; font-weight: 600; color: var(--text); }
.nc-save-bar-actions { flex: none; display: flex; gap: 8px; }
.nc-save-bar-discard {
  border: none; background: none; cursor: pointer; font-family: inherit;
  font-size: 0.9rem; font-weight: 600; color: var(--muted); padding: 9px 14px; border-radius: 999px;
}
.nc-save-bar-discard:hover { color: var(--text); }
.nc-save-bar-save {
  border: none; cursor: pointer; font-family: inherit;
  font-size: 0.9rem; font-weight: 700; color: #fff; background: var(--primary);
  padding: 10px 22px; border-radius: 999px; transition: background 0.15s ease, opacity 0.15s ease;
}
.nc-save-bar-save:hover { background: var(--primary-dark); }
.nc-save-bar-save:disabled, .nc-save-bar-discard:disabled { opacity: 0.55; cursor: default; }

@media (prefers-reduced-motion: reduce) {
  .nc-save-bar, .nc-switch-knob { transition: opacity 0.2s ease; }
}
@media (max-width: 560px) {
  .nc-set-field { flex-wrap: wrap; gap: 8px 12px; }
  .nc-set-field-label { flex-basis: 100%; }
  .nc-set-field-value { text-align: left; }
  .nc-set-field-input { flex-basis: 100%; }
  .nc-identity { gap: 14px; }
  .nc-set-avatar { width: 60px; height: 60px; }
  .nc-photo-card, .nc-crop-stage, .nc-crop-ring { max-width: 100%; }
}

.nc-modal-heading {
  margin: 4px 0 18px;
}

@media (max-width: 560px) {
  .nc-settings-row { flex-direction: column; align-items: stretch; gap: 6px; }
  .nc-settings-row-label { flex-basis: auto; }
  .nc-settings-row-value { text-align: left; }
  .nc-settings-flag { margin-left: 0; }
  /* The row goes align-items:stretch above so a full-width text input reads
     right -- but that same stretch turned the compact language pill into an
     oversized full-width capsule (Duke's phone screenshot, 2026-08-01), and
     would do the same to the Change-email button. The language pill left
     this row entirely on 2026-08-02; the button still needs the rule. */
  .nc-settings-row .nc-settings-secondary-btn { align-self: flex-start; }
  /* Language is a full-width choice on a phone: one option per line, so
     neither reads as the smaller of the two. */
  .nc-lang-choice { flex-direction: column; }
  .nc-lang-opt { min-width: 0; }
}

/* Appearance picker -- three tappable tiles (Light / Dark / Automatic),
   each a tiny hand-styled mockup rather than a live-CSS-var preview: the
   Light tile must still look light even while the page itself is rendered
   in dark, and vice versa, so its colors are hardcoded to match the real
   :root / dark-override values instead of inheriting them. */
/* flex (not wrap) with a max-width cap: three tiles always share one row,
   from the narrowest phone up to desktop, instead of wrapping 2-then-1 once
   the fixed width used to not fit (Duke, phone screenshot, 2026-08-01). */
.nc-theme-picker { display: flex; gap: 12px; }
.nc-theme-opt {
  display: flex; flex-direction: column; align-items: center; gap: 9px;
  flex: 1 1 0; min-width: 0; max-width: 120px;
  background: none; border: none; padding: 0; margin: 0;
  cursor: pointer; -webkit-tap-highlight-color: transparent;
}
.nc-theme-preview {
  position: relative;
  width: 100%; height: 72px;
  border-radius: 12px;
  border: 1.5px solid var(--border);
  overflow: hidden;
  box-shadow: var(--shadow);
  transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
}
.nc-theme-opt:hover .nc-theme-preview { transform: translateY(-2px); }
.nc-theme-opt-label {
  font-size: var(--t-label); font-weight: 600; color: var(--muted);
  transition: color 0.15s ease;
}
.nc-theme-opt.is-selected .nc-theme-preview {
  border-color: var(--primary); border-width: 2px;
  animation: nc-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.nc-theme-opt.is-selected .nc-theme-opt-label { color: var(--primary-dark); }
/* Selection badge, same circle+check construction as .opt.selected-answer
   (multiple-choice) -- one "you picked this" motif across the app. */
.nc-theme-opt.is-selected .nc-theme-preview::after {
  content: ""; position: absolute; top: 6px; right: 6px;
  width: 18px; height: 18px; border-radius: 50%; background: var(--primary);
  z-index: 2;
}
.nc-theme-opt.is-selected .nc-theme-preview::before {
  content: ""; position: absolute; top: 11px; right: 12px;
  width: 7px; height: 4px;
  border-left: 2px solid #fff; border-bottom: 2px solid #fff;
  transform: rotate(-45deg); z-index: 3;
}

.nc-theme-preview-bar {
  position: absolute; top: 0; left: 0; right: 0; height: 16px;
}
.nc-theme-preview-card {
  position: absolute; left: 9px; right: 9px; top: 24px; height: 18px; border-radius: 5px;
}
.nc-theme-preview-line { position: absolute; left: 9px; height: 5px; border-radius: 3px; }
.nc-theme-preview-line.l1 { top: 50px; width: 58%; }
.nc-theme-preview-line.l2 { top: 59px; width: 38%; }

.nc-theme-preview-light { background: #fefaf6; }
.nc-theme-preview-light .nc-theme-preview-bar { background: #ffffff; border-bottom: 1px solid #ede1d3; }
.nc-theme-preview-light .nc-theme-preview-card { background: #e4744f; }
.nc-theme-preview-light .nc-theme-preview-line { background: #cabfae; }

.nc-theme-preview-dark { background: #1d1712; }
.nc-theme-preview-dark .nc-theme-preview-bar { background: #27201a; border-bottom: 1px solid #3a2f26; }
.nc-theme-preview-dark .nc-theme-preview-card { background: #ea855f; }
.nc-theme-preview-dark .nc-theme-preview-line { background: #4a3f34; }

/* Automatic: no single mockup describes it, so -- as on iOS -- it gets an
   abstract split swatch instead of the bar+card+lines mockup. */
.nc-theme-preview-auto {
  background: linear-gradient(135deg, #fefaf6 0%, #fefaf6 48%, #1d1712 52%, #1d1712 100%);
}

/* Review screen */
.nc-remaining-pill {
  background: var(--surface-2);
  color: var(--muted);
  font-size: 0.82rem;
  font-weight: 700;
  padding: 7px 14px;
  border-radius: 999px;
}
.nc-review-card {
  background: var(--card);
  border: 1.5px solid var(--border);
  border-radius: 24px;
  box-shadow: var(--shadow);
  padding: 36px 22px;
  text-align: center;
  margin: 10px 0 16px;
  min-height: 300px;
  display: flex;
  flex-direction: column;
  justify-content: center;
}
.nc-review-prompt { color: var(--muted); font-size: 0.88rem; margin: 0 0 14px; }
.nc-review-hanzi { font-family: var(--font-hanzi-serif); font-size: 3.4rem; line-height: 1.25; }
.nc-answer-hanzi { font-family: var(--font-hanzi-serif); font-size: 2.4rem; line-height: 1.25; }
.nc-answer-hanzi rt {
  font-family: var(--font-sans);
  color: var(--primary-dark);
  font-weight: 600;
  font-size: 0.35em;
  letter-spacing: 0.01em;
}
.nc-review-meaning {
  font-family: var(--font-display); font-size: 1.45rem; font-weight: 700; line-height: 1.35; }
.nc-answer-divider { height: 1.5px; background: var(--border); margin: 22px auto; width: 56%; }
.nc-review-pinyin { color: var(--primary-dark); font-weight: 600; font-size: 1.15rem; margin: 0 0 6px; }
.nc-review-def { font-family: var(--font-display); margin: 0; font-size: 1.05rem; }
.nc-review-example {
  margin: 18px 0 0;
  font-family: var(--font-hanzi-serif);
  font-size: 1.25rem;
  color: var(--muted);
  line-height: 2.1;
}
/* Each <rt> is only as wide as its own character, so with no horizontal
   breathing room adjacent syllables butt together and read as one word --
   "xiaomaohen" instead of "xiǎo māo hěn" (reported 2026-07-28). The deck list
   avoids this because .nc-card-pinyin sits under a flex row that spaces the
   characters itself; inline ruby in a sentence has no such parent, so the
   spacing has to come from the ruby text. ruby-align keeps the reading
   centred over its character once it is wider than the hanzi. */
.nc-review-example ruby { ruby-position: over; ruby-align: center; margin: 0 0.06em; }
/* The prompt-side example is plain hanzi (no ruby), so it needs its own
   line-height -- the annotated version's 2.1 leaves a large gap with nothing
   in it. */
.nc-review-example-front { line-height: 1.6; }
.nc-review-example rt {
  font-size: 0.6em;
  color: var(--muted);
  font-family: var(--font-sans);
  letter-spacing: 0.02em;
  padding: 0 0.14em;
}
.nc-reveal-btn {
  width: 100%;
  padding: 16px;
  border-radius: 16px;
  background: var(--primary);
  color: #fff;
  font-weight: 800;
  font-size: 1.05rem;
  border: none;
  cursor: pointer;
}
.nc-reveal-btn:hover { background: var(--primary-dark); }
.nc-rating-form { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
.nc-rate {
  border: none;
  border-radius: 14px;
  padding: 12px 2px 10px;
  cursor: pointer;
  display: flex;
  flex-direction: column;
  gap: 2px;
  align-items: center;
}
.nc-rate:active { transform: scale(0.96); }
.nc-rate-label { font-weight: 800; font-size: 0.92rem; }
.nc-rate-int { font-size: 0.7rem; opacity: 0.8; }
.nc-rate-again { background: var(--danger-wash); color: var(--accent); }
.nc-rate-again:hover { background: var(--danger-wash-hover); }
.nc-rate-hard { background: var(--gold-light); color: var(--gold-dark); }
.nc-rate-good { background: var(--primary); color: #fff; }
.nc-rate-good:hover { background: var(--primary-dark); }
.nc-rate-easy { background: var(--green-light); color: var(--green-dark); }
.nc-kbd-hint { text-align: center; color: var(--muted); font-size: 0.76rem; margin-top: 14px; }
@media (hover: none) { .nc-kbd-hint { display: none; } }
.nc-review-done { margin-top: 10vh; }
.nc-done-btn { display: block; text-decoration: none; text-align: center; margin-top: 18px; }

/* Add-card form */
.nc-form-card {
  background: var(--card);
  border: 1.5px solid var(--border);
  border-radius: 20px;
  box-shadow: var(--shadow);
  padding: 26px 22px;
}
.nc-form-heading { margin: 0 0 18px;    }
.nc-lookup-row { display: flex; gap: 10px; align-items: flex-end; }
.nc-lookup-field { flex: 1; margin-bottom: 0; }
.nc-lookup-btn {
  padding: 12px 18px;
  border-radius: 12px;
  background: var(--surface-2);
  border: 1.5px solid var(--border);
  font-weight: 700;
  font-size: 0.95rem;
  color: var(--text);
  cursor: pointer;
  white-space: nowrap;
}
.nc-lookup-btn:hover { background: var(--surface-2-hover); }
.nc-form-divider { height: 1.5px; background: var(--border); margin: 22px 0; }

@media (min-width: 480px) {
  .nc-hero-btn { padding: 16px 32px; }
}

/* ---- Live add-card experience (2026-07-20): flip preview, lookup shimmer,
   autofill pop, success toast + fly-away. Pure CSS, no libraries. ---- */

.nc-enter { animation: nc-rise 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; }
.nc-enter-2 { animation-delay: 0.08s; }
@keyframes nc-rise {
  from { opacity: 0; transform: translateY(14px); }
  to { opacity: 1; transform: none; }
}

.nc-preview-wrap { margin-bottom: 18px; }
.nc-preview {
  perspective: 900px;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  width: min(300px, 80%);
  height: 168px;
  margin: 0 auto;
}
.nc-preview-inner {
  position: relative;
  width: 100%;
  height: 100%;
  transform-style: preserve-3d;
  transition: transform 0.55s cubic-bezier(0.4, 0.2, 0.2, 1);
}
.nc-preview.is-flipped .nc-preview-inner { transform: rotateY(180deg); }
.nc-preview-face {
  position: absolute;
  inset: 0;
  backface-visibility: hidden;
  -webkit-backface-visibility: hidden;
  background: var(--card);
  border: 1.5px solid var(--border);
  border-radius: 18px;
  box-shadow: var(--shadow);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 16px;
  text-align: center;
  overflow: hidden;
}
.nc-preview-back { transform: rotateY(180deg); }
.nc-preview-hanzi { font-family: var(--font-hanzi-serif); font-size: 2.6rem; line-height: 1.2; }
.nc-preview-pinyin { color: var(--primary-dark); font-weight: 700; font-size: 1.1rem; }
.nc-preview-def {
  color: var(--muted);
  font-size: 0.92rem;
  display: -webkit-box;
  -webkit-line-clamp: 3;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.nc-preview-hint { text-align: center; color: var(--muted); font-size: 0.76rem; margin: 10px 0 0; }
.nc-preview.is-flying { pointer-events: none; }
.nc-preview.is-flying .nc-preview-inner {
  animation: nc-fly 0.6s cubic-bezier(0.5, -0.2, 0.75, 0.4) forwards;
}
@keyframes nc-fly {
  to { transform: translateY(-90px) scale(0.25) rotate(-8deg); opacity: 0; }
}

.nc-input-hanzi { font-family: var(--font-hanzi-serif); font-size: 1.35rem; }

/* Lookup in flight: soft shimmer sweep across the fields being filled. */
.nc-field.is-looking input {
  background-image: linear-gradient(100deg, transparent 30%, var(--primary-light) 50%, transparent 70%);
  background-size: 220% 100%;
  animation: nc-shimmer 1.1s linear infinite;
}
@keyframes nc-shimmer {
  from { background-position: 130% 0; }
  to { background-position: -90% 0; }
}
.nc-field.is-filled input { animation: nc-pop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1); }
@keyframes nc-pop {
  0% { transform: scale(1); }
  40% { transform: scale(1.025); background-color: var(--primary-light); }
  100% { transform: scale(1); }
}

.nc-defs-hint { color: var(--muted); font-size: 0.78rem; margin: -8px 0 16px; }

.nc-toast {
  position: fixed;
  left: 50%;
  bottom: 28px;
  transform: translate(-50%, 80px);
  display: flex;
  align-items: center;
  gap: 10px;
  background: var(--text);
  color: var(--bg);
  font-weight: 700;
  padding: 12px 20px;
  border-radius: 999px;
  box-shadow: var(--shadow);
  opacity: 0;
  transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.35s;
  z-index: 50;
}
.nc-toast.is-showing { transform: translate(-50%, 0); opacity: 1; }
.nc-toast-check {
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--green);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 0.85rem;
}

@media (prefers-reduced-motion: reduce) {
  .nc-enter, .nc-preview-inner, .nc-field.is-looking input, .nc-field.is-filled input,
  .nc-preview.is-flying .nc-preview-inner { animation: none; transition: none; }
}

/* ---- Add-card layout v2 (2026-07-20, Duke's feedback): bank-card
   proportions for the preview, explicit lookup with a language choice,
   and a two-column "card issuing" layout on desktop -- card on the left,
   form on the right, everything vertically centered. ---- */

.nc-preview {
  width: min(340px, 86%);
  height: auto;
  aspect-ratio: 1.586; /* ISO 7810 bank-card ratio */
}
.nc-preview-face {
  /* Soft diagonal sheen so it reads as a physical card, not a panel. */
  background:
    linear-gradient(115deg, transparent 40%, rgba(255, 255, 255, 0.05) 45%, transparent 62%),
    var(--card);
}

.nc-lookup-row { display: flex; flex-direction: column; gap: 10px; }
.nc-lookup-controls { display: flex; gap: 8px; align-items: stretch; }
.nc-lookup-langs {
  display: flex;
  border: 1.5px solid var(--border);
  border-radius: 12px;
  overflow: hidden;
  background: var(--card);
}
.nc-lang-chip {
  border: none;
  background: transparent;
  color: var(--muted);
  font-family: inherit;
  font-weight: 800;
  font-size: 0.82rem;
  padding: 10px 14px;
  cursor: pointer;
}
.nc-lang-chip.is-on { background: var(--primary); color: #fff; }
.nc-lookup-btn { flex: 1; }

@media (min-width: 920px) {
  .nc-app-add { max-width: 1060px; }
  .nc-add-layout {
    display: grid;
    grid-template-columns: 5fr 6fr;
    gap: 56px;
    align-items: center;
    min-height: calc(100vh - 140px);
  }
  .nc-app-add .nc-preview-wrap { margin-bottom: 0; }
  .nc-app-add .nc-preview { width: min(400px, 100%); margin: 0 auto; }
  .nc-app-add .nc-preview-hanzi { font-size: 3.2rem; }
  .nc-app-add .nc-form-card { padding: 34px 32px; }
  .nc-app-add .nc-lookup-controls { justify-content: flex-end; }
  .nc-app-add .nc-lookup-btn { flex: none; padding: 12px 26px; }
}

/* ---- Example-translation field + "i" info tooltip (2026-07-20) ---- */
.nc-label-with-info { display: flex; align-items: center; gap: 8px; }
.nc-info { position: relative; display: inline-flex; }
.nc-info-btn {
  width: 18px;
  height: 18px;
  border-radius: 50%;
  border: 1.5px solid var(--muted);
  background: transparent;
  color: var(--muted);
  /* The one deliberate exception to "nothing below 0.8rem is serif" (see
     the type block): this is not text, it is the conventional serif-italic
     info glyph, and drawn in Inter it is just a vertical stick. Named here
     so the font audit's one allowed hit has a reason attached. */
  font-family: var(--font-display);
  font-style: italic;
  font-weight: 600;
  font-size: 0.72rem;
  line-height: 1;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
}
.nc-info-btn:hover, .nc-info-btn:focus { border-color: var(--primary); color: var(--primary); outline: none; }
/* Persistent "add an example" nudge on a pre-filled add-card page. Primary-
   tinted and quiet -- a coach's aside, not an error. */
.nc-example-nudge {
  margin: 0 0 14px;
  padding: 10px 14px;
  border-radius: 12px;
  border-left: 3px solid var(--primary);
  background: var(--primary-light);
  color: var(--primary-dark);
  line-height: 1.45;
}

.nc-info-pop {
  position: absolute;
  bottom: calc(100% + 10px);
  left: 50%;
  transform: translateX(-50%) translateY(4px);
  width: 240px;
  background: var(--text);
  color: var(--bg);
  font-size: 0.8rem;
  font-weight: 500;
  line-height: 1.45;
  padding: 10px 12px;
  border-radius: 10px;
  box-shadow: var(--shadow);
  opacity: 0;
  visibility: hidden;
  transition: opacity 0.2s, transform 0.2s;
  z-index: 30;
  pointer-events: none;
}
.nc-info-pop::after {
  content: '';
  position: absolute;
  top: 100%;
  left: 50%;
  transform: translateX(-50%);
  border: 6px solid transparent;
  border-top-color: var(--text);
}
.nc-info:hover .nc-info-pop, .nc-info:focus-within .nc-info-pop {
  opacity: 1;
  visibility: visible;
  transform: translateX(-50%) translateY(0);
}
/* Keep the bubble on-screen when the field sits near the left edge on phones. */
@media (max-width: 480px) {
  .nc-info-pop { left: auto; right: -12px; transform: translateY(4px); }
  .nc-info-pop::after { left: auto; right: 16px; transform: none; }
  .nc-info:hover .nc-info-pop, .nc-info:focus-within .nc-info-pop { transform: translateY(0); }
}

.nc-review-example-tr { color: var(--muted); font-size: 0.95rem; margin: 6px 0 0; font-style: italic; }
/* On the front of a production card, the translated example is the only
   thing under the meaning (no pinyin/def line above it to sit close to),
   so it needs the same breathing room as .nc-review-example gets on the
   recognition front. */
.nc-review-example-tr-front { margin-top: 18px; }

/* ---- Add-card v3 + flashcard theming (2026-07-20, Duke's second design
   pass): compact single-definition form, magnifier lookup inside the word
   input, deck-stack under the preview, floating hanzi ambience. ---- */

/* Floating faded hanzi behind the app screens. Decorative only. */
.nc-decor { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
.nc-decor span {
  position: absolute;
  font-family: var(--font-hanzi-serif);
  color: var(--primary);
  opacity: 0.06;
  animation: nc-float 16s ease-in-out infinite;
}
@keyframes nc-float {
  50% { transform: translateY(-20px) rotate(3deg); }
}
.nc-app { position: relative; z-index: 1; }

/* A little stack of cards peeking out behind an element -- it's a deck,
   after all. Used by the add-card preview and the dashboard card list. */
.nc-deck-stack { position: relative; }
.nc-deck-stack::before, .nc-deck-stack::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 18px;
  border: 1.5px solid var(--border);
  background: var(--surface-2);
  z-index: -1;
}
.nc-deck-stack::before { transform: rotate(-4.5deg) translateY(7px); }
.nc-deck-stack::after { transform: rotate(3deg) translateY(4px); opacity: 0.7; }
.nc-app-add .nc-deck-stack { width: min(340px, 86%); margin: 0 auto; }
.nc-deck-stack .nc-preview { width: 100%; }

/* Dashboard deck: a narrow centered column so it reads as a pile of real
   flashcards, not a full-width list. The peeking cards anchor to the
   bottom of the pile (not the whole list) so the fan keeps its pronounced
   angles no matter how tall the deck grows. */
.nc-deck { width: min(420px, 100%); margin: 0 auto; }
.nc-deck-stack-pile::before, .nc-deck-stack-pile::after { inset: auto 0 0 0; height: 84px; }
.nc-deck-stack-pile::before { transform: rotate(-3.6deg) translateY(10px); }
.nc-deck-stack-pile::after { transform: rotate(2.6deg) translateY(6px); }
/* Tiny alternating tilt so the pile feels hand-stacked, not ruler-straight. */
.nc-deck-stack-pile .nc-card-row:nth-child(odd) { transform: rotate(-0.6deg); }
.nc-deck-stack-pile .nc-card-row:nth-child(even) { transform: rotate(0.5deg); }

/* EVERY card sits on its own little fan of card edges -- the stacked look
   from under the bottom card, applied per row (Duke's spec). The wrapper-
   level fan is off here so the bottom card isn't doubled up. */
.nc-deck-stack-pile::before, .nc-deck-stack-pile::after { content: none; }
.nc-deck-stack-pile .nc-card-list { gap: 16px; }
.nc-deck-stack-pile .nc-card-row::before,
.nc-deck-stack-pile .nc-card-row::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 14px;
  border: 1.5px solid var(--border);
  background: var(--surface-2);
  z-index: -1;
}
.nc-deck-stack-pile .nc-card-row::before { transform: rotate(-1.7deg) translateY(7px); }
.nc-deck-stack-pile .nc-card-row::after { transform: rotate(1.2deg) translateY(4px); opacity: 0.7; }

/* Stacked mode: the pile fans out of its MIDDLE. The newest card (pos0)
   sits at the center, fully visible on its fan; its neighbours (pos-1
   above, pos1 below) stay mostly readable with a modest overlap; the
   outer pair (pos-2/pos2) peek by an edge with a stronger tilt. Overlap
   margins only apply when a row has a predecessor, so 2-4 card decks
   degrade cleanly. Hovering a buried card draws it out via JS. */
.nc-card-list.stacked .nc-card-row.pos0 { z-index: 5; }
.nc-card-list.stacked .nc-card-row.pos-1 { z-index: 4; opacity: 0.85; transform: rotate(-2.2deg); }
.nc-card-list.stacked .nc-card-row.pos1 { z-index: 3; opacity: 0.85; transform: rotate(2.2deg); }
.nc-card-list.stacked .nc-card-row.pos-2 { z-index: 2; opacity: 0.55; transform: rotate(3.4deg); }
.nc-card-list.stacked .nc-card-row.pos2 { z-index: 1; opacity: 0.55; transform: rotate(-3.4deg); }
.nc-card-list.stacked .nc-card-row + .nc-card-row.pos-1 { margin-top: -52px; }
.nc-card-list.stacked .nc-card-row + .nc-card-row.pos0 { margin-top: -32px; }
.nc-card-list.stacked .nc-card-row + .nc-card-row.pos1 { margin-top: -32px; }
.nc-card-list.stacked .nc-card-row + .nc-card-row.pos2 { margin-top: -52px; }
.nc-card-list.stacked .nc-card-row:not(.pos0)::before,
.nc-card-list.stacked .nc-card-row:not(.pos0)::after { content: none; }

/* The hero rides its own little deck -- THE flashcard on top of the pile.
   Capped to the same width as .nc-deck below so it reads as a card sitting
   on the pile, not a full-width banner (Duke, 2026-07-24: at full nc-app
   width the short, wide hero looked like a coupon, not a card). */
.nc-hero-stack { width: min(420px, 100%); margin: 0 auto 34px; }
.nc-hero-stack::before, .nc-hero-stack::after { border-radius: 26px; }
.nc-hero-stack::before { transform: rotate(-2.1deg) translateY(11px); }
.nc-hero-stack::after { transform: rotate(1.5deg) translateY(6px); }
.nc-hero-stack .nc-hero { margin-bottom: 0; }

/* Premium motion for the due hero: a sheen that sweeps the card every few
   seconds, a breathing coral glow, the 加油 glyph drifting, and a button
   that lifts on hover. All CSS, all subtle. */
.nc-hero-due { animation: nc-hero-glow 4.5s ease-in-out infinite; }
.nc-hero-due::after {
  content: '';
  position: absolute;
  inset: 0;
  background: linear-gradient(115deg, transparent 40%, rgba(255, 255, 255, 0.16) 50%, transparent 60%);
  transform: translateX(-130%);
  animation: nc-hero-sheen 6s ease-in-out infinite;
  pointer-events: none;
}
@keyframes nc-hero-sheen {
  0%, 55% { transform: translateX(-130%); }
  85%, 100% { transform: translateX(130%); }
}
@keyframes nc-hero-glow {
  0%, 100% { box-shadow: var(--shadow); }
  50% { box-shadow: 0 16px 44px var(--coral-glow); }
}
.nc-hero-hanzi { animation: nc-float 13s ease-in-out infinite; }
.nc-hero-btn { transition: transform 0.15s ease, box-shadow 0.15s ease; }
.nc-hero-btn:hover { transform: translateY(-2px); box-shadow: 0 10px 24px rgba(0, 0, 0, 0.14); }

/* Premium done-state hero (2026-07-22): same family as the due hero and
   the marketing/auth cards -- swept background glint (background-image
   technique, not a transformed overlay: see .recall-flipcard-face for
   the iOS Safari reason), a soft breathing coral glow, a sonar pulse on
   the check mark, and the proverb sized up like a proper card face with
   pinyin above the hanzi. The corner watermark glyph is the proverb's
   own first character (set in the template), tinted for the quiet card
   surface rather than the due hero's white-on-coral. */
.nc-hero-done {
  background-image: linear-gradient(75deg, transparent 42%, rgba(255, 255, 255, 0.14) 50%, rgba(255, 255, 255, 0.05) 55%, transparent 63%);
  background-size: 260% 100%;
  background-repeat: no-repeat;
  background-position: 100% 0;
  animation: recall-shine 7s ease-in-out infinite, nc-done-glow 5s ease-in-out infinite;
}
@keyframes nc-done-glow {
  0%, 100% { box-shadow: 0 6px 20px rgba(0, 0, 0, 0.08); }
  50% { box-shadow: 0 16px 44px var(--coral-glow); }
}
.nc-hero-done .nc-hero-hanzi {
  color: var(--text);
  opacity: 0.05;
}
.nc-hero-check { position: relative; }
.nc-hero-check::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: 50%;
  border: 2px solid var(--green-dark);
  animation: nc-check-pulse 2.4s ease-out infinite;
}
@keyframes nc-check-pulse {
  0% { transform: scale(1); opacity: 0.55; }
  70%, 100% { transform: scale(1.6); opacity: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .nc-hero-due, .nc-hero-due::after, .nc-hero-hanzi, .nc-enter,
  .nc-hero-done, .nc-hero-check::after { animation: none; }
}

/* ---- Dashboard card manager (2026-07-20): last-5 pile, live search,
   touch-tilt rows, and a flip-open edit/stats modal. ---- */

.nc-card-row {
  cursor: pointer;
  position: relative;
  transition: transform 0.22s ease, box-shadow 0.22s ease, opacity 0.22s ease;
  will-change: transform;
}
.nc-card-row:hover { box-shadow: 0 10px 24px rgba(0, 0, 0, 0.10); }

.nc-search-wrap {
  position: relative;
  margin: 14px 0 18px;
}
.nc-search-wrap svg {
  position: absolute;
  left: 14px;
  top: 50%;
  transform: translateY(-50%);
  color: var(--muted);
  pointer-events: none;
}
.nc-search-wrap input {
  width: 100%;
  padding: 12px 16px 12px 42px;
  border-radius: 999px;
  border: 1.5px solid var(--border);
  background: var(--card);
  color: var(--text);
  font: inherit;
  font-size: 0.95rem;
}
.nc-search-wrap input:focus {
  outline: none;
  border-color: var(--primary);
  box-shadow: 0 0 0 3px var(--coral-glow);
}
.nc-search-empty { color: var(--muted); font-size: 0.9rem; margin: 12px 2px 0; }

/* Detail modal: page blurs behind, the card flips out of the pile. */
.nc-modal { position: fixed; inset: 0; z-index: 100; }
.nc-modal-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(20, 12, 8, 0.35);
  -webkit-backdrop-filter: blur(7px);
  backdrop-filter: blur(7px);
  opacity: 0;
  transition: opacity 0.25s ease;
}
.nc-modal.open .nc-modal-backdrop { opacity: 1; }
.nc-modal-card {
  position: absolute;
  left: 50%;
  top: 50%;
  width: min(440px, calc(100% - 32px));
  max-height: calc(100vh - 48px);
  overflow-y: auto;
  background: var(--card);
  border: 1.5px solid var(--border);
  border-radius: 24px;
  padding: 26px 26px 22px;
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.25);
  transform: translate(-50%, -50%) perspective(1100px) rotateY(-84deg) scale(0.9);
  opacity: 0;
  transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1), opacity 0.22s ease;
}
.nc-modal.open .nc-modal-card {
  transform: translate(-50%, -50%) perspective(1100px) rotateY(0) scale(1);
  opacity: 1;
}
.nc-modal-close {
  position: absolute;
  top: 14px;
  right: 14px;
  width: 34px;
  height: 34px;
  border-radius: 50%;
  border: 1.5px solid var(--border);
  background: var(--card);
  color: var(--muted);
  font-size: 1.2rem;
  line-height: 1;
  cursor: pointer;
}
.nc-modal-close:hover { color: var(--text); border-color: var(--muted); }
.nc-modal-hanzi {
  font-family: var(--font-hanzi-serif);
  font-size: 2.6rem;
  line-height: 1.25;
  text-align: center;
  margin: 4px 0 18px;
}
.nc-modal-stats { margin: 4px 0 18px; }
.nc-modal-stats h3 {
  margin: 0 0 10px;
}
.nc-stat-block { margin-bottom: 12px; }
.nc-stat-line {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 12px;
  font-size: 0.85rem;
}
.nc-stat-dir { font-weight: 700; }
.nc-stat-detail { color: var(--muted); text-align: right; }
.nc-strength {
  height: 6px;
  border-radius: 999px;
  background: var(--surface-2);
  margin-top: 6px;
  overflow: hidden;
}
.nc-strength-fill {
  height: 100%;
  border-radius: 999px;
  background: linear-gradient(90deg, var(--primary), var(--primary-dark));
  transition: width 0.4s ease;
}
.nc-stat-memory { color: var(--muted); font-size: 0.75rem; }
.nc-modal-actions {
  display: flex;
  justify-content: space-between;
  gap: 12px;
  margin-top: 4px;
}
.nc-modal-save {
  flex: 1;
  border: none;
  background: var(--primary);
  color: #fff;
  font-weight: 800;
  font-size: 0.95rem;
  padding: 12px 18px;
  border-radius: 999px;
  cursor: pointer;
}
.nc-modal-save:hover { background: var(--primary-dark); }
.nc-modal-save:disabled { opacity: 0.7; cursor: default; }
.nc-modal-delete {
  border: 1.5px solid var(--border);
  background: transparent;
  color: var(--muted);
  font-weight: 700;
  font-size: 0.9rem;
  padding: 12px 16px;
  border-radius: 999px;
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.nc-modal-delete:hover { color: #e5484d; border-color: #e5484d; }
.nc-modal-delete.armed { background: #e5484d; border-color: #e5484d; color: #fff; }

@media (prefers-reduced-motion: reduce) {
  .nc-card-row, .nc-modal-card, .nc-modal-backdrop { transition: none; }
}

/* Magnifier lookup button living inside the word input. */
.nc-word-wrap { position: relative; }
.nc-word-wrap .nc-input-hanzi { width: 100%; padding-right: 52px; }
.nc-lookup-icon {
  position: absolute;
  right: 7px;
  top: 50%;
  transform: translateY(-50%);
  width: 36px;
  height: 36px;
  border-radius: 10px;
  border: none;
  background: var(--primary-light);
  color: var(--primary-dark);
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
}
.nc-lookup-icon:hover { background: var(--primary); color: #fff; }
.nc-lookup-icon.is-busy svg { animation: nc-spin 0.9s linear infinite; }
@keyframes nc-spin { to { transform: rotate(360deg); } }

/* Compact form: tighter rhythm so the column stays close to card height. */
.nc-form-compact .nc-field { margin-bottom: 12px; }
.nc-form-compact .nc-field label { margin-bottom: 4px; font-size: 0.8rem; }
.nc-form-compact .nc-field input { padding: 10px 12px; }
.nc-form-compact .nc-word-wrap .nc-input-hanzi { padding-right: 52px; }
.nc-form-compact .nc-form-heading { margin-bottom: 14px; font-size: 1.25rem; }
.nc-form-compact .nc-auth-btn { margin-top: 4px; }

@media (min-width: 920px) {
  .nc-app-add .nc-deck-stack { width: min(400px, 100%); }
}

/* Big faded hanzi in the dashboard hero corner. */
.nc-hero { position: relative; overflow: hidden; }
.nc-hero-hanzi {
  position: absolute;
  right: -4px;
  bottom: -34px;
  font-family: var(--font-hanzi-serif);
  font-size: 8.5rem;
  line-height: 1;
  color: #fff;
  opacity: 0.1;
  pointer-events: none;
}

/* First-time coach marks (2026-07-23): a small dismissible tip that shows
   once next to a feature the first time it's encountered -- card stats in
   the detail modal, the SRS rating buttons on the review page. */
.nc-tour-callout {
  background: var(--primary-light);
  border: 1px solid var(--primary);
  border-radius: 14px;
  padding: 14px 16px;
  margin-bottom: 14px;
  opacity: 0;
  transform: translateY(-4px);
  transition: opacity 0.18s ease, transform 0.18s ease;
}
.nc-tour-callout.open { opacity: 1; transform: translateY(0); }
.nc-tour-callout h4 {
  margin: 0 0 6px;
  font-size: 0.95rem;
  font-weight: 800;
  color: var(--primary-dark);
}
.nc-tour-callout p { margin: 0 0 9px; font-size: 0.92rem; color: var(--text); line-height: 1.5; }
.nc-tour-callout p:last-of-type { margin-bottom: 12px; }
.nc-tour-dismiss {
  background: var(--primary);
  color: #fff;
  border: none;
  border-radius: 10px;
  padding: 7px 16px;
  font-size: 0.85rem;
  font-weight: 700;
  cursor: pointer;
}
.nc-tour-dismiss:hover { background: var(--primary-dark); }

/* Spotlight walkthrough for first-time users: full-screen overlay, a
   box-shadow "hole" that dims everything except the current step's
   target, and a floating tip card with Next/Skip. */
.nc-spot-overlay {
  position: fixed;
  inset: 0;
  z-index: 950;
  opacity: 0;
  transition: opacity 0.22s ease;
}
.nc-spot-overlay.open { opacity: 1; }
/* requireClick steps: the dim layer stops intercepting clicks so the
   real, highlighted element underneath is actually clickable -- the tip
   card opts back in so its own Skip button still works. */
.nc-spot-overlay.pass-through { pointer-events: none; }
.nc-spot-hole {
  position: absolute;
  box-shadow: 0 0 0 9999px rgba(20, 12, 6, 0.62);
  transition: all 0.28s ease;
  pointer-events: none;
}
.nc-spot-tip {
  position: absolute;
  width: min(400px, calc(100vw - 32px));
  background: var(--card);
  border: 1.5px solid var(--border);
  border-radius: 18px;
  padding: 20px 22px;
  box-shadow: 0 12px 36px rgba(0, 0, 0, 0.25);
  transition: top 0.28s ease, left 0.28s ease;
  pointer-events: auto;
}
.nc-spot-tip h4 {
  margin: 0 0 8px;
  line-height: 1.3;
  color: var(--text);
}
.nc-spot-tip p { margin: 0 0 10px; font-size: 1rem; color: var(--text); opacity: 0.82; line-height: 1.5; }
.nc-spot-tip p:last-of-type { margin-bottom: 16px; }
.nc-spot-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; flex-wrap: wrap; }
.nc-spot-skip {
  /* Supporting print, not a CTA -- see .nci-skip. */
  font-family: var(--font-sans);
  background: none;
  border: none;
  color: var(--muted);
  font-size: 0.9rem;
  cursor: pointer;
  padding: 6px 2px;
}
.nc-spot-skip:hover { color: var(--text); }
.nc-spot-dots { display: flex; gap: 5px; }
.nc-spot-dots span {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--border);
}
.nc-spot-dots span.on { background: var(--primary); }
.nc-spot-next {
  background: var(--primary);
  color: #fff;
  border: none;
  border-radius: 10px;
  padding: 9px 20px;
  font-size: 0.92rem;
  font-weight: 700;
  cursor: pointer;
}
.nc-spot-next:hover { background: var(--primary-dark); }
.nc-spot-hint {
  font-size: 0.88rem;
  font-weight: 700;
  color: var(--primary);
}

/* A write that didn't land. Amber rather than red: nothing is broken and
   nothing is lost, they just need to press the button again. */
.nc-save-failed {
  max-width: 420px;
  margin: 0 auto 14px;
  padding: 10px 14px;
  border-radius: 12px;
  background: var(--gold-light);
  color: var(--gold-dark);
  font-size: 0.92rem;
  font-weight: 600;
  text-align: center;
}

/* ---------- Session end: the "still coming back" screen ----------
   Deliberately NOT the green tick. A tick says finished; this screen exists
   because the session is not finished -- a word the student just missed is
   on its way back. Amber ring + live countdown reads as "hold on", which is
   the actual instruction. */
.nc-review-waiting {
  /* Same card shell as .nc-hero-done so the three endings are visibly the
     same screen in three moods -- only the icon and colour change. */
  background: var(--card);
  border: 1.5px solid var(--border);
  text-align: center;
  padding: 44px 30px 36px;
  margin-top: 10vh;
}
.nc-wait-ring {
  position: relative;
  width: 54px;
  height: 54px;
  margin: 0 auto 14px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: var(--gold-light);
}
.nc-wait-ring svg { position: absolute; inset: 5px; width: 44px; height: 44px; }
.nc-wait-track { stroke: var(--border); }
.nc-wait-arc {
  stroke: var(--gold);
  transition: stroke-dashoffset 0.95s linear;
}
.nc-wait-count {
  position: relative;
  z-index: 1;
  font-family: var(--font-display);
  font-size: 1rem;
  font-weight: 700;
  color: var(--gold-dark);
  font-variant-numeric: tabular-nums;
}
.nc-review-waiting h2 { margin: 0 0 8px; }
.nc-wait-ring-static .nc-wait-hands { stroke: var(--gold-dark); }
.nc-wait-count-line {
  margin: 0 0 6px;
  font-size: 0.95rem;
  font-weight: 600;
  color: var(--text);
}
.nc-wait-why {
  margin: 0 auto 18px;
  max-width: 34ch;
  font-size: 0.95rem;
  line-height: 1.55;
  color: var(--muted);
}
.nc-wait-leave,
.nc-review-done .nc-wait-leave {
  display: inline-block;
  margin-top: 12px;
  font-size: 0.9rem;
  color: var(--muted);
  text-decoration: none;
  border-bottom: 1px solid transparent;
}
.nc-wait-leave:hover { color: var(--text); border-bottom-color: var(--border); }
.nc-done-sub {
  margin: 0 auto 16px;
  max-width: 34ch;
  font-size: 0.95rem;
  line-height: 1.55;
  color: var(--muted);
}
.nc-done-extend { margin: 0; }
@media (prefers-reduced-motion: reduce) {
  .nc-wait-arc { transition: none; }
}

/* ==========================================================================
   First-run intro (nc-intro.js)
   The one screen a student sees before they know what NihaoCard is. It gets
   a real opening -- a box that takes its time, then throws cards out -- and
   then explains the product in four beats, ending on the daily-time choice.
   ========================================================================== */
.nci-overlay {
  position: fixed;
  inset: 0;
  z-index: 1200;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  /* No backdrop-filter here on purpose. It looked good but promoted the
     overlay to its own compositing layer, which made the page behind paint
     over the panel in every screenshot -- i.e. it could not be verified,
     only hoped about. A slightly deeper dim reads as "the page is gone"
     just as well, works in browsers without backdrop-filter, and costs
     nothing on a mid-range phone. */
  background: rgba(20, 12, 6, 0.68);
  opacity: 0;
  transition: opacity 0.26s ease;
  overflow-y: auto;
}
.nci-overlay.open { opacity: 1; }
body.nci-locked { overflow: hidden; }

.nci-panel {
  width: min(460px, 100%);
  background: var(--card);
  border: 1.5px solid var(--border);
  border-radius: 24px;
  padding: 28px 26px 22px;
  box-shadow: 0 24px 60px rgba(0, 0, 0, 0.3);
  margin: auto;
}
/* ---- slide transitions ---------------------------------------------
   The opening box sets a bar the rest of the sequence has to clear. It
   did not: every "Next" wiped the panel and redrew it, so a piece of
   theatre was followed by four hard cuts (Duke, 2026-08-03).

   The stage holds one .nci-slide at a time and tweens its height between
   them, so the panel grows or shrinks into its new size rather than
   snapping. During a move the outgoing slide is taken out of flow (both
   occupy the same box) and the two cross-fade past each other. The
   footer lives OUTSIDE the stage and never moves. */
.nci-stage {
  position: relative;
  transition: height 0.26s cubic-bezier(0.4, 0, 0.2, 1);
}
.nci-stage.is-moving { overflow: hidden; }

/* Phase 1: the old content leaves, alone. A short fade and a small lift --
   no travel, because a slab sliding sideways past another slab was the
   thing that read as cheap. */
.nci-slide.is-leaving {
  opacity: 0;
  transform: translateY(-6px);
  transition: opacity 0.13s ease, transform 0.13s ease;
  pointer-events: none;
}

/* Phase 3: the new slide's parts rise in one after another rather than the
   whole panel arriving as a block. The per-part delays are set in JS from
   DOM order, so the copy-first slide staggers heading -> body -> picker
   while the others go figure -> heading -> body. This stagger is the part
   that makes it feel made rather than mechanical. */
.nci-slide > .nci-figure,
.nci-slide .nci-copy > h2,
.nci-slide .nci-copy > p {
  transition: opacity 0.32s ease, transform 0.42s cubic-bezier(0.22, 0.9, 0.3, 1);
}
.nci-slide.is-arriving > .nci-figure,
.nci-slide.is-arriving .nci-copy > h2,
.nci-slide.is-arriving .nci-copy > p {
  opacity: 0;
  transform: translateY(12px);
}

.nci-copy h2 {
  margin: 0 0 10px;
  line-height: 1.25;
  color: var(--text);
}
.nci-copy p {
  margin: 0 0 10px;
  font-size: 0.98rem;
  line-height: 1.55;
  color: var(--text);
  opacity: 0.82;
}
.nci-copy p:last-child { margin-bottom: 18px; }

.nci-row { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
/* A way out, not a call to action -- so it is supporting print in Inter,
   not the serif the button rule hands every control by default. Serif at
   400 next to the serif-700 "Next" read as two different treatments of
   the same row. Matches .nc-spot-skip. */
.nci-skip {
  font-family: var(--font-sans);
  background: none; border: none; padding: 6px 2px;
  color: var(--muted); font-size: 0.9rem; cursor: pointer;
}
.nci-skip:hover { color: var(--text); }
.nci-dots { display: flex; gap: 5px; }
.nci-dots span { width: 7px; height: 7px; border-radius: 50%; background: var(--border); }
.nci-dots span.on { background: var(--primary); }
.nci-next {
  background: var(--primary); color: #fff; border: none;
  border-radius: 10px; padding: 10px 22px;
  font-size: 0.95rem; font-weight: 700; cursor: pointer;
  transition: background 0.16s ease, transform 0.16s ease;
}
.nci-next:hover { background: var(--primary-dark); transform: translateY(-1px); }

/* ---- the opening box -----------------------------------------------
   Timeline, all of it deliberate rather than quick:
     0.00s  box settles in
     0.55s  three small anticipation shakes
     1.15s  lid tips back
     1.30s  glow blooms out of the opening
     1.45s  four cards launch, 130ms apart, arc out and hang
     2.45s  copy and buttons fade up (.nci-hold)
   Roughly 2.5 seconds before anything is clickable, which is the point:
   it buys the product a moment of anticipation instead of dropping the
   student straight into a form. */
.nci-box-stage {
  position: relative;
  height: 190px;
  margin: 6px 0 20px;
  perspective: 700px;
}
.nci-box-glow {
  position: absolute;
  left: 50%; top: 52%;
  width: 210px; height: 210px;
  margin: -105px 0 0 -105px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--coral-glow) 0%, rgba(255, 133, 99, 0.14) 38%, transparent 70%);
  opacity: 0;
  transform: scale(0.3);
  animation: nciGlow 1.5s ease-out 1.3s forwards;
}
.nci-box {
  position: absolute;
  left: 50%; bottom: 26px;
  width: 132px; height: 78px;
  margin-left: -66px;
  transform-style: preserve-3d;
  animation: nciShake 0.6s ease-in-out 0.55s;
}
.nci-box-back, .nci-box-front {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  border-radius: 6px 6px 12px 12px;
}
.nci-box-back {
  height: 64px;
  background: var(--primary-dark);
  filter: brightness(0.82);
}
.nci-box-front {
  height: 58px;
  background: linear-gradient(168deg, var(--primary) 0%, var(--primary-dark) 100%);
  box-shadow: 0 12px 26px -12px rgba(180, 70, 40, 0.6);
  display: flex; align-items: center; justify-content: center;
}
/* Two characters now (你好), so the plate is a wordmark panel rather than a
   single-glyph square -- fixed height, width from the content, and the
   type stepped down so it sits comfortably on a 58px box front. */
.nci-box-seal {
  font-family: var(--font-hanzi-serif);
  font-size: 1.15rem;
  letter-spacing: 0.06em;
  color: rgba(255, 255, 255, 0.92);
  border: 2px solid rgba(255, 255, 255, 0.55);
  border-radius: 8px;
  height: 32px;
  padding: 0 10px;
  display: flex; align-items: center; justify-content: center;
  line-height: 1;
}
.nci-box-lid {
  position: absolute;
  left: -4px; right: -4px;
  bottom: 56px;
  height: 18px;
  border-radius: 8px;
  background: linear-gradient(168deg, var(--primary) 0%, var(--primary-dark) 100%);
  transform-origin: 50% 100%;
  animation: nciLid 0.9s cubic-bezier(0.34, 1.3, 0.64, 1) 1.15s forwards;
}
.nci-box-cards { position: absolute; inset: 0; }
.nci-fly {
  position: absolute;
  left: 50%; bottom: 14px;
  width: 52px; height: 66px;
  margin-left: -26px;
  border-radius: 8px;
  background: var(--card);
  border: 1.5px solid var(--border);
  box-shadow: 0 10px 22px -10px rgba(60, 40, 20, 0.5);
  display: flex; flex-direction: column;
  align-items: center; justify-content: center;
  gap: 2px;
  opacity: 0;
  transform: translateY(0) scale(0.5);
}
.nci-fly-py { font-size: 0.6rem; color: var(--primary); font-weight: 600; }
.nci-fly-hz { font-family: var(--font-hanzi-serif); font-size: 1.5rem; color: var(--text); line-height: 1; }
.nci-fly-0 { animation: nciFly0 1.25s cubic-bezier(0.22, 0.9, 0.3, 1) 1.45s forwards; }
.nci-fly-1 { animation: nciFly1 1.25s cubic-bezier(0.22, 0.9, 0.3, 1) 1.58s forwards; }
.nci-fly-2 { animation: nciFly2 1.25s cubic-bezier(0.22, 0.9, 0.3, 1) 1.71s forwards; }
.nci-fly-3 { animation: nciFly3 1.25s cubic-bezier(0.22, 0.9, 0.3, 1) 1.84s forwards; }

@keyframes nciShake {
  0%, 100% { transform: translateX(0) rotate(0deg); }
  20% { transform: translateX(-3px) rotate(-1.6deg); }
  45% { transform: translateX(3px) rotate(1.6deg); }
  70% { transform: translateX(-2px) rotate(-1deg); }
}
@keyframes nciLid {
  0% { transform: rotateX(0deg) translateY(0); }
  100% { transform: rotateX(-118deg) translateY(-16px); }
}
@keyframes nciGlow {
  0% { opacity: 0; transform: scale(0.3); }
  45% { opacity: 1; transform: scale(1.05); }
  100% { opacity: 0.55; transform: scale(1); }
}
/* Each card leaves on its own arc and stops where it lands -- a fan, not a
   pile. Peak height differs per card so the group reads as thrown rather
   than animated in lockstep. */
@keyframes nciFly0 {
  0%   { opacity: 0; transform: translate(0, 0) scale(0.5) rotate(0deg); }
  22%  { opacity: 1; }
  55%  { transform: translate(-58px, -104px) scale(1) rotate(-16deg); }
  100% { opacity: 1; transform: translate(-72px, -74px) scale(1) rotate(-13deg); }
}
@keyframes nciFly1 {
  0%   { opacity: 0; transform: translate(0, 0) scale(0.5) rotate(0deg); }
  22%  { opacity: 1; }
  55%  { transform: translate(-20px, -126px) scale(1) rotate(-6deg); }
  100% { opacity: 1; transform: translate(-25px, -96px) scale(1) rotate(-5deg); }
}
@keyframes nciFly2 {
  0%   { opacity: 0; transform: translate(0, 0) scale(0.5) rotate(0deg); }
  22%  { opacity: 1; }
  55%  { transform: translate(20px, -126px) scale(1) rotate(6deg); }
  100% { opacity: 1; transform: translate(25px, -96px) scale(1) rotate(5deg); }
}
@keyframes nciFly3 {
  0%   { opacity: 0; transform: translate(0, 0) scale(0.5) rotate(0deg); }
  22%  { opacity: 1; }
  55%  { transform: translate(58px, -104px) scale(1) rotate(16deg); }
  100% { opacity: 1; transform: translate(72px, -74px) scale(1) rotate(13deg); }
}
/* Copy waits for the box to finish its performance. `backwards` holds the
   from-state through the delay, which also means the buttons are sitting
   there invisible -- and an invisible button is still a clickable one, so
   pointer events are held off until they have actually appeared. */
.nci-hold {
  animation: nciRise 0.5s ease-out 2.45s backwards, nciEnable 0s linear 2.45s backwards;
}
@keyframes nciRise {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: none; }
}
@keyframes nciEnable {
  from { pointer-events: none; }
  to { pointer-events: auto; }
}

/* ---- slide 2: the forgetting curve ---- */
.nci-figure { margin: 0 0 18px; }

/* ---- slide 2: the forgetting curve, drawn in time ----
   The dash offsets and per-element delays are set by animateCurve() in
   nc-intro.js -- path length has to be measured from the live DOM. All
   this does is state the from-state and the to-state. */
.nci-curve svg { width: 100%; height: 110px; overflow: visible; }
.nci-curve-grid { stroke: var(--border); stroke-width: 1.5; }
.nci-curve-fade {
  fill: none; stroke: var(--muted); stroke-width: 2.5; opacity: 0.45;
  stroke-linecap: round;
}
.nci-curve.is-drawn .nci-curve-fade { stroke-dashoffset: 0 !important; }
/* Already dashed for its own reason, so it cannot be drawn with the dash
   trick -- it grows up out of the baseline instead. */
.nci-curve-jump {
  fill: none; stroke: var(--primary); stroke-width: 2.5;
  stroke-dasharray: 4 4; stroke-linecap: round;
  transform: scaleY(0);
  transform-box: fill-box;
  transform-origin: bottom;
}
.nci-curve.is-drawn .nci-curve-jump { transform: scaleY(1); }
.nci-curve-dot {
  fill: var(--primary);
  transform: scale(0);
  transform-box: fill-box;
  transform-origin: center;
}
.nci-curve.is-drawn .nci-curve-dot { transform: scale(1); }

/* ---- slide 3: the two directions ---- */
.nci-dirs { display: flex; flex-direction: column; gap: 10px; }
.nci-dir {
  display: flex; align-items: center; justify-content: center; gap: 12px;
  padding: 12px 14px;
  background: var(--surface-2);
  border-radius: 14px;
  opacity: 0;
  transform: translateY(8px);
  transition: opacity 0.34s ease, transform 0.34s cubic-bezier(0.22, 0.9, 0.3, 1);
}
.nci-dirs.is-shown .nci-dir { opacity: 1; transform: none; }
.nci-dir-a, .nci-dir-b { font-size: 0.95rem; color: var(--text); }
.nci-dir .is-hanzi { font-family: var(--font-hanzi-serif); font-size: 1.35rem; }
.nci-dir-arrow { color: var(--primary); font-size: 1.1rem; }
/* Each row plays prompt -> arrow -> answer, so the picture performs the
   direction of recall instead of just labelling it. Delays per part are
   set in animateDirections(). */
.nci-dir-a, .nci-dir-arrow, .nci-dir-b {
  opacity: 0;
  transition: opacity 0.3s ease, transform 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.nci-dir-a { transform: translateX(-8px); }
.nci-dir-arrow { transform: scaleX(0.3); }
.nci-dir-b { transform: scale(0.7); }
.nci-dirs.is-shown .nci-dir-a,
.nci-dirs.is-shown .nci-dir-arrow,
.nci-dirs.is-shown .nci-dir-b { opacity: 1; transform: none; }

/* ---- slide 4: time picker + projection ---- */
.nci-time-row {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 8px;
  margin-bottom: 16px;
}
/* Tiles land one by one, then the chart, then the bars grow. The per-tile
   delays come from the picker's _ncPlay(). The offset transform is the
   entrance state; :hover takes over once the tile has landed. */
.nci-time-opt {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 1px;
  min-height: 62px;
  padding: 8px 4px;
  background: var(--card);
  border: 1.5px solid var(--border);
  border-radius: 12px;
  cursor: pointer;
  opacity: 0;
  transform: translateY(10px) scale(0.94);
  transition: border-color 0.16s ease, background 0.16s ease,
              opacity 0.3s ease, transform 0.34s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.nci-picker.is-shown .nci-time-opt { opacity: 1; transform: none; }
.nci-time-opt:hover { border-color: var(--primary); transform: translateY(-2px); }
.nci-time-opt.is-selected { border-color: var(--primary); background: var(--primary-light); }
/* Figures serif, words Inter -- see rule (2) in the type block. These
   spans live inside a <button>, so without a family of their own they
   inherit the CTA serif and "min" renders as 0.72rem serif right next
   to a 0.72rem Inter "month 1" in the chart below it. */
.nci-time-value {
   font-size: 1.35rem; 
  color: var(--text); line-height: 1.1;
}
.nci-time-value.is-word {
  font-family: var(--font-sans);
  font-size: 0.72rem; font-weight: 700; line-height: 1.25; text-align: center;
}
.nci-time-unit {   color: var(--muted); }
.nci-time-opt.is-selected .nci-time-unit { color: var(--primary-dark); }

.nci-chart {
  background: var(--surface-2); border-radius: 14px; padding: 14px 14px 10px;
  opacity: 0;
  transform: translateY(10px);
  transition: opacity 0.34s ease, transform 0.4s cubic-bezier(0.22, 0.9, 0.3, 1);
}
.nci-picker.is-shown .nci-chart { opacity: 1; transform: none; }
.nci-bars { display: flex; align-items: flex-end; gap: 12px; height: 130px; }
.nci-bar-col { flex: 1; display: flex; flex-direction: column; align-items: center; height: 100%; justify-content: flex-end; }
.nci-bar {
  width: 100%;
  height: 6%;
  border-radius: 8px 8px 4px 4px;
  background: linear-gradient(180deg, var(--primary) 0%, var(--primary-dark) 100%);
  transition: height 0.55s cubic-bezier(0.22, 0.9, 0.3, 1);
}
.nci-bar-value {
  margin-bottom: 4px;
   font-size: 0.88rem; 
  color: var(--text);
}
.nci-bar-label { margin-top: 6px; font-size: 0.72rem; color: var(--muted); }
.nci-chart-caption {
  margin: 10px 0 0;
  font-size: 0.82rem;
  line-height: 1.45;
  color: var(--muted);
  text-align: center;
}

/* ---- slide 5: the deck lands ----
   This slide had no figure at all, so after four moving screens the last
   one just sat there -- and it is the one asking for the click. Three
   cards drop into a stack and the "+" arrives on top: the same object the
   whole intro has been about, showing up where the student is about to
   add to it. */
.nci-deck {
  position: relative;
  height: 132px;
  display: flex;
  align-items: center;
  justify-content: center;
}
.nci-deck-card {
  position: absolute;
  width: 92px;
  height: 116px;
  border-radius: 14px;
  background: var(--card);
  border: 1.5px solid var(--border);
  box-shadow: 0 10px 24px -14px rgba(60, 40, 20, 0.5);
  display: flex;
  align-items: center;
  justify-content: center;
  opacity: 0;
  transition: opacity 0.34s ease, transform 0.52s cubic-bezier(0.22, 0.9, 0.3, 1);
}
.nci-deck-hz {
  font-family: var(--font-hanzi-serif);
  font-size: 2.1rem;
  color: var(--text);
}
/* Start fanned out and flat, settle into a tidy angled stack. */
.nci-deck-2 { transform: translate(-70px, 26px) rotate(-22deg); }
.nci-deck-1 { transform: translate(70px, 26px) rotate(22deg); }
.nci-deck-0 { transform: translateY(34px) scale(0.9); }
.nci-deck.is-stacked .nci-deck-2 { opacity: 1; transform: translate(-24px, 4px) rotate(-9deg); transition-delay: 0.06s; }
.nci-deck.is-stacked .nci-deck-1 { opacity: 1; transform: translate(24px, 4px) rotate(9deg); transition-delay: 0.16s; }
.nci-deck.is-stacked .nci-deck-0 { opacity: 1; transform: none; transition-delay: 0.26s; }
.nci-deck-plus {
  position: absolute;
  right: calc(50% - 74px);
  top: 6px;
  width: 32px;
  height: 32px;
  border-radius: 50%;
  background: linear-gradient(135deg, var(--primary), var(--primary-dark));
  color: #fff;
  font-family: var(--font-sans);
  font-size: 1.25rem;
  font-weight: 700;
  line-height: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 8px 20px -6px var(--coral-glow);
  opacity: 0;
  transform: scale(0.2);
  transition: opacity 0.24s ease, transform 0.42s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.nci-deck.is-stacked .nci-deck-plus { opacity: 1; transform: none; transition-delay: 0.46s; }

@media (max-width: 420px) {
  .nci-panel { padding: 22px 18px 18px; border-radius: 20px; }
  .nci-box-stage { height: 172px; }
}

/* Reduced motion: the sequence still tells its story, it just stops
   performing -- box open, cards already fanned, nothing flying. */
@media (prefers-reduced-motion: reduce) {
  .nci-box, .nci-box-lid, .nci-box-glow, .nci-fly, .nci-hold, .nci-bar { animation: none !important; }
  .nci-box-glow { opacity: 0.5; transform: scale(1); }
  .nci-box-lid { transform: rotateX(-118deg) translateY(-16px); }
  .nci-fly { opacity: 1; }
  .nci-fly-0 { transform: translate(-72px, -74px) rotate(-13deg); }
  .nci-fly-1 { transform: translate(-25px, -96px) rotate(-5deg); }
  .nci-fly-2 { transform: translate(25px, -96px) rotate(5deg); }
  .nci-fly-3 { transform: translate(72px, -74px) rotate(13deg); }
  .nci-bar { transition: none; }
  /* Every figure added 2026-08-03 arrives finished instead of playing.
     Killing the transitions is enough and is deliberately ALL that happens
     here: play() still runs and still adds is-drawn / is-shown /
     is-stacked, so the end state is the same one everyone else lands on --
     the deck keeps its fanned stack, the tiles keep their layout. Forcing
     `transform: none` here instead would collapse all three deck cards
     onto the same spot. !important because animateCurve() and the picker
     set their delays inline. */
  .nci-stage, .nci-slide, .nci-slide > .nci-figure, .nci-slide .nci-copy > h2,
  .nci-slide .nci-copy > p, .nci-dir, .nci-dir-a, .nci-dir-arrow, .nci-dir-b,
  .nci-time-opt, .nci-chart, .nci-deck-card, .nci-deck-plus,
  .nci-curve-fade, .nci-curve-jump, .nci-curve-dot {
    transition: none !important;
    transition-delay: 0s !important;
  }
}

/* ---------- Language choice on the signup form ----------
   Reuses the Settings picker's .nc-lang-* look so the control a student
   meets at registration is the same one they will find later in Settings.
   Difference is only the mechanism: real radios inside <label>s, so the
   choice posts with the form and works with no JavaScript at all. */
.nc-signup-lang { margin-top: 8px; }
.nc-signup-lang-label { display: block; }
.nc-signup-lang .nc-lang-opt { min-width: 120px; }
.nc-signup-lang input[type="radio"] {
  position: absolute;
  opacity: 0;
  width: 0;
  height: 0;
}
/* :has() drives the selected look, so it tracks the radio itself. The
   template deliberately does NOT also render .lang-active: with both, the
   initially-selected option stayed highlighted after the student picked the
   other one and two options looked chosen at once. */
.nc-signup-lang .nc-lang-opt:has(input:checked) {
  border-color: var(--primary);
  background: var(--primary-light);
}
.nc-signup-lang .nc-lang-opt:has(input:checked) .nc-lang-check { opacity: 1; }
.nc-signup-lang .nc-lang-opt:has(input:checked) .nc-lang-code {
  background: rgba(255, 255, 255, 0.65);
  color: var(--primary-dark);
}
/* Keyboard users must be able to see where they are: the radio is visually
   gone, so the focus ring has to live on the label. */
.nc-signup-lang .nc-lang-opt:focus-within {
  outline: 2px solid var(--primary);
  outline-offset: 2px;
}
:root[data-theme="dark"] .nc-signup-lang .nc-lang-opt:has(input:checked) .nc-lang-code {
  background: rgba(0, 0, 0, 0.28);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .nc-signup-lang .nc-lang-opt:has(input:checked) .nc-lang-code {
    background: rgba(0, 0, 0, 0.28);
  }
}

/* ---------- Daily study-time picker (Settings + intro) ---------- */
.nc-settings-hint {
  margin: -4px 0 14px;
  font-size: 0.9rem;
  line-height: 1.5;
  color: var(--muted);
  max-width: 52ch;
}
.nc-time-choice {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
  gap: 10px;
}
.nc-time-opt {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  min-height: 74px;
  padding: 12px 8px;
  background: var(--card);
  border: 1.5px solid var(--border);
  border-radius: 14px;
  cursor: pointer;
  transition: border-color 0.16s ease, background 0.16s ease, transform 0.16s ease;
}
.nc-time-opt:hover { border-color: var(--primary); transform: translateY(-2px); }
.nc-time-opt.is-selected {
  border-color: var(--primary);
  background: var(--primary-light);
}
.nc-time-value {
  font-size: 1.5rem;
  color: var(--text);
  line-height: 1.1;
}
/* "Без ограничений" is a phrase, not a numeral -- at display size it would
   overflow the tile, so the wordy option drops to label size. Dropping to
   label size also means dropping out of the serif: figures serif, words
   Inter (rule 2 in the type block). Same picker, same rule, as the
   first-run intro's .nci-time-* tiles. */
.nc-time-opt:not(:has(.nc-time-unit)) .nc-time-value {
  font-family: var(--font-sans);
  font-size: 0.92rem;
  font-weight: 700;
  line-height: 1.3;
  text-align: center;
}
.nc-time-unit {   color: var(--muted); }
.nc-time-opt.is-selected .nc-time-unit { color: var(--primary-dark); }

/* ---------- Student profile header (student_detail.html) ------------------
   Rebuilt 2026-08-03. The page opened with a plain <h1> and a fat card whose
   only content was one muted run-on line ("Level: HSK4 · Teacher: … · ID: …"),
   and the Link-account button was a raw slab: it carried `.btn-primary` (just
   a colour) but not `.btn` (the shape/padding/font), so it rendered a size
   smaller and square-cornered next to a full-width e-mail field. This section
   is the header + the account block; the analytics cards below it were fine. */
.stu-head {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 20px 28px;
  margin-bottom: 22px;
}
.stu-head-id { display: flex; align-items: center; gap: 16px; min-width: 0; }
.stu-avatar {
  width: 54px;
  height: 54px;
  flex-shrink: 0;
  border-radius: 50%;
  background: var(--primary-light);
  color: var(--primary-dark);
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-display);
  font-size: 1.55rem;
  font-weight: 700;
}
.stu-head-text { min-width: 0; }
.stu-head-text h1 { margin: 0 0 7px; font-size: 1.55rem; color: var(--text); line-height: 1.1; }
.stu-tags { display: flex; flex-wrap: wrap; gap: 7px; align-items: center; }
.stu-level {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.8rem;
  color: var(--primary-dark);
  background: var(--primary-light);
  padding: 3px 11px;
  border-radius: 999px;
  letter-spacing: 0.02em;
}
.stu-tag {
  font-size: 0.8rem;
  color: var(--muted);
  background: var(--surface-2);
  padding: 3px 11px;
  border-radius: 999px;
  font-weight: 600;
}
.stu-tag-mono { font-family: var(--font-mono); font-size: 0.74rem; letter-spacing: 0; }

/* Three stat tiles: tests assigned, average score across graded tests, and
   whether the student has a live NihaoCard account. What a teacher wants to
   know before reading any of the detail below. */
.stu-kpis { display: flex; gap: 12px; flex-wrap: wrap; }
.stu-kpi {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 11px 18px;
  min-width: 88px;
  display: flex;
  flex-direction: column;
  gap: 6px;
  box-shadow: var(--shadow);
}
.stu-kpi-num,
.stu-kpi-badge { display: inline-flex; align-items: center; min-height: 26px; }
.stu-kpi-num {
  font-size: 1.5rem;
  color: var(--text);
  line-height: 1;
}
.stu-kpi-badge {
  font-weight: 700;
  font-size: 0.82rem;
  padding: 3px 11px;
  border-radius: 999px;
}
.stu-kpi-badge.yes { background: var(--green-light); color: var(--green-dark); }
.stu-kpi-badge.no { background: var(--surface-2); color: var(--muted); }
.stu-kpi-label {
  font-size: var(--t-eyebrow);
  text-transform: uppercase;
  letter-spacing: 0.07em;
  color: var(--muted);
  font-weight: 700;
}

.stu-notes { padding: 16px 20px; }

/* Card section header: title on the left, a status pill hard right. */
.stu-section-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 12px;
}
.stu-section-head h3 { margin: 0; }

/* The account form, sized like a control rather than sprawling the card width.
   align-items: stretch makes the field and the button the same height without
   hand-tuning either one. */
.stu-link-form {
  display: flex;
  gap: 10px;
  align-items: stretch;
  max-width: 480px;
}
.stu-link-form input[type="email"] { flex: 1; min-width: 0; }
.stu-link-form .btn { flex-shrink: 0; }

/* Linked state: the account it points at, set off in its own quiet panel with
   the unlink action beside it -- not a bare coloured link under a line of text. */
.stu-linked {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 14px;
  flex-wrap: wrap;
  background: var(--surface-2);
  border-radius: 12px;
  padding: 12px 16px;
}
.stu-linked-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stu-linked-name { font-weight: 700; color: var(--text); }
.stu-linked-email { font-size: 0.84rem; color: var(--muted); font-family: var(--font-mono); }

/* ---------- Assign / test-management page (assign.html) -------------------
   Rebuilt 2026-08-04, then re-presented the same day. The first pass reused
   the shared admin-table + stacked forms; on a phone that table collapses to
   a column of NAME/ACCOUNT/PROGRESS/... caps labels -- which read as "raw".
   This version is purpose-built: a segmented Roster/Email assigner, and a
   roster of student cards each anchored by a circular progress ring that
   fills to their score. No table, no caps-label stack. */
.assign-back {
  display: inline-block;
  margin-bottom: 14px;
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--muted);
}
.assign-back:hover { color: var(--primary-dark); }

/* The KPI number can carry a quiet "/ total" tail (completed) without the
   denominator competing with the count. */
.stu-kpi-sub { font-size: 0.9rem; color: var(--muted); font-weight: 600; margin-left: 1px; }

.assign-actionbar {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 22px;
}

.assign-add { padding: 18px 20px; }

/* Segmented control: one assigner at a time (roster or e-mail), so the panel
   is a single input row instead of two stacked labelled forms with an "or". */
.asg-seg {
  display: inline-flex;
  background: var(--surface-2);
  border-radius: 999px;
  padding: 4px;
  gap: 4px;
  margin: 2px 0 16px;
}
.asg-seg button {
  border: 0;
  background: transparent;
  font: inherit;
  font-weight: 700;
  font-size: 0.88rem;
  color: var(--muted);
  padding: 8px 18px;
  border-radius: 999px;
  cursor: pointer;
  transition: color 0.15s ease, background 0.15s ease;
}
.asg-seg button.on {
  background: var(--card);
  color: var(--text);
  box-shadow: 0 1px 4px rgba(60, 40, 20, 0.12);
}
.asg-pane { display: none; }
.asg-pane.on { display: block; animation: asg-fade 0.2s ease; }
@keyframes asg-fade { from { opacity: 0; transform: translateY(3px); } }
.asg-row { display: flex; gap: 10px; align-items: stretch; }
.asg-row input[type="email"] { flex: 1; min-width: 0; }
.asg-row .btn { flex-shrink: 0; }
/* The roster <select> is upgraded to the shared .custom-select widget (app.js),
   so it's that wrapper -- not the raw select -- that must grow in the row and
   whose trigger must match the button's height. */
.asg-row .custom-select { flex: 1; min-width: 0; display: flex; }
.asg-row .custom-select .custom-select-trigger { flex: 1; min-width: 0; }
/* Keep the label on one line -- the placeholder is long enough to wrap in the
   narrower width left beside the Assign button. */
.asg-row .custom-select-trigger > span:first-child {
  min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
}
.asg-hint { font-size: 0.82rem; color: var(--muted); margin: 10px 0 0; line-height: 1.45; }

/* Roster of assignees: a stack of self-contained student cards. */
.asg-roster { display: flex; flex-direction: column; gap: 12px; }
.asg-card {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 15px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 18px;
  padding: 15px 18px;
  box-shadow: 0 1px 3px rgba(60, 40, 20, 0.05);
  transition: transform 0.16s ease, box-shadow 0.16s ease;
  position: relative;
}
.asg-card:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
.asg-ava {
  width: 46px; height: 46px; flex-shrink: 0;
  border-radius: 50%;
  background: var(--primary-light);
  color: var(--primary-dark);
  display: grid; place-items: center;
  font-family: var(--font-display);
  font-size: 1.25rem; font-weight: 700;
}
.asg-body { min-width: 0; }
.asg-name {
  color: var(--text); line-height: 1.2;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.asg-mail {
  color: var(--muted); font-size: 0.84rem; margin-top: 1px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.asg-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 7px; }
.asg-dot { display: inline-flex; align-items: center; gap: 6px; font-size: 0.78rem; font-weight: 600; color: var(--muted); }
.asg-dot::before { content: ""; width: 7px; height: 7px; border-radius: 50%; background: var(--muted); }
.asg-dot.linked { color: var(--green-dark); }
.asg-dot.linked::before { background: var(--green); }
.asg-dot.unlinked { color: var(--gold-dark); }
.asg-dot.unlinked::before { background: var(--gold); }
.asg-when { font-size: 0.78rem; color: var(--muted); }

/* The star of the card: a ring that fills to the student's score, colour-coded,
   drawing in on load. Links through to their answers. "Not started" is a quiet
   dashed ring instead. */
.asg-ring {
  position: relative;
  width: 58px; height: 58px;
  display: grid; place-items: center;
  text-decoration: none; flex-shrink: 0;
}
.asg-ring svg { width: 58px; height: 58px; transform: rotate(-90deg); }
.asg-ring circle { fill: none; stroke-width: 5; }
.asg-ring .ring-bg { stroke: var(--surface-2); }
.asg-ring .ring-fg {
  stroke-linecap: round;
  stroke-dasharray: 100.53;
  stroke-dashoffset: var(--off, 100.53);
  animation: asg-draw 1s cubic-bezier(0.22, 0.8, 0.24, 1) forwards;
}
@keyframes asg-draw { from { stroke-dashoffset: 100.53; } }
.asg-ring.good .ring-fg { stroke: var(--green); }
.asg-ring.mid  .ring-fg { stroke: var(--gold); }
.asg-ring.low  .ring-fg { stroke: var(--accent); }
.asg-ring-num {
  position: absolute;
   font-size: 0.92rem;
  color: var(--text);
  display: flex; flex-direction: column; align-items: center; line-height: 1;
}
.asg-ring-num small { font-size: 0.55rem; font-weight: 700; letter-spacing: 0.04em;
  text-transform: uppercase; color: var(--muted); margin-top: 2px; }
/* Not started: a calm, solid faint ring with a single muted dash -- the empty
   state, not a busy dashed spinner. The words "Not started" live in the card
   body as a quiet pill instead of being crammed two-up inside the ring. */
.asg-ring.todo .ring-bg { stroke: var(--border); }
.asg-ring.todo .asg-ring-num { color: var(--muted); font-size: 1.35rem; font-weight: 600; }
.asg-ring:hover .ring-fg { filter: saturate(1.2); }

.asg-pill-todo {
  font-size: 0.74rem;
  font-weight: 700;
  color: var(--muted);
  background: var(--surface-2);
  padding: 2px 9px;
  border-radius: 999px;
  letter-spacing: 0.01em;
}

/* Unassign: a quiet corner control, not a red word in a column. */
.asg-x {
  position: absolute; top: 10px; right: 12px;
  line-height: 0;
}
.asg-x button {
  border: 0; background: transparent; cursor: pointer;
  color: var(--muted); font-size: 1.05rem; line-height: 1;
  padding: 4px; border-radius: 8px;
  opacity: 0.45; transition: opacity 0.15s ease, color 0.15s ease, background 0.15s ease;
}
.asg-card:hover .asg-x button { opacity: 1; }
.asg-x button:hover { color: var(--accent); background: var(--accent-light); }

.asg-empty { text-align: center; color: var(--muted); padding: 30px 16px; }

@media (max-width: 520px) {
  .asg-card { gap: 12px; padding: 14px 15px; grid-template-columns: auto 1fr auto; }
  .asg-ring, .asg-ring svg { width: 52px; height: 52px; }
  .asg-x { top: 8px; right: 9px; }
  .asg-x button { opacity: 1; }
  /* Stack the assigner so the select gets full width -- no cramped, ellipsised
     placeholder squeezed beside the button. */
  .asg-row { flex-direction: column; }
  .asg-row .btn { width: 100%; }
}
@media (prefers-reduced-motion: reduce) {
  .asg-ring .ring-fg { animation: none; }
  .asg-pane.on { animation: none; }
  .asg-card { transition: none; }
}

/* ---------- Lesson schedule (lessons_admin.html) --------------------------
   Rebuilt 2026-08-03. Was raw native checkboxes wrapped in a row, an unstyled
   grey Save button, and grey "not linked" text -- no cue what the page did or
   how to use it (Duke: "I don't even know how to use it"). Now a three-step
   how-it-works strip up top, and each calendar series is a card that states
   its own live/hidden status, names itself for students, and offers the
   accounts as selectable chips instead of bare checkboxes. */
.lsn-steps {
  display: flex;
  flex-wrap: wrap;
  gap: 12px 26px;
  margin: 16px 0 22px;
}
.lsn-step { display: flex; align-items: center; gap: 10px; }
.lsn-step-n {
  flex-shrink: 0;
  width: 25px;
  height: 25px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--primary);
  color: #fff;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.82rem;
}
.lsn-step-t { color: var(--muted); font-size: 0.9rem; font-weight: 600; }

.lsn-saved {
  background: var(--green-light);
  color: var(--green-dark);
  font-weight: 700;
  padding: 12px 18px;
  border-radius: 12px;
  margin-bottom: 16px;
}

.lsn-card { padding: 20px 22px; margin-bottom: 16px; }
.lsn-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 18px;
  padding-bottom: 15px;
  border-bottom: 1px solid var(--border);
}
.lsn-title { margin: 0 0 4px; font-size: 1.1rem; }
.lsn-when { color: var(--muted); font-size: 0.85rem; }
.lsn-status {
  flex-shrink: 0;
  white-space: nowrap;
  font-weight: 700;
  font-size: 0.76rem;
  padding: 4px 12px;
  border-radius: 999px;
}
.lsn-status.on { background: var(--green-light); color: var(--green-dark); }
.lsn-status.off { background: var(--surface-2); color: var(--muted); }

.lsn-field { margin-bottom: 18px; }
.lsn-flabel {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--muted);
  margin-bottom: 10px;
}
.lsn-fn {
  flex-shrink: 0;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: var(--primary-light);
  color: var(--primary-dark);
  font-size: 0.72rem;
  font-weight: 800;
  letter-spacing: 0;
}
.lsn-input { max-width: 460px; }

.lsn-accounts { display: flex; flex-wrap: wrap; gap: 9px; }
.lsn-account {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 9px;
  padding: 6px 14px 6px 6px;
  border-radius: 999px;
  border: 1.5px solid var(--border);
  background: var(--card);
  cursor: pointer;
  user-select: none;
  transition: border-color 0.15s, background 0.15s;
}
.lsn-account input { position: absolute; opacity: 0; width: 0; height: 0; }
.lsn-account:hover { border-color: var(--muted); }
.lsn-account:has(input:checked) { border-color: var(--primary); background: var(--primary-light); }
.lsn-account:has(input:focus-visible) { box-shadow: 0 0 0 3px var(--coral-glow); }
.lsn-acc-avatar {
  flex-shrink: 0;
  width: 30px;
  height: 30px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--surface-2);
  color: var(--muted);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.85rem;
}
.lsn-account:has(input:checked) .lsn-acc-avatar { background: var(--primary); color: #fff; }
.lsn-acc-text { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; }
.lsn-acc-name { font-weight: 700; font-size: 0.86rem; color: var(--text); }
.lsn-acc-email { font-size: 0.72rem; color: var(--muted); font-family: var(--font-mono); }
.lsn-acc-check {
  flex-shrink: 0;
  color: var(--primary);
  opacity: 0;
  transform: scale(0.6);
  transition: opacity 0.15s, transform 0.15s;
}
.lsn-account:has(input:checked) .lsn-acc-check { opacity: 1; transform: scale(1); }

.lsn-actions { display: flex; align-items: center; gap: 12px; }
.lsn-synced { font-size: 0.82rem; margin-top: 4px; }

/* ---------- Admin students list: engagement at a glance ---------- */
.streak-chip { font-weight: 700; white-space: nowrap; }
/* Colour-coded so a quiet student is visible while scanning the column,
   without having to read every number. Text still carries the meaning --
   colour is reinforcement, not the only signal. */
.last-active { font-weight: 600; white-space: nowrap; }
.last-active.ok { color: var(--green-dark); }
.last-active.warm { color: var(--gold-dark); }
.last-active.cold { color: var(--accent); }

/* Student roster as tap-through cards (2026-08-04). Was the admin-table above,
   which on a phone became an eight-row NAME/LEVEL/TEACHER/... caps-label stack
   per student. Now one card per student matching the assign roster and the
   responses list: avatar, name, level/teacher/tests meta, engagement chips
   (linked, streak, last-active), average score pill hard right. */
.stu-list { display: flex; flex-direction: column; gap: 10px; }
.stu-row {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 14px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 16px;
  padding: 13px 16px;
  text-decoration: none;
  color: inherit;
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.stu-row:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
.stu-row-ava {
  width: 44px; height: 44px; flex-shrink: 0;
  border-radius: 50%;
  background: var(--primary-light);
  color: var(--primary-dark);
  display: grid; place-items: center;
  font-family: var(--font-display);
  font-size: 1.2rem; font-weight: 700;
}
.stu-row-body { min-width: 0; }
.stu-row-name {
    color: var(--text); line-height: 1.2;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stu-row-meta {
  color: var(--muted); font-size: 0.83rem; margin-top: 2px;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.stu-row-chips { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin-top: 8px; }
.stu-row-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.stu-row .perf-score { font-size: 0.95rem; padding: 5px 11px; }
.stu-row-noavg { color: var(--muted); font-weight: 700; }
.stu-row-arrow { color: var(--muted); font-size: 1.05rem; transition: transform 0.15s ease; }
.stu-row:hover .stu-row-arrow { transform: translateX(3px); color: var(--primary-dark); }
@media (max-width: 520px) {
  .stu-row { gap: 12px; padding: 12px 14px; }
  .stu-row-arrow { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .stu-row { transition: none; }
  .stu-row:hover { transform: none; }
}

/* ---------- Accounts (registered flashcard users) -------------------------
   accounts.html reuses .stu-row; account_detail.html reuses .stu-head/.stu-kpi.
   These are the account-specific bits: status badges, the card-count stat, and
   the detail page's field list, deck and manage panel. */
.acct-badge { font-size: 0.72rem; font-weight: 700; padding: 3px 10px; border-radius: 999px; white-space: nowrap; }
.acct-badge.suspended { background: var(--accent-light); color: var(--accent); }
.acct-badge.provisional { background: var(--gold-light, #f7eeda); color: var(--gold-dark, #8a6112); }
.acct-badge.student { background: var(--green-light); color: var(--green-dark); }
.stu-row.is-suspended { opacity: 0.72; }
.acct-cards {
  display: inline-flex; align-items: center; gap: 5px;
  font-size: 0.9rem; font-weight: 700; color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.acct-cards svg { width: 15px; height: 15px; opacity: 0.85; }

.acct-fields { display: flex; flex-direction: column; gap: 10px; }
.acct-fields > div { display: flex; gap: 14px; align-items: baseline; }
.acct-field-k { flex: 0 0 96px; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; color: var(--muted); font-weight: 700; }
.acct-field-v { color: var(--text); min-width: 0; word-break: break-word; }
.acct-linked {
  display: flex; align-items: center; justify-content: space-between; gap: 14px; flex-wrap: wrap;
  margin-top: 14px; padding-top: 14px; border-top: 1px solid var(--border);
}
.acct-linked strong { margin-left: 10px; color: var(--text); }

.acct-deck { display: flex; flex-direction: column; }
.acct-card-row {
  display: grid; grid-template-columns: auto auto 1fr; gap: 12px; align-items: baseline;
  padding: 9px 2px; border-bottom: 1px solid var(--border);
}
.acct-card-row:last-child { border-bottom: none; }
.acct-card-hz { font-size: 1.1rem; font-weight: 600; color: var(--text); }
.acct-card-py { font-size: 0.85rem; color: var(--primary-dark); white-space: nowrap; }
.acct-card-def { font-size: 0.88rem; color: var(--muted); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Compact: this is a secondary panel, not a feature. Small heading, small
   buttons, one-line hints beside them -- not full-width buttons over paragraphs. */
.acct-manage { padding: 16px 18px; }
.acct-manage h3 { margin: 0 0 12px; font-size: 1rem; }
.acct-manage-actions { display: flex; flex-direction: column; gap: 10px; }
.acct-manage-item { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.acct-manage-item form { flex-shrink: 0; }
.btn-compact { padding: 8px 16px; font-size: 0.88rem; min-width: 118px; }
.acct-manage-hint { flex: 1; min-width: 150px; font-size: 0.78rem; color: var(--muted); margin: 0; line-height: 1.4; }

/* ---------- Tests list as cards (list.html, 2026-08-04) --------------------
   The main /teach table was the last admin-table. On a phone it became the
   STUDENT/TEST/CREATED/RESPONSES/AVG SCORE/ACTIONS caps-label stack. Now a card
   per test matching the rest of the admin: title-glyph avatar, title, student +
   date + responses meta, the action buttons, and the average-score pill hard
   right. Column-sort (was header clicks) is replaced by the sort dropdown. */
/* Toolbar: count on the left, a real dropdown pill on the right, so the row
   reads as a balanced list header rather than a lone raw <select>. */
.tst-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.tst-count { font-size: 0.85rem; font-weight: 600; color: var(--muted); }
.tst-sort { display: inline-flex; align-items: center; gap: 8px; font-size: 0.85rem; color: var(--muted); font-weight: 600; }
.tst-sort select {
  appearance: none; -webkit-appearance: none;
  width: auto; min-width: 0;
  margin: 0;
  padding: 7px 32px 7px 14px;
  font-family: var(--font-sans); font-size: 0.85rem; font-weight: 600;
  color: var(--text);
  background-color: var(--card);
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8' fill='none'%3E%3Cpath d='M1.5 1.75 6 6.25l4.5-4.5' stroke='%23ff8563' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 12px center;
  border: 1px solid var(--border); border-radius: 999px;
  cursor: pointer;
  transition: border-color 0.15s ease, box-shadow 0.15s ease;
}
.tst-sort select:hover { border-color: var(--primary-light); }
.tst-sort select:focus { outline: none; border-color: var(--primary); box-shadow: 0 0 0 3px var(--coral-glow); }

/* The tests list is cards, not a wide data table, so it does not want the
   1200px admin width -- at full width each row became a long thin bar with a
   dead gap in its middle. Cap it to a comfortable column so title-left /
   actions-right sit close, and everything on the page (title, search, list)
   stays aligned. Higher specificity than the body:has(.admin-nav) rule above,
   so it wins. */
body.teach-narrow:has(.admin-nav) .container { max-width: 960px; }

/* One row per test: identity on the left; score + responses + actions grouped
   on the right. Single text line, but with enough vertical padding that the
   row reads as a card, not a squashed bar. */
.tst-list { display: flex; flex-direction: column; gap: 10px; }
.tst-card {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: 8px 12px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 14px;
  padding: 16px 16px;
  box-shadow: 0 1px 3px rgba(60, 40, 20, 0.05);
  transition: box-shadow 0.15s ease, border-color 0.15s ease;
}
.tst-card:hover { box-shadow: 0 8px 20px -12px rgba(60,40,20,0.35); border-color: var(--primary-light); }
.tst-ava {
  flex-shrink: 0;
  width: 40px; height: 40px;
  border-radius: 12px;
  background: var(--primary-light);
  color: var(--primary-dark);
  display: grid; place-items: center;
  font-family: var(--font-display);
  font-size: 1.15rem; font-weight: 700;
}
/* Title and meta share one line -- "课后作业 · Василина · 2026-07-31" -- so the
   row is a single text line tall, not two. Title truncates first; the meta
   never wraps under it and doubles the height. */
.tst-body {
  flex: 1 1 200px; min-width: 0;
  display: flex; align-items: baseline; gap: 8px;
}
.tst-title {
    color: var(--text); line-height: 1.2;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  flex: 0 1 auto; min-width: 0;
}
.tst-meta { color: var(--muted); font-size: 0.8rem; line-height: 1.2; white-space: nowrap; flex: none; }
.tst-student { color: var(--primary-dark); font-weight: 600; text-decoration: none; }
.tst-student:hover { text-decoration: underline; }
.tst-right { display: flex; align-items: center; gap: 9px; flex-shrink: 0; }
.tst-right .perf-score { font-size: 0.82rem; padding: 2px 9px; }
/* Response count as a small stat with an icon, not trailing meta text. */
.tst-responses {
  display: inline-flex; align-items: center; gap: 4px;
  font-size: 0.82rem; font-weight: 700; color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.tst-responses svg { width: 14px; height: 14px; opacity: 0.85; }
.tst-actions { display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
.tst-actions .action-link { padding: 4px 10px; font-size: 0.78rem; }
@media (max-width: 680px) {
  /* Keep the avatar beside the title (don't orphan it on its own line);
     only the action chips drop to a full-width row of their own. */
  .tst-right { order: 4; }
  .tst-actions { order: 5; width: 100%; }
}
@media (prefers-reduced-motion: reduce) { .tst-card { transition: none; } }

/* ---------- Student's own answer review (test_review.html) ---------- */
/* ---------- Student's own answer review (rebuilt 2026-07-31) ----------
   The page a student actually learns from, and the rawest screen left in the
   product (Duke, from a phone). Three things were wrong with it:

     - the pinyin was set in bold coral at weight 800, so on a page that is
       mostly Chinese the pronunciation shouted louder than the characters;
     - the score, the one fact they open the page for, was a sentence
       ("Правильно 3 из 5") in muted grey;
     - the correct answer for a typed blank printed the whole `accepted`
       list -- "起 / qi / qǐ" -- which is our marking tolerance, not the
       answer, and reads as three different things to learn.

   Structure now: an editorial header carrying the mark, then one card per
   question whose verdict is the first thing in it, then your answer, then
   the right answer, then anything the teacher wrote. */

.qr-head { max-width: 620px; margin: 4px auto 26px; text-align: center; }
.qr-eyebrow {
  margin: 0 0 8px;
  color: var(--pr-eyebrow);
}
.qr-test-name {
  margin: 0;
  line-height: 1.15;
}
/* The mark, as the same ring the tests list uses. */
.qr-score {
  display: inline-flex; align-items: center; gap: 14px;
  margin: 20px 0 0;
}
.qr-score .score-ring { width: 64px; height: 64px; }
.qr-score .score-ring::before { inset: 5px; background: var(--stage-bg); }
.qr-score .score-ring b { font-size: 1.16rem; }
.qr-score-text { display: flex; flex-direction: column; align-items: flex-start; gap: 1px; }
.qr-score-text b {
  font-family: var(--font-display); font-weight: 700;
  font-size: 1.35rem; letter-spacing: -0.02em; line-height: 1;
}
.qr-score-text i {
  font-style: normal; 
   color: var(--pr-quiet);
}

/* ---- The scorecard strip ----
   Every question as one mark, in order. The whole result at a glance and a
   jump to any question -- reading five cards to find which two went wrong is
   work the page should do for the student. */
.qr-strip {
  display: flex; flex-wrap: wrap; justify-content: center; gap: 7px;
  margin: 20px 0 0;
}
.qr-pip {
  width: 30px; height: 30px; border-radius: 10px;
  display: inline-flex; align-items: center; justify-content: center;
  font-family: var(--font-sans);
  font-size: 0.78rem; font-weight: 700; text-decoration: none;
  border: 1px solid transparent;
  transition: transform 0.16s cubic-bezier(0.2, 0.8, 0.3, 1), box-shadow 0.16s ease;
  animation: rvPipIn 0.4s cubic-bezier(0.3, 1.5, 0.5, 1) backwards;
}
.qr-pip:nth-child(1) { animation-delay: 0.30s; }
.qr-pip:nth-child(2) { animation-delay: 0.36s; }
.qr-pip:nth-child(3) { animation-delay: 0.42s; }
.qr-pip:nth-child(4) { animation-delay: 0.48s; }
.qr-pip:nth-child(n+5) { animation-delay: 0.54s; }
@keyframes rvPipIn { from { opacity: 0; transform: translateY(6px) scale(0.7); } }
.qr-pip:hover { transform: translateY(-2px); box-shadow: 0 6px 14px -6px rgba(94, 58, 20, 0.45); }
.qr-pip.ok   { background: var(--green-light); color: var(--green-dark); border-color: rgba(74, 217, 145, 0.45); }
.qr-pip.bad  { background: var(--danger-wash); color: var(--accent); border-color: rgba(176, 92, 74, 0.4); }
.qr-pip.open { background: var(--gold-light); color: var(--gold-dark); border-color: rgba(240, 180, 41, 0.45); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .qr-pip.ok { background: rgba(74, 217, 145, 0.14); }
  :root:not([data-theme="light"]) .qr-pip.bad { background: rgba(217, 141, 120, 0.14); }
  :root:not([data-theme="light"]) .qr-pip.open { background: rgba(240, 180, 41, 0.14); }
}
/* Jumping to a question shouldn't drop it under the top edge. */
.qr-q { scroll-margin-top: 18px; }

@media (prefers-reduced-motion: reduce) {
  .qr-anim .qr-q { opacity: 1; transform: none; transition: none; }
  .qr-pip, .qr-stamp i { animation: none; }
  .qr-pip:hover { transform: none; }
}
.qr-lead {
  margin: 16px 0 0;
   line-height: 1.5; color: var(--pr-quiet);
}

/* A rotating line of encouragement under the score, tier-coloured. Same voice
   as the release email so a result reads the same in the inbox and on the
   page. Tier colours are never near-black, safe from any auto-darkening. */
.qr-praise {
  width: fit-content; margin: 16px auto 0; padding: 7px 18px;
  border-radius: 999px; font-weight: 800; font-size: 0.92rem; text-align: center;
}
.qr-praise.tier-hi  { background: var(--green-light); color: var(--green-dark); }
.qr-praise.tier-mid { background: var(--gold-light); color: var(--gold-dark); }
.qr-praise.tier-lo  { background: var(--accent-light); color: var(--accent-dark); }

/* The focus banner: the words a student missed, one tap from their deck.
   Logged-in only (the template gates it), so this never dangles a word list
   at a visitor who'd have to sign up to use it. */
.qr-focus {
  max-width: 620px; margin: 22px auto 0;
  display: flex; align-items: center; gap: 16px;
  background: var(--card); border: 1px solid var(--border); border-radius: 18px;
  padding: 15px 18px;
  box-shadow: 0 1px 2px rgba(94, 58, 20, 0.05), 0 16px 32px -26px rgba(94, 58, 20, 0.6);
}
.qr-focus-ic {
  width: 44px; height: 44px; border-radius: 12px; flex-shrink: 0;
  display: grid; place-items: center; font-size: 1.35rem;
  background: var(--primary-light); color: var(--primary-dark);
}
.qr-focus-body { flex: 1; min-width: 0; }
.qr-focus-t { margin: 0; font-weight: 800; font-size: 0.98rem; color: var(--text); }
.qr-focus-words { margin-top: 7px; display: flex; gap: 6px; flex-wrap: wrap; }
.qr-wchip {
  font-family: var(--font-hanzi); font-weight: 700; font-size: 0.95rem;
  padding: 4px 11px; border-radius: 9px; background: var(--surface-2); color: var(--text);
  display: inline-flex; align-items: baseline; gap: 6px;
}
.qr-wchip i { font-style: normal; font-family: var(--font-sans); font-weight: 700;
  font-size: 0.74rem; color: var(--primary-dark); }
.qr-focus-form { flex-shrink: 0; margin: 0; }
.qr-focus-cta {
  display: inline-block; flex-shrink: 0; cursor: pointer; border: none;
  background: linear-gradient(135deg, var(--primary), var(--primary-dark)); color: #fff;
  border-radius: 12px; padding: 12px 18px; font-weight: 800; font-size: 0.88rem;
  font-family: inherit; text-decoration: none; white-space: nowrap;
  box-shadow: 0 6px 16px -6px rgba(207, 116, 88, 0.55); transition: transform 0.12s ease;
}
.qr-focus-cta:hover { transform: translateY(-1px); }
.qr-focus-cta.is-done { background: var(--green-light); color: var(--green-dark); box-shadow: none; cursor: default; }
@media (max-width: 560px) {
  .qr-focus { flex-wrap: wrap; }
  .qr-focus-form, .qr-focus-cta { flex-basis: 100%; text-align: center; }
}

/* Fold a card by its head. A right answer opens folded to a one-line peek so
   the wrong ones -- the ones worth reading -- carry the page. */
.qr-band { cursor: pointer; }
.qr-band:hover { filter: brightness(0.985); }
.qr-peek {
  margin-left: auto; font-family: var(--font-hanzi); color: var(--muted);
  font-size: 0.95rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 46%;
}
.qr-chev { color: var(--muted); transition: transform 0.2s ease; flex-shrink: 0; font-size: 0.9rem; }
.qr-q:not(.is-collapsed) .qr-peek { display: none; }
.qr-q:not(.is-collapsed) .qr-chev { margin-left: auto; transform: rotate(180deg); }
.qr-q.is-collapsed .qr-chev { margin-left: 10px; }
.qr-q.is-collapsed .qr-q-body { display: none; }
.qr-q.is-collapsed .qr-band { border-bottom: none; }
@media (prefers-reduced-motion: reduce) { .qr-chev, .qr-focus-cta { transition: none; } }

/* ---- One question, as a marked answer sheet ----
   The cut before this set question, answer and key as three label/value rows
   divided by 1px rules, which on a phone read as one grey column with no
   shape to it (Duke: "each section isn't very clear"). Structure carries the
   meaning now: a coloured band stamps the verdict across the top, and the
   answers sit in tinted panels you can tell apart without reading a word. */
.qr-q {
  max-width: 620px; margin: 0 auto 16px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 20px;
  overflow: hidden;
  box-shadow: 0 1px 2px rgba(94, 58, 20, 0.05), 0 16px 32px -26px rgba(94, 58, 20, 0.6);
  transition: opacity 0.5s ease, transform 0.5s cubic-bezier(0.2, 0.9, 0.3, 1);
}
/* Cards rise as they come into view. The hidden state is applied by the
   script (rv-anim) rather than sitting in the stylesheet: if the observer
   never runs -- old browser, script error, JS off -- the default is a
   student seeing their marked work, not a blank page. */
.qr-anim .qr-q { opacity: 0; transform: translateY(14px); }
.qr-anim .qr-q.in { opacity: 1; transform: none; }

/* The band. Tinted by verdict, so the answer to "how did I do on this one"
   is the shape and colour of the card before any text is read. */
.qr-band {
  display: flex; flex-wrap: wrap; align-items: center; gap: 10px;
  padding: 11px 18px;
  border-bottom: 1px solid var(--border);
}
.qr-q.is-ok   .qr-band { background: var(--green-light); border-bottom-color: rgba(74, 217, 145, 0.35); }
.qr-q.is-bad  .qr-band { background: var(--danger-wash); border-bottom-color: rgba(176, 92, 74, 0.32); }
.qr-q.is-open .qr-band { background: var(--gold-light); border-bottom-color: rgba(240, 180, 41, 0.35); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .qr-q.is-ok   .qr-band { background: rgba(74, 217, 145, 0.12); }
  :root:not([data-theme="light"]) .qr-q.is-bad  .qr-band { background: rgba(217, 141, 120, 0.13); }
  :root:not([data-theme="light"]) .qr-q.is-open .qr-band { background: rgba(240, 180, 41, 0.12); }
}
.qr-q-num {
  font-size: 0.9rem; line-height: 1;
  width: 26px; height: 26px;
  display: inline-flex; align-items: center; justify-content: center;
  border-radius: 9px;
  background: var(--card); color: var(--muted);
  box-shadow: 0 1px 3px rgba(94, 58, 20, 0.12);
}
/* The verdict reads as something pressed onto the paper. */
.qr-stamp {
  display: inline-flex; align-items: center; gap: 6px;
  font-family: var(--font-sans);
  font-size: 0.82rem; font-weight: 700; letter-spacing: 0.01em;
}
.qr-stamp i {
  font-style: normal; font-size: 0.9rem; font-weight: 700;
  width: 20px; height: 20px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  color: #fff;
  animation: rvStampPop 0.42s cubic-bezier(0.3, 1.6, 0.5, 1) backwards;
}
.qr-q.in .qr-stamp i { animation-delay: 0.12s; }
@keyframes rvStampPop { from { opacity: 0; transform: scale(0.3) rotate(-25deg); } }
.qr-stamp.ok      { color: var(--green-dark); }
.qr-stamp.ok i    { background: var(--green-dark); }
.qr-stamp.bad     { color: var(--accent); }
.qr-stamp.bad i   { background: var(--accent); }
.qr-by-teacher {
  margin-left: auto;
  font-family: var(--font-sans);
  font-size: 0.72rem; font-weight: 700; color: var(--gold-dark);
}

.qr-q-body { padding: 16px 18px 18px; }
.qr-q-title {
   line-height: 1.8; color: var(--text);
  overflow-wrap: anywhere;
}

/* The pinyin steps back. It is a reading aid on a page whose subject is the
   characters; in bold coral it was the loudest ink on the screen. Scoped to
   this page so the take/answer flow is untouched. */
.qr-stage .qr-q .zi-py,
.qr-stage .qr-panel .zi-py {
  color: var(--muted);
  font-weight: 600;
  opacity: 0.9;
}

/* ---- Your answer, and the right one, as two things you can tell apart ----
   Side by side where there is room, stacked on a phone, each in its own
   tinted box. A student should be able to see "mine / correct" without
   reading the labels at all. */
.qr-answers {
  display: grid; gap: 10px;
  margin-top: 16px;
}
@media (min-width: 560px) {
  .qr-answers:has(.qr-panel + .qr-panel) { grid-template-columns: 1fr 1fr; }
}
.qr-panel {
  display: flex; flex-direction: column; gap: 5px;
  padding: 12px 14px;
  border-radius: 14px;
  background: var(--surface-2);
  border: 1px solid transparent;
}
.qr-panel.is-ok  { background: var(--green-light); border-color: rgba(74, 217, 145, 0.4); }
.qr-panel.is-bad { background: var(--danger-wash); border-color: rgba(176, 92, 74, 0.35); }
.qr-panel.is-key { background: var(--green-light); border-color: rgba(74, 217, 145, 0.4); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .qr-panel.is-ok, :root:not([data-theme="light"]) .qr-panel.is-key { background: rgba(74, 217, 145, 0.11); }
  :root:not([data-theme="light"]) .qr-panel.is-bad { background: rgba(217, 141, 120, 0.12); }
}
.qr-panel-label {
  color: var(--muted);
}
.qr-panel.is-ok .qr-panel-label,
.qr-panel.is-key .qr-panel-label { color: var(--green-dark); }
.qr-panel.is-bad .qr-panel-label { color: var(--accent); }
.qr-panel-value { font-size: 1.05rem; line-height: 1.55; overflow-wrap: anywhere; }
.qr-panel.is-key .qr-panel-value { color: var(--green-dark); font-weight: 600; }
.qr-panel-value em { color: var(--muted); font-size: 0.9rem; font-style: normal; }
.qr-open {
  margin: 14px 0 0;
   color: var(--pr-quiet);
}

.qr-pairs { display: flex; flex-direction: column; gap: 5px; margin-top: 2px; }
.qr-pair { padding: 5px 10px; border-radius: 8px; background: rgba(255, 255, 255, 0.55); }
.qr-pair.ok  { color: var(--green-dark); }
.qr-pair.bad { color: var(--accent); }

/* ---- What the teacher wrote ----
   A person talking, not another field on a form: it hangs off the left like
   a note in the margin, with a quote mark set into the corner. */
.qr-note {
  position: relative;
  max-width: 620px; margin: 16px auto 0;
  display: flex; flex-direction: column; gap: 4px;
  padding: 14px 16px 14px 18px;
  border-left: 3px solid var(--gold);
  border-radius: 0 14px 14px 0;
  background: var(--gold-light);
}
.qr-note::before {
  content: "\201C";
  position: absolute; right: 12px; top: 2px;
  font-family: var(--font-display); font-size: 2.4rem; line-height: 1;
  color: var(--gold); opacity: 0.35;
}
.qr-q .qr-note { margin: 16px 0 0; }
.qr-note-general { margin-bottom: 20px; }
.qr-note-label {
  color: var(--gold-dark);
}
.qr-note-body { font-size: 1rem; line-height: 1.55; color: var(--text); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .qr-note { background: rgba(240, 180, 41, 0.1); }
  :root:not([data-theme="light"]) .qr-pair { background: rgba(255, 255, 255, 0.06); }
}

.qr-passage {
  max-width: 620px; margin: 0 auto 14px;
  padding: 16px 18px;
  background: var(--surface-2);
  border-radius: 16px;
}

.qr-foot { max-width: 620px; margin: 24px auto 0; text-align: center; }
.qr-back {
  font-family: var(--font-sans);
  font-size: var(--t-label); font-weight: 600; text-decoration: none;
  color: var(--primary-dark); padding: 10px 16px;
}
.qr-back:hover { color: var(--text); }

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .qr-note { background: rgba(240, 180, 41, 0.1); }
  :root:not([data-theme="light"]) .qr-q { box-shadow: 0 10px 26px -22px rgba(0, 0, 0, 0.8); }
}

/* Item-analysis flags on the responses page */
.ia-flag { display: inline-block; font-size: 0.72rem; font-weight: 800; padding: 3px 9px; border-radius: 99px; white-space: nowrap; }
.ia-ambiguous { background: var(--accent-light); color: var(--accent); }
.ia-weak { background: var(--gold-light, #f7eeda); color: var(--gold-dark, #8a6112); }
.ia-too_hard { background: var(--accent-light); color: var(--accent); }
.ia-too_easy { background: var(--surface-2); color: var(--muted); }

/* "Under review" state on the student results screen */
.pending-stamp { color: var(--gold-dark, #8a6112); border-color: var(--gold, #e3b23c); }
.pending-badge { display: inline-block; font-size: 0.72rem; font-weight: 800; text-transform: uppercase;
  letter-spacing: 0.06em; padding: 4px 12px; border-radius: 99px;
  background: var(--gold-light, #f7eeda); color: var(--gold-dark, #8a6112); margin: 0 0 10px; }

/* Per-question manual verdict controls (admin response review) */
.ov-form { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; margin-top: 12px;
  padding-top: 10px; border-top: 1px dashed var(--border); }
.ov-btn { font-size: 0.75rem; font-weight: 700; padding: 4px 11px; border-radius: 99px;
  border: 1px solid var(--border); background: var(--card); color: var(--muted); cursor: pointer; }
.ov-btn:hover { border-color: var(--muted); color: var(--text); }
.ov-btn.ov-ok:hover { border-color: var(--green); color: var(--green-dark); }
.ov-btn.ov-bad:hover { border-color: var(--accent); color: var(--accent); }
.ov-manual { font-size: 0.72rem; font-weight: 800; text-transform: uppercase; letter-spacing: 0.04em;
  color: var(--gold-dark, #8a6112); background: var(--gold-light, #f7eeda);
  padding: 3px 9px; border-radius: 99px; margin-right: 4px; }

.nc-test-pending { font-size: 0.78rem; font-weight: 800; padding: 6px 14px; border-radius: 99px;
  background: var(--gold-light, #f7eeda); color: var(--gold-dark, #8a6112); white-space: nowrap; }

/* ============ Teacher review workspace (response_detail.html) ============
   Rebuilt 2026-07-26. Previously each question rendered every option as a
   full-width row, so one submission ran to ~10,000px with no way to find the
   answers needing attention. This layout is built for triage: dense rows,
   answered-vs-expected instead of the whole option list, and a filter. */
/* Back to 1140 (2026-08-05, same day it went to 960). Narrowing was the wrong
   answer to the empty right-hand side of a card: the space wasn't surplus,
   it was unused. The card now spends it on the answer panel, and that panel
   needs the room. */
/* One reading column, not the full 1140px. The old width let every card
   stretch edge to edge, so a one-character answer sat a foot away from its
   question with a river of empty white between them (Duke, 2026-08-05: "the
   block is weirdly long, leaving white empty spaces"). Pulling the column in
   closes that gap and lines the answers up where the eye can catch them. */
/* The header bar and admin strip get a comfortable width; the question list
   is reined in below. 1140 let the cards stretch; 820 as one width squashed
   the header and strip (Duke, 2026-08-05). Keeping the container wide and the
   list narrow gives both what they want. */
body.review-stage .container { max-width: 1080px; }

.rv-shell { padding-bottom: 60px; }

.rv-bar {
  position: sticky; top: 0; z-index: 20;
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
  flex-wrap: wrap; padding: 14px 18px; margin: 0 0 18px;
  background: var(--card); border: 1px solid var(--border); border-radius: 14px;
  box-shadow: 0 2px 10px rgba(0,0,0,.05);
}
.rv-bar-id { display: flex; align-items: center; gap: 12px; min-width: 0; }
.rv-bar-who { min-width: 0; }
.rv-back { font-size: var(--t-title); color: var(--muted); text-decoration: none; line-height: 1;
  width: 34px; height: 34px; border-radius: 10px; display: inline-flex; align-items: center;
  justify-content: center; flex-shrink: 0; transition: background .15s ease, color .15s ease; }
.rv-back:hover { color: var(--text); background: var(--surface-2); }
.rv-test { font-size: var(--t-caption); color: var(--muted); }
.rv-bar-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.rv-score {   font-size: 1.25rem;
  font-variant-numeric: tabular-nums; padding: 4px 12px; border-radius: 10px; }
.rv-score .of { font-size: .8rem; color: var(--muted); font-weight: 600; }
/* Class-average pill in the responses header: the score component with a small
   uppercase label, so the group's result reads at a glance beside the count. */
.rv-avg { display: inline-flex; align-items: center; gap: 8px; }
.rv-avg-label { font-size: .64rem; text-transform: uppercase; letter-spacing: .07em; color: var(--muted); font-weight: 800; }
.rv-avg .rv-score { font-size: 1.05rem; padding: 3px 11px; }
.rv-score .pct { font-size: .78rem; margin-left: 6px; opacity: .75; }
.rv-score.good { background: var(--green-light); color: var(--green-dark); }
.rv-score.mid  { background: var(--gold-light, #f7eeda); color: var(--gold-dark, #8a6112); }
.rv-score.low  { background: var(--accent-light); color: var(--accent); }
.rv-status { font-size: .72rem; font-weight: 800; text-transform: uppercase; letter-spacing: .05em;
  padding: 4px 10px; border-radius: 99px; }
.rv-status.done { background: var(--green-light); color: var(--green-dark); }
.rv-status.pending { background: var(--gold-light, #f7eeda); color: var(--gold-dark, #8a6112); }
.rv-btn { font-size: .82rem; font-weight: 700; padding: 8px 14px; border-radius: 10px; cursor: pointer;
  border: 1px solid var(--border); background: var(--card); color: var(--text); }
.rv-btn.primary { background: var(--primary); border-color: var(--primary); color: #fff; }
.rv-btn.primary:hover { filter: brightness(1.05); }
.rv-btn.ghost:hover { border-color: var(--muted); }
.rv-flash { background: var(--green-light); color: var(--green-dark); padding: 8px 14px;
  border-radius: 10px; font-size: .85rem; font-weight: 700; margin: 0 0 14px; }

/* The three facts about a submission that aren't answers: who it counts for,
   where the result is sent, and any whole-test comment. Rebuilt 2026-08-05
   (round 4) -- they were two big padded cards plus a comment box that ate ~40%
   of the height above the first question. Now one compact strip: a light
   surface, the three fields inline, each control still saving on change with a
   tick. The attribution and delivery fields flex side by side; the comment
   disclosure takes the full width beneath them. */
.rv-adminstrip {
  display: flex; flex-wrap: wrap; align-items: start; gap: 10px 22px;
  margin-bottom: 18px; padding: 11px 16px;
  background: var(--surface-2); border-radius: 12px;
}
/* The two fields sit two-up on a wide screen and stack on a narrow one. Each
   is label + control on a baseline, with its hint wrapped full-width beneath
   -- bounding the field is what keeps the long delivery hint from stretching
   the field wide enough to shove the other onto its own line. */
.rv-af { flex: 1 1 320px; min-width: 0;
  display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: baseline; gap: 4px 10px; }
.rv-af-lab { grid-column: 1; font-size: var(--t-eyebrow); font-weight: 800;
  text-transform: uppercase; letter-spacing: .06em; color: var(--muted); }
.rv-af-control { grid-column: 2; min-width: 0;
  display: flex; align-items: center; flex-wrap: wrap; gap: 5px 8px; }
/* The comment disclosure takes its own full-width row under the two fields --
   it opens into a textarea, so it can't share a baseline with them. */
.rv-af-note { flex: 1 1 100%; display: block; margin-top: 2px;
  border-top: 1px solid var(--border); padding-top: 8px; }
.rv-assign, .rv-email { display: flex; align-items: center; gap: 8px; }
.rv-assign select, .rv-email input {
  min-width: 0; max-width: 100%; padding: 5px 10px; border-radius: 9px;
  border: 1px solid var(--border); background: var(--card); color: var(--text);
  font-size: var(--t-body-sm); font-family: inherit;
  transition: border-color .15s ease, background .15s ease; }
.rv-email input { width: 200px; }
.rv-assign select:focus, .rv-email input:focus {
  outline: none; background: var(--card); border-color: var(--primary); }
.rv-email input::placeholder { color: var(--muted); opacity: .7; }
/* Picking a name in the select IS the save, so its button is dead weight once
   the script is running -- html.rv-js is set by the page script. The email
   field keeps its button: typing an address has no equivalent "done" moment
   a blur can be trusted to stand for. */
.rv-js .rv-assign .rv-nojs { display: none; }
.rv-email button { flex-shrink: 0; }
.rv-saved { color: var(--green-dark); font-weight: 800; font-size: var(--t-body-sm);
  animation: rvTick .25s ease; }
@keyframes rvTick { from { opacity: 0; transform: scale(.6); } to { opacity: 1; transform: none; } }
.rv-field-hint { margin: 0; font-size: var(--t-caption); color: var(--muted); line-height: 1.4; }
/* In the strip the hint drops to its own line under the control it explains,
   rather than sitting beside it and pushing the field width around. */
.rv-af-control .rv-field-hint { flex: 1 0 100%; }
.rv-field-hint.warn { color: var(--accent); font-weight: 700; }
.rv-delivery-fixed { margin: 0; font-size: var(--t-body-sm); font-weight: 700;
  display: flex; align-items: center; gap: 8px; }
.rv-delivery-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
.rv-busy { opacity: .55; pointer-events: none; }
.rv-form-error input, .rv-form-error select { border-color: var(--accent) !important; }
.rv-warn { font-size: var(--t-caption); color: var(--accent); font-weight: 700; }

/* The question list is a tight reading column, narrower than the full-width
   toolbars above it: a short question plus a two-character answer does not
   want a 940px line. Left edge aligned with everything above. */
.rv-filter, .rv-list { max-width: 1040px; }
.rv-filter { display: flex; gap: 8px; flex-wrap: wrap; margin-bottom: 14px; }
.rv-chip { font-size: .8rem; font-weight: 700; padding: 6px 14px; border-radius: 99px;
  border: 1px solid var(--border); background: var(--card); color: var(--muted); cursor: pointer; }
.rv-chip b { font-variant-numeric: tabular-nums; margin-left: 4px; color: var(--text); }
.rv-chip:hover { border-color: var(--muted); }
.rv-chip.active { background: var(--text); border-color: var(--text); color: #fff; }
.rv-chip.active b { color: #fff; }

.rv-list { display: flex; flex-direction: column; gap: 6px; }
.rv-list[data-filter="wrong"] .rv-q:not([data-state="wrong"]),
.rv-list[data-filter="open"] .rv-q:not([data-state="open"]),
.rv-list[data-filter="overridden"] .rv-q:not([data-overridden]) { display: none; }
.rv-list[data-filter="wrong"] .rv-passage,
.rv-list[data-filter="open"] .rv-passage,
.rv-list[data-filter="overridden"] .rv-passage { display: none; }

/* ============================================================
   The answer ledger (rebuilt 2026-08-05, round 5). Each record is three
   parts across one card: a RAIL (number + verdict token), the QUESTION
   column (calm, reference only), and the ANSWER DOCK -- a tinted panel
   holding the student's work AND the ✓ ✗ ↺ + note controls on one line, so
   there is no empty half-card (Duke circled that dead space on the previous
   shape). Correct rows go quiet; wrong/open keep a coloured edge and stand
   out. The class names are unchanged from the old shape on purpose -- the
   background-save JS keys off .rv-q/data-state/data-qid/.rv-badge/.rv-ov-btn
   and must keep working. */
.rv-q {
  display: grid;
  grid-template-columns: 46px minmax(0, 1fr) minmax(320px, 440px);
  align-items: stretch;
  background: var(--card); border: 1px solid var(--border);
  border-radius: 18px; overflow: hidden;
  box-shadow: 0 1px 2px rgba(60,40,20,.05), 0 6px 16px rgba(60,40,20,.05);
  transition: box-shadow .2s ease, transform .2s ease;
}
@media (prefers-color-scheme: dark) {
  .rv-q { box-shadow: 0 1px 2px rgba(0,0,0,.3), 0 6px 18px rgba(0,0,0,.28); }
}
.rv-q:hover { box-shadow: var(--shadow); transform: translateY(-1px); }

/* ---- rail ---- */
.rv-q-rail { display: flex; flex-direction: column; align-items: center; gap: 8px; padding: 16px 0;
  background: linear-gradient(180deg, var(--surface-2), color-mix(in srgb, var(--surface-2) 55%, var(--card))); }
.rv-q-num { font-family: var(--font-display); font-weight: 700; font-size: 1rem;
  color: var(--muted); font-variant-numeric: tabular-nums; }
/* A gold dot: this verdict was set by hand. Costs no line; gives the rail a
   column to scan. */
.rv-manual { width: 6px; height: 6px; border-radius: 50%; background: var(--gold); display: none; }
.rv-q.overridden .rv-manual { display: block; }
/* The verdict token: a filled disc so a run of records shows a scannable
   column of green ✓ / red ✗ and dashed gold rings for the ones still to
   mark. Reads as status, not a control -- the real buttons are the segmented
   pill in the dock, a different shape entirely. */
.rv-badge { width: 24px; height: 24px; border-radius: 50%; flex-shrink: 0;
  display: inline-flex; align-items: center; justify-content: center;
  font-family: var(--font-display); font-size: .84rem; font-weight: 800; line-height: 1; color: #fff;
  box-shadow: 0 1px 2px rgba(60,40,20,.18); }
.rv-badge.ok  { background: linear-gradient(160deg, var(--green), var(--green-dark)); }
.rv-badge.bad { background: linear-gradient(160deg, var(--accent), var(--accent-dark)); }
.rv-badge.open { background: transparent; border: 2px dashed var(--gold); color: transparent; box-shadow: none; }

/* ---- question column: calm reference ---- */
.rv-q-ask { min-width: 0; padding: 16px 8px 16px 4px; display: flex; flex-direction: column; justify-content: center; }
.rv-q-title { min-width: 0; font-family: var(--font-display); font-weight: 500; font-size: 1.18rem;
  line-height: 1.85; color: var(--text); }
/* Do NOT raise line-height further to space wrapped Chinese: `.zi` is
   vertical-align:bottom, so punctuation on the baseline floats above the
   characters. Room comes from block gaps, not leading. */
.rv-q-meta { margin-top: 10px; display: flex; gap: 9px; align-items: center; flex-wrap: wrap; }
.rv-qtype { font-size: .62rem; font-weight: 800; text-transform: uppercase; letter-spacing: .06em;
  color: var(--muted); background: var(--surface-2); padding: 3px 10px; border-radius: 7px; }
.rv-details summary { font-size: .78rem; font-weight: 600; color: var(--muted); cursor: pointer; }
.rv-details summary:hover { color: var(--text); }
.rv-details[open] { flex-basis: 100%; }
.rv-details-body { display: flex; gap: 6px; flex-wrap: wrap; margin-top: 8px; }
.rv-opt { font-size: .85rem; padding: 3px 11px; border-radius: 8px; background: var(--surface-2); color: var(--muted); }
.rv-opt.ok { background: var(--green-light); color: var(--green-dark); font-weight: 700; }
.rv-opt.picked { outline: 2px solid var(--primary); outline-offset: 1px; }

/* ================= the answer dock: the star ================= */
.rv-ansdock { position: relative; display: flex; flex-direction: column; gap: 9px;
  padding: 15px 18px 15px 22px; border-left: 1px solid var(--border);
  background: radial-gradient(130% 150% at 100% 0,
      color-mix(in srgb, var(--surface-2) 35%, var(--card)), var(--surface-2)); }
.rv-ansdock::before { content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 4px; }
.rv-q.wrong   .rv-ansdock::before { background: var(--accent); }
.rv-q.open    .rv-ansdock::before { background: var(--gold); }
.rv-q.correct .rv-ansdock::before { background: var(--green); }
.rv-eyebrow { font-size: .62rem; font-weight: 800; text-transform: uppercase; letter-spacing: .08em; color: var(--muted); }
.rv-eyebrow.warn { color: var(--gold-dark); }

/* the answer and its marks share one line -- the fix for the empty half-card */
.rv-ansrow { display: flex; align-items: center; gap: 14px; flex-wrap: wrap; }
.rv-ansbox { flex: 1; min-width: 0; display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px; }

/* Colour of the tiles says which value is whose; the ANSWER / KEY overlines
   go (Duke rejected them twice as "too much furniture"). */
.rv-lab { display: none; }
.rv-row { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.rv-ans-value { min-width: 0; font-family: var(--font-hanzi); font-weight: 700; }
/* her answer, struck, in a soft red tile */
.rv-ans-value.is-wrong { display: inline-flex; align-items: center; padding: 4px 13px; border-radius: 12px;
  background: var(--accent-light); color: var(--accent-dark);
  text-decoration: line-through; text-decoration-thickness: 2.5px;
  text-decoration-color: color-mix(in srgb, var(--accent) 55%, transparent); }
.rv-ans-value.is-wrong .zi-han { text-decoration: line-through; text-decoration-thickness: 2.5px;
  text-decoration-color: color-mix(in srgb, var(--accent) 55%, transparent); }
.rv-ans-value.is-wrong .zi-py { color: var(--accent-dark); }
/* the key, in a soft green tile */
.rv-ans-value.is-key { display: inline-flex; align-items: center; padding: 4px 13px; border-radius: 12px;
  background: var(--green-light); color: var(--green-dark); }
.rv-ans-value.is-key .zi-py { color: var(--green-dark); }
/* an arrow from her answer to the key */
.rv-row-key { position: relative; padding-left: 26px; }
.rv-row-key::before { content: "\2192"; position: absolute; left: 3px; top: 50%; transform: translateY(-50%);
  color: var(--muted); font-weight: 700; font-size: 1.05rem; }
/* multi-row answers (cloze, matching) */
.rv-mini { display: flex; flex-direction: column; gap: 3px; }
.rv-mini-row { font-size: var(--t-body-sm); }
.rv-mini-row.ok { color: var(--green-dark); }
.rv-mini-row.bad { color: var(--accent-dark); }
.rv-mini-note { font-size: .74rem; color: var(--muted); font-weight: 700; }

/* ---- the marks: a segmented pill, same spot on every record ---- */
.rv-q-side { display: flex; align-items: center; flex-shrink: 0; }
.rv-ov { display: inline-flex; background: var(--card); border: 1px solid var(--border);
  border-radius: 13px; overflow: hidden; box-shadow: 0 1px 2px rgba(60,40,20,.05); }
.rv-ov-btn { width: 46px; height: 42px; border: none; background: transparent; color: var(--muted);
  cursor: pointer; font-size: 1.1rem; line-height: 1; font-family: var(--font-display);
  transition: background .13s, color .13s, transform .1s; }
.rv-ov-btn + .rv-ov-btn { border-left: 1px solid var(--border); }
.rv-ov-btn:hover { color: var(--text); background: var(--surface-2); }
.rv-ov-btn:active { transform: scale(.9); }
.rv-ov-btn.ok:hover  { color: var(--green-dark); background: var(--green-light); }
.rv-ov-btn.bad:hover { color: var(--accent-dark); background: var(--accent-light); }
/* reset only means anything once a manual verdict exists */
.rv-ov-btn.reset { display: none; font-size: 1rem; }
.rv-q.overridden .rv-ov-btn.reset { display: block; }
/* echo the verdict just set, so a background save is visibly a save */
.rv-q.overridden.correct .rv-ov-btn.ok  { background: var(--green-light); color: var(--green-dark); }
.rv-q.overridden.wrong   .rv-ov-btn.bad { background: var(--accent-light); color: var(--accent-dark); }
.rv-pulse { animation: rvPulse .5s ease; }
@keyframes rvPulse { 0% { transform: none; } 40% { transform: scale(1.12); } 100% { transform: none; } }

/* ---- overridden: a thin gold ring round the whole record ---- */
.rv-q.overridden { outline: 1.5px solid color-mix(in srgb, var(--gold) 70%, transparent); outline-offset: -1px; }

/* ---- correct rows: the case you scroll past, so make it quiet ---- */
.rv-q.correct { box-shadow: none; background: transparent;
  border-color: color-mix(in srgb, var(--border) 55%, transparent); }
.rv-q.correct:hover { transform: none;
  box-shadow: 0 1px 2px rgba(60,40,20,.05), 0 6px 16px rgba(60,40,20,.05); }
.rv-q.correct .rv-q-rail { background: transparent; }
.rv-q.correct .rv-q-title { font-size: 1.04rem; color: var(--muted); font-weight: 400; }
.rv-q.correct .rv-q-title .zi-han { color: var(--text); }
.rv-q.correct .rv-q-meta { display: none; }
.rv-q.correct .rv-ansdock { background: transparent; border-left: none; padding: 13px 18px; }
.rv-q.correct .rv-ansdock::before { display: none; }
.rv-q.correct .rv-row-key { display: none; }
.rv-q.correct .rv-ans-value { color: var(--green-dark); }
.rv-q.correct .rv-ans-value .zi-py { color: var(--green-dark); }
.rv-q.correct .rv-q-side { opacity: .35; transition: opacity .15s ease; }
.rv-q.correct:hover .rv-q-side { opacity: 1; }

/* ---- wide answers (free writing, translation, ordering, matching, cloze):
   the dock drops to a full-width second row instead of wrapping to death in
   a 320px column ---- */
.rv-q.wide-answer { grid-template-columns: 46px minmax(0, 1fr); }
.rv-q.wide-answer .rv-q-rail  { grid-row: 1; grid-column: 1; }
.rv-q.wide-answer .rv-q-ask   { grid-row: 1; grid-column: 2; }
.rv-q.wide-answer .rv-ansdock { grid-row: 2; grid-column: 1 / -1; border-left: none;
  border-top: 1px solid var(--border); }
.rv-q.wide-answer.correct .rv-ansdock { border-top-color: color-mix(in srgb, var(--border) 55%, transparent); }
.rv-q.wide-answer .rv-ansbox { flex: 1 1 100%; flex-direction: column; align-items: flex-start; gap: 6px; }
.rv-q.wide-answer .rv-ans-value { font-weight: 500; font-size: 1.1rem; line-height: 1.7; }
.rv-q.wide-answer .rv-mini { flex-direction: row; flex-wrap: wrap; gap: 2px 18px; }
.rv-q.wide-answer .rv-q-side { margin-left: auto; }

/* ---- the whole-question note lives full width at the foot of the dock ---- */
.rv-ansdock > .rv-notes { margin-top: 1px; }

/* ---- passage header (a shared reading text above its sub-questions) ---- */
.rv-passage { background: var(--surface-2); border-radius: 14px; padding: 14px 16px; margin-top: 6px; }
.rv-passage h3 { margin: 0 0 8px; font-size: 1rem; }
.rv-passage-text { line-height: 2.1; }
.rv-hint { font-size: .78rem; color: var(--muted); margin: 6px 0 0; }
.rv-audio { display: block; margin: 8px 0; max-width: 340px; }

/* ---- stack the dock under the question on narrow screens ---- */
@media (max-width: 860px) {
  .rv-q, .rv-q.wide-answer { grid-template-columns: 46px minmax(0, 1fr); }
  .rv-q .rv-q-rail  { grid-row: 1; grid-column: 1; }
  .rv-q .rv-q-ask   { grid-row: 1; grid-column: 2; }
  .rv-q .rv-ansdock { grid-row: 2; grid-column: 1 / -1; border-left: none; border-top: 1px solid var(--border); }
  .rv-q.correct .rv-ansdock { border-top-color: color-mix(in srgb, var(--border) 55%, transparent); }
}
@media (max-width: 640px) {
  .rv-bar { position: static; }
  .rv-ansrow { gap: 10px; }
}

/* Review-workspace tables (responses list + item analysis) */
.rv-table-wrap { overflow-x: auto; border: 1px solid var(--border); border-radius: 12px; background: var(--card); }
.rv-table { width: 100%; border-collapse: collapse; font-size: .9rem; }
.rv-table th { text-align: left; padding: 10px 14px; font-size: .68rem; text-transform: uppercase;
  letter-spacing: .05em; color: var(--muted); background: var(--surface-2); font-weight: 800; }
.rv-table td { padding: 11px 14px; border-top: 1px solid var(--border); vertical-align: middle; }
.rv-table tr.is-pending td { background: color-mix(in srgb, var(--gold-light, #f7eeda) 45%, transparent); }
.rv-date { font-variant-numeric: tabular-nums; color: var(--muted); font-size: .85rem; }
.rv-muted { color: var(--muted); }
.rv-score-sm { font-weight: 800; font-variant-numeric: tabular-nums; padding: 3px 9px; border-radius: 8px; }
.rv-score-sm.good { background: var(--green-light); color: var(--green-dark); }
.rv-score-sm.mid { background: var(--gold-light, #f7eeda); color: var(--gold-dark, #8a6112); }
.rv-score-sm.low { background: var(--accent-light); color: var(--accent); }
.rv-tag { display: inline-block; font-size: .68rem; font-weight: 800; text-transform: uppercase;
  letter-spacing: .04em; padding: 3px 9px; border-radius: 99px; white-space: nowrap; }
.rv-tag.ok { background: var(--green-light); color: var(--green-dark); }
.rv-tag.pending { background: var(--gold-light, #f7eeda); color: var(--gold-dark, #8a6112); }
.rv-tag.muted { background: var(--surface-2); color: var(--muted); }
.rv-pending-inline { color: var(--gold-dark, #8a6112); font-weight: 800; }
.rv-panel { margin-top: 22px; background: var(--card); border: 1px solid var(--border);
  border-radius: 14px; padding: 18px; }
.rv-panel h3 { margin: 0 0 4px; font-size: 1rem; }
.rv-panel-sub { font-size: .82rem; color: var(--muted); margin: 0 0 12px; }
.rv-empty { color: var(--muted); padding: 20px; }
@media (max-width: 720px) {
  .rv-table thead { display: none; }
  .rv-table tr { display: block; border-top: 1px solid var(--border); padding: 8px 0; }
  .rv-table td { display: flex; justify-content: space-between; gap: 12px; border: none; padding: 5px 14px; }
  .rv-table td::before { content: attr(data-label); font-size: .68rem; text-transform: uppercase;
    letter-spacing: .04em; color: var(--muted); font-weight: 800; }
}

/* Submission list as student cards (2026-08-04). Was the rv-table above, which
   on a phone collapsed to a STUDENT/SUBMITTED/SCORE/STATUS caps-label stack --
   the same look the assign roster moved away from. Each submission is now a
   tap-through card matching that roster: avatar, name, when + status, score
   pill hard right. Keeps the rv-score / rv-tag pills (Duke's preference). */
.rv-subs { display: flex; flex-direction: column; gap: 10px; }
.rv-sub {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: center;
  gap: 14px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 16px;
  padding: 13px 16px;
  text-decoration: none;
  color: inherit;
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.rv-sub:hover { transform: translateY(-2px); box-shadow: var(--shadow); }
/* Awaiting review: a quiet gold wash so the ones that need Duke stand out. */
.rv-sub.is-pending { background: color-mix(in srgb, var(--gold-light, #f7eeda) 45%, var(--card)); }
.rv-sub-ava {
  width: 44px; height: 44px; flex-shrink: 0;
  border-radius: 50%;
  background: var(--primary-light);
  color: var(--primary-dark);
  display: grid; place-items: center;
  font-family: var(--font-display);
  font-size: 1.2rem; font-weight: 700;
}
.rv-sub-body { min-width: 0; }
.rv-sub-name {
    color: var(--text);
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
}
.rv-sub-meta { color: var(--muted); font-size: 0.82rem; margin-top: 2px;
  font-variant-numeric: tabular-nums; }
.rv-sub-chips { margin-top: 7px; }
.rv-sub-right { display: flex; align-items: center; gap: 10px; flex-shrink: 0; }
.rv-sub .rv-score-sm { font-size: 0.95rem; padding: 5px 11px; border-radius: 10px; }
.rv-sub-arrow { color: var(--muted); font-size: 1.05rem; transition: transform 0.15s ease; }
.rv-sub:hover .rv-sub-arrow { transform: translateX(3px); color: var(--primary-dark); }
@media (max-width: 520px) {
  .rv-sub { gap: 12px; padding: 12px 14px; }
  .rv-sub-arrow { display: none; }
}
@media (prefers-reduced-motion: reduce) {
  .rv-sub { transition: none; }
  .rv-sub:hover { transform: none; }
}

/* ---------- Teacher notes on a submission ---------- */
/* No margin of its own: inside a question card this is one item in the
   footer row (.rv-q-foot owns the spacing), and an empty box must take up
   nothing at all -- there is one per question, most of them empty. */
.rv-notes { display: flex; flex-direction: column; gap: 5px; }
.rv-notes:empty { display: none; }
.rv-af-note .rv-notes:not(:empty) { margin-bottom: 8px; }
.rv-note, .rv-note-new { border-radius: 9px; }
.rv-note {
  background: var(--gold-light, #f7eeda);
  border-left: 3px solid var(--gold, #e3b23c);
  padding: 7px 11px;
}
.rv-note.private { background: var(--surface-2); border-left-color: var(--muted); }
.rv-note > summary, .rv-note-new > summary { cursor: pointer; list-style: none; }
.rv-note > summary::-webkit-details-marker, .rv-note-new > summary::-webkit-details-marker { display: none; }
.rv-note-body { font-size: .9rem; white-space: pre-wrap; }
.rv-note-tag { font-size: .62rem; font-weight: 800; text-transform: uppercase; letter-spacing: .04em;
  color: var(--muted); margin-left: 8px; }
.rv-note-new > summary { font-size: .76rem; font-weight: 800; color: var(--muted); padding: 3px 0; }
.rv-note-new > summary:hover { color: var(--primary-dark); }
.rv-note-form { display: flex; flex-direction: column; gap: 7px; margin-top: 8px; }
.rv-note-form textarea { width: 100%; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border);
  font: inherit; font-size: .9rem; resize: vertical; background: var(--card); color: var(--text); }
.rv-note-priv { font-size: .76rem; color: var(--muted); display: flex; align-items: center; gap: 6px; }
.rv-note-actions { display: flex; gap: 7px; }
.rv-btn.danger { border-color: var(--accent); color: var(--accent); background: var(--card); }
.rv-btn.danger:hover { background: var(--accent-light); }
/* The whole-test comment now lives in the admin strip (.rv-af-note); its old
   standalone card is gone. The note styling above still applies wherever a
   note renders -- per-question footers and the strip alike. */

/* Student-facing rendering of the same notes (test_review.html) */
.sr-notes { margin-top: 10px; display: flex; flex-direction: column; gap: 6px; }
.sr-note { background: var(--gold-light, #f7eeda); border-left: 3px solid var(--gold, #e3b23c);
  border-radius: 0 9px 9px 0; padding: 9px 13px; }
.sr-note-label { display: block; color: var(--gold-dark, #8a6112); margin-bottom: 3px; }
.sr-note-body { white-space: pre-wrap; font-size: .93rem; }
.sr-general { margin-bottom: 14px; }

/* ===== Student stats: hero + focus (2026-07-26 redesign) =====
   The KPI numbers previously rendered at the same size as the page heading,
   so nothing signalled what to look at first. One number now leads --
   recognition vs production, the distinction the teaching is built around --
   and every percentage carries how much evidence sits behind it. */
/* The mark that names a module, in the same line-icon vocabulary as the deck
   door on /study. The modules were identical white boxes with an abstract
   Russian noun on top and read as furniture: "you don't know what they are
   at all, they just there sticking out" (Duke, 2026-08-02). The component
   they all share now lives with the rest of the stats CSS above. */
.nc-emblem {
  width: 38px; height: 38px; border-radius: 12px; flex: none;
  display: flex; align-items: center; justify-content: center;
  background: var(--surface-2); color: var(--primary-dark);
}
.nc-emblem svg { width: 20px; height: 20px; }
.nc-mode-pair { display: grid; grid-template-columns: 1fr 1fr; gap: 26px; }
@media (max-width: 520px) { .nc-mode-pair { grid-template-columns: 1fr; gap: 18px; } }
/* An eye for "pick the right option", a speech bubble for "produce it
   yourself". The two Russian words are abstract; the icons are not, and
   they do the job the folded-away paragraph used to have to do. */
.nc-mode-label { font-size: .72rem; font-weight: 800; text-transform: uppercase;
  letter-spacing: .06em; color: var(--muted); margin-bottom: 6px;
  display: flex; align-items: center; gap: 6px; }
.nc-mode-ico { display: flex; color: var(--primary-dark); }
.nc-mode-ico svg { width: 15px; height: 15px; }
.nc-mode-col.thin .nc-mode-ico { color: var(--muted); }
.nc-mode-num {   font-size: 2.6rem;
  line-height: 1; color: var(--text); font-variant-numeric: tabular-nums; }
.nc-mode-num .unit { font-size: 1.1rem; color: var(--muted); margin-left: 2px; }
.nc-mode-track { height: 9px; border-radius: 99px; background: var(--surface-2);
  overflow: hidden; margin: 10px 0 6px; }
.nc-mode-fill { height: 100%; border-radius: 99px; }
/* Coloured by the score, not by which mode it is -- the same green/gold/red
   scale as the topic bars, so green means one thing on this page. */
.nc-mode-fill.hi { background: var(--green); }
.nc-mode-fill.mid { background: var(--gold); }
.nc-mode-fill.lo { background: var(--accent); }
.nc-mode-meta { font-size: .74rem; color: var(--muted); font-weight: 600; }
/* Thin evidence is shown, not hidden -- but visibly dialled back so it can't
   be mistaken for a settled result. */
.nc-mode-col.thin .nc-mode-num { color: var(--muted); }
.nc-mode-col.thin .nc-mode-fill { opacity: .45; }
.nc-thin-flag { font-weight: 800; color: var(--gold-dark, #8a6112); }

.nc-focus-list { list-style: none; padding: 0; margin: 0 0 14px; display: flex;
  flex-direction: column; gap: 10px; }
.nc-focus-list li { display: grid; grid-template-columns: minmax(0,1fr) 90px 42px auto;
  gap: 10px; align-items: center; }
@media (max-width: 560px) {
  .nc-focus-list li { grid-template-columns: minmax(0,1fr) 42px; }
  .nc-focus-track, .nc-focus-n { display: none; }
}
.nc-focus-name { font-size: .88rem; font-weight: 700; overflow: hidden; text-overflow: ellipsis; }
.nc-focus-track { height: 7px; border-radius: 99px; background: var(--surface-2); overflow: hidden; }
.nc-focus-fill { display: block; height: 100%; border-radius: 99px; background: var(--accent); }
.nc-focus-pct { font-size: .85rem; font-weight: 800; color: var(--accent);
  text-align: right; font-variant-numeric: tabular-nums; }
.nc-focus-n { font-size: .72rem; color: var(--muted); white-space: nowrap; }
.nc-focus-cta { display: inline-block; background: var(--primary); color: #fff;
  font-weight: 700; font-size: .85rem; padding: 9px 18px; border-radius: 99px; text-decoration: none; }
.nc-focus-cta:hover { filter: brightness(1.05); }
.nc-focus-empty { font-size: .88rem; color: var(--muted); margin: 0; }

/* Sample size beside each topic percentage. Spelled out ("across 6
   questions"), so it needs its own width rather than a 22px number column --
   and it drops on phones, exactly like .nc-focus-n. */
.nc-topic-n { font-size: .7rem; color: var(--muted); text-align: right;
  white-space: nowrap; font-variant-numeric: tabular-nums; flex-shrink: 0; }
@media (max-width: 560px) { .nc-topic-n { display: none; } }

/* ---- /study v2 (2026-07-27, after Duke's reviews + Duolingo-style
   layering): three layers that never mix. Status = the streak chip in the
   top bar; plan = ONE ordered path for today ending at the lesson; nav =
   two illustrated doors with no status on them. Analytics live on
   /flashcards/stats. Palette/type from the app vars (dark mode free). ---- */

.nc-study { max-width: 820px; }

.st-streak-chip {
  display: inline-flex; align-items: center; gap: 6px;
  border: 1.5px solid var(--border); border-radius: 99px; padding: 4px 12px 4px 9px;
  font-weight: 700; font-size: 0.88rem; color: var(--gold-dark);
  font-variant-numeric: tabular-nums; text-decoration: none;
}
.st-streak-chip svg { width: 15px; height: 15px; color: var(--gold); }
.st-streak-chip span { color: var(--text); }
.st-streak-chip:hover { border-color: var(--gold); }

.st-path {
  background: var(--card); border: 1px solid var(--border); border-radius: 20px;
  box-shadow: 0 1px 2px rgba(94, 62, 44, 0.05), 0 10px 30px -18px rgba(94, 62, 44, 0.25);
  padding: 6px 26px 8px; margin-bottom: 14px;
}
.st-step { display: flex; align-items: center; gap: 15px; padding: 15px 0; position: relative; }
/* Content always paints above a fill layer, on any row. */
.st-step-dot, .st-step-main, .st-step-cta, .st-step-quiet { position: relative; z-index: 1; }
.st-step + .st-step { border-top: 1px solid var(--border); }
.st-step-dot {
  font-family: var(--font-display);
  width: 33px; height: 33px; border-radius: 50%; flex: none;
  display: grid; place-items: center; font-weight: 700; font-size: 0.86rem;
  border: 1.5px solid var(--border); color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.st-step.now .st-step-dot { background: var(--primary-dark); border-color: var(--primary-dark); color: #fff; }
.st-step.done .st-step-dot { background: var(--green-light); border-color: transparent; color: var(--green-dark); }
.st-step-main { flex: 1; min-width: 0; }
.st-step.now .st-step-t { font-size: 1.04rem; }
/* The count the student acts on, given weight inside the sentence. */
.st-step-n {
  font-family: var(--font-display); font-weight: 700; font-size: 1.5em;
  color: var(--primary-dark); font-variant-numeric: tabular-nums;
  padding: 0 1px; line-height: 1;
}
.st-step.done .st-step-t { color: var(--muted); text-decoration: line-through; text-decoration-color: var(--border); }
.st-step-sub { font-size: 0.82rem; color: var(--muted); margin-top: 1px; }
.st-step-sub a { color: inherit; text-underline-offset: 3px; }
.st-step-sub a:hover { color: var(--primary-dark); }
/* The button now carries the time estimate and is sized to match the
   bar's visual weight -- it used to read as an afterthought next to a
   full-width row (Duke, 2026-07-27). */
.st-step-cta {
  flex: none; display: flex; flex-direction: column; align-items: center; gap: 1px;
  background: var(--primary-dark); color: #fff; text-decoration: none;
  border-radius: 14px; padding: 12px 26px; white-space: nowrap;
  box-shadow: 0 10px 22px -10px var(--primary-dark);
  transition: transform 0.15s ease, box-shadow 0.15s ease;
}
.st-step-cta:hover { transform: translateY(-1px); box-shadow: 0 12px 26px -10px var(--primary-dark); }
.st-cta-main { font-weight: 700; font-size: 1.02rem; }
.st-cta-est { font-weight: 600; font-size: 0.7rem; opacity: 0.8; }
.st-step-quiet { flex: none; font-size: 0.82rem; color: var(--muted); white-space: nowrap; }
a.st-step-book { color: var(--primary-dark); font-weight: 600; text-decoration: none; }
a.st-step-book:hover { text-decoration: underline; text-underline-offset: 3px; }
.st-step.lesson .st-step-dot {
  border-style: dashed; border-color: var(--primary); color: var(--primary-dark);
  background: var(--primary-light);
}
.st-step.lesson .st-step-t { font-size: 1.08rem; letter-spacing: -0.01em; }
.st-all-clear { margin: 0 0 12px; padding-left: 48px; font-size: 0.85rem; color: var(--green-dark); }

.st-note {
  display: flex; gap: 16px; background: var(--card); border: 1px solid var(--border);
  border-radius: 16px; padding: 20px 24px; margin-bottom: 22px;
}
.st-note-dot {
  width: 38px; height: 38px; border-radius: 50%; flex: none;
  background: var(--gold-light); color: var(--gold-dark);
  display: grid; place-items: center; font-family: var(--font-display);
  font-weight: 700; font-size: 1.05rem;
}
.st-note blockquote {
  margin: 4px 0 0; font-family: var(--font-display); font-size: 1.02rem;
  line-height: 1.55; letter-spacing: -0.005em; max-width: 62ch;
}
.st-note-who { margin-top: 7px; font-size: 0.82rem; color: var(--muted); }
.st-note-lbl {
  font-size: 0.68rem; font-weight: 700; letter-spacing: 0.1em; text-transform: uppercase;
  color: var(--muted); margin-bottom: 7px;
}

/* ---- The day's entrance ---------------------------------------------
   Time-of-day greeting in hanzi, each syllable of pinyin over its own
   character, on a soft wash that moves with the hour: sunrise peach ->
   midday coral -> dusk rose -> a cool night. Replaced the plain
   "Nihao, {name}! / Here's what you have today." header on 2026-08-01.

   Every band is rendered into the markup and this CSS reveals the one
   named by [data-band] on .st-hi, which is what lets a two-line script
   re-pick the band from the reader's clock without a reflow or a swap
   of visible text. */
.st-hi {
  position: relative; margin: 16px 0 26px; padding: 4px 0 2px;
  display: flex; align-items: center; justify-content: space-between; gap: 24px;
}
.st-hi-main { position: relative; z-index: 1; min-width: 0; }
/* Registered so the two wash colours can be transitioned. Without this a
   custom property flips instantly, and the hour turning over in front of
   someone with the page open would be a jump cut. */
@property --hi-a { syntax: '<color>'; inherits: true; initial-value: transparent; }
@property --hi-b { syntax: '<color>'; inherits: true; initial-value: transparent; }

/* Two lobes kept over the greeting rather than spread across the column
   -- pushed wider they stop reading as light behind the words and start
   reading as a smudge. They drift on different periods and in opposite
   directions, and breathe on a third, so the light never repeats the
   same frame; one lobe on one loop read as a sliding blob. */
/* Wide enough that the sun has a sky to cross rather than a corner to
   sit in, but stopping well short of the week strip -- at golden hour
   the sun sat directly under MON/TUE/WED and the labels became unreadable
   against it. The strip has no background of its own to protect it. */
.st-hi-wash { position: absolute; inset: -30px 36% -18px -50px; z-index: 0; pointer-events: none; }
.st-hi-wash i {
  position: absolute; display: block; border-radius: 50%;
  will-change: transform, opacity;
}
/* The broad field of colour -- the sky itself. */
.w-sky {
  left: -10%; top: -24%; width: 74%; height: 144%;
  background: radial-gradient(closest-side, var(--hi-a), transparent 78%);
  filter: blur(28px);
  animation: stSkyDrift 26s ease-in-out infinite alternate;
}
/* A second body of light, counter-drifting, so the field never settles
   into one static shape. */
.w-glow {
  left: 26%; top: -4%; width: 62%; height: 128%;
  background: radial-gradient(closest-side, var(--hi-b), transparent 76%);
  filter: blur(32px);
  animation: stGlowDrift 19s ease-in-out infinite alternate,
             stGlowBreathe 11s ease-in-out infinite alternate;
}
/* The sun. Its place in the sky is the hour: low on the left at dawn,
   overhead at noon, low on the right at dusk, and after dark it is a
   small pale moon instead. This is the part that actually says what time
   it is -- a colour alone can't, which is what the previous three passes
   kept proving (Duke, 2026-08-01). */
.w-sun, .w-moon {
  left: var(--orb-x); top: calc(var(--orb-y) * var(--orb-y-scale, 1));
  margin: 0; border-radius: 50%;
  animation: stOrbBob 13s ease-in-out infinite alternate;
}
.w-sun {
  width: calc(var(--orb-size) * var(--orb-scale, 1));
  height: calc(var(--orb-size) * var(--orb-scale, 1));
  opacity: var(--sun-op, 1);
  /* A flat core before the falloff. A single-stop gradient under a 10px
     blur spread into just another soft patch -- on a cream page there is
     no headroom above the background, so the sun has to read by having
     an edge rather than by being brighter. */
  background: radial-gradient(closest-side,
    var(--orb-c) 0 24%, var(--orb-c) 30%, transparent 74%);
  filter: blur(6px);
}
/* The moon is a disc with maria on it rather than a pale blob -- at this
   size three soft darker patches are the whole difference between "moon"
   and "another glow". It carries its own colour because a moon-white
   disc is invisible on a cream page in the light theme. */
.w-moon {
  /* One place defines the diameter; the phase offset is derived from it
     rather than measured in JS, so a hidden or zero-width viewport can't
     hand back a bogus width and skew the phase. */
  --moon-d: calc(46px * var(--orb-scale, 1));
  width: var(--moon-d);
  height: var(--moon-d);
  opacity: var(--moon-op, 0);
  background:
    radial-gradient(closest-side at 63% 37%, var(--moon-mare) 0 13%, transparent 15%),
    radial-gradient(closest-side at 37% 60%, var(--moon-mare) 0 10%, transparent 12%),
    radial-gradient(closest-side at 55% 71%, var(--moon-mare) 0 7%, transparent 9%),
    radial-gradient(closest-side, var(--moon-c) 0 44%, transparent 76%);
  filter: blur(1.4px);
  /* The phase, cut as a hole rather than painted as a shadow. Laying a
     disc of page colour over the moon showed up as a pale blob beside
     it -- a flat fill cannot match the tinted sky it sits on.
     Subtracting one mask circle from another removes the unlit part
     outright, so whatever is behind shows through instead.
     The offset must be in px: a percentage mask-position resolves
     against (box - image), which is zero when the mask fills the
     element, so a percentage shift silently does nothing and the moon
     comes out as a ring. */
  -webkit-mask-image: radial-gradient(closest-side, #000 0 82%, transparent 84%),
                      radial-gradient(closest-side, #000 0 64%, transparent 82%);
  mask-image: radial-gradient(closest-side, #000 0 82%, transparent 84%),
              radial-gradient(closest-side, #000 0 64%, transparent 82%);
  -webkit-mask-size: 100% 100%, 100% 100%;
  mask-size: 100% 100%, 100% 100%;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: 0 0, calc(var(--moon-lit, 1) * var(--moon-d)) 0;
  mask-position: 0 0, calc(var(--moon-lit, 1) * var(--moon-d)) 0;
  -webkit-mask-composite: source-out;
  mask-composite: subtract;
}
/* Stars. Fixed positions so they don't reshuffle on every repaint --
   a sky whose constellations change each minute is unsettling. */
.w-stars { inset: 0; opacity: var(--stars, 0); transition: opacity 2.5s ease; }
.w-stars i {
  width: 3px; height: 3px; border-radius: 50%; background: var(--star-c);
  box-shadow: 0 0 5px 1px var(--star-glow);
  animation: stTwinkle 4s ease-in-out infinite alternate;
}
.w-stars i:nth-child(1) { left: 8%; top: 18%; animation-delay: -0.2s; }
.w-stars i:nth-child(2) { left: 17%; top: 46%; width: 2px; height: 2px; animation-delay: -1.6s; }
.w-stars i:nth-child(3) { left: 24%; top: 12%; animation-delay: -3.1s; }
.w-stars i:nth-child(4) { left: 31%; top: 62%; width: 2px; height: 2px; animation-delay: -0.9s; }
.w-stars i:nth-child(5) { left: 39%; top: 28%; width: 4px; height: 4px; animation-delay: -2.4s; }
.w-stars i:nth-child(6) { left: 46%; top: 8%; width: 2px; height: 2px; animation-delay: -3.8s; }
.w-stars i:nth-child(7) { left: 53%; top: 52%; animation-delay: -1.1s; }
.w-stars i:nth-child(8) { left: 61%; top: 22%; width: 2px; height: 2px; animation-delay: -2.9s; }
.w-stars i:nth-child(9) { left: 68%; top: 68%; width: 2px; height: 2px; animation-delay: -0.5s; }
.w-stars i:nth-child(10) { left: 74%; top: 34%; width: 4px; height: 4px; animation-delay: -3.4s; }
.w-stars i:nth-child(11) { left: 82%; top: 14%; animation-delay: -1.9s; }
.w-stars i:nth-child(12) { left: 88%; top: 56%; width: 2px; height: 2px; animation-delay: -2.6s; }
.w-stars i:nth-child(13) { left: 12%; top: 74%; width: 2px; height: 2px; animation-delay: -3.6s; }
.w-stars i:nth-child(14) { left: 57%; top: 80%; width: 2px; height: 2px; animation-delay: -0.7s; }
.w-stars i:nth-child(15) { left: 93%; top: 30%; width: 2px; height: 2px; animation-delay: -2.1s; }
.w-stars i:nth-child(16) { left: 4%; top: 40%; width: 2px; height: 2px; animation-delay: -1.3s; }
@keyframes stTwinkle {
  from { opacity: 0.25; transform: scale(0.7); }
  to { opacity: 1; transform: scale(1.15); }
}

/* A shooting star, roughly every twenty seconds. It lives inside the
   star layer, so it inherits --stars and cannot fire in daylight
   without any extra bookkeeping. */
.w-shoot {
  position: absolute; display: block; left: 10%; top: 4%;
  width: 84px; height: 2px; border-radius: 2px;
  background: linear-gradient(90deg, transparent, var(--star-c));
  opacity: 0; transform-origin: left center;
  animation: stShoot 21s ease-in infinite;
}
@keyframes stShoot {
  0%, 90% { opacity: 0; transform: translate3d(0, 0, 0) rotate(26deg) scaleX(0.15); }
  91% { opacity: 0; transform: translate3d(0, 0, 0) rotate(26deg) scaleX(0.15); }
  94% { opacity: 1; }
  99.5% { opacity: 0; transform: translate3d(230px, 112px, 0) rotate(26deg) scaleX(1); }
  100% { opacity: 0; transform: translate3d(0, 0, 0) rotate(26deg) scaleX(0.15); }
}

/* Clouds. The single biggest thing separating one daylight hour from
   another -- noon is thick with them, dawn and dusk nearly clear. */
.w-clouds { inset: 0; opacity: var(--clouds, 0); transition: opacity 3s ease; }
.w-clouds i {
  border-radius: 50%;
  background: radial-gradient(closest-side, var(--cloud-c), transparent 74%);
  filter: blur(15px);
}
.w-clouds i:nth-child(1) { left: -24%; top: 10%; width: 48%; height: 46%; animation: stCloudA 71s linear infinite; }
.w-clouds i:nth-child(2) { left: -32%; top: 44%; width: 40%; height: 34%; animation: stCloudB 97s linear infinite; }
.w-clouds i:nth-child(3) { left: -28%; top: -4%; width: 32%; height: 30%; animation: stCloudC 83s linear infinite; }
@keyframes stCloudA { to { transform: translate3d(300%, 6px, 0); } }
@keyframes stCloudB { to { transform: translate3d(360%, -8px, 0); } }
@keyframes stCloudC { to { transform: translate3d(430%, 5px, 0); } }

/* Motes drifting up through low, raking light -- the thing that makes
   golden hour look like golden hour. Off at midday, when the sun is
   overhead and there is no beam to catch them in. */
.w-motes { inset: 0; opacity: var(--motes, 0); transition: opacity 3s ease; }
.w-motes i {
  width: 3px; height: 3px; border-radius: 50%; background: var(--mote-c);
  animation: stMote 17s ease-in-out infinite;
}
.w-motes i:nth-child(1) { left: 12%; top: 62%; animation-delay: -1s; }
.w-motes i:nth-child(2) { left: 21%; top: 78%; width: 2px; height: 2px; animation-delay: -5s; }
.w-motes i:nth-child(3) { left: 29%; top: 50%; animation-delay: -9s; }
.w-motes i:nth-child(4) { left: 37%; top: 84%; width: 2px; height: 2px; animation-delay: -13s; }
.w-motes i:nth-child(5) { left: 45%; top: 66%; width: 4px; height: 4px; animation-delay: -3s; }
.w-motes i:nth-child(6) { left: 54%; top: 44%; width: 2px; height: 2px; animation-delay: -7s; }
.w-motes i:nth-child(7) { left: 63%; top: 74%; animation-delay: -11s; }
.w-motes i:nth-child(8) { left: 72%; top: 56%; width: 2px; height: 2px; animation-delay: -15s; }
.w-motes i:nth-child(9) { left: 82%; top: 70%; width: 2px; height: 2px; animation-delay: -6s; }
@keyframes stMote {
  0% { opacity: 0; transform: translate3d(0, 16px, 0) scale(0.5); }
  22% { opacity: 1; }
  72% { opacity: 1; }
  100% { opacity: 0; transform: translate3d(14px, -30px, 0) scale(1.15); }
}
/* Rays fan out from the sun and are longest when it sits low -- the
   whole reason golden hour looks the way it does. Set to zero after
   dark, when there is no sun to throw them. */
.w-rays {
  left: var(--orb-x); top: calc(var(--orb-y) * var(--orb-y-scale, 1));
  width: calc(var(--orb-size) * var(--orb-scale, 1) * 2.8);
  height: calc(var(--orb-size) * var(--orb-scale, 1) * 2.8);
  opacity: calc(var(--rays) * var(--sun-op, 1));
  background: repeating-conic-gradient(from 0deg at 50% 50%,
    var(--orb-c) 0deg 4.5deg, transparent 4.5deg 18deg);
  -webkit-mask-image: radial-gradient(closest-side, #000 14%, transparent 70%);
  mask-image: radial-gradient(closest-side, #000 14%, transparent 70%);
  filter: blur(7px);
  animation: stRaySpin 140s linear infinite;
}
.w-orb, .w-rays { transform: translate(-50%, -50%); }

@keyframes stSkyDrift {
  from { transform: translate3d(-16px, 7px, 0) scale(0.95); }
  to { transform: translate3d(30px, -12px, 0) scale(1.14); }
}
@keyframes stGlowDrift {
  from { transform: translate3d(20px, -9px, 0) scale(1.1); }
  to { transform: translate3d(-24px, 11px, 0) scale(0.88); }
}
@keyframes stGlowBreathe { from { opacity: 0.5; } to { opacity: 1; } }
@keyframes stOrbBob {
  from { transform: translate(-50%, -50%) translateY(-7px) scale(0.96); }
  to { transform: translate(-50%, -50%) translateY(7px) scale(1.07); }
}
@keyframes stRaySpin {
  from { transform: translate(-50%, -50%) rotate(0deg); }
  to { transform: translate(-50%, -50%) rotate(360deg); }
}

/* Colour crossfade when the hour turns over, and on first paint. */
.st-hi { transition: --hi-a 3s ease, --hi-b 3s ease; }
.st-hi-wash { animation: stHiBloom 1.4s ease-out backwards; }
@keyframes stHiBloom { from { opacity: 0; } }

/* Deliberately no entrance animation on the text. Staggering the
   characters in was the wrong reading of "make it animated" -- an
   entrance plays once and is over, and what Duke asked for was the
   background carrying the time of day continuously (2026-08-01). The
   motion lives in the sky above; the words just sit in it. */

@media (prefers-reduced-motion: reduce) {
  .st-hi-wash, .st-hi-wash i { animation: none; }
  .st-hi { transition: none; }
}

/* ---- Nine hours of light ----
   Gold-led, never coral-led, and that is the whole fix. This page already
   carries a coral halo across its top (see body.nc-app-stage), so a coral
   wash here was a second coral layer stacked on the first -- and two
   coral glows over cream is precisely how you mix pink. Duke read the
   result as a dusty mauve twice before the cause turned up in the page
   background rather than in these values (2026-08-01).
   So: blue channel held well under green in every stop, gold carrying the
   large lobe and orange only ever the small one. That keeps the wash a
   different kind of light from the halo it sits on instead of more of the
   same, and the hours separate by warmth and brightness -- pale gold at
   dawn, clear gold through the morning, the brightest at noon, amber
   through the afternoon, deep amber at golden hour, and dimming to embers
   overnight. */
/* Moon and stars carry their own colours because the obvious ones don't
   work in the light theme: a moon-white disc and white stars are simply
   invisible on a cream page. They go warm gold there and only turn pale
   on the dark canvas. */
.st-hi {
  --sun-op: 1; --moon-op: 0; --stars: 0;
  /* Deeper and more saturated than the dark theme's, not paler: on cream
     a moon has to read by being darker than the page, which is the
     opposite of how a real one works. */
  --moon-c: rgba(188, 148, 78, 0.95); --moon-mare: rgba(132, 98, 48, 0.5);
  --star-c: rgba(172, 128, 56, 0.9); --star-glow: rgba(206, 162, 84, 0.4);
  /* Clouds have to be a shade deeper than cream to exist at all here;
     on the dark canvas they go pale and drop right back. */
  --cloud-c: rgba(244, 202, 152, 0.5); --mote-c: rgba(206, 150, 68, 0.8);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .st-hi {
    --moon-c: rgba(250, 242, 216, 0.96); --moon-mare: rgba(184, 166, 128, 0.45);
    --star-c: rgba(255, 246, 224, 0.96); --star-glow: rgba(255, 234, 188, 0.6);
    --cloud-c: rgba(255, 224, 188, 0.16); --mote-c: rgba(255, 226, 172, 0.85);
  }
}
:root[data-theme="dark"] .st-hi {
  --moon-c: rgba(250, 242, 216, 0.96); --moon-mare: rgba(184, 166, 128, 0.45);
  --star-c: rgba(255, 246, 224, 0.96); --star-glow: rgba(255, 234, 188, 0.6);
  --cloud-c: rgba(255, 224, 188, 0.16); --mote-c: rgba(255, 226, 172, 0.85);
}
/* Crossfade the changeover rather than snapping the sun off. */
.w-sun, .w-moon { transition: opacity 4s ease; }

/* --hi-a/--hi-b are the sky; --orb-* place and colour the sun or moon;
   --rays is how much of a fan it throws; --stars is how much of the sky
   is showing through. These are the no-JS baseline -- the script on the
   page overrides position, size and the sun/moon/star mix from the
   reader's actual clock, so the sky moves by the minute rather than
   jumping between nine presets. */
.st-hi[data-band="deep-night"] {
  --clouds: 0; --motes: 0;
  --sun-op: 0; --moon-op: 1; --stars: 1;
  --hi-a: rgba(142, 100, 52, 0.4); --hi-b: rgba(112, 82, 50, 0.28);
  --orb-x: 30%; --orb-y: 22%; --orb-size: 46px; --rays: 0;
  --orb-c: rgba(242, 226, 188, 0.62);
}
.st-hi[data-band="dawn"] {
  --clouds: 0.45; --motes: 0.55;
  --stars: 0.3;
  --hi-a: rgba(250, 190, 120, 0.42); --hi-b: rgba(244, 160, 100, 0.28);
  --orb-x: 9%; --orb-y: 80%; --orb-size: 116px; --rays: 0.34;
  --orb-c: rgba(255, 194, 126, 0.9);
}
.st-hi[data-band="sunrise"] {
  --clouds: 0.6; --motes: 0.7;
  --hi-a: rgba(255, 192, 96, 0.52); --hi-b: rgba(252, 154, 78, 0.34);
  --orb-x: 17%; --orb-y: 58%; --orb-size: 128px; --rays: 0.6;
  --orb-c: rgba(255, 176, 84, 0.95);
}
.st-hi[data-band="morning"] {
  --clouds: 0.85; --motes: 0.2;
  --hi-a: rgba(255, 206, 112, 0.54); --hi-b: rgba(250, 182, 100, 0.34);
  --orb-x: 31%; --orb-y: 32%; --orb-size: 100px; --rays: 0.32;
  --orb-c: rgba(255, 206, 120, 0.95);
}
.st-hi[data-band="noon"] {
  --clouds: 1; --motes: 0;
  --hi-a: rgba(255, 218, 132, 0.56); --hi-b: rgba(253, 198, 116, 0.34);
  --orb-x: 50%; --orb-y: 14%; --orb-size: 84px; --rays: 0.14;
  --orb-c: rgba(255, 234, 172, 1);
}
.st-hi[data-band="afternoon"] {
  --clouds: 0.85; --motes: 0.28;
  --hi-a: rgba(252, 196, 102, 0.52); --hi-b: rgba(246, 170, 88, 0.34);
  --orb-x: 68%; --orb-y: 30%; --orb-size: 102px; --rays: 0.3;
  --orb-c: rgba(255, 200, 110, 0.95);
}
.st-hi[data-band="golden"] {
  --clouds: 0.7; --motes: 0.85;
  --hi-a: rgba(250, 178, 70, 0.56); --hi-b: rgba(238, 144, 64, 0.38);
  --orb-x: 82%; --orb-y: 56%; --orb-size: 130px; --rays: 0.66;
  --orb-c: rgba(252, 166, 62, 1);
}
.st-hi[data-band="dusk"] {
  --clouds: 0.4; --motes: 0.5;
  --stars: 0.35;
  --hi-a: rgba(232, 156, 74, 0.48); --hi-b: rgba(198, 126, 66, 0.34);
  --orb-x: 91%; --orb-y: 78%; --orb-size: 118px; --rays: 0.42;
  --orb-c: rgba(238, 140, 66, 0.95);
}
.st-hi[data-band="night"] {
  --clouds: 0; --motes: 0;
  --sun-op: 0; --moon-op: 1; --stars: 1;
  --hi-a: rgba(158, 112, 58, 0.42); --hi-b: rgba(126, 92, 56, 0.3);
  --orb-x: 74%; --orb-y: 20%; --orb-size: 52px; --rays: 0;
  --orb-c: rgba(246, 230, 192, 0.72);
}

.st-hi-line {
  margin: 0; font-family: var(--font-hanzi-serif); font-weight: 700;
  font-size: 2.4rem; line-height: 1.22; letter-spacing: 0.01em;
}
/* One phrase shows; the rest stay in the DOM for the clock script. The
   greeting keys are separate from the light bands on purpose -- nine
   hours of light share six phrases between them. */
.st-hi-v { display: none; }
.st-hi[data-greet="zaoshang"] .st-hi-v[data-greet="zaoshang"],
.st-hi[data-greet="shangwu"] .st-hi-v[data-greet="shangwu"],
.st-hi[data-greet="zhongwu"] .st-hi-v[data-greet="zhongwu"],
.st-hi[data-greet="xiawu"] .st-hi-v[data-greet="xiawu"],
.st-hi[data-greet="wanshang"] .st-hi-v[data-greet="wanshang"],
.st-hi[data-greet="wanan"] .st-hi-v[data-greet="wanan"] { display: inline; }
/* A full-width comma carries half a character of trailing air, which is
   right between two hanzi and far too much before a Latin name. */
.st-hi-comma { margin-right: -0.34em; }
/* The name is the reader's, not the lesson's -- set in the UI serif so
   it reads as an address rather than as more Chinese to decode. */
.st-hi-name { font-family: var(--font-display); font-size: 0.7em; font-weight: 600; }
.st-hi-date { margin: 6px 0 0; color: var(--muted); font-size: 0.9rem; }

/* ---- The week, beside the greeting ----------------------------------
   Moved out of the top bar on 2026-08-01. As a chip it was a number in
   the corner; here it is the week itself -- filled for days practised,
   ringed for today, dashed for the days still ahead -- so it says what
   to do as well as how long the run is. Still a link to /stats, which
   holds the full calendar and heatmap. */
.st-streak {
  position: relative; z-index: 1; flex: none;
  display: flex; align-items: center; gap: 15px;
  padding: 9px 15px; border-radius: 16px;
  border: 1px solid transparent; text-decoration: none; color: var(--text);
  transition: border-color 0.15s ease, background 0.15s ease;
}
.st-streak:hover { border-color: var(--border); background: var(--card); }
.st-streak-n { display: flex; align-items: center; gap: 4px; }
.st-streak-n svg { width: 19px; height: 19px; color: var(--gold); flex: none; }
.st-streak-n b {
   font-size: 1.45rem; 
  color: var(--gold-dark); font-variant-numeric: tabular-nums; line-height: 1;
}
.st-streak-week { display: flex; gap: 6px; }
/* Fixed column width, not shrink-to-fit: "FRI" is narrower than "WED"
   (and Russian's two-letter labels narrower still), so letting the label
   size the column puts the squares on an uneven rhythm. */
.st-wk { width: 27px; display: flex; flex-direction: column; align-items: center; gap: 6px; }
.st-wk i {
  font-style: normal; font-size: 0.58rem; font-weight: 700;
  letter-spacing: 0.05em; text-transform: uppercase; color: var(--muted);
}
/* A day gone by with no practice still has to look like a square. Left
   as a bare surface fill it vanished into the warm page behind it and
   read as a hole in the week rather than a day that was missed. */
.st-wk b {
  display: block; width: 20px; height: 20px; border-radius: 7px;
  background: var(--surface-2); border: 1px solid var(--border);
}
.st-wk.on b { background: var(--gold); border-color: var(--gold); }
/* Days not yet lived read as empty slots, not as days missed. */
.st-wk.soon b { background: none; border-style: dashed; }
/* Today wears a ring whether or not it is filled -- unfilled it is the
   one square asking to be earned, filled it is the day just banked. */
.st-wk.now b { box-shadow: 0 0 0 2px var(--bg), 0 0 0 3.5px var(--primary-dark); }
.st-wk.now i { color: var(--primary-dark); }
.st-wk.now.on b { animation: stWkLand 0.5s cubic-bezier(0.3, 1.55, 0.5, 1) 0.25s backwards; }
@keyframes stWkLand { from { transform: scale(0.35); opacity: 0; } }
@media (prefers-reduced-motion: reduce) {
  .st-streak, .st-wk.now.on b { animation: none; }
}

.nc-study .st-note { margin-bottom: 14px; }

/* Doors: illustrated navigation, reusing the tile scenes (fanned cards,
   stamped papers) with a slim name+meta+arrow body. */
.st-doors { display: grid; grid-template-columns: repeat(2, 1fr); gap: 14px; }
.st-door {
  background: var(--card); border: 1px solid var(--border); border-radius: 16px;
  overflow: hidden; text-decoration: none; color: var(--text);
  display: flex; flex-direction: column;
  transition: transform 0.15s ease;
}
.st-door:hover { transform: translateY(-2px); }
/* The tile scene was built for a padded card with -20px bleed margins;
   the door has no padding, so neutralize them. */
.st-door .nc-tile-visual { margin: 0; height: 108px; }
.st-door-body { padding: 13px 18px 15px; display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.st-door-body.deck { display: block; }
.st-door-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }

/* Deck door: mastered-share donut + labeled counts. */
/* Deck-door journey bar: the four FSRS stages as one left-to-right
   path (new -> learning -> growing -> mastered). Replaced the mastered-%
   donut, which sat at 0% for weeks on a young deck. */
.st-journey { margin: auto 0; padding: 10px 0 0; }
.st-journey-bar {
  display: flex; height: 12px; gap: 2px;
  transform-origin: left center;
  animation: stJourneyGrow 0.8s cubic-bezier(0.25, 0.7, 0.3, 1) 0.15s backwards;
}
@keyframes stJourneyGrow { from { transform: scaleX(0); } }
/* min-width keeps a 1-card stage visible even in a 200-card deck. */
.st-journey-bar .seg { border-radius: 3px; min-width: 7px; }
.st-journey-bar .seg:first-child { border-top-left-radius: 7px; border-bottom-left-radius: 7px; }
.st-journey-bar .seg:last-child { border-top-right-radius: 7px; border-bottom-right-radius: 7px; }
.seg.s-new { background: var(--border); }
.seg.s-learning { background: var(--primary); }
.seg.s-young { background: var(--gold); }
.seg.s-mature { background: var(--green); }
.st-journey-lbls {
  display: flex; flex-wrap: wrap; justify-content: space-between;
  gap: 4px 10px; margin-top: 9px; font-size: 0.74rem;
}
.jl { display: inline-flex; align-items: center; gap: 4px; white-space: nowrap; }
.jl svg { width: 13px; height: 13px; flex: none; }
.jl b { font-weight: 700; font-variant-numeric: tabular-nums; }
/* Empty stages stay visible (they ARE the road ahead) but recede. */
.jl.off { opacity: 0.45; }
.jl-new { color: var(--muted); }
.jl-learning { color: var(--primary-dark); }
.jl-young { color: var(--gold-dark); }
.jl-mature { color: var(--green-dark); }
/* First mastered word earns a little gold seal around the count --
   trophy case, not just a fourth bucket. */
.jl-mature b.stamp {
  width: 17px; height: 17px; border-radius: 50%;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--gold-light); color: var(--gold-dark);
  border: 1px dashed var(--gold-dark); font-size: 0.68rem;
}
/* Tests door: same column skeleton as the deck door, with the featured
   state (placement CTA / latest medal / waiting note) centered in the
   space the deck door spends on its journey bar. */
.st-tests-mid {
  margin: auto 0; padding: 12px 0;
  display: flex; align-items: center; justify-content: center; gap: 12px;
  min-width: 0;
}
.st-tests-cta {
  font-family: var(--font-display);
  display: inline-flex; align-items: center; gap: 6px;
  padding: 9px 18px; border: 1.5px solid var(--primary-dark); border-radius: 999px;
  color: var(--primary-dark); font-weight: 600; font-size: 0.92rem; white-space: nowrap;
  transition: background 0.15s ease, color 0.15s ease;
}
.st-door:hover .st-tests-cta { background: var(--primary-dark); color: #fff; }
.st-tests-latest { min-width: 0; }
.st-tests-latest-t {
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.st-tests-latest-s { font-size: 0.75rem; color: var(--muted); margin-top: 1px; }
.st-tests-pend { display: inline-flex; align-items: center; gap: 8px; color: var(--muted); font-size: 0.88rem; }
/* Medal bands: gold is earned (>=80), green is solid (>=60), a low score
   keeps a neutral ring -- a 7% in a gold trophy read as a joke. */
.st-door-medal {
  width: 46px; height: 46px; border-radius: 50%; flex: none;
  background: var(--gold-light); border: 2px solid var(--gold);
  color: var(--gold-dark); display: flex; align-items: center; justify-content: center;
  font-weight: 700; font-size: 0.8rem; font-variant-numeric: tabular-nums;
}
.st-door-medal.mid { background: var(--green-light); border-color: var(--green); color: var(--green-dark); }
.st-door-medal.low { background: var(--surface-2); border-color: var(--border); color: var(--muted); }

/* The "now" step is a highlighted block inside the path, with the fanned
   加油 cards washing across it as background art -- illustration as
   atmosphere for the primary action, not a sticker beside the button. */
/* The highlighted block treatment applies to the active step AND to any
   step carrying a progress bar -- a completed bar must keep the exact
   same rounded shape as its unfilled state, not collapse into a bare
   square (Duke, 2026-07-27). */
.st-step.now, .st-step.bar {
  /* No horizontal bleed: a -14px margin only clawed back part of
     .st-path's 26px side padding, leaving a ~12px strip of the card's
     own background as a visible frame around the bar on every side --
     which is what read as "not matching the bar behind it" (Duke,
     2026-07-27 screenshot). Sitting in the exact same horizontal bounds
     as every sibling row is what makes it look like one continuous list
     with one row highlighted, not a separate floating card. */
  position: relative; overflow: hidden; border-radius: 14px;
  margin: 6px 0; padding: 16px 14px;
  background:
    radial-gradient(340px 150px at 78% 45%, var(--primary-light), transparent 72%),
    var(--surface-2);
}
.st-step.now, .st-step.now + .st-step,
.st-step.bar, .st-step.bar + .st-step { border-top: 0; }
/* Cleared bar: the frame behind the fill must match it. The base rule
   above always washes the row in --primary-light (orange/coral), which
   reads fine while the fill is the matching orange in-progress gradient
   but clashes once the fill goes green at 100% (Duke, 2026-07-27
   screenshot). Swap the backdrop tint to green so the frame and the
   fill agree once the step is actually done. */
.st-step.bar.done {
  background:
    radial-gradient(340px 150px at 78% 45%, var(--green-light), transparent 72%),
    var(--surface-2);
}
/* The row IS the progress bar (video-game health bar): a translucent
   coral layer grows left-to-right across the whole highlighted row as
   reviews are logged, with a bright leading edge and a slow sheen.
   Content sits above it (z-indexed already).

   The fill's own box is ALWAYS the full inset track (left/right/top/
   bottom: 3px) -- fixed size, so its border-radius nests perfectly inside
   the row's radius at every percentage, including 100%. Progress is
   painted with scaleX from the left edge instead of resizing the box:
   sizing it via width% while only inset-ing the left edge overflowed the
   row by that same inset at 100%, and the parent's overflow:hidden then
   clipped it into a hard square corner instead of a matching curve --
   the seam Duke spotted. transform never has that overflow problem. */
.st-step-fill {
  position: absolute; inset: 3px; z-index: 0; pointer-events: none;
  border-radius: 11px; overflow: hidden;
  transform-origin: left center;
  transform: scaleX(var(--pct, 0));
  background: linear-gradient(90deg, rgba(255, 133, 99, 0.16), rgba(255, 107, 74, 0.34));
  animation: stBarGrow 1s cubic-bezier(0.25, 0.7, 0.3, 1) 0.15s backwards;
}
@keyframes stBarGrow { from { transform: scaleX(0); } to { transform: scaleX(var(--pct, 0)); } }
.st-step-fill::after {
  content: ""; position: absolute; right: 0; top: 0; bottom: 0; width: 3px;
  background: var(--primary-dark); opacity: 0.6;
  box-shadow: 0 0 14px 3px rgba(255, 107, 74, 0.45);
}
.st-step-fill::before {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(105deg, transparent 30%, rgba(255, 255, 255, 0.35) 50%, transparent 70%);
  background-size: 220% 100%;
  animation: stBarSheen 3s ease-in-out 1.2s infinite;
}
@keyframes stBarSheen { from { background-position: 130% 0; } to { background-position: -90% 0; } }
/* Zero state: a slim pulsing sliver at the left edge -- the bar visibly
   waiting to grow. A fixed pixel width, not a scale of the track, so it
   stays a small dot rather than "0% of the width" (which would be 0). */
.st-step-fill.zero {
  transform: none !important; right: auto; width: 14px;
  animation: stSliverPulse 1.9s ease-in-out infinite;
}
.st-step-fill.zero::before, .st-step-fill.zero::after { display: none; }
@keyframes stSliverPulse {
  0%, 100% { opacity: 0.45; }
  50% { opacity: 1; }
}
/* Cleared: the whole bar washes green. The idle sheen and leading edge
   retire -- the celebration sequence supplies its own.
   At 100% the fill also drops the 3px inner-track inset and goes flush
   with the row: once nothing "remaining" is visible, the frame around
   the fill stopped reading as a track and just looked like a mismatched
   box (Duke, 2026-07-27). The gradient lands deeper green at the right
   so the bar visibly ends where it maxed out. */
.st-step-fill.full {
  inset: 0; border-radius: 14px;
  background: linear-gradient(90deg, rgba(74, 217, 145, 0.24), rgba(31, 138, 86, 0.72));
}
.st-step-fill.full::after { display: none; }
.st-step-fill.full::before { animation: none; background: none; }
/* The done/total count sits on the gradient's deep-green end -- white,
   not muted gray, or it disappears into the fill. It's what makes the
   cleared banner retroactively legible as a meter ("this was counting
   something"), so it must stay readable. */
.st-step.bar.done .st-step-quiet { color: #fff; font-weight: 600; }
@media (max-width: 640px) {
  .st-step.now, .st-step.bar { margin: 6px 0; padding: 15px 8px; }
}
@media (prefers-reduced-motion: reduce) {
  .st-step-fill, .st-step-fill::before, .st-step-fill.zero { animation: none; }
}

/* Completion celebration (rebuilt 2026-07-27): a real ~2s sequence, not a
   static green state. The bar sweeps to full, a shine runs across it, the
   check rings out, and confetti launches from points spread ALONG the bar.
   Dormant until JS adds .celebrate so it can't fire mid-render. */
.st-step[data-celebrate] .st-step-dot { position: relative; }
/* Today's cleared pile is a win, not stale history -- override the
   generic dim/strikethrough that other "done" steps get. Ink darker than
   --green-dark: the flush fill behind it is itself mid-green now, and
   green-on-green washed out. Dark mode keeps --green-dark (the fill over
   a dark surface reads dark, so the lighter ink still contrasts). */
.st-step[data-celebrate] .st-step-t { color: #14684a; text-decoration: none; font-weight: 700; }
/* White chip pops the check off the green fill (green-light bg faded
   into it). */
.st-step[data-celebrate] .st-step-dot { background: #fff; border-color: transparent; color: var(--green-dark); }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .st-step[data-celebrate] .st-step-t { color: var(--green); }
  :root:not([data-theme="light"]) .st-step[data-celebrate] .st-step-dot { background: var(--green-light); color: var(--green); }
}

/* 1. The fill sweeps to 100% (slower than the normal grow, so it's felt). */
.st-step.celebrate .st-step-fill {
  animation: stBarGrow 1s cubic-bezier(0.22, 0.7, 0.25, 1) backwards;
}
/* 2. A shine runs the length of the filled bar as it lands. */
.st-step.celebrate .st-step-fill::before {
  content: ""; display: block; position: absolute; inset: 0;
  background: linear-gradient(105deg, transparent 35%, rgba(255, 255, 255, 0.75) 50%, transparent 65%);
  background-size: 250% 100%;
  animation: stWinShine 1.1s ease-out 0.75s 2 backwards;
}
@keyframes stWinShine {
  from { background-position: 160% 0; }
  to { background-position: -110% 0; }
}
/* 3. The whole row gives one soft glow pulse as it completes. */
.st-step.celebrate {
  animation: stRowGlow 1.4s ease-out 0.8s 1;
}
@keyframes stRowGlow {
  0% { box-shadow: 0 0 0 0 rgba(47, 164, 108, 0); }
  30% { box-shadow: 0 0 26px 2px rgba(47, 164, 108, 0.35); }
  100% { box-shadow: 0 0 0 0 rgba(47, 164, 108, 0); }
}
/* 4. Ring pulses out of the checkmark, twice. */
.st-step.celebrate .st-step-dot::after {
  content: ""; position: absolute; inset: -3px; border-radius: 50%;
  border: 2.5px solid var(--green); opacity: 0;
  animation: stRingPulse 1s ease-out 0.85s 2 backwards;
}
@keyframes stRingPulse {
  0% { opacity: 0.9; transform: scale(0.6); }
  100% { opacity: 0; transform: scale(2.6); }
}
.st-step.celebrate .st-step-dot {
  animation: stCheckPop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 0.8s backwards;
}
@keyframes stCheckPop {
  0% { transform: scale(0.5); }
  60% { transform: scale(1.18); }
  100% { transform: scale(1); }
}
.st-step.celebrate .st-step-t {
  animation: stTextPop 0.6s cubic-bezier(0.34, 1.56, 0.64, 1) 0.85s backwards;
}
@keyframes stTextPop {
  0% { transform: scale(0.94); opacity: 0.6; }
  55% { transform: scale(1.06); }
  100% { transform: scale(1); opacity: 1; }
}

/* 5. Confetti: launched from 14 points spread along the bar so the whole
   width celebrates, not one corner. Each piece gets its own vector via
   custom properties. */
.st-burst { position: absolute; inset: 0; overflow: visible; pointer-events: none; z-index: 2; }
.st-burst i {
  position: absolute; top: 50%; width: 7px; height: 9px; border-radius: 2px;
  opacity: 0; background: var(--primary);
}
.st-step.celebrate .st-burst i {
  animation: stBurstFly 1.15s cubic-bezier(0.15, 0.75, 0.35, 1) backwards;
}
@keyframes stBurstFly {
  0% { opacity: 0; transform: translate(0, 0) rotate(0) scale(0.6); }
  12% { opacity: 1; }
  70% { opacity: 1; }
  100% { opacity: 0; transform: translate(var(--dx), var(--dy)) rotate(var(--r)) scale(1); }
}
.st-burst i:nth-child(1) { left: 6%; background: var(--gold); }
.st-step.celebrate .st-burst i:nth-child(1) { animation-delay: 0.89s; --dx: -26px; --dy: -37px; --r: -70deg; }
.st-burst i:nth-child(2) { left: 13%; background: var(--green); }
.st-step.celebrate .st-burst i:nth-child(2) { animation-delay: 0.92s; --dx: -14px; --dy: 44px; --r: 45deg; }
.st-burst i:nth-child(3) { left: 20%; background: var(--primary-dark); }
.st-step.celebrate .st-burst i:nth-child(3) { animation-delay: 0.95s; --dx: 8px; --dy: -51px; --r: 110deg; }
.st-burst i:nth-child(4) { left: 28%; background: var(--gold); }
.st-step.celebrate .st-burst i:nth-child(4) { animation-delay: 0.99s; --dx: 22px; --dy: 32px; --r: -35deg; }
.st-burst i:nth-child(5) { left: 35%; background: var(--green-dark); }
.st-step.celebrate .st-burst i:nth-child(5) { animation-delay: 1.02s; --dx: -18px; --dy: -39px; --r: 80deg; }
.st-burst i:nth-child(6) { left: 42%; background: var(--primary); }
.st-step.celebrate .st-burst i:nth-child(6) { animation-delay: 1.06s; --dx: 14px; --dy: 46px; --r: -120deg; }
.st-burst i:nth-child(7) { left: 49%; background: var(--primary); }
.st-step.celebrate .st-burst i:nth-child(7) { animation-delay: 0.85s; --dx: 28px; --dy: -53px; --r: 60deg; }
.st-burst i:nth-child(8) { left: 56%; background: var(--gold); }
.st-step.celebrate .st-burst i:nth-child(8) { animation-delay: 0.89s; --dx: -8px; --dy: 34px; --r: 95deg; }
.st-burst i:nth-child(9) { left: 63%; background: var(--green); }
.st-step.celebrate .st-burst i:nth-child(9) { animation-delay: 0.92s; --dx: 18px; --dy: -41px; --r: -55deg; }
.st-burst i:nth-child(10) { left: 70%; background: var(--primary-dark); }
.st-step.celebrate .st-burst i:nth-child(10) { animation-delay: 0.95s; --dx: -24px; --dy: 48px; --r: 130deg; }
.st-burst i:nth-child(11) { left: 77%; background: var(--gold); }
.st-step.celebrate .st-burst i:nth-child(11) { animation-delay: 0.99s; --dx: 10px; --dy: -55px; --r: -90deg; }
.st-burst i:nth-child(12) { left: 84%; background: var(--green-dark); }
.st-step.celebrate .st-burst i:nth-child(12) { animation-delay: 1.02s; --dx: 26px; --dy: 36px; --r: 40deg; }
.st-burst i:nth-child(13) { left: 90%; background: var(--primary); }
.st-step.celebrate .st-burst i:nth-child(13) { animation-delay: 1.06s; --dx: -16px; --dy: -43px; --r: 75deg; }
.st-burst i:nth-child(14) { left: 95%; background: var(--primary); }
.st-step.celebrate .st-burst i:nth-child(14) { animation-delay: 0.85s; --dx: 20px; --dy: 50px; --r: -110deg; }

@media (prefers-reduced-motion: reduce) {
  .st-step.celebrate,
  .st-step.celebrate .st-step-fill,
  .st-step.celebrate .st-step-fill::before,
  .st-step.celebrate .st-step-dot,
  .st-step.celebrate .st-step-dot::after,
  .st-step.celebrate .st-step-t,
  .st-step.celebrate .st-burst i { animation: none; }
}

/* Deck door as a live panel: the student's own words drift faintly in the
   background, the journey bar sweeps in, the numbers count up (JS). */
.st-door-live { position: relative; }
/* Starts below the illustrated header so the drifting words live in the
   stats area, not over the picture. */
.st-door-float { position: absolute; left: 0; right: 0; top: 108px; bottom: 0; pointer-events: none; overflow: hidden; }
.st-door-float span {
  position: absolute; font-family: var(--font-display); font-weight: 700;
  color: var(--primary-dark); opacity: 0.08; user-select: none; line-height: 1;
  animation: stCharDrift 9s ease-in-out infinite alternate;
}
.st-door-float span:nth-child(1) { right: 7%; top: 8%; font-size: 2.7rem; }
.st-door-float span:nth-child(2) { right: 28%; bottom: 6%; font-size: 1.9rem; animation-delay: -2.5s; }
.st-door-float span:nth-child(3) { left: 42%; top: 14%; font-size: 1.4rem; animation-delay: -4.2s; }
.st-door-float span:nth-child(4) { right: 8%; bottom: 34%; font-size: 1.5rem; animation-delay: -5.8s; }
.st-door-float span:nth-child(5) { left: 55%; bottom: 24%; font-size: 2.1rem; animation-delay: -7.4s; }
@keyframes stCharDrift {
  from { transform: translateY(-7px) rotate(-4deg); }
  to { transform: translateY(9px) rotate(4deg); }
}
.st-door-body.deck { position: relative; flex: 1; display: flex; flex-direction: column; }
@media (prefers-reduced-motion: reduce) {
  .st-door-float span, .st-journey-bar { animation: none; }
  .st-step-fan i, .st-step-fan b { transition: none; }
}
.st-door-meta { font-size: 0.8rem; color: var(--muted); margin-top: 1px; }
.st-door-arrow { color: var(--primary-dark); flex: none; width: 15px; height: 15px; }

@media (max-width: 640px) {
  .st-doors { grid-template-columns: 1fr; }
  .st-step-cta { padding: 8px 14px; }
  .st-path { padding: 4px 18px 6px; }
  /* No room for the week alongside the greeting, so it drops beneath it
     -- and the wash stays over the greeting rather than stretching to
     cover both. */
  .st-hi { margin: 6px 0 20px; flex-direction: column; align-items: stretch; gap: 12px; }
  .st-hi-line { font-size: 1.85rem; }
  .st-hi-name { font-size: 0.62em; }
  /* The sky keeps a right margin here rather than bleeding off-screen:
     a setting sun sliced flat by the viewport edge looks like a bug, not
     like a sunset. Smaller sun to match the narrower sky. */
  .st-hi-wash { inset: -20px 9% auto -28px; height: 150px; }
  /* A phone has only about 100px between the nav and the week strip, so
     the sun is both smaller and kept to the upper part of that band --
     at full range a low sun sat right on top of the SUN label. */
  .st-hi { --orb-scale: 0.62; --orb-y-scale: 0.58; }
  .st-streak { align-self: flex-start; padding: 0; gap: 14px; }
  .st-streak:hover { border-color: transparent; background: none; }
  /* Room to breathe once it has the width to itself. */
  .st-wk { width: 32px; }
  .st-wk b { width: 24px; height: 24px; border-radius: 8px; }
}

/* The wash is warm colour at low alpha, which reads as a glow on the dark
   canvas rather than as a tint -- pulled back so it lifts the greeting
   without fogging it. */
/* Only the two soft fields get the extra blur on a dark canvas. The sun
   and its rays keep their own, much tighter, values -- blurring those to
   34px turns the one element that tells the time back into a smudge. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .st-hi-wash { opacity: 0.68; }
  :root:not([data-theme="light"]) .w-sky,
  :root:not([data-theme="light"]) .w-glow { filter: blur(34px); }
}
:root[data-theme="dark"] .st-hi-wash { opacity: 0.68; }
:root[data-theme="dark"] .w-sky,
:root[data-theme="dark"] .w-glow { filter: blur(34px); }

/* ==========================================================================
   /study/level -- the student's own Level Check report (2026-07-27)
   Reads top to bottom as one story: where you are, how it moved, what each
   skill is doing, how far up the ladder you still hold ground. Uses the
   dashboard's type system (serif display + Inter) and the same warm card
   surfaces as the rest of the student area, so it lands as part of the app
   rather than as a report bolted onto it.
   ========================================================================== */
.lvl-page { max-width: 720px; }
.lvl-back {
  font-size: 0.85rem; font-weight: 600; color: var(--muted);
  text-decoration: none; white-space: nowrap;
}
.lvl-back:hover { color: var(--text); }

/* ---- hero ---- */
.lvl-hero {
  display: flex; align-items: center; gap: 22px;
  padding: 6px 4px 26px;
}
.lvl-hero-badge { flex: 0 0 auto; }
.lvl-hero-badge .stamp { width: 108px; height: 108px; }
.lvl-hero-badge .score { font-size: 1.6rem; }
.lvl-hero-text { min-width: 0; }
.lvl-eyebrow {
  margin: 0 0 2px; color: var(--muted);
}
.lvl-hero-title {
  margin: 0;  
   line-height: 1.05;  color: var(--text);
}
.lvl-hero-sub {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  margin: 8px 0 0;
}
.lvl-hero-date { font-size: 0.82rem; color: var(--muted); }
.lvl-hero-stats { display: flex; flex-wrap: wrap; gap: 26px; margin-top: 16px; }
.lvl-stat { display: flex; flex-direction: column; gap: 1px; }
.lvl-stat-n {
    font-size: 1.5rem;
  line-height: 1; color: var(--primary); font-variant-numeric: tabular-nums;
}
.lvl-stat-l { font-size: 0.74rem; color: var(--muted); line-height: 1.3; max-width: 13ch; }

/* ---- cards ---- */
.lvl-card {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 20px;
  padding: 22px 20px;
  margin-bottom: 16px;
}
.lvl-card-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 12px; margin-bottom: 18px;
}
.lvl-card-head h2 {
  margin: 0;  
    color: var(--text);
}
.lvl-card-note { font-size: 0.75rem; color: var(--muted); }
.lvl-gain {
  font-size: 0.74rem; font-weight: 700; padding: 3px 9px; border-radius: 20px;
  background: var(--green-light); color: var(--green-dark); white-space: nowrap;
}
.lvl-note { margin: 4px 0 0; font-size: 0.84rem; line-height: 1.55; color: var(--muted); }

/* ---- the climb ---- */
.lvl-chart-wrap { margin: 0 -4px; }
.lvl-chart { width: 100%; height: auto; display: block; overflow: visible; }
.lvl-grid { stroke: var(--border); stroke-width: 1; }
.lvl-grid-lbl { fill: var(--muted); font-size: 9px; opacity: 0.7; font-weight: 600; }
.lvl-line {
  fill: none; stroke: var(--primary); stroke-width: 2.5;
  stroke-linejoin: round; stroke-linecap: round;
}
.lvl-dot { fill: var(--card); stroke: var(--primary); stroke-width: 2.5; }
.lvl-dot.now { fill: var(--primary); }
.lvl-dot-lbl { fill: var(--muted); font-size: 9.5px; font-variant-numeric: tabular-nums; }
/* The line draws itself in on arrival -- one gesture, not a per-element
   cascade, so it reads as the climb happening rather than as decoration. */
@media (prefers-reduced-motion: no-preference) {
  .lvl-line { stroke-dasharray: 1400; animation: lvlDraw 1.1s ease-out 0.15s backwards; }
  .lvl-area { animation: lvlFade 0.7s ease-out 0.7s backwards; }
  .lvl-dot { animation: lvlPop 0.35s cubic-bezier(0.34, 1.56, 0.64, 1) 0.8s backwards; }
  @keyframes lvlDraw { from { stroke-dashoffset: 1400; } }
  @keyframes lvlFade { from { opacity: 0; } }
  @keyframes lvlPop { from { opacity: 0; transform: scale(0.3); transform-origin: center; } }
}

/* ---- skills ---- */
.lvl-skills {
  display: grid; grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: 16px 14px;
}
.lvl-skill { display: flex; align-items: center; gap: 13px; min-width: 0; }
.lvl-dial { flex: 0 0 auto; }
.lvl-skill-meta { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.lvl-skill-name {
  font-size: 0.88rem; font-weight: 700; color: var(--text); line-height: 1.2;
}
.lvl-skill-sub {
  display: flex; align-items: center; gap: 7px;
  font-size: 0.72rem; color: var(--muted);
}
.lvl-delta { font-weight: 700; font-variant-numeric: tabular-nums; }
.lvl-delta.up { color: var(--green-dark); }
.lvl-delta.down { color: var(--primary-dark); }
.lvl-skill.mid .pr-dial-pct { color: var(--gold); }
.lvl-skill.low .pr-dial-pct { color: var(--primary); }
.lvl-skill.mid .pr-dial-fill { stroke: var(--gold); }
.lvl-skill.low .pr-dial-fill { stroke: var(--primary); }

/* One piece of guidance, not four -- a "focus area" per skill would be a
   to-do list nobody acts on. */
.lvl-focus {
  margin-top: 20px; padding: 14px 16px;
  border-radius: 14px; background: var(--surface-2);
  border-left: 3px solid var(--primary);
}
.lvl-focus-tag {
  display: block; font-size: 0.62rem; font-weight: 700;
  letter-spacing: 0.13em; color: var(--primary-dark); margin-bottom: 5px;
}
.lvl-focus p { margin: 0; font-size: 0.88rem; line-height: 1.55; color: var(--text); }

/* ---- the ladder ---- */
.lvl-ladder { display: flex; flex-direction: column; gap: 7px; }
.lvl-rung {
  display: grid; grid-template-columns: 58px 1fr 84px;
  align-items: center; gap: 12px;
  padding: 5px 8px; border-radius: 10px;
}
.lvl-rung-tier {
  font-size: 0.78rem; font-weight: 700; color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.lvl-rung-track {
  height: 9px; border-radius: 20px; background: var(--surface-2); overflow: hidden;
}
.lvl-rung-fill {
  display: block; height: 100%; width: var(--pct, 0%); border-radius: inherit;
  background: var(--green);
  animation: lvlRung 0.8s cubic-bezier(0.25, 0.7, 0.3, 1) 0.3s backwards;
}
@keyframes lvlRung { from { width: 0; } }
.lvl-rung.shaky .lvl-rung-fill { background: var(--gold); }
.lvl-rung.weak .lvl-rung-fill { background: var(--primary); }
.lvl-rung-val {
  font-size: 0.75rem; color: var(--muted); text-align: right;
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.lvl-rung-val i { font-style: normal; opacity: 0.55; font-size: 0.9em; }
.lvl-rung.untested { opacity: 0.45; }
/* The tier they still hold is the answer the whole panel exists to give,
   so it gets the only highlight on the ladder. */
.lvl-rung.ceiling {
  background: var(--surface-2);
  box-shadow: inset 0 0 0 1px var(--border);
}
.lvl-rung.ceiling .lvl-rung-tier { color: var(--text); }
.lvl-ceiling-note { margin-top: 14px; }

.lvl-actions { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-top: 6px; }

/* ---- empty state ---- */
.lvl-empty {
  text-align: center; padding: 44px 24px 40px;
  background: var(--card); border: 1px solid var(--border); border-radius: 20px;
}
.lvl-empty-stamp { margin: 0 auto 20px; width: 96px; height: 96px; }
.lvl-empty-stamp .score { font-size: 1.4rem; }
.lvl-empty h1 {
  margin: 0 0 8px;   
}
.lvl-empty p {
  margin: 0 auto 22px; max-width: 42ch; font-size: 0.9rem;
  line-height: 1.6; color: var(--muted);
}

@media (max-width: 560px) {
  .lvl-hero { flex-direction: column; align-items: flex-start; gap: 16px; padding-bottom: 20px; }
  .lvl-hero-badge .stamp { width: 88px; height: 88px; }
  .lvl-hero-badge .score { font-size: 1.3rem; }
  .lvl-hero-stats { gap: 18px; }
  .lvl-skills { grid-template-columns: 1fr; }
  .lvl-rung { grid-template-columns: 50px 1fr 72px; gap: 8px; }
}

/* Level Check door on /study: a struck HSK medal rather than a third pile of
   cards, so the three doors don't read as the same object three times. */
.lvl-door-ring {
  position: absolute; top: 50%; left: 50%;
  width: 74px; height: 74px; margin: -37px 0 0 -37px;
  border-radius: 50%; border: 2.5px solid var(--gold);
  box-shadow: inset 0 0 0 3px var(--card), inset 0 0 0 4.5px var(--gold-light);
}
.lvl-door-mark {
  position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%) rotate(-8deg);
  font-family: var(--font-display);
  font-weight: 700; font-size: 1.05rem; letter-spacing: 0.02em; color: var(--gold-dark);
}
/* The tests door's medal is a 46px circle sized for "85%"; "HSK 6+" needs a
   pill or it spills out of the ring. */
.st-door-medal.lvl {
  width: auto; height: 30px; padding: 0 12px; border-radius: 20px;
  font-size: 0.82rem; letter-spacing: 0.01em; white-space: nowrap;
}

/* The level standing strip that sat on /study above the doors was removed
   on 2026-08-03 along with its styles: the level is listed with the tests
   now, at /study/tests, one row per sitting. */

/* ---------- Level Check intro screen (redesigned 2026-07-29) ----------
   The page was a bare form on an empty stage: no brand, no way back to
   nihaolingo.com, a raw white native checkbox, and nothing that moved. It is
   the first thing a lot of people ever see of the product, so it now carries
   a real nav bar and a designed entrance. Everything here is scoped to
   .lc-intro / .lc-* so the mid-test and result screens are untouched. */

body.test-stage.lc-intro {
  /* test-stage centers the whole page vertically; the nav bar has to sit at
     the top edge instead, so this screen lays out as a column. */
  display: block;
  padding: 0;
}

/* Same shape as body.nc-app-stage .container: full-bleed, so the shared
   .nc-app-bar-full reaches the viewport edges the way it does on /study
   instead of being boxed into a 792px island. */
body.test-stage.lc-intro .container {
  position: relative;
  z-index: 1;
  max-width: none;
  padding: 0 0 24px;
  margin: 0;
  /* Column, with a floor of one viewport, so the panel below can claim the
     leftover space. min-height rather than height: when the panel is taller
     than the screen the container simply grows and the page scrolls -- the
     auto margins fall to zero rather than pushing the top out of reach. */
  min-height: 100vh;
  display: flex;
  flex-direction: column;
}

/* The panel centres in whatever room the bar leaves, instead of being pinned
   directly under it (Duke, 2026-07-30: "the modal is completely misplaced").
   It was top-anchored, so the result deck -- whose card is 355px tall on the
   words slide and 700px on the finale -- hung off the nav bar with a void of
   varying size underneath, and grew downwards as you stepped through. Centred,
   it grows from the middle and the bar keeps its distance on every slide. */
body.test-stage.lc-intro .container > .card {
  margin: auto;
  width: calc(100% - 32px);
}
/* The width cap the 792px container used to supply. Set on the start card
   only, so the result deck keeps its own narrower certificate widths
   (620px, 720px on the finale) rather than inheriting this one. */

/* ---- Level Check as a series (rebuilt 2026-08-05) ---------------------
   The single dense panel is gone. This screen is now a sequence of scenes --
   welcome, what it is, how it works, then the questions one at a time --
   moved by static/lc-scenes.js. Duke asked for "a theatre vibe", modelled on
   the flashcards first-run intro.

   The engine only moves scenes and toggles .is-playing. Every animation is
   here, so the page degrades to plain markup rather than to a broken script.
   Without JS the .js class never lands, .lc-scene stays in normal flow and
   the whole thing reads as one long page with one working form. */
.lc-series {
  margin: auto;
  width: calc(100% - 32px);
  max-width: 660px;
  padding: 8px 0 28px;
}

/* ---- The stage -------------------------------------------------------- */
/* Height is set by the engine to whichever scene is showing, and tweened, so
   the page settles once per move instead of snapping. */
.js .lc-stage {
  position: relative;
  transition: height 0.34s cubic-bezier(0.16, 1, 0.3, 1);
}

.js .lc-scene {
  position: absolute;
  inset: 0 0 auto;
  opacity: 0;
  pointer-events: none;
  /* NEVER display:none -- Turnstile renders into the final scene on load and
     needs a laid-out box. See _turnstile.html and lc-scenes.js. */
  transition: opacity 0.3s ease, transform 0.34s cubic-bezier(0.16, 1, 0.3, 1);
}
.js .lc-scene.is-active {
  position: relative;
  opacity: 1;
  pointer-events: auto;
  transform: none;
}

.lc-scene { text-align: center; }
/* A narrow measure. A line of body copy that runs the full width of the stage
   reads as a paragraph in a document; held to ~34 characters it reads as a
   statement, which is most of the difference between "informative" and
   "considered". */
.lc-copy { max-width: 34ch; margin: 0 auto; }
.lc-title {
  line-height: 1.12;
  margin: 0;
  color: var(--text);
}
.lc-body {
  margin: 14px 0 0;
  line-height: 1.6;
  color: var(--muted);
}

/* Quiet, small: where a duration or a price belongs. Shares the caps device
   defined with .lc-report-eyebrow; only spacing and weight-of-presence here. */
.lc-meta {
  margin: 16px 0 0;
  opacity: 0.72;
}

/* Parts of the active scene rise in one after another, so a scene assembles
   rather than appearing whole. Slower and gentler than the first pass: the
   quick, snappy version read as an interface reacting; this reads as
   something being placed. */
.js .lc-scene.is-active > * {
  animation: lcRise 0.66s cubic-bezier(0.16, 1, 0.3, 1) backwards;
}
.js .lc-scene.is-active > *:nth-child(1) { animation-delay: 0.05s; }
.js .lc-scene.is-active > *:nth-child(2) { animation-delay: 0.17s; }
.js .lc-scene.is-active > *:nth-child(3) { animation-delay: 0.29s; }
.js .lc-scene.is-active > *:nth-child(4) { animation-delay: 0.39s; }
@keyframes lcRise { from { opacity: 0; transform: translateY(10px); } }

/* ---- Scene 1: the chop presses, characters lift off ------------------- */
.lc-figure {
  position: relative;
  display: grid;
  place-items: center;
  margin: 0 auto 30px;
}
.lc-fig-seal { height: 168px; }
.lc-fig-seal .lc-seal {
  width: clamp(96px, 12vw, 124px);
  height: clamp(96px, 12vw, 124px);
  color: var(--primary);
  transform-origin: 62% 88%;
}
.is-playing .lc-fig-seal .lc-seal {
  animation: lcSealPress 0.85s cubic-bezier(0.22, 1, 0.36, 1) 0.1s backwards,
             lcSealFloat 7s ease-in-out 1.2s infinite;
}
@keyframes lcSealPress {
  0%   { opacity: 0; transform: rotate(10deg) scale(1.14); filter: blur(3px); }
  40%  { opacity: 1; }
  74%  { transform: rotate(-11deg) scale(0.99); filter: blur(0); }
  100% { transform: rotate(-8deg) scale(1); filter: blur(0); }
}
@keyframes lcSealFloat {
  0%, 100% { transform: rotate(-8deg) translateY(0); }
  50%      { transform: rotate(-7.4deg) translateY(-5px); }
}
/* Ink soaking into the paper under the chop. */
.lc-seal-ink {
  position: absolute;
  width: 150px; height: 150px;
  border-radius: 50%;
  background: radial-gradient(circle, var(--coral-glow) 0%, transparent 68%);
  opacity: 0;
}
.is-playing .lc-seal-ink { animation: lcInk 1.2s ease-out 0.34s backwards; }
@keyframes lcInk {
  0%   { opacity: 0;    transform: scale(0.55); }
  30%  { opacity: 0.95; }
  100% { opacity: 0;    transform: scale(1.7); }
}
/* Four characters lifting away from the press, each on its own path. */
.lc-seal-hz {
  position: absolute;
  font-family: var(--font-display);
  font-size: 1.5rem;
  font-weight: 700;
  color: var(--primary);
  opacity: 0;
}
.lc-seal-hz1 { --lc-x: -104px; --lc-y: -46px; }
.lc-seal-hz2 { --lc-x:  100px; --lc-y: -58px; }
.lc-seal-hz3 { --lc-x: -84px;  --lc-y:  44px; }
.lc-seal-hz4 { --lc-x:  92px;  --lc-y:  38px; }
.is-playing .lc-seal-hz { animation: lcLift 2.4s ease-out backwards; }
.is-playing .lc-seal-hz1 { animation-delay: 0.62s; }
.is-playing .lc-seal-hz2 { animation-delay: 0.74s; }
.is-playing .lc-seal-hz3 { animation-delay: 0.86s; }
.is-playing .lc-seal-hz4 { animation-delay: 0.98s; }
@keyframes lcLift {
  0%   { opacity: 0; transform: translate(0, 0) scale(0.6); }
  25%  { opacity: 0.9; }
  100% { opacity: 0; transform: translate(var(--lc-x), var(--lc-y)) scale(1); }
}

/* ---- Scene 2: a sample of the report you earn ------------------------- */
/* Framed like the certificate the result screen hands back: a hairline rule,
   generous air, two facts and nothing else. This replaced a coral bar chart
   sitting under a big word count under a big "10-15 minutes" heading -- three
   things competing to be the focal point, two of them in the same display
   serif, and the least important one (the duration) shouting loudest. */
.lc-fig-report { margin-bottom: 34px; }

.lc-report {
  display: flex;
  align-items: stretch;
  gap: clamp(20px, 6vw, 44px);
  padding: clamp(20px, 5vw, 30px) clamp(24px, 7vw, 44px);
  border-radius: 20px;
  border: 1px solid var(--border);
  background:
    linear-gradient(180deg, color-mix(in srgb, var(--card) 92%, transparent), var(--card));
  box-shadow: 0 18px 44px -28px rgba(0, 0, 0, 0.35);
}

.lc-report-cell {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
}

/* Small letterspaced caps -- ONE definition, shared with .lc-meta below.
   These were two near-identical rules (0.64rem/0.2em here, 0.7rem/0.16em
   there). Two caps styles that differ by half a pixel don't read as two
   styles, they read as carelessness. */
.lc-report-eyebrow, .lc-meta {
  color: var(--muted);
}

/* The hairline between the two facts, full height of the card's content. */
.lc-report-rule {
  width: 1px;
  align-self: stretch;
  background: var(--border);
}

/* The level arrives by counting rather than appearing: a strip of digits
   inside a one-line window, stepped from 1 to 4. The number is the thing
   being measured, so it should look measured. */
.lc-report-roll {
  display: block;
  height: 1em;
  overflow: hidden;
  font-size: clamp(2.6rem, 9vw, 3.4rem);
  line-height: 1;
  color: var(--primary);
}
.lc-report-roll i {
  display: block;
  height: 1em;
  font-style: normal;
}
/* Every digit moves, not just the first. Translating only i:first-child slid
   that one out of the window and left 2-4 sitting exactly where they were,
   clipped -- so the cell rendered empty. Shifting the whole strip by 3em
   brings the fourth digit into the window. */
.is-playing .lc-report-roll i {
  animation: lcRoll 1.15s cubic-bezier(0.5, 0, 0.2, 1) 0.45s both;
}
@keyframes lcRoll {
  from { transform: translateY(0); }
  to   { transform: translateY(-3em); }
}

.lc-report-words {
  font-size: clamp(2rem, 7vw, 2.6rem);
  line-height: 1;
  color: var(--text);
  font-variant-numeric: tabular-nums;
}
.lc-report-words em {
  font-style: normal;
  font-size: 0.5em;
  color: var(--primary);
  margin-left: 4px;
}

/* The card settles in; nothing bounces. */
.is-playing .lc-report { animation: lcSettle 0.7s cubic-bezier(0.16, 1, 0.3, 1) 0.1s backwards; }
@keyframes lcSettle {
  from { opacity: 0; transform: translateY(14px) scale(0.985); }
}

/* ---- Scene 3: the three features ------------------------------------- */
.lc-feats {
  list-style: none;
  margin: 26px 0 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 14px;
  text-align: left;
}
.lc-feat {
  display: flex;
  align-items: center;
  gap: 15px;
  padding: 14px 16px;
  border-radius: 16px;
  background: var(--surface-2);
  border: 1px solid var(--border);
}
.js .lc-scene.is-active .lc-feat { animation: lcRise 0.55s cubic-bezier(0.16, 1, 0.3, 1) backwards; }
.js .lc-scene.is-active .lc-feat:nth-child(1) { animation-delay: 0.22s; }
.js .lc-scene.is-active .lc-feat:nth-child(2) { animation-delay: 0.40s; }
.js .lc-scene.is-active .lc-feat:nth-child(3) { animation-delay: 0.58s; }
.lc-feat-art {
  flex: none;
  width: 52px; height: 36px;
  display: grid;
  place-items: center;
  color: var(--primary);
}
.lc-feat-art svg { width: 44px; height: 30px; overflow: visible; }
.lc-feat-text { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
/* Inter, not the display serif. A 16px serif bold sitting directly on top of
   a 14px Inter line inside a small card reads as two unrelated styles, not as
   a hierarchy -- there isn't enough size between them for the serif to be
   leading anything. Same family, two weights, does the job quietly. */
.lc-feat-text strong {
  font-family: var(--font-sans);
  font-size: var(--t-body);
  font-weight: 600;
  color: var(--text);
}
.lc-feat-text span { font-size: var(--t-body-sm); line-height: 1.4; color: var(--muted); }

/* The adaptive staircase draws itself, so "adapts" is shown as a path that
   steps up and down rather than stated. */
.lc-adapt-path { stroke-dasharray: 96; stroke-dashoffset: 96; }
.is-playing .lc-adapt-path { animation: lcDraw 1.1s ease 0.5s forwards; }
@keyframes lcDraw { to { stroke-dashoffset: 0; } }

/* Six blocks for six levels, lighting left to right. */
.lc-art-scope { display: flex; gap: 3px; align-items: flex-end; }
.lc-art-scope i {
  width: 5px;
  border-radius: 2px;
  background: color-mix(in srgb, var(--primary) 26%, transparent);
}
.lc-art-scope i:nth-child(1) { height: 9px; }
.lc-art-scope i:nth-child(2) { height: 13px; }
.lc-art-scope i:nth-child(3) { height: 17px; }
.lc-art-scope i:nth-child(4) { height: 21px; }
.lc-art-scope i:nth-child(5) { height: 25px; }
.lc-art-scope i:nth-child(6) { height: 29px; }
/* `both`, not `backwards`: the lit colour is the END state and has to stay.
   With backwards the blocks flashed coral and dropped back to the pale
   resting fill the moment each animation finished. */
.is-playing .lc-art-scope i { animation: lcLitBlock 0.4s ease both; }
.is-playing .lc-art-scope i:nth-child(1) { animation-delay: 0.70s; }
.is-playing .lc-art-scope i:nth-child(2) { animation-delay: 0.78s; }
.is-playing .lc-art-scope i:nth-child(3) { animation-delay: 0.86s; }
.is-playing .lc-art-scope i:nth-child(4) { animation-delay: 0.94s; }
.is-playing .lc-art-scope i:nth-child(5) { animation-delay: 1.02s; }
.is-playing .lc-art-scope i:nth-child(6) { animation-delay: 1.10s; }
@keyframes lcLitBlock {
  from { opacity: 0; transform: scaleY(0.3); }
  to   { opacity: 1; background: var(--primary); }
}

/* The result bars grow from the baseline. */
.lc-bar { transform-origin: bottom; }
.is-playing .lc-art-result .lc-bar { animation: lcGrow 0.5s cubic-bezier(0.2, 1.4, 0.4, 1) backwards; }
.is-playing .lc-art-result .lc-bar:nth-child(1) { animation-delay: 0.88s; }
.is-playing .lc-art-result .lc-bar:nth-child(2) { animation-delay: 0.98s; }
.is-playing .lc-art-result .lc-bar:nth-child(3) { animation-delay: 1.08s; }
.is-playing .lc-art-result .lc-bar:nth-child(4) { animation-delay: 1.18s; }
@keyframes lcGrow { from { opacity: 0; transform: scaleY(0); } }

/* ---- The question scenes --------------------------------------------- */
/* Labels in Inter, not the display serif at weight 800.
   `.field-label` is a shared component used across signup, settings and the
   test pages, so this overrides it HERE rather than changing it everywhere --
   the rest of the app has its own balance and is not being redesigned.

   The rule this restores is the project's own: the serif leads, Inter
   supports. On a question scene the serif heading is the lead; a label naming
   the box under it is support, and dressing it in the heaviest serif on the
   page gave one short screen three competing voices (Duke, 2026-08-05: "why
   is the fonts style so messy, like i feel like im seeing many styles"). */
/* Colour only. This rule used to restate the family, size and weight so
   that the Level Check's labels would not come out in the display serif
   that .field-label carried everywhere else -- one label class, two
   faces, decided by page. .field-label takes the label role now, so
   there is nothing left here to disagree about. */
.lc-series .field-label {
  color: var(--muted);
}
.lc-series .field-label .required { font-family: var(--font-sans); font-weight: 700; }

/* A sane measure with the labels back on the inputs' own left edge: a centred
   label floating over a wide input reads as a misalignment. */
.lc-fields {
  width: 100%;
  max-width: 440px;
  margin: 24px auto 0;
  text-align: left;
}
.lc-ask .lc-fields { margin-top: 28px; }

/* The message sits under the field it belongs to and only when it applies. */
.lc-fielderr {
  margin: 8px 2px 0;
  font-size: 0.86rem;
  font-weight: 600;
  color: var(--danger, #c0392b);
  display: none;
}
.lc-fielderr.is-on { display: block; animation: lcRise 0.3s ease; }
.lc-series input.has-error { border-color: var(--danger, #c0392b); }

.lc-notice { width: 100%; max-width: 440px; margin: 22px auto 0; text-align: left; }

/* ---- The final scene -------------------------------------------------- */
/* A column, so the confirmation sits UNDER the button. Both are inline-level
   otherwise and shared a line, which pushed Start off the centre axis. */
.lc-go {
  margin-top: 28px;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.lc-start {
  font-size: 1.05rem;
  padding: 14px 34px;
  border-radius: 999px;
  box-shadow: 0 10px 26px var(--coral-glow);
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), box-shadow 0.18s ease, background 0.15s ease;
}
.lc-start:hover { transform: translateY(-2px); box-shadow: 0 16px 34px var(--coral-glow); }
.lc-start:active { transform: translateY(0); }

/* The signed-in student's confirmation: the one thing the nav bar's account
   chip cannot tell them. */
.lc-saved-note {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  margin: 16px 0 0;
  font-family: var(--font-sans);
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--green-dark);
}
.lc-saved-note svg { flex: none; }

/* ---- Chrome: back, dots, next ---------------------------------------- */
/* A `display` declaration in a class beats the user-agent's [hidden] rule,
   so every control here needs to say so explicitly. Without this the Next
   arrow stayed on screen on the final scene beside the Start button, Skip
   never went away -- and with JavaScript off the whole navigation row showed
   up on a page where there is nothing to navigate. */
.lc-chrome[hidden], .lc-nav[hidden], .lc-skip[hidden] { display: none; }
/* Next on the final scene: still occupying its slot, just not there. */
.lc-nav.is-invisible { visibility: hidden; pointer-events: none; }

.lc-chrome {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 20px;
  margin-top: 30px;
}
.lc-nav {
  width: 46px; height: 46px;
  border-radius: 50%;
  display: grid;
  place-items: center;
  border: 1.5px solid var(--border);
  background: var(--card);
  color: var(--text);
  cursor: pointer;
  transition: transform 0.18s cubic-bezier(0.34, 1.56, 0.64, 1), border-color 0.16s ease,
              background 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease;
}
.lc-nav:hover:not(:disabled) {
  border-color: var(--primary);
  color: var(--primary);
  transform: translateY(-2px);
}
.lc-nav:disabled { opacity: 0.32; cursor: default; }
/* Next is the one thing to press on every scene but the last, so it carries
   the weight -- and a slow pulse, so it reads as the way on. */
.lc-next {
  background: var(--primary);
  border-color: var(--primary);
  color: #fff;
  box-shadow: 0 8px 20px var(--coral-glow);
}
.lc-next:hover:not(:disabled) { background: var(--primary-dark); color: #fff; }
/* A slow, faint halo -- a signal that this is the way on, not a nag. The
   first pass pulsed every 2.8s at 45% opacity with a 2px ring, which on a
   quiet page reads as an app trying to get your attention. Hairline, half
   as opaque, and a long gap between breaths. */
.lc-next::after {
  content: "";
  position: absolute;
  inset: -5px;
  border-radius: 50%;
  border: 1px solid var(--primary);
  opacity: 0;
  pointer-events: none;
  animation: lcPing 4.6s cubic-bezier(0.16, 1, 0.3, 1) infinite;
}
.lc-next { position: relative; }
@keyframes lcPing {
  0%   { opacity: 0.28; transform: scale(0.9); }
  55%  { opacity: 0;    transform: scale(1.16); }
  100% { opacity: 0;    transform: scale(1.16); }
}

.lc-dots { display: flex; gap: 6px; }
.lc-dots span {
  width: 5px; height: 5px;
  border-radius: 50%;
  background: var(--border);
  transition: background 0.3s ease, transform 0.3s cubic-bezier(0.16, 1, 0.3, 1);
}
.lc-dots span.on { background: var(--primary); transform: scale(1.5); }

/* Offered only from the second visit onwards -- see lc-scenes.js. */
.lc-skip {
  display: block;
  margin: 16px auto 0;
  background: none;
  border: 0;
  font-family: var(--font-sans);
  font-size: 0.85rem;
  font-weight: 600;
  color: var(--muted);
  cursor: pointer;
  text-decoration: underline;
  text-underline-offset: 3px;
}
.lc-skip:hover { color: var(--text); }

@media (max-width: 560px) {
  .lc-fig-seal { height: 140px; }
  .lc-chrome { gap: 16px; margin-top: 26px; }
}

/* Motion off: the scenes still change, they just stop performing. */
@media (prefers-reduced-motion: reduce) {
  .js .lc-scene, .js .lc-scene.is-active > *,
  .lc-seal, .lc-seal-ink, .lc-seal-hz, .lc-report, .lc-report-roll i,
  .lc-feat, .lc-adapt-path, .lc-art-scope i, .lc-bar, .lc-next::after {
    animation: none !important;
    transition: none !important;
  }
  .lc-seal-hz, .lc-seal-ink, .lc-next::after { display: none; }
  .lc-adapt-path { stroke-dashoffset: 0; }
  /* The roll can't play, so park the window on the final digit. */
  .lc-report-roll i { transform: translateY(-3em); }
}

/* ---- Fields ---- */
/* The base rule only swaps the border colour on focus, which on the dark
   theme is nearly invisible. A coral halo makes it obvious which field is
   live -- this is a form people fill in on a phone. */
.lc-series input[type="text"],
.lc-series input[type="email"] {
  background: var(--surface-2);
  transition: border-color 0.16s ease, box-shadow 0.16s ease, background 0.16s ease;
}
.lc-series input[type="text"]:focus,
.lc-series input[type="email"]:focus {
  background: var(--card);
  box-shadow: 0 0 0 3px var(--coral-glow);
}

/* The dashboard's own measure. RESTORED 2026-08-05: this rule was deleted by
   accident in afd03eb, a Level Check commit that had no business touching it,
   and nothing since had defined .dash -- so the student home page lost its
   1040px measure and its centring and ran the full width of the window.
   --dash-sh went with it, leaving three var(--dash-sh) shadows further down
   resolving to nothing. Both are back. */
.dash { --dash-sh: 0 2px 10px -5px rgba(60,40,20,0.18); max-width: 1040px; margin: 0 auto; padding: 4px 0 52px; }

/* ---- Hero ---------------------------------------------------------------- */
.dash-hero { margin: 12px 0 22px; }
.dash-hello {
  line-height: 1.1;
  color: var(--text);
  margin: 0;
}
.dash-hello-name { color: var(--primary); }
.dash-hello-sep { color: var(--text); }
.dash-hello-wave {
  display: inline-block;
  margin-left: 6px;
  transform-origin: 70% 80%;
  animation: dash-wave 2.6s ease-in-out 0.4s 2;
}
@keyframes dash-wave {
  0%, 60%, 100% { transform: rotate(0deg); }
  10%, 30% { transform: rotate(16deg); }
  20%, 40% { transform: rotate(-10deg); }
}
.dash-sub { margin: 6px 0 0; color: var(--muted); font-size: 0.94rem; }

/* ---- Stat coins ---------------------------------------------------------- */
.dash-stats {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 12px;
  margin-bottom: 18px;
}
.dash-stat {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 15px 16px 14px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 15px;
  box-shadow: var(--dash-sh);
  text-decoration: none;
  color: var(--text);
  overflow: hidden;
  transition: transform 0.16s cubic-bezier(.2,.8,.2,1), box-shadow 0.16s ease, border-color 0.16s ease;
  animation: dash-rise 0.45s cubic-bezier(.2,.8,.2,1) backwards;
  animation-delay: calc(var(--i, 0) * 55ms);
}
.dash-stat::after {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(120% 90% at 100% 0%, var(--coral-glow), transparent 60%);
  opacity: 0;
  transition: opacity 0.16s ease;
  pointer-events: none;
}
.dash-stat:hover {
  transform: translateY(-3px);
  box-shadow: 0 10px 22px -14px rgba(60,40,20,0.4);
  border-color: var(--primary-light);
}
.dash-stat:hover::after { opacity: 1; }
.dash-stat-ico {
  display: grid;
  place-items: center;
  width: 32px; height: 32px;
  border-radius: 10px;
  background: var(--surface-2);
  color: var(--primary);
  margin-bottom: 6px;
  transition: transform 0.16s ease;
}
.dash-stat:hover .dash-stat-ico { transform: rotate(-6deg) scale(1.06); }
.dash-stat-ico svg { width: 18px; height: 18px; }
.dash-stat-num {
  font-size: 1.85rem;
  line-height: 1.05;
}
.dash-stat-label { color: var(--muted); font-size: 0.85rem; font-weight: 500; }
.dash-stat-foot { color: var(--primary); font-size: 0.76rem; font-weight: 600; margin-top: 1px; }
.dash-stat.is-hot { background: linear-gradient(150deg, var(--primary), var(--primary-dark)); border-color: transparent; }
.dash-stat.is-hot .dash-stat-num,
.dash-stat.is-hot .dash-stat-label { color: #fff; }
.dash-stat.is-hot .dash-stat-ico { background: rgba(255,255,255,0.22); color: #fff; }
.dash-stat.is-hot::after { display: none; }

/* ---- Two-panel row ------------------------------------------------------- */
.dash-grid {
  display: grid;
  grid-template-columns: 1.25fr 0.75fr;
  gap: 14px;
  margin-bottom: 24px;
  align-items: start;
}
.dash-panel {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 15px;
  box-shadow: var(--dash-sh);
  padding: 15px 17px 16px;
}
.dash-panel-head {
  display: flex;
  align-items: baseline;
  gap: 10px;
  margin-bottom: 8px;
}
.dash-panel-head h2 {
  margin: 0;
  color: var(--text);
  white-space: nowrap;
}
.dash-panel-count {
  font-size: 0.72rem;
  font-weight: 700;
  color: #fff;
  background: var(--primary);
  border-radius: 999px;
  padding: 1px 8px;
}
.dash-panel-link {
  margin-left: auto;
  font-size: 0.82rem;
  font-weight: 600;
  color: var(--primary);
  text-decoration: none;
  white-space: nowrap;
  flex: none;
}
.dash-panel-link:hover { text-decoration: underline; }
.dash-empty {
  color: var(--muted);
  font-size: 0.9rem;
  padding: 14px 4px;
  margin: 0;
  text-align: center;
}

/* Review list */
.dash-review-list, .dash-queue-list { list-style: none; margin: 0; padding: 0; }
.dash-review-item {
  display: flex;
  align-items: center;
  gap: 11px;
  padding: 8px 9px;
  border-radius: 11px;
  text-decoration: none;
  color: var(--text);
  transition: background 0.13s ease;
}
.dash-review-item:hover { background: var(--surface-2); }
.dash-review-ava {
  flex: none;
  width: 32px; height: 32px;
  display: grid; place-items: center;
  border-radius: 9px;
  background: var(--primary-light);
  color: var(--primary-dark);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.92rem;
}
.dash-review-body { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 1px; }
.dash-review-who { font-weight: 600; font-size: 0.9rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dash-review-test { color: var(--muted); font-size: 0.8rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.dash-review-side { display: flex; align-items: center; gap: 10px; flex: none; }
.dash-review-date { color: var(--muted); font-size: 0.74rem; white-space: nowrap; }
.dash-score {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 0.82rem;
  padding: 1px 8px;
  border-radius: 999px;
  white-space: nowrap;
}
.dash-score.good { background: var(--green-light); color: var(--green-dark); }
.dash-score.mid { background: var(--gold-light); color: var(--gold-dark); }
.dash-score.low { background: var(--danger-wash); color: var(--primary-dark); }
.dash-score.none { background: var(--surface-2); color: var(--muted); font-size: 0.72rem; font-family: var(--font-sans); font-weight: 600; }

/* Queue list */
.dash-queue-item {
  display: flex;
  align-items: flex-start;
  gap: 9px;
  padding: 8px 9px;
  border-radius: 11px;
  text-decoration: none;
  color: var(--text);
  transition: background 0.13s ease;
}
.dash-queue-item:hover { background: var(--surface-2); }
.dash-queue-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; margin-top: 5px; background: var(--gold); }
.dash-queue-dot.dash-st-in_progress { background: var(--primary); }
.dash-queue-dot.dash-st-pending { background: var(--gold); }
.dash-queue-body { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
.dash-queue-who { font-weight: 600; font-size: 0.86rem; }
.dash-queue-desc { color: var(--muted); font-size: 0.8rem; line-height: 1.35; overflow: hidden; text-overflow: ellipsis; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; }

/* ---- Quick-action tiles -------------------------------------------------- */
.dash-quick-title {
  margin: 0 0 11px;
  color: var(--text);
}
.dash-quick {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
}
.dash-tile {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 13px 14px;
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: 14px;
  box-shadow: var(--dash-sh);
  text-decoration: none;
  color: var(--text);
  transition: transform 0.16s cubic-bezier(.2,.8,.2,1), box-shadow 0.16s ease, border-color 0.16s ease;
}
.dash-tile:hover {
  transform: translateY(-3px);
  box-shadow: 0 10px 22px -14px rgba(60,40,20,0.4);
  border-color: var(--primary-light);
}
.dash-tile-ico {
  flex: none;
  display: grid; place-items: center;
  width: 38px; height: 38px;
  border-radius: 11px;
  background: var(--surface-2);
  color: var(--primary);
  transition: transform 0.16s ease;
}
.dash-tile:hover .dash-tile-ico { transform: rotate(-6deg) scale(1.06); }
.dash-tile-ico svg { width: 20px; height: 20px; }
.dash-tile-txt { display: flex; flex-direction: column; min-width: 0; flex: 1; gap: 1px; }
.dash-tile-name { font-weight: 600; font-size: 0.92rem; }
.dash-tile-sub { color: var(--muted); font-size: 0.78rem; }
.dash-tile-arrow {
  flex: none;
  color: var(--muted);
  font-size: 1.05rem;
  transform: translateX(-2px);
  opacity: 0;
  transition: transform 0.16s ease, opacity 0.16s ease;
}
.dash-tile:hover .dash-tile-arrow { transform: translateX(2px); opacity: 1; color: var(--primary); }
.dash-tile-primary { background: linear-gradient(150deg, var(--primary), var(--primary-dark)); border-color: transparent; }
.dash-tile-primary .dash-tile-name,
.dash-tile-primary .dash-tile-arrow { color: #fff; }
.dash-tile-primary .dash-tile-sub { color: rgba(255,255,255,0.85); }
.dash-tile-primary .dash-tile-ico { background: rgba(255,255,255,0.22); color: #fff; }
.dash-tile-primary:hover { border-color: transparent; }
.dash-tile-primary .dash-tile-arrow { opacity: 0.8; }
.dash-tile-primary:hover .dash-tile-arrow { opacity: 1; color: #fff; }

@keyframes dash-rise {
  from { opacity: 0; transform: translateY(12px); }
  to { opacity: 1; transform: translateY(0); }
}

/* ---- Responsive ---------------------------------------------------------- */
@media (max-width: 860px) {
  .dash-stats { grid-template-columns: repeat(2, 1fr); }
  .dash-grid { grid-template-columns: 1fr; }
  .dash-quick { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 540px) {
  .dash-stats { grid-template-columns: repeat(2, 1fr); gap: 10px; }
  .dash-quick { grid-template-columns: 1fr; }
  .dash-stat { padding: 13px 13px 12px; }
  .dash-stat-num { font-size: 1.7rem; }
  .dash-review-side { flex-direction: column; align-items: flex-end; gap: 3px; }
}

@media (prefers-reduced-motion: reduce) {
  .dash-stat, .dash-tile { animation: none; transition: none; }
  .dash-hello-wave { animation: none; }
  .dash-stat:hover, .dash-tile:hover { transform: none; }
  .dash-stat:hover .dash-stat-ico, .dash-tile:hover .dash-tile-ico { transform: none; }
  .dash-tile-arrow { opacity: 1; }
}

/* ==========================================================================
   Login redesign v2 2026-08-04: no card. A full-bleed split "studio door".
   Left = a coral brand stage with a giant 你好 watermark and the animated
   wordmark; right = the form floating on the page, underline inputs, no box.
   Supersedes the .login-card version above (that markup is no longer emitted).
   ========================================================================== */
body.login-stage .container { max-width: none; padding: 0; margin: 0; }
body.login-stage .lang-switch { z-index: 10; }

.signin { display: flex; min-height: 100vh; }

/* ---- Left: branded coral stage ---- */
.signin-brand {
  position: relative; overflow: hidden;
  flex: 1 1 54%;
  display: flex; flex-direction: column; justify-content: center;
  padding: 9vh 6vw;
  background:
    radial-gradient(120% 90% at 15% 12%, rgba(255,255,255,0.16), transparent 55%),
    linear-gradient(155deg, var(--primary) 0%, var(--primary-dark) 62%, #e05433 100%);
  color: #fff;
}
/* Giant hello, tone-on-tone, breathing slowly behind the wordmark. */
.signin-hanzi {
  position: absolute; right: -3vw; bottom: -12vh; z-index: 0;
  font-family: var(--font-hanzi-serif);
  font-size: 52vh; line-height: 0.78; font-weight: 700;
  letter-spacing: -0.04em;
  color: rgba(255, 255, 255, 0.11);
  pointer-events: none; user-select: none;
  transform-origin: 70% 70%;
  animation: signin-breathe 9s ease-in-out infinite;
}
@keyframes signin-breathe {
  0%, 100% { transform: scale(1); }
  50% { transform: scale(1.035); }
}
.signin-brand-inner { position: relative; z-index: 1; }
.signin-mark { display: block; font-size: clamp(2.4rem, 5.2vw, 4rem); line-height: 1; white-space: nowrap; }
/* Recolour the wordmark for the coral panel: cream names, a gold heartbeat,
   and a warm gold highlight drifting through Stasya (the app-default shimmer is
   coral-on-coral here and would vanish). */
/* Literal warm gold, not var(--gold-light): the coral stage is the same in
   light and dark, but that token flips to a dark value in dark mode and turned
   the & near-black. */
.signin-brand .aw-duke { color: #fff; }
.signin-brand .aw-amp { color: #ffe1b3; }
.signin-brand .aw-stasya { color: #fff; }
@supports ((-webkit-background-clip: text) or (background-clip: text)) {
  .signin-brand .aw-stasya {
    background-image: linear-gradient(100deg, #fff 0%, #fff 40%, #ffe1b3 50%, #fff 60%, #fff 100%);
    background-size: 260% 100%;
    -webkit-background-clip: text; background-clip: text;
    -webkit-text-fill-color: transparent;
  }
}
.signin-tagline {
  margin: 20px 0 0; max-width: 20ch;
  font-family: var(--font-display);
  font-size: clamp(1.05rem, 1.5vw, 1.5rem);
  font-weight: 400; line-height: 1.35;
  color: rgba(255, 255, 255, 0.92);
}

/* ---- Right: the form, no card ---- */
.signin-panel {
  flex: 1 1 46%;
  display: flex; align-items: center; justify-content: center;
  padding: 9vh 6vw;
}
.signin-form-inner { width: 100%; max-width: 360px; }
.signin-eyebrow {
  margin: 0 0 12px;
  color: var(--primary);
}
.signin-greet {
  margin: 0 0 4px;
  color: var(--text);
}
.login-wave { display: inline-block; transform-origin: 70% 80%; animation: dash-wave 2.6s ease-in-out 0.5s 2; }
.signin-sub { margin: 0 0 30px; color: var(--muted); font-size: 1rem; }
.signin-error {
  background: var(--danger-wash); color: var(--accent);
  padding: 11px 14px; border-radius: 10px; margin-bottom: 20px;
  font-weight: 500; font-size: 0.95rem;
}
.signin-field { margin-bottom: 24px; }
.signin-field label {
  display: block; margin-bottom: 8px;
  text-transform: uppercase;
  color: var(--muted);
}
/* Scoped to .signin-form so it out-specifies the global input[type="text"]
   rule (0,1,1) -- without this the username field kept its boxed styling
   while the password field went underline, and the two didn't match. */
.signin-form .signin-input {
  width: 100%;
  border: none;
  /* A soft coral rule, not the neutral --border (which nearly vanished on the
     dark panel). color-mix keeps it on-brand and legible on both the light
     paper and the dark ground; the shorthand fallback covers old browsers. */
  border-bottom: 2px solid var(--border);
  border-bottom-color: color-mix(in srgb, var(--primary) 42%, transparent);
  border-radius: 0; background: transparent;
  padding: 8px 2px; font-size: 1.15rem;
  font-family: inherit; color: var(--text);
  -webkit-appearance: none; appearance: none;
  box-shadow: none;
  transition: border-color 0.18s ease;
}
.signin-form .signin-input::placeholder { color: var(--muted); opacity: 0.55; }
.signin-form .signin-input:focus { outline: none; border-bottom-color: var(--primary); box-shadow: none; }
.signin-btn {
  margin-top: 6px; width: 100%;
  display: inline-flex; align-items: center; justify-content: center; gap: 10px;
  background: var(--primary); color: #fff;
  border: none; border-radius: 14px;
  padding: 15px; font-size: 1.05rem; font-weight: 700;
  font-family: inherit; cursor: pointer;
  transition: background 0.15s, transform 0.15s ease, box-shadow 0.15s ease;
}
.signin-btn:hover {
  background: var(--primary-dark);
  transform: translateY(-2px);
  box-shadow: 0 14px 30px -14px var(--primary-dark);
}
.signin-arrow { transition: transform 0.15s ease; }
.signin-btn:hover .signin-arrow { transform: translateX(4px); }

/* ---- Stacked on narrow screens ---- */
@media (max-width: 820px) {
  .signin { flex-direction: column; min-height: 100vh; }
  .signin-brand { flex: 0 0 auto; padding: 8vh 8vw 6vh; }
  .signin-hanzi { font-size: 40vh; right: -6vw; bottom: -14vh; }
  .signin-panel { flex: 1 1 auto; padding: 7vh 8vw 9vh; }
  .signin-form-inner { max-width: 420px; margin: 0 auto; }
}

@media (prefers-reduced-motion: reduce) {
  .signin-hanzi { animation: none; }
  .login-wave { animation: none; }
  .signin-btn:hover { transform: none; }
  .signin-btn:hover .signin-arrow { transform: none; }
}

/* ============================ Error page (404 / 500) =======================
   RESTORED 2026-08-05. Every rule below was deleted by accident in afd03eb --
   a commit whose subject was "Level Check: drop the final scene". It removed
   the terms checkbox it meant to remove AND this entire block, and nothing
   noticed because a stylesheet cannot fail loudly: the page just quietly
   started rendering as an unstyled document.

   What that looked like, and why it is worth recognising next time: the hero
   <svg> carries a square viewBox and no width, so with no CSS it stretched to
   its container's full width and, being square, became just as TALL -- an
   ~800px block that pushed every word below the fold. "404" dropped to 16px
   body text. Only "Page not found" still looked deliberate, because it is an
   <h1> and picked up the global heading default by luck.

   The design: a wrong turn kept as part of the product. The same warm
   practice stage as a test, a wordmark so the page has a head rather than a
   floating card, and a hero where the thematic character is written stroke by
   stroke on a 米字格 grid by a calligraphy brush (err404.js).

   The type is NOT restated here -- it comes from the roles at the top of this
   file (.err-code is a figure, .err-heading a title, .err-zi-cap the serif
   kicker, .err-message body). That is the difference between this copy and
   the one that was lost: there is now nothing here for a stray edit to
   silently take away, because the sizes do not live here. */
body.err-stage {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  padding: 0;
  background:
    radial-gradient(ellipse 900px 520px at 50% -8%, var(--coral-glow), transparent 62%),
    repeating-linear-gradient(90deg, var(--grid-line) 0 1px, transparent 1px 56px),
    repeating-linear-gradient(0deg, var(--grid-line) 0 1px, transparent 1px 56px),
    var(--stage-bg);
  background-attachment: fixed;
}
/* This is the centring. The stage is a column flexbox the height of the
   viewport and the container is the growing child, so the card sits in the
   optical middle of the window at any height. */
body.err-stage .container {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  max-width: 620px;
  padding: 80px 20px 56px;
}

/* Wordmark, top-left, so the fixed RU/EN pill has a partner in the corner.
   Its size comes from .recall-wordmark, which the template also sets -- this
   rule only moves it. */
.err-wordmark {
  position: fixed;
  top: 16px;
  left: 20px;
  z-index: 40;
  color: var(--text);
  text-decoration: none;
}
@media (max-width: 480px) {
  .err-wordmark { top: 11px; left: 14px; }
}

.err-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  animation: err-rise 0.6s cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes err-rise {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: none; }
}

/* Hero: the practice cell + its caption. The two min() sizes are the whole
   reason the illustration stays an illustration -- they are what was missing
   while this block was gone. */
.err-hero { margin: 0 0 30px; }
.err-writer {
  width: min(260px, 62vw);
  height: min(260px, 62vw);
  margin: 0 auto;
  position: relative;
}
.err-write { width: 100%; height: 100%; display: block; overflow: visible; }

/* Fallback glyph: shown when the brush animation cannot load. Once err404.js
   replaces .err-writer's contents with the SVG, this element goes with it.
   Artwork, so it keeps its own size rather than taking a role. */
.err-zi-fallback {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  height: 100%;
  font-family: var(--font-hanzi-serif);
  font-size: 8rem;
  line-height: 1;
  color: var(--text);
}

.err-brush { transition: opacity 0.18s ease; }
.err-brush.is-lifted { opacity: 0; }

.err-zi-cap { margin-top: 16px; color: var(--muted); }
/* The figure role gives this its face and weight; the SIZE stays here,
   because that is the rule for figures -- they are sized by the space they
   occupy, not by the text scale. This one is the largest thing on the page
   on purpose: the code is the answer to "what went wrong". */
.err-code { font-size: clamp(3rem, 12vw, 4.2rem); line-height: 1; color: var(--text); }
.err-heading { line-height: 1.2; margin: 8px 0 12px; color: var(--muted); }
.err-message { margin: 0 auto; max-width: 34ch; color: var(--muted); line-height: 1.55; }

.err-actions {
  margin-top: 26px;
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  justify-content: center;
}
.err-btn-home { box-shadow: 0 10px 24px -12px var(--primary-dark); }
.err-btn-back {
  background: transparent;
  color: var(--muted);
  border: 1px solid var(--border);
}
.err-btn-back:hover { background: var(--surface-2); color: var(--text); }

/* ============================================================================
   ATELIER — scoped to the student test & Level-Check pages only (2026-08-05)
   ----------------------------------------------------------------------------
   Duke wants the warm cream + warm-coral "Atelier" skin on the pages a
   student meets a *test* on -- taking one, the thanks screen, the answer
   review, and the Level Check -- and NOT (yet) on the rest of the dashboard.
   Those pages all carry `test-stage`; the flashcard auth/marketing pages also
   carry it but add `recall-stage`, so :not(.recall-stage) excludes them.
   Everything here is token overrides, so the components re-skin themselves.
   Coral stays the default for the whole app outside this scope.
   ---------------------------------------------------------------------------- */
body.test-stage:not(.recall-stage) {
  --primary: #e4744f; --primary-dark: #c25733; --primary-light: #fbe3d7;
  --green: #4f9e5f; --green-dark: #3c7d49; --green-light: #e7f3e1;
  --gold: #d6a232; --gold-dark: #a97d18; --gold-light: #fbeecb;
  --bg: #f8f0e2; --card: #fffefb; --text: #33291f; --muted: #ab8f6c; --border: #ecdcc3;
  --accent: #cf5f42; --accent-light: #fbe6dd; --accent-dark: #ad4930;
  --danger-wash: #fbe6dd; --danger-wash-hover: #f6d9cb;
  --surface-2: #f1e7d3; --surface-2-hover: #ece0c9;
  --shadow: 0 10px 30px rgba(80, 60, 30, 0.11);
  --grid-line: rgba(228, 116, 79, 0.06); --coral-glow: rgba(228, 116, 79, 0.13);
  --stage-bg: #f4ecdb;
}
/* Dark, both ways it can be set. Higher specificity than the scoped light
   block above, so a test page in dark mode stays dark (see the note on the
   :root dark blocks). */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) body.test-stage:not(.recall-stage) {
    --primary: #ea855f; --primary-dark: #cf6a44; --primary-light: #3d2418;
    --green: #6fb87d; --green-dark: #9ccfa2; --green-light: #24311d;
    --gold: #d8b45f; --gold-dark: #e0c07a; --gold-light: #352b1a;
    --bg: #211b15; --card: #2b241d; --text: #f0e7d6; --muted: #b0a086; --border: #3b3226;
    --accent: #e08a70; --accent-light: #372820; --accent-dark: #ecab97;
    --danger-wash: #372820; --danger-wash-hover: #43302a;
    --surface-2: #2f2820; --surface-2-hover: #38302a;
    --shadow: 0 10px 34px rgba(0, 0, 0, 0.45);
    --grid-line: rgba(234, 133, 95, 0.12); --coral-glow: rgba(234, 133, 95, 0.14);
    --stage-bg: #1a1510;
  }
}
:root[data-theme="dark"] body.test-stage:not(.recall-stage) {
  --primary: #ea855f; --primary-dark: #cf6a44; --primary-light: #3d2418;
  --green: #6fb87d; --green-dark: #9ccfa2; --green-light: #24311d;
  --gold: #d8b45f; --gold-dark: #e0c07a; --gold-light: #352b1a;
  --bg: #211b15; --card: #2b241d; --text: #f0e7d6; --muted: #b0a086; --border: #3b3226;
  --accent: #e08a70; --accent-light: #372820; --accent-dark: #ecab97;
  --danger-wash: #372820; --danger-wash-hover: #43302a;
  --surface-2: #2f2820; --surface-2-hover: #38302a;
  --shadow: 0 10px 34px rgba(0, 0, 0, 0.45);
  --grid-line: rgba(234, 133, 95, 0.12); --coral-glow: rgba(234, 133, 95, 0.14);
  --stage-bg: #1a1510;
}

/* ---- Atelier warm-coral, scoped to the /study student pages ----------
   Page-by-page rollout of the Atelier skin across the student site
   (see the test-stage block just above for the same palette). These
   pages are entirely token-driven -- the dashboard's greeting sky,
   streak week, review health-bar and doors; the deck's review hero, card
   pile and search; and the global practice-grid all read their colours
   from these variables -- so recolouring is a pure token swap with no
   change to any markup or animation. The `study-atelier` body class is
   added to one template per step of the rollout (study.html, then
   flashcards_dashboard.html, ...); pages that don't yet carry it keep the
   default palette until their own turn. `teach-atelier` is the same palette
   for the teacher console under /teach. Every teacher page includes the
   admin nav, so `body:has(.admin-nav)` recolours the whole /teach area
   without a per-template class (matches the existing :has(.admin-nav) rule
   above). */
body.study-atelier,
body.teach-atelier,
body:has(.admin-nav) {
  --primary: #e4744f; --primary-dark: #c25733; --primary-light: #fbe3d7;
  --green: #4f9e5f; --green-dark: #3c7d49; --green-light: #e7f3e1;
  --gold: #d6a232; --gold-dark: #a97d18; --gold-light: #fbeecb;
  --bg: #f8f0e2; --card: #fffefb; --text: #33291f; --muted: #ab8f6c; --border: #ecdcc3;
  --accent: #cf5f42; --accent-light: #fbe6dd; --accent-dark: #ad4930;
  --danger-wash: #fbe6dd; --danger-wash-hover: #f6d9cb;
  --surface-2: #f1e7d3; --surface-2-hover: #ece0c9;
  --shadow: 0 10px 30px rgba(80, 60, 30, 0.11);
  --grid-line: rgba(228, 116, 79, 0.06); --coral-glow: rgba(228, 116, 79, 0.13);
  --stage-bg: #f4ecdb;
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) body.study-atelier,
  :root:not([data-theme="light"]) body.teach-atelier,
  :root:not([data-theme="light"]) body:has(.admin-nav) {
    --primary: #ea855f; --primary-dark: #cf6a44; --primary-light: #3d2418;
    --green: #6fb87d; --green-dark: #9ccfa2; --green-light: #24311d;
    --gold: #d8b45f; --gold-dark: #e0c07a; --gold-light: #352b1a;
    --bg: #211b15; --card: #2b241d; --text: #f0e7d6; --muted: #b0a086; --border: #3b3226;
    --accent: #e08a70; --accent-light: #372820; --accent-dark: #ecab97;
    --danger-wash: #372820; --danger-wash-hover: #43302a;
    --surface-2: #2f2820; --surface-2-hover: #38302a;
    --shadow: 0 10px 34px rgba(0, 0, 0, 0.45);
    --grid-line: rgba(234, 133, 95, 0.12); --coral-glow: rgba(234, 133, 95, 0.14);
    --stage-bg: #1a1510;
  }
}
:root[data-theme="dark"] body.study-atelier,
:root[data-theme="dark"] body.teach-atelier,
:root[data-theme="dark"] body:has(.admin-nav) {
  --primary: #ea855f; --primary-dark: #cf6a44; --primary-light: #3d2418;
  --green: #6fb87d; --green-dark: #9ccfa2; --green-light: #24311d;
  --gold: #d8b45f; --gold-dark: #e0c07a; --gold-light: #352b1a;
  --bg: #211b15; --card: #2b241d; --text: #f0e7d6; --muted: #b0a086; --border: #3b3226;
  --accent: #e08a70; --accent-light: #372820; --accent-dark: #ecab97;
  --danger-wash: #372820; --danger-wash-hover: #43302a;
  --surface-2: #2f2820; --surface-2-hover: #38302a;
  --shadow: 0 10px 34px rgba(0, 0, 0, 0.45);
  --grid-line: rgba(234, 133, 95, 0.12); --coral-glow: rgba(234, 133, 95, 0.14);
  --stage-bg: #1a1510;
}

/* ---- Review loop: elevated Atelier study card (page 4 of the rollout) --
   A redesign, not just a recolour: a deck-stack behind the card, a tracked
   serif eyebrow, an ornamented divider, a gradient reveal, and the four
   FSRS ratings as chips with a keyboard-number badge, a colour accent bar
   and the interval hint. SURFACE ONLY -- the reveal/rating/scheduling JS
   and the front/back-by-direction logic are untouched. Scoped to
   nc-review-atelier so nothing else moves. */

/* Deck-stack behind the card. */
body.nc-review-atelier .nc-review-stack { position: relative; }
body.nc-review-atelier .nc-review-stack::before,
body.nc-review-atelier .nc-review-stack::after {
  content: ""; position: absolute; inset: 0; border-radius: 24px;
  background: var(--card); border: 1px solid var(--border); z-index: 0;
}
body.nc-review-atelier .nc-review-stack::before { transform: rotate(-2deg) translateY(10px); opacity: 0.55; }
body.nc-review-atelier .nc-review-stack::after { transform: rotate(1.4deg) translateY(6px); opacity: 0.75; }
body.nc-review-atelier .nc-review-card { position: relative; z-index: 1; box-shadow: 0 14px 40px -20px rgba(90, 66, 36, 0.5); }
:root[data-theme="dark"] body.nc-review-atelier .nc-review-card { box-shadow: 0 14px 40px -18px rgba(0, 0, 0, 0.6); }

/* Tracked serif eyebrow with hairline rules. */
body.nc-review-atelier .nc-review-prompt {
  display: flex; align-items: center; justify-content: center; gap: 10px;
  font-weight: 800; font-size: 0.68rem; letter-spacing: 0.2em;
  text-transform: uppercase; color: var(--muted); margin: 0 0 18px;
}
body.nc-review-atelier .nc-review-prompt::before,
body.nc-review-atelier .nc-review-prompt::after { content: ""; width: 16px; height: 1px; background: var(--border); }

/* Prompt-side example (recognition front + production front translation):
   plain and quiet, never boxed. */
body.nc-review-atelier .nc-review-example-front,
body.nc-review-atelier .nc-review-example-tr-front {
  background: none; border: none; padding: 0; color: var(--muted); margin-top: 16px;
}

/* Ornamented divider: a fading hairline with a small coral diamond. */
body.nc-review-atelier .nc-answer-divider {
  position: relative; height: 1px; width: 60%; margin: 22px auto 20px;
  background: linear-gradient(90deg, transparent, var(--border) 22%, var(--border) 78%, transparent);
}
body.nc-review-atelier .nc-answer-divider::after {
  content: ""; position: absolute; left: 50%; top: 50%;
  width: 6px; height: 6px; background: var(--primary);
  transform: translate(-50%, -50%) rotate(45deg);
}

/* Revealed reading + meaning. */
body.nc-review-atelier .nc-review-pinyin { font-family: var(--font-display); font-size: 1.35rem; letter-spacing: 0.02em; }
body.nc-review-atelier .nc-review-def { font-size: 1.2rem; font-weight: 600; }

/* Reveal content is plain -- no box. On a Chinese-front card the reveal is
   just the reading, meaning and the example's translation; the Chinese
   example isn't repeated here (see the template). Production cards show the
   Chinese answer example plainly via the base .nc-review-example styles. */
body.nc-review-atelier .nc-review-answer .nc-review-example-tr { margin-top: 12px; }

/* Gradient reveal button with a soft coral glow. */
body.nc-review-atelier .nc-reveal-btn {
  border-radius: 16px;
  background: linear-gradient(135deg, var(--primary), var(--primary-dark));
  box-shadow: 0 12px 30px -10px var(--coral-glow);
}
body.nc-review-atelier .nc-reveal-btn:hover { background: linear-gradient(135deg, var(--primary-dark), var(--primary-dark)); }

/* The four ratings as chips: colour accent bar on top, keyboard-number
   badge in the corner, serif label in the tone, interval hint below. */
body.nc-review-atelier .nc-rating-form { gap: 10px; }
body.nc-review-atelier .nc-rate {
  position: relative; overflow: hidden; border-radius: 15px;
  background: var(--card); border: 1px solid var(--border);
  padding: 16px 6px 12px; box-shadow: 0 6px 16px -12px rgba(90, 66, 36, 0.5);
  transition: transform 0.14s ease, box-shadow 0.14s ease;
}
body.nc-review-atelier .nc-rate:hover { transform: translateY(-2px); box-shadow: 0 12px 24px -14px rgba(90, 66, 36, 0.6); }
body.nc-review-atelier .nc-rate::after { content: ""; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: currentColor; }
body.nc-review-atelier .nc-rate::before {
  position: absolute; top: 6px; right: 9px;
  font-size: 0.62rem; font-weight: 800; color: var(--muted); opacity: 0.75;
}
body.nc-review-atelier .nc-rate-again::before { content: "1"; }
body.nc-review-atelier .nc-rate-hard::before  { content: "2"; }
body.nc-review-atelier .nc-rate-good::before  { content: "3"; }
body.nc-review-atelier .nc-rate-easy::before  { content: "4"; }
body.nc-review-atelier .nc-rate-again { background: var(--card); color: var(--accent); }
body.nc-review-atelier .nc-rate-hard  { background: var(--card); color: var(--gold-dark); }
body.nc-review-atelier .nc-rate-good  { background: var(--card); color: var(--primary-dark); }
body.nc-review-atelier .nc-rate-easy  { background: var(--card); color: var(--green-dark); }
:root[data-theme="dark"] body.nc-review-atelier .nc-rate-good { color: var(--primary); }
body.nc-review-atelier .nc-rate-label { font-family: var(--font-display); font-weight: 700; color: inherit; }
body.nc-review-atelier .nc-rate-int { color: var(--muted); opacity: 1; margin-top: 1px; }

/* Refined remaining pill. */
body.nc-review-atelier .nc-remaining-pill { border: 1px solid var(--border); background: var(--card); }
