/* ---------- fonts ----------
   Self-hosted rather than pulled from Google's CDN: no third-party request on
   the critical path, nothing to go wrong on a slow connection, and no student
   traffic leaking to fonts.gstatic.com. It is also not optional — the CSP sets
   font-src 'self', so a Google Fonts <link> would simply be blocked.

   The VIETNAMESE subset is not optional either — the whole interface is
   Vietnamese and latin-ext stops short of U+1EA0–1EF9, which is where most of
   the tone marks live (ạ ả ấ ầ ộ ướ). A face without it falls back to a system
   font MID-WORD on any toned syllable. That is not hypothetical: "Archivo
   Black" (the separate family, not Archivo variable) caused exactly that here
   before, which is why every face below was checked for the subset first.

   Cal Sans + Alexandria replaced Archivo + Inter (2026-08-26), matching
   theforumcenter.com's pairing. Both ship a vietnamese subset; both are OFL.

   Cal Sans ships exactly ONE weight, but is declared `font-weight: 400 900`
   on purpose. The interface asks for --weight-display (800) and
   --weight-black (900) in ~46 places; without that range the browser would
   treat those as unavailable and synthesize faux bold, which smears a
   rounded display face badly. Declaring the range maps all of them onto the
   one real file, so nothing is ever synthesized. Do NOT "fix" this to
   `font-weight: 400` — the 46 call sites are the reason it reads this way.
   (Same trick the Caveat face below already uses.)

   Alexandria is a true variable font — Google serves one identical file for
   every weight in the axis — so `font-weight: 100 900` is literal there.

   Neither family publishes a latin-ext subset, so unlike the old Archivo/Inter
   blocks there are two files each, not three.

   Playfair Display joined in v1.4.40 for the landing headline. FOUR files, not
   two: roman and italic are separate faces here, and the italic is the point —
   the whole look rests on emphasising one or two words with a TRUE italic, and
   a browser-synthesised oblique (which is what you get if the file is missing)
   looks obviously wrong at display size. Verified as a real, separate file:
   the roman and italic latin subsets differ by checksum, they are not the same
   font slanted.

   Variable, like Alexandria — one file covers the whole weight axis — so
   `font-weight: 400 900` is literal, not the aliasing trick Cal Sans needs.

   Kept OFF --font-display on purpose. That token still points at Cal Sans,
   which has one real weight; Playfair has real weights, so repointing it would
   make ~46 call sites of --weight-display/--weight-black jump in weight across
   all ten pages at once. The landing uses --font-serif instead. Migrating the
   app is its own pass, with those call sites reviewed one by one.

   (Luxurious Script lived here for part of one day — a script face for the
   brand name, dropped when the whole direction changed. Nothing references it;
   its two files are deleted.)

   font-display: swap — text is readable in a fallback face immediately and
   reflows when the real font lands, rather than sitting invisible. */

@font-face {
  font-family: 'Playfair Display'; font-style: normal; font-weight: 400 900;
  font-display: swap; src: url('/static/fonts/playfair-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
                 U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Playfair Display'; font-style: normal; font-weight: 400 900;
  font-display: swap; src: url('/static/fonts/playfair-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
                 U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Playfair Display'; font-style: italic; font-weight: 400 900;
  font-display: swap; src: url('/static/fonts/playfair-italic-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
                 U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Playfair Display'; font-style: italic; font-weight: 400 900;
  font-display: swap; src: url('/static/fonts/playfair-italic-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
                 U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ---------- Space Grotesk + Be Vietnam Pro (the app's faces) ----------
   Added when the app was brought onto the landing page's type system. These
   two plus JetBrains Mono are now the WHOLE system: headings, body, and
   numbers. The three families below them (Cal Sans, Alexandria, Playfair) are
   no longer referenced by any token — see the note there.

   Declared here as well as in landing.css, and that duplication is correct:
   the two stylesheets are never loaded by the same page (/about loads
   landing.css alone; these nine pages load style.css), and JetBrains Mono has
   been duplicated between style.css and about.css on the same reasoning since
   it was first added. One shared file would have to be loaded by BOTH, which
   would put the landing's whole palette on the app's critical path.

   Space Grotesk is VARIABLE — one file per subset covers the axis, so
   `font-weight: 300 700` is literal. Note the ceiling: 700. There is no 800 or
   900 in this face, which is why --weight-display and --weight-black below are
   both 700. Do not raise them.

   Be Vietnam Pro is STATIC — one file per weight, hence eight blocks. */

@font-face {
  font-family: 'Space Grotesk'; font-style: normal; font-weight: 300 700;
  font-display: swap; src: url('/static/fonts/space-grotesk-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
                 U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Space Grotesk'; font-style: normal; font-weight: 300 700;
  font-display: swap; src: url('/static/fonts/space-grotesk-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
                 U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 400;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-400-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
                 U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 400;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-400-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
                 U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 500;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-500-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
                 U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 500;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-500-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
                 U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 600;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-600-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
                 U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 600;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-600-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
                 U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 700;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-700-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
                 U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Be Vietnam Pro'; font-style: normal; font-weight: 700;
  font-display: swap; src: url('/static/fonts/be-vietnam-pro-700-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
                 U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* ---------- retired faces ----------
   Cal Sans, Alexandria and Playfair Display are kept declared but NO TOKEN
   POINTS AT THEM any more. An unreferenced @font-face costs nothing at
   runtime — the browser only fetches a file when some element actually
   matches the family — so the three .woff2 pairs stay on disk and stay
   declared, and reviving any of them is a one-line token change rather than a
   re-download.
   Logged in STATUS.md for a later cleanup pass. Do not delete the files
   without first grepping for the family names in every .css. */

@font-face {
  font-family: 'Cal Sans'; font-style: normal; font-weight: 400 900;
  font-display: swap; src: url('/static/fonts/cal-sans-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
                 U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Cal Sans'; font-style: normal; font-weight: 400 900;
  font-display: swap; src: url('/static/fonts/cal-sans-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
                 U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

@font-face {
  font-family: 'Alexandria'; font-style: normal; font-weight: 100 900;
  font-display: swap; src: url('/static/fonts/alexandria-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323, U+0329,
                 U+1EA0-1EF9, U+20AB;
}
@font-face {
  font-family: 'Alexandria'; font-style: normal; font-weight: 100 900;
  font-display: swap; src: url('/static/fonts/alexandria-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191,
                 U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* JetBrains Mono — originally a landing-page-only extra (about.css), promoted
   here because the redesigned login card (.login-card below) uses it for the
   eyebrow and field labels. Same self-hosted file, same subsetting approach as
   above. Caveat used to sit alongside it for the handwritten accent word; it
   was retired 2026-08-28 and every site that used it now takes the body face
   (see --font-hand). */
@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal; font-weight: 600; font-display: swap;
  src: url('/static/fonts/jetbrains-mono-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA,
                 U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122,
                 U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal; font-weight: 600; font-display: swap;
  src: url('/static/fonts/jetbrains-mono-vietnamese.woff2') format('woff2');
  unicode-range: U+0102-0103, U+0110-0111, U+0128-0129, U+0168-0169, U+01A0-01A1,
                 U+01AF-01B0, U+0300-0301, U+0303-0304, U+0308-0309, U+0323,
                 U+0329, U+1EA0-1EF9, U+20AB;
}
:root {
  /* Typography. Cal Sans (display) + Alexandria (body), the pairing
     theforumcenter.com uses — swapped in 2026-08-26 for the old Archivo/Inter
     pair. Cal Sans is a rounded display face with a single real weight, so
     unlike Archivo the contrast here comes from SCALE rather than from weight;
     see the @font-face note above for why the weight tokens below still work. */
  --font-display: 'Space Grotesk', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  --font-body: 'Be Vietnam Pro', 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  /* The landing headline, and the italic emphasis inside it. Separate from
     --font-display on purpose — see the @font-face note above. */
  --font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif;
  /* Kept for anything still referencing it. The wordmark used to be live SVG
     text whose cyan/pink ghost offsets were hand-placed against this exact
     stack, which is why it was pinned outside the font system. v1.4.40 drew
     the mark in --font-script instead and deleted the ghosts, so nothing
     load-bearing depends on this any more. */
  --font-wordmark: 'Segoe UI', Roboto, Helvetica, Arial, ui-sans-serif, system-ui, sans-serif;
  /* Mono for eyebrows/labels, on the login card only. */
  --font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  /* Was Caveat, a script face. Retired 2026-08-28: everything that used it now
     renders in the body face. That also lifts the "keep this English" rule the
     annotations carried — Caveat shipped no Vietnamese subset, so a single tone
     mark used to drop mid-word to a system face. */
  --font-hand: var(--font-body);

  --weight-body: 400;
  --weight-medium: 500;
  --weight-strong: 600;
  /* ⛔ BOTH CAPPED AT 700, AND THIS IS LOAD-BEARING.
     Space Grotesk's variable axis stops at 700. These two tokens are read in
     ~38 places; while --font-display pointed at Cal Sans they could say 800
     and 900 because that face declares `font-weight: 400 900` to alias every
     request onto its single real file. Space Grotesk does not do that — ask it
     for 800 and the browser SYNTHESISES a fake bold, which smears the letter-
     forms and reports nothing. No error, no warning, just slightly wrong type
     on every heading in the app.
     The names are kept (38 call sites, and "display" vs "black" still reads as
     an intent ladder) but they now resolve to the same real weight. If a
     heavier face is ever wanted, change the FACE, not these numbers. */
  --weight-display: 700;
  --weight-black: 700;

  /* Reading sizes stay in rem so they honour the browser's font-size setting.
     ---------------------------------------------------------------------
     Raised ~15% on 2026-09-03. The old scale put --text-base at 15.2px and
     --text-sm at 13.9px, which meant most CHROME on every screen (labels,
     meta lines, table rows) rendered between 10 and 14px. Marc read the built
     app on his own machine and the verdict was simply that it is too small —
     and it is: this interface is Vietnamese, whose stacked diacritics need
     more vertical room than Latin at the same nominal size, and a good share
     of these students are on iPads.

     The two largest steps are deliberately raised LESS (~8-10%, not 15%).
     They already sit at 32px and 41.6px, where the complaint does not apply,
     and --text-display drives the band readout inside a fixed-width summary
     card that starts wrapping if it grows much further. The problem was the
     small end of the scale, so the small end is where the 15% goes. */
  --text-micro: 0.8rem;     /* uppercase tracked labels */
  --text-xs: 0.9rem;
  --text-sm: 1rem;
  --text-base: 1.09rem;
  --text-lg: 1.26rem;
  --text-xl: 1.6rem;
  --text-2xl: 2.2rem;
  --text-display: 2.8rem;   /* the big band readout */

  --leading-tight: 1.15;
  --leading-snug: 1.4;
  --leading-body: 1.65;     /* Vietnamese stacks diacritics; it needs the room */
  --tracking-label: 0.11em;

  /* Greyscale palette. Replaces the old navy as the dominant colour across the
     login backdrop and the working screens. The names stay --navy/--navy-dark
     because they are referenced in ~60 places and renaming them would be a
     refactor, not a recolour; read them as "the dominant dark". */
  --ink-950: #0A0A0A;
  --ink-900: #161616;
  --ink-800: #1D1C1C;
  --ink-700: #232323;
  --ink-600: #2C2C2C;
  --ink-500: #363636;
  --ink-400: #454444;

  --navy: #1D1C1C;
  --navy-dark: #0A0A0A;
  --crimson: #E31837;
  --crimson-dark: #b8112b;

  /* The neutral ramp, de-tinted to match the palette above. These were a cool
     blue-grey (slate), which read as a blue cast once the dominant colour went
     neutral black. Each step keeps its old lightness, so contrast ratios and
     every layout decision built on them are unchanged — only the hue is gone.
     The names stay for the same reason --navy does: they are used everywhere. */
  --offwhite: #F5F5F5;
  --slate-800: #232323;
  --slate-700: #363636;
  --slate-600: #454444;
  --slate-500: #6b6b6b;
  --slate-400: #9a9a9a;
  --slate-300: #d2d2d2;
  --slate-200: #e6e6e6;
  --slate-100: #f3f3f3;
  --slate-50: #fafafa;
  --amber-100: #fef3c7;
  --amber-200: #fde68a;
  --amber-300: #fcd34d;
  --amber-700: #b45309;
  --amber-800: #92400e;
  --blue-100: #dbeafe;
  --blue-200: #bfdbfe;
  --blue-300: #93c5fd;
  --blue-700: #1d4ed8;
  --blue-800: #1e40af;
  --emerald-50: #ecfdf5;
  --emerald-100: #d1fae5;
  --emerald-600: #059669;
  --emerald-700: #047857;

  /* ---------- shape & surface (v1.4.40) ----------
     These four groups did not exist before. Every radius, border width, shadow
     and gap in this codebase was typed by hand at the call site, which is how
     the interface ended up with SEVEN different corner radii and FOURTEEN
     letter-spacing values doing the same job. Redesigning meant editing ~84
     radius declarations across seven files one at a time.

     The point of naming them is not tidiness — it is that the next change to
     the visual language should be one edit here, not another eighty-four.

     The values below are the "Sổ tay" (notebook) language: thin ruled lines
     instead of heavy frames, near-square corners, and NO hard offset shadows.
     The old language stacked a 6px solid crimson shadow behind a 3px ink
     border on every card; that combination, repeated 17 times, is most of what
     read as childish. It is deliberately absent here — --shadow-card is a
     genuine soft shadow, and most surfaces should use --shadow-none and a
     hairline border instead. */

  /* Corners. --radius stays 8px so existing callers do not move. */
  --radius: 8px;
  --radius-sm: 3px;
  --radius-card: 6px;
  --radius-pill: 999px;   /* still exists; the landing/app chrome no longer uses it */

  /* Rules. A hairline is the whole vocabulary of separation. */
  --border-hair: 1px;
  --border-thin: 1.5px;
  --line: var(--slate-200);

  /* ---------- Calm Product UI surfaces (the two mockups) ----------
     The app's ground is NOT the landing's. The landing gets a 32px technical
     grid and 6px offset shadows because it has twenty seconds to make an
     impression; these screens have to survive forty-five minutes of reading
     and writing, so the same language at the same volume would be fatigue.
     Flat ground, 1px rules, no offset shadows.

     Two grounds, one shade apart, and the difference is deliberate: the
     dashboard is a page of cards (--surface-app, very slightly warm, so white
     cards separate from it), the workspace is a page of text (--surface-work,
     near-white, so a long essay is not read against grey). */
  --surface-app: #f7f7f5;
  --surface-work: #fbfbfb;
  --surface-card: #ffffff;

  /* The crisp technical rule the mockups ask for (zinc-300). --line is a
     softer separator for rows INSIDE a card; this one draws the card itself. */
  --line-crisp: #d4d4d8;
  /* Near-black 1px, for the one element on a screen that must out-rank the
     others — the Socratic question box. Used sparingly on purpose: everything
     bordered in ink reads as nothing bordered in ink. */
  --line-ink: #18181b;

  /* Elevation. No offset/solid shadows: those are the sticker language. */
  --shadow-none: none;
  --shadow-card: 0 1px 2px rgba(35, 32, 25, .04), 0 4px 14px rgba(35, 32, 25, .05);
  --shadow-lift: 0 2px 6px rgba(35, 32, 25, .06), 0 10px 26px rgba(35, 32, 25, .07);

  /* Spacing. A 4px base, because every hand-typed value already in the file
     lands on or near a multiple of 4. */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 22px;
  --space-6: 30px;
  --space-7: 44px;
  --space-8: 64px;

  /* The landing palette. Near-white ground with a whisper of blue, ink that is
     a very dark blue-grey rather than a true black, and a wide gap down to the
     subtitle grey — that gap is what makes the composition read as airy rather
     than merely empty.

     Replaced the "sotay" notebook set (warm #F4F4F0 paper, #232019 ink) after
     one look at it on the real page. The token NAMES are new too, not just the
     values: leaving a --sotay-* name pointing at a non-notebook colour is how
     a palette becomes impossible to reason about six months later. */
  --lp-ground: #FCFCFD;
  --lp-surface: #FFFFFF;
  --lp-ink: #16181D;
  --lp-ink-soft: #3A3F4A;
  --lp-dim: #6E7480;
  --lp-dimmer: #9AA0AB;
  --lp-line: #E6E8EC;
  /* The glow behind the headline. Cool, barely-there, and it must stay barely
     there — the moment it reads as a coloured shape it stops being light. */
  --lp-glow: rgba(120, 145, 210, .17);
}

* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
  margin: 0;
  /* --surface-app, not --offwhite (#F5F5F5). The working screens override this
     to --surface-work; see the note on those tokens. */
  background: var(--surface-app);
  color: var(--slate-800);
  font-family: var(--font-body);
  font-weight: var(--weight-body);
  -webkit-font-smoothing: antialiased;
  /* vh first as a fallback for browsers that don't know dvh, dvh second so it
     wins in every browser that does. `100vh` on mobile Safari is measured
     WITHOUT the collapsible address bar — so every time the bar hides/shows
     during a scroll, the browser has to re-run layout against a viewport
     height that just changed, which is a well-documented stutter source. And
     this one loads on every logged-in page, not just one screen — it's on
     `body` itself. `dvh` tracks the bar's real, current height instead. Same
     fix `.about-body--solo` already uses in about.css; this just extends it
     to the pages that load style.css. */
  min-height: 100vh;
  min-height: 100dvh;
}
.container { max-width: 1280px; margin: 0 auto; padding: 0 24px; }
/* Above this the 1280px cap was leaving ~340px of empty gutter on each side
   while the student edited a whole paragraph through a 449px-wide box. The
   extra width goes mostly to the sidebar, which is the actual working surface. */
@media (min-width: 1500px) {
  .container { max-width: 1560px; }
}

/* ---------- header ---------- */
/* The lesson screen reads as a page of text rather than a page of cards, so it
   gets the near-white ground. One shade, and the shade is the point: a long
   essay set on grey is measurably harder to read for long stretches. */
body.writing-page { background: var(--surface-work); }

/* White, with a 1px rule under it — both mockups specify exactly this, and the
   reason holds beyond the mockups: this bar is on screen for the whole lesson,
   directly above a long piece of the student's own writing. A dark slab there
   is a permanent block of high contrast in the corner of the eye, and it was
   also forcing every control inside it into a second colour scheme (white
   text, a crimson-outlined menu button, a light "sticker" card floating on
   dark). One ground for the whole page removes that second scheme.

   Everything that used to be white-on-navy is re-coloured below; grep for
   `header.top` before assuming any of it is decorative. */
header.top {
  background: var(--surface-card);
  border-bottom: var(--border-hair) solid var(--line-crisp);
}
.header-inner {
  display: flex;
  flex-direction: column;
  gap: 16px;
  /* Carries .container's 24px horizontal padding rather than zeroing it. This
     element has BOTH classes, and .header-inner wins on source order, so
     `padding: 16px 0` was silently dropping the gutter: the wordmark sat hard
     against the screen edge on anything under 1280px, and sat 24px left of the
     body content above it. */
  padding: 16px 24px;
}
@media (min-width: 1024px) {
  .header-inner { flex-direction: row; align-items: center; justify-content: space-between; }
}
.logo-row { display: flex; align-items: center; gap: 12px; }
/* .logo-text is an <a> to the hub on every signed-in page (see the note in
   index.html). Block, or the two <p> children make the inline box wrap oddly
   as a flex item; no underline, and the two <p> rules below keep owning the
   colours so the anchor must not impose its own. Hover dims the whole lockup
   rather than underlining it — an underline under a two-line lockup reads as
   a mistake. */
.logo-text { display: block; text-decoration: none; color: inherit; transition: opacity 0.16s; }
a.logo-text:hover { opacity: 0.75; }
.logo-text p:first-child {
  font-family: var(--font-display);
  color: var(--ink-950); font-weight: var(--weight-black); font-size: 23px; margin: 0;
  /* letter-spacing was -0.015em. Removed, not reduced: Vietnamese stacks marks
     above AND below the same syllable, and negative tracking at this size is
     where they start touching their neighbours. The lockup is Latin today but
     the rule holds for the whole type system. */
  line-height: var(--leading-tight); letter-spacing: normal;
}
.logo-text p:last-child {
  color: var(--slate-500); font-size: var(--text-xs); margin: 3px 0 0; line-height: 1.2;
  letter-spacing: 0.08em; text-transform: uppercase; font-weight: var(--weight-strong);
}
/* The signed-in lockup is the way back to the skill picker — the one thing
   every app header is expected to do, and the only route back from /credits
   and /account that isn't the dropdown. `display: block` because .logo-text is
   an <a> now and the two <p>s inside it must still stack. */
.logo-home-link { display: block; text-decoration: none; }
@media (hover: hover) {
  .logo-home-link { transition: opacity 0.16s; }
  .logo-home-link:hover { opacity: 0.78; }
}

/* ---------- header right-hand cluster ----------
   The .site-nav link row that used to sit here is gone (2026-08-28): every
   top-level destination now lives in the account-menu dropdown instead, so
   the header carries one control rather than four links plus a control. The
   only survivors of that block are #navWallet below and .account-menu
   further down.

   Deliberately NEVER links to /admin — that page has its own separate login
   and is not a "function" a student should be steered toward. /writing is
   deliberately absent too: the way in is the Task 1 / Task 2 cards on the hub
   itself, which land on the right task's form; a bare /writing link would only
   put its chooser screen in front of the same question.

   margin-left: auto lives on #navWallet now — it is the first of the two
   remaining clusters, so pushing it right pushes the pair together as one
   group flush against the right edge (Marc's call, 2026-08-23: "dồn hết nút
   chức năng qua phải"). Only bites at the min-width:1024px row layout —
   harmless no-op in the column layout. */

/* The student's own credit balance (2026-08-20 audit round 2, NOTE 1) — one
   span per non-zero bucket (Trial/Plan/Reload), or a single
   "Unlimited" badge for a comped account. Deliberately its OWN class, not
   .site-nav-link: this reads, it does not navigate, so it carries none of
   that class's hover/is-active affordances — a hover state on text that
   does nothing on click would be a lie. Sits right next to #auth-indicator,
   same "hidden until auth confirmed" posture, populated by loadNavWallet()
   in hub.js/app.js/upgrade.js. */
.site-nav-credits {
  margin-left: auto;
  font-family: var(--font-mono); font-weight: 600; font-size: var(--text-xs);
  letter-spacing: var(--tracking-label); text-transform: uppercase;
  color: var(--slate-500);
  padding: 8px 10px;
  white-space: nowrap;
}
.site-nav-credits .snc-unlimited { color: var(--emerald-100, #d1fae5); }
.site-nav-credits .snc-upgrade { color: var(--amber-300); text-decoration: underline; text-underline-offset: 2px; }

.summary-card {
  background: #fff; border-radius: 10px; box-shadow: 0 10px 25px rgba(0,0,0,0.15);
  padding: 16px 20px; display: flex; align-items: center; gap: 20px; position: relative;
}
@media (min-width: 1024px) { .summary-card { min-width: 440px; } }
.band-eyebrow { font-size: var(--text-micro); font-weight: 700; letter-spacing: 0.08em; color: var(--slate-400); text-transform: uppercase; margin: 0; }
/* The band score is the number the whole screen is about — display face, and
   tabular figures so it does not shuffle sideways when it changes. */
.band-eyebrow-vn { font-size: var(--text-micro); letter-spacing: 0.02em; color: var(--slate-400); margin: 0 0 4px; }
.band-number-row { display: flex; align-items: baseline; gap: 4px; }
.band-number { font-family: var(--font-display); font-size: 38px; font-weight: var(--weight-black); letter-spacing: -0.02em; color: var(--navy); font-variant-numeric: tabular-nums; display: inline-block; transition: transform .15s ease; }
.band-max { font-size: var(--text-sm); font-weight: 600; color: var(--slate-400); }
.divider-v { height: 40px; width: 1px; background: var(--slate-200); flex-shrink: 0; }
.chips-row { display: flex; flex-wrap: wrap; gap: 4px 16px; margin-bottom: 8px; }
.chip { font-size: var(--text-sm); }
.chip .label { color: var(--slate-400); }
.chip .value { font-weight: 700; color: var(--navy); font-variant-numeric: tabular-nums; }
.gauge-track { position: relative; height: 8px; border-radius: 999px; background: var(--slate-200); }
.gauge-fill { position: absolute; inset: 0 auto 0 0; border-radius: 999px; background: rgba(29,28,28,0.22); transition: width 700ms cubic-bezier(.22,1,.36,1); }
.gauge-marker {
  position: absolute; top: -6px; width: 16px; height: 16px; border-radius: 999px;
  background: var(--crimson); border: 2px solid #fff; box-shadow: 0 2px 6px rgba(0,0,0,.25);
  transition: left 700ms cubic-bezier(.22,1,.36,1);
}
.gauge-scale { display: flex; justify-content: space-between; font-size: var(--text-micro); color: var(--slate-400); margin-top: 4px; font-variant-numeric: tabular-nums; }

/* ---------- hub: pick a skill ---------- */
/* Sizes here come from the :root scale (--text-*), never loose px. The page
   shipped at 10-14px hardcoded and read as fine print on the one screen a
   student sees at the start of every session. */
.hub-main { padding-top: 40px; }
.hub-head { margin-bottom: 28px; }
.hub-eyebrow {
  font-family: var(--font-display); font-size: var(--text-xs); font-weight: 700;
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--crimson); margin: 0;
}
.hub-meta { font-size: var(--text-sm); color: var(--slate-400); margin: 8px 0 0; }
.hub-grid { display: grid; grid-template-columns: 1fr; gap: 24px; }
@media (min-width: 720px) { .hub-grid { grid-template-columns: 1fr 1fr; } }
.hub-card { display: flex; flex-direction: column; padding: 24px 28px; margin-bottom: 0; }
/* Dimmed, and the action below says why. A skill that is not built yet should
   read as "not yet" at a glance, never as "still loading". */
.hub-card.is-locked { opacity: 0.55; }
.hub-card .dash-head { margin-bottom: 8px; }

/* ---------- the primary action button ----------
   One recipe, applied to every button that starts or submits something:
   solid ink, WHITE UPPERCASE display type, letter-spaced, SQUARE corners, and
   crimson on hover. Marc's reference is the workspace mockup's submit button.

   Uppercase + positive tracking is safe for Vietnamese and this is the one
   place it is used at size — it is NEGATIVE tracking that pulls stacked
   diacritics into their neighbours. Never set a negative value here.

   Square, not rounded: the rounded dark pill was the last thing on these
   screens still speaking the old language, which is exactly what Marc flagged.

   Full width by default, because every place this appears it is the only
   action in its container (a Task card, the sidebar composer, the login
   card). Anything that needs to sit inline overrides `width` locally. */
.hub-action,
.btn-submit,
.btn.btn-primary,
.login-btn {
  display: block;
  width: 100%;
  border: 0;
  border-radius: 0;
  background: var(--line-ink);
  color: #fff;
  font-family: var(--font-display);
  font-weight: var(--weight-display);
  font-size: var(--text-sm);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  text-align: center;
  padding: 13px 20px;
  cursor: pointer;
  transition: background 0.15s ease;
}
.hub-action:hover:not(:disabled),
.btn-submit:hover:not(:disabled),
.btn.btn-primary:hover:not(:disabled),
.login-btn:hover:not(:disabled) { background: var(--crimson); }

.hub-action { margin-top: 18px; }
.hub-action:hover { background: var(--crimson); }
/* The locked state has to lose the ink fill entirely, or a disabled control
   reads as the loudest thing on the card. Sentence case as well: shouting a
   refusal is worse than stating it. */
.hub-action.is-soon {
  background: var(--slate-100); color: var(--slate-500); cursor: default;
  font-family: var(--font-body); font-weight: 500;
  text-transform: none; letter-spacing: normal; font-size: var(--text-sm);
}
.hub-action.is-soon:hover { background: var(--slate-100); }
/* Secondary variant, used next to the primary "Tiếp tục" for "Bài mới" — same
   shape as .btn-secondary elsewhere, quiet enough not to compete with it. */
.hub-action-quiet {
  background: transparent; color: var(--navy); border: 1px solid var(--navy);
}
.hub-action-quiet:hover { background: var(--slate-100); }

/* An in-progress draft the student can resume — see draft_store.py. Lives in
   its own full-width strip above the Task cards now (hub-dash.css's #hubDraft),
   because while it is on screen BOTH cards are locked; the note names which
   essay it is, so a student is never guessing which one "Tiếp tục" resumes. */
.hub-draft-note { margin: 18px 0 10px; }
.hub-draft-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.hub-draft-actions .hub-action { margin-top: 0; }

/* ---------- student dashboard ----------
   The hub cards are down to a skill name and one way in — every score readout
   they used to carry now lives on /history, against the essay it came from. */
.dash-head { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
/* No min-width on the label any more: it was a 74px column so the meta text
   beside it lined up between rows, and at --text-xl every skill name is wider
   than that anyway, so it only ever forced a gap. .dash-head's own gap does
   the job. */
.dash-skill { font-family: var(--font-display); font-size: var(--text-xl); font-weight: var(--weight-display); color: var(--navy); }
.dash-meta { font-size: var(--text-sm); color: var(--slate-400); }
.dash-tag { font-size: 12px; font-weight: 700; color: var(--slate-500); background: var(--slate-100); border-radius: 4px; padding: 1px 5px; white-space: nowrap; }

/* Phone: the meta line goes to the far edge so the name and it read as two
   columns instead of one run-on line. */
@media (max-width: 640px) {
  .dash-meta { margin-left: auto; }
}

/* ---------- round stepper ---------- */
.stepper { display: flex; align-items: center; gap: 12px; margin-bottom: 24px; font-size: var(--text-base); flex-wrap: wrap; }
.step { display: flex; align-items: center; gap: 8px; }
.step-dot { width: 20px; height: 20px; border-radius: 999px; display: flex; align-items: center; justify-content: center; font-size: var(--text-xs); font-weight: 700; background: var(--slate-200); color: var(--slate-500); }
.step-dot.on { background: var(--navy); color: #fff; }
.step-dot.done { background: var(--emerald-600); color: #fff; }
.step-label { color: var(--slate-500); font-weight: var(--weight-medium); }
.step-label.on { font-family: var(--font-display); color: var(--navy); font-weight: var(--weight-display); }
.step-label.done { color: var(--emerald-700); font-weight: var(--weight-strong); }
.step-rule { width: 32px; height: 1px; background: var(--slate-300); }
.restart-link { margin-left: auto; background: none; border: 0; font-size: var(--text-sm); font-weight: 600; color: var(--slate-400); cursor: pointer; padding: 4px 0; }
.restart-link:hover { color: var(--navy); }

/* ---------- layout ---------- */
.layout { display: grid; grid-template-columns: 1fr; gap: 24px; padding-bottom: 40px; }
@media (min-width: 1024px) { .layout { grid-template-columns: 7fr 5fr; } }
/* The essay is read, the sidebar is worked in — so past a point the essay column
   stops growing (a 850px measure is already past comfortable line length) and
   the rest of the width goes to the editor. */
@media (min-width: 1500px) {
  .layout { grid-template-columns: minmax(0, 760px) 1fr; }
}
/* Final report screen: feedback + score only, full width — no essay column
   to split with (renderReport() in app.js toggles this class; resetForNew
   Essay() clears it). #id + .class outweighs the two grid-template-columns
   media rules above (both plain .layout, one class) regardless of viewport,
   so this wins without needing !important. */
#workspace.report-mode { grid-template-columns: 1fr; }
#workspace.report-mode > .card { display: none; }

.card {
  background: var(--surface-card); border-radius: var(--radius-card);
  border: var(--border-hair) solid var(--line-crisp);
  box-shadow: var(--shadow-none); margin-bottom: 24px;
}
.essay-head { padding: 20px 24px 16px; border-bottom: 1px solid var(--slate-100); }
.essay-eyebrow { font-family: var(--font-display); font-size: var(--text-lg); font-weight: var(--weight-display); letter-spacing: 0.08em; color: var(--crimson); text-transform: uppercase; margin: 0; }
.essay-meta { font-size: var(--text-sm); color: var(--slate-400); margin: 4px 0 0; }
/* The student's own essay. A literal on purpose, not a --text-* token: this
   and .question-text below are the two surfaces a student actually READS for
   minutes at a time, and the scale straddles the size they should be. Marc's
   number — it was 16.5px, raised to 18px in the same pass that took the whole
   scale up ~15%, so the one thing you read longest did not end up SMALLER
   relative to the chrome around it. */
.essay-body { padding: 24px; font-size: 20.5px; line-height: 1.9; }
.essay-body p { margin: 0 0 20px; white-space: pre-wrap; }
.essay-body p:last-child { margin-bottom: 0; }
.essay-legend { padding: 12px 24px; border-top: 1px solid var(--slate-100); display: flex; align-items: center; gap: 6px; font-size: var(--text-xs); color: var(--slate-400); flex-wrap: wrap; }
.legend-swatch { width: 10px; height: 10px; border-radius: 2px; display: inline-block; }

.highlight-btn {
  background: transparent; border: 0; padding: 0; margin: 0; font: inherit; color: inherit;
  cursor: default; display: inline; text-align: left;
}
.highlight-btn.active {
  cursor: pointer; border-radius: 3px; padding: 0.02em 0.15em; margin: 0 -0.02em;
  text-decoration: underline; text-decoration-style: dotted; text-underline-offset: 3px;
  transition: background-color .15s ease, box-shadow .15s ease;
  /* Trên iPad, chạm vào một nút chữ inline hay bị iOS hiểu thành BÔI ĐEN chữ và
     bật menu "Look Up · Search" — nuốt mất cú click nên hộp sửa không mở. Tắt
     chọn-chữ, tắt callout, và bỏ trễ 300ms để cú chạm thành nhấn nút thật sự. */
  -webkit-user-select: none; user-select: none;
  -webkit-touch-callout: none; touch-action: manipulation;
}
.highlight-btn.active.logic { background: var(--amber-100); text-decoration-color: var(--amber-700); box-shadow: inset 0 -1px 0 var(--amber-300); }
.highlight-btn.active.logic:hover { background: var(--amber-200); }
.highlight-btn.active.language { background: var(--blue-100); text-decoration-color: var(--blue-700); box-shadow: inset 0 -1px 0 var(--blue-300); }
.highlight-btn.active.language:hover { background: var(--blue-200); }
/* Every remaining misspelling is marked at once and each is its own click
   target, so the student fixes them where they sit in the essay. */
.highlight-btn.active.spelling {
  background: #fdeceb; text-decoration-color: var(--crimson);
  box-shadow: inset 0 -1px 0 var(--crimson); cursor: pointer;
}
.highlight-btn.active.spelling:hover { background: #f7c9c4; }
.highlight-btn.resolved { color: var(--slate-500); }

/* Anchored to the word rather than parked in the sidebar: the student is
   looking at the typo, so the box to retype it belongs there. */
.spell-pop {
  position: absolute; z-index: 60; width: min(260px, calc(100vw - 16px));
  background: var(--surface-card); border: var(--border-hair) solid var(--line-ink);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-lift); padding: 14px 16px;
}
.spell-pop-title {
  margin: 0 0 2px; font-family: var(--font-display);
  font-size: var(--text-xs); font-weight: var(--weight-display);
  letter-spacing: var(--tracking-label); text-transform: uppercase;
  color: var(--crimson);
}
.spell-pop-hint { margin: 0 0 8px; font-size: var(--text-sm); color: var(--slate-600); }
/* font-size PHẢI là 16px thật, không phải rem hay số nhỏ hơn: dưới 16px thì iOS
   tự phóng to trang khi ô này được focus, và cú phóng đó bắn ra một sự kiện
   resize. Đó là đường thứ hai từng làm hộp nhập chính tả biến mất trên iPad. */
.spell-pop-input {
  width: 100%; padding: 8px 10px; font: inherit; font-size: 18.5px; font-weight: 600;
  color: var(--navy); background: var(--slate-50);
  border: var(--border-hair) solid var(--line-crisp); border-radius: var(--radius-sm);
}
/* A focus RING, not an offset shadow. The old `3px 3px 0` moved the box's
   visual weight down-right on focus, which reads as the field jumping. */
.spell-pop-input:focus {
  outline: none; background: #fff;
  border-color: var(--line-ink); box-shadow: 0 0 0 3px rgba(227, 24, 55, 0.15);
}
.spell-pop-msg {
  margin: 8px 0 0; font-size: var(--text-sm); line-height: var(--leading-snug);
}
.spell-pop-msg.wrong { color: var(--crimson-dark); }
.spell-pop-actions {
  margin-top: 10px; display: flex; justify-content: flex-end; gap: 8px; align-items: center;
}
.spell-pop-cancel {
  background: none; border: 0; cursor: pointer;
  font-size: var(--text-sm); font-weight: 600; color: var(--slate-500);
}
.spell-pop-cancel:hover { color: var(--navy); }
.spell-pop-submit {
  background: var(--crimson); color: #fff; border: 0; border-radius: 4px;
  padding: 7px 14px; font: inherit; font-size: var(--text-sm); font-weight: 700; cursor: pointer;
}
.spell-pop-submit:hover:not(:disabled) { background: var(--crimson-dark); }
.spell-pop-submit:disabled { opacity: 0.5; cursor: not-allowed; }
.resolved-check { color: var(--emerald-600); font-size: var(--text-sm); vertical-align: super; }

aside.sidebar-col .sticky-wrap { position: static; }
@media (min-width: 1024px) { aside.sidebar-col .sticky-wrap { position: sticky; top: 24px; } }
/* THE ONE HARD SHADOW LEFT IN THE APP, and it is deliberate — the workspace
   mockup specifies `shadow-[2px_2px_0px_#000]` on exactly this element and
   nothing else. Everything on the lesson screen is a flat 1px card so that
   this one card is not; it is where the tutor speaks and where the student
   types their answer, and it has to out-rank the essay beside it without
   resorting to colour. 2px, not the landing's 6px: this is a working surface
   somebody stares at for forty-five minutes, not a hero.

   The crimson left rail is gone with the navy header — with an ink border and
   an offset the card already reads as the active one, and the rail was a
   third device doing the same job. */
.sidebar-card {
  overflow: hidden;
  border-color: var(--line-ink);
  box-shadow: 2px 2px 0 var(--line-ink);
}
/* White, with a rule under it. It was a solid navy bar, which forced the one
   line of text inside it into white and made the sidebar read as a different
   component from the essay card next to it. */
.sidebar-head {
  background: var(--surface-card);
  border-bottom: var(--border-hair) solid var(--line-crisp);
  padding: 14px 20px;
}
/* Just the live progress line now — the "Gia sư Socratic" title and the
   "Bước X:" prefix were dropped because the stepper above already names the
   step; this is the one line left, so it is targeted directly rather than
   through :first-child/:last-child (which both matched it as the only
   child and fought over the style). */
/* Ink, not white — the bar behind it is white now. letter-spacing dropped
   from -0.01em to normal: negative tracking is where Vietnamese stacked
   diacritics start touching their neighbours. */
#sidebarSubtitle {
  font-family: var(--font-display); color: var(--ink-950);
  font-weight: var(--weight-strong); font-size: var(--text-base);
  letter-spacing: normal; margin: 0;
}
.sidebar-body { padding: 20px; min-height: 280px; }

/* Đường thoát giữa buổi học. `margin-left: 0` là bắt buộc: .restart-link mang
   sẵn `margin-left: auto` cho chỗ cũ của nó trong flex .stepper-actions, để
   nguyên thì nút bị đẩy sang phải ở đây. */
.sidebar-exit { padding: 0 20px 16px; border-top: 1px solid var(--slate-100); }
.sidebar-exit .restart-link { margin-left: 0; padding-top: 12px; }

.panel-empty { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 40px 0; color: var(--slate-400); }
.panel-empty svg { width: 40px; height: 40px; margin-bottom: 12px; color: var(--slate-300); }
.panel-empty .msg1 { font-size: var(--text-base); font-weight: 500; color: var(--slate-500); margin: 0; }
.panel-empty .msg2 { font-size: var(--text-sm); color: var(--slate-400); margin: 4px 0 0; max-width: 220px; }

/* `.panel-empty` đặt `display:flex`, và một selector class (0,1,0) thắng rule mặc
   định của trình duyệt `[hidden] { display: none }` (0,0,0). Thiếu dòng này thì
   showPanel() set `.hidden = true` bao nhiêu lần panel vẫn hiện — chính là lý do
   thẻ "Kết thúc & xem báo cáo" còn nằm lại trên màn hình báo cáo. */
[hidden] { display: none !important; }

/* The label carries the model's own name for the flaw, which for logic errors
   runs long ("Mâu thuẫn logic / Thiếu nhất quán giữa Mở bài và Thân bài" — 434px
   of a 500px card). Uppercase with tracking was sized for a one-word chip, so
   long names get sentence case, normal tracking and room to wrap onto a second
   line instead of straining against the card edge. */
.tag {
  display: inline-block; font-family: var(--font-display);
  font-size: var(--text-micro); font-weight: var(--weight-display); letter-spacing: 0.05em;
  text-transform: uppercase; padding: 4px 8px; border-radius: 4px;
  max-width: 100%; line-height: 1.45; overflow-wrap: anywhere;
}
.tag.long {
  text-transform: none; letter-spacing: 0.01em; font-size: var(--text-xs);
}
.tag.logic { background: var(--amber-100); color: var(--amber-800); }
.tag.language { background: var(--blue-100); color: var(--blue-800); }
.quote-block { font-size: var(--text-base); font-style: italic; color: var(--slate-600); padding-left: 12px; margin: 12px 0 0; }
.quote-block.logic { border-left: 2px solid var(--amber-300); }
.quote-block.language { border-left: 2px solid var(--blue-300); }
/* Vietnamese teaching text: diacritics stack above and below, so it gets more
   leading than the English essay body does. */
.question-text { font-size: 20.5px; color: var(--slate-700); line-height: var(--leading-body); margin: 16px 0 0; }
.incorrect-verdict {
  font-size: var(--text-sm); color: var(--crimson-dark); background: #fdeceb; border: 1px solid #f7c9c4;
  border-radius: 6px; padding: 10px 12px; margin: 16px 0 0; line-height: 1.5;
}
textarea.correction-input {
  /* 1rem = 16px, same reason as .login-field input: iOS Safari zooms the page
     when a focused input is under 16px, and this is the box the student types
     every single fix into. Not a --text-* token; the scale straddles 16. */
  width: 100%; margin-top: 12px; font-size: 1rem; border: 1px solid var(--slate-300); border-radius: 6px;
  padding: 8px 12px; resize: vertical; font-family: inherit; color: inherit;
}
textarea.correction-input:focus { outline: none; border-color: var(--navy); box-shadow: 0 0 0 2px rgba(29,28,28,0.25); }
textarea.correction-input::placeholder { color: var(--slate-400); }
.fix-toolbar { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; gap: 8px; flex-wrap: wrap; }
.fix-toolbar span { font-size: var(--text-sm); color: var(--slate-400); }
/* Shape comes from the shared primary recipe above; only the disabled state is
   its own. It used to be a crimson pill — crimson is the HOVER now, so the
   colour still means "this is the action", it just no longer shouts at rest. */
.btn-submit:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-advance {
  margin-top: 16px; width: 100%; background: var(--navy); color: #fff; font-size: var(--text-base); font-weight: 600;
  padding: 10px 0; border-radius: 6px; border: 0; cursor: pointer; transition: background-color .15s ease;
}
.btn-advance:hover { background: var(--navy-dark); }
.btn-ghost {
  margin-top: 20px; background: transparent; border: 1px solid var(--navy); color: var(--navy);
  font-size: var(--text-base); font-weight: 600; padding: 8px 16px; border-radius: 6px; cursor: pointer;
  transition: background-color .15s ease, color .15s ease;
}
.btn-ghost:hover { background: var(--navy); color: #fff; }
.btn-ghost:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-ghost:disabled:hover { background: transparent; color: var(--navy); }

/* Deleting the highlighted span. Sits in the same row as "Nộp bài sửa" but must
   not read as the primary action: cutting text is the rarer, heavier choice, so
   it stays outlined while the submit button keeps the crimson fill. The shared
   .btn-ghost rule carries a 20px top margin for the standalone buttons it was
   written for; inside the toolbar that would break the row alignment. */
.fix-toolbar .btn-ghost { margin-top: 0; padding: 9px 14px; font-size: var(--text-sm); }

.delete-preview {
  margin-top: 16px; border: 1px solid var(--amber-300); background: var(--amber-100);
  border-radius: 6px; padding: 12px 14px;
}
.delete-preview-title { font-size: var(--text-sm); font-weight: 700; color: var(--amber-800); margin: 0 0 8px; }
/* The result of the cut, shown in the essay's own typeface: this is English
   prose about to be spliced in, not Vietnamese teaching text. */
.delete-preview-after {
  font-size: var(--text-base); line-height: var(--leading-body); color: var(--slate-800);
  background: #fff; border: 1px solid var(--amber-200); border-radius: 4px;
  padding: 10px 12px; margin: 0;
}
.delete-preview-note {
  font-size: var(--text-sm); line-height: 1.5; color: var(--crimson-dark); margin: 10px 0 0;
}
.delete-preview-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 12px; flex-wrap: wrap; }
.delete-preview-actions .btn-ghost { margin-top: 0; padding: 9px 14px; font-size: var(--text-sm); }
.delete-preview-actions .btn-submit { padding: 9px 16px; font-size: var(--text-sm); }

/* Socratic Penalty engine (Credits.pdf): the 2nd sloppy submission in a row
   on the same error (POST /api/fix returning system_warning=True) gets a
   stronger red border on top of the ordinary .incorrect-verdict styling —
   deliberately no animation/blink (CLAUDE.md: prefers-reduced-motion is
   always on for this class's users, so anything that "spins" reads as the
   app being stuck), just a heavier, static crimson frame. */
.incorrect-verdict.system-warning {
  border: 2px solid var(--crimson); background: #fbdedb;
  font-weight: 600;
}

/* The 3rd sloppy submission (force_terminated=True): the fix box is blurred
   and inert underneath #forceTerminatedOverlay rather than hidden, so the
   essay/error context the student was working from stays visible. */
#fixInputArea.blurred {
  filter: blur(3px); pointer-events: none; user-select: none;
}
.force-terminated-overlay {
  margin-top: 16px; border: 2px solid var(--crimson-dark); background: #1d1c1c;
  border-radius: 6px; padding: 16px;
}
.force-terminated-tag {
  font-family: ui-monospace, "SF Mono", "Courier New", monospace;
  font-size: var(--text-sm); font-weight: 700; color: var(--crimson); margin: 0 0 8px;
  letter-spacing: 0.02em;
}
.force-terminated-text { font-size: var(--text-sm); line-height: 1.5; color: #f3f4f6; margin: 0; }
.force-terminated-actions { display: flex; justify-content: flex-end; align-items: center; gap: 8px; margin-top: 16px; flex-wrap: wrap; }
.force-terminated-actions .btn-ghost { margin-top: 0; padding: 9px 14px; font-size: var(--text-sm); border-color: #f3f4f6; color: #f3f4f6; }
.force-terminated-actions .btn-ghost:hover { background: #f3f4f6; color: #1d1c1c; }
.force-terminated-actions .btn-danger { padding: 9px 16px; font-size: var(--text-sm); }

/* Shown only when a quote could not be located in the essay, so the student is
   told why nothing is marked instead of being asked to rewrite an invisible
   span. */
.highlight-warning {
  margin: 0; padding: 12px 24px; font-size: var(--text-sm); line-height: 1.5;
  color: var(--amber-800); background: var(--amber-100);
  border-top: 1px solid var(--amber-200);
}

.success-block { text-align: center; padding: 16px 0; }
.success-icon { width: 44px; height: 44px; border-radius: 999px; background: var(--emerald-100); color: var(--emerald-600); display: flex; align-items: center; justify-content: center; margin: 0 auto 12px; }
.success-icon svg { width: 20px; height: 20px; }
.success-title { font-weight: 600; color: var(--navy); font-size: var(--text-base); margin: 0; }
.success-quote {
  font-size: var(--text-sm); color: var(--slate-600); background: var(--slate-50); border: 1px solid var(--slate-100);
  border-radius: 6px; padding: 10px 12px; margin-top: 12px; text-align: left; font-style: italic; line-height: 1.5;
}
.success-note { font-size: var(--text-base); color: var(--slate-600); margin-top: 16px; }

/* ---------- per-fix band credit ---------- */
/* Just the "why this fix mattered" sentence now — no point-count badge next
   to it. The exact numbers are reserved for showPhaseSummary and the final
   report. */
.delta-note {
  display: flex; align-items: flex-start; gap: 8px; text-align: left;
  margin-top: 12px; padding: 10px 12px; border-radius: 6px;
  background: var(--emerald-50, #ecfdf5); border: 1px solid var(--emerald-100);
}
.delta-note-text { font-size: var(--text-sm); color: var(--slate-600); line-height: 1.5; }

/* ---------- provisional vs verified scoring ---------- */
.band-state {
  display: inline-block; font-size: var(--text-micro); font-weight: 700; text-transform: uppercase;
  letter-spacing: 0.06em; padding: 2px 6px; border-radius: 4px; margin-top: 2px;
}
.band-state.estimated { background: var(--amber-100, #fef3c7); color: #92400e; }
.band-state.verified { background: var(--emerald-100); color: var(--emerald-700); }
.chip.unmeasured { opacity: 0.45; }

/* Padding and margin deliberately absent: #notificationBanner carries both
   .banner and .banner-info, and .banner sets them further down this file —
   same specificity, later source, so it wins. This rule used to declare
   `padding: 10px 14px` that never once applied. Only the things .banner
   does not set belong here. */
.banner-info {
  background: #eff6ff; border: 1px solid #bfdbfe; color: #1e3a5f;
  font-size: var(--text-sm); line-height: 1.55;
}

#student-select, #student-other {
  width: 100%; padding: 10px 12px; font-size: 1rem; font-family: inherit;
  color: var(--navy); background: #fff;
  border: 1px solid var(--slate-200, #e6e6e6); border-radius: 6px;
}
#student-select:focus, #student-other:focus {
  outline: 2px solid var(--navy); outline-offset: -1px;
}

/* ---------- spelling checklist ---------- */
/* Every misspelling at once. Typos are too cheap to spend a Socratic round on
   each, so the whole set is cleared in one pass before grammar begins. */
.spelling-panel {
  background: var(--amber-100); border: 1px solid var(--amber-300);
  border-left: 4px solid var(--amber-700); border-radius: var(--radius);
  padding: 14px 18px; margin-bottom: 16px;
}
.spelling-head { display: flex; align-items: baseline; gap: 10px; }
.spelling-title {
  font-family: var(--font-display);
  font-size: var(--text-xs); font-weight: var(--weight-display); letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--amber-800);
}
.spelling-count { font-size: var(--text-xs); font-weight: 600; color: var(--amber-700); }
.spelling-note { margin: 6px 0 10px; font-size: var(--text-sm); color: var(--slate-600); line-height: var(--leading-body); }
.spelling-list { list-style: none; margin: 0; padding: 0; display: flex; flex-wrap: wrap; gap: 8px; }
.spelling-list li {
  background: #fff; border: var(--border-hair) solid var(--amber-300);
  border-radius: var(--radius-sm);
  padding: 4px 10px; font-size: var(--text-sm); color: var(--slate-700);
}
.spelling-list .wrong { color: var(--crimson-dark); text-decoration: line-through; font-weight: 600; }
.spelling-list .right { color: var(--emerald-700); font-weight: 700; }
.spelling-list li.fixed { opacity: 0.55; }
.spelling-list li.fixed .wrong { text-decoration: line-through; }


/* Sits on the navy header, so it cannot inherit the navy-on-white button
   colours — those rendered as an invisible button on an invisible label.
   Both pieces use the display font (Archivo), same family as the wordmark
   and every other nav label on this header — they had been falling back to
   the body font by omission, not by design. */
/* margin-left: auto lives on #navWallet above — this is the last item in that
   same right-flush cluster, not a lone element that needs its own push. */
#auth-indicator { display: flex; align-items: center; gap: 12px; }
/* Mono, not the display font — matches the account-menu trigger button
   right next to it (.account-menu-btn below), on the same /about-style mono
   treatment; Archivo next to them read like two
   different typefaces arguing in one header. Not uppercase, unlike the
   pills: this is a person's actual name, not a UI label.

   color: inherit, not its own fixed grey (2026-08-23, reported after
   shipping): this span now lives INSIDE .account-menu-btn, which is
   off-white idle and switches to solid crimson + white text on hover/open
   (aria-expanded). A fixed grey here doesn't follow that switch — it just
   sat there washed-out on the crimson fill, unreadable ("chữ bị bạc").
   Inheriting the button's own color keeps the two in lockstep in every
   state instead of hand-syncing two separate color rules. */
#auth-name {
  font-family: var(--font-mono); font-weight: 600;
  letter-spacing: 0.01em; font-size: var(--text-sm); color: inherit;
}

/* ---------- account menu ----------
   Replaces the old bare "name + separate Log out pill" pair inside
   #auth-indicator with one clickable trigger that opens a small dropdown
   (Quản lý tài khoản / Đăng xuất) — Marc's call, 2026-08-23. Trigger reuses
   the #logout-btn pill recipe (mono, pill, crimson border) rather than
   #auth-name's plain text style, since the whole thing is a button now, not
   a label. Wiring lives in Socratic.wireAccountMenu() (session.js). */
.account-menu { position: relative; }
.account-menu-btn {
  display: flex; align-items: center; gap: 6px;
  font-family: var(--font-mono); font-weight: 600; font-size: var(--text-sm);
  letter-spacing: 0.01em; color: var(--ink-950);
  padding: 8px 14px; border-radius: var(--radius-card); background: transparent;
  border: var(--border-hair) solid var(--line-crisp); cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
}
.account-menu-btn:hover, .account-menu-btn[aria-expanded="true"] {
  background: var(--crimson); border-color: var(--crimson); color: #fff;
}
.account-menu-caret { width: 10px; height: 6px; flex: none; transition: transform 0.15s ease; }
.account-menu-btn[aria-expanded="true"] .account-menu-caret { transform: rotate(180deg); }

/* Same white-card-on-black-border-with-offset-shadow recipe as .spell-pop
   above — the one floating-panel pattern this codebase already has. */
.account-menu-panel {
  position: absolute; top: calc(100% + 8px); right: 0; z-index: 60;
  min-width: 200px; background: var(--surface-card);
  border: var(--border-hair) solid var(--line-ink);
  border-radius: var(--radius-card); box-shadow: var(--shadow-lift);
  padding: 6px; display: flex; flex-direction: column; gap: 2px;
}
.account-menu-item {
  display: block; width: 100%; text-align: left; text-decoration: none;
  font-family: var(--font-mono); font-weight: 600; font-size: var(--text-sm);
  color: var(--navy); padding: 11px 14px; border-radius: 4px;
  border: none; background: transparent; cursor: pointer;
  transition: background 0.12s ease;
}
.account-menu-item:hover { background: var(--slate-100); }
.account-menu-item-danger { color: var(--crimson); }

/* ---------- login (comic energy, greyscale palette) ---------- */
/* Halftone dots over a greyscale field. Same two-layer construction as before,
   recoloured to the #0A0A0A–#454444 ramp: the light now blooms out of the black
   the way it does in the smoke reference, instead of glowing crimson and blue.
   The white card and its crimson shadow still read hard against it. */
/* Full-bleed: the login owns the whole viewport rather than sitting as a panel
   on a white page. The header floats over it transparently (see .login-mode). */
#login-section {
  position: fixed; inset: 0; z-index: 1;
  /* place-items: safe center, not the bare "center" this used to be: the
     signup variant of .login-card (extra "Your name" + "Email" fields, see
     hub.html's #signup-displayname-field / #signup-email-field) is taller
     than the login variant and can exceed the viewport on shorter screens.
     Plain center alignment (grid or flex, both behave the same) treats
     overflow as split evenly off BOTH edges, and scrollTop 0 already starts
     past the top half of that split — there is no scroll position that
     reaches the true top, so the card's eyebrow line sat permanently cut
     off under the header pill no matter how far you scrolled (real bug,
     reported against the signup flow, confirmed via scrollTop/scrollHeight
     inspection, not just visually). The "safe" keyword is the standard fix:
     it centers only while content fits, and falls back to start-alignment
     (so the top edge is always reachable) the moment it doesn't. */
  display: grid; place-items: safe center;
  overflow-y: auto;
  padding: 88px 16px 24px;
}

/* Full-bleed blurred hero painting instead of the old comic gradient — the
   same Socrates-statue scene /about now uses as its hero backdrop
   (static/images/about/scene-socrates.png, commit 5d0a13c; scene-flat.jpg is
   the scene /about showed before that and is no longer this page's source
   either), just pushed out of focus so it reads as atmosphere behind the card
   rather than a second thing to look at. Oversized + scaled so the blur never
   shows an edge. No pointer tracking here (the old parallax is gone along
   with .login-stage/.cursor-blob) — hub.js's trackLoginParallax() no-ops
   safely when it can't find `.login-stage`, so it needed no change. */
.login-backdrop {
  /* fixed, not absolute: #login-section became overflow-y:auto (see its own
     comment) so a taller signup card can scroll instead of silently
     clipping. absolute positions this backdrop AGAINST that scrollable
     section, and its deliberate -5% bleed then counted as scrollable
     overflow all on its own — the section reported "needs scrolling" from
     the backdrop's bleed alone, even when the card content fit the viewport
     fine (caught by measuring scrollHeight vs the card's actual height,
     which didn't match). Fixed positioning still covers the same full
     viewport (this section is itself inset:0 fixed already) but takes the
     backdrop out of any ancestor's scrollable-overflow bookkeeping. */
  position: fixed; inset: -5%;
  background: url('/static/images/about/scene-socrates.png') 50% 30%/cover no-repeat;
  filter: blur(30px) saturate(1.05) brightness(0.75);
  transform: scale(1.08);
}
.login-backdrop::after {
  content: ''; position: absolute; inset: 0; background: rgba(10, 10, 10, 0.35);
}
/* The same 5% technical-grid texture /about layers over its dark sections
   (about.css .about-grid-bg::before) — style.css doesn't load about.css, so
   the pattern is written out again here rather than imported cross-file.
   Sits above the blur+tint but below the card (.login-card has its own
   z-index: 1). */
.login-backdrop::before {
  content: '';
  position: absolute; inset: 0;
  pointer-events: none;
  background-image:
    repeating-linear-gradient(to right,  rgba(245,245,245,0.05) 0 1px, transparent 1px 64px),
    repeating-linear-gradient(to bottom, rgba(245,245,245,0.05) 0 1px, transparent 1px 64px);
}

/* Over the full-bleed backdrop the solid navy bar would read as a stripe, so
   the header becomes a floating pill instead — same frosted-glass
   construction as /about's sticky nav (.about-nav, about.css:218-247):
   gradient tint, backdrop blur+saturate, inset highlight/shadow layering,
   999px radius. This is the one place the login screen and the marketing
   page share a literal nav treatment, which is also why the "About" link
   below lives here — it's the only way back to /about once a student has
   bounced to the login wall. Scoped to body.login-mode only: the header used
   by the signed-in hub view (skill grid) is untouched. */
body.login-mode { overflow: hidden; }

/* No pill, no bar: on the login screen the wordmark simply sits in the corner
   of the artwork. The frosted pill was borrowed from /about's nav, but that
   pill exists to hold nav LINKS — with the About link gone there is nothing
   left inside it, and an empty capsule reads as chrome for its own sake.
   Scoped to body.login-mode only; the signed-in hub keeps its solid bar. */
body.login-mode header.top {
  position: absolute; top: 0; left: 0; z-index: 10;
  width: auto; margin: 0;
  background: none; border: 0; border-radius: 0; box-shadow: none;
  backdrop-filter: none; -webkit-backdrop-filter: none;
}
body.login-mode .header-inner {
  flex-direction: row; align-items: center;
  padding: 22px clamp(20px, 4vw, 40px);
}

body.login-mode .foot { display: none; }

/* Two anchors, one lockup, and exactly one of them on screen at a time. They
   are separate elements because they go to different places: signed out, the
   mark is the only way back to the public landing page; signed in, "home"
   means the hub. */
.login-home-link { display: none; }
body.login-mode .login-home-link { display: block; }
body.login-mode .logo-text.logo-home-link { display: none; }

/* The login lockup sits on the dark blurred backdrop, so it inverts. This is
   the only place the lockup is light-on-dark; everywhere else the header is
   white (see header.top). */
body.login-mode .login-home-link p:first-child { color: #fff; }
body.login-mode .login-home-link p:last-child { color: rgba(255, 255, 255, 0.72); }

/* The old `pointer-events: none` guard is gone with the SVG it protected: the
   mark was a <use> of a <symbol>, so hit-testing landed on the instantiated
   shadow content and the click never reached the anchor. Live text has no
   such problem, and the link works without help. */
@media (hover: hover) {
  .login-home-link { transition: opacity 0.16s; }
  .login-home-link:hover { opacity: 0.78; }
}

/* The card: form-only. Was split into an image side and a form side (a
   statue portrait cropped from the same painting as .login-backdrop below);
   the portrait was dropped per request, the blurred full-bleed backdrop
   stays as the page's only Socrates imagery. Border + dual offset shadow
   still match /about's ink-shadow card (.about-letter, about.css) — that's
   independent of what's inside the card. */
.login-card {
  position: relative; z-index: 1;
  width: 100%; max-width: 460px;
  background: var(--surface-card);
  border: var(--border-hair) solid var(--line-crisp);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-lift);
  overflow: hidden;
}

.login-form {
  display: flex; flex-direction: column; justify-content: center;
  /* Trimmed from 40/32 (top/bottom) — every bit here plus the tighter
     title/divider/note spacing below was reclaimed so the signup variant
     (extra name+email row) fits the viewport without scrolling on an
     ordinary laptop window, not just a full-height one. */
  padding: 30px 36px 24px;
}

.login-title {
  font-family: var(--font-display); font-weight: var(--weight-black);
  font-size: clamp(1.8rem, 3vw, 2.4rem); line-height: 1.08; letter-spacing: -0.02em;
  color: var(--navy); margin: 0 0 20px;
}

.login-label {
  display: block; margin-bottom: 6px;
  font-family: var(--font-mono); font-weight: 600;
  font-size: var(--text-xs); letter-spacing: var(--tracking-label);
  text-transform: uppercase; color: var(--slate-500);
}
.login-field { margin-bottom: 16px; }

/* .login-field-row (signup's "Your name" + "Email" side by side) was removed
   in v1.4.39. It existed to keep signup mode from costing an extra stacked
   field-height, back when the card overflowing the viewport was a live bug.
   But the card is capped at 460px at EVERY width (.login-card max-width
   below), so half a row was ~183px minus padding — about 148px of actual
   typing space for an email address. The 480px media query it carried never
   helped, because the constraint was the card, not the viewport.
   Both fields are plain stacked .login-field rows now; the height is
   absorbed by #login-section's `place-items: safe center` + `overflow-y:
   auto` above, which is the actual fix for a card taller than the screen. */

/* Segmented single-choice control — signup's "Bạn là: Học sinh / Giáo viên".
   Styling hangs off :checked on a visually-hidden real radio, so keyboard
   focus, arrow-key navigation and screen-reader semantics are the browser's,
   not ours. */
.login-segmented { display: flex; gap: 8px; }
.login-segment { flex: 1; min-width: 0; cursor: pointer; }
/* Not display:none — that would take the radio out of the tab order and
   silence it for assistive tech. Clipped to nothing instead, so it stays a
   focusable, announceable control sitting under its own label. */
.login-segment input {
  position: absolute; width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  clip-path: inset(50%); overflow: hidden; white-space: nowrap;
}
.login-segment span {
  display: block; padding: 11px 8px; text-align: center;
  font-family: var(--font-body); font-size: var(--text-base); font-weight: 500;
  color: var(--slate-500); background: #fff;
  border: 1.5px solid var(--slate-200); border-radius: 12px;
  transition: border-color 0.12s ease, color 0.12s ease, background 0.12s ease;
}
.login-segment input:checked + span {
  color: var(--navy); font-weight: 600;
  border-color: var(--crimson);
  background: rgba(227, 24, 55, 0.06);
}
/* Focus ring on the label's box, since the input itself is clipped away. */
.login-segment input:focus-visible + span {
  outline: 2px solid var(--crimson); outline-offset: 2px;
}
/* Class selector, not per-id: every field in the login/signup card (username,
   password, and signup's name/email) shares one input style — previously
   this was #login-username/#login-password only, which left the signup-only
   fields (#signup-displayname/#signup-email) unstyled and visibly
   inconsistent the moment signup mode revealed them. */
.login-field input {
  width: 100%; padding: 13px 16px;
  /* 1rem = 16px, deliberately NOT one of the --text-* tokens: iOS Safari
     zooms the whole page when a focused input is under 16px, and the scale
     straddles it (--text-base 15.2, --text-lg 17.6). Do not "normalise"
     this to a token. */
  font-size: 1rem; font-family: var(--font-body); font-weight: 500;
  color: var(--navy); background: #fff;
  border: 1.5px solid var(--slate-200); border-radius: 12px;
  transition: border-color 0.12s ease, box-shadow 0.12s ease;
}
.login-field input::placeholder {
  color: var(--slate-400); font-weight: 400;
}
.login-field input:focus {
  outline: none; border-color: var(--crimson);
  box-shadow: 0 0 0 3px rgba(227, 24, 55, 0.14);
}

/* .password-field is combined with .login-field on the same div in hub.html,
   which also holds the "PASSWORD" label ABOVE the input — the label is part
   of this same positioning context. .password-toggle anchoring with
   `top: 1.5px` (relative to this whole div, label included) stretched the
   button from just under the label down to the bottom of the input, well
   past the input's own top edge — a real bug, reproducible on production
   today: the button reads as a disconnected grey box poking out above the
   pill. Anchoring from `bottom` + a fixed `height` instead makes the button
   track the INPUT's height regardless of how tall the label above it is,
   since the input is always the last piece of visible content at the
   bottom of this div. */
.password-field { position: relative; }
.password-field #login-password { padding-right: 46px; }

/* .password-toggle used to anchor with `top: 1.5px` relative to the WHOLE
   .password-field div, which also holds the "PASSWORD" label above the
   input — so the button stretched from just under the label down to the
   bottom of the input, well past the input's own top edge. Reproducible on
   production: the button read as a disconnected grey box poking out above
   the pill. Anchoring from `bottom` + a fixed `height` instead makes the
   button track the INPUT's own height regardless of the label's height,
   since the input is always the last piece of visible content at the
   bottom of this div. */
.password-toggle {
  position: absolute; right: 1.5px; bottom: 1.5px; height: 44px;
  display: flex; align-items: center; justify-content: center;
  width: 42px; padding: 0; cursor: pointer;
  background: none; border: 0; border-radius: 0 10px 10px 0;
  color: var(--slate-500);
  transition: color 0.12s ease, background 0.12s ease;
}
.password-toggle:hover { color: var(--navy); background: var(--slate-100); }
.password-toggle[aria-pressed="true"] { color: var(--crimson); }
.password-toggle:focus-visible { outline: 2px solid var(--navy); outline-offset: -2px; }
.password-toggle svg { width: 19px; height: 19px; }

.login-error {
  margin: 16px 0 0; padding: 10px 14px;
  background: #fff1f2; border-radius: 10px;
  color: var(--crimson-dark); font-size: var(--text-sm); font-weight: 600;
}

.login-btn {
  margin-top: 14px; padding: 15px 18px;
  font-size: var(--text-base);
  transition: background 0.15s ease, transform 0.08s ease;
}
.login-btn:active:not(:disabled) { transform: translateY(1px); }
.login-btn:focus-visible { outline: 3px solid var(--amber-300); outline-offset: 2px; }
.login-btn:disabled { opacity: 0.6; cursor: not-allowed; }

.login-divider {
  margin: 12px 0; display: flex; align-items: center; gap: 10px;
  font-size: var(--text-xs); letter-spacing: 0.08em; text-transform: uppercase; color: var(--slate-400);
}
.login-divider::before, .login-divider::after {
  content: ""; flex: 1; height: 1px; background: var(--ink-400); opacity: 0.4;
}

/* Google's own brand guidelines want a white/neutral button, not the
   app's crimson — this is the one place in the product that borrows
   someone else's brand mark, so it stays visually distinct on purpose. */
.login-btn-google {
  margin-top: 0; background: #fff; color: var(--ink-900);
  border: 1.5px solid var(--ink-400);
  display: flex; align-items: center; justify-content: center; gap: 10px;
}
.login-btn-google svg { width: 18px; height: 18px; flex: none; }
.login-btn-google:hover:not(:disabled) { background: #f7f7f7; }

/* Device-lock note (functional, keep it — one account signs another device
   out) and the forgot-password line — a mailto link to the team, since
   there is still no self-service reset. */
.login-note {
  margin: 10px 0 0; font-size: var(--text-xs); line-height: var(--leading-body); color: var(--slate-500);
}
.login-alt {
  margin: 6px 0 0; font-size: var(--text-sm); color: var(--slate-500);
}

.login-brand {
  margin-top: auto; padding-top: 18px;
  display: flex; align-items: center; gap: 10px;
}
.login-brand-name {
  font-family: var(--font-display);
  font-weight: var(--weight-display);
  font-size: 19.5px;
  line-height: 1.15;
  color: var(--ink-950);
}
/* The four ghost-layer fills that used to sit here (.about-logo-the,
   .about-ghost-cyan, .about-ghost-pink, .about-logo-main) styled SVG <text>
   inside the drawn wordmark. Both pages that instantiated it now use live
   text, so the rules had nothing left to match and are gone with it.
   /tutorial still draws the old mark from its own copy of the <symbol>; it
   carries its own copy of these fills in about.css. */
.login-tagline {
  font-size: var(--text-xs); line-height: 1.3; color: var(--slate-400);
  border-left: 1px solid var(--slate-200); padding-left: 10px;
}

@media (max-width: 480px) {
  #login-section { padding: 80px 12px 24px; }
  .login-form { padding: 26px 22px 20px; }
  .login-title { font-size: 1.7rem; }
}
#student-other { margin-top: 8px; }
/* "You cannot afford a session" — amber, not red: nothing has gone wrong and
   nothing is broken, the student just needs to top up before writing. */
.low-credit-note {
  margin: 0 0 12px; padding: 10px 14px; border-radius: 8px;
  background: var(--amber-100, #fef3c7); color: #92400e;
  font-size: var(--text-sm); line-height: 1.5; font-weight: var(--weight-medium);
}
.low-credit-note a { color: inherit; font-weight: 700; }

.consent-note {
  font-size: var(--text-xs); color: var(--slate-400); line-height: 1.5; margin: 8px 0 0;
}

.edit-hint {
  font-size: var(--text-xs); color: var(--slate-400); line-height: 1.5; margin: 12px 0 6px;
}

.scope-note {
  font-size: var(--text-xs); color: var(--slate-400); margin: 0 0 8px;
  padding-bottom: 8px; border-bottom: 1px dashed var(--slate-100);
}

.stepper-actions { margin-left: auto; display: flex; align-items: center; gap: 12px; }

.cycle-actions { display: flex; flex-direction: column; gap: 8px; width: 100%; margin-top: 16px; }

/* ---------- final report ---------- */
.report { text-align: left; }
.report-overall { text-align: center; padding: 12px 0 16px; border-bottom: 1px solid var(--slate-100); }
.report-overall-label {
  font-size: var(--text-micro); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--slate-400); margin: 0 0 6px;
}
.report-overall-scores {
  margin: 0; display: flex; align-items: center; justify-content: center; gap: 8px;
  font-family: var(--font-display); letter-spacing: -0.02em;
  font-size: 36px; font-weight: var(--weight-black); color: var(--navy); font-variant-numeric: tabular-nums;
  line-height: 1.1;
}
.report-overall-scores .was { color: var(--slate-400); font-size: 27.5px; }
.report-overall-scores .arrow { color: var(--slate-400); font-size: 20.5px; }

.report-row { padding: 12px 0; border-bottom: 1px solid var(--slate-100); }
.report-row-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; }
.report-crit { font-family: var(--font-display); font-size: var(--text-base); font-weight: var(--weight-display); color: var(--navy); }
.report-scores {
  display: flex; align-items: baseline; gap: 5px; font-size: var(--text-sm);
  font-weight: 700; color: var(--navy); font-variant-numeric: tabular-nums; white-space: nowrap;
}
.report-scores .was { color: var(--slate-400); font-weight: 600; }
.report-scores .arrow { color: var(--slate-400); font-size: var(--text-xs); }
.report-comment { font-size: var(--text-sm); color: var(--slate-600); line-height: var(--leading-body); margin: 6px 0 0; }

.report-delta { font-size: var(--text-micro); font-weight: 700; padding: 1px 5px; border-radius: 4px; }
.report-delta.up { background: var(--emerald-100); color: var(--emerald-700); }
.report-delta.down { background: #fee2e2; color: #b91c1c; }
.report-delta.flat { background: var(--slate-100); color: var(--slate-600); }

.report-block { margin-top: 16px; }
.report-block-title {
  font-size: var(--text-micro); font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase;
  color: var(--slate-400); margin: 0 0 4px;
}
.report-block p:last-child { font-size: var(--text-sm); color: var(--slate-600); line-height: var(--leading-body); margin: 0; }
.report-ceiling-line {
  margin: 8px 0 0; font-size: var(--text-sm); color: var(--slate-400);
  font-variant-numeric: tabular-nums;
}
.report-ceiling-line strong { color: var(--navy); font-weight: 700; }
.report-ceiling-reason {
  margin: 8px 0 0; font-size: var(--text-sm); color: var(--slate-600); line-height: var(--leading-body); text-align: left;
}
.report-scores .ceiling { color: var(--slate-400); font-weight: 600; font-size: var(--text-xs); }

.report-block.ceiling {
  background: var(--slate-50); border-left: 2px solid var(--navy);
  padding: 10px 12px; border-radius: 0 6px 6px 0;
}

.report-block.focus {
  background: var(--slate-50); border-left: 2px solid var(--crimson);
  padding: 10px 12px; border-radius: 0 6px 6px 0;
}
.report-warn {
  margin-top: 12px; font-size: var(--text-sm); color: #92400e;
  background: var(--amber-100, #fef3c7); padding: 8px 10px; border-radius: 6px; line-height: 1.5;
}
.report-meta { margin-top: 12px; font-size: var(--text-xs); color: var(--slate-400); text-align: center; }

/* ---------- essay submission card ---------- */
#essay-input-section textarea#essay-input,
#essay-input-section textarea#topic-input {
  width: 100%; font-family: inherit;
  /* 1rem, not a token: iOS Safari zooms the page on focus under 16px, and this
     is where a whole essay gets pasted and edited. Same call as the login and
     correction inputs. */
  font-size: 1rem; padding: 0.75rem; border-radius: 8px;
  border: 1px solid var(--slate-300); background: var(--slate-50); color: var(--slate-800); resize: vertical;
}
.input-label { display: block; font-size: var(--text-base); font-weight: 600; color: var(--navy); margin-bottom: 6px; }
.required-star { color: var(--crimson); }
.topic-meta { margin-top: 6px; }
.topic-meta span { font-size: var(--text-sm); color: var(--slate-400); }
.topic-banner {
  padding: 12px 24px; border-bottom: 1px solid var(--slate-100); background: var(--slate-50);
  font-size: var(--text-sm); color: var(--slate-600); line-height: 1.5;
}
.topic-banner strong { color: var(--navy); font-weight: 700; display: block; font-size: var(--text-xs);
  text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 4px; }
.essay-toolbar {
  display: flex; justify-content: space-between; align-items: center; margin-top: 0.75rem; flex-wrap: wrap; gap: 0.5rem;
}
.essay-actions { display: flex; gap: 0.5rem; }
#word-count { color: var(--slate-400); font-size: var(--text-sm); }
.btn { border: none; border-radius: 0; padding: 0.6rem 1.1rem; font-size: var(--text-base); cursor: pointer; font-weight: 600; }
/* .btn-primary takes the shared primary recipe above. It sits in a row beside
   .btn-secondary, so it is the one place that opts OUT of full width. */
.btn.btn-primary { width: auto; display: inline-block; }
/* The quiet partner: same square geometry and the same uppercase register, but
   outlined rather than filled, so the pair reads as one control group. */
.btn-secondary {
  background: transparent; color: var(--ink-950);
  border: var(--border-hair) solid var(--line-ink); border-radius: 0;
  font-family: var(--font-display); font-weight: var(--weight-display);
  font-size: var(--text-sm); letter-spacing: 0.08em; text-transform: uppercase;
  padding: 13px 20px;
}
.btn-secondary:hover { background: var(--slate-100); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; }
.field-error { color: var(--crimson-dark); font-size: var(--text-sm); margin-top: 0.5rem; }

.banner { padding: 0.85rem 1rem; border-radius: 8px; font-size: var(--text-base); margin-bottom: 16px; }
.banner-error { background: #fdeceb; color: var(--crimson-dark); }
/* Idle-timeout warning, ~60s before auto-logout — see session.js:startIdleWatch.
   Clickable: the whole banner counts as activity, so tapping it is how a
   student staying at their desk dismisses it. */
.banner-warn { background: var(--amber-100, #fef3c7); color: #92400e; cursor: pointer; font-weight: 600; }

footer.foot { padding: 8px 0 40px; }
footer.foot p { font-size: var(--text-xs); color: var(--slate-400); margin: 0; }
/* Privacy/Terms: real destinations (Google OAuth's consent screen requires
   /privacy to exist — see privacy.html's docstring) but not something a
   student navigates to on purpose, so they live down here rather than in the
   account-menu dropdown next to the pages a student actually uses. */
.foot-links { margin-top: 4px; }
.foot-links a { color: var(--slate-400); text-decoration: underline; }
.foot-links a:hover { color: var(--slate-500); }
.foot-links span { margin: 0 6px; }

/* The writing page used to pin this footer to the bottom-left corner with
   `position: fixed` from 1024px up. Removed on Marc's call: fixed meant the
   links floated over the working surface and followed the student down the
   whole page, cutting across the column rule beside the intake card. Every
   page now keeps the footer in the flow at the end of .container, and it is
   seen at the bottom of the page or not at all — which is the right weight for
   two legal links nobody navigates to on purpose.

   .foot-corner is gone from index.html with it; if it ever reappears, it is a
   stale class, not a rule waiting for a stylesheet.

   What replaces it is `sticky` with a `top` deliberately below the fold, which
   is the whole trick: a sticky box can only be pushed around inside its
   parent's box, and body already carries min-height:100dvh. So on a short
   screen — the task chooser is the one that matters, two cards and nothing
   else — the offset drives the footer down until it hits body's bottom edge and
   it sits flush at the bottom of the window instead of stranded halfway up the
   page. On a tall screen (the intake card, the report) 100vh is past everything
   visible, so the footer simply rides at the end of the document the way it
   does everywhere else. No `position: fixed`, so it never floats over the work
   and never follows the student down the page.

   Why not make body a flex column with margin-top:auto, the usual answer: this
   page's scrolling was only just repaired in v1.4.45 and changing body's
   display would re-open every one of those questions to save one rule. */
.writing-page footer.foot {
  position: sticky;
  top: 100vh;
}

.hidden { display: none !important; }

@keyframes fadeScaleIn { from { opacity: 0; transform: translateY(4px) scale(0.98); } to { opacity: 1; transform: translateY(0) scale(1); } }
.fade-in { animation: fadeScaleIn 220ms ease-out; }

::-webkit-scrollbar { width: 8px; height: 8px; }
::-webkit-scrollbar-thumb { background: var(--slate-300); border-radius: 8px; }
::-webkit-scrollbar-track { background: transparent; }

:focus-visible { outline: 2px solid var(--navy); outline-offset: 2px; }

/* ---------- boot screen ----------
   Covers the page until hub.js / app.js decide what to render. Present in the
   markup and visible by default; JS adds .is-gone. See the note on the markup
   for why that direction and not the other.

   No prefers-reduced-motion guard (CLAUDE.md rule 4). The bar is the only
   thing telling a visitor the page is alive rather than broken. */
.boot-screen {
  position: fixed; inset: 0; z-index: 90;
  background: var(--surface-app);
  display: flex; align-items: center; justify-content: center; padding: 24px;
  transition: opacity 0.25s ease;
}
/* Removed from the flow entirely, not just faded: it sits above everything,
   and a transparent layer over the whole page would swallow every click. */
.boot-screen.is-gone { opacity: 0; pointer-events: none; visibility: hidden; }

.boot-inner { display: flex; flex-direction: column; align-items: center; text-align: center; max-width: 280px; width: 100%; }

.boot-mark {
  width: 48px; height: 48px; margin-bottom: 16px;
  display: flex; align-items: center; justify-content: center;
  background: var(--line-ink); color: #fff;
  font-family: var(--font-display); font-weight: var(--weight-display); font-size: 26px;
  border: 2px solid var(--line-ink);
  box-shadow: 4px 4px 0 var(--crimson);
}

.boot-name {
  margin: 0; font-family: var(--font-display);
  font-weight: var(--weight-display); font-size: var(--text-lg); color: var(--ink-950);
}
.boot-als {
  margin: 2px 0 22px; font-family: var(--font-mono);
  font-size: 12px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--slate-500);
}

.boot-track {
  width: 100%; height: 8px; padding: 1px;
  background: var(--slate-200); border: var(--border-hair) solid var(--line-ink);
  overflow: hidden;
}
/* Indeterminate on purpose: nothing here knows how long /api/me will take, so
   a bar that crept toward a number would be inventing one. It sweeps. */
.boot-fill { height: 100%; width: 40%; background: var(--line-ink); animation: bootSweep 1.15s ease-in-out infinite; }
@keyframes bootSweep {
  0%   { transform: translateX(-110%); }
  100% { transform: translateX(260%); }
}

.boot-status {
  margin-top: 9px; font-family: var(--font-mono);
  font-size: 12px; letter-spacing: 0.06em; color: var(--slate-500);
}


/* ---------- skeleton primitive ----------
   ONE definition of the shimmer, used by two different skeletons: the hub
   dashboard's (.hub-skel*, laid out in hub-dash.css) and the writing page's
   resume skeleton (.resume-skel*, below). It lives here rather than in
   hub-dash.css because style.css is the file every page loads; hub-dash.css is
   loaded by hub.html and index.html only, and a shimmer that silently dies the
   day someone trims that link is the kind of drift this codebase keeps paying
   for. hub-dash.css now sets sizes on top of this and nothing else.

   Sizes are NOT here on purpose. The hub's `.sk-label/-value/-note/-row` are
   measured against hub card internals and stay scoped to #hubView; the resume
   skeleton's `.rsk-*` are measured against the workspace and stay below. A
   shared size class would couple two layouts that have no reason to agree.

   No prefers-reduced-motion query (CLAUDE.md rule 4). The shimmer is the only
   thing separating "loading" from "these boxes are empty". */
.sk {
  display: block; border-radius: var(--radius-sm);
  background: linear-gradient(90deg, var(--slate-100) 25%, var(--slate-200) 37%, var(--slate-100) 63%);
  background-size: 400% 100%;
  animation: skShimmer 1.4s ease-in-out infinite;
}
@keyframes skShimmer {
  0%   { background-position: 100% 0; }
  100% { background-position: 0 0; }
}


/* ---------- resume skeleton ----------
   Shown while /api/draft/resume is in flight, then removed. See the markup in
   index.html for why this exists instead of the waiting screen.

   Every measurement below is copied from the real element it stands in for,
   not chosen to look right: .stepper's `gap: 12px; margin-bottom: 24px`,
   .step-dot's 20px circle, .step-rule's 32x1px, .layout's `gap: 24px` and its
   7fr/5fr split at 1024px, .card's surface/border/radius. The point of a
   skeleton is that nothing moves when the content replaces it. */
.resume-skel-stepper {
  display: flex; align-items: center; gap: 12px;
  margin-bottom: 24px; flex-wrap: wrap;
}
.rsk-dot { width: 20px; height: 20px; border-radius: 999px; flex: none; }
.rsk-step { width: 96px; height: 13px; flex: none; }
/* Not a .sk: .step-rule is a plain hairline in the real stepper and it does
   not shimmer there either. A shimmering connector would be the one part of
   this skeleton claiming to be content. */
.rsk-rule { width: 32px; height: 1px; background: var(--slate-300); flex: none; }

.resume-skel-layout {
  display: grid; grid-template-columns: 1fr; gap: 24px; padding-bottom: 40px;
}
@media (min-width: 1024px) { .resume-skel-layout { grid-template-columns: 7fr 5fr; } }
@media (min-width: 1500px) { .resume-skel-layout { grid-template-columns: minmax(0, 760px) 1fr; } }

.resume-skel-card {
  background: var(--surface-card); border-radius: var(--radius-card);
  border: var(--border-hair) solid var(--line-crisp);
  padding: 20px 24px;
  display: flex; flex-direction: column; gap: 12px;
  /* Matches .card's own margin-bottom so the two columns end where the real
     ones do. */
  margin-bottom: 24px;
  /* align-items, so the width on each bar below is honoured instead of every
     one of them stretching to the column. */
  align-items: flex-start;
}

.rsk-eyebrow { height: 16px; width: 34%; }
/* Stands in for .topic-banner, which is a filled block, not a line of text. */
.rsk-topic   { height: 46px; width: 100%; }
.rsk-line    { height: 14px; width: 100%; }
.rsk-line-short { width: 62%; }
/* #fix-input is a 9-row textarea; this is the same visual weight. */
.rsk-box     { height: 150px; width: 100%; margin-top: 4px; }
.rsk-btn     { height: 38px; width: 148px; border-radius: var(--radius-card); }


/* ---------- the waiting screen ----------
   ⚠️ NOTHING IN THIS BLOCK MAY BE PUT BEHIND @media (prefers-reduced-motion).
   CLAUDE.md rule 4 in general, but here it is the sharpest case in the whole
   codebase: every machine in this project reports reduce-motion as ON, and a
   frozen waiting screen is indistinguishable from a hung app. This project has
   already shipped that bug once — a spinner that stopped and made the app look
   crashed. Motion here IS the message. */

.loading-overlay {
  position: fixed; inset: 0;
  background: rgba(24, 24, 27, 0.42);
  backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
  display: flex; align-items: center; justify-content: center;
  padding: 20px; z-index: 50;
}

/* The one modal in the app that keeps a hard offset shadow. The lesson screen
   is deliberately flat so the Socratic box can carry the only 2px offset — but
   this card is not part of that composition: it covers the whole screen and is
   the only thing on it. */
.load-card {
  width: 100%; max-width: 520px;
  max-height: calc(100dvh - 40px); overflow-y: auto;
  background: var(--surface-card);
  border: 2px solid var(--line-ink);
  box-shadow: 6px 6px 0 var(--line-ink);
}

.load-bar-top {
  display: flex; align-items: center; gap: 9px;
  padding: 10px 16px;
  background: var(--line-ink);
  border-bottom: 2px solid var(--line-ink);
}

/* The only element on screen that proves the page is still alive when a
   response is slow. Deliberately a pulse and not a spin: a dot that beats
   reads as a heartbeat, and it cannot look "stuck mid-rotation". */
.load-live {
  width: 9px; height: 9px; flex: none; border-radius: 50%;
  background: var(--crimson);
  animation: loadLive 1.1s ease-in-out infinite;
}
@keyframes loadLive {
  0%, 100% { opacity: 1; transform: scale(1); }
  50% { opacity: 0.35; transform: scale(0.72); }
}

.load-engine {
  font-family: var(--font-mono); font-weight: 600;
  font-size: 12px; letter-spacing: 0.05em; text-transform: uppercase;
  color: #a1a1aa;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.load-badge {
  margin-left: auto; flex: none;
  background: var(--amber-300); color: var(--ink-950);
  font-family: var(--font-mono); font-weight: 700;
  font-size: 12px; letter-spacing: 0.04em; text-transform: uppercase;
  padding: 2px 8px; white-space: nowrap;
}

.load-body { padding: 22px 24px; display: flex; flex-direction: column; gap: 18px; }

.load-title {
  margin: 0; font-family: var(--font-display);
  font-weight: var(--weight-display); font-size: var(--text-lg);
  line-height: 1.3; color: var(--ink-950);
}
.load-sub {
  margin: 5px 0 0; font-size: var(--text-sm);
  line-height: 1.55; color: var(--slate-500);
}

/* The bar's WIDTH is time-based (app.js), so it can stall. The stripes run
   regardless, which is what keeps a stalled bar from reading as a dead one. */
.load-track {
  height: 16px; padding: 2px;
  background: var(--slate-100);
  border: 2px solid var(--line-ink);
  overflow: hidden;
}
.load-fill {
  height: 100%; width: 0%;
  background-color: var(--crimson);
  background-image: linear-gradient(45deg,
    rgba(0,0,0,0.16) 25%, transparent 25%, transparent 50%,
    rgba(0,0,0,0.16) 50%, rgba(0,0,0,0.16) 75%, transparent 75%, transparent);
  background-size: 16px 16px;
  animation: loadStripes 0.9s linear infinite;
  transition: width 0.4s linear;
}
@keyframes loadStripes { to { background-position: 32px 0; } }

/* ---------- the per-step checklist ---------- */

.load-steps {
  list-style: none; margin: 0; padding: 14px 0;
  border-top: var(--border-hair) solid var(--slate-200);
  border-bottom: var(--border-hair) solid var(--slate-200);
  display: flex; flex-direction: column; gap: 11px;
}
.load-step {
  display: flex; align-items: center; gap: 10px;
  font-family: var(--font-mono); font-size: 12.5px; line-height: 1.4;
  color: var(--slate-400);
}
.load-step-mark {
  width: 18px; height: 18px; flex: none; border-radius: 50%;
  display: flex; align-items: center; justify-content: center;
  font-size: 12px; font-weight: 700;
  border: var(--border-hair) solid var(--slate-300);
  background: var(--slate-100); color: var(--slate-400);
}
.load-step-note { margin-left: auto; flex: none; font-size: 12px; }

.load-step.is-done { color: var(--slate-600); }
.load-step.is-done .load-step-mark {
  background: var(--emerald-100); color: var(--emerald-700);
  border-color: var(--emerald-600);
}
.load-step.is-now { color: var(--ink-950); font-weight: 600; }
.load-step.is-now .load-step-mark {
  background: var(--amber-100); color: var(--amber-800);
  border-color: var(--amber-300);
  animation: loadLive 1.1s ease-in-out infinite;
}
.load-step.is-now .load-step-note {
  background: var(--amber-100); color: var(--amber-800);
  border: var(--border-hair) solid var(--amber-300); padding: 1px 6px;
}

/* ---------- what the student just fixed ---------- */

.load-recall {
  padding: 13px 15px;
  background: var(--slate-50);
  border: var(--border-hair) solid var(--line-crisp);
  font-size: 12.5px; line-height: 1.6; color: var(--slate-600);
}
.load-recall-head {
  display: block; margin-bottom: 4px;
  font-family: var(--font-display); font-weight: var(--weight-display);
  font-size: 12.5px; color: var(--ink-950);
}
.load-recall-quote {
  display: block; margin-top: 3px;
  font-style: italic; color: var(--slate-500);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

.load-bar-bottom {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 9px 16px;
  background: var(--slate-100);
  border-top: 2px solid var(--line-ink);
  font-family: var(--font-mono); font-size: 12px; color: var(--slate-500);
}
/* Set by app.js once elapsed passes the step's typical time. The bar is near
   its ceiling by then and stops being informative, so the honest signal moves
   to the words. */
.load-bar-bottom.is-over #loadingEta { color: var(--amber-800); font-weight: 600; }

@media (max-width: 520px) {
  .load-body { padding: 18px 16px; gap: 15px; }
  .load-engine { font-size: 12px; }
  .load-step { font-size: 12px; }
}

/* --- Discard-essay warning modal (static/index.html #discardModal, hub.js's
   dynamically-built equivalent) -------------------------------------------
   Same overlay posture as .loading-overlay (fixed, dimmed backdrop) but
   holds a white card rather than a spinner — this one needs to be read and
   chosen from, not just glanced at. */
.discard-modal {
  position: fixed; inset: 0; background: rgba(0, 12, 38, 0.55);
  backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
  display: flex; align-items: center; justify-content: center;
  padding: 20px; z-index: 60;
}
.discard-modal-box {
  background: #fff; border-radius: 14px; padding: 28px 28px 20px;
  max-width: 420px; width: 100%; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
}
.discard-modal-box h2 { margin: 0 0 12px; font-size: 22px; color: var(--navy); }
.discard-modal-box p { margin: 0 0 12px; font-size: var(--text-base); line-height: 1.5; color: var(--slate-600); }
.discard-modal-warn {
  background: var(--amber-100); color: var(--amber-800); border-radius: 8px;
  padding: 10px 12px; font-weight: 500;
}
.discard-modal-actions { display: flex; flex-direction: column; gap: 10px; margin-top: 18px; }
.discard-modal-danger {
  border: none; border-radius: 8px; padding: 0.6rem 1.1rem; font-size: var(--text-base);
  font-weight: 600; cursor: pointer; background: var(--crimson); color: #fff;
}
.discard-modal-danger:hover { background: var(--crimson-dark); }
.discard-modal-cancel { display: block; margin: 14px auto 0; }

/* --- Essay before/after compare modal (static/index.html #essayCompareModal)
   Shares .discard-modal's overlay but needs its own box: two side-by-side
   text columns instead of a narrow warning card, so .discard-modal-box's
   420px cap doesn't fit. Same 720px split-point as #submitGrid.submit-split
   — one breakpoint for "stack on mobile, side by side above" everywhere. */
.essay-compare-box {
  background: #fff; border-radius: 14px; padding: 24px;
  max-width: 900px; width: 100%; max-height: 85vh;
  display: flex; flex-direction: column;
  box-shadow: 0 20px 60px rgba(0, 0, 0, 0.35);
}
.essay-compare-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.essay-compare-head h2 { margin: 0; font-size: 19.5px; color: var(--navy); }
.essay-compare-close {
  border: none; background: none; cursor: pointer; font-size: 25.5px; line-height: 1;
  color: var(--slate-400); padding: 4px 8px;
}
.essay-compare-close:hover { color: var(--navy); }
.essay-compare-grid { display: grid; grid-template-columns: 1fr; gap: 20px; overflow-y: auto; min-height: 0; }
@media (min-width: 720px) {
  .essay-compare-grid { grid-template-columns: 1fr 1fr; }
}
.essay-compare-col { min-width: 0; }
.essay-compare-label {
  font-size: var(--text-micro); font-weight: 700; letter-spacing: 0.08em; text-transform: uppercase;
  color: var(--slate-400); margin: 0 0 8px;
}
.essay-compare-text {
  font-size: var(--text-sm); line-height: 1.6; color: var(--slate-700);
  white-space: pre-wrap; word-wrap: break-word;
}
.essay-compare-text p { margin: 0 0 12px; }

/* --- Writing Task 1: chart submission and the standing chart banner --------
   The two-column submit form reuses .hub-grid's breakpoint rather than
   inventing a second one, so the page has one place where it goes narrow. */
#submitGrid { display: grid; grid-template-columns: 1fr; gap: 20px; }
#submitGrid.submit-split { gap: 24px; }
/* Splits at the same width as .layout (the lesson screen), not at .hub-grid's
   720px: Task 2's ratio is lopsided on purpose and 4fr of a 720px viewport is
   too narrow to read a question in. Task 1 stays even because its left column
   carries the chart upload as well as the topic. */
@media (min-width: 1024px) {
  #submitGrid.submit-split.is-task1 { grid-template-columns: 1fr 1fr; }
  /* 5fr, not the 4fr this shipped with: a Task 2 question runs 2-3 lines and at
     a third of the width it wrapped into a narrow ragged column. The answer box
     keeps the majority — it is still the side being written in. */
  #submitGrid.submit-split.is-task2 { grid-template-columns: 5fr 7fr; }
}
.submit-col { min-width: 0; }
/* Stacked, the answer box needs air above it; side by side it does not. */
@media (max-width: 1023px) {
  #submitGrid .submit-col + .submit-col { margin-top: 4px; }
}
/* The whole point of the split is not scrolling between question and answer,
   which only works if the answer box is tall enough to hold the answer. */
@media (min-width: 1024px) {
  #submitGrid.submit-split.is-task2 #essay-input { min-height: clamp(340px, 58vh, 720px); }
  #submitGrid.submit-split.is-task1 #essay-input { min-height: clamp(280px, 42vh, 560px); }
}
/* Both the space to scroll past the last control AND the clearance under
   "Chấm bài". .feedback-widget is position:fixed at bottom:18px and about 40px
   tall, so it owns the bottom ~58px of the viewport; 104px here means that at
   full scroll the button row is clear above it.

   Two earlier attempts, both wrong, both worth naming:
   - Reserving 96px of horizontal padding on .essay-toolbar. It worked, at the
     cost of the page's primary action never sitting where the eye looks for
     it — the right edge of the box being written in.
   - Hiding the widget entirely while the intake form is up. That took the Góp
     ý button away from the screen where a student is most likely to want it.

   What is NOT solved here, and cannot be: a fixed element crosses every
   element at some scroll offset, so mid-scroll the widget still passes over
   the buttons. That is true of every floating action button on every site and
   is not worth deforming the layout for — what matters is the resting state,
   where a student actually clicks. */
#essay-input-section { margin-bottom: 104px; }

/* Shared by the topic and essay boxes: the line that says a paste was repaired,
   and the button that repairs one the paste handler never saw (drag-drop, or
   text typed around a stale paste). */
.paste-hint {
  margin: 6px 0 0; font-size: var(--text-xs); line-height: 1.5; color: var(--slate-600);
  background: #fffbeb; border-left: 2px solid #f0b429;
  padding: 6px 10px; border-radius: 0 4px 4px 0;
}
.btn-tidy {
  background: none; border: 1px solid var(--slate-300); border-radius: 5px;
  font-family: inherit; font-size: var(--text-xs); color: var(--slate-600);
  padding: 3px 9px; cursor: pointer;
}
.btn-tidy:hover { border-color: var(--navy); color: var(--navy); }
.topic-meta { display: flex; align-items: center; gap: 10px; }
.essay-toolbar #essayTidyBtn { margin-left: 10px; }

.chart-hint { font-size: var(--text-sm); color: var(--slate-400); margin: 0 0 8px; line-height: 1.5; }
#chart-input { font-size: var(--text-sm); color: var(--slate-600); }
.chart-preview { margin-top: 12px; }
/* Capped: a photo of a whole A4 page is ~1.4x taller than it is wide, and
   uncapped it pushed "Chấm bài" and the essay box right off the screen. */
.chart-preview img {
  display: block; max-width: 100%; max-height: 320px; height: auto;
  object-fit: contain; object-position: left top;
  border-radius: 8px; border: 1px solid var(--slate-300); background: #fff;
}
.chart-preview-actions { display: flex; flex-wrap: wrap; gap: 10px; }
.chart-preview .btn-ghost { margin-top: 8px; }
.btn-ghost-danger { border-color: var(--crimson); color: var(--crimson); }
.btn-ghost-danger:hover { background: var(--crimson); color: #fff; }

.chart-banner {
  border: 1px solid var(--slate-200); border-radius: 12px; background: #fff;
  margin-bottom: 24px; overflow: hidden;
}
.chart-banner-head {
  cursor: pointer; padding: 12px 20px; background: var(--slate-50);
  font-family: var(--font-display); font-weight: var(--weight-display);
  font-size: var(--text-sm); color: var(--navy); letter-spacing: -0.01em;
}
.chart-banner-body { padding: 16px 20px 20px; }
/* Still capped against the viewport so a tall chart cannot push the fix panel
   off the bottom of the screen — the thing the student is supposed to be typing
   into — but 340px was too small to read a value off. Anything the cap still
   shrinks past legibility is one click away in the lightbox. */
.chart-banner-body img {
  /* max-width, not width: a full-page photo is taller than it is wide, and
     forcing it to the column width just letterboxed it inside a slab of empty
     white. Height is what needs the cap; width follows the picture. */
  display: block; margin: 0 auto;
  max-width: 100%; max-height: min(62vh, 620px);
  width: auto; height: auto;
  border-radius: 8px; border: 1px solid var(--slate-200); background: #fff;
  cursor: zoom-in;
}
.chart-banner-hint {
  margin: 8px 0 0; font-size: var(--text-xs); color: var(--slate-400); text-align: center;
}

.chart-crop-note {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  margin: 8px 0 0; font-size: var(--text-xs); line-height: 1.5; color: var(--slate-600);
}

/* The zoomed chart. Natural size inside a scroll container: no transform, no
   pinch maths — the browser's own scrolling is the pan. */
.chart-lightbox {
  position: fixed; inset: 0; z-index: 120;
  background: rgba(15, 16, 20, 0.92);
  display: flex; align-items: center; justify-content: center;
}
.chart-lightbox-scroll {
  max-width: 96vw; max-height: 92vh; overflow: auto;
  background: #fff; border-radius: 8px;
}
.chart-lightbox-scroll img { display: block; max-width: none; height: auto; }
.chart-lightbox-close {
  position: absolute; top: 16px; right: 20px;
  background: none; border: 0; color: #fff; font-size: 34px; line-height: 1;
  cursor: pointer; padding: 4px 10px;
}

/* ---------- báo lỗi ----------
   A student who hits a bug mid-essay has to be able to say so without losing
   their place, so this floats over the lesson rather than living inside any one
   step's markup. */

/* Pinned to the true bottom-right corner. It used to share that line with the
   footer links pinned bottom-left; those went back into the flow (see
   .foot-corner's removal above), so this is now the only thing floating over
   the bottom of the page. */
.feedback-widget {
  position: fixed;
  right: 16px;
  bottom: 12px;
  z-index: 60;   /* above the lesson, below the loading overlay */
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 10px;
}

/* Crimson, not the old white-on-grey pill. This is on all five signed-in pages
   now, and it is the only way a student can tell anyone something is broken —
   a quiet button that nobody notices reports nothing. Same fill as .btn-submit
   and .login-btn so it reads as an action, not a decoration. */
.feedback-fab {
  border: 1px solid var(--crimson);
  background: var(--crimson);
  color: #fff;
  font-family: var(--font-display);
  letter-spacing: -0.01em;
  font-size: var(--text-base);
  font-weight: var(--weight-display);
  padding: 11px 20px;
  border-radius: var(--radius-card);
  cursor: pointer;
  /* No coloured glow. A crimson halo under a crimson button is the one effect
     that made the old chrome read as a toy; the fill alone is enough weight. */
  box-shadow: var(--shadow-none);
  /* Comfortably past the 44px touch target on the iPad this is aimed at. */
  min-height: 44px;
}

.feedback-fab:hover { background: var(--crimson-dark); border-color: var(--crimson-dark); }

.feedback-panel {
  order: -1;   /* opens upward, so the button never jumps under the finger */
  width: min(340px, calc(100vw - 36px));
  background: #fff;
  border: 1px solid var(--slate-200);
  border-radius: 12px;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.16);
  padding: 14px 16px 16px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}

.feedback-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

.feedback-head strong {
  font-family: var(--font-display);
  font-size: var(--text-sm);
  color: var(--navy);
}

.feedback-close {
  border: none;
  background: none;
  cursor: pointer;
  font-size: 25.5px;
  line-height: 1;
  color: var(--slate-400);
  padding: 4px 8px;
}

.feedback-close:hover { color: var(--navy); }

/* Spells out what rides along with the message. The student is reporting a bug
   about their own essay and should not have to guess what is attached. */
.feedback-context {
  margin: 0;
  font-size: var(--text-xs);
  color: var(--slate-500);
  line-height: var(--leading-snug);
}

.feedback-label {
  font-size: var(--text-xs);
  font-weight: var(--weight-medium);
  color: var(--slate-600);
}

.feedback-panel textarea {
  width: 100%;
  font-family: var(--font-body);
  font-size: var(--text-sm);
  line-height: var(--leading-body);
  padding: 9px 11px;
  border: 1px solid var(--slate-300);
  border-radius: 8px;
  resize: vertical;
}

.feedback-panel textarea:focus {
  outline: none;
  border-color: var(--navy);
}

.feedback-note {
  margin: 0;
  font-size: var(--text-xs);
  color: var(--slate-500);
}

/* A failed send has to read as a failure. If the student thinks it went through
   when it did not, they say nothing more and the bug stays invisible — which is
   the exact failure this whole feature exists to prevent. */
.feedback-note.is-error { color: var(--crimson); font-weight: var(--weight-medium); }

.feedback-actions { display: flex; justify-content: flex-end; }

/* On a phone the panel spans the screen, so the widget stops hugging the right
   edge and the fab keeps its thumb-reachable corner. */
@media (max-width: 480px) {
  .feedback-widget { right: 12px; left: 12px; bottom: 12px; align-items: flex-end; }
  .feedback-panel { width: 100%; }
}

/* Ảnh học viên vừa đính, trước khi gửi. */
.feedback-image {
  display: flex;
  align-items: flex-start;
  gap: 10px;
}

.feedback-image img {
  display: block;
  max-width: 100%;
  max-height: 120px;
  height: auto;
  border: 1px solid var(--slate-200);
  border-radius: 8px;
}

.feedback-image-clear {
  border: none;
  background: none;
  padding: 0;
  cursor: pointer;
  font-family: var(--font-body);
  font-size: var(--text-xs);
  color: var(--slate-500);
  text-decoration: underline;
}

.feedback-image-clear:hover { color: var(--crimson); }

/* Two buttons now, so they sit at opposite ends: attaching is a side path and
   should not crowd the one button that actually sends. */
.feedback-actions { justify-content: space-between; }

/* Ẩn với mắt nhưng vẫn nằm trong layout và vẫn nhận được sự kiện. Dùng cho
   <input type="file"> mà một nút khác bấm hộ: display:none từng khiến Safari
   không mở trình chọn ảnh khi .click() gọi vào nó. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.billing-gate-box { max-width: 520px; text-align: center; }
.billing-gate-box h2 { margin: 10px 0; color: var(--navy); font-family: var(--font-display); }
.billing-gate-box > p:not(.session-feedback-wordmark) { color: var(--slate-600); line-height: 1.6; }
.billing-gate-cta { display: flex; justify-content: center; text-decoration: none; margin: 22px 0 14px; }
.report-upgrade {
  margin-top: 18px;
  padding: 18px 20px;
  border: 1.5px solid var(--line-crisp);
  border-left: 5px solid var(--crimson);
  border-radius: var(--radius-card);
  background: #fff7d6;
}
.report-upgrade p { margin: 0 0 12px; }
.report-upgrade a { color: var(--crimson); font-weight: 750; text-decoration: underline; text-underline-offset: 3px; }

/* --- Post-report feedback card (static/index.html #sessionFeedbackModal) ---
   Deliberately does NOT share .discard-modal-box's plain white card: this
   dialog asks for something, it doesn't warn against something, so it wears
   the login screen's own signature instead — .login-card's ink border +
   hard offset shadow, .login-title's Archivo headline treatment, .login-btn's
   crimson CTA. Approved directly against a rendered mockup (see the chat
   history this shipped from) rather than drafted blind. */
/* overflow-y on BOTH: the modal scrolls when the box is taller than the
   viewport (short laptop screens, iPad in landscape), the box caps itself so
   it never runs past the edge. Without this the nine-question survey simply
   clips and the Send button becomes unreachable. */
.session-feedback-modal {
  position: fixed; inset: 0; overflow-y: auto; background: rgba(0, 12, 38, 0.55);
  backdrop-filter: blur(3px); -webkit-backdrop-filter: blur(3px);
  display: flex; align-items: center; justify-content: center;
  padding: 20px; z-index: 60;
}
.session-feedback-box {
  position: relative;
  max-height: calc(100dvh - 40px); overflow-y: auto; width: 100%; max-width: 400px;
  background: var(--surface-card); border: var(--border-hair) solid var(--line-crisp);
  border-radius: var(--radius-card);
  box-shadow: var(--shadow-lift);
  padding: 26px 30px;
}
.session-feedback-close {
  position: absolute; top: 14px; right: 18px; border: none; background: none;
  cursor: pointer; font-size: 23px; line-height: 1; color: var(--slate-400);
  padding: 4px 8px;
}
.session-feedback-close:hover { color: var(--navy); }

/* Live text now, not a 96px-wide drawn SVG. */
.session-feedback-wordmark {
  display: block; margin: 0 0 14px;
  font-family: var(--font-display);
  font-weight: var(--weight-display);
  font-size: 19.5px;
  line-height: 1.15;
  color: var(--ink-950);
}

/* The one line under the sign-up card's headline on /hoc-thu. It has one job:
   answer the question a student actually has at that moment — "do I lose what
   I just wrote?" — before they decide whether to close the card. */
.demo-auth-lead {
  margin: 0 0 18px;
  font-size: var(--text-base); line-height: var(--leading-body);
  color: var(--slate-600);
}

.session-feedback-headline {
  margin: 0 0 18px; font-family: var(--font-display); font-weight: var(--weight-black);
  font-size: 1.5rem; line-height: 1.15; letter-spacing: -0.02em; color: var(--navy);
}
.session-feedback-headline em {
  font-family: inherit; font-style: normal; font-weight: var(--weight-black);
  color: var(--crimson); font-size: 1em;
}

/* ---------- end-of-session survey (v1.4.35) ----------
   Replaced the single 5-star row. Two of the three scales here do not mean
   "quality" — "Rất mất thời gian → Rất hữu ích" is a direction, not a score —
   so stars read wrong on them, and three scales a student compares while
   answering have to look identical. Numbered buttons do both. */
.sf-survey { margin-bottom: 4px; }
.sf-section { padding-top: 18px; border-top: 1px solid var(--slate-200); margin-top: 18px; }
.sf-section:first-child { padding-top: 0; border-top: 0; margin-top: 0; }
.sf-section-title {
  margin: 0 0 14px;
  font-family: var(--font-mono); font-weight: 600;
  font-size: var(--text-xs); letter-spacing: var(--tracking-label);
  text-transform: uppercase; color: var(--crimson);
}
.sf-q {
  margin: 0 0 10px;
  font-size: var(--text-sm); line-height: var(--leading-snug);
  font-weight: var(--weight-medium); color: var(--navy);
}
.sf-section .sf-q:not(:first-of-type) { margin-top: 18px; }

.sf-scale { display: flex; gap: 6px; }
.sf-scale-btn {
  flex: 1; min-width: 0; padding: 10px 0; cursor: pointer;
  font-family: var(--font-display); font-weight: var(--weight-display);
  font-size: var(--text-base); color: var(--navy);
  background: #fff; border: 1.5px solid var(--slate-200); border-radius: 8px;
  transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.sf-scale-btn:hover { border-color: var(--crimson); }
.sf-scale-btn.is-picked {
  background: var(--crimson); border-color: var(--crimson); color: #fff;
}
/* The two anchors, pushed to the ends of the row they label. */
.sf-scale-anchors {
  display: flex; justify-content: space-between; gap: 12px;
  margin: 6px 0 0; font-size: var(--text-xs); color: var(--slate-400);
}
.sf-scale-anchors span:last-child { text-align: right; }

.sf-choice { display: flex; flex-direction: column; gap: 8px; }
.sf-choice-btn {
  text-align: left; padding: 11px 14px; cursor: pointer;
  font-family: var(--font-body); font-weight: var(--weight-medium);
  font-size: var(--text-sm); color: var(--navy);
  background: #fff; border: 1.5px solid var(--slate-200); border-radius: 10px;
  transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.sf-choice-btn:hover { border-color: var(--crimson); }
.sf-choice-btn.is-picked {
  background: var(--crimson); border-color: var(--crimson); color: #fff;
}

.sf-followup { margin-top: 14px; }
.sf-label {
  display: block; margin: 18px 0 6px;
  font-family: var(--font-mono); font-weight: 600;
  font-size: var(--text-xs); letter-spacing: var(--tracking-label);
  text-transform: uppercase; color: var(--slate-500);
}
.sf-followup .sf-label { margin-top: 0; }
.session-feedback-box textarea {
  width: 100%; box-sizing: border-box;
  font-family: var(--font-body); font-weight: 500; font-size: 1rem; line-height: var(--leading-body);
  padding: 12px 14px; border: 1.5px solid var(--slate-200); border-radius: 12px;
  resize: vertical; color: var(--navy); background: #fff;
}
.session-feedback-box textarea:focus {
  outline: none; border-color: var(--crimson);
  box-shadow: 0 0 0 3px rgba(227, 24, 55, 0.14);
}

.session-feedback-note { margin: 8px 0 0; font-size: var(--text-xs); color: var(--slate-500); }
.session-feedback-note.is-error { color: var(--crimson-dark); font-weight: var(--weight-medium); }

.session-feedback-send {
  margin-top: 16px; width: 100%; padding: 14px 18px; cursor: pointer;
  background: var(--crimson); color: #fff; border: none; border-radius: 12px;
  font-family: var(--font-display); font-size: var(--text-lg); font-weight: var(--weight-display);
  letter-spacing: 0.01em; transition: background 0.15s ease;
}
.session-feedback-send:hover:not(:disabled) { background: var(--crimson-dark); }
.session-feedback-send:disabled { opacity: 0.6; cursor: not-allowed; }

.session-feedback-skip {
  display: block; margin: 12px auto 0; border: none; background: none; cursor: pointer;
  font-family: var(--font-mono); font-weight: 600; font-size: var(--text-xs);
  letter-spacing: 0.06em; text-transform: uppercase; color: var(--slate-400); padding: 4px;
}
.session-feedback-skip:hover { color: var(--navy); }

/* Xác nhận đã cộng credit — opacity/translate fade giống các toast khác
   trong trang, nhưng đứng độc lập giữa màn hình, z-index CAO HƠN modal
   (70 > 60) để vẫn hiện được ngay khi modal vừa đóng. */
.session-feedback-toast {
  position: fixed; top: 20px; left: 50%; z-index: 70;
  transform: translateX(-50%) translateY(-8px);
  background: var(--emerald-600); color: #fff; font-size: var(--text-sm); font-weight: 700;
  padding: 10px 18px; border-radius: var(--radius-card); box-shadow: var(--shadow-lift);
  opacity: 0; transition: opacity 0.3s ease, transform 0.3s ease; pointer-events: none;
}
.session-feedback-toast.show { opacity: 1; transform: translateX(-50%) translateY(0); }

@media (max-width: 480px) {
  .session-feedback-box { padding: 22px 20px; }
}
