/* ExamPass.ai shared component kit.

   The one place a button, link, card, pill, dialog, form field, state message
   or toast is defined. Every page loads tokens.css then this file (including
   the five standalone pages and the generated subject/resource pages), and
   NO page or JS-injected <style> block redefines anything here — the
   2026-09-05 UI review counted eight cancel-button treatments, eleven error
   banners and ~20 pill recipes, all copies of what this file now owns.
   sidebar.css keeps only the sidebar and signed-in page layout.

   Rules of the kit:
   - Sizes come from the type scale in tokens.css (--fs-*), colours from the
     semantic tokens. No bare rem for text, no raw hex.
   - Anything tappable is 44px tall by default. A control that must LOOK
     smaller keeps its visual size and takes the invisible ::before hit-area
     (.btn-sm, .link-inline, .pill-btn), never a smaller box.
   - Every control declares BOTH color and background — a native control
     missing one of the pair is the dark-mode bug shape (CLAUDE.md §3).
   - Hover styles live under @media (hover: hover) so a tap on a phone does
     not leave a control stuck in its hover state.
   - Variants are self-sufficient: class="btn-primary" alone renders
     correctly, class="btn btn-primary" renders identically. */

/* ── Page canvas ─────────────────────────────────────────────────────────
   The box-sizing reset every page used to declare for itself. It is
   load-bearing for the kit: every min-height and hit-area below assumes
   border-box, and without it a 28px pill renders at 36px. */
*, *::before, *::after { box-sizing: border-box; }

/* One font stack and one background for every page. A page that needs the
   raised (white) or sunken canvas says so on <body> instead of redeclaring. */
/* Deliberately NO global line-height. The site has never set one, so every
   page renders at the UA's `normal` (~1.2); imposing 1.55 here inflated the
   sidebar links by 4px each and the settings page by 289px (measured
   2026-09-05). --lh-body is applied by the components that hold prose
   (states, modal body, notices, results copy) and by the reading-text rules
   in the pages themselves; --lh-tight by the compact controls. */
body {
  margin: 0;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
  font-size: var(--fs-body);
  color: var(--text);
  background: var(--surface);
  -webkit-text-size-adjust: 100%;
}

/* ── Buttons ─────────────────────────────────────────────────────────────
   One box, six fills. Verdict buttons (quiz "Got it / Missed", flashcard
   "Knew it / Didn't know / Skip") are variants of the same box so the three
   shapes the review found collapse to one. */
.btn,
.btn-primary, .btn-secondary, .btn-outline, .btn-danger, .btn-inverse,
.btn-verdict-yes, .btn-verdict-no, .btn-verdict-skip {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  position: relative;
  min-height: 44px;
  padding: 0.75rem 1.5rem;
  border: 1.5px solid transparent;
  border-radius: 8px;
  font-size: var(--fs-body);
  font-weight: 600;
  line-height: var(--lh-tight);
  text-decoration: none;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s, color 0.15s, filter 0.15s, transform 0.15s, box-shadow 0.15s;
  /* Labels wrap rather than overflow their row (the review's checkout footer
     pushed "Continue to checkout" off a 375px screen with nowrap). Layouts
     that need a single line stack the row instead — see .modal-actions. */
  white-space: normal;
  text-align: center;
}
.btn-primary { background: var(--fill-accent); color: var(--text-on-accent); }
.btn-secondary { background: var(--surface-raised); color: var(--text); border-color: var(--border); }
.btn-outline { background: var(--surface-raised); color: var(--text-accent); border-color: var(--text-accent); }
.btn-danger { background: var(--fill-danger); color: var(--text-on-accent); }
/* A button that sits ON an accent surface (the subject pages' blue CTA box):
   .btn-primary would vanish into the same blue, so this is the inverse pair —
   raised surface, accent text. Owner decision 2026-09-09 (dev-review MED-17). */
.btn-inverse { background: var(--surface-raised); color: var(--text-accent); border-color: var(--surface-raised); }
.btn-verdict-yes { background: var(--fill-success); color: var(--text-on-accent); font-weight: 700; }
.btn-verdict-no { background: var(--fill-danger); color: var(--text-on-accent); font-weight: 700; }
.btn-verdict-skip { background: var(--surface-raised); color: var(--text-secondary); border-color: var(--border); font-weight: 600; }
/* Grading buttons keep the 52px height both quiz and flashcards already used —
   they are tapped repeatedly and at speed, so they stay larger than 44px. */
.btn-verdict-yes, .btn-verdict-no, .btn-verdict-skip {
  min-height: 52px;
  /* Narrow sides on purpose: these sit three-up in a grid, so at 375px each
     button is ~108px wide and the standard 1.5rem sides left only 60px of
     content — "Didn't know" wrapped to two lines (measured 2026-09-05). */
  padding-left: 0.5rem;
  padding-right: 0.5rem;
}
/* Three-up at phone widths each button is ~108px, which fits "Didn't know" on
   one line at 14px but not at 16px (measured: needs 92.4px, has 89.9px). The
   grade buttons are 52px tall targets, so the smaller label costs nothing. */
@media (max-width: 479px) {
  .btn-verdict-yes, .btn-verdict-no, .btn-verdict-skip { font-size: var(--fs-small); }
}

/* Hover only where a pointer exists, so a tap never leaves a button stuck in
   its hover state. Filled buttons darken a clear step and lift; the tinted
   ones fill with the light accent. The old token-only hover was a few shades
   and read as no response at all (owner, 2026-09-05). */
@media (hover: hover) {
  .btn-primary:hover:not(:disabled), .btn-danger:hover:not(:disabled),
  .btn-verdict-yes:hover:not(:disabled), .btn-verdict-no:hover:not(:disabled) {
    filter: brightness(0.86);
    box-shadow: var(--shadow-soft-sm);
    transform: translateY(-1px);
  }
  .btn-secondary:hover:not(:disabled), .btn-outline:hover:not(:disabled), .btn-verdict-skip:hover:not(:disabled) {
    background: var(--accent-subtle);
    border-color: var(--text-accent);
  }
  /* Same lift as the raised-surface buttons, but the border stays the fill
     colour: an accent border on an accent box would read as a second edge. */
  .btn-inverse:hover:not(:disabled) {
    background: var(--accent-subtle);
    border-color: var(--accent-subtle);
  }
}
.btn:disabled, .btn[aria-disabled="true"],
.btn-primary:disabled, .btn-secondary:disabled, .btn-outline:disabled, .btn-danger:disabled,
.btn-verdict-yes:disabled, .btn-verdict-no:disabled, .btn-verdict-skip:disabled {
  background: var(--fill-disabled);
  color: var(--text-on-accent);
  border-color: transparent;
  cursor: not-allowed;
  filter: none;
}

/* Small visual size, full 44px tap target. The ::before extends the hit-box
   above and below without moving anything on screen. */
.btn-sm {
  min-height: 36px;
  padding: 0.45rem 1rem;
  font-size: var(--fs-small);
}
.btn-sm::before { content: ''; position: absolute; inset: -4px 0; }

/* A big marketing CTA (landing/subject-page hero, resource-page inline CTA) —
   the one place the standard box reads too small. Only size grows; weight and
   radius stay the base button's, same pattern as .btn-sm changing only size. */
.btn-lg {
  min-height: 52px;
  padding: 1rem 2rem;
  font-size: var(--fs-lead);
}

/* A compact inline action inside a dense row (a subject card's "practise all",
   a topic list's "show more") — visually smaller than the standard button but
   still 44px tall, same hit-area rule as .btn-sm. Kit change (dev-review
   2026-09-09): three pages each carried a near-identical hand-rolled compact
   button; .btn-sm's own 36px visual size measured too far from all three to
   reuse as-is. */
.btn-primary--compact {
  min-height: 44px;
  padding: 0.4rem 0.8rem;
  font-size: var(--fs-eyebrow);
  border-radius: 6px;
}

.btn-block { display: flex; width: 100%; }

/* Inline spinner for a button while its request is in flight. The button is
   disabled by the inFlight() guard; the spinner is what tells the student it
   was heard. */
@keyframes kit-spin { to { transform: rotate(360deg); } }
.btn-spinner {
  width: 1em; height: 1em;
  /* Two-tone (kit change, dev-review 2026-09-09): a faint currentColor ring
     with a solid arc reads on any button fill, filled or outline alike —
     the old solid-ring-minus-one-side version was close to invisible on a
     filled accent button (class-submit.html's .spinner carried its own
     copy of exactly this fix before the kit caught up). */
  border: 2px solid color-mix(in srgb, currentColor 40%, transparent);
  border-top-color: currentColor;
  border-radius: 50%;
  animation: kit-spin 0.7s linear infinite;
  flex-shrink: 0;
}

/* ── Text links ──────────────────────────────────────────────────────────
   Quiet at rest, underline on hover. Every inline link carries the 44px
   hit-area by default; the ::before sits outside normal flow so line
   spacing is untouched. `font: inherit` comes BEFORE font-weight so the
   weight is not reset (a stylelint finding of the review). */
.link-inline {
  position: relative;
  display: inline-block;
  color: var(--text-accent);
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
}
.link-inline::before { content: ''; position: absolute; inset: -12px -4px; }
.link-inline.link-quiet { color: var(--text-muted); }
.link-inline.link-danger { color: var(--text-danger); }
@media (hover: hover) {
  .link-inline:hover { color: var(--text-accent-hover); text-decoration: underline; text-underline-offset: 2px; }
  .link-inline.link-quiet:hover { color: var(--text); }
  .link-inline.link-danger:hover { color: var(--text-danger); }
}
/* Body-copy links (prose, lists, footers) — underlined at rest so they read
   as links inside text; the hit-area is the same. */
.link-text { position: relative; color: var(--text-accent); text-decoration: underline; text-underline-offset: 2px; }
.link-text::before { content: ''; position: absolute; inset: -12px -2px; }

/* Back link at the top of a secondary page. One definition for <a> and the
   teacher page's view-switching <button>. */
.link-back,
.page-header-back {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  min-height: 44px;
  color: var(--text-accent);
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  font-size: var(--fs-small);
  font-weight: 500;
  text-decoration: none;
  cursor: pointer;
}
.link-back svg,
.page-header-back svg { flex-shrink: 0; width: 16px; height: 16px; }
@media (hover: hover) {
  .link-back:hover, .page-header-back:hover { color: var(--text-accent-hover); }
}
/* The teacher page's back control is a <button> view-switch, not an <a>, so
   the same class drives both and needs the native-button reset. */
button.link-back, button.page-header-back { background: none; border: none; padding: 0; font: inherit; cursor: pointer; }

/* ── Cards ───────────────────────────────────────────────────────────────
   The raised surface every list item, panel and modal sits on. The accent
   stripe is the site's "this is a thing you own" mark (history cards,
   landing widget). */
.card {
  background: var(--surface-raised);
  border: 1px solid var(--border);
  border-radius: 12px;
  /* --shadow-card, not --shadow-card-soft: 46 of the 54 shadowed surfaces on
     the site already use it, so this is the existing look, not a new one. */
  box-shadow: var(--shadow-card);
  padding: 1.25rem;
}
.card--accent { border-left: 3px solid var(--text-accent); }
.card--flat { box-shadow: none; }
/* A card whose CHILDREN carry the padding, so their dividers reach the card
   edge — the settings page's row list is the shape this exists for. */
.card--rows { padding: 0; overflow: hidden; }
.card--interactive { cursor: pointer; transition: box-shadow 0.15s, border-color 0.15s; }
@media (hover: hover) {
  .card--interactive:hover { box-shadow: var(--shadow-card-hover); border-color: var(--border-accent); }
}
/* A single centred panel on an otherwise empty page — error.html and
   404.html's shared shape. Keeps the kit's radius/border/shadow; only the
   padding, centred text and a page-width cap are page-specific. */
.card--centred { padding: 48px 40px; text-align: center; max-width: 440px; width: 100%; }

/* ── Pills and badges ────────────────────────────────────────────────────
   A pill is a short status or category label. Non-interactive by default
   (28px). A pill that does something is a .pill-btn and gets the 44px
   hit-area. Tones follow the colour roles in tokens.css: accent for
   interactive/current, success/warning/danger for state, neutral for facts,
   paper for "this came from a marked paper" (Cluster 22). */
.pill {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  min-height: 28px;
  padding: 0.2rem 0.75rem;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--surface-sunken);
  color: var(--text-secondary);
  font-size: var(--fs-small);
  font-weight: 600;
  line-height: var(--lh-tight);
  white-space: nowrap;
}
.pill--accent { background: var(--accent-subtle); color: var(--text-accent); border-color: var(--border-accent); }
.pill--success { background: var(--bg-success); color: var(--text-success); border-color: var(--border-success); }
.pill--warning { background: var(--bg-warning); color: var(--text-warning); border-color: var(--border-warning); }
.pill--danger { background: var(--bg-danger); color: var(--text-danger); border-color: var(--border-danger); }
.pill--paper { background: var(--surface-raised); color: var(--text-secondary); border-style: dashed; }
/* Truncation: text-overflow only works on a block/inline-block box, not on
   a flex container's anonymous item, so the label is an inner span. */
.pill > .pill-label { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
/* REST state is neutral (kit change, dev-review 2026-09-09): a tappable pill
   that is not yet selected/hovered/pressed is not automatically "current",
   so it no longer pre-empts the accent tint. Two shapes share this box:
   an UNTONED pill-btn (a plain toggle/filter chip) tints to accent on
   hover/select/press below; a pill that also carries a .pill--* tone class
   (a permanently-tinted tappable chip — teacher.html's insights filters,
   dashboard/study-strengths' "practise this" topic chips, study-notes'
   ready/pending topic chips) keeps its tone at rest via the combined
   selectors further down, which win regardless of source order because they
   are more specific than this rule. */
.pill-btn {
  position: relative;
  min-height: 44px;
  padding: 0.45rem 0.9rem;
  cursor: pointer;
  background: var(--surface-raised);
  color: var(--text-secondary);
  border-color: var(--border);
  font: inherit;
  font-size: var(--fs-small);
  font-weight: 600;
}
.pill-btn[aria-pressed="true"], .pill-btn.is-active, .pill-btn.selected { background: var(--accent-subtle); color: var(--text-accent); border-color: var(--text-accent); }
/* A pressed pill already sits on the hover colour, so its hover has to be a
   further step or the pointer gets no response from a control that is still
   tappable (tapping un-presses it). */
@media (hover: hover) {
  .pill-btn:hover { background: var(--accent-subtle); color: var(--text-accent); border-color: var(--text-accent); }
  .pill-btn[aria-pressed="true"]:hover, .pill-btn.is-active:hover, .pill-btn.selected:hover { filter: brightness(0.94); }
}
/* A tone-carrying pill that is ALSO tappable (.pill--accent.pill-btn etc.) —
   the tone is a fact about the item (its category, its ready/pending state),
   not an interaction state, so it applies at rest and stays through
   hover/press rather than being replaced by the generic accent tint above. */
.pill-btn.pill--accent  { background: var(--accent-subtle); color: var(--text-accent); border-color: var(--border-accent); }
.pill-btn.pill--success { background: var(--bg-success); color: var(--text-success); border-color: var(--border-success); }
.pill-btn.pill--warning { background: var(--bg-warning); color: var(--text-warning); border-color: var(--border-warning); }
.pill-btn.pill--danger  { background: var(--bg-danger); color: var(--text-danger); border-color: var(--border-danger); }
@media (hover: hover) {
  .pill-btn.pill--accent:hover, .pill-btn.pill--success:hover, .pill-btn.pill--warning:hover, .pill-btn.pill--danger:hover { filter: brightness(0.94); }
}

/* ── Dialogs ─────────────────────────────────────────────────────────────
   The shell openDialog() in utils.js opens: overlay, box, title, close,
   body, actions. The overlay is display:none until .open so it costs
   nothing at rest. Actions stack on phones — three side-by-side buttons is
   how "Continue to checkout" fell off the screen. */
.modal-overlay {
  display: none;
  position: fixed;
  inset: 0;
  z-index: 300;
  align-items: center;
  justify-content: center;
  padding: 1rem;
  background: var(--scrim);
  overscroll-behavior: contain;
}
.modal-overlay.open { display: flex; }
/* No ring around the dialog ITSELF. openDialog() focuses the box (it carries
   tabindex="-1" so it can take focus at all), which tokens.css's global
   [tabindex]:focus-visible rule then outlines — a 2px ring around the whole
   modal, on mouse and keyboard alike, since the focus is programmatic either
   way. Keyed on the role rather than a class so it covers every wrapper
   openDialog accepts. Nothing else is suppressed: every control INSIDE the
   dialog keeps its own ring, which is what a keyboard user tabs through, and
   the dialog is still announced by role + aria-modal. */
[role="dialog"]:focus-visible { outline: none; }
.modal-box {
  position: relative;
  width: 100%;
  max-width: 440px;
  max-height: 90vh;
  overflow-y: auto;
  overscroll-behavior: contain;
  background: var(--surface-raised);
  color: var(--text);
  border-radius: 16px;
  box-shadow: var(--shadow-card);
  padding: 1.75rem;
}
.modal-title {
  font-size: var(--fs-lead);
  font-weight: 700;
  line-height: var(--lh-tight);
  margin: 0 2.5rem 1rem 0;
  color: var(--text);
}
.modal-close {
  position: absolute;
  top: 0.75rem;
  right: 0.75rem;
  width: 44px;
  height: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: none;
  border: none;
  border-radius: 8px;
  color: var(--text-muted);
  font-size: 1.25rem;
  cursor: pointer;
}
@media (hover: hover) { .modal-close:hover { background: var(--surface-sunken); color: var(--text); } }
.modal-body { font-size: var(--fs-body); line-height: var(--lh-body); color: var(--text); }
.modal-note { font-size: var(--fs-small); color: var(--text-muted); margin-top: 0.75rem; }
.modal-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 0.75rem;
  margin-top: 1.5rem;
}
@media (max-width: 767px) {
  .modal-actions { flex-direction: column-reverse; }
  .modal-actions > * { width: 100%; }
}

/* ── Form fields ─────────────────────────────────────────────────────────
   Label above field, 44px field, 1rem text (iOS zoom floor). The custom
   select trigger built by enhanceSelect() matches .form-input exactly. */
.form-group { margin-bottom: 1rem; }
.form-label {
  display: block;
  margin-bottom: 0.4rem;
  font-size: var(--fs-small);
  font-weight: 600;
  color: var(--text);
}
/* .hint is the same thing outside a form group (the planner's topic-coverage
   note, the reset page's footer line); both were undefined before the kit. */
.form-hint, .hint { margin-top: 0.35rem; font-size: var(--fs-small); color: var(--text-muted); line-height: var(--lh-body); }
/* Kit adopts the landing page's auth-modal hint link (kit migration pass 2,
   2026-09-09): accent-coloured, no underline — the link stands out from the
   surrounding hint text instead of reading as the same muted line. */
.form-hint a { color: var(--text-accent); text-decoration: none; }
/* Centred is the auth modal's own layout choice, not every .form-hint's —
   the planner's topic-coverage note and the reset page's footer line stay
   left-aligned. */
.form-hint--centred { text-align: center; }
.form-input, .form-select, .form-textarea {
  display: block;
  width: 100%;
  min-height: 44px;
  padding: 0.65rem 0.75rem;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--surface-raised);
  color: var(--text);
  font-size: var(--fs-body);
  line-height: var(--lh-tight);
}
.form-textarea { min-height: 120px; resize: vertical; line-height: var(--lh-body); }
.form-input::placeholder, .form-textarea::placeholder { color: var(--text-muted); }
.form-input[aria-invalid="true"], .form-textarea[aria-invalid="true"] { border-color: var(--fill-danger); }
.form-error { margin-top: 0.35rem; font-size: var(--fs-small); color: var(--text-danger); }

/* ── State messages ──────────────────────────────────────────────────────
   The four things a list or panel can be instead of content. Each is one
   block with an optional action; the loader owns which one is showing. */
.empty-state, .loading-state, .error-state, .success-state {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.75rem;
  text-align: center;
  padding: 2rem 1rem;
  border-radius: 12px;
  font-size: var(--fs-body);
  line-height: var(--lh-body);
}
/* Kit adopts notifications.html's look (kit migration pass 2, 2026-09-09):
   muted rather than secondary, and its paragraph carries weight. */
.empty-state { color: var(--text-muted); }
.empty-state p { font-weight: 500; }
.empty-state .state-title { font-weight: 700; color: var(--text); }
/* A whole-page empty state (notifications.html's two lists) earns roomier
   padding than an in-card one — the base 2rem 1rem stays the default for an
   empty state sitting inside a card or panel. */
.empty-state--page { padding: 4rem 2rem; }
.loading-state { color: var(--text-muted); }
.error-state {
  background: var(--bg-danger);
  border: 1px solid var(--border-danger);
  color: var(--text-danger);
  padding: 1rem;
}
.success-state {
  background: var(--bg-success);
  border: 1px solid var(--border-success);
  color: var(--text-success);
  padding: 1rem;
}
/* Inline variant — a one-line notice inside a form or card rather than a
   whole-panel state. */
.notice { display: flex; gap: 0.6rem; align-items: flex-start; padding: 0.75rem 1rem; border-radius: 8px; font-size: var(--fs-small); line-height: var(--lh-body); border: 1px solid var(--border); background: var(--surface-sunken); color: var(--text-secondary); }
.notice--warning { background: var(--bg-warning); border-color: var(--border-warning); color: var(--text-warning); }
.notice--danger { background: var(--bg-danger); border-color: var(--border-danger); color: var(--text-danger); }
.notice--success { background: var(--bg-success); border-color: var(--border-success); color: var(--text-success); }
/* A link inside a notice takes the notice's own text colour: the UA default
   blue has no contrast on the tinted backgrounds, and the weight plus
   underline is what marks it as a link on the same tint. */
.notice a { color: inherit; text-decoration: underline; font-weight: 600; }

.spinner {
  width: 28px; height: 28px;
  /* Two-tone (kit change, dev-review 2026-09-09), same reasoning as
     .btn-spinner above: a faint accent ring with a solid arc, not a
     neutral-bordered ring — class-submit.html's .spinner-dark carried this
     exact look as a page-local copy before the kit caught up. */
  border: 3px solid color-mix(in srgb, var(--text-accent) 20%, transparent);
  border-top-color: var(--text-accent);
  border-radius: 50%;
  animation: kit-spin 0.8s linear infinite;
}
.spinner--sm { width: 18px; height: 18px; border-width: 2px; }
.spinner--lg { width: 40px; height: 40px; }

/* Placeholder blocks while content loads. --skeleton-base/--skeleton-band
   (tokens.css) are their own pair, not --surface-sunken/--border: a skeleton
   has to read as "loading" at a glance, distinct from a merely sunken panel
   (kit change, dev-review 2026-09-09). */
@keyframes kit-shimmer { from { background-position: -200px 0; } to { background-position: 200px 0; } }
.skeleton {
  border-radius: 6px;
  background: linear-gradient(90deg, var(--skeleton-base) 25%, var(--skeleton-band) 50%, var(--skeleton-base) 75%);
  background-size: 400px 100%;
  animation: kit-shimmer 1.2s linear infinite;
  min-height: 1em;
}

/* ── Toast ───────────────────────────────────────────────────────────────
   Bottom-centre notice from showToast(). The element carries role="status"
   so assistive tech hears it. */
.app-toast {
  position: fixed;
  bottom: 1.5rem;
  left: 50%;
  transform: translateX(-50%);
  width: calc(100% - 2rem);
  max-width: 360px;
  z-index: 2000;
  padding: 0.875rem 1.25rem;
  border-radius: 8px;
  background: var(--surface-inverse);
  color: var(--text-on-accent);
  font-size: var(--fs-small);
  font-weight: 500;
  line-height: var(--lh-body);
  text-align: center;
  box-shadow: var(--shadow-toast);
  opacity: 1;
  transition: opacity 0.4s ease;
}
.app-toast.fade-out { opacity: 0; }
.app-toast--celebrate { display: flex; align-items: center; gap: 0.625rem; text-align: left; background: var(--fill-accent-hover); }
.app-toast--celebrate .app-toast-icon { flex-shrink: 0; width: 22px; height: 22px; }

/* ── Headings ────────────────────────────────────────────────────────────
   Three roles cover every page: an eyebrow label, the page title, its
   subtitle. Section headings inside a page use .eyebrow. */
.eyebrow {
  font-size: var(--fs-eyebrow);
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--text-accent);
}
.page-title {
  margin: 0;
  font-size: var(--fs-title);
  font-weight: 700;
  line-height: var(--lh-tight);
  color: var(--text);
  overflow-wrap: anywhere;
}
.page-subtitle { margin: 0.35rem 0 0; font-size: var(--fs-body); color: var(--text-secondary); }

/* ── Transcript box ──────────────────────────────────────────────────────
   What the system read back from a photographed answer. It is proof-read line
   by line, so it grows with its content instead of showing seven lines of a
   ten-page paper through a slot (paper-2). The cap keeps it from swallowing
   the page; JS sets the height from scrollHeight on load and on input. */
.extracted-textarea { max-height: 70vh; }

/* ── Progress ────────────────────────────────────────────────────────────
   One thin bar for "how far through": quiz set, flashcard batch, results
   score share. Width is set inline by JS (style="--progress: 40%"). */
.progress {
  position: relative;
  height: 6px;
  border-radius: 999px;
  background: var(--surface-sunken);
  border: 1px solid var(--border);
  overflow: hidden;
}
.progress-fill {
  height: 100%;
  width: var(--progress, 0%);
  border-radius: inherit;
  background: var(--fill-accent);
  transition: width 0.3s ease;
}
.progress--success .progress-fill { background: var(--fill-success); }
.progress--warm .progress-fill { background: var(--accent-warm); }
.progress-caption {
  display: flex;
  justify-content: space-between;
  margin-top: 0.35rem;
  font-size: var(--fs-small);
  color: var(--text-secondary);
}

/* ── Results ─────────────────────────────────────────────────────────────
   The shared "you finished" surface for a quiz set, a marked paper and a
   class submission (Cluster 20). The tier message leads; the number is
   secondary and neutral except on the top tier; the primary action is the
   next step, not "back to dashboard". Tier is set by one rule in JS
   (applyScoreTier: top ≥ 70%, mid ≥ 50%, low below) as data-tier. */
.results { display: grid; gap: 1.25rem; }
.results-head { display: grid; gap: 0.35rem; }
.results-heading { margin: 0; font-size: var(--fs-title); font-weight: 700; line-height: var(--lh-tight); color: var(--text); }
.results-message { margin: 0; font-size: var(--fs-body); color: var(--text-secondary); }
.results-score {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  font-size: var(--fs-body);
  color: var(--text-secondary);
}
.results-score-value { font-size: var(--fs-lead); font-weight: 700; color: var(--text); font-variant-numeric: tabular-nums; }
.results[data-tier="top"] .results-score-value { color: var(--text-success); }
.results-review { display: grid; gap: 0.75rem; }
.results-review-item { padding: 1rem; }
.results-review-item .review-q { margin: 0 0 0.5rem; font-weight: 600; color: var(--text); }
.results-review-item .review-a { margin: 0; color: var(--text-secondary); }
/* Primary spans the card; the secondary pair share a row at equal width and
   stack on phones. No text links in the button block — the page's own Back
   control at the top is the way out (owner, 2026-09-05). */
.results-actions { display: grid; gap: 0.6rem; }
.results-actions .btn-primary { width: 100%; }
.results-actions .results-actions-row { display: grid; grid-template-columns: 1fr 1fr; gap: 0.6rem; }
@media (max-width: 479px) {
  .results-actions .results-actions-row { grid-template-columns: 1fr; }
}

/* The practise link always takes its own line under the pills, left-aligned
   (owner, 2026-09-05) — predictable whatever the pill count. */
.topic-practise { display: inline-flex; align-items: center; gap: 0.3rem; font-size: var(--fs-body); white-space: nowrap; flex-basis: 100%; margin-top: 0.6rem; }

/* The two sources, inside a subject. A quiz-sourced topic can be practised on
   its own; a paper-sourced one carries a free-text topic that matches nothing
   in the quiz topic list, so it is shown as a fact and the group offers one
   subject-level action instead. */
.topic-source-group + .topic-source-group { margin-top: 1rem; padding-top: 0.85rem; border-top: 1px solid var(--border); }
.topic-source-head { display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.25rem 0.75rem; margin-bottom: 0.5rem; }
.topic-source-hint { font-size: var(--fs-small); color: var(--text-muted); }
.topic-chip-passive {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  min-height: 28px;
  padding: 0.2rem 0.75rem;
  border: 1px dashed var(--border);
  border-radius: 999px;
  background: var(--surface-raised);
  color: var(--text-secondary);
  font-size: var(--fs-small);
  font-weight: 500;
  max-width: 100%;
}
.topic-chip-passive .pill-label { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.topic-chip-passive .pill-icon { width: 14px; height: 14px; flex-shrink: 0; color: var(--text-muted); }

/* Results primary: ONE button, for the topic missed most often (ties → the
   earliest question); more than half the set missed → the whole subject
   instead. The other misses are NOT listed here — one plain line points at
   the dashboard's "Topics to revisit", which already owns that list (owner,
   2026-09-05). Both the "starting with" wording and that line disappear
   when only one topic was missed. */
.results-also { margin: 0; font-size: var(--fs-small); color: var(--text-muted); }

/* ── Celebration card ────────────────────────────────────────────────────
   The one moment on a page worth a little warmth: plan built, streak kept,
   deck finished (R3b). Tinted, with the success stripe; never red. */
.card--celebrate {
  border-left: 3px solid var(--fill-success);
  background: var(--accent-subtle);
  background-image: linear-gradient(to bottom, rgba(255,255,255,0.35) 0%, transparent 60%);
}
.card--celebrate .celebrate-title { display: flex; align-items: center; gap: 0.5rem; margin: 0 0 0.35rem; font-size: var(--fs-lead); font-weight: 700; color: var(--text); }
.card--celebrate .celebrate-icon { font-size: 1.4rem; line-height: 1; }
.card--celebrate p { margin: 0; color: var(--text-secondary); }


/* ── Hidden items ─────────────────────────────────────────────────────────
   The way back from the X on a history card (Issue 11). It is NOT a filter:
   the pages already carry three or four, and "hidden" is not a status the way
   Marked or Awaiting marking are (owner, 2026-09-06). The section is not
   rendered at all until this student has hidden something, and it opens
   collapsed every time. Rows are ordered by when they were hidden and show
   that date, because the row a student wants is the one they just removed —
   and the date shown has to be the date sorted by, or the column reads out of
   order. */
.hidden-section { margin-top: 2rem; border: 1px solid var(--border); border-radius: 10px; background: var(--surface); }
.hidden-toggle {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  width: 100%;
  min-height: 44px;
  padding: 0.75rem 1rem;
  background: none;
  border: none;
  font: inherit;
  font-weight: 600;
  color: var(--text-secondary);
  text-align: left;
  cursor: pointer;
}
.hidden-chevron { display: inline-block; transition: transform 0.15s ease; }
.hidden-section.open .hidden-chevron { transform: rotate(90deg); }
.hidden-body { display: none; padding: 0 1rem 1rem; }
.hidden-section.open .hidden-body { display: block; }
.hidden-note { margin: 0 0 0.75rem; font-size: var(--fs-small); color: var(--text-muted); }
.hidden-row { display: flex; align-items: center; gap: 0.75rem; padding: 0.6rem 0; border-top: 1px solid var(--border); }
.hidden-row-main { flex: 1; min-width: 0; }
.hidden-row-label { color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.hidden-row-date { font-size: var(--fs-small); color: var(--text-muted); }
.hidden-more { margin: 0.75rem 0 0; font-size: var(--fs-small); color: var(--text-muted); }

/* A full-page loading state wants a bigger spinner than an inline one. A
   MODIFIER, not a second .spinner: paper.html carried its own 40px copy that
   also named a `spin` keyframe the kit does not define. */
.spinner--lg { width: 40px; height: 40px; }

/* ── Utilities ───────────────────────────────────────────────────────────*/
/* Author `display` on the same element beats the UA `[hidden]` rule
   regardless of specificity, so a plain `hidden` attribute alone is not
   enough once any of our own rules set `display` — `!important` here wins. */
[hidden] { display: none !important; }
.hidden { display: none !important; }
.sr-only {
  position: absolute !important;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}
/* Keyboard users jump past the sidebar/nav to the page content. Visible
   only while focused. */
.skip-link {
  position: absolute;
  top: -100px;
  left: 1rem;
  z-index: 3000;
  padding: 0.6rem 1rem;
  border-radius: 8px;
  background: var(--fill-accent);
  color: var(--text-on-accent);
  font-weight: 600;
  text-decoration: none;
}
.skip-link:focus { top: 1rem; }

/* Reduced motion: spinners slow down (they still say "working"), and every
   decorative animation stops. The confetti is also never launched — quiz.js
   checks the same query before building it. */
@media (prefers-reduced-motion: reduce) {
  .btn-spinner, .spinner, .skeleton, .gen-spinner, .modal-spinner { animation-duration: 2s; }
  .btn, .btn-primary, .btn-secondary, .btn-outline, .btn-danger, .card--interactive, .app-toast { transition: none; }
  .history-card-wrap.is-generating::after, .confetti-piece, #summary-results.celebrate,
  .question-card.got-it, .is-pending .pill-dot, #page-loader-bar { animation: none; }
}

/* Print: the content only, on white. The sidebar, top bars, buttons and
   action rows are chrome; .print-footer is the one element that exists only
   on paper (position: fixed prints once per page in Chrome and Safari). */
.print-footer { display: none; }
@media print {
  :root, :root[data-theme="dark"] { --surface: #fff; --surface-raised: #fff; --surface-sunken: #f5f5f5; --text: #111; --text-secondary: #333; --text-muted: #555; --border: #ccc; }
  body { background: #fff; color: #111; }
  .sidebar, .sidebar-backdrop, .mobile-top-bar, .page-header-bar, .teacher-banner, .app-toast,
  .modal-overlay, .report-modal-overlay, .actions-row, .results-actions, .ms-pdf-actions, .cta-row,
  button, .btn, .btn-primary, .btn-secondary, .btn-outline, .btn-danger, .link-back { display: none !important; }
  .main, .layout { margin: 0; display: block; }
  .card, .q-card { box-shadow: none; break-inside: avoid; }
  .print-footer { display: block; position: fixed; bottom: 0; left: 0; right: 0; padding: 4mm 0 0; border-top: 1px solid #ccc; font-size: 9pt; color: #555; text-align: center; }
  body { padding-bottom: 14mm; }
}
