/* Chemlingo — components: buttons, cards, rings, tree, modal, lesson, exams */

/* ── Shared motion (used across pages — kept here, not in any one page's stylesheet) ───── */

@keyframes tile-in {
  from { opacity: 0; transform: translateY(10px) scale(0.96); }
  to { opacity: 1; transform: translateY(0) scale(1); }
}

/* The streak icon's idle flicker. Home and profile both style their stat pills as reward tiles and
   both animate this icon; a @keyframes can't be scoped by selector, so it lives here rather than
   being duplicated byte-for-byte in each page stylesheet. */
@keyframes flame-flicker {
  0%, 100% { transform: scale(1) rotate(0deg); }
  50% { transform: scale(1.08) rotate(-3deg); }
}

/* ── Page-opening entrances (site-wide) ───────────────────────────────
   Every page but home used to render its content with no arrival motion at all. These extend
   home's tile-in/nth-child stagger to the rest of the app's static card shells, plus a couple of
   JS-gated variants for pages whose content also rebuilds on routine interaction (exams' chips,
   lesson's blocks) — gated so the entrance only ever plays once, not on every filter click or
   "Mark complete". The sidebar is never included: it's identical chrome on every navigation. */

body[data-page="plans"] .pricing-card { animation: tile-in 420ms var(--ease) backwards; }
body[data-page="plans"] .pricing-card:nth-child(1) { animation-delay: 0ms; }
body[data-page="plans"] .pricing-card:nth-child(2) { animation-delay: 70ms; }
body[data-page="plans"] .pricing-card:nth-child(3) { animation-delay: 140ms; }

/* Profile's own entrance stagger (two independent column runs) lives in css/profile.css, next to
   the .profile-columns layout it choreographs — friends.html no longer exists as a separate page. */

/* The toolbar's masthead band (title, then count). Named for the two-column toolbar that replaced
   `.exam-toolbar-head`, and kept through the stacked masthead that replaced *that*; the stagger has
   moved with it each time rather than being dropped. */
body[data-page="exams"] .toolbar-lede > * { animation: tile-in 320ms var(--ease) backwards; }
body[data-page="exams"] .toolbar-lede > *:nth-child(1) { animation-delay: 0ms; }
body[data-page="exams"] .toolbar-lede > *:nth-child(2) { animation-delay: 60ms; }

/* exams.js tags chip buttons .is-entrance only on the very first render() call (page load) —
   never on the re-renders a filter click or search keystroke triggers — and sets --stagger-i per
   chip since the years/rounds rows are data-driven, not a fixed count. */
.chip.is-entrance {
  animation: tile-in 260ms var(--ease) backwards;
  animation-delay: min(calc(var(--stagger-i, 0) * 40ms), 240ms);
}

/* lesson.js threads an isEntrance flag through render()/renderRail()/renderBody() so only the
   nodes built by the very first render() (page load) carry these wrapper classes — the "Mark
   complete" re-render never does, regardless of ancestor state. */
.blocks-grid.is-entrance > * { animation: tile-in 360ms var(--ease) backwards; }
.blocks-grid.is-entrance > *:nth-child(1) { animation-delay: 0ms; }
.blocks-grid.is-entrance > *:nth-child(2) { animation-delay: 70ms; }
.blocks-grid.is-entrance > *:nth-child(3) { animation-delay: 140ms; }
.blocks-grid.is-entrance > *:nth-child(4) { animation-delay: 210ms; }

.lesson-list.is-entrance > .lesson-row { animation: tile-in 300ms var(--ease) backwards; }
.lesson-list.is-entrance > .lesson-row:nth-child(1) { animation-delay: 0ms; }
.lesson-list.is-entrance > .lesson-row:nth-child(2) { animation-delay: 40ms; }
.lesson-list.is-entrance > .lesson-row:nth-child(3) { animation-delay: 80ms; }
.lesson-list.is-entrance > .lesson-row:nth-child(4) { animation-delay: 120ms; }
.lesson-list.is-entrance > .lesson-row:nth-child(5) { animation-delay: 160ms; }

/* ── Icons ─────────────────────────────────────────────────────────────
   One primitive for every UI icon in the app. Replaces the Unicode characters that used to stand
   in for icons in three places — the sidebar's ⌂ ▤ ✦ ◑ ⏻, the five field glyphs (⌬ ∫ ⚖ ◈ ⬡) and
   the stat tiles' 🔥 📘 🏆 — none of which were icons: they rendered in whatever the font happened
   to have, at whatever weight it happened to draw, and 🔥/📘/🏆 arrived as full-colour emoji that
   ignored every colour token around them. ⌬ was worse than inconsistent: it is the brand mark, so
   Organic Chemistry was labelled with the app's own logo, against the Chrome-vs-Content rule.

   Drawn with `mask` rather than an image element, and that is the load-bearing choice. The source
   files carry hardcoded fills (#000000, and sent.svg ships #7c3aed), so an image would render those
   colours verbatim and no colour token could reach it — an active nav item could not take
   --brand-text and
   a field tile could not take --accent-text. A mask uses only the source's alpha, so the artwork
   becomes a stencil and `background-color: currentColor` fills it. Every tile that hosts an icon
   already sets `color` correctly, so each icon inherits the right token for free and the
   Chrome-vs-Content rule holds with no per-icon colour rules at all.

   Sized in `em` so the icon scales with whatever tile it lands in — the existing font-size on
   .sidebar-item-icon / .field-glyph / .stat-pill-icon keeps working as the size control it always
   was, and no call site needed a new dimension. */
.ui-icon {
  flex: none;
  display: inline-block;
  width: 1em;
  height: 1em;
  background-color: currentColor;
  /* The fallback is a fully transparent gradient, not `none`. An unregistered or misspelled
     `data-icon` leaves --ui-icon undefined; with no fallback the shorthand is invalid and the
     element paints as a solid currentColor square, and with `none` it does the same thing because
     an unmasked box is a filled box. A transparent mask masks everything away, so the failure mode
     of a typo is an invisible gap — which reads as "missing icon" — rather than a black block. */
  -webkit-mask: var(--ui-icon, linear-gradient(#0000, #0000)) center / contain no-repeat;
          mask: var(--ui-icon, linear-gradient(#0000, #0000)) center / contain no-repeat;
}

/* The registry. Field icons are keyed on the field id, deliberately mirroring how --accent is
   keyed on [data-field] in theme.css rather than carried in data/fields.json: which picture names
   a field is presentation, exactly as which colour does. (Topic icons are different and do live in
   the data — those are per-topic chemistry artwork, i.e. content.) A sixth field needs a line here
   and a line in theme.css, the same pair the five existing fields already need. */
[data-icon="fields"]       { --ui-icon: url("../assets/icons/fields.svg"); }
[data-icon="exams"]        { --ui-icon: url("../assets/icons/exams.svg"); }
[data-icon="plans"]        { --ui-icon: url("../assets/icons/plans.svg"); }
[data-icon="profile"]      { --ui-icon: url("../assets/icons/profile.svg"); }
[data-icon="signout"]      { --ui-icon: url("../assets/icons/signout.svg"); }
[data-icon="search"]       { --ui-icon: url("../assets/icons/search.svg"); }
[data-icon="groups"]       { --ui-icon: url("../assets/icons/groups.svg"); }
[data-icon="sent"]         { --ui-icon: url("../assets/icons/sent.svg"); }
[data-icon="receive"]      { --ui-icon: url("../assets/icons/receive.svg"); }
[data-icon="streaks"]      { --ui-icon: url("../assets/icons/streaks.svg"); }
[data-icon="longest"]      { --ui-icon: url("../assets/icons/longest_streak.svg"); }
[data-icon="completed"]    { --ui-icon: url("../assets/icons/lesson_completed.svg"); }
[data-icon="organic"]      { --ui-icon: url("../assets/icons/organic.svg"); }
[data-icon="physical"]     { --ui-icon: url("../assets/icons/physical.svg"); }
[data-icon="analytical"]   { --ui-icon: url("../assets/icons/analytical.svg"); }
[data-icon="inorganic"]    { --ui-icon: url("../assets/icons/inorganic.svg"); }
[data-icon="biochemistry"] { --ui-icon: url("../assets/icons/biochem.svg"); }

/* Icons that sit inline in a run of text rather than centred in a tile of their own. Both set an
   explicit font-size because .ui-icon is sized in `em`: inheriting an 11px uppercase label would
   draw an 11px icon, which is below the size these shapes stay legible at. The negative
   vertical-align pulls the box onto the text's optical centre — an inline-block otherwise sits on
   the baseline, leaving the icon riding high against a capital-height uppercase label. */
.section-label-icon {
  font-size: 13px;
  vertical-align: -0.16em;
  margin-right: 6px;
  opacity: 0.85;
}
.widget-title-icon {
  font-size: 15px;
  vertical-align: -0.14em;
  margin-right: 7px;
  color: var(--brand-text);
}

/* ── Mascot (the ⌬ brand mark, given a face) ─────────────────────────
   Shared across any page that wants the signature illustrated device. Five sites, deliberately:
   at hero scale the home hero, the sign-in card and the end of the skill path (.path-end below), plus two
   small earned moments drawn as data-URI backgrounds rather than live markup — home's empty state
   (home.css) and the completion pop at .node.is-just-completed::after. Not stamped everywhere,
   but not a one-off partial either. */
.mascot {
  width: 56px;
  height: 56px;
  flex: none;
  animation: mascot-bob 3.2s var(--ease) infinite;
}
.mascot-shell {
  fill: var(--brand);
  stroke: var(--brand-ridge);
  stroke-width: 3;
  stroke-linejoin: round;
}
.mascot-eye { fill: var(--brand-ink); }
.mascot-mouth { stroke: var(--brand-ink); stroke-width: 2.6; }
.mascot-cheek { fill: var(--brand-ridge); opacity: 0.28; }

@keyframes mascot-bob {
  0%, 100% { transform: translateY(0) rotate(-2deg); }
  50% { transform: translateY(-6px) rotate(2deg); }
}

/* ── Buttons ───────────────────────────────────────────────────────── */

.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--s2);
  padding: 9px 16px;
  border-radius: var(--radius);
  border: 2px solid var(--border-strong);
  background: var(--raised);
  color: var(--text);
  font-size: 14px;
  font-weight: 500;
  white-space: nowrap;
  transition: background var(--dur) var(--ease), border-color var(--dur) var(--ease),
              color var(--dur) var(--ease), transform var(--dur) var(--ease),
              box-shadow var(--dur) var(--ease);
}
/* text-decoration: none because `.btn` is worn by anchors as well as buttons (the profile's Change
   password, plans' "Start learning", sign-in's Google button), and theme.css underlines any `a` on
   hover — which on a control that already lifts and tints reads as a link pretending to be a
   button. Buttons proper are unaffected. */
.btn:hover {
  background: var(--bg);
  border-color: var(--faint);
  transform: translateY(-1px);
  text-decoration: none;
}
.btn:active { transform: translateY(1px); }
.btn:disabled { opacity: 0.4; cursor: not-allowed; transform: none; }

/* Chunky "pressed" CTA: a solid ridge along the bottom edge stands in for depth, and collapses
   into the button on press — a plain colour swap read as flat next to the rest of the playful
   sidebar/badge language, so primary actions get the one properly physical control in the UI.
   Brand, not accent: a button is app chrome and must not change colour with the field you happen
   to be standing in. */
.btn-primary {
  background: var(--brand);
  border: 2px solid var(--brand);
  color: var(--brand-ink);
  font-weight: 700;
  padding-block: 10px;
  box-shadow: 0 4px 0 var(--brand-ridge);
  transform: translateY(-2px);
}
.btn-primary:hover {
  background: color-mix(in srgb, var(--brand) 88%, var(--brand-ridge));
  border-color: color-mix(in srgb, var(--brand) 88%, var(--brand-ridge));
}
.btn-primary:active {
  transform: translateY(0);
  box-shadow: 0 0 0 var(--brand-ridge);
}
.btn-primary:disabled {
  transform: translateY(-2px);
  box-shadow: 0 4px 0 var(--brand-ridge);
}

.btn-ghost { background: transparent; border-color: transparent; color: var(--muted); }
.btn-ghost:hover { background: var(--brand-faint); color: var(--brand-text); border-color: transparent; }

.btn-sm { padding: 5px 11px; font-size: 13px; }

/* A button whose whole face is its icon. Square with zero padding rather than `.btn-sm` with the
   label removed: `.btn-sm`'s 5px/11px is asymmetric on purpose for text, so dropping the label
   leaves the icon sitting left of the box's own centre. `.btn` already centres its content, so a
   fixed 30px box is the entire rule. The accessible name lives in `aria-label` at the call site —
   an icon-only control with no name is a control a screen reader cannot announce. */
.icon-btn {
  width: 30px;
  height: 30px;
  padding: 0;
  gap: 0;
}

/* The UI icon system: 16px, one stroke weight, round caps and joins, drawn in `currentColor` so a
   button's hover/disabled colours carry the icon with them rather than needing their own rules.
   `max-width: none` because the global `svg { max-width: 100% }` otherwise resolves against a
   parent that has no width of its own here and collapses the box. */
.btn-icon {
  width: 16px;
  height: 16px;
  max-width: none;
  flex: none;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* A small primary is not a scaled-down large one. `.btn-primary`'s `border` is the same colour as
   its own face, so the only weight the shape carries is the bottom ridge — at `btn-sm`'s 6px/11px
   that left the top and sides reading as a thin plate with a lip, with a 13px 700-weight label
   crowding all three. The answer is mass rather than a new edge treatment (a ridge-coloured keyline
   and an inset top highlight were both tried against it): 8px/16px gives the face enough ground that
   the edges stop being the thing you notice, and `--radius-lg` keeps the corners in proportion to
   the larger box. `padding`, not `padding-block`, so it beats `.btn-sm`'s inline value too. */
.btn-sm.btn-primary {
  padding: 8px 16px;
  border-radius: var(--radius-lg);
  box-shadow: 0 3px 0 var(--brand-ridge);
  transform: translateY(-1.5px);
}
.btn-sm.btn-primary:active { transform: translateY(0); box-shadow: 0 0 0 var(--brand-ridge); }
/* Tinted at rest, not only on hover: "Reset all" sits next to "Export" on the profile page and,
   for a signed-in user, wipes progress on every device — it should not be indistinguishable from
   its harmless neighbours until the pointer happens to land on it. */
.btn-danger { color: color-mix(in srgb, var(--danger) 78%, var(--text)); }
.btn-danger:hover { color: var(--danger); border-color: color-mix(in srgb, var(--danger) 45%, transparent); }

.btn-block { width: 100%; }

/* ── Cards ─────────────────────────────────────────────────────────── */

.card {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-lg);
  padding: var(--s5);
}

.field-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
  gap: var(--s4);
}

/* Three fixed tiers, not an open-ended collection like .field-grid — auto-fill would let a card
   go full-width alone on a mid-size viewport instead of stacking cleanly, so this is a plain
   3-column grid with its own stack breakpoint, matching .home-layout's 900px point. */
.pricing-grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: var(--s4);
}
@media (max-width: 900px) {
  .pricing-grid { grid-template-columns: 1fr; }
}
.pricing-card {
  display: grid;
  align-content: start;
  gap: var(--s4);
  border-width: 2px;
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-tier);
}
.pricing-price { font-size: 28px; }
.pricing-price-period { font-size: 14px; }
.pricing-features { padding-left: 1.1em; list-style: disc; display: grid; gap: 6px; }

/* ── Home page layout ─────────────────────────────────────────────── */

/* A single vertical column — "up next" leads the page uncontested by a competing rail, and the
   900px breakpoint used to collapse a two-column .home-layout into exactly this, so one column
   at every width removes a special case rather than adding one. */
.home-layout {
  display: grid;
  gap: var(--s6);
}

/* The more prominent card tier (profile sections, pricing tiers, the auth card) — a resting
   ink-hue shadow, not just a border, so these read as sitting slightly off the page rather than
   flat. Plain .card stays flat: dense list-adjacent contexts (friend rows, chat panes) would turn
   into shadow soup if every card carried one. */
.widget-card {
  border-width: 2px;
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-tier);
  display: grid;
  gap: var(--s3);
}
.widget-title { font-size: 14.5px; font-weight: 700; letter-spacing: -0.01em; }
/* Card heading with a trailing action button. */
.widget-head { display: flex; align-items: center; justify-content: space-between; gap: var(--s3); }
.widget-body { color: var(--muted); font-size: 13.5px; line-height: 1.55; }

/* ── Status row (streak + overall progress, folded into one line) ── */

/* The streak's flame keeps its own warm-orange tile rather than the neutral accent .stat-pill-icon
   uses elsewhere — streak is the one stat this app gives Duolingo-style personality to. Sized to
   match .stat-pill-icon (38px) so it drops into a plain .stat-pill instead of a bigger standalone
   widget layout. */
.stat-pill-icon.is-flame {
  background: color-mix(in srgb, var(--flame) 12%, transparent);
  color: var(--flame);
  font-size: 18px;
}

/* ── Up next (promoted lead entry) ────────────────────────────────── */

/* Reuses .field-card's accent styling (border, glyph, tokens) as a compact horizontal row rather
   than inventing a second colour system, the same precedent .profile-field follows. A
   resting accent-coloured border (not just on hover) is what reads as *live*, distinct from the
   muted past-tense rows in the timeline underneath it. */
.timeline-next.field-card {
  flex-direction: row;
  align-items: center;
  gap: var(--s4);
  padding: var(--s4);
  border-color: var(--accent-dim);
}
.timeline-next .field-glyph { width: 48px; height: 48px; font-size: 21px; }
.timeline-next-topic {
  font-size: 12.5px;
  color: var(--faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.timeline-next-title {
  font-size: 16px;
  font-weight: 700;
  letter-spacing: -0.015em;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.timeline-next-cta {
  margin-left: auto;
  flex: none;
  font-size: 12.5px;
  font-weight: 700;
  color: var(--accent-text);
}

/* ── Timeline (recent activity log) ───────────────────────────────── */

.timeline { display: grid; gap: 2px; }
/* .timeline-row and .lesson-row share this body byte-for-byte — a single "labeled navigable row"
   shape used for two different lists, so it lives once as .list-row and each keeps its own class
   only for its distinct children (the glyph tile here, the completion tick on .lesson-row). */
.list-row {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: 9px var(--s3);
  border-radius: var(--radius-sm);
  font-size: 13.5px;
  text-align: left;
  width: 100%;
  color: var(--text);
  transition: background var(--dur) var(--ease);
}
a.list-row:hover { background: var(--raised); text-decoration: none; }
.timeline-row-glyph {
  flex: none;
  width: 28px; height: 28px;
  display: grid; place-items: center;
  border-radius: var(--radius-sm);
  background: var(--accent-faint);
  border: 1.5px solid var(--accent-dim);
  color: var(--accent-text);
  font-size: 13px;
  line-height: 1;
}
.timeline-row-title {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.timeline-row-field { color: var(--faint); font-weight: 400; }
.timeline-row-time { flex: none; font-size: 11.5px; color: var(--faint); }

.field-card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  text-align: left;
  overflow: hidden;
  color: var(--text);
  /* A real border, not a hover-only tint — chunky outlines are the through-line of the whole
     Duolingo-flavoured language (buttons, sidebar, badges), and a card that only gains one on
     hover reads as flat until touched. */
  border-width: 2px;
  border-color: var(--border);
  transition: border-color var(--dur) var(--ease), background var(--dur) var(--ease),
              transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}
.field-card::after {
  content: "";
  position: absolute;
  inset: -40% -30% auto auto;
  width: 180px; height: 180px;
  background: radial-gradient(circle, var(--accent-faint), transparent 68%);
  pointer-events: none;
  opacity: 0;
  transition: opacity var(--dur) var(--ease);
}
.field-card:hover {
  border-color: var(--accent-dim);
  background: var(--raised);
  transform: translateY(-3px);
  box-shadow: 0 10px 24px -8px color-mix(in srgb, var(--accent) 30%, transparent);
  text-decoration: none;
}
.field-card:hover::after { opacity: 1; }

.field-card-head { display: flex; align-items: flex-start; gap: var(--s3); }
.field-glyph {
  flex: none;
  width: 44px; height: 44px;
  display: grid;
  place-items: center;
  border-radius: 13px;
  background: var(--accent-faint);
  border: 2px solid var(--accent-dim);
  color: var(--accent-text);
  font-size: 20px;
  line-height: 1;
}
.field-name { font-size: 17px; font-weight: 700; letter-spacing: -0.015em; }
.field-meta {
  display: flex;
  align-items: center;
  gap: var(--s2);
  font-size: 12.5px;
  color: var(--faint);
  margin-top: auto;
}
.field-meta > span:first-child {
  font-weight: 700;
  color: var(--accent-text);
}

/* ── Progress bar & ring ───────────────────────────────────────────── */

.bar {
  height: 8px;
  border-radius: var(--radius-pill);
  background: var(--border);
  overflow: hidden;
}
.bar > i {
  display: block;
  height: 100%;
  width: 0;
  border-radius: inherit;
  background: var(--accent);
  transition: width 320ms var(--ease);
}

.ring { flex: none; transform: rotate(-90deg); }
.ring circle { fill: none; stroke-width: 3; }
.ring .ring-track { stroke: var(--border); }
.ring .ring-fill {
  stroke: var(--accent);
  stroke-linecap: round;
  transition: stroke-dashoffset 380ms var(--ease);
}

/* A ring with something centred inside it. Used by the field cards on the home page and the
   lesson rail; the skill tree has its own badge treatment and does not go through this. */
.node-ring { position: relative; flex: none; }
.node-ring .ring { position: absolute; inset: 0; width: 100%; height: 100%; }
.node-ring-label {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  font-size: 15px;
  font-weight: 600;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}

/* ── Stat strip ────────────────────────────────────────────────────── */

.stats {
  display: flex;
  flex-wrap: wrap;
  gap: var(--s3);
  align-items: stretch;
}
.stat { display: flex; flex-direction: column; gap: 2px; }
.stat-value { font-size: 20px; font-weight: 600; letter-spacing: -0.02em; font-variant-numeric: tabular-nums; }
.stat-label { font-size: 11px; letter-spacing: 0.1em; text-transform: uppercase; color: var(--faint); }

/* A stat with a bold colour-tiled icon standing in front of it — the pill format Duolingo uses
   for streak/gems/hearts, adapted to real progress numbers instead. */
.stat-pill {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s3) var(--s4);
  border-radius: var(--radius-lg);
  border: 2px solid var(--border);
  background: var(--surface);
}
.stat-pill-icon {
  flex: none;
  width: 38px;
  height: 38px;
  display: grid;
  place-items: center;
  border-radius: 11px;
  font-size: 19px;
  line-height: 1;
  background: var(--accent-faint);
  color: var(--accent-text);
}

/* ── Status pills ──────────────────────────────────────────────────── */

.pill {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 3px 10px;
  border-radius: var(--radius-pill);
  border: 1px solid var(--border-strong);
  font-size: 11.5px;
  font-weight: 500;
  color: var(--muted);
  white-space: nowrap;
}
.pill[data-status="completed"] {
  color: var(--accent-text);
  border-color: var(--accent-dim);
  background: var(--accent-faint);
}
.pill[data-status="in-progress"] { color: var(--text); border-color: var(--border-strong); }
.pill[data-status="locked"] { color: var(--faint); border-style: dashed; }

/* Mock-exam score pill (js/pages/exams.js) — reuses .pill as a button; data-medal is absent for
   the pre-score "Enter score" state, which just gets the plain .pill look above. */
button.pill { cursor: pointer; }
.pill[data-medal="gold"] {
  color: var(--gold);
  border-color: color-mix(in srgb, var(--gold) 45%, transparent);
  background: color-mix(in srgb, var(--gold) 12%, transparent);
}
.pill[data-medal="silver"] {
  color: var(--silver);
  border-color: color-mix(in srgb, var(--silver) 45%, transparent);
  background: color-mix(in srgb, var(--silver) 12%, transparent);
}
.pill[data-medal="bronze"] {
  color: var(--bronze);
  border-color: color-mix(in srgb, var(--bronze) 45%, transparent);
  background: color-mix(in srgb, var(--bronze) 12%, transparent);
}

/* ── Backbone path ─────────────────────────────────────────────────── */

/* The skill tree is a chain: one winding column of topics read top to bottom, drawn as a
   skeletal-formula zig-zag with a badge at each vertex.

   It replaced an atom — a nucleus with one elliptical shell per level, fitted by a camera — and the
   single most important consequence is visible right here in the numbers. Every length in the old
   tree was a *world* unit that a ~0.36 fit then shrank: the badge was 128px to render at 46, the
   monogram 40px to render at 14, the title 24px to render at 8.6, and a `LABEL_FLOOR_ZOOM` existed
   purely to hide titles once the fit got worse than that. There is no camera now. These are real
   pixels, at the size they are read at, and the phone case is one media query rather than a
   width-bound fit at zoom 0.15.

   The page scrolls normally, so this element is plain in-flow content: no `position: fixed`, no
   `overflow: hidden`, no stacking-context games, and no `left: var(--sidebar-w)` — theme.css's
   `body { padding-left: var(--sidebar-w) }` covers field.html now like every other page. The old
   canvas had to be `z-index: 0` and never negative, because a negative z-index put it behind the
   body for *hit testing* and produced a tree that rendered perfectly and could not be clicked
   anywhere. That whole hazard leaves with the layer that had it. */
.path-scroll {
  position: relative;

  /* Path-local palette. A badge you can act on is a *filled* disc and a locked one is an outline:
     the filled/hollow contrast is what carries reachability, so on a light page the fill has to be
     the dark end. Ink is near-white on that fill; locked badges take the page itself, opaque rather
     than transparent, so a bond behind them reads as passing behind, not through. */
  --disc: #2c2445;
  --disc-ink: #f7f5fd;
  --disc-locked: var(--bg);
}

/* Atmosphere: a wash of the field's own accent behind the head of the chain, fading out as you
   descend. Top-anchored rather than centred — the old wash was centred because the atom was
   symmetric about its nucleus, and a chain has a top instead of a middle. Purely a backdrop; it
   must never intercept a pointer meant for a badge. */
.path-scroll::before {
  content: "";
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    radial-gradient(70% 40% at 50% 0%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 70%),
    linear-gradient(180deg, var(--surface), var(--bg) 40%);
}

/* The page. Two numbers govern every row and they live together here so the route can be
   re-proportioned in one place — and so the ≤640px override below is a few lines rather than a
   second layout.

   The padding is how fixed chrome gets cleared, read straight from the custom properties the chrome
   itself is sized by. js used to read these with `getComputedStyle` and a `n > 0` guard that
   rejected `--header-h: 0px` — the desktop case, where there is no top bar — and mis-fitted every
   window above 640px by 60px for months. CSS cannot make that mistake. */
.path {
  --badge: 88px;
  --node-gap: 14px;
  /* Room at each end of a row for a title wider than the badge it captions. Applied identically to
     `.path-row-track` and `.row-arrow`, so the two boxes the connector-to-badge alignment depends
     on stay inset by the same amount and keep sharing a centre — see `.row-arrow` below. */
  --row-edge-pad: 18px;

  position: relative;
  width: min(1050px, 100%);
  margin-inline: auto;
  /* Clears the mobile top bar and nothing else — the 104px here used to be reserved for the
     floating field bubble, which no longer exists. */
  padding-top: calc(var(--header-h) + var(--s6));
  padding-bottom: calc(var(--footer-h) + var(--s7));
  padding-inline: var(--s4);
}

/* One authored entrance, on first load only (js/path.js removes the class after it plays). */
@keyframes path-in {
  from { opacity: 0; transform: translateY(10px); }
  to { opacity: 1; transform: translateY(0); }
}
/* --ease, not --ease-bounce: this is an arrival, not a celebration. */
.path.is-entering { animation: path-in 520ms var(--ease); }

/* ── Arrows ────────────────────────────────────────────────────────── */

/* Reaction arrows, ordinary in-flow markup rather than measured SVG. A row's badges and arrows never
   move once laid out — a topic's detail panel expands in a shared strip at the *foot* of the row
   (see `.path-row`), never between two badges — so a fixed-length arrow can never detach from the
   badge it is meant to reach, and js/path.js never has to read a box to draw one. `redraw()` still
   touches every arrow, but only to flip `data-state`.

   Weight follows progress, the same register a synthetic route already uses on paper: a
   transformation you've finished is drawn solid, the rest is the sketch of one you haven't done
   yet. */
/* The arrows, not the badges, absorb whatever width a row has left over. A fixed-width arrow made
   the row's total width a function of its topic count, so the widest row overflowed and scrolled
   while narrower rows had slack to spare; letting each arrow flex means every row
   fits its track at the badge's designed size, and only a genuinely narrow viewport — where the
   arrows have shrunk to `min-width` — falls back to the row's own `overflow-x`.
   `flex-basis: 0` with an explicit `min-width` (not `auto`) is what lets an arrow shrink past the
   min-content width of its own reagent label. */
.path-arrow {
  display: flex;
  flex: 1 1 0;
  min-width: 44px;
  flex-direction: column;
  align-items: center;
  gap: 2px;
  /* No inline padding: the track's own `--node-gap` already separates an arrow from the badges on
     either side, and 2px more only shortened the shaft. */
  padding-inline: 0;
  color: var(--accent-dim);
}

/* The row connector needs a *real* horizontal anchor, not the vertical one `.path-arrow` uses —
   `.row-arrow` is `flex-direction: row` so `justify-content` genuinely means "which edge", and its
   one child (`.row-arrow-anchor`) is fixed at exactly `--badge` wide. `.path-row-track`'s
   `justify-content: space-between` (below) pins the row's first and last badge flush to those same
   two edges when the row isn't wide enough to need its own scroll, so a `--badge`-wide anchor
   flush against the matching edge lands with the *same centre* as the badge above or below it —
   no measurement, no coordinate, just two things independently glued to the one edge they share. */
.row-arrow {
  display: flex;
  flex-direction: row;
  width: 100%;
  padding-block: var(--s2);
  /* Must match `.path-row-track`'s: the row's first and last badge sit flush against the *inside*
     of that padding, so the connector has to be inset by the same amount to land on them. */
  padding-inline: var(--row-edge-pad);
}
/* A vertical arrow takes its conditions *beside* the shaft, not above and below it: on paper the
   reagent sits to the left of a downward arrow and the conditions to the right, the same way a
   horizontal arrow takes them over and under. `.is-top`/`.is-bottom` keep their names — they mean
   reagent and conditions, and the horizontal arrows still draw them over/under.

   Both labels are taken out of flow so the glyph stays centred in the `--badge`-wide box no matter
   how lopsided the two strings are ("HO(CH₂)₂OH" against "H⁺"). A flex row would centre the *group*
   instead, sliding the shaft off the badge centre it has to line up with — the one thing this
   anchor exists to guarantee. Overflowing the 88px box is free here: a connector row holds nothing
   else. */
.row-arrow-anchor {
  position: relative;
  display: grid;
  place-items: center;
  width: var(--badge);
  color: var(--accent-dim);
}
.row-arrow-anchor .path-arrow-label {
  position: absolute;
  top: 50%;
  transform: translateY(-50%);
  max-width: 11ch;
  min-height: 0; /* the level-glyph reservation is a horizontal-arrow concern */
}
/* 12px is half the glyph's 24px width; the extra 4px is the gap off the shaft. */
.row-arrow-anchor .is-top { right: calc(50% + 16px); text-align: right; }
.row-arrow-anchor .is-bottom { left: calc(50% + 16px); text-align: left; }
/* Which side the connector sits on is read straight off the row it follows — never measured. A row
   reading left-to-right ends on the right; the connector into the next row has to start there. */
.path-section[data-dir="ltr"] + .row-arrow { justify-content: flex-end; }
.path-section[data-dir="rtl"] + .row-arrow { justify-content: flex-start; }

/* Fills the flexible arrow it sits in, up to the length it was drawn at. The viewBox scales
   uniformly, so a shortened arrow keeps its head in proportion — nothing here is redrawn in JS. */
.arrow-glyph {
  width: 100%;
  max-width: 180px;
  /* Must match the viewBox's 24-unit height (see js/path.js's `arrowGlyph`): a shorter box makes
     the height the binding term in `meet`'s scale and the arrow silently stops growing. */
  height: 24px;
  overflow: visible;
}
.arrow-glyph-vertical { width: 24px; height: 64px; }
/* The horizontal arrow gives back 10px of its slot. Two things this is deliberately *not*: not a
   lower `max-width`, because the glyph's box is set by its flex slot (~180px at desktop) well under
   the 200px cap, so lowering the cap changes nothing until it drops below the slot; and not a
   margin, because `width: 100%` above resolves against the full containing block and a margin then
   overflows it rather than insetting the box — the drawn arrow does not move. Taking it out of the
   width itself is the only one of the three that actually shortens the mark. Scoped to
   `.path-arrow` so the row connector's vertical glyph, whose horizontal centring is what lands it
   on a badge, is never touched. */
.path-arrow .arrow-glyph { width: calc(100% - 10px); }
.arrow-glyph path {
  fill: none;
  stroke: currentColor;
  stroke-width: 3;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* A row that reads right-to-left still draws its arrow pointing the way you actually travel — the
   glyph itself mirrors, nothing in js/path.js draws a second version. */
.path-section[data-dir="rtl"] .path-arrow .arrow-glyph { transform: scaleX(-1); }

/* Reagent on top, conditions on the bottom — a fixed min-height on *both* lines, even the empty
   one, so an arrow whose step only needs one line still keeps its glyph level with the arrow next
   to it in the same row, which may have text on both. */
.path-arrow-label {
  min-height: 1.15em;
  /* Never wider than the arrow it captions, now that the arrow's width is the row's leftover. A
     plain 100%, not `min(14ch, 100%)`: at 15px the ch-based ceiling exceeds the arrow on the
     longer reagents and pushes the row into its own horizontal scroll. */
  max-width: 100%;
  /* Mono, not the UI face. A reagent is a formula — "HO(CH₂)₂OH", "Et₂O", "NaBH₄" — and setting it
     in Grandstander at caption size read as a label *about* the step; the mono face at 15px reads
     as something written over a reaction arrow, which is what a synthetic scheme actually does. */
  font-family: var(--mono);
  font-size: 15px;
  font-weight: 500;
  letter-spacing: 0.01em;
  line-height: 1.15;
  text-align: center;
  /* Reagent above the shaft is what you add, so it carries the field accent; conditions below it
     are secondary and stay `--muted`. */
  color: var(--accent-text);
  white-space: normal;
}
.path-arrow-label.is-bottom { color: var(--muted); }

[data-state="done"] { color: var(--accent); }
[data-state="done"] .path-arrow-label { color: var(--text); }
/* `[data-state="locked"]` deliberately has no rule of its own. It used to draw a dashed
   `--border-strong` shaft, which made the route's *connections* argue with its badges about what
   "not yet" means — the badge already says it, and a dash additionally read as "this step is
   provisional" rather than "this topic is". A locked arrow now just inherits `.path-arrow`'s
   `--accent-dim`, the same faded shaft an available step draws: the arrow states what reacts into
   what, which is true regardless of whether you have got there yet. `arrowState()` still computes
   "locked" — the attribute is what the debug report reads, and `done` is still styled above. */

/* ── Sections ──────────────────────────────────────────────────────── */

.path-section + .path-section { margin-top: var(--s6); }

/* ── The route ─────────────────────────────────────────────────────── */

/* One row of the path: a horizontally scrolling track of badges and arrows, with the shared detail
   panel as a plain full-width sibling underneath it — not a member of the track, so it is never
   subject to the track's own `overflow-x` and never has to fight for room with the badges.

   `overflow-x: auto` on the track is the whole phone (and, now that arrows are long, the desktop)
   story: a row that doesn't fit its column scrolls itself horizontally rather than wrapping badges
   (which would break the straight-arrow assumption) or shrinking past the point of reading. `?debug`
   still publishes `bodyScroll` vs `viewport` as the live check that the *document* itself never
   needs to scroll sideways — every row owns its own overflow instead. */
.path-row {
  display: flex;
  flex-direction: column;
  min-width: 0;
  row-gap: var(--s4);
}
/* `min-width: 0` breaks flexbox's automatic-minimum-size trap: without it, a `nowrap` row of
   badges and arrows this wide reports that width as its content-minimum, which forces every
   ancestor up to `.path` wide enough to fit it instead of letting `overflow-x` do its job — the
   document itself would need to scroll sideways on a narrow screen, not just this track. */
.path-row-track {
  display: flex;
  flex-wrap: nowrap;
  min-width: 0;
  align-items: center;
  /* `space-between`, not `center`: the row connector (`.row-arrow-anchor`, above) is glued to
     this same container's edge, so the row's first and last badge have to be glued to it too, or
     the connector lands wherever the row happened to be centred instead of on the badge it is
     meant to reach. A row too wide for its container ignores this (there is no leftover space to
     distribute) and scrolls via `overflow-x` exactly as before. */
  justify-content: space-between;
  column-gap: var(--node-gap);
  padding-inline: var(--row-edge-pad);
  overflow-x: auto;
  /* Inert, and kept only as documentation: a non-`visible` `overflow-x` forces the block axis to
     clip too, so this track *does* cut anything drawn outside it vertically. The padding below is
     what actually buys the room. */
  overflow-y: visible;
  padding-block: var(--s2);
}
/* A row that reads right-to-left is a plain reversal of the same DOM order pathOrder produced —
   js/path.js never rebuilds it — so Tab still visits topics in curriculum order even though the
   badge it lands on next sits to the *left*. */
.path-section[data-dir="rtl"] .path-row-track { flex-direction: row-reverse; }

/* Fixed at exactly `--badge` wide, not sized to fit its own label — `.node-label` is very often
   wider than the badge (a title can run to 16ch) and, left to size the flex item naturally, would
   make that wider box — not the badge — the thing `.path-row-track`'s `justify-content:
   space-between` pins to the row's edge. Fixing the item's own width to the badge means the badge
   is always exactly what sits flush at the edge (the label just overflows the box visually, still
   centred, exactly like `.path-arrow-label` already does off `.row-arrow-anchor`'s matching
   `--badge`-wide box) — the two edge-anchors describe the same width for the same reason. */
.path-item {
  position: relative;
  z-index: 1;
  flex: none;
  width: var(--badge);
  display: flex;
  flex-direction: column;
  align-items: center;
}

/* ── Badges ────────────────────────────────────────────────────────── */

.node {
  /* Real pixels. The old 128 was a world unit chosen so a ~0.36 camera fit would land it near 46
     on screen, and it was mirrored by hand into js/lib/layout.js's BADGE because the whole
     clearance argument was stated in that number. Nothing mirrors this: the browser lays the chain
     out, so there is no second copy to drift. */
  --badge-size: var(--badge, 88px);
  /* The containing block for the icon/mark, which are absolutely positioned inside it. `.node`'s
     own filter already establishes one, but stating it here keeps that from being load-bearing. */
  position: relative;
  flex: none;
  width: var(--badge-size);
  height: var(--badge-size);
  padding: 0;
  border: 0;
  border-radius: var(--radius-lg);
  /* No disc. The icons are transparent black line art, so a filled plum badge behind them hid the
     drawing it existed to frame — the artwork now sits flush on the page ground and *is* the
     badge. Everything the disc used to carry (status, open, locked) moved onto the artwork or
     beside it; see the four state rules below. */
  background: transparent;
  color: var(--disc);
  display: grid;
  place-items: center;
  cursor: pointer;
  transition: transform 220ms var(--ease-bounce), outline-color var(--dur) var(--ease),
              color var(--dur) var(--ease);
}

/* A badge carries no pointer or focus handlers at all beyond the click that opens it — hover and
   focus feedback are entirely these two rules, so merely crossing the chain costs no
   class-toggling pass over sixteen badges and no state. */
.node:hover,
.node:focus-visible {
  transform: scale(1.07);
}

/* The one earned mascot moment on this page: fired by redraw() the instant a topic's status
   actually flips to completed, not on every redraw a completed topic happens to already be in. */
.node.is-just-completed {
  animation: node-pop 950ms var(--ease-bounce);
}
@keyframes node-pop {
  0% { transform: scale(1); }
  45% { transform: scale(1.18); }
  70% { transform: scale(0.94); }
  100% { transform: scale(1); }
}
.node.is-just-completed::after {
  content: "";
  position: absolute;
  margin: -68px 0 0 60px;
  width: 20px;
  height: 20px;
  pointer-events: none;
  background-repeat: no-repeat;
  background-size: contain;
  /* The same mascot face as the live .mascot markup, inlined as a data URI (hex literals, not
     var()s — a data URI can't read custom properties) for the one place this effect needs a
     background-image rather than live SVG. */
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Cpolygon points='32,3 58,17.5 58,46.5 32,61 6,46.5 6,17.5' fill='%23a78bfa' stroke='%237c3aed' stroke-width='3'/%3E%3Ccircle cx='24' cy='30' r='3.4' fill='%23191428'/%3E%3Ccircle cx='40' cy='30' r='3.4' fill='%23191428'/%3E%3Cpath d='M23 39 Q32 47 41 39' fill='none' stroke='%23191428' stroke-width='2.6' stroke-linecap='round'/%3E%3C/svg%3E");
  animation: node-pop-face 900ms var(--ease-bounce);
}
@keyframes node-pop-face {
  0% { opacity: 0; transform: scale(0.6) translateY(4px); }
  25% { opacity: 1; transform: scale(1) translateY(0); }
  70% { opacity: 1; transform: scale(1) translateY(0); }
  100% { opacity: 0; transform: scale(1) translateY(0); }
}

/* Most of the chain is locked on a fresh profile, so locked has to read as "not yet" rather than as
   an error. With no disc to unfill, that reading is carried entirely by the artwork's weight (see
   `.node[data-status="locked"] .node-icon`); the button itself has nothing left to restyle. */
.node[data-status="locked"] {
  color: color-mix(in srgb, var(--disc) 66%, transparent);
}
.node[data-status="locked"]:hover,
.node[data-status="locked"]:focus-visible { color: var(--disc); }

/* Open: a caret aimed at the panel this badge just opened, plus the label committing to brand.
   The offset outline this replaces was the third attempt at the same job and the only one that
   could be *clipped*: `.path-row-track` sets `overflow-x: auto`, which forces the block axis to
   clip too (its `overflow-y: visible` is inert), and a 2px ring held 4px off the box — then
   multiplied by the hover `scale(1.07)` — reached ~9.5px past the badge into 8px of padding. Any
   halo drawn *outside* the 88px box re-loses that argument, so this one is drawn against the
   thing it refers to instead: the caret points down at the row's shared `.node-panel-wrap`, which
   is the actual relationship being signalled, and it sits inside the label's own 10px gap rather
   than in the track's padding. Not a plate or an inset ring either: both re-enclose the line art
   that `.node`'s `background: transparent` deliberately un-enclosed. */
.node.is-active::before {
  content: "";
  position: absolute;
  top: calc(100% + 1px);
  left: 50%;
  width: 12px;
  height: 6px;
  margin-left: -6px;
  pointer-events: none;
  background: var(--brand-text);
  clip-path: polygon(50% 100%, 0 0, 100% 0);
}

/* The badge's face is the icon alone — no progress rim, no wrapper span, no separate border. The
   icon positions against `.node` itself (see its `position: relative`). */

/* The icon is the badge, full-bleed in the 88px box. `contain`, never `cover`: these are line-art
   drawings of a reaction, so a crop cuts the chemistry — and with no disc behind them there is no
   shape left to crop *to*. No radius either, for the same reason. The drop-shadow is cast by the
   strokes themselves rather than by a box, which is what keeps the artwork sitting on the page
   instead of floating over it now that nothing else gives it weight. */
.node-icon {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: contain;
  filter: drop-shadow(0 2px 3px rgba(var(--shadow-ink), 0.24));
}
/* Locked reads as "not yet": a quieted picture, not a hidden or broken one. Opacity alone — the
   artwork is already black on transparent, so the old `grayscale(1)` was a no-op, and any filter
   here would replace (not compose with) the drop-shadow above. */
.node[data-status="locked"] .node-icon {
  filter: none;
  opacity: 0.38;
}

/* Completion stamps the badge instead of replacing its face (see js/path.js's `updateNode` for why
   the swap stopped working once the disc went). A filled accent disc in the corner, sized against
   the ✓ it holds rather than the badge, so it stays a stamp at any badge size. It carried a 3px
   ring in the page ground for a while, to hold it off any black stroke it overlapped; the ring read
   as a white border on a badge that otherwise has no borders at all, and the accent fill turns out
   to separate from the line art on its own. */
.node-seal {
  position: absolute;
  right: -2px;
  bottom: -2px;
  width: 21px;
  height: 21px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--accent);
  color: var(--surface);
  font-size: 12px;
  line-height: 1;
}

/* ── Start flag ────────────────────────────────────────────────────── */

/* "You are here" — the first topic in progress, else the first available one. The atom had no
   equivalent at all, which is most of why it read as a diagram rather than as a route: nothing on
   it said which badge was yours. The chain's reading order says what comes next in general; this
   says what comes next for *you*.

   A pennant on a mast, not the free-floating pill it replaces. The pill said "here" but pointed at
   nothing: it hovered in the gap right of the badge and, bobbing on its own, read as a tooltip
   about to dismiss itself. The mast is *planted* in the badge's right shoulder, so the eye climbs
   it before it reads the pennant and the marker resolves to the badge rather than to the gap; the
   pennant then flies out over the arrow lane, which is the direction you are being sent.

   Brand, not accent: it is the app telling you where to go, not a property of the field. Filled
   brand, so it takes --brand-ink (white on the pale lavender measures 2.3:1). Both pieces are
   positioned off `--badge` rather than off literal pixels, exactly as the pill was, so the marker
   tracks the badge at any badge size. */
.path-start-mast {
  position: absolute;
  top: 4px;
  left: calc(50% + var(--badge) / 2 - 14px);
  z-index: 4;
  width: 3px;
  height: 44px;
  border-radius: 2px;
  background: var(--brand-ridge);
  pointer-events: none;
}
/* The finial. Without it the mast reads as a stray 3px rule the moment the pennant flutters off
   its top edge. */
.path-start-mast::after {
  content: "";
  position: absolute;
  left: -2px;
  top: -5px;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--brand-ridge);
}

/* The ridge is a `drop-shadow` filter, not the `box-shadow: 0 3px 0` the pill carried: the
   swallowtail is cut with `clip-path`, and a box-shadow is cast by the *box*, so the ridge would
   run straight across the notch the clip just cut out. A drop-shadow follows the clipped silhouette
   and the notch keeps its edge. */
.path-start-flag {
  position: absolute;
  top: 3px;
  left: calc(50% + var(--badge) / 2 - 12px);
  z-index: 4;
  width: 68px;
  height: 26px;
  display: flex;
  align-items: center;
  padding-left: 11px;
  background: var(--brand);
  clip-path: polygon(0 0, 100% 0, 84% 50%, 100% 100%, 0 100%);
  filter: drop-shadow(0 3px 0 var(--brand-ridge));
  pointer-events: none;
  transform-origin: left center;
  animation: start-fly 2.4s var(--ease) infinite;
}
.path-start-flag .path-start-text {
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.08em;
  line-height: 1;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--brand-ink);
}

.path-item:not(.is-next) .path-start-mast,
.path-item:not(.is-next) .path-start-flag { display: none; }
/* Hidden while its own topic is open — you have arrived, it has nothing left to say. */
.path-item[data-open="true"] .path-start-mast,
.path-item[data-open="true"] .path-start-flag { display: none; }

/* Pivot, not bob. The pennant is hinged at the mast, so the one thing it can do without coming
   loose from it is swing about that hinge — which is why `transform-origin` is `left center`. */
@keyframes start-fly {
  0%, 100% { transform: rotate(0deg); }
  50% { transform: rotate(4deg); }
}
@media (prefers-reduced-motion: reduce) {
  .path-start-flag { animation: none; }
}

/* ── Label ─────────────────────────────────────────────────────────── */

/* Titles are permanently visible and set at the size they are read at. On the atom they were 24px
   of *world* type that a camera then shrank to 8.6, with a `LABEL_FLOOR_ZOOM` to hide them entirely
   once the fit got worse — the whole "canvas type is double what it renders at" rule, and the
   `labelSide` / `data-label-side` / `LABEL_W` / `LABEL_DROP` machinery that framed a box the camera
   had to reserve, exist nowhere in this file's replacement. A title is now just a caption under a
   button.

   A sibling of the button, never a child: `.node` carries `filter: url(#tree-wobble)`, and a filter
   establishes a containing block and a stacking context — a title inside one would be smeared by
   the displacement map meant for the disc's outline. */
.node-label {
  margin-top: 10px;
  /* Wraps inside the badge's own column rather than spilling past it. The spill used to be
     harmless; with rows now sized to fit their track exactly, a label hanging off the first or
     last badge is the only thing left that can push a row into its own horizontal scroll. */
  /* A single long word ("Regioselectivity") is wider than the badge it captions, so the label is
     allowed to overhang into the row's `--row-edge-pad` rather than being broken mid-word. Beyond
     that it hard-breaks, which is a last resort for a title no reasonable width can hold. */
  max-width: calc(100% + 2 * var(--row-edge-pad));
  overflow-wrap: break-word;
  font-size: 15px;
  font-weight: 600;
  line-height: 1.3;
  text-align: center;
  text-wrap: balance;
  color: var(--muted);
  pointer-events: none;
  transition: color var(--dur) var(--ease);
}
/* `~`, not a chain of `+`: the start marker between the badge and its label is two elements (mast
   and pennant) rather than the pill's one, and a chain of adjacent-sibling steps would have to
   spell out both — and then break again the next time the marker gains or loses a piece. The
   general sibling combinator says the only thing that actually matters here, which is that the
   label follows the badge inside the same `.path-item`. */
.node:hover ~ .node-label,
.node:focus-visible ~ .node-label { color: var(--text); }
/* Open goes further than hover's `--text`, since it has to hold while the pointer is elsewhere.
   Colour only, never a weight change: the label is the widest thing in the badge's column and
   `text-wrap: balance` would re-flow a two-line title the moment it was opened. 4.58:1 on `--bg`. */
.node.is-active ~ .node-label { color: var(--brand-text); }

/* ── The panel ─────────────────────────────────────────────────────── */

/* Expand-in-place, replacing a `<dialog>` preview modal. One per row, shared by every topic in it —
   not one per badge — so opening a topic never resizes its row-mates inline; it always renders in
   this single strip at the row's foot instead.

   It sits there simply because `.path-row` is a column flex with the badge track above and this
   wrapper below — a plain sibling, not a member of the track's own horizontally scrolling row, so
   it is always full-width and never has to force a wrap the way it did when the track and the
   panel shared one wrapping flex container.

   `grid-template-rows: 0fr → 1fr` is the only pure-CSS way to animate to an intrinsic height, so
   nothing measures a panel and nothing hardcodes one. The closed state is `data-open="false"` plus
   `inert` on the panel itself, deliberately *not* the `hidden` attribute: `hidden` is only a UA
   rule and this wrapper's author `display: grid` beats it outright. */
.node-panel-wrap {
  display: grid;
  /* `minmax(0, 0fr)`, not a bare `0fr`: a grid track still floors itself at the item's automatic
     minimum size, and the card carries its padding and border at rest, so a plain `0fr` row
     collapsed to 52px rather than to nothing and left a permanent gap under every row. */
  grid-template-rows: minmax(0, 0fr);
  width: 100%;
  /* The closing duration. Opening overrides it below: an exit that matches its entrance reads as
     latency, since by then the reader has already decided to leave. */
  transition: grid-template-rows 190ms var(--ease);
  /* The card keeps its padding and border at rest (see `.node-panel`), and a `0fr` grid row cannot
     shrink a box below those — a collapsed panel held a 52px floor without this, so every row
     carried a permanent gap. `clip` rather than `hidden` because it takes `overflow-clip-margin`:
     the resting `--shadow-tier` is cast *outside* the card and would otherwise be sliced off flush
     while open. 28px covers its 20px blur plus the 6px offset. */
  overflow: clip;
  overflow-clip-margin: 28px;
}
.node-panel-wrap[data-open="true"] {
  grid-template-rows: minmax(0, 1fr);
  margin-top: var(--s4);
  transition: grid-template-rows 300ms var(--ease);
}
/* The row opening is what makes space; this is what makes the panel *arrive in* it. On its own the
   `0fr → 1fr` row was a pure slide-down — the card's top edge held still and its bottom edge swept
   past the reader, which reads as the page being pushed apart rather than as this topic opening.
   Scaling the card out from its own centre against that same expansion makes both edges travel
   outward from the middle, so the motion describes the panel growing rather than the layout moving.
   Transform and opacity only: the height is already being animated by the grid row above, and this
   card is full-width and content-sized, so scaling it drives no second layout pass. */
/* The card's whole visual box lives here, not on the open state. It used to be declared under
   `[data-open="true"]`, which meant closing stripped the display mode, padding, border, background
   and shadow in a single frame — the card blinked out of existence before the row beneath it had
   animated anywhere, so only the *opening* ever read as motion. Everything state-dependent is now
   just the three properties that can actually be interpolated (the row above, transform, opacity),
   and `opacity: 0` when closed is what keeps the resting shadow from painting a sliver under a
   zero-height row. */
.node-panel {
  overflow: hidden;
  min-height: 0;
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  padding: var(--s5);
  border: 2px solid var(--accent-dim);
  border-radius: var(--radius-xl);
  background: var(--surface);
  box-shadow: var(--shadow-tier);
  transform: scale(0.94);
  opacity: 0;
  transform-origin: center;
  transition: transform 190ms var(--ease), opacity 150ms var(--ease);
}
.node-panel-wrap[data-open="true"] .node-panel {
  transform: scale(1);
  opacity: 1;
  /* Slightly longer than the row it rides, so the card is still settling as the space finishes
     opening rather than snapping to rest inside a gap that is still growing. */
  transition: transform 320ms var(--ease), opacity 200ms var(--ease);
}
.node-panel-foot {
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
  margin-top: var(--s2);
  padding-top: var(--s4);
  border-top: 1px solid var(--border);
}
@media (prefers-reduced-motion: reduce) {
  .node-panel-wrap,
  .node-panel-wrap[data-open="true"],
  .node-panel,
  .node-panel-wrap[data-open="true"] .node-panel { transition: none; }
  /* Not just an un-transitioned transform: without this the closed card keeps `scale(0.94)` and
     `opacity: 0` as static values, so an open panel would render shrunken and invisible rather
     than simply appearing without motion. */
  .node-panel { transform: none; opacity: 1; }
}

/* ── The end of the chain ──────────────────────────────────────────── */

/* The mascot, marking what you are walking toward.

   This is the tree's existing hero-scale site *relocated*, not a sixth one — DESIGN.md budgets the
   mascot five sites and the tree's hub was the third. It moved from "the root everything grows out
   of" to "the end of the chain," which is the identical job once the diagram is read top-to-bottom
   instead of outward. Rejected alternative: the head of the chain, where it would compete with both
   the START pill and the field bubble, which already own the top of this page. */
.path-end {
  width: 96px;
  height: 96px;
  margin: var(--s7) auto 0;
  opacity: 0.75;
  animation: path-end-bob 4s var(--ease) infinite;
}
.path-end .mascot { width: 100%; height: 100%; }
@keyframes path-end-bob {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-7px); }
}
@media (prefers-reduced-motion: reduce) {
  .path-end { animation: none; }
}

/* The mascot wears the *field's* colour here, not the brand's — the one mascot site that does.
   That is the Chrome-vs-Content rule applied rather than bent: this mascot is standing inside the
   skill tree, which is accent territory along with field cards and per-field rows. A brand-violet
   mascot at the end of a green chain read as a foreign object sitting on top of the field rather
   than as part of it. The home hero and the sign-in card keep the brand colours, because those sit
   in chrome with no field scope at all — which is exactly what `:root`'s brand-matching `--accent`
   fallback is there to handle.

   `--accent-text` (accent mixed toward black) stands in for `--brand-ridge` as the darker sibling,
   the same relationship the brand pair has. The face stays near-black: it reads on all five
   accents, and the eyes and smile are the mascot's identity rather than its colour. */
.path-end .mascot-shell,
.tree-empty-mascot .mascot-shell {
  fill: var(--accent);
  stroke: var(--accent-text);
  filter: url(#tree-wobble) drop-shadow(0 4px 8px rgba(var(--shadow-ink), 0.22));
}
.path-end .mascot-cheek,
.tree-empty-mascot .mascot-cheek { fill: var(--accent-text); }

/* ── Empty field ───────────────────────────────────────────────────── */

/* Four of the five fields ship with no curriculum authored yet. That is a real state, not a
   failure, so it says so in words — a blank page would be indistinguishable from a fetch that
   silently failed. js/lib/path.js's ordering functions all return empty for these fields and
   tools/path_test.js asserts it, so this is a tested path rather than a fallback.

   The mascot above it is the end-of-chain mascot standing in for itself: with no topics there is no
   chain for it to end. Still the tree's one mascot site (see DESIGN.md), not a sixth. */
.tree-empty {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--s3);
  width: min(420px, calc(100% - 2 * var(--s5)));
  margin: calc(var(--header-h) + 18vh) auto 0;
  padding: var(--s5);
  text-align: center;
}
.tree-empty-mascot {
  width: 84px;
  height: 84px;
  /* 4px literal: the app's spacing scale starts at --s2, so the `var(--s1)` this used to carry
     named nothing and the declaration was silently dropped. */
  margin-bottom: 4px;
}
.tree-empty-mascot .mascot { width: 100%; height: 100%; }
.tree-empty-title {
  margin: 0;
  font-size: 22px;
  font-weight: 700;
  line-height: 1.25;
  letter-spacing: -0.015em;
  color: var(--text);
}
.tree-empty-body {
  margin: 0;
  max-width: 34ch;
  color: var(--muted);
  font-size: 14px;
  line-height: 1.55;
}
.tree-empty .btn { margin-top: 4px; }

/* ── Modal (topic preview) ─────────────────────────────────────────── */

.modal {
  width: min(540px, calc(100vw - 32px));
  max-height: min(86vh, 760px);
  padding: 0;
  border: 2px solid var(--border-strong);
  border-radius: var(--radius-xl);
  background: var(--surface);
  color: var(--text);
  overflow: hidden;
}
.modal::backdrop {
  background: rgba(4, 6, 8, 0.66);
  backdrop-filter: blur(3px);
  -webkit-backdrop-filter: blur(3px);
}
.modal[open] { animation: modal-in 220ms var(--ease); }
@keyframes modal-in {
  from { opacity: 0; transform: translateY(10px) scale(0.94); }
  60%  { transform: translateY(-2px) scale(1.015); }
  to   { opacity: 1; transform: none; }
}

/* signin.html's one card is static HTML never rebuilt by signin.js — reuses the modal's pop
   idiom rather than inventing a new one, since it's the same shape of moment: one card arriving. */
body[data-page="signin"] .auth-card,
body[data-page="password"] .auth-card { animation: modal-in 320ms var(--ease); }

.modal-inner { display: flex; flex-direction: column; max-height: inherit; }

.modal-head {
  display: flex;
  align-items: flex-start;
  /* Wraps so group chat's .group-member-row can be appended here and drop to its own full-width
     line under the title, rather than competing with it for horizontal space. */
  flex-wrap: wrap;
  gap: var(--s3);
  padding: var(--s5) var(--s5) var(--s4);
  border-bottom: 1px solid var(--border);
}
.modal-title { flex: 1; min-width: 0; font-size: 18px; font-weight: 600; letter-spacing: -0.02em; line-height: 1.3; }
.modal-close {
  flex: none;
  width: 30px; height: 30px;
  display: grid; place-items: center;
  border-radius: var(--radius-sm);
  color: var(--muted);
  font-size: 17px;
  line-height: 1;
  transition: background var(--dur) var(--ease), color var(--dur) var(--ease);
}
.modal-close:hover { background: var(--raised); color: var(--text); }

.modal-scroll { overflow-y: auto; padding: var(--s5); display: grid; gap: var(--s5); }

.modal-foot {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s4) var(--s5);
  border-top: 1px solid var(--border);
  background: var(--raised);
}
/* The composer's input takes the slack; its button keeps its natural width. */
.modal-foot .search { flex: 1; min-width: 0; background: var(--surface); }

/* Group chat's member controls, injected into .modal-head next to the title. Wraps under the
   title on narrow screens rather than crushing the select. */
.group-member-row {
  display: flex;
  align-items: center;
  gap: var(--s2);
  flex-wrap: wrap;
  width: 100%;
  margin-top: var(--s2);
}
.group-member-row select {
  flex: 1;
  min-width: 140px;
  padding: 5px 8px;
  border-radius: var(--radius);
  border: 1px solid var(--border-strong);
  background: var(--bg);
  font-size: 13px;
}

.notice {
  display: flex;
  gap: var(--s3);
  padding: var(--s3) var(--s4);
  border-radius: var(--radius);
  border: 1px dashed var(--border-strong);
  background: var(--raised);
  font-size: 13px;
  color: var(--muted);
  line-height: 1.5;
}
.notice b { color: var(--text); font-weight: 550; }

.section-label {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--faint);
  margin-bottom: var(--s2);
}
/* A second label further down the same card needs air above it, not just below. */
.section-label.is-spaced { margin-top: var(--s4); }

.lesson-list { display: grid; gap: 2px; }
.lesson-row .tick {
  flex: none;
  width: 17px; height: 17px;
  display: grid; place-items: center;
  border-radius: 50%;
  border: 1.5px solid var(--border-strong);
  font-size: 10px;
  color: transparent;
}
.lesson-row[data-done="true"] .tick {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--surface);
}
.lesson-row[data-done="true"] .lesson-row-title { color: var(--muted); }
.lesson-row-title { flex: 1; min-width: 0; }
.lesson-row-kind {
  font-size: 11px;
  color: var(--faint);
  text-transform: uppercase;
  letter-spacing: 0.07em;
}

.objectives { display: grid; gap: var(--s2); }
.objectives li {
  display: flex;
  gap: var(--s3);
  font-size: 13.5px;
  color: var(--muted);
  line-height: 1.5;
}
.objectives li::before { content: "▸"; color: var(--faint); flex: none; }

/* ── Lesson page ───────────────────────────────────────────────────── */

.breadcrumb {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--s2);
  font-size: 13px;
  color: var(--faint);
}
.breadcrumb a { color: var(--muted); }
.breadcrumb a:hover { color: var(--text); }
.breadcrumb .sep { color: var(--border-strong); }

.lesson-layout {
  display: grid;
  grid-template-columns: 232px minmax(0, 1fr);
  gap: var(--s7);
  align-items: start;
}

.lesson-rail {
  position: sticky;
  top: calc(var(--header-h) + var(--s5));
  display: grid;
  gap: var(--s4);
}

.block {
  border: 1px dashed var(--border-strong);
  border-radius: var(--radius);
  padding: var(--s5);
  background: color-mix(in srgb, var(--surface) 55%, transparent);
  display: grid;
  gap: var(--s2);
}
.block-kind {
  font-size: 11px;
  font-weight: 600;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--accent-text);
  opacity: 0.75;
}
.block-hint { color: var(--faint); font-size: 13.5px; line-height: 1.6; }
.block-hint code {
  font-family: var(--mono);
  font-size: 12.5px;
  background: var(--raised);
  border: 1px solid var(--border);
  border-radius: 5px;
  padding: 1px 6px;
  color: var(--muted);
}

.lesson-foot {
  display: flex;
  align-items: center;
  gap: var(--s3);
  flex-wrap: wrap;
  margin-top: var(--s6);
  padding-top: var(--s5);
  border-top: 1px solid var(--border);
}

/* ── Exams ─────────────────────────────────────────────────────────── */

/* The page scrolls; it is not a locked viewport pane. This used to be `height: calc(100vh - …)`
   with the viewer flexing into whatever the toolbar left over — which capped a PDF at roughly half
   a screen and made the *iframe* do all the scrolling, inside a box the reader could not grow.
   Now the column is as tall as its content, the viewer states its own generous height (below), and
   the document takes the overflow. `min-height` keeps a short page (no exam selected) from
   floating the footer up mid-screen. */
.exam-layout {
  display: flex;
  flex-direction: column;
  gap: var(--s4);
  min-height: calc(100vh - var(--header-h));
  /* Narrower than the 1400px it used to allow, and with a wider gutter: at a typical laptop width
     the cards were landing 24px off the sidebar and 24px off the window edge, which read as
     cramped rather than full-bleed. The cap also stops the toolbar's chip rows from stretching
     into one very long line on a wide display. */
  max-width: 1240px;
  margin-inline: auto;
  padding: var(--s6) var(--s7) var(--s5);
}

/* A masthead band over three labelled filter rows, in a centred 1050px column — not the fixed 200px
   lede *beside* the chips it used to be. Three things changed together:

   The card is centred rather than left-aligned. `.exam-layout` is 1240px wide, so a card that only
   obeyed `max-width` sat hard against the layout's left edge with the leftover ground on its right,
   and the viewer below it — which took the full 1240 — made that offset read as a mistake rather
   than as a column. `margin-inline: auto` here and a matching cap on `.exam-viewer` put both cards
   on one axis at one width, so the page is a single centred stack. **The two caps are one
   decision**: change this width and change the viewer's with it.

   The two-column split is gone with it. Side by side, the title's 200px column and the chips were
   competing for the same width, and the year row (11 chips) was the thing that lost. Stacked, the
   chips get the whole column and the rows can be labelled. */
.exam-toolbar {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--s4);
  flex: none;
  width: 100%;
  max-width: 1050px;
  margin-inline: auto;
  background: var(--surface);
  border: 2px solid var(--border-strong);
  border-radius: var(--radius-xl);
  box-shadow: var(--shadow-tier);
  padding: var(--s5) var(--s6);
}

/* Title and count share one baseline rather than stacking. At this column width the stacked pair
   left a long empty line beside the count, and a page with a single heading does not need two lines
   to say what it is. `flex-wrap` keeps that honest on a narrow window, where the count drops under
   the title on its own instead of squeezing it. */
.toolbar-lede {
  flex: none;
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--s2) var(--s4);
  padding-bottom: var(--s4);
  border-bottom: 1px solid var(--border);
}
.toolbar-lede .field-meta { margin-top: 0; }

.toolbar-filters {
  display: flex;
  flex-direction: column;
  gap: var(--s3);
  /* Without this the year row's full unwrapped width becomes the column's minimum and pushes the
     toolbar past its cap — flexbox's automatic-minimum-size rule again. */
  min-width: 0;
  flex: 1;
}

/* Baseline, not centre: the caption is 12px next to 12px chips that sit in taller pill boxes, and
   centring it against the row's full height floats it off the first line of chips whenever the row
   wraps to two. */
.filter-row {
  display: flex;
  align-items: baseline;
  gap: var(--s4);
  min-width: 0;
}
/* A fixed gutter, so all three chip rows start on one left edge however long the caption is — the
   rows read as a table of filters rather than three unrelated chip piles. Chip groups used to
   carry these words only as `aria-label`s, which is to say the sighted reader was the one who had
   to infer that the middle row was years. */
.filter-label {
  flex: none;
  width: 78px;
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--faint);
}
/* A chip row is one line that scrolls, never a block that wraps. The archive runs 2026 back to
   2010, so the year row is seventeen chips; wrapped, it took three lines and turned a filter into
   the tallest thing in the card, and every added year made the toolbar taller again. One line is a
   fixed row height at any number of years.

   `min-width: 0` on the track *and* on `.filter-row` above, or flexbox's automatic-minimum-size rule
   reports the unwrapped strip's full content width as the row's minimum and the toolbar — not the
   strip — is what ends up too wide. The same rule the skill route's `.path-row-track` needed. */
.filter-row .chips {
  flex: 1;
  min-width: 0;
  flex-wrap: nowrap;
  overflow-x: auto;
  scrollbar-width: none;
  /* The pressed chip's ring and the hover `scale(1.05)` both draw outside the chip's box, and an
     overflow container clips the block axis even when only `overflow-x` is set. */
  padding-block: 2px;
}
.filter-row .chips::-webkit-scrollbar { display: none; }
/* Chips size to their own text; without this the track's own shrinking would squeeze "2026" to fit
   rather than letting the row scroll. */
.filter-row .chip { flex: none; }

/* Under ~560px the 78px gutter costs the year chips more than the alignment is worth, so the
   caption goes above its row instead of beside it. */
@media (max-width: 560px) {
  .filter-row {
    flex-direction: column;
    align-items: stretch;
    gap: var(--s2);
  }
  .filter-label { width: auto; }
}

.exam-title { font-size: 31px; font-weight: 600; letter-spacing: -0.018em; }

.search {
  display: flex;
  align-items: center;
  gap: var(--s2);
  padding: 7px var(--s3);
  border-radius: var(--radius);
  border: 1px solid var(--border-strong);
  background: var(--bg);
}
.search input {
  flex: 1;
  min-width: 0;
  border: none;
  background: none;
  outline: none;
  font-size: 13.5px;
}
.search input::placeholder { color: var(--faint); }
/* font-size, not width: .ui-icon sizes itself in `em`, so this is the size control. */
.search-icon { flex: none; color: var(--faint); font-size: 16px; }

/* Inline form status line. min-height reserves the row so showing an error doesn't shove the
   layout down — used by sign-in, the profile username editor and the friends search box. */
.form-msg { font-size: 13px; min-height: 1.2em; }

.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.chip {
  padding: 4px 10px;
  border-radius: var(--radius-pill);
  border: 1px solid var(--border-strong);
  background: transparent;
  color: var(--muted);
  font-size: 12px;
  transition: all var(--dur) var(--ease);
}
.chip:hover { color: var(--text); border-color: var(--faint); transform: scale(1.05); }
.chip:active { transform: scale(0.96); }
.chip[aria-pressed="true"] {
  background: var(--accent-faint);
  border-color: var(--accent-dim);
  color: var(--accent-text);
  font-weight: 700;
}
.chip[data-empty="true"] { opacity: 0.45; }
.chip[data-empty="true"]:not([aria-pressed="true"]):hover { opacity: 0.7; }

/* The same 1050px column as `.exam-toolbar`, centred on the same axis. The viewer used to take the
   layout's full 1240 while the toolbar capped narrower, so the two cards shared neither edge; a
   paper is portrait anyway, so the extra viewer width was margin inside the PDF, not more paper.
   Change this cap and the toolbar's together — a single shared width is the whole point. */
.exam-viewer {
  display: flex;
  flex-direction: column;
  flex: none;
  width: 100%;
  max-width: 1050px;
  margin-inline: auto;
  min-height: 60vh;
  background: var(--surface);
  border: 2px solid var(--border);
  border-radius: var(--radius-xl);
  overflow: hidden;
}
/* A viewer with a paper in it is taller than the viewport on purpose: a paper is portrait, and a
   viewer that fits the screen fits about half a page of one. `vh` rather than a flat pixel height
   so a large display gets more of the paper, with a ceiling so a very tall one doesn't turn a
   single exam into three screens of scrolling. The document takes the overflow — see
   `.exam-layout` above.

   Keyed on the iframe rather than applied to every `.exam-viewer`, because the same box also holds
   the "No exam selected" and "PDF not added yet" states, and 148vh of empty card is not a state
   worth designing for. */
.exam-viewer:has(.viewer-stage iframe) { height: min(148vh, 1500px); }

.viewer-bar {
  display: flex;
  align-items: center;
  gap: var(--s3);
  padding: var(--s3) var(--s4);
  border-bottom: 1px solid var(--border);
  flex-wrap: wrap;
}
.viewer-title { font-size: 14px; font-weight: 600; letter-spacing: -0.01em; }
.viewer-sub { font-size: 12.5px; color: var(--faint); }

.viewer-stage { flex: 1; min-height: 0; position: relative; background: var(--bg); }
.viewer-stage iframe { width: 100%; height: 100%; border: none; display: block; }

.empty {
  height: 100%;
  display: grid;
  place-content: center;
  justify-items: center;
  gap: var(--s3);
  padding: var(--s6);
  text-align: center;
}
/* Brand-tinted, not neutral grey: an empty state is still a moment the app should feel like
   itself in, not a dashed placeholder relic. Confined to chrome (brand), never a field accent —
   an empty state has no field of its own. */
.empty-mark {
  width: 52px; height: 52px;
  display: grid; place-items: center;
  border-radius: 16px;
  border: 2px solid var(--brand-dim);
  background: var(--brand-faint);
  color: var(--brand-text);
  font-size: 22px;
}
/* The base .empty fills a whole viewer pane; inside a card it just needs to be a centred block. */
.empty.is-inline { height: auto; padding: var(--s5) var(--s2); gap: var(--s2); }
.empty.is-inline .empty-mark { width: 40px; height: 40px; font-size: 20px; }
.empty-title { font-size: 15px; font-weight: 550; }
.empty-text { color: var(--muted); font-size: 13.5px; max-width: 44ch; line-height: 1.6; }
.empty-text code {
  font-family: var(--mono);
  font-size: 12.5px;
  background: var(--raised);
  border: 1px solid var(--border);
  border-radius: 5px;
  padding: 2px 7px;
  color: var(--accent-text);
  display: inline-block;
  margin-top: var(--s2);
  word-break: break-all;
}

/* ── Sign in ───────────────────────────────────────────────────────── */

.auth-layout {
  display: flex;
  justify-content: center;
  padding-block: var(--s7);
}

.auth-card {
  width: 100%;
  max-width: 380px;
  border-width: 2px;
  border-radius: var(--radius-xl);
  box-shadow: 0 10px 24px -10px rgba(var(--shadow-ink), 0.18);
}

/* The signed-out landing card leads with the rail's own brand lockup — mascot plus wordmark on
   one row — so the first screen carries the same mark as the app behind it. `.auth-wordmark`
   repeats `.brand`'s weight/tracking/colour rather than reusing that class: `.brand` is a
   full-width centred row sized for the sidebar, and this one sits inline beside the mascot.
   The `+ .title` rule outranks `.stack > * + *` (two classes beat one), buying the heading a
   little more air below the lockup than the stack's default 12px. */
.auth-lockup {
  display: flex;
  align-items: center;
  gap: var(--s3);
}
.auth-lockup .mascot { width: 44px; height: 44px; }
.auth-wordmark {
  font-size: 30px;
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--brand-ridge);
  line-height: 1;
}
.auth-lockup + .title { margin-top: var(--s4); }

/* Sign-up only: `setMode()` in js/pages/signin.js toggles `hidden` on every `.auth-terms`, since
   signing in agrees to nothing new. It sits inside the form, under the submit button, so the
   commitment reads as part of the action rather than as page furniture. No author `display` rule
   here on purpose — a `<p>` falls back to the UA `[hidden]` rule, and adding one would need its
   own `[hidden]` companion. */
.auth-terms {
  margin-top: var(--s3);
  font-size: 13px;
  line-height: 1.5;
  color: var(--muted);
}
.auth-terms a { color: var(--brand-text); text-decoration: underline; }

/* The account-recovery row under the submit button. `display: flex` needs its own `[hidden]`
   companion for the buttons inside it, since an author `display` beats the UA `[hidden]` rule —
   the same trap `.auth-terms` above documents for the other direction. */
.auth-actions {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--s2);
  margin-top: var(--s3);
}
.auth-actions [hidden] { display: none; }

/* The landing page is two columns: a pitch on the left, the auth card on the right. No grid is
   needed — theme.css already makes `body[data-page="signin"] .page` a centred flex row for the
   full-viewport auth screens, so the pitch section is simply its first item and the card becomes
   the second. `.auth-layout` gives up `.wrap`'s 1080px width here (it would eat the row) and
   keeps only the card's own 380px. Below 900px the row turns into a column, pitch first.
   Scoped to `data-page="signin"`, not to both auth pages: password.html has no pitch column and
   must keep theme.css's plain centred single card. */
body[data-page="signin"] .page {
  gap: var(--s7);
  justify-content: center;
  padding-inline: var(--s5);
}
/* `.wrap`'s `margin-inline: auto` has to go, not just its width: an auto margin on a flex item
   absorbs all free space before `justify-content` ever runs, which pins the pitch to the left
   edge and the card to the right with a chasm between them. */
body[data-page="signin"] .auth-layout {
  flex: none;
  width: 380px;
  max-width: 100%;
  margin-inline: 0;
  padding-inline: 0;
}
@media (max-width: 900px) {
  body[data-page="signin"] .page {
    flex-direction: column;
    gap: var(--s5);
    padding-block: var(--s6);
  }
}

.about-pitch { max-width: 30rem; }
.about-pitch .about-title {
  font-size: 40px;
  font-weight: 700;
  line-height: 1.1;
  letter-spacing: -0.03em;
  color: var(--text);
  text-wrap: balance;
}
.about-pitch .about-body {
  margin-top: var(--s4);
  max-width: 46ch;
  font-size: 17px;
  line-height: 1.6;
  color: var(--muted);
}
/* `em` marks the archive as the concrete proof in the sentence; brand text, not italics, since
   the face's italic is barely distinguishable at this size. */
.about-pitch .about-body em {
  font-style: normal;
  color: var(--brand-text);
  font-weight: 600;
}

.auth-divider {
  display: flex;
  align-items: center;
  gap: var(--s3);
  margin-block: var(--s4);
  color: var(--faint);
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
.auth-divider::before, .auth-divider::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--border);
}

.auth-field { display: block; margin-top: var(--s3); }
.auth-label {
  display: block;
  margin-bottom: 6px;
  font-size: 12px;
  font-weight: 600;
  color: var(--muted);
}

/* ── Profile ───────────────────────────────────────────────────────── */

/* The page-specific two-column dashboard grid (column widths, breakpoints, entrance stagger)
   lives in css/profile.css, following the same "page-specific layout, shared components" split
   home.css established.
 *
 * .card-stack is a vertical run of cards spaced one gap apart. Needed where a section wraps its
 * cards in a toggled container (#friends-root) instead of dropping them straight into that
 * column grid — nesting them puts them a level below the grid, so they inherit no gap at all and
 * sit border-to-border.
 *
 * The [hidden] guard belongs to a real idiom — `display: grid` outranks the UA stylesheet's
 * `[hidden] { display: none }` — but it is currently redundant, because theme.css declares
 * `[hidden] { display: none !important }`, which wins over any author rule regardless of order.
 * Kept because it becomes load-bearing again the moment that !important goes. Same for
 * .poke-banner[hidden]. */
.card-stack { display: grid; gap: var(--s4); }
.card-stack[hidden] { display: none; }

/* Label/value pairs. Rules between rows rather than around each one, so a four-row block reads as
   one table instead of four stacked cards. */
.profile-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--s4);
  padding-block: 8px;
  border-bottom: 1px solid var(--border);
}
.profile-row:last-child { border-bottom: 0; padding-bottom: 0; }
.profile-key { flex: none; color: var(--muted); font-size: 13px; }
.profile-val { min-width: 0; font-size: 13.5px; text-align: right; overflow-wrap: anywhere; }

.profile-field {
  display: block;
  padding-block: var(--s3);
  border-bottom: 1px solid var(--border);
}
.profile-field:last-child { border-bottom: 0; padding-bottom: 0; }
.profile-field:hover { text-decoration: none; }
.profile-field:hover .profile-field-name { color: var(--brand-text); }

.profile-field-head {
  display: flex;
  align-items: center;
  gap: var(--s2);
  margin-bottom: 7px;
}
.profile-field-name { flex: 1; min-width: 0; font-size: 13.5px; font-weight: 500; }
.profile-field-count { flex: none; font-size: 12.5px; color: var(--faint); font-variant-numeric: tabular-nums; }

/* ── Friends ───────────────────────────────────────────────────────── */

/*
 * Circular initial tile. Built on .field-glyph's formula (faint fill, 2px dim border, darkened
 * ink) so it belongs to the same visual family, but round rather than squircle — a person, not a
 * subject. The derived tints are declared in this same rule as --av, which is safe: a custom
 * property's var() resolves against the element's own cascaded value, so the [data-hue] overrides
 * below reach them. (The re-declare-on-every-override trap only bites when the derived token sits
 * on an *ancestor* of the element that overrides the base.)
 */
.avatar {
  flex: none;
  width: 40px;
  height: 40px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  font-size: 16px;
  font-weight: 700;
  line-height: 1;
  text-transform: uppercase;
  user-select: none;
  --av: var(--brand);
  background: color-mix(in srgb, var(--av) 15%, transparent);
  border: 2px solid color-mix(in srgb, var(--av) 34%, transparent);
  color: color-mix(in srgb, var(--av) 80%, black);
}
/* Keyed on the numeric user id, so one person keeps one colour everywhere they appear. */
.avatar[data-hue="0"] { --av: var(--brand); }
.avatar[data-hue="1"] { --av: var(--field-physical); }
.avatar[data-hue="2"] { --av: var(--field-organic); }
.avatar[data-hue="3"] { --av: var(--field-biochemistry); }
.avatar[data-hue="4"] { --av: var(--field-analytical); }
.avatar[data-hue="5"] { --av: var(--field-inorganic); }
.avatar-sm { width: 26px; height: 26px; font-size: 11px; border-width: 1.5px; }
.avatar-lg { width: 52px; height: 52px; font-size: 21px; }

.friend-row {
  display: flex;
  align-items: center;
  gap: var(--s3);
  /* Negative inline margin so the hover tint bleeds to the card's padding edge and the row reads
     as one target, while the text still lines up with the heading above it. */
  padding: var(--s2) var(--s3);
  margin-inline: calc(var(--s3) * -1);
  border-radius: var(--radius);
  transition: background var(--dur) var(--ease);
}
.friend-row + .friend-row { box-shadow: 0 -1px 0 var(--border); }
.friend-row:hover { background: var(--raised); }
.friend-row-main { min-width: 0; flex: 1; }
.friend-row-name { display: block; font-size: 14px; font-weight: 600; }
.friend-row-sub { display: block; font-size: 12px; color: var(--faint); }
.friend-row-actions { display: flex; align-items: center; gap: var(--s2); flex-wrap: wrap; }
.friend-row-name-btn {
  appearance: none;
  background: none;
  border: none;
  padding: 0;
  font: inherit;
  font-weight: 600;
  font-size: 14px;
  color: var(--text);
  cursor: pointer;
  text-align: left;
}
.friend-row-name-btn:hover { color: var(--brand-text); text-decoration: underline; }

/* Icon-only trailing actions (Poke / Remove in the friends list — see js/pages/friends/people.js
   for why they lost their labels). Square at .btn-sm's own height so they read as the same control
   family as the filled Message beside them, just quieter. */
.friend-row-icon {
  display: inline-grid;
  place-items: center;
  width: 30px;
  height: 30px;
  padding: 0;
  color: var(--muted);
}
.friend-row-icon svg { display: block; }
.friend-row-icon.is-danger { color: color-mix(in srgb, var(--danger) 72%, var(--text)); }
.friend-row-icon.is-danger:hover {
  color: var(--danger);
  border-color: color-mix(in srgb, var(--danger) 45%, transparent);
}
/* Poked: brand-filled and disabled for the cooldown. `.btn:disabled`'s 0.4 opacity would fade the
   one confirmation the button has left now that it carries no text. */
.friend-row-icon.is-done,
.friend-row-icon.is-done:disabled {
  opacity: 1;
  color: var(--brand-text);
  background: var(--brand-faint);
  border-color: var(--brand-dim);
}
.friend-row-icon.is-warn {
  color: var(--danger);
  border-color: color-mix(in srgb, var(--danger) 45%, transparent);
}

@media (max-width: 640px) {
  /* Actions drop to their own full-width line rather than being squeezed beside a truncated name. */
  .friend-row { flex-wrap: wrap; }
  .friend-row-main { flex-basis: calc(100% - 40px - var(--s3)); }
  .friend-row-actions { width: 100%; }
}

/* Small numeral pill on the Friends sidebar item — flex:none + margin-left:auto pushes it to the
   trailing edge without disturbing .sidebar-item-label's own layout. */
.sidebar-badge {
  flex: none;
  margin-left: auto;
  min-width: 18px;
  height: 18px;
  padding: 0 5px;
  border-radius: 999px;
  background: var(--brand);
  color: var(--brand-ink);
  font-size: 11px;
  font-weight: 700;
  line-height: 18px;
  text-align: center;
}

/* Message thread inside the friends.js message modals — bubbles left (them) / right (me).
   .modal-scroll's shared gap is var(--s5) (24px), which reads as a list of announcements rather
   than a conversation, so a thread overrides it rather than changing the rule every modal uses. */
.modal-scroll.is-thread { gap: 2px; align-content: start; }

.message-row { display: flex; align-items: flex-end; gap: var(--s2); }
.message-row.is-mine { flex-direction: row-reverse; }
/* A run of messages from the same sender keeps the avatar's indent but hides the repeat, so the
   run reads as one turn in the conversation. */
.message-row.is-follow { margin-top: 0; }
.message-row.is-follow .avatar { visibility: hidden; }
.message-row:not(.is-follow) { margin-top: var(--s3); }
.message-row:first-child { margin-top: 0; }

.message-stack { display: grid; gap: 2px; min-width: 0; max-width: 78%; }
.message-row.is-mine .message-stack { justify-items: end; }

.message-bubble {
  padding: 8px 12px;
  border-radius: 16px;
  background: var(--raised);
  font-size: 13.5px;
  line-height: 1.4;
  overflow-wrap: anywhere;
}
.message-bubble.is-mine {
  background: var(--brand-faint);
  color: var(--brand-text);
  border: 2px solid var(--brand-dim);
}
.message-time { font-size: 11px; color: var(--faint); padding-inline: 4px; }
/* Group chat only — a 1:1 thread never needs to label who sent a bubble, since there's only ever
   one other party. */
.message-sender {
  display: block;
  font-size: 11px;
  font-weight: 600;
  color: var(--faint);
  padding-inline: 4px;
}

/* The global poke toast, appended once to <body> by js/lib/account.js. Fixed rather than part of
   any page's layout, since a poke can land while the visitor is on any page. */
.poke-banner {
  position: fixed;
  top: var(--s4);
  right: var(--s4);
  z-index: 40;
  display: flex;
  align-items: center;
  gap: var(--s3);
  max-width: min(360px, calc(100vw - 2 * var(--s4)));
  padding: var(--s3) var(--s4);
  border-radius: 16px;
  border: 2px solid var(--brand-dim);
  background: var(--bg);
  box-shadow: 0 10px 24px -8px color-mix(in srgb, var(--brand) 30%, transparent);
  font-size: 13.5px;
  animation: tile-in 320ms var(--ease) backwards;
}
.poke-banner[hidden] { display: none; }
.poke-banner-text { flex: 1; min-width: 0; }
.poke-banner-close {
  flex: none;
  appearance: none;
  background: none;
  border: none;
  color: var(--muted);
  cursor: pointer;
  font-size: 14px;
  line-height: 1;
  padding: 2px;
}
.poke-banner-close:hover { color: var(--text); }

/* ── Responsive ────────────────────────────────────────────────────── */

@media (max-width: 900px) {
  .lesson-layout { grid-template-columns: 1fr; gap: var(--s5); }
  .lesson-rail { position: static; }

  /* No inline PDF here (the iframe is hidden below), so the viewer is just a card holding an
     "Open PDF" prompt — it has no page to be tall for. The `:has` selector is repeated rather
     than overridden by `.exam-viewer` alone, which would lose on specificity. */
  .exam-viewer,
  .exam-viewer:has(.viewer-stage iframe) { height: auto; min-height: 60vh; }
  /* iframe PDF rendering is unreliable on mobile browsers — prefer an explicit open link */
  .viewer-stage iframe { display: none; }
  .viewer-stage .mobile-open { display: grid; }
}

@media (min-width: 901px) {
  .viewer-stage .mobile-open { display: none; }
}

@media (max-width: 640px) {
  /* The entire phone story for the skill route: shrink the badge and let each row scroll its own
     overflow sideways. Nothing about a row's *shape* changes — `.path-row` already wraps only its
     panel — so there is no second layout here, only smaller numbers and a `-webkit-overflow-
     scrolling` nicety.

     This is where the old atom was at its worst. A camera fitted the whole diagram to the width, so
     390px landed at zoom ≈ 0.15: badges around 19px and titles hidden below LABEL_FLOOR_ZOOM,
     measurably worse than the top-down tree the atom itself had replaced. There is no camera, so a
     badge is 72px here because this rule says 72px. */
  .path { --badge: 72px; --node-gap: 18px; }
  /* Tighter end padding on a phone, set once so the row and its connector stay in step. */
  .path { --row-edge-pad: var(--s2); }
  .path-row-track { -webkit-overflow-scrolling: touch; }
  .node-seal { width: 19px; height: 19px; font-size: 11px; }
  .node-label { font-size: 14px; max-width: 16ch; }
  /* Same 24-unit viewBox, scaled down as a whole — the ceiling and the box height come down
     together, or `meet` letterboxes the glyph exactly as it did at desktop size. */
  .arrow-glyph { max-width: 120px; height: 16px; }
  .arrow-glyph-vertical { width: 20px; height: 54px; }
  .path-arrow .arrow-glyph { width: calc(100% - 6px); }
  .path-arrow-label { font-size: 12px; }
  .path-item.is-next .node-label { margin-top: 34px; }
  .node-panel-wrap[data-open="true"] .node-panel { padding: var(--s4); }

  .modal-scroll { padding: var(--s4); }
  .modal-head { padding: var(--s4) var(--s4) var(--s3); }
  .modal-foot { padding: var(--s3) var(--s4); }
  /* A lone confirm button spans the footer; a composer's Send must not, or it takes half the row
     from the input it sits next to. */
  .modal-foot .btn { flex: 1; }
  .modal-foot:has(.search) .btn { flex: none; }
}
