  /* ============================================================
     SELF-HOSTED FONTS
     Previously loaded from Google Fonts (fonts.googleapis.com), which
     meant a third-party request on every page load and a mention in
     the privacy policy. Now served from ./fonts/, subset to exactly
     the weights/styles this app actually uses — verified by checking
     each element's real computed font-weight, not just what the CSS
     visibly declares:
       Libre Franklin (Structure): 500, 600, 700, 800. All four weights
         are one physical variable-font file (Google served the same
         bytes for all four in the original request too) — one
         download, four @font-face declarations, one per static weight
         actually used.
       IBM Plex Mono (Data): 500, 600, 700. A form control
         (.progress-code-box) was unweighted and
         inheriting from a UA default rather than the page's, which
         would have silently requested weight 400 the first time
         someone tabbed into them — pinned to 500 below instead of
         adding a fourth file for it.
       PT Serif (Voice): 400, 700, and 400 italic. NOT 500 — despite
         body text elsewhere defaulting to font-weight: 500, PT Serif
         has no 500 face at all (Google Fonts only offers 400/700 for
         it), so every unweighted paragraph using this typeface
         (explanation text, session messages, footers, the landing
         note, ...) has always actually rendered at 400, silently,
         via the browser's own font-weight fallback matching. Made
         explicit below with real font-weight: 400 declarations,
         rather than continuing to rely on that fallback by accident.
     Every @font-face below is font-display: swap, so text renders
     immediately in a fallback font rather than staying invisible
     while the real one loads. */
  @font-face {
    font-family: "Libre Franklin";
    font-style: normal;
    font-weight: 500;
    font-display: swap;
    src: url("fonts/librefranklin-variable.woff2") format("woff2");
  }
  @font-face {
    font-family: "Libre Franklin";
    font-style: normal;
    font-weight: 600;
    font-display: swap;
    src: url("fonts/librefranklin-variable.woff2") format("woff2");
  }
  @font-face {
    font-family: "Libre Franklin";
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url("fonts/librefranklin-variable.woff2") format("woff2");
  }
  @font-face {
    font-family: "Libre Franklin";
    font-style: normal;
    font-weight: 800;
    font-display: swap;
    src: url("fonts/librefranklin-variable.woff2") format("woff2");
  }
  @font-face {
    font-family: "IBM Plex Mono";
    font-style: normal;
    font-weight: 500;
    font-display: swap;
    src: url("fonts/ibmplexmono-500.woff2") format("woff2");
  }
  @font-face {
    font-family: "IBM Plex Mono";
    font-style: normal;
    font-weight: 600;
    font-display: swap;
    src: url("fonts/ibmplexmono-600.woff2") format("woff2");
  }
  @font-face {
    font-family: "IBM Plex Mono";
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url("fonts/ibmplexmono-700.woff2") format("woff2");
  }
  @font-face {
    font-family: "PT Serif";
    font-style: normal;
    font-weight: 400;
    font-display: swap;
    src: url("fonts/ptserif-400.woff2") format("woff2");
  }
  @font-face {
    font-family: "PT Serif";
    font-style: normal;
    font-weight: 700;
    font-display: swap;
    src: url("fonts/ptserif-700.woff2") format("woff2");
  }
  @font-face {
    font-family: "PT Serif";
    font-style: italic;
    font-weight: 400;
    font-display: swap;
    src: url("fonts/ptserif-400italic.woff2") format("woff2");
  }

  /* ============================================================
     DESIGN SYSTEM
     The desk and the page: the app background (Bone) and the one
     elevated card (Paper) are two consistently different tones, so
     elevation is real instead of two shades of the same white. Color
     beyond that is spent only on things the product already means —
     correct, incorrect, earned — never as decoration. There is no
     separate "brand" accent: buttons are ink (Char), the way a
     well-made book uses black boldly and saves color for annotation.
     ============================================================ */
  :root {
    /* ---- Surfaces ---- */
    --bone: #EDE8DD;   /* the desk: the app's own background */
    --paper: #FBF9F4;  /* the page: the one elevated card */

    /* ---- Ink ---- */
    --char: #1E1C1A;
    --char-soft: #58524A;   /* muted ink: secondary text, captions */
    --rule: #DBD3C2;        /* hairline borders — warm, not cold grey */

    /* ---- Semantic color. Each one is a real pigment/reagent name,
       and each is always paired with an icon and a text label in the
       markup below — never color alone. ---- */
    --verdigris: #1F5C4E;
    --verdigris-bg: #DEEBE4;
    --vermilion: #9C3B26;
    --vermilion-bg: #F3E2D9;
    --marigold: #865515;
    --marigold-bg: #F1E1C2;

    /* ---- Accent color: the three difficulty tiers ----
       Foundational/Challenging/Advanced — shown as a badge on each
       problem and as three bar segments on the progress screen.

       Two attempts before this one tried to keep all three (or two of
       three) inside the "green" gap between SmilesDrawer's atom colors,
       varying hue and lightness within that one family. That doesn't
       work: only three hue windows clear every atom color by ~20° at
       all — roughly 68-125° (green), 224-262° (indigo), 302-345°
       (magenta) — and cramming two tiers into one 57°-wide window,
       however carefully lit, isn't the same as three actually distinct
       families. So each tier now gets its own window instead:
       Foundational is green (117°), Challenging is indigo (232°, in
       the window Organic 1/2 used to occupy before course color was
       removed below), Advanced is magenta (326°). Pairwise separation
       under a standard sRGB deuteranopia/protanopia simulation is now
       99-132 (was as low as 11 when two tiers shared the green window
       at the same lightness) — see the project notes for the full
       numbers. Every tier still clears every atom color by >=20° of
       hue (28°, 28°, 40°) and passes WCAG AA as text on Paper, on
       Bone, and on its own pale background tint below. */
    --tier-foundational: #185D14;
    --tier-foundational-bg: #DDEFDC;
    --tier-challenging: #2538B1;
    --tier-challenging-bg: #DCDFEF;
    --tier-advanced: #B12574;
    --tier-advanced-bg: #EFDCE7;

    /* ---- Type: three registers, three jobs ----
       Structure (Libre Franklin) — UI chrome: titles, buttons, nav,
         labels, captions.
       Voice (PT Serif) — anything meant to be read: prompts,
         explanations, feedback prose.
       Data (IBM Plex Mono) — anything where character alignment is
         the point: reagents, formulas, counters, stat values. */
    --font-structure: "Libre Franklin", -apple-system, "Segoe UI", Roboto, sans-serif;
    --font-voice: "PT Serif", Georgia, "Times New Roman", serif;
    --font-data: "IBM Plex Mono", ui-monospace, "SFMono-Regular", Consolas, monospace;

    --radius: 16px;     /* the one elevated card */
    --radius-sm: 10px;  /* buttons and boxed elements inside it */
    --radius-pill: 999px;

    /* ---- Spacing ----
       Every margin/padding/gap in this file used to be a raw rem value
       — 22 distinct ones, eight of them crammed between 0.1rem and
       0.45rem alone, an accumulation of one-off choices rather than a
       real scale. Every one of those 22 values has been snapped to the
       nearest of these 7 steps (round-half-up on the one exact tie,
       2rem). The two biggest single adjustments were the rarest, largest
       values — 2rem and 3rem both landed on --space-xl (2.5rem), a
       ±0.5rem change to a handful of screen-level margins/padding-bottoms
       where it's not visible. Nothing was kept off-scale: no exceptions
       were needed anywhere in the file. */
    --space-3xs: 0.25rem;
    --space-2xs: 0.5rem;
    --space-xs: 0.75rem;
    --space-sm: 1rem;
    --space-md: 1.25rem;
    --space-lg: 1.5rem;
    --space-xl: 2.5rem;

    --shadow-card: 0 1px 2px rgba(30, 26, 20, 0.05), 0 16px 32px -18px rgba(30, 26, 20, 0.35);

    --motion-fast: 120ms;
    --motion-base: 180ms;
    --motion-slow: 260ms;
    --ease: cubic-bezier(0.22, 0.7, 0.32, 1);
  }

  /* ============================================================
     DARK THEME
     Follows the system by default (prefers-color-scheme), overridden
     by the manual toggle in the footer, which sets data-theme on
     <html> and persists it in localStorage — see applyTheme() and
     THEME_KEY in the script. The two blocks below are deliberately
     identical: the first applies automatically when the system
     prefers dark AND the learner hasn't explicitly chosen light; the
     second applies whenever they've explicitly chosen dark, regardless
     of what the system says. Repeating the values (rather than one
     rule the other extends) is what lets the toggle override the
     system either direction without a specificity fight.

     This is a designed palette, not an inverted one. The Bone/Paper
     elevation relationship survives as two distinct dark tones — Paper
     is the LIGHTER of the two here, since a shadow can't read against
     a background this dark; elevation now comes from that lightness
     step plus a tighter, more opaque shadow (below) instead. Char
     flips to a warm off-white, because in a dark UI the "ink" — the
     color of text and of every filled button — has to be the light
     color; nothing here is a 1:1 hex inversion, every token was
     re-picked and re-checked against Bone-dark/Paper-dark for WCAG AA,
     the same as the light palette was against Bone/Paper.

     The three semantic colors and three tier colors keep their light-
     theme hues (so the mental model carries over between themes) but
     are all measurably brighter — verified with the same
     deuteranopia/protanopia simulation used for the light tiers, and
     they clear the *dark* theme's SmilesDrawer atom-color palette,
     which is the same set of hues as "light" but rendered lighter
     against a near-black canvas (see getMoleculeTheme() in the
     script). */
  @media (prefers-color-scheme: dark) {
    :root:not([data-theme="light"]) {
      --bone: #1D1A16;
      --paper: #2D2925;
      --char: #F1EEEA;
      --char-soft: #AAA297;
      --rule: #49433C;

      --verdigris: #64C4AE;
      --verdigris-bg: #1D342F;
      --vermilion: #DF7D68;
      --vermilion-bg: #34221D;
      --marigold: #DBA257;
      --marigold-bg: #342A1D;

      --tier-foundational: #43BA3B;
      --tier-foundational-bg: #20331E;
      --tier-challenging: #889FDD;
      --tier-challenging-bg: #1E2433;
      --tier-advanced: #DF90A5;
      --tier-advanced-bg: #331E24;

      /* Black, not the light theme's warm brown-black — and tighter,
         more opaque, since a soft wide shadow simply doesn't register
         against a background this dark. Elevation here is mostly
         Paper's own lightness against Bone; this is a secondary cue. */
      --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 10px 24px -16px rgba(0, 0, 0, 0.6);
    }
  }

  :root[data-theme="dark"] {
    --bone: #1D1A16;
    --paper: #2D2925;
    --char: #F1EEEA;
    --char-soft: #AAA297;
    --rule: #49433C;

    --verdigris: #64C4AE;
    --verdigris-bg: #1D342F;
    --vermilion: #DF7D68;
    --vermilion-bg: #34221D;
    --marigold: #DBA257;
    --marigold-bg: #342A1D;

    --tier-foundational: #43BA3B;
    --tier-foundational-bg: #20331E;
    --tier-challenging: #889FDD;
    --tier-challenging-bg: #1E2433;
    --tier-advanced: #DF90A5;
    --tier-advanced-bg: #331E24;

    --shadow-card: 0 1px 2px rgba(0, 0, 0, 0.4), 0 10px 24px -16px rgba(0, 0, 0, 0.6);
  }

  * {
    box-sizing: border-box;
  }

  body {
    margin: 0;
    background: var(--bone);
    color: var(--char);
    font-family: var(--font-structure);
    font-weight: 500;
    line-height: 1.5;
    -webkit-font-smoothing: antialiased;
  }

  h1, h2, h3 {
    font-family: var(--font-structure);
    font-weight: 700;
  }

  /* Every animation and transition in this file is listed under
     --motion-* / var(--ease) above, or as an explicit @keyframes
     below — this single rule is what makes all of it vanish for
     anyone whose system says not to move things. */
  @media (prefers-reduced-motion: reduce) {
    * {
      animation-duration: 0.001ms !important;
      animation-iteration-count: 1 !important;
      transition-duration: 0.001ms !important;
      scroll-behavior: auto !important;
    }
  }

  /* Visible on screen readers only — for headings and other structure
     that keyboard/screen-reader users need but a sighted user doesn't
     (the page already shows them the information some other way). */
  .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;
  }

  /* One visible focus style for every kind of interactive element —
     buttons, links, inputs, and the programmatic landing points used
     for keyboard focus management after a screen or question changes.
     :focus-visible means it only appears for keyboard use, not clicks. */
  :focus-visible {
    outline: 2px solid var(--char);
    outline-offset: 2px;
  }

  /* The practice screen is a narrow column, centered, so it reads
     well on a phone and doesn't stretch too wide on a desktop. */
  #home-screen,
  #practice-screen,
  #progress-screen,
  #landing-screen,
  #scope-screen,
  #exam-setup-screen,
  #exam-screen,
  #exam-results-screen {
    max-width: 640px;
    margin: 0 auto;
    padding: var(--space-md) var(--space-sm) var(--space-xl);
  }

  /* ============================================================
     LANDING SCREEN
     Shown instead of the home screen only to a first-time visitor
     (no saved progress yet — see hasSavedProgress() below). Every
     rule here reuses existing tokens; nothing new is introduced, this
     is just vertical rhythm between blocks that don't otherwise sit
     inside a shared card.
     ============================================================ */
  .landing-section {
    margin-top: var(--space-xl);
  }

  .landing-sample-label {
    text-align: center;
    margin-bottom: var(--space-xs);
  }

  /* ---- The personal note ----
     A different voice from the rest of the page, and set apart to look
     like one: italic Voice type (the same typeface, marked the way a
     pull-quote or an aside is), on its own quiet surface with a rule
     down the left edge instead of a full border — enough separation
     that it doesn't read as a continuation of "What's covered" above
     it, without becoming another shadowed card. */
  .landing-note {
    margin-top: var(--space-xl);
    padding: var(--space-sm) var(--space-md);
    border-left: 3px solid var(--rule);
  }

  .landing-note-text {
    margin: 0;
    font-family: var(--font-voice);
    font-style: italic;
    font-weight: 400; /* PT Serif has no 500 — see the self-hosted-fonts note up top */
    font-size: 0.95rem;
    line-height: 1.6;
    color: var(--char-soft);
  }

  .landing-note-text a {
    color: var(--char);
  }

  /* ---- Position counter, e.g. "Question 1 of 15" ---- */
  .counter {
    text-align: center;
    font-size: 0.82rem;
    font-weight: 600;
    color: var(--char-soft);
    margin-bottom: var(--space-3xs);
  }

  .topic-label {
    text-align: center;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--char-soft);
    margin-bottom: var(--space-2xs);
  }

  /* The difficulty tier, shown just below the topic — the text label
     ("Foundational"/"Challenging"/"Advanced") already carries the full
     meaning, so the tier color here is a genuine accent, not the only
     signal. tier-* modifiers set below, alongside every other place
     that uses these three colors. */
  .difficulty-badge-row {
    text-align: center;
    margin-bottom: var(--space-xs);
  }

  .difficulty-badge {
    display: inline-block;
    font-family: var(--font-structure);
    font-size: 0.7rem;
    font-weight: 700;
    padding: var(--space-3xs) var(--space-2xs);
    border-radius: var(--radius-pill);
  }

  .difficulty-badge.tier-foundational { background: var(--tier-foundational-bg); color: var(--tier-foundational); }
  .difficulty-badge.tier-challenging { background: var(--tier-challenging-bg); color: var(--tier-challenging); }
  .difficulty-badge.tier-advanced { background: var(--tier-advanced-bg); color: var(--tier-advanced); }

  /* A quiet, restrained "in a row" indicator — appears only once a
     streak is actually worth mentioning, and never fights with the
     molecule for attention. Earned, not gamified: no badge shape, no
     animation loop, just ink that briefly turns Marigold. */
  .streak-indicator {
    display: block;
    text-align: center;
    font-family: var(--font-data);
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--marigold);
    margin-bottom: var(--space-xs);
  }
  .streak-indicator[hidden] {
    display: none;
  }

  /* ============================================================
     THE PRACTICE CARD
     The page, on the desk. One shadow, one border, one surface — the
     reaction, the prompt, the options, and the feedback that follows
     all live inside it, so answering never feels like leaving the
     question behind.
     ============================================================ */
  .practice-card {
    background: var(--paper);
    border: 1px solid var(--rule);
    border-radius: var(--radius);
    box-shadow: var(--shadow-card);
    padding: var(--space-lg) var(--space-md) var(--space-md);
    transition: opacity var(--motion-base) var(--ease), transform var(--motion-base) var(--ease);
  }

  .practice-card.card-exit {
    opacity: 0;
    transform: translateX(-10px);
  }
  .practice-card.card-enter {
    opacity: 0;
    transform: translateX(10px);
  }

  /* ---- The reaction: starting material, reagents, arrow ----
     No border, no fill of its own — it sits directly on the card. The
     structure is the content; nothing should compete with its bond
     lines, not even the surface it's drawn on. */
  .reaction-card {
    padding: 0;
    margin-bottom: var(--space-md);
  }

  .molecule-box {
    display: flex;
    justify-content: center;
  }
  /* Without this, "display: flex" above would win over the browser's
     built-in rule for the hidden attribute — same fix as
     .next-button[hidden] elsewhere in this file. Hidden for
     concept-format problems, which show .species-row instead. */
  .molecule-box[hidden] {
    display: none;
  }

  .molecule-box canvas {
    max-width: 100%;
    height: auto;
  }

  .reagents-text {
    text-align: center;
    font-family: var(--font-data);
    font-size: 0.92rem;
    font-weight: 600;
    margin-top: var(--space-3xs);
  }

  /* A plain horizontal arrow drawn with CSS: a line with a
     triangular arrowhead on the right end. It doubles as the rule
     line under the reaction. */
  .arrow-line {
    position: relative;
    height: 1px;
    background: var(--char);
    width: 70%;
    margin: var(--space-2xs) auto var(--space-3xs);
  }

  .arrow-line::after {
    content: "";
    position: absolute;
    right: -1px;
    top: 50%;
    transform: translateY(-50%);
    border-top: 5px solid transparent;
    border-bottom: 5px solid transparent;
    border-left: 9px solid var(--char);
  }

  /* ---- Concept-format problems: a row of labelled structures ----
     Shown instead of the starting-material canvas + reagent arrow
     above, for any problem.format === "concept" — see
     renderProblemInto() in the script below. Same "no border, no fill"
     restraint as .molecule-box: nothing competes with the bond lines. */
  .species-row {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--space-sm);
  }
  /* Same [hidden]-vs-"display: flex" fix as .molecule-box[hidden]
     above — this row sets display: flex too. */
  .species-row[hidden] {
    display: none;
  }

  /* Four or more species force exactly two per row at every width —
     plain flex-wrap only wraps once items literally don't fit, which
     at a desktop-width card let three species share the first row and
     orphaned the fourth alone below. Applied by JS
     (renderProblemInto()/buildReactionAreaInto()) only when there are
     4+ species; fewer keeps the plain flex-wrap row above, so three
     species still share one row exactly as before. Overrides
     "display: flex" above by specificity — no !important needed. */
  .species-row.species-row--grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    justify-items: center;
  }

  .species-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-3xs);
  }

  .species-item canvas {
    max-width: 100%;
    height: auto;
  }

  .species-label {
    font-family: var(--font-structure);
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--char-soft);
    text-align: center;
  }

  /* A single-heavy-atom species' conventional formula (H2O, NH3, F-,
     ...) — stands in for the canvas SmilesDrawer would otherwise draw
     as a misleading bonded structure (see buildSingleAtomFormula() in
     the script below). Same size/weight as .species-label above it;
     full ink rather than --char-soft since this is the actual content
     being shown, not a caption. <sub>/<sup> children (built as real
     DOM nodes, never innerHTML) render the hydrogen count and charge. */
  .species-formula {
    display: block;
    font-family: var(--font-structure);
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--char);
    text-align: center;
  }

  /* ============================================================
     CHAIR CONFORMATION
     chair.js's renderChair() returns a bare <svg class="chair"> with no
     colour of its own — lines and text are coloured here, so the theme
     controls it entirely and a theme change needs no redraw (unlike the
     SmilesDrawer canvases). These are chair.js's demo page's four rules
     re-expressed in the app's tokens instead of the demo's:
       - Lines and labels are --char, the same ink SmilesDrawer's
         molecules are drawn in: a chair is structure (the content), so
         it gets the structure ink, NOT --verdigris — the demo's teal is
         the app's "correct" colour, and colour is only spent on meaning.
       - Ring-carbon numbers (C1..C6) are --char-soft instead of the
         demo's opacity: .55 — a real token (a dimmed element's contrast
         is whatever the opacity happens to multiply to; a token is
         checked once, in both themes) and a caption-weight cue.
       - Labels use --font-structure, the same sans family the atom
         labels on SmilesDrawer's canvases read as.
     Font sizes are SVG user units, not CSS px: the <svg> carries a
     viewBox and is scaled by its CSS width, so the text scales with the
     drawing and the label geometry chair.js laid out stays true at any
     width. ============================================================ */
  .chair {
    display: block;
    width: 100%;
    height: auto;
    max-width: 300px;
  }
  .chair line {
    stroke: var(--char);
    stroke-linecap: round;
    fill: none;
  }
  .chair text {
    fill: var(--char);
    font-family: var(--font-structure);
  }
  .chair .chair-label {
    font-size: 15px;
    font-weight: 500;
  }
  .chair .chair-num {
    fill: var(--char-soft);
    font-size: 11px;
  }

  /* Any drawing in the species row (a concept problem's `chairs` array
     and the other DRAWING_KINDS in index.html share that row — see
     buildDrawingItem()). min-width: 0 lets a flex item shrink below its
     SVG's intrinsic width instead of overflowing the card. */
  .species-item--drawing {
    min-width: 0;
  }

  /* A chair inside the species row. Unlike a
     140x108 species canvas, a chair needs real width to stay legible:
     its text is in viewBox units, so a chair drawn 150px wide shows its
     15-unit labels at ~9px and its 11-unit ring numbers at ~6px. The
     14rem basis therefore lets two chairs share a row only where each
     can still be ~224px+ (a desktop-width card) and drops to one per
     line on a phone, instead of squeezing two into ~150px each. */
  .species-item--chair {
    flex: 1 1 14rem;
    max-width: 300px;
  }
  /* In the 2-column grid (4+ items) the cell already sets the width. */
  .species-row--grid .species-item--chair {
    width: 100%;
  }
  /* ...but "exactly two per row at every width" (the 4+ rule above) would
     squeeze four chairs to ~150px each on a phone. A row that holds any
     drawing (JS adds .species-row--drawings) keeps two columns where each
     can still be 14rem wide — a desktop-width card — and falls to one
     per line where it can't. Same specificity-by-selector-count override
     as the grid rule itself, no !important. */
  .species-row.species-row--grid.species-row--drawings {
    grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
  }

  /* ============================================================
     NEWMAN PROJECTION
     newman.js's renderNewman() returns a bare <svg class="newman">,
     colourless like a chair, and is coloured the same way and for the
     same reasons (see CHAIR CONFORMATION above): bonds, the back
     carbon's circle and the group labels are --char, the structure ink;
     the optional conformer caption under the drawing is --char-soft, a
     real token in place of the demo page's opacity: .7. The font sizes
     (15 for labels, 13 for the caption, in viewBox units) are newman.js's
     own — its label collision avoidance is computed for them. Drawn at
     the same 260-unit square as a chair, so a projection in the species
     row takes a chair's row space: two to a row only where each can be
     ~14rem wide, one per line on a phone.
     ============================================================ */
  .newman {
    display: block;
    width: 100%;
    height: auto;
    max-width: 300px;
  }
  .newman line,
  .newman circle {
    stroke: var(--char);
    stroke-linecap: round;
    fill: none;
  }
  .newman text {
    fill: var(--char);
    font-family: var(--font-structure);
  }
  .newman .newman-label {
    font-size: 15px;
    font-weight: 500;
  }
  .newman .newman-caption {
    fill: var(--char-soft);
    font-size: 13px;
  }
  .species-item--newman {
    flex: 1 1 14rem;
    max-width: 300px;
  }
  .species-row--grid .species-item--newman {
    width: 100%;
  }

  /* ============================================================
     FISCHER PROJECTION
     fischer.js's renderFischer() returns a bare <svg class="fischer">,
     colourless, coloured like a chair or a Newman projection: every bond
     and label is --char, the structure ink (vertical and horizontal
     bonds deliberately look the same — the convention, not a colour,
     says which way each points). Unlike those two it is drawn at its
     NATURAL size, not stretched to the row: fischer.js sets the svg's
     width/height so every bond is 44px and every label 15px, a one-centre
     projection ~150px square and a four-centre sugar ~160 x 280px.
     Stretched to a chair's 14rem a four-centre sugar would stand ~500px
     tall with ~28px labels; at natural size projections of different
     lengths also match each other on the card. max-width only shrinks one
     on a card too narrow for it (it scales down, proportionally).
     ============================================================ */
  .fischer {
    display: block;
    max-width: 100%;
    height: auto;
    margin: 0 auto;
  }
  .fischer line {
    stroke: var(--char);
    stroke-linecap: round;
    fill: none;
  }
  .fischer text {
    fill: var(--char);
    font-family: var(--font-structure);
  }
  .fischer .fischer-label {
    font-size: 15px;
    font-weight: 500;
  }
  .species-item--fischer {
    flex: 0 1 auto;
  }

  /* ---- The question ---- */
  .question {
    text-align: center;
    font-family: var(--font-voice);
    font-weight: 700;
    font-size: 1.15rem;
    margin: 0 0 var(--space-sm);
  }

  /* ---- Answer options ----
     The only genuinely boxed content on the card — because these are
     the only things a student selects among, a box here means
     something specific: choose one. Single column on phones, per the
     brief; two-up once there's room. */
  .options {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--space-xs);
  }

  /* A real <button> (so it's keyboard-focusable and Enter/Space-
     activatable out of the box) — these reset properties strip the
     browser's native button appearance. */
  .option-card {
    appearance: none;
    font: inherit;
    text-align: inherit;
    width: 100%;
    background: var(--paper);
    border: 1.5px solid var(--rule);
    border-radius: var(--radius-sm);
    padding: var(--space-xs);
    min-height: 44px; /* tap target rule: at least 44px tall */
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-2xs);
    cursor: pointer;
    transition: border-color var(--motion-fast) var(--ease),
                background-color 120ms var(--ease),
                transform var(--motion-fast) var(--ease);
  }

  .option-card:hover:not(:disabled) {
    border-color: var(--char-soft);
  }

  .option-card:active:not(:disabled) {
    transform: scale(0.99);
  }

  .option-card:disabled {
    cursor: default;
  }

  .option-card canvas {
    max-width: 100%;
    height: auto;
  }

  /* A concept-format option's text — a claim, a ranking, a mechanism
     name — instead of a molecule canvas. Same card, same tap target,
     same locked/correct/incorrect states below; only the content
     inside differs. Structure, not Voice: this is a discrete choice
     being labeled, not prose being read. */
  .option-text {
    font-family: var(--font-structure);
    font-weight: 700;
    font-size: 1rem;
    line-height: 1.4;
    text-align: center;
    /* Without this, a <button>'s own UA text color wins instead of the
       page's ink — invisible-looking dark grey on the dark card in
       dark mode. --char is the same token every other primary-ink
       text in the app already uses, and it's already re-checked for
       contrast against --verdigris-bg/--vermilion-bg in both themes
       (see the design-system note in CLAUDE.md), so the locked/
       correct/incorrect backgrounds below need no separate check. */
    color: var(--char);
  }

  /* Once an answer is picked, every card is locked (no more taps).
     The ones that are neither the pick nor the correct answer fade
     back a little so attention goes to the two that matter. Mock Exam
     never marks anything correct/incorrect (see handleExamAnswerClick)
     but still needs its one selected card excluded from this dimming —
     "you picked this" is not the same claim as "this was right or
     wrong", so it gets its own modifier below instead of reusing
     either state color. */
  .option-card.locked {
    cursor: default;
  }
  .option-card.locked:not(.correct):not(.incorrect):not(.selected-neutral) {
    opacity: 0.45;
    transition: opacity var(--motion-base) var(--ease);
  }

  .option-card.correct {
    border-color: var(--verdigris);
    background: var(--verdigris-bg);
  }

  .option-card.incorrect {
    border-color: var(--vermilion);
    background: var(--vermilion-bg);
  }

  /* Mock Exam's "you picked this, no verdict yet" state. */
  .option-card.selected-neutral {
    border-color: var(--char-soft);
  }

  /* The reveal itself: color and fill are what's transitioning (set
     up on .option-card above); this just lets the correct answer
     arrive a beat after a wrong pick, so the eye travels from what you
     chose to what was right instead of everything changing at once. */
  .option-card.correct {
    transition-delay: var(--reveal-delay, 0ms);
  }

  /* Correct/incorrect get an icon AND a word AND a color — never
     color alone. The icon is an inline SVG (decorative, aria-hidden),
     so the badge's accessible text is exactly the label. */
  .option-badge {
    display: inline-flex;
    align-items: center;
    gap: var(--space-3xs);
    font-family: var(--font-structure);
    font-size: 0.75rem;
    font-weight: 700;
    padding: var(--space-3xs) var(--space-xs);
    border-radius: var(--radius-pill);
    color: var(--paper);
    opacity: 0;
    transform: translateY(-2px);
    animation: badge-in 120ms var(--ease) forwards;
    animation-delay: var(--reveal-delay, 0ms);
  }

  .option-badge svg {
    width: 0.85em;
    height: 0.85em;
    flex: none;
  }

  @keyframes badge-in {
    to {
      opacity: 1;
      transform: translateY(0);
    }
  }

  .option-card.correct .option-badge {
    background: var(--verdigris);
  }

  .option-card.incorrect .option-badge {
    background: var(--vermilion);
  }

  /* Two-up layout once there's enough width for it. */
  @media (min-width: 640px) {
    .options {
      grid-template-columns: 1fr 1fr;
    }
  }

  /* ---- Feedback, shown after an answer is picked ----
     A colored top edge marks the shift, not a second shadow — this
     stays one surface, one page, with a tint instead of a whole new
     box. It's the emotional beat of the app, so it gets the most
     deliberate motion: it arrives, it doesn't just appear. */
  .explanation-panel {
    border-top: 3px solid var(--rule);
    padding: var(--space-sm) var(--space-3xs) var(--space-3xs);
    margin-top: var(--space-lg);
    opacity: 0;
    transform: translateY(6px);
  }

  .explanation-panel.panel-in {
    /* A small fixed delay (not tied to the correct/wrong stagger above)
       so the panel reads as arriving after the option colors have
       landed, without needing to know which case is playing out. */
    animation: panel-in 150ms var(--ease) 80ms forwards;
  }

  @keyframes panel-in {
    to {
      opacity: 1;
      transform: translateY(0);
    }
  }

  .explanation-panel.correct-panel {
    border-top-color: var(--verdigris);
  }
  .explanation-panel.wrong-panel {
    border-top-color: var(--vermilion);
  }

  .explanation-heading {
    display: flex;
    align-items: center;
    gap: var(--space-2xs);
    font-family: var(--font-structure);
    font-weight: 700;
    font-size: 1rem;
    margin-bottom: var(--space-2xs);
  }

  .explanation-heading svg {
    width: 1.1em;
    height: 1.1em;
    flex: none;
  }

  .explanation-heading.correct-text {
    color: var(--verdigris);
  }

  .explanation-heading.wrong-text {
    color: var(--vermilion);
  }

  .explanation-text {
    margin: 0;
    font-family: var(--font-voice);
    font-weight: 400; /* PT Serif has no 500 — see the self-hosted-fonts note up top */
    font-size: 1.05rem;
    line-height: 1.6;
    color: var(--char);
  }

  /* ---- Next button ---- */
  .next-button {
    display: block;
    width: 100%;
    min-height: 48px; /* tap target rule: at least 44px tall */
    margin-top: var(--space-md);
    background: var(--char);
    color: var(--paper);
    border: none;
    border-radius: var(--radius-sm);
    font-family: var(--font-structure);
    font-size: 1rem;
    font-weight: 700;
    cursor: pointer;
    transition: transform var(--motion-fast) var(--ease), opacity var(--motion-fast) var(--ease);
  }

  .next-button:active {
    transform: scale(0.98);
  }

  /* Without this, "display: block" above would win over the browser's
     built-in rule for the hidden attribute, and the button would stay
     visible even while hidden is set. */
  .next-button[hidden] {
    display: none;
  }

  /* ---- Session message ----
     Shown instead of the quiz when there's no problem to serve right
     now. The plain "nothing due" state stays quiet, on the card
     surface — see .session-complete below for the one screen that
     spends real boldness. */
  .session-message {
    background: var(--paper);
    border: 1px solid var(--rule);
    border-radius: var(--radius);
    box-shadow: var(--shadow-card);
    padding: var(--space-xl) var(--space-lg);
    text-align: center;
    color: var(--char-soft);
    font-size: 1.05rem;
    transition: background-color var(--motion-slow) var(--ease), color var(--motion-slow) var(--ease);
  }

  .session-message-text {
    margin: 0 0 var(--space-lg);
    font-family: var(--font-voice);
    font-weight: 400; /* PT Serif has no 500 — see the self-hosted-fonts note up top */
    font-size: 1.15rem;
    color: var(--char);
  }

  /* ---- The one deliberate exception: finishing a session ----
     A full inversion to ink — the only dark surface in the app —
     because finishing is the one point in the flow that isn't just
     another question. Fades in once; the number gets a single
     restrained emphasis pulse, not a count-up or any confetti. */
  .session-message.session-complete {
    background: var(--char);
    border-color: var(--char);
  }
  .session-message.session-complete .session-message-text {
    color: var(--paper);
  }
  .session-message.session-complete.reveal-in {
    animation: complete-in var(--motion-slow) var(--ease) forwards;
  }
  .session-message.session-complete .session-message-text.score-pulse {
    animation: score-pulse 420ms var(--ease);
  }

  @keyframes complete-in {
    from { opacity: 0; transform: scale(0.98); }
    to { opacity: 1; transform: scale(1); }
  }

  @keyframes score-pulse {
    0% { transform: scale(1); }
    40% { transform: scale(1.035); }
    100% { transform: scale(1); }
  }

  /* ---- Small text-style link/button, used to move between screens ----
     Plain ink with an underline — color is spent elsewhere. */
  .text-link {
    display: block;
    width: 100%;
    min-height: 44px; /* tap target rule: at least 44px tall */
    background: none;
    border: none;
    color: var(--char);
    font-family: var(--font-structure);
    font-size: 0.92rem;
    font-weight: 600;
    text-align: center;
    text-decoration: underline;
    text-decoration-color: var(--rule);
    text-underline-offset: 3px;
    cursor: pointer;
    padding: var(--space-2xs);
  }

  /* The "&larr; Home" links — same .text-link everywhere, just with an
     inline SVG arrow instead of a font-drawn "&larr;" character, so
     it's the same stroke weight/color as every other icon in the app.
     Underline stays off the icon glyph itself (text-decoration only
     ever applies to the text run around it, but svg isn't underlined
     by definition — no extra rule needed for that part). */
  .nav-back-link svg {
    width: 0.85em;
    height: 0.85em;
    vertical-align: -0.1em;
    flex: none;
  }

  /* ============================================================
     BRAND MARK — the Ochem Chef logo
     Two inline-SVG marks (see assets/logo-full.svg and
     assets/logo-simplified.svg for the standalone copies — any inline
     copy in a page must use the same paths and class names). Each is
     drawn with a single stroke weight per mark (--mark-stroke, in
     the SVG's own units: 2 for the full mark, 4.5 for the simplified
     one — whose toque is hollow, as in the artwork; see CLAUDE.md)
     and carry NO colour of their own — every fill and
     stroke below is an existing theme token, so both themes work
     with no new colour values:
       --verdigris  the outlines (the same teal the app already uses
                    for "correct"; its dark-theme value is a lighter
                    teal, so the lines never go muddy on dark Paper)
       --marigold   the liquid in the flask
       --paper      every enclosed interior (hat, hands, flask,
                    benzene ring, spatula slots) — on a Paper card the
                    interiors simply vanish into the card, on Bone they
                    read as a lighter "sticker", and in dark mode that
                    is the dark Paper tone, never cream.
     ============================================================ */
  .brand-mark { display: block; }
  .brand-mark--full { --mark-stroke: 2; }
  .brand-mark--simple { --mark-stroke: 4.5; }
  .brand-mark .m-ink { fill: none; stroke: var(--verdigris); stroke-width: var(--mark-stroke); stroke-linecap: round; stroke-linejoin: round; }
  .brand-mark .m-surface { fill: var(--paper); stroke: var(--verdigris); stroke-width: var(--mark-stroke); stroke-linecap: round; stroke-linejoin: round; }
  .brand-mark .m-solid { fill: var(--verdigris); stroke: var(--verdigris); stroke-width: var(--mark-stroke); stroke-linecap: round; stroke-linejoin: round; }
  .brand-mark .m-rim { fill: none; stroke: var(--verdigris); stroke-width: var(--mark-stroke); stroke-linecap: square; }
  .brand-mark .m-slot { fill: none; stroke: var(--paper); stroke-width: 1.6; stroke-linecap: round; }
  .brand-mark .m-liquid { fill: var(--marigold); stroke: none; }

  /* ---- Lockups: a mark beside (or above) the name, which stays live
     text in the interface face rather than being baked into an image.
     The mark is decorative (aria-hidden) — the name beside it is the
     accessible label. Sizes are rem so they follow the reader's font
     size, like the type next to them. ---- */
  .brand-lockup {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-xs);
  }
  .brand-lockup .brand-mark {
    flex: none;
    height: var(--lockup-mark, 5.5rem);
    width: auto;
  }
  /* The alternative to mark-left: mark above the name. Not used by the
     app today (it costs ~3rem of vertical space on a screen whose point
     is to get the sample problem above the fold) but kept as a one-class
     switch — see the Brand mark entry in the styleguide. */
  .brand-lockup--stacked {
    flex-direction: column;
    gap: var(--space-2xs);
  }
  /* The home screen header uses the simplified mark, smaller. */
  .brand-lockup--home {
    --lockup-mark: 2.75rem;
    gap: var(--space-2xs);
  }
  /* Footer: a small brand line above the theme control. */
  .app-footer .footer-brand {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-2xs);
    margin: 0 0 var(--space-sm);
    font-family: var(--font-structure);
    font-weight: 700;
    font-size: 0.95rem;
    color: var(--char);
  }
  .app-footer .footer-brand .brand-mark {
    flex: none;
    height: 1.5rem;
    width: auto;
  }

  /* ============================================================
     HOME SCREEN
     ============================================================ */
  .app-title {
    text-align: center;
    font-family: var(--font-structure);
    font-weight: 800;
    font-size: 1.75rem;
    letter-spacing: -0.01em;
    margin: var(--space-xl) 0 var(--space-2xs);
  }

  .home-due-text {
    text-align: center;
    color: var(--char-soft);
    font-family: var(--font-voice);
    font-weight: 400; /* PT Serif has no 500 — see the self-hosted-fonts note up top */
    font-size: 1.05rem;
    margin: 0 0 var(--space-xl);
  }

  /* The one big, obvious button on the home screen — same ink fill as
     every other primary action, just bigger. */
  .primary-button {
    display: block;
    width: 100%;
    min-height: 56px; /* tap target rule: at least 44px tall (this is bigger, on purpose) */
    background: var(--char);
    color: var(--paper);
    border: none;
    border-radius: var(--radius-sm);
    font-family: var(--font-structure);
    font-size: 1.1rem;
    font-weight: 700;
    cursor: pointer;
    margin-bottom: var(--space-sm);
    transition: transform var(--motion-fast) var(--ease);
  }
  .primary-button:active {
    transform: scale(0.98);
  }

  /* ---- Mode caption ----
     A one-line, plain-language gloss under the Start button explaining
     what daily practice actually does — small enough not to compete
     with the button above it, icon + text the same way .option-badge
     and .explanation-heading pair them everywhere else in this app. */
  .mode-caption {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-3xs);
    text-align: center;
    font-family: var(--font-structure);
    font-size: 0.82rem;
    font-weight: 500;
    color: var(--char-soft);
    margin: 0 0 var(--space-md);
  }
  .mode-caption svg {
    width: 1em;
    height: 1em;
    flex: none;
  }

  /* ---- "How this works" disclosure ----
     Collapsed by default (see HOW_IT_WORKS_KEY in the script). The
     chevron is the only thing that animates open/closed — no
     accordion-height animation, so the content just appears/disappears
     with [hidden] the same way every other toggled panel in this app
     does when reduced motion is off but nothing else needs to move. */
  .disclosure-toggle {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--space-3xs);
    width: 100%;
    background: none;
    border: none;
    color: var(--char-soft);
    font-family: var(--font-structure);
    font-size: 0.85rem;
    font-weight: 600;
    cursor: pointer;
    padding: var(--space-2xs);
    margin-top: var(--space-2xs);
  }
  .disclosure-toggle svg {
    width: 0.8em;
    height: 0.8em;
    flex: none;
    transition: transform var(--motion-fast) var(--ease);
  }
  .disclosure-toggle[aria-expanded="true"] svg {
    transform: rotate(180deg);
  }
  .disclosure-content {
    margin-top: var(--space-xs);
    padding-top: var(--space-xs);
    border-top: 1px solid var(--rule);
  }
  .disclosure-content p {
    margin: 0;
    font-family: var(--font-voice);
    font-weight: 400; /* PT Serif has no 500 — see the self-hosted-fonts note up top */
    font-size: 0.95rem;
    line-height: 1.6;
    color: var(--char-soft);
  }

  /* ============================================================
     PROGRESS SCREEN
     ============================================================ */
  .progress-title {
    text-align: center;
    font-family: var(--font-structure);
    font-weight: 800;
    font-size: 1.5rem;
    margin: var(--space-2xs) 0 var(--space-lg);
  }

  .stats-grid {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    gap: var(--space-2xs);
    margin-bottom: var(--space-xl);
  }

  /* Boxed, but quiet — a fill and a border, no shadow. Only the
     practice card earns a shadow in this app; see the "one page, one
     shadow" note in the design plan. */
  .stat-box {
    background: var(--paper);
    border: 1px solid var(--rule);
    border-radius: var(--radius-sm);
    padding: var(--space-sm) var(--space-2xs);
    text-align: center;
  }

  .stat-value {
    font-family: var(--font-data);
    font-size: 1.3rem;
    font-weight: 700;
    color: var(--char);
  }

  .stat-label {
    font-family: var(--font-structure);
    font-size: 0.72rem;
    font-weight: 600;
    color: var(--char-soft);
    margin-top: var(--space-3xs);
  }

  .section-heading {
    font-family: var(--font-structure);
    font-weight: 700;
    font-size: 1rem;
    margin: 0 0 var(--space-sm);
  }

  /* ---- Foundations / Organic 1 / Organic 2 grouping ----
     No color spent here at all — course is a neutral distinction, not
     a state or a category that needs to compete with the tier colors
     for attention. A plain ink-outlined numeral does the job: the
     topic list is already split into labeled sections, so this just
     makes each one identifiable at a glance from the glyph alone, the
     same way a numbered list would. Foundations has no course number,
     so its badge carries an "F" in the same outline. */
  .course-heading {
    display: flex;
    align-items: center;
    gap: var(--space-2xs);
  }

  .course-heading + .course-heading,
  .topic-row + .course-heading {
    margin-top: var(--space-lg);
  }

  .course-badge {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 1.5rem;
    height: 1.5rem;
    border: 1.5px solid var(--char);
    border-radius: 50%;
    font-family: var(--font-data);
    font-size: 0.8rem;
    font-weight: 700;
    color: var(--char);
    flex: none;
  }

  /* ---- Scope screen: course/topic checkboxes ----
     Native checkboxes, not a custom-built control — accent-color is
     enough to keep them ink-toned in both themes without losing any of
     the accessibility a real checkbox gets for free. */
  input[type="checkbox"] {
    width: 1.15em;
    height: 1.15em;
    accent-color: var(--char);
    flex: none;
  }

  .scope-course-row {
    margin-top: var(--space-lg);
  }

  .scope-course-row:first-child {
    margin-top: var(--space-md);
  }

  .scope-course-label {
    display: flex;
    align-items: center;
    gap: var(--space-2xs);
    cursor: pointer;
  }

  .scope-topic-rows {
    margin-top: var(--space-sm);
    padding-left: 1.9rem; /* aligns under the course label's text, past its checkbox */
  }

  .scope-topic-row {
    display: flex;
    align-items: center;
    gap: var(--space-2xs);
    font-family: var(--font-structure);
    font-size: 0.88rem;
    font-weight: 500;
    cursor: pointer;
    padding: var(--space-2xs) 0;
  }

  /* A declared Foundations topic with no problems merged yet: still a
     real, selectable checkbox, with a plain-text note after its name
     (same quiet style as the progress screen's scope note). */
  .scope-topic-name .topic-empty-note {
    margin-left: var(--space-2xs);
    white-space: nowrap;
  }

  /* ---- Difficulty legend ----
     One quiet line explaining the three colors the tier segments below
     use, so color is never the only way to tell them apart. */
  .tier-legend {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2xs) var(--space-sm);
    font-family: var(--font-structure);
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--char-soft);
    margin: calc(-1 * var(--space-2xs)) 0 var(--space-md);
  }

  .tier-legend-item {
    display: inline-flex;
    align-items: center;
    gap: var(--space-3xs);
  }

  .tier-legend-item::before {
    content: "";
    display: inline-block;
    width: 0.6em;
    height: 0.6em;
    border-radius: 50%;
  }

  .tier-legend-item.tier-foundational::before { background: var(--tier-foundational); }
  .tier-legend-item.tier-challenging::before { background: var(--tier-challenging); }
  .tier-legend-item.tier-advanced::before { background: var(--tier-advanced); }

  /* ---- One bar per topic ---- */
  .topic-row {
    margin-bottom: var(--space-md);
  }

  /* A topic excluded from practice (see the scope screen) — dimmed,
     plus its own text note below, since opacity alone isn't a signal
     everyone reading this screen will register. */
  .topic-row.out-of-scope {
    opacity: 0.55;
  }

  .topic-row-scope-note,
  .topic-empty-note {
    font-family: var(--font-structure);
    font-size: 0.72rem;
    font-weight: 600;
    color: var(--char-soft);
  }

  .topic-row-label {
    display: flex;
    align-items: center;
    gap: var(--space-2xs);
    font-family: var(--font-structure);
    font-size: 0.88rem;
    font-weight: 600;
    margin-bottom: var(--space-3xs);
  }

  /* Mastery is earned, not decorative: this only appears once a topic
     clears a real accuracy bar over a real number of attempts (see
     renderProgressScreen() below). Marigold means exactly one thing
     everywhere it appears — a streak, or mastery — never anything else. */
  .mastery-badge {
    display: inline-flex;
    align-items: center;
    gap: var(--space-3xs);
    font-family: var(--font-structure);
    font-size: 0.68rem;
    font-weight: 700;
    color: var(--marigold);
    background: var(--marigold-bg);
    padding: var(--space-3xs) var(--space-2xs);
    border-radius: var(--radius-pill);
  }
  .mastery-badge svg {
    width: 0.8em;
    height: 0.8em;
    flex: none;
  }

  .topic-bar-track {
    background: var(--bone);
    border: 1px solid var(--rule);
    border-radius: var(--radius-pill);
    height: 10px;
    overflow: hidden;
  }

  .topic-bar-fill {
    background: var(--char-soft);
    height: 100%;
    width: 0%;
    transition: width var(--motion-slow) var(--ease), background-color var(--motion-slow) var(--ease);
  }

  /* Mastering a topic is the one thing that overrides the plain
     char-soft fill above, with Marigold instead — course no longer
     colors this bar at all (see the Organic 1/2 grouping note above). */
  .topic-bar-fill.mastered { background: var(--marigold); }

  .topic-row-percent {
    font-family: var(--font-data);
    font-size: 0.78rem;
    color: var(--char-soft);
    margin-top: var(--space-3xs);
    text-align: right;
  }

  /* ---- Difficulty-tier segments ----
     A second, thinner bar under the main one: three independent tracks
     (foundational | challenging | advanced), each filled to that
     tier's own accuracy for this topic. Where the main bar above says
     "how am I doing on this topic," this says "which parts of it." */
  .topic-tier-track {
    display: flex;
    gap: var(--space-3xs);
    margin-top: var(--space-2xs);
  }

  .topic-tier-segment {
    flex: 1;
    height: 6px;
    border-radius: var(--radius-pill);
    overflow: hidden;
  }

  .topic-tier-segment.tier-foundational { background: var(--tier-foundational-bg); }
  .topic-tier-segment.tier-challenging { background: var(--tier-challenging-bg); }
  .topic-tier-segment.tier-advanced { background: var(--tier-advanced-bg); }

  .topic-tier-fill {
    height: 100%;
    width: 0%;
    transition: width var(--motion-slow) var(--ease);
  }

  .topic-tier-segment.tier-foundational .topic-tier-fill { background: var(--tier-foundational); }
  .topic-tier-segment.tier-challenging .topic-tier-fill { background: var(--tier-challenging); }
  .topic-tier-segment.tier-advanced .topic-tier-fill { background: var(--tier-advanced); }

  .danger-button {
    display: block;
    width: 100%;
    min-height: 44px; /* tap target rule: at least 44px tall */
    margin-top: var(--space-xl);
    background: var(--paper);
    color: var(--vermilion);
    border: 1.5px solid var(--vermilion);
    border-radius: var(--radius-sm);
    font-family: var(--font-structure);
    font-size: 0.9rem;
    font-weight: 700;
    cursor: pointer;
  }

  /* ---- Secondary button: same size rules as the others, quieter look ---- */
  .secondary-button {
    display: block;
    width: 100%;
    min-height: 44px; /* tap target rule: at least 44px tall */
    margin-top: var(--space-xs);
    background: var(--paper);
    color: var(--char);
    border: 1.5px solid var(--rule);
    border-radius: var(--radius-sm);
    font-family: var(--font-structure);
    font-size: 0.9rem;
    font-weight: 700;
    cursor: pointer;
  }

  /* The setup screen's course presets set this — the same
     inverted-fill language every other "currently selected" control in
     this app uses. */
  .secondary-button[aria-pressed="true"] {
    background: var(--char);
    color: var(--paper);
    border-color: var(--char);
  }

  /* ---- Practice setup screen ----
     Built from existing pieces: the presets are .secondary-button
     (pressed = selected), length and difficulty are .segmented-control,
     notes reuse the .mock-exam-notice callout, Start is .primary-button.
     These rules only lay them out. */
  .setup-presets {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--space-2xs);
  }
  .setup-preset {
    margin-top: 0;
    padding: var(--space-2xs) var(--space-xs);
    text-align: left;
  }
  .setup-preset-name {
    display: block;
  }
  /* Inherits the button's own ink, so it stays legible on both the
     plain and the pressed (inverted) state. */
  .setup-preset-caption {
    display: block;
    margin-top: var(--space-3xs);
    font-size: 0.78rem;
    font-weight: 500;
  }
  /* Each block's heading needs room above it: here they follow a
     control or a hint, not a paragraph with its own bottom margin. */
  #scope-screen .section-heading {
    margin-top: var(--space-lg);
  }
  .setup-summary {
    margin-top: var(--space-lg);
  }
  .setup-start-button {
    margin-top: var(--space-md);
  }
  /* A disabled Start always sits under a notice saying why (the setup
     screen never disables it silently), so the muted outline here is
     not the only signal. */
  .primary-button:disabled {
    background: var(--paper);
    color: var(--char-soft);
    border: 1.5px solid var(--rule);
    cursor: not-allowed;
  }
  .primary-button:disabled:active {
    transform: none;
  }
  .setup-hint {
    margin: var(--space-2xs) 0 0;
    font-family: var(--font-structure);
    font-size: 0.82rem;
    font-weight: 500;
    color: var(--char-soft);
  }
  #setup-notice-text .mock-exam-notice-text + .mock-exam-notice-text {
    margin-top: var(--space-2xs);
  }

  /* ---- Progress code: move progress to another device ----
     Outlined, not filled — a labeled region grouping several controls,
     not itself something you act on. */
  .progress-code-section {
    border: 1px solid var(--rule);
    border-radius: var(--radius-sm);
    padding: var(--space-sm);
    margin: var(--space-xl) 0;
  }

  .progress-code-hint {
    font-family: var(--font-voice);
    font-weight: 400; /* PT Serif has no 500 — see the self-hosted-fonts note up top */
    font-size: 0.9rem;
    color: var(--char-soft);
    margin: 0 0 var(--space-xs);
  }

  /* Same link treatment as the footer's mailto link — ink, not the
     browser's default link blue. */
  .progress-code-hint a {
    color: var(--char);
  }

  .progress-code-box {
    display: block;
    width: 100%;
    margin-top: var(--space-2xs);
    padding: var(--space-2xs);
    border: 1px solid var(--rule);
    border-radius: var(--radius-sm);
    font-family: var(--font-data);
    font-size: 0.75rem;
    /* Textareas don't inherit the page's default font-weight — without
       this they'd silently request weight 400, which isn't one of the
       three IBM Plex Mono weights self-hosted below. */
    font-weight: 500;
    resize: vertical;
    background: var(--bone);
    color: var(--char);
  }

  /* Same fix as .next-button[hidden] above: without this, "display:
     block" would win over the browser's built-in rule for the hidden
     attribute, and this box would stay visible while hidden is set. */
  .progress-code-box[hidden] {
    display: none;
  }

  .progress-code-status {
    display: flex;
    align-items: center;
    gap: var(--space-3xs);
    font-family: var(--font-structure);
    font-size: 0.85rem;
    margin-top: var(--space-2xs);
  }
  /* Without this, "display: flex" above would win the specificity tie
     against the browser's built-in [hidden] rule, and this would stay
     visible even while hidden is set — same fix as .next-button[hidden]
     elsewhere in this file. */
  .progress-code-status[hidden] {
    display: none;
  }
  .progress-code-status svg {
    width: 1em;
    height: 1em;
    flex: none;
  }

  .progress-code-status.success-text {
    color: var(--verdigris);
  }

  .progress-code-status.error-text {
    color: var(--vermilion);
  }

  .restore-progress-panel {
    margin-top: var(--space-xs);
  }

  .restore-progress-panel label {
    display: block;
    font-family: var(--font-structure);
    font-size: 0.85rem;
    color: var(--char-soft);
  }

  /* ============================================================
     FOOTER
     ============================================================ */
  .app-footer {
    max-width: 640px;
    margin: var(--space-sm) auto 0;
    padding: var(--space-lg) var(--space-sm) var(--space-xl);
    text-align: center;
    font-family: var(--font-voice);
    font-weight: 400; /* PT Serif has no 500 — see the self-hosted-fonts note up top */
    font-size: 0.85rem;
    color: var(--char-soft);
  }

  .app-footer a {
    color: var(--char);
  }

  /* ---- Segmented control ----
     Fixed, always-true labels (each option always paired with its own
     icon, if it has one) rather than a button whose label had to
     describe an action — this shape can't go stale the way a single
     "press for the next state" button can, and it's the only one that
     keeps every option reachable with one tap. First built for the
     theme control (Light/Dark/System); reused as-is for the exam-setup
     screen's sub-mode and session-length choices. Neutral like the
     course badge — a control, not a state the palette marks, so it
     spends no color of its own; the active segment inverts to Char the
     same way every other "selected" filled element in this app does. */
  .segmented-control {
    display: inline-flex;
    border: 1.5px solid var(--rule);
    border-radius: var(--radius-pill);
    overflow: hidden;
  }

  /* In the footer the theme control is the first thing in a centered
     column, so it needs space below it before the text that follows. */
  .app-footer .theme-toggle {
    margin-bottom: var(--space-sm);
  }

  .segmented-control button {
    appearance: none;
    display: inline-flex;
    align-items: center;
    gap: var(--space-3xs);
    padding: 0.4rem 0.7rem;
    border: none;
    background: none;
    color: var(--char-soft);
    font-family: var(--font-structure);
    font-size: 0.78rem;
    font-weight: 600;
    cursor: pointer;
  }

  .segmented-control button + button {
    border-left: 1.5px solid var(--rule);
  }

  .segmented-control button[aria-pressed="true"] {
    background: var(--char);
    color: var(--paper);
  }

  .segmented-control svg {
    width: 1em;
    height: 1em;
    flex: none;
  }

  /* ---- Exam mode descriptions ----
     Both modes' one-line description stay visible together under the
     segmented control above, not just the selected one — the point is
     comparing the two before choosing. The active one reads in full
     ink; the other stays muted. Icons live on the segmented-control
     buttons themselves (reusing the check/clock vocabulary), so these
     lines stay plain text rather than repeating the same icon twice. */
  .mode-description-list {
    list-style: none;
    margin: var(--space-xs) 0 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: var(--space-3xs);
  }
  .mode-description {
    font-family: var(--font-structure);
    font-size: 0.85rem;
    font-weight: 500;
    color: var(--char-soft);
  }
  .mode-description strong {
    font-weight: 700;
  }
  .mode-description.active {
    color: var(--char);
  }

  /* ---- Mock Exam expectations notice ----
     Shown only while Mock Exam is the selected mode (see
     updateMockExamNotice() in the script), right above the Start
     button — a plain informational callout, not a correct/incorrect/
     mastery state, so it stays neutral (Bone-on-Paper) rather than
     borrowing one of the three semantic colors for something they
     don't mean. */
  .mock-exam-notice {
    display: flex;
    align-items: flex-start;
    gap: var(--space-2xs);
    margin-top: var(--space-md);
    padding: var(--space-sm);
    background: var(--bone);
    border: 1px solid var(--rule);
    border-radius: var(--radius-sm);
  }
  .mock-exam-notice svg {
    width: 1.1em;
    height: 1.1em;
    flex: none;
    margin-top: 0.15em;
    color: var(--char-soft);
  }
  .mock-exam-notice-text {
    margin: 0;
    font-family: var(--font-voice);
    font-weight: 400; /* PT Serif has no 500 — see the self-hosted-fonts note up top */
    font-size: 0.92rem;
    line-height: 1.5;
    color: var(--char);
  }
  /* Same [hidden]-vs-"display: flex" specificity fix as
     .progress-code-status[hidden] elsewhere in this file. */
  .mock-exam-notice[hidden] {
    display: none;
  }

  /* ---- Perfect Mock Exam review ----
     Pairs the same check icon the explanation panel uses for a correct
     answer with the "nothing missed" message, instead of leaving it as
     unstyled plain text the only place in the app a full-correct state
     goes unmarked. */
  .review-perfect {
    display: flex;
    align-items: center;
    gap: var(--space-2xs);
    color: var(--verdigris);
  }
  .review-perfect svg {
    width: 1.2em;
    height: 1.2em;
    flex: none;
  }

  /* ============================================================
     LEGAL PAGES (privacy.html, terms.html)
     Not app "screens" (no JS, no hidden/shown state), so they get one
     container rule of their own — but everything inside it is the
     app's existing type/spacing, reused on purpose rather than given
     its own rules: .app-title, .counter (for the "Last updated" line),
     .section-heading, .landing-section, .explanation-text,
     .progress-code-section, .text-link, and .app-footer all appear
     here exactly as they do on the landing page. Only a handful of
     things the app itself never needed — lists, multi-paragraph
     spacing, and a plain link color for prose — are added below.
     ============================================================ */
  .legal-main {
    max-width: 640px;
    margin: 0 auto;
    padding: var(--space-md) var(--space-sm) var(--space-xl);
  }

  .legal-content p + p {
    margin-top: var(--space-sm);
  }

  .legal-content ul {
    padding-left: var(--space-md);
    margin: var(--space-xs) 0;
  }

  .legal-content li {
    font-family: var(--font-voice);
    font-weight: 400; /* PT Serif has no 500 — see the self-hosted-fonts note up top */
    font-size: 1.05rem;
    line-height: 1.6;
    color: var(--char);
    margin-bottom: var(--space-2xs);
  }

  .legal-content a {
    color: var(--char);
  }

  /* Inline code (e.g. "localStorage") gets the Data typeface and a
     quiet chip, the same instinct as reagents-text elsewhere: anything
     meant to be read character-for-character gets IBM Plex Mono. */
  code {
    font-family: var(--font-data);
    font-size: 0.92em;
    background: var(--bone);
    padding: 0.1em 0.4em;
    border-radius: 4px;
  }

  /* ============================================================
     DEV TEST MODE INDICATOR
     Only ever shown when a devTest URL parameter activates test
     mode (see index.html's Developer test mode block) — small and
     out of the way on purpose, since it exists to keep a developer
     from mistaking a test session for a real one, not to be a UI
     feature anyone else sees. Ink-on-Paper, the same "buttons are
     ink" chrome language as the rest of the app, not a semantic
     color (correct/incorrect/mastery) — it isn't reporting a state
     the product means, just that this page load isn't a real one.
     ============================================================ */
  .dev-test-indicator {
    position: fixed;
    bottom: var(--space-sm);
    right: var(--space-sm);
    z-index: 1000;
    display: inline-flex;
    align-items: center;
    gap: var(--space-3xs);
    padding: var(--space-3xs) var(--space-xs);
    border-radius: var(--radius-pill);
    background: var(--char);
    color: var(--paper);
    font-family: var(--font-structure);
    font-size: 0.72rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    box-shadow: var(--shadow-card);
    pointer-events: none;
  }
  .dev-test-indicator[hidden] {
    display: none;
  }
