/* ============================================================
   Mr Fraction's Word Problem Express
   Visual language derived from Mr. Fraction's Factory.
   Tokens verified by tools/check-contrast.ps1 — 39 pairs, 0 failing.
   ============================================================ */

/* ---------- Self-hosted faces (offline, no CDN, no tracking) ---------- */
@font-face {
  font-family: 'Atkinson Hyperlegible';
  src: url('../fonts/AtkinsonHyperlegible-400.woff2') format('woff2');
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Atkinson Hyperlegible';
  src: url('../fonts/AtkinsonHyperlegible-700.woff2') format('woff2');
  font-weight: 700; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Atkinson Hyperlegible';
  src: url('../fonts/AtkinsonHyperlegible-400i.woff2') format('woff2');
  font-weight: 400; font-style: italic; font-display: swap;
}
@font-face {
  font-family: 'Black Han Sans';
  src: url('../fonts/BlackHanSans-400.woff2') format('woff2');
  font-weight: 400; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Libre Baskerville';
  src: url('../fonts/LibreBaskerville-400i.woff2') format('woff2');
  font-weight: 400; font-style: italic; font-display: swap;
}

:root {
  /* Surfaces */
  --cream:        #F5EDE0;
  --cream-light:  #FDF8F0;
  --cream-mid:    #EDE0CC;
  --brown-dark:   #2C2214;

  /* Ink */
  --ink:          #2C2214;
  --ink-mid:      #5A3E28;
  --ink-muted:    #6B5138;
  --ink-on-dark:  #F3EDE1;

  /* Accent */
  --orange:       #C96A1F;
  --orange-deep:  #A85413;
  --orange-light: #E8843A;

  /* Status */
  --go:           #45742B;
  --stop:         #A32E22;
  --caution:      #A85413;
  --info:         #2A5FA0;

  /* Edges */
  --border:        #C8B89A;
  --border-strong: #8F7F63;
  --shadow:        rgba(44, 34, 20, .12);
  --shadow-lift:   rgba(44, 34, 20, .18);

  /* Lines */
  --line-change:    #45742B;
  --line-compare:   #2A5FA0;
  --line-groups:    #A85413;
  --line-ratio:     #2B7166;
  --line-partwhole: #A32E22;
  /* Percent is a ROUTE, not a sixth schema (see data.js), but it carries a
     colour because the map card is the half of the hybrid that makes it feel
     first-class. Violet: the one hue not already spoken for by the five, so it
     never reads as a near-miss of a line a student already knows.
     Contrast MEASURED, not assumed — see the note beside the five above. */
  --line-percent:   #6B3FA0;

  /* Type */
  --font-display: 'Black Han Sans', 'Arial Black', sans-serif;
  --font-body:    'Atkinson Hyperlegible', Verdana, Tahoma, sans-serif;
  --font-serif:   'Libre Baskerville', Georgia, serif;
  --font-size-base: 19px;
  --line-height-body: 1.6;
  --letter-spacing: .02em;
  --word-spacing: .04em;
  --measure: 64ch;

  /* Space */
  --su: 8px;
  --radius:    12px;
  --radius-sm: 8px;
  --radius-xs: 5px;
  --target-min: 44px;

  --tint: transparent;
}

/* Reader font options */
:root[data-font="rounded"]  { --font-body: 'Century Gothic', 'URW Gothic', 'Trebuchet MS', sans-serif; }
:root[data-font="dyslexic"] { --font-body: 'OpenDyslexic', 'Comic Sans MS', Verdana, sans-serif; }
/* The root font-size moves with the setting too. Only `--font-size-base`
   changed before, so everything sized in rem — the question in the question
   panel, the answer choices, the lever, Mr Fraction — stayed exactly the same
   size at "150%" while the story grew around it (Cycle 31, theme-reviewer). */
:root[data-scale="125"] { --font-size-base: 23.75px; font-size: 125%; }
:root[data-scale="150"] { --font-size-base: 28.5px; font-size: 150%; }

/* ---------- Base ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
body {
  margin: 0;
  background: var(--cream);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: var(--font-size-base);
  line-height: var(--line-height-body);
  letter-spacing: var(--letter-spacing);
  word-spacing: var(--word-spacing);
  text-align: left;
  overflow-x: hidden;
}
img, svg { max-width: 100%; }
p { margin: 0 0 1.3em; max-width: var(--measure); }
ul, ol { max-width: var(--measure); }
em, i { font-style: normal; font-weight: 700; }   /* bold, never italic */

h1, h2, h3 { margin: 0 0 calc(var(--su) * 2); line-height: 1.15; }
.display { font-family: var(--font-display); letter-spacing: .04em; font-weight: 400; }
h1 { font-family: var(--font-display); font-weight: 400; letter-spacing: .04em; font-size: 1.5rem; }
h2 { font-family: var(--font-display); font-weight: 400; letter-spacing: .04em; font-size: 1.35rem; }
h3 { font-size: 1.05rem; font-weight: 700; letter-spacing: .01em; }

/* The eyebrow — the reference's signature move */
.eyebrow {
  display: block;
  font-family: var(--font-body);
  font-size: .62em;
  font-weight: 700;
  letter-spacing: .12em;
  text-transform: uppercase;
  color: var(--orange-deep);
  margin-bottom: 4px;
}

/* Tint overlay for visual stress */
body::after {
  content: ""; position: fixed; inset: 0;
  background: var(--tint); pointer-events: none;
  mix-blend-mode: multiply; z-index: 90;
}

/* ---------- Ambient life (decorative, behind everything) ---------- */
.ambient { position: fixed; inset: 0; pointer-events: none; z-index: 0; overflow: hidden; }
.amb {
  position: absolute;
  color: var(--ink);
}
.amb svg { display: block; width: 100%; height: 100%; }
@keyframes wheel-spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
@keyframes steam-drift {
  0%   { transform: translate(0, 0) scale(1);      opacity: 0; }
  15%  { opacity: .5; }
  100% { transform: translate(22vw, -14vh) scale(1.5); opacity: 0; }
}
.amb-w1 { top: -40px;  left: -40px;  width: 190px; height: 190px; opacity: .11; animation: wheel-spin 26s linear infinite; }
.amb-w2 { bottom: 30px; right: -50px; width: 250px; height: 250px; opacity: .10; animation: wheel-spin 34s linear infinite reverse; }
.amb-w3 { top: 46%;    right: -30px; width: 130px; height: 130px; opacity: .09; animation: wheel-spin 18s linear infinite; }
.amb-w4 { bottom: -30px; left: 8%;   width: 160px; height: 160px; opacity: .08; animation: wheel-spin 30s linear infinite reverse; }
.amb-w5 { top: 12%;    left: 14%;    width: 78px;  height: 78px;  opacity: .08; animation: wheel-spin 14s linear infinite; }
.amb-w6 { bottom: 26%; right: 12%;   width: 88px;  height: 88px;  opacity: .07; animation: wheel-spin 22s linear infinite reverse; }
.amb-s1 { bottom: 8%;  left: 4%;  width: 120px; height: 120px; opacity: .07; animation: steam-drift 52s linear infinite; }
.amb-s2 { bottom: 18%; left: 34%; width: 90px;  height: 90px;  opacity: .06; animation: steam-drift 66s linear infinite 12s; }
.amb-s3 { bottom: 2%;  left: 62%; width: 140px; height: 140px; opacity: .05; animation: steam-drift 58s linear infinite 26s; }

/* ---------- Focus ---------- */
:focus-visible { outline: 3px solid var(--info); outline-offset: 2px; border-radius: 4px; }

.skip-link {
  position: absolute; left: -9999px;
  background: var(--brown-dark); color: var(--ink-on-dark);
  padding: calc(var(--su) * 1.5) calc(var(--su) * 2);
  z-index: 300; text-decoration: none; font-weight: 700;
}
.skip-link:focus { left: var(--su); top: var(--su); }

.visually-hidden {
  position: absolute !important; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ---------- Shell ---------- */
.shell { min-height: 100vh; display: flex; flex-direction: column; position: relative; z-index: 1; }

/* Station sign — enamel plate */
.station-sign {
  position: sticky; top: 0; z-index: 100;
  background:
    linear-gradient(180deg, #3A2E1C 0%, var(--brown-dark) 62%, #241B10 100%);
  color: var(--ink-on-dark);
  border-bottom: 3px solid var(--orange);
  box-shadow: 0 3px 16px var(--shadow);
}
/* HEIGHT, MEASURED AGAINST THE SISTER SITE.
   Mr. Fraction's Factory runs a 70px sticky bar: 10px 24px padding, a 48px
   logo mark, 39px buttons. Ours was 135px — 18.8% of a 720px viewport gone
   before any content, against their 9.7%.

   Three things were paying for that, and the biggest was not the padding:
     - the portrait was 52px wide, which at its 120x130 viewBox makes it 56px
       tall, and it sat in an inline span whose line-height added 9 more. 65px
       of a 89px row was one graphic.
     - 12px vertical padding against their 10px.
     - the ticker underneath at 43px, a third of the whole header.

   The pills deliberately keep --target-min (44px) rather than matching the
   sister site's 39px buttons. That is this project's own accessibility floor,
   the row is no taller for it once the portrait is smaller, and it is not a
   thing to give up for 5px. */
.sign-inner {
  max-width: 1180px; margin: 0 auto;
  padding: var(--su) calc(var(--su) * 3);
  display: flex; align-items: center; gap: calc(var(--su) * 2);
  /* NOWRAP AT EVERY WIDTH, not just on phones.
     This was `flex-wrap: wrap`, and fixing 1280px and 375px left 768px at
     146px — TALLER than the 135px I started from. In between the two sizes I
     had looked at, the nav dropped onto its own row and the title wrapped
     beside it. Two good measurements and a broken middle.
     `min-width: 0` on the text block is the other half: a flex item will not
     shrink below its content's intrinsic width without it, which is what was
     forcing the wrap in the first place. */
  flex-wrap: nowrap;
}
.sign-text { min-width: 0; }
/* flex, not inline: an inline replaced element sits on the text baseline and
   carries the line-box descender with it, which was adding 9px of nothing. */
.sign-mark { width: 40px; flex-shrink: 0; display: flex; align-items: center; }
.sign-mark svg { display: block; width: 100%; height: auto; }
.sign-text { display: flex; flex-direction: column; line-height: 1.05; }
.sign-title {
  font-family: var(--font-display); font-weight: 400;
  /* Fluid, not a step at a breakpoint. "Mr Fraction's Word Problem Express" is
     34 characters and it is the only thing in this bar that can wrap; at a
     fixed size it went to five lines in the tight middle widths and the row
     grew with it. */
  font-size: clamp(12px, 1.1vw + 4px, 17px);
  letter-spacing: .05em; margin: 0;
  color: var(--ink-on-dark);
}
/* Stays at 10px. It was briefly 9px while chasing height, which bought about
   one pixel — the row is set by the 44px pills, not by this. The smallest text
   on the site is not the place to save nothing. */
.sign-sub {
  font-family: var(--font-display); font-size: 10px;
  letter-spacing: .16em; text-transform: uppercase;
  color: var(--orange-light); margin-top: 2px;
}
/* nowrap: with wrap, the two pills stacked into a 96px column the moment the
   row got tight, and THAT — not the title — was the 139px header sitting
   between 641px and 833px wide. Found by sweeping every width in an iframe
   rather than spot-checking desktop and phone, which is how it hid. */
.sign-nav { margin-left: auto; display: flex; gap: var(--su); flex-wrap: nowrap; }

/* Departure-board ticker */
.ticker {
  background: #1E1710;
  border-top: 1px solid rgba(243,237,225,.10);
  overflow: hidden; white-space: nowrap; padding: 3px 0;
  /* .ticker-track is inline-flex, so it sits in THIS element's line box — and
     the line box was 30px tall around a 16px track, because it inherited the
     19px body font. The strip was twice the height of the thing inside it.
     line-height:0 collapses the box onto the track; the track's own children
     set their own. Measured: 37px -> 22px. */
  line-height: 0; font-size: 10px;
}
.ticker-track { display: inline-flex; animation: ticker-scroll 34s linear infinite; }
.ticker-item {
  display: inline-flex; align-items: center;
  font-family: var(--font-display); font-size: 11px;
  /* Explicit, because .ticker zeroes its own line-height to collapse the line
     box the track sits in — and that inherits. Without this the text clipped
     to 5px. The zero belongs on the parent only. */
  line-height: 1.6;
  letter-spacing: .14em; text-transform: uppercase;
  color: #BCAE97; padding: 0 14px;
}
.ticker-dot {
  display: inline-block; width: 5px; height: 5px; border-radius: 50%;
  background: var(--orange-light); margin: 0 6px; vertical-align: middle;
}
@keyframes ticker-scroll { from { transform: translateX(0); } to { transform: translateX(-50%); } }

/* Nav pills */
.pill {
  font-family: var(--font-body); font-size: 13px; font-weight: 700;
  letter-spacing: .03em;
  display: inline-flex; align-items: center; gap: 6px;
  min-height: var(--target-min); padding: 8px 16px;
  border-radius: 24px; border: 2px solid rgba(243,237,225,.34);
  background: transparent; color: var(--ink-on-dark);
  cursor: pointer; transition: background .18s, border-color .18s, transform .18s;
  white-space: nowrap;
}
.pill:hover { background: rgba(243,237,225,.12); border-color: var(--ink-on-dark); }
/* "For Teachers" is a link wearing a pill, and a link brings an underline.
   And `display: inline-flex` above beats the `hidden` attribute, which is how
   that pill is kept off every screen but the home map — so say it again. */
a.pill { text-decoration: none; }
.pill[hidden] { display: none; }
/* A third pill costs the title room, so its label sheds words as the width
   drops — "For Teachers", then "Teachers", then the icon alone. Each part is
   clipped rather than removed, so the accessible name is "For Teachers" at
   every width. Breakpoints are measured, not picked: at 641px the full label
   pushed the header from 84px to 108px, and at 375–480px from 56px to 81px;
   at 320px it ran off the edge. */
@media (max-width: 720px) {
  .teach-for {
    position: absolute !important; width: 1px; height: 1px;
    padding: 0; margin: -1px; overflow: hidden;
    clip: rect(0 0 0 0); white-space: nowrap; border: 0;
  }
}
@media (max-width: 500px) {
  /* Icon alone would be 37px wide — under the 44px target every other
     control here meets. */
  #btn-teachers { min-width: var(--target-min); justify-content: center; }
  .sign-nav { gap: calc(var(--su) * .5); }
  .teach-word {
    position: absolute !important; width: 1px; height: 1px;
    padding: 0; margin: -1px; overflow: hidden;
    clip: rect(0 0 0 0); white-space: nowrap; border: 0;
  }
}
.pill[aria-expanded="true"], .pill.active {
  background: var(--orange-deep); border-color: var(--orange-deep); color: #fff;
}
/* 375–440px, home map only: three pills leave the title so little room it
   wraps one line more (56px header -> 81px at 375). The portrait stands aside
   while the Teachers pill is showing — the same trade the phone rules below
   weigh, and here the wordmark stays. Below 375 the wordmark goes instead
   (see the very-narrow rules), so the portrait comes back there. */
@media (min-width: 375px) and (max-width: 440px) {
  .station-sign:has(#btn-teachers:not([hidden])) .sign-mark { display: none; }
}

main { flex: 1; width: 100%; max-width: 1180px; margin: 0 auto; padding: calc(var(--su) * 4) calc(var(--su) * 2.5) calc(var(--su) * 9); }

footer.rail-foot {
  background: var(--brown-dark); color: #C3B9A6;
  padding: calc(var(--su) * 2.5); font-size: 13px;
  border-top: 3px solid var(--orange-deep);
}
footer.rail-foot p { max-width: 72ch; margin: 0 auto; }
footer.rail-foot .foot-lead { font-size: 14px; }
/* Emphasis has to lighten, not bold-only: at this size on --brown-dark, weight
   alone doesn't read as emphasis. */
footer.rail-foot .foot-lead strong { color: #E8DFCE; }

/* ---------- Journey panel: the leg you're travelling, above the title ---------- */
.journey {
  border: 2px solid var(--border-strong);
  border-radius: var(--radius);
  overflow: hidden;
  margin-bottom: calc(var(--su) * 3);
  background: var(--cream-light);
  box-shadow: 0 2px 6px var(--shadow);
}
.journey-map { line-height: 0; background: #DCEEF6; }
.leg-svg { display: block; width: 100%; height: clamp(150px, 25vw, 250px); }
/* This animates an INNER group; the car's placement is on its parent. Putting
   both on one element let the CSS transform override the SVG transform
   attribute and stack every car at the origin. */
.leg-car {
  transform-box: fill-box; transform-origin: center;
  animation: leg-car-in .45s cubic-bezier(.2,.85,.3,1) backwards;
  animation-delay: calc(var(--lc-i) * 90ms);
}
@keyframes leg-car-in {
  from { opacity: 0; transform: scale(.55); }
  to   { opacity: 1; transform: scale(1); }
}

.journey-bar {
  display: flex; align-items: center; gap: calc(var(--su) * 2);
  padding: calc(var(--su) * 1.25) calc(var(--su) * 2);
  border-top: 2px solid var(--border-strong);
  background: var(--cream-mid);
}
.journey-bar .eyebrow { margin: 0; white-space: nowrap; }
.journey-dots { list-style: none; display: flex; align-items: center; gap: 7px; margin: 0; padding: 0; }
.jd { width: 13px; height: 13px; border-radius: 50%; border: 2.5px solid var(--border-strong); background: var(--cream-light); }
.jd[data-s="done"] { background: var(--go); border-color: var(--go); }
.jd[data-s="here"] {
  background: var(--orange-deep); border-color: var(--orange-deep);
  width: 17px; height: 17px; box-shadow: 0 0 0 3px rgba(168,84,19,.22);
}

@media (prefers-reduced-motion: reduce) { .leg-car { animation: none; } }

/* ---------- Station header ---------- */
.station-head {
  display: flex; align-items: flex-start; gap: calc(var(--su) * 2);
  padding-bottom: calc(var(--su) * 2.5);
  border-bottom: 2px solid var(--border);
  margin-bottom: calc(var(--su) * 3);
}
.station-thumb { width: 68px; height: 68px; flex-shrink: 0; }
.station-meta { flex: 1; min-width: 0; }
.station-meta h2 { font-size: 1.55rem; margin: 0; }
.station-desc { color: var(--ink-muted); font-size: 14px; margin: 6px 0 0; max-width: 46ch; }

/* ---------- Buttons ---------- */
.btn {
  font-family: var(--font-body); font-size: 15px; font-weight: 700;
  letter-spacing: .02em;
  min-height: var(--target-min); min-width: var(--target-min);
  padding: calc(var(--su) * 1.5) calc(var(--su) * 3);
  border-radius: var(--radius-sm);
  border: 2px solid var(--orange-deep);
  background: var(--orange-deep); color: #fff;
  cursor: pointer; text-align: center;
  box-shadow: 0 2px 0 rgba(44,34,20,.18);
  transition: transform .15s, box-shadow .15s, background .15s;
}
.btn:hover { transform: translateY(-2px); box-shadow: 0 4px 0 rgba(44,34,20,.20); }
.btn:active { transform: translateY(0); box-shadow: 0 1px 0 rgba(44,34,20,.20); }
.btn[disabled] { opacity: .45; cursor: not-allowed; transform: none; box-shadow: none; }
.btn-secondary {
  background: var(--cream-light); color: var(--ink-mid);
  border-color: var(--border-strong); box-shadow: 0 2px 0 rgba(44,34,20,.10);
}
.btn-secondary:hover { background: var(--cream-mid); border-color: var(--ink-mid); }
.btn-quiet {
  background: transparent; color: var(--ink-muted);
  border: 2px dashed var(--border-strong); box-shadow: none;
  padding: var(--su) calc(var(--su) * 2); font-size: 14px;
}
.btn-quiet:hover { background: var(--cream-mid); color: var(--ink); transform: none; }
.btn-row { display: flex; gap: calc(var(--su) * 2); flex-wrap: wrap; margin-top: calc(var(--su) * 3); }

/* ---------- Cards ---------- */
.card {
  background: var(--cream-light);
  border: 1.5px solid var(--border);
  border-radius: var(--radius);
  padding: calc(var(--su) * 3);
  margin-bottom: calc(var(--su) * 3);
  box-shadow: 0 1px 3px var(--shadow);
}
/* THE WAY BACK TO A TRIP IN PROGRESS.
   It has to out-rank five line cards, a hero image and the rail map on the same
   screen, because it is the only thing on the map that is about THIS student
   rather than about the railway. Same accent grammar the Plan models use for a
   settled answer — an orange edge on the warmer cream — so "this one is yours"
   reads the same way here as it does inside a station.

   AFTER `.card`, and that is not tidiness. It carries `class="card resume-card"`
   so both rules match at the same specificity, and the later one wins: written
   above `.card` its background was overridden by the very rule it is modifying,
   which is a defect that looks like a typo and reads like nothing at all. */
.resume-card {
  border-left: 5px solid var(--orange-deep);
  background: var(--cream-mid);
}
.card-title {
  display: flex; align-items: center; gap: var(--su);
  font-size: 13px; font-weight: 700; letter-spacing: .1em;
  text-transform: uppercase; color: var(--ink);
  margin-bottom: calc(var(--su) * 1.75);
}

/* The problem panel — where the art direction deliberately stops */
.problem-well {
  background: var(--cream-mid);
  border-left: 7px solid var(--line-current, var(--line-partwhole));
  border-radius: var(--radius-xs);
  padding: calc(var(--su) * 3);
  margin: calc(var(--su) * 2) 0 calc(var(--su) * 3);
  font-size: 1.02em; line-height: 1.75;
  max-width: var(--measure);
}
.problem-well p { margin: 0 0 .8em; }
.problem-well p:last-child { margin-bottom: 0; }
/* Sentences run on as prose by default. "Chunk sentences" in the reading
   panel breaks them onto their own lines for anyone who reads better that
   way — that option is the reason the sentences stay separately marked up. */
.problem-well .s { }
:root[data-chunked="on"] .problem-well .s { display: block; margin-bottom: .9em; }
:root[data-chunked="on"] .problem-well .s:last-child { margin-bottom: 0; }

/* Sentences the student can tap — the Platform Check at Read 1. These are real
   <button>s so keyboard and screen-reader users get them for free, but they
   have to keep reading as prose: a paragraph of button chrome stops looking
   like something a person wrote, and this is the phase whose entire job is
   getting the story read. Hence: inherit everything, and mark them with an
   underline that says "touchable" rather than a box that says "control". */
.problem-well .pickable {
  font: inherit; color: inherit; letter-spacing: inherit;
  background: none; border: 0; margin: 0; padding: 0;
  text-align: inherit; cursor: pointer;
  /* DISPLAY IS THE WHOLE BUG, AND IT WAS THE DEFAULT.
     A <button> is inline-block, which is an atomic box: it cannot break across
     lines. So every sentence jumped whole to its own line and the paragraph
     became six stacked blocks — measured, six client rects of one each, against
     4/4/3/2/3/2 for the same sentences rendered as spans one screen earlier.
     At 360px all six collapsed to identical full-width blocks: a radio list,
     not a story. The comment above says these must read as prose; this line is
     what makes that true. */
  display: inline;
  /* Underline via text-decoration, NOT border-bottom. A border paints once
     around an inline-block box — on a wrapped sentence it drew under the last
     line only. text-decoration paints per line fragment automatically. */
  text-decoration: underline;
  text-decoration-style: dotted;
  text-decoration-thickness: 1.5px;
  text-underline-offset: .2em;
  text-decoration-color: var(--border);
  border-radius: 3px;
}
.problem-well .pickable:hover,
.problem-well .pickable:focus-visible {
  background: var(--cream-light);
  text-decoration-color: var(--ink-muted);
}
/* Chosen. Not colour alone — the fill plus dotted-to-solid plus the weight
   change all survive greyscale and forced-colors (WCAG 1.4.1).
   `box-decoration-break: clone` gives every line fragment its own padding and
   rounded ends, so a selection that wraps stays text-shaped instead of
   becoming one rectangle. The 2px cream ring keeps two picked sentences on
   adjacent lines from merging into a single slab — and the design REQUIRES
   multi-sentence picks, so that case is normal, not an edge. */
.problem-well .pickable[data-picked] {
  background: var(--line-current, var(--line-partwhole));
  color: var(--cream-light);
  text-decoration-style: solid;
  text-decoration-color: currentColor;
  font-weight: 600;
  padding: .04em .16em;
  box-shadow: 0 0 0 2px var(--cream-mid);
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
}
/* The global ring is 3px at 2px offset — into a ~0–3px gap between lines it
   would overlap the sentences above and below. */
.problem-well .pickable:focus-visible { outline-offset: 1px; }
/* After the check resolves the picks stay on screen, marked but inert: the
   map's first column is headed "What you found", and the evidence used to be
   deleted at the exact moment that column appeared. */
.problem-well .pickable[aria-disabled="true"] { cursor: default; }
.problem-well .pickable[aria-disabled="true"]:hover { background: none; text-decoration-color: var(--border); }
.problem-well .pickable[data-picked][aria-disabled="true"],
.problem-well .pickable[data-picked][aria-disabled="true"]:hover {
  background: var(--line-current, var(--line-partwhole));
  text-decoration-color: currentColor;
}

/* The map: what you found, which line it makes it, and why it has to be. The
   student's own row is marked — this is the payoff screen of the first read. */
.table-scroll { overflow-x: auto; margin: calc(var(--su) * 2) 0; }
.map-table {
  /* Capped: at 1280 the "why it has to be" column ran the full 1090px and left
     a lot of dead air to the right of "before, event, after". */
  border-collapse: collapse; width: 100%; min-width: 480px; max-width: 900px;
  font-size: .98em; line-height: 1.55;
}
.map-table th, .map-table td {
  text-align: left; padding: calc(var(--su) * 1.5);
  border-bottom: 1px solid var(--border);
  vertical-align: top;
}
/* The last rule used to float unattached above the button. */
.map-table tbody tr:last-child td { border-bottom: 0; }
.map-table thead th {
  /* Was .78em — 14.2px, uppercase, tracked. Column headings are read, not
     decoration. */
  font-size: .86em; letter-spacing: .07em; text-transform: uppercase;
  color: var(--ink-muted); border-bottom: 2px solid var(--border-strong);
}
/* Line names are display-face territory per ART-DIRECTION §1 — "station signs,
   line names". They were set in body face like any table cell. */
.map-table .line-name {
  font-family: var(--font-display); font-weight: 400; letter-spacing: .03em;
  font-size: 1.06em;
}
.map-table .marker { font-size: 1.15em; }
.map-table td:last-child { color: var(--ink-muted); }
/* The student's own row. A ~16-value tint plus a 5px bar on one cell was not
   saying "you are here" — this is the payoff of the screen. */
.map-table .row-here { background: var(--cream-mid); }
.map-table .row-here td { border-bottom-color: var(--border-strong); }
.map-table .row-here td:first-child {
  box-shadow: inset 5px 0 0 var(--line-current, var(--line-partwhole));
}
/* BOLD, AND IT IS AN ACCESSIBILITY FIX RATHER THAN EMPHASIS.
   Measured 2026-08-10, on the rendered screen: this label is the line's own
   colour on `--cream-mid`, at 19.7px. At weight 400 that is normal text and
   needs 4.5:1 — and three of the five lines miss it. change 4.26, groups 4.09,
   ratio 4.41; only compare (4.98) and partwhole (5.43) pass. The long-standing
   open item was "--line-compare has never had its contrast measured", and
   compare turns out to be one of the two that were fine.

   At weight 700 this clears 18.66px, which is WCAG large text, so the threshold
   becomes 3:1 and all five pass with room — the worst is groups at 4.09.

   Deliberately NOT fixed by darkening the five: that changes the palette on
   every surface of the site and is bound up with the palette reconciliation
   (ROADMAP 5b) that is still the user's open decision. The numbers are recorded
   there so it can be decided properly rather than pre-empted here. Bolding the
   student's own row is defensible on its own terms anyway — it is the one row
   on that table that is about them. */
.map-table .row-here .line-name {
  color: var(--line-current, var(--line-partwhole));
  font-weight: 700;
}
.you-are-here {
  display: inline-block; margin-left: .5em; vertical-align: .08em;
  font-family: var(--font-body); font-size: .62em; font-weight: 700;
  letter-spacing: .08em; text-transform: uppercase;
  padding: .18em .5em; border-radius: 999px;
  background: var(--line-current, var(--line-partwhole)); color: var(--cream-light);
}

/* Below this width a side-scrolling table is the wrong shape for the sentence
   that explains the answer — half of it was unreachable and the line names were
   chopped mid-word ("Equa Grou Expre"). Restack as cards. */
@media (max-width: 560px) {
  .map-table { min-width: 0; display: block; }
  .map-table thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
  .map-table tbody, .map-table tr, .map-table td { display: block; width: auto; }
  .map-table tr {
    border: 2px solid var(--border); border-radius: var(--radius-xs);
    padding: calc(var(--su) * 1.5); margin-bottom: calc(var(--su) * 1.5);
  }
  .map-table td { border-bottom: 0; padding: 0 0 .35em; }
  .map-table tbody tr:last-child td { border-bottom: 0; }
  .map-table .row-here { border-color: var(--line-current, var(--line-partwhole)); border-width: 3px; }
  .map-table .row-here td:first-child { box-shadow: none; }
}

/* (The five checklist cards' rail, `#r1checks`, lived here. The checklist was
   removed 2026-10-20.) */

/* `.hint-text` is 14px, which is fine for chrome and wrong for instructions.
   On these two screens it carries the actual task — "Tap every sentence that
   carries it" — so here it is body-sized. Scoped, so the rest of the site
   keeps the small form. */
.platform-instruction { font-size: 1em; color: var(--ink); max-width: var(--measure); }

/* NO TRANSITION ON THE PICKED FILL.
   `transition: background 120ms` used to live here. Two agents flagged it and
   both were right for the same reason the codebase already records at
   `.car-fill` below: never transition a property that CARRIES state. It also
   made the state unmeasurable in any harness without a frame clock — one agent
   read the un-advanced value and filed a CRITICAL saying the picked sentence
   renders invisible at 1.23:1, which real rendered pixels then disproved.
   A property that only reads correctly on a compositing page is a property
   that will be mismeasured again. */

.q-sentence { font-weight: 700; }
.q-sentence.highlight {
  background: #FBE9B8; box-shadow: 0 0 0 4px #FBE9B8; border-radius: 3px;
  /* keeps the highlight intact when the question wraps across lines */
  -webkit-box-decoration-break: clone; box-decoration-break: clone;
}
/* The blank stood a full line-box tall (34px against a 34px line advance), so
   blanks on consecutive lines abutted at 0px and at some widths overlapped by
   1px — reading as one damaged column rather than two separate blanks.
   Measured on the platform screen at 360px and 1280px. */
.masked {
  display: inline-block; background: var(--ink-muted);
  color: transparent; border-radius: 3px; padding: 0 .12em; user-select: none;
  line-height: .92; vertical-align: -.06em;
}
/* Inside a picked sentence the blank was --ink-muted brown on the line's red
   fill, which read as a hole punched in the highlight. It is `aria-hidden`, so
   restyling it costs nothing. */
.problem-well .pickable[data-picked] .masked {
  background: rgba(253, 248, 240, .55);
}
.num { font-weight: 700; font-variant-numeric: tabular-nums; white-space: nowrap; }

/* ---------- Route strip ---------- */
.route-strip { background: var(--cream-mid); border-bottom: 1px solid var(--border); }
.route-inner { max-width: 1180px; margin: 0 auto; padding: calc(var(--su)*1.5) calc(var(--su)*2.5); }
.route { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; align-items: center; }
.route li { display: flex; align-items: center; }
.route .node {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: 12px; font-weight: 700; letter-spacing: .04em;
  padding: 6px 12px; border-radius: 999px;
  border: 2px solid var(--border-strong); color: var(--ink-muted);
  background: var(--cream-light); white-space: nowrap;
}
.route .connector { width: 20px; height: 4px; background: var(--border-strong); opacity: .6; }
.route li[data-state="done"] .node { border-color: var(--go); color: var(--go); }
.route li[data-state="done"] + li .connector { background: var(--go); opacity: .8; }
.route li[data-state="current"] .node {
  border-color: var(--orange-deep); background: var(--orange-deep); color: #fff;
  box-shadow: 0 2px 0 rgba(44,34,20,.2);
}

/* ---------- Phases ---------- */
.phase-label {
  display: inline-block; font-size: 11px; font-weight: 700;
  letter-spacing: .14em; text-transform: uppercase;
  color: var(--orange-deep); margin-bottom: 6px;
}

/* ---------- Forms ---------- */
label { display: block; font-weight: 700; margin-bottom: var(--su); max-width: var(--measure); }
.hint-text { font-weight: 400; color: var(--ink-muted); font-size: 14px; display: block; margin-top: 4px; }
input[type="text"], input[type="number"], textarea, select {
  font-family: var(--font-body); font-size: 1rem;
  width: 100%; max-width: var(--measure);
  min-height: var(--target-min);
  padding: calc(var(--su) * 1.25);
  border: 2px solid var(--border-strong);
  border-radius: var(--radius-sm);
  background: var(--cream-light); color: var(--ink);
}
input:focus, textarea:focus, select:focus { border-color: var(--info); }
textarea { min-height: calc(var(--target-min) * 2); resize: vertical; }
select {
  appearance: none; -webkit-appearance: none;
  background-image: linear-gradient(45deg, transparent 50%, var(--ink-mid) 50%),
                    linear-gradient(135deg, var(--ink-mid) 50%, transparent 50%);
  background-position: calc(100% - 20px) calc(50% + 2px), calc(100% - 14px) calc(50% + 2px);
  background-size: 6px 6px, 6px 6px; background-repeat: no-repeat;
  padding-right: 40px;
}
.field { margin-bottom: calc(var(--su) * 3); }
.inline-fields { display: flex; gap: calc(var(--su) * 2); flex-wrap: wrap; }
.inline-fields .field { flex: 1 1 170px; }

/* Choice tiles */
/* margin auto: see the note on .map-lines — as a <ul> this inherits the 64ch
   reading measure and was sitting flush left in a much wider main. */
.choices { list-style: none; padding: 0; margin: 0 auto; display: grid; gap: calc(var(--su) * 1.25); }
.choice {
  display: flex; align-items: flex-start; gap: calc(var(--su) * 1.75);
  width: 100%; text-align: left;
  font-family: var(--font-body); font-size: 1rem;
  min-height: var(--target-min);
  padding: calc(var(--su) * 2);
  border: 2px solid var(--border-strong); border-radius: var(--radius);
  background: var(--cream-light); color: var(--ink); cursor: pointer;
  /* border-color is deliberately NOT transitioned. When a choice is answered
     the colour change was getting stuck part-way through the transition and
     the box kept its unselected grey edge — the answer registered but did
     not look registered. Selection state must never depend on an animation
     completing. */
  transition: transform .15s, box-shadow .15s;
  box-shadow: 0 1px 2px var(--shadow);
}
.choice:hover { border-color: var(--orange-deep); transform: translateY(-2px); box-shadow: 0 4px 10px var(--shadow-lift); }
.choice .marker { font-size: 1.3em; line-height: 1; flex-shrink: 0; }
.choice strong { display: block; font-size: 1.02em; }
.choice small { color: var(--ink-muted); display: block; margin-top: 2px; }
/* An answered choice has to be unmistakable at a glance: a tick or a cross
   appears and the border thickens. Colour is never the only signal (WCAG 1.4.1).

   THE GLYPH HANGS OFF THE CHOICE, NOT OFF A `.marker` CHILD — and that is the
   whole point of this rule. It used to be `.choice[data-result] .marker::after`,
   and a `::after` needs a `.marker` element to attach to. Cycle 30 found FOUR
   builders that emit no marker and then set `data-result` anyway — `data-hcar`
   (app.js:876), `data-strat` (app.js:890), `#unsck` (stations.js:1962) and
   `#estck` (stations.js:2083), the last of which is on the estimate gate. On all
   four, right and wrong differed by hue alone: 1.274:1 on the border and 1.022:1
   on the background, which is nothing at all in greyscale or with red-green CVD.

   Seven other builders did emit a marker and did get the glyph, so the failure
   was invisible on screen and every new `.choice` builder was exempt by default.
   That is this project's `discovered, not listed` defect in its purest form: the
   indicator depended on somebody remembering to emit a sibling. Now the state
   carries its own indicator and a builder cannot opt out of it.

   The comment that used to sit here claimed "the glyph and the border weight
   both carry it". BOTH HALVES WERE FALSE — the glyph was absent on four
   builders, and `border-width` goes 2px->3px for right and wrong ALIKE, so
   weight separates answered from unanswered and never right from wrong. It is
   kept as a second channel for answered-ness, which is what it actually does.

   No box-shadow here: `.choice[disabled]` clears it later in the cascade, so
   a ring set at this point would silently do nothing. */
.choice[data-result="right"] { border-color: var(--go);   background: #F0F6EA; border-width: 3px; }
.choice[data-result="wrong"] { border-color: var(--stop); background: #FBEFED; border-width: 3px; }
.choice[data-result]::before { font-size: 1.25rem; font-weight: 700; line-height: 1; flex-shrink: 0; }
.choice[data-result="right"]::before { content: "\2713"; color: var(--go); }
.choice[data-result="wrong"]::before { content: "\2717"; color: var(--stop); }
/* The builders that DO emit a marker must not now show two glyphs, and a
   zero-font-size marker would still draw a second flex gap. Removed outright. */
.choice[data-result] .marker { display: none; }
.choice[disabled] { cursor: default; opacity: .9; transform: none; box-shadow: none; }
/* :not([data-result]) matters — without it, hovering an already-answered
   choice reset its border back to grey and the answer appeared to un-select. */
.choice[disabled]:not([data-result]):hover { transform: none; border-color: var(--border-strong); }
.choice[disabled][data-result]:hover { transform: none; }

/* ---------- Feedback ---------- */
.feedback { margin-top: calc(var(--su) * 2); max-width: var(--measure); }
.msg {
  display: flex; gap: calc(var(--su) * 1.5); align-items: flex-start;
  padding: calc(var(--su) * 2); border-radius: var(--radius-sm);
  border-left: 5px solid var(--info); background: #EDF2FA;
  margin-bottom: calc(var(--su) * 1.5);
}
.msg .ico { flex-shrink: 0; font-weight: 700; font-size: 1.05em; }
.msg p { margin: 0; }
/* The hint nudge after a run of wrong answers. Set apart from the feedback it
   follows, and quieter than it: the message above is about the answer, this is
   an aside about what is available. A rule rather than a second colour, because
   the msg box already carries a state colour and adding another would make an
   offer of help look like a second verdict. */
.msg p.hint-nudge {
  margin-top: calc(var(--su) * 1.25);
  padding-top: calc(var(--su) * 1.25);
  border-top: 1px dashed var(--border);
  color: var(--ink-muted);
  font-size: .94em;
}
.msg-go      { border-color: var(--go);      background: #F0F6EA; }
.msg-stop    { border-color: var(--stop);    background: #FBEFED; }
.msg-caution { border-color: var(--caution); background: #FCF3E4; }

/* ---------- Mr Fraction ---------- */
.mf-aside { display: flex; gap: calc(var(--su) * 2); align-items: flex-start; margin-bottom: calc(var(--su) * 3); }
.mf-aside .mf-svg { flex-shrink: 0; }
.mf-bubble {
  position: relative;
  background: var(--cream-light);
  border: 2px solid var(--border-strong);
  border-radius: var(--radius);
  padding: calc(var(--su) * 2.25);
  max-width: var(--measure);
  box-shadow: 0 2px 6px var(--shadow);
}
.mf-bubble::before {
  content: ""; position: absolute; left: -11px; top: 26px;
  width: 18px; height: 18px; transform: rotate(45deg);
  background: var(--cream-light);
  border-left: 2px solid var(--border-strong);
  border-bottom: 2px solid var(--border-strong);
}
/* Mr Fraction's voice — the one bounded italic-serif exception (AD-2) */
.mf-bubble p { font-family: var(--font-serif); font-style: italic; font-size: .95em; line-height: 1.65; margin: 0; }
.mf-bubble p + p { margin-top: .8em; }
.mf-bubble strong { font-family: var(--font-body); font-style: normal; }

/* ---------- The Scene: a picture of the problem, beside the words ----------
   Objects count themselves out on arrival, then settle. The filled ones keep
   a slow idle (steam, spin) so the page has life without anything jittering
   while you read. Every state is also in the figure's aria-label and caption. */
.problem-pair {
  display: grid; gap: calc(var(--su) * 2);
  align-items: start;
  margin: calc(var(--su) * 2) 0 calc(var(--su) * 3);
}
@media (min-width: 760px) {
  .problem-pair { grid-template-columns: minmax(0, 1fr) minmax(210px, 300px); }
  .problem-pair.no-scene { grid-template-columns: 1fr; }
}
.problem-pair .problem-well { margin: 0; }

.scene {
  margin: 0; padding: calc(var(--su) * 1.75);
  background: var(--cream-light);
  border: 2px solid var(--border-strong);
  border-radius: var(--radius);
  --sc-ink: var(--ink);
  --sc-fill: var(--line-current, var(--line-partwhole));
  --sc-fill-hi: #FFFFFF;
  --sc-empty: var(--cream-mid);
  --sc-empty-hi: var(--cream-light);
}
.sc-grid { display: flex; flex-wrap: wrap; gap: 6px; justify-content: center; }
.sc-item {
  width: 40px; height: 40px; display: block;
  animation: sc-land .38s cubic-bezier(.2,.85,.3,1) backwards;
  animation-delay: calc(var(--sc-i) * 85ms);
}
.scene[data-count="20"] .sc-item,
.scene[data-count="10"] .sc-item { width: 32px; height: 32px; }
.sc-item svg { display: block; width: 100%; height: 100%; }
@keyframes sc-land {
  from { transform: translateY(-10px) scale(.6); opacity: 0; }
  60%  { transform: translateY(2px)   scale(1.04); opacity: 1; }
  to   { transform: none; opacity: 1; }
}
/* idle life, filled objects only */
.sc-item.is-on .sc-steam {
  transform-box: fill-box; transform-origin: bottom center;
  animation: sc-steam 3.2s ease-in-out infinite;
  animation-delay: calc(var(--sc-i) * 240ms);
}
@keyframes sc-steam {
  0%, 100% { transform: translateY(0)    scaleY(1);   opacity: .55; }
  50%      { transform: translateY(-3px) scaleY(1.15); opacity: .2; }
}
/* Each wheel spins about its OWN centre — they were one group, so the origin
   fell between them and the pair orbited the frame. */
.sc-item.is-on .sc-wheel {
  transform-box: fill-box; transform-origin: center;
  animation: sc-roll 2.6s linear infinite;
}
@keyframes sc-roll { to { transform: rotate(360deg); } }

/* Every illustration gets an idle, and only the counted objects get it — the
   motion then means "this one is included" rather than being decoration.
   The idle lives on the inner <svg> so it composes with the arrival animation
   on the parent instead of replacing it. */
.sc-item.is-on > svg { transform-box: fill-box; transform-origin: center bottom; }

.ic-ball.is-on  > svg { animation: sc-bounce 1.9s cubic-bezier(.3,0,.4,1) infinite; animation-delay: calc(var(--sc-i) * 130ms); }
@keyframes sc-bounce { 0%,100% { transform: translateY(0); } 45% { transform: translateY(-4px); } }

.ic-coin.is-on  > svg { animation: sc-flip 3.4s ease-in-out infinite; animation-delay: calc(var(--sc-i) * 190ms); }
@keyframes sc-flip { 0%,72%,100% { transform: scaleX(1); } 86% { transform: scaleX(.18); } }

.ic-box.is-on   > svg { animation: sc-rock 3s ease-in-out infinite; animation-delay: calc(var(--sc-i) * 160ms); }
@keyframes sc-rock { 0%,100% { transform: rotate(-2.5deg); } 50% { transform: rotate(2.5deg); } }

.ic-horn.is-on  > svg { animation: sc-toot 2.8s ease-in-out infinite; animation-delay: calc(var(--sc-i) * 170ms); }
@keyframes sc-toot { 0%,70%,100% { transform: scale(1) rotate(0deg); } 82% { transform: scale(1.1) rotate(-5deg); } }

.ic-patch.is-on > svg { animation: sc-ripple 3.6s ease-in-out infinite; animation-delay: calc(var(--sc-i) * 120ms); }
@keyframes sc-ripple { 0%,100% { transform: scale(1); } 50% { transform: scale(1.07); } }

.ic-bowl.is-on  > svg { animation: sc-simmer 4.2s ease-in-out infinite; animation-delay: calc(var(--sc-i) * 200ms); }
@keyframes sc-simmer { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-1.5px); } }

.sc-cap {
  margin-top: calc(var(--su) * 1.5);
  font-size: 12.5px; line-height: 1.45; color: var(--ink-mid); text-align: center;
}

/* --- unit mode: ONE object, subdivided --- */
.unit-frame {
  border: 3px solid var(--ink); border-radius: var(--radius-sm);
  padding: 5px; background: var(--cream-mid);
  box-shadow: 0 3px 0 rgba(44,34,20,.18);
}
.unit-grid { display: grid; grid-template-columns: repeat(var(--u-cols), 1fr); gap: 3px; }
.ucell {
  aspect-ratio: 1 / 1; border-radius: 2px;
  border: 1.5px solid rgba(44,34,20,.35);
  display: flex; align-items: center; justify-content: center;
  font-size: 12px; font-weight: 700; color: var(--ink);
  animation: sc-land .34s cubic-bezier(.2,.85,.3,1) backwards;
  animation-delay: calc(var(--u-i) * 55ms);
}
.u-blue { background: var(--line-compare); }
.u-red  { background: var(--line-partwhole); }
.u-unknown {
  background: repeating-linear-gradient(45deg,
    var(--cream-light) 0 5px, var(--cream-mid) 5px 10px);
  animation: sc-land .34s cubic-bezier(.2,.85,.3,1) backwards,
             u-wonder 2.6s ease-in-out infinite;
}
@keyframes u-wonder {
  0%, 100% { box-shadow: inset 0 0 0 0 rgba(168,84,19,0); }
  50%      { box-shadow: inset 0 0 0 2px rgba(168,84,19,.55); }
}

.unit-legend {
  display: flex; flex-wrap: wrap; gap: 4px 12px; justify-content: center;
  margin-top: calc(var(--su) * 1.5); font-size: 12px; color: var(--ink-mid);
}
.ulg { display: inline-flex; align-items: center; gap: 5px; }
.usw {
  width: 13px; height: 13px; border-radius: 2px;
  border: 1.5px solid rgba(44,34,20,.45); flex-shrink: 0;
}
.unit-legend strong { color: var(--ink); }

@media (prefers-reduced-motion: reduce) {
  .ucell, .u-unknown { animation: none; }
  .u-unknown { box-shadow: inset 0 0 0 2px rgba(168,84,19,.55); }
}

@media (prefers-reduced-motion: reduce) {
  .sc-item, .sc-item.is-on > svg { animation: none; transform: none; }
  .sc-item.is-on .sc-steam, .sc-item.is-on .sc-wheel { animation: none; }
}

/* The Grand Tour card. Marked out from the five line cards with a dashed
   edge in neutral ink — it belongs to no single line, so it must not borrow
   any line's colour. */
.line-card-mixed {
  border-left-color: var(--ink-secondary) !important;
  border-style: dashed;
  background: repeating-linear-gradient(135deg,
    var(--cream-light) 0 12px, var(--cream-mid) 12px 24px);
}
/* THE PERCENT CARD reads as a line, because that is the map presence the
   hybrid promised, and as NOT one of the five, because it is not. The five all
   carry a solid left edge; this one is solid in its own colour and dashed
   everywhere else — the same grammar the Grand Tour uses to say "a way of
   travelling, not a place", without borrowing its grey. */
.line-card-percent {
  border-style: dashed;
  border-left-style: solid;
}
.route-more { margin: calc(var(--su) * 2) 0; }
.route-more summary {
  cursor: pointer; font-weight: 700; padding: calc(var(--su));
  border-radius: var(--radius-sm);
}
.route-more summary:focus-visible { outline: 3px solid var(--orange-deep); outline-offset: 2px; }

/* ---------- Animated scenes for the Ratio & Rate Rail ----------
   A rate is something happening over time, so these illustrations run. Every
   moving part is decorative: the caption carries the meaning, and the whole
   thing still reads composed when frozen. */
.rsc-frame {
  border: 3px solid var(--ink);
  border-radius: var(--radius-sm);
  background: linear-gradient(180deg, #E8F2F7 0%, var(--cream-light) 78%);
  box-shadow: 0 3px 0 rgba(44,34,20,.18);
  overflow: hidden;
}
.rsc-svg { display: block; width: 100%; height: auto; }
.rsc-svg [class*="rsc-"] { transform-box: fill-box; }
/* Every staggered part fills BACKWARDS. Without this, an element waiting out
   its animation-delay renders in its base state — fully opaque, at its start
   position — so on load the mixer showed water hanging under the spout while
   the bowl was still over on the flour side, and the press showed its whole
   stack of posters parked in the slot. The delay is meant to be an absence,
   not a freeze-frame. */
.rsc-svg [class*="rsc-"] { animation-fill-mode: backwards; }

/* Rolling stock and machinery */
.rsc-roller   { transform-origin: center; animation: rsc-spin 1.5s linear infinite; }
.rsc-roller-b { animation-duration: 1.9s; }
@keyframes rsc-spin { to { transform: rotate(360deg); } }

/* The press ejecting sheet after sheet — the rate, made visible */
.rsc-sheet { animation: rsc-eject 3.6s cubic-bezier(.32,0,.28,1) infinite; animation-delay: calc(var(--i) * 1.2s); }
@keyframes rsc-eject {
  0%        { transform: translateX(0) rotate(0deg);      opacity: 0; }
  9%        { opacity: 1; }
  62%       { transform: translateX(74px) rotate(-4deg);  opacity: 1; }
  100%      { transform: translateX(96px) rotate(-9deg);  opacity: 0; }
}
.rsc-stacktop { animation: rsc-land 3.6s ease-out infinite; }
@keyframes rsc-land { 0%, 55% { transform: translateY(-6px); opacity: .35; } 70%, 100% { transform: none; opacity: 1; } }

/* THE DEPARTURE, and it exists because the arrival was being used for both.
   `rsc-land` descends 6 units and fades IN from .35 to 1 — an item joining a
   stack. The kiosk's sandwich is LEAVING one, with an arrow above it pointing
   out, and it was wearing `rsc-stacktop`: measured y0 56 -> 62 and opacity .35
   -> 1, so the picture of a decrease showed something arriving. Three cues, and
   the animation was the one disagreeing with the other two.

   This is not a second vocabulary for the same job (the scene headers forbid
   that); it is the missing half of one. A line about change needs a word for
   both directions, and rsc-land was the only word there was. */
.rsc-lift { animation: rsc-lift 3.6s ease-in infinite; }
@keyframes rsc-lift {
  0%, 12% { transform: translateY(6px);   opacity: 1; }
  60%     { transform: translateY(-8px);  opacity: 1; }
  100%    { transform: translateY(-14px); opacity: 0; }
}
.rsc-blink { animation: rsc-blink 1.6s steps(1) infinite; }
@keyframes rsc-blink { 0%, 60% { opacity: 1; } 61%, 100% { opacity: .25; } }

/* The sweeping minute hand: pivot is the bbox bottom-centre by construction */
.rsc-hand { transform-origin: 50% 100%; animation: rsc-sweep 6s linear infinite; }
@keyframes rsc-sweep { to { transform: rotate(360deg); } }

/* Two ingredients falling in step — they must never drift out of phase, which
   is the entire idea the picture is carrying. */
.rsc-drop   { animation: rsc-fall 1.5s linear infinite; animation-delay: calc(var(--i) * .5s); }
.rsc-drop-b { animation-name: rsc-fall; }
@keyframes rsc-fall {
  0%   { transform: translateY(0);    opacity: 0; }
  12%  { opacity: 1; }
  80%  { opacity: 1; }
  100% { transform: translateY(24px); opacity: 0; }
}
.rsc-swirl { transform-origin: center; animation: rsc-swirl 5s ease-in-out infinite; }
@keyframes rsc-swirl { 0%,100% { transform: scaleX(1); } 50% { transform: scaleX(.82); } }

/* The mixer's bowl shuttles under each hopper in turn. ALL FOUR of these
   share the same 3.2s cycle — bowl, flour, water — because the whole point is
   that a stream only ever falls while the bowl is under it. Change one
   duration and you have to change all of them, or the flour starts missing
   again. The ±40 travel is what a 128-wide bowl needs to reach spouts at
   x 74 and x 220. */
.rsc-bowl { animation: rsc-shuttle 3.2s cubic-bezier(.5,0,.5,1) infinite; }
@keyframes rsc-shuttle {
  0%, 34%   { transform: translateX(-40px); }
  50%, 86%  { transform: translateX(40px); }
  100%      { transform: translateX(-40px); }
}
.rsc-mdrop   { animation: rsc-mfall 3.2s linear infinite; }
.rsc-mdrop-l { animation-delay: calc(var(--i) * .18s); }
.rsc-mdrop-r { animation-delay: calc(1.6s + var(--i) * .18s); }
/* The fall must FINISH inside the bowl's stay, not merely start there. The
   last drop leaves 0.36s after the first (2 x the 0.18s stagger), so the fall
   itself gets 20% of the cycle: 0.36s + 0.64s = 1.0s, comfortably inside the
   bowl's departure at 34% (1.088s). Lengthen this window and the final drop
   starts falling through thin air again. */
@keyframes rsc-mfall {
  0%        { transform: translateY(0);    opacity: 0; }
  3%        { opacity: 1; }
  16%       { transform: translateY(24px); opacity: 1; }
  20%, 100% { transform: translateY(28px); opacity: 0; }
}
.rsc-fill-rise { transform-origin: center bottom; animation: rsc-rise 6s ease-in-out infinite; }
@keyframes rsc-rise { 0% { transform: scaleY(.18); } 55%, 100% { transform: scaleY(.72); } }

/* Road and rail rushing past.

   THE TRAVEL MUST BE A WHOLE MULTIPLE OF THE STRIP'S OWN REPEAT, OR THE GROUND
   JUMPS ONCE PER LOOP. A scrolling strip only looks endless if the frame it
   ends on is identical to the frame it starts on. The keyframe used to travel
   a flat -320px for everything, which is right for the van (road dashes repeat
   every 40 units, 320/40 = 8) and WRONG for the train, whose sleepers repeat
   every 26: 320 mod 26 = 8, so the sleeper strip snapped 8 units backwards
   every 1.6 seconds under a train that is supposed to be running smoothly.
   Measured, not eyeballed — `translateX(-320px)` on an SVG group is 320 VIEWBOX
   units (measured 319.98 at a render width of 302 CSS px, so px = user units
   here), and the sleeper pitch measured 26.00.

   So the distance is now the strip's business, declared where the strip is
   drawn. If you add a scrolling strip, set --rsc-span to a whole multiple of
   its repeat AND check the strip is still long enough to cover 0..320 at the
   far end of the travel. */
.rsc-scroll      { animation: rsc-scroll 1.6s linear infinite; }
.rsc-scroll-slow { animation: rsc-scroll 6s linear infinite; }
@keyframes rsc-scroll { to { transform: translateX(calc(var(--rsc-span, 320px) * -1)); } }
.rsc-bounce { animation: rsc-bounce 1.1s ease-in-out infinite; }
@keyframes rsc-bounce { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-1.6px); } }
.rsc-puff { animation: rsc-puff 2.4s ease-out infinite; animation-delay: calc(var(--i) * .8s); }
@keyframes rsc-puff {
  0%   { transform: translate(0,0) scale(.4);      opacity: .95; }
  100% { transform: translate(-34px,-22px) scale(1.5); opacity: 0; }
}
.rsc-swing { transform-origin: 50% 0; animation: rsc-swing 3.2s ease-in-out infinite; animation-delay: calc(var(--i) * .6s); }
@keyframes rsc-swing { 0%,100% { transform: rotate(-7deg); } 50% { transform: rotate(7deg); } }

/* SWAY IS THE OTHER HALF OF SWING, and the vocabulary was missing it. `rsc-swing`
   pivots about the TOP of its own box — right for a sign or an umbrella hanging
   from a rail, upside down for anything rooted to the ground. A plant bends from
   its base. `transform-box: fill-box` is already set on everything in an
   .rsc-svg, so 50% 100% resolves to the bottom centre of the element's own box:
   draw the stem from its base upward and the pivot is where the stem meets the
   earth, with no extra wrapper.

   Slower and shallower than swing (±4° over 3.6s against ±7° over 3.2s) because
   this is background: it must read as a breeze behind the subject, never as
   something asking to be looked at. Same reason it is staggered — a row moving
   in lockstep reads as one mechanism, which is the opposite of a garden. */
.rsc-sway { transform-origin: 50% 100%; animation: rsc-sway 3.6s ease-in-out infinite; animation-delay: calc(var(--i) * .45s); }
@keyframes rsc-sway { 0%,100% { transform: rotate(-4deg); } 50% { transform: rotate(4deg); } }

/* ---------- The Lighthouse: colour-coding the two halves ----------
   TWO ROLE COLOURS, AND THEY ARE DELIBERATELY NONE OF THE SIX LINE COLOURS.
   `--xo-a` and `--xo-b` mean FIRST HALF and SECOND HALF. They do not mean
   Compare, or Ratio, or any other situation, and that distinction is the whole
   reason they are new values rather than two `--line-*` tokens.

   Colouring a generic crossover diagram in, say, compare-blue and ratio-teal
   would teach that a crossover IS those two — a rule of the form "see this,
   go there", which is the tier-3 lie this site exists to break, and it would
   be wrong on five of the seven island problems. `lighthouse.js` says in its
   own header that the diagrams never name which two situations are joined.

   They are also off the line palette so that neither can be read as a schema a
   student already knows: the six hues in use are green, blue, orange, teal,
   red and violet, and these two are a slate and a plum. Nothing here touches
   `--line-*`, so the open palette decision in ROADMAP §5b is untouched.

   COLOUR IS NEVER THE ONLY SIGNAL. Every half in every diagram below also
   carries a word ("first part", "the rest") and a shape difference (solid
   versus dashed), because a diagram that only works in colour does not work.
   WCAG 1.4.1, and the same discipline `[data-half]` already follows. */
.hub-art { --xo-a: #3D5A73; --xo-b: #7B3F63; }

/* A CHECKLIST BEING TICKED — and the reason it is a whole animation rather
   than five static ticks is that the ORDER is the lesson: you run the five
   questions down the list, and then you run them again on the other half.
   `--i` staggers the rows; `--ph` offsets the second pass, so one list
   finishes before the other starts and the two are never both mid-run. */
.rsc-tick {
  opacity: 1; transform-origin: 50% 50%;
  animation: rsc-tick 5.6s ease-in-out infinite;
  animation-delay: calc(var(--i, 0) * .24s + var(--ph, 0s));
}
@keyframes rsc-tick {
  0%, 2%    { opacity: 0; transform: scale(.5); }
  8%        { opacity: 1; transform: scale(1.18); }
  12%, 44%  { opacity: 1; transform: scale(1); }
  50%, 100% { opacity: 0; transform: scale(.5); }
}

/* The band behind whichever half is being read right now. Low opacity on
   purpose: it marks which column is live without competing with the ticks. */
.rsc-glow { opacity: .16; animation: rsc-glow 5.6s ease-in-out infinite; animation-delay: var(--ph, 0s); }
@keyframes rsc-glow { 0%, 2% { opacity: 0; } 8%, 44% { opacity: .16; } 50%, 100% { opacity: 0; } }

/* The transfer crossing the seam. It leaves the first half only after that
   half has been read, which is why it starts at 8% and not at 0. */
.rsc-token { animation: rsc-token 5.6s ease-in-out infinite; }
@keyframes rsc-token {
  0%, 8%    { transform: translateX(0); opacity: 0; }
  14%       { transform: translateX(14px); opacity: 1; }
  46%, 100% { transform: translateX(64px); opacity: 1; }
}

.rsc-pulse { animation: rsc-pulse 2.6s ease-in-out infinite; }
@keyframes rsc-pulse { 0%, 100% { opacity: .5; } 50% { opacity: 1; } }

@media (prefers-reduced-motion: reduce) {
  /* Frozen, not emptied. Everything is placed where it reads as a finished
     picture: sheets out of the slot, streams mid-air, contents in the urn. */
  .rsc-svg [class*="rsc-"] { animation: none !important; }
  /* A frozen checklist is simply RUN — every box ticked, on both halves. That
     is the true still of "the same five questions, answered twice"; a
     half-ticked list would be a picture of an interrupted read. Both bands
     show, because with no motion there is no "current" column to mark. */
  .rsc-tick { opacity: 1; transform: none; }
  .rsc-glow { opacity: .16; }
  /* The transfer frozen has ARRIVED. Caught mid-flight it would show a number
     belonging to neither half, which is the one thing this picture denies. */
  .rsc-token { transform: translateX(64px); opacity: 1; }
  .rsc-pulse { opacity: 1; }
  .rsc-sheet { opacity: 1; }
  .rsc-sheet:nth-of-type(1) { transform: translateX(0); }
  .rsc-drop  { opacity: 1; transform: translateY(10px); }
  /* Frozen, the mixer has to be caught at a moment that is TRUE: bowl parked
     under the flour, flour falling into it, water not yet pouring. A still
     with both streams running would show water missing the bowl. */
  .rsc-bowl    { transform: translateX(-40px); }
  .rsc-mdrop-l { opacity: 1; transform: translateY(12px); }
  .rsc-mdrop-r { opacity: 0; }
  .rsc-fill-rise { transform: scaleY(.6); }
  .rsc-puff { opacity: .5; transform: translate(-18px,-12px) scale(1.1); }
  .rsc-hand { transform: rotate(48deg); }
  /* The Equal Groups tray frozen is simply COMPLETE — every group down, the
     bracket present. That is the honest still: the finished act, not a
     half-laid row, which would be a picture of a different number of groups. */
  .eg-box, .ttg-box, .eg-total-bracket { animation: none !important; opacity: 1; transform: none; }
  /* A garden frozen stands upright. Freezing it mid-lean would read as a
     photograph of wind, which is a stranger still than no wind at all. */
  .rsc-sway { transform: none; }
  /* A hub page arrives instantly and complete. The entrance is a nicety; the
     content is the point, and none of it may depend on the animation running. */
  .hub-page, .hub-cl-row { animation: none !important; opacity: 1; transform: none; }
  /* Frozen, the departing item has to be caught at a moment that is TRUE:
     clear of the stack and still solid. Its base state is translateY(0) with
     full opacity, which sits it back level with the stack top and reads as
     "not going anywhere" — the same still the animation exists to contradict. */
  .rsc-lift { transform: translateY(-8px); opacity: 1; }
}

/* ---------- The Ratio Table: the Plan model for the Ratio & Rate Rail ----------
   Two rows that scale together. The visual weight is deliberately on the
   OPERATION column, because the operation being the same for both rows is the
   whole lesson — not the numbers either side of it. */
.rtab {
  border: 2px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--cream-light);
  padding: calc(var(--su) * 2.5);
  box-shadow: 0 2px 6px var(--shadow);
  margin: calc(var(--su) * 3) 0;
}
/* ---------- The Crossover Read ----------
   The half being asked about is spotlit and the other half steps back. Not
   hidden: a student has to be able to see WHERE the half sits in the story, and
   cutting the other half off the screen would turn "which kind is this part"
   into a question about a fragment with no context.

   Opacity is the only difference, and it is deliberately mild — this is
   emphasis, never state. What half you are on is carried by the heading above
   the options ("the FIRST part", "the SECOND part"), so nothing here depends on
   a student seeing a contrast at all. */
[data-half="off"] { opacity: .42; }
[data-half="on"]  { background: var(--cream-mid); border-radius: 3px; }
@media (prefers-reduced-motion: no-preference) {
  [data-half] { transition: opacity .25s ease; }
}
/* The sentence the student picked as the seam keeps its mark once found, the
   way the Platform Check keeps its evidence on screen.

   THESE TWO RULES USED TO BE BYTE-IDENTICAL APART FROM THE COLOUR TOKEN, and
   that was worse here than anywhere else on the site. A wrong pick sets
   `data-result="wrong"` and nothing else — no aria change, no disable, because
   `data-picked` and the aria-disabled sweep are both on the right branch only
   (stations.js:1030). So wrong marks PERSIST AND ACCUMULATE: a student can
   finish this phase looking at four underlined sentences, three of them meaning
   "no" and one meaning "yes", separable only by 1.274:1 of hue. In greyscale,
   or with the commonest colour vision deficiency, that is four identical marks
   and no way to tell which one was the answer.

   So the two states now differ in SHAPE as well as colour — a solid rule under
   the seam, a dotted one under a rejected sentence — and each carries a glyph.
   Shape is the channel that survives both greyscale and CVD; the colour stays
   because it is genuinely useful to everyone else. */
#xr-story [data-result="right"] {
  box-shadow: inset 0 -3px 0 var(--go);
  text-decoration: none;
}
#xr-story [data-result="wrong"] {
  box-shadow: none;
  text-decoration: underline dotted var(--stop);
  text-decoration-thickness: 2px;
  text-underline-offset: 3px;
  opacity: .72;
}
#xr-story [data-result]::after {
  font-size: .82em; font-weight: 700; margin-inline-start: .25em;
  vertical-align: baseline;
}
#xr-story [data-result="right"]::after { content: "\2713"; color: var(--go); }
#xr-story [data-result="wrong"]::after { content: "\2717"; color: var(--stop); }

/* ---------- Crossover Island ----------
   The island map sits in the same frame the mainland map does, so the two read
   as the same kind of object — `.map-figure` already carries the framing, the
   700px min-width and the `overflow-x: auto` that makes a wide map scroll
   inside its own box instead of scrolling the page.

   THERE WAS A SECOND `svg { width: 100%; height: auto }` RULE HERE AND IT WAS
   PURE DUPLICATION. It was added on a measurement that said the island SVG ran
   past the right edge of a 390px viewport — which is true, and is what the
   mainland does too: measured, both figures come out 363 wide with 732 of
   scroll, and neither scrolls the page. The instrument was asking "is the SVG
   inside the viewport" when the question is "does anything but the figure
   scroll". Deleted rather than left as a harmless copy: a second declaration
   of the same intent is a second thing to keep in step. */
.island-hero { margin: calc(var(--su) * 2) 0 calc(var(--su) * 4); }

/* The one animated mark on the island. A lighthouse that does not sweep is a
   tower. Slow enough to read as a beacon rather than a spinner, and it stops
   entirely under reduced motion — the beam is still drawn, so the lighthouse
   is a lighthouse either way. */
.mf-beam { transform-box: fill-box; transform-origin: 4% 50%; animation: xo-sweep 6.5s ease-in-out infinite; }
@keyframes xo-sweep {
  0%, 100% { transform: rotate(-18deg); opacity: .18; }
  50%      { transform: rotate(16deg);  opacity: .34; }
}
@media (prefers-reduced-motion: reduce) { .mf-beam { animation: none; } }

/* The island's card on the main map. It is not one of the five and not one of
   the two specials either, so it takes the ink outline rather than a line
   colour — the same reasoning as the neutral track on the island itself. */
.line-card-challenge { border-color: var(--ink); }
.line-card-challenge .line-name { color: var(--ink); }

/* ---------- The Crossover ----------
   The Plan phase of a two-line problem: first picture, the slot, second
   picture. The slot is the only new furniture; both pictures are the existing
   models and the existing `.rtab` frame, deliberately, so a student meets
   two familiar things and one new one rather than three new ones.

   THE SECOND PICTURE IS DELIBERATELY QUIETER THAN THE FIRST. It is not
   disabled — nothing in it was ever interactive — it is WAITING, and it has to
   read that way at a glance or it looks like a bug. Dashed rather than solid,
   and the frame is the cream the page uses for things not yet in play. Colour
   is never the only signal: the cells say "?" and `.xo-waiting` says why in
   words, so the state survives a greyscale screen and a screen reader. */
.xo-slot {
  border: 2px dashed var(--border-strong);
  border-radius: var(--radius);
  background: var(--cream-mid);
  padding: calc(var(--su) * 2.5);
  margin: calc(var(--su) * 3) 0;
}
/* The join, drawn. A short vertical rule from the first picture down into the
   slot — the one piece of ornament here, and it is doing a job: the two halves
   are a sequence, not a list, and nothing else on the screen says so. */
.xo-arrow {
  width: 0; height: calc(var(--su) * 3);
  margin: calc(var(--su) * -4) auto calc(var(--su) * 2);
  border-left: 3px dotted var(--orange-deep);
}
.xo-second { border-style: dashed; background: var(--cream-mid); }
.xo-waiting {
  margin: calc(var(--su) * 1.5) 0 0;
  color: var(--ink-mid);
  border-left: 3px solid var(--orange-deep);
  padding-left: calc(var(--su) * 1.5);
}
/* Once named, the transfer cell holds a WORD and not a number, and it is set
   in the body face rather than the figure face for exactly that reason — a
   quantity's name should not be dressed as a value. */
.xo-second [data-cell="transfer"] { font-family: var(--font-body); font-size: .92em; }

.rt-wrap { overflow-x: auto; }          /* wide tables scroll themselves, never the page */
.rt-grid { border-collapse: collapse; width: 100%; min-width: 380px; margin: calc(var(--su) * 2) 0; }
.rt-grid th, .rt-grid td {
  border: 2px solid var(--border-strong);
  padding: calc(var(--su) * 1.25);
  text-align: center;
}
.rt-grid thead th { background: var(--cream-mid); font-size: .82em; letter-spacing: .04em; }
.rt-grid tbody th { text-align: left; background: var(--cream-mid); font-size: .92em; }
.rt-unit { font-weight: 400; color: var(--ink-secondary); font-size: .85em; }
.rt-val { font-size: 1.25em; font-weight: 700; font-variant-numeric: tabular-nums; }
.rt-unknown { color: var(--orange-deep); }

/* The operation cell. Empty until the student finds it, so the column reads as
   the thing missing from the picture rather than as decoration. */
.rt-grid td.rt-op, .rt-grid th.rt-op { width: 4.5em; background: var(--cream-mid); padding: 0; }
.rt-arrow {
  display: block; min-height: 2.4em; line-height: 2.4em;
  font-weight: 700; color: var(--orange-deep); font-size: 1.05em;
  position: relative;
}
/* The sweep is an OVERLAY on a separate element. State is set by the text and
   by aria the instant the answer lands; this only decorates it. */
.rtab[data-stage="applied"] .rt-arrow::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(90deg, transparent, rgba(168,84,19,.28), transparent);
  animation: rt-sweep .75s ease-out 1 backwards;
}
.rtab[data-stage="applied"] tbody tr { animation: rt-lift .5s ease-out backwards; }
.rtab[data-stage="applied"] tbody tr:nth-child(2) { animation-delay: .12s; }
@keyframes rt-sweep { from { transform: translateX(-100%); } to { transform: translateX(100%); } }
@keyframes rt-lift  { from { background: rgba(168,84,19,.14); } to { background: transparent; } }

.rt-ask { font-weight: 700; margin: calc(var(--su) * 2) 0 calc(var(--su)); }
.rt-opt[data-result="wrong"] { border-color: var(--line-partwhole); }
.rt-opt[data-result="right"] { background: var(--orange-deep); border-color: var(--orange-deep); color: #fff; }
.rt-law {
  margin-top: calc(var(--su) * 2); padding: calc(var(--su) * 1.75);
  border-left: 5px solid var(--line-ratio); background: var(--cream-mid);
  border-radius: 0 var(--radius-sm) var(--radius-sm) 0;
}
/* The Junction: choosing a route. Deliberately styled as equals — no option
   may look like the preferred one, because none of them is. */
.route-opt { display: flex; align-items: center; gap: calc(var(--su) * 1.5); }
.route-tag {
  margin-left: auto; flex-shrink: 0;
  font-size: .74em; letter-spacing: .06em; text-transform: uppercase;
  color: var(--ink-secondary); border: 1.5px solid var(--border-strong);
  border-radius: 999px; padding: 2px 9px; white-space: nowrap;
}
.route-opt[aria-pressed="true"] .route-tag { color: inherit; border-color: currentColor; }

.rt-law-txt { margin: 0 0 calc(var(--su)); }
.rt-pending { margin: 0; font-size: .88em; color: var(--ink-secondary); }

/* The Change Train — three cars, one missing. Shares the Ratio Table's grid
   and buttons on purpose; two Plan models that look like two different
   products is its own defect. Only the differences live here. */
.ctrain .rt-law { border-left-color: var(--line-change); }
.ct-grid tbody th { width: 55%; }
/* The move is written BESIDE the question mark, never over it. The car stays
   unknown through the whole Plan phase — naming the route to a value is not
   the same as handing the value over. */
.ct-move { color: var(--line-change); font-weight: 700; white-space: nowrap; }
.ctrain[data-stage="applied"] .ct-move { animation: rt-lift .45s ease-out backwards; }
/* The car that is missing reads as missing before anything is chosen. */
.ct-grid tr[data-unknown="1"] th { color: var(--ink); font-weight: 700; }

@media (prefers-reduced-motion: reduce) {
  .rtab[data-stage="applied"] .rt-arrow::after,
  .rtab[data-stage="applied"] tbody tr { animation: none; }
  /* Frozen, the operation column still has to read as the answer that landed. */
  .rtab[data-stage="applied"] .rt-arrow { background: rgba(168,84,19,.14); }
}

/* ---------- The Test Track ----------
   The demonstration between the estimate and the Engine Room. Deliberately
   built from .rtab's frame and .opt's buttons so it reads as another stop on
   the same railway rather than a new kind of screen. */
.tt-stage {
  margin: calc(var(--su) * 2) 0;
  padding: calc(var(--su) * 2);
  background: var(--cream-mid);
  border-radius: var(--radius-sm);
}
.tt-demo-label { margin: 0 0 var(--su); font-weight: 700; color: var(--ink-mid); font-size: 14px; }

/* The mini bar. Parts are invisible until the split move is chosen — the
   picture must not do the first move before the student picks it. */
.tt-bar { display: flex; gap: 4px; }
.tt-seg {
  flex: 1 1 0; min-height: 54px;
  display: flex; align-items: center; justify-content: center;
  border: 2px dashed var(--border-strong); border-radius: var(--radius-sm);
  background: var(--cream-light);
  font-family: var(--font-display); font-size: 1.05rem; color: var(--ink);
  transition: background .3s ease, border-color .3s ease, transform .3s ease;
}
.tt-seg-val { opacity: 0; transition: opacity .3s ease; }
.tt-seg[data-filled="1"] { border-style: solid; }
.tt-seg[data-filled="1"] .tt-seg-val { opacity: 1; }
/* The sections the story actually gives you. Marked by FILL and by a check,
   never by colour alone. */
.tt-seg[data-held="1"] {
  background: var(--orange-deep); border-color: var(--orange-deep); color: #fff;
  transform: translateY(-3px);
}
.tt-seg[data-held="1"]::after {
  content: "\2713"; font-size: .85em;
}
/* The worked bar is quieter than the student's — it is the example, not the
   task, and the eye should land on the one they are working on. */
.tt-watch { opacity: .92; }
.tt-watch .tt-seg { min-height: 40px; }
.tt-yours { border-left: 4px solid var(--orange-deep); }
.tt-q { margin-top: calc(var(--su) * 2); }

/* ---- cross (Ratio): the two diagonals of a set proportion ---- */
.tt-cross { position: relative; }
.tt-grid { width: 100%; }
.tt-grid td.tt-cell {
  text-align: center; font-family: var(--font-display); font-size: 1.15rem;
  transition: background .3s ease, color .3s ease;
}
/* A lit diagonal is marked by BACKGROUND and by a BRACKET SHAPE — round for
   one diagonal, square for the other. There used to be an SVG line drawn along
   each diagonal as well; it was removed for cutting across the numbers the
   student is trying to read. The bracket shapes are what stops the two pairs
   being told apart by hue alone now that the lines are gone. */
.tt-cell[data-diag] { background: rgba(168,84,19,.13); font-weight: 700; }
.tt-cell[data-diag="1"] span::before { content: "("; }
.tt-cell[data-diag="1"] span::after  { content: ")"; }
.tt-cell[data-diag="2"] { background: rgba(31,90,110,.13); }
.tt-cell[data-diag="2"] span::before { content: "["; }
.tt-cell[data-diag="2"] span::after  { content: "]"; }
.tt-eq {
  margin: var(--su) 0 0; min-height: 1.5em;
  font-family: var(--font-display); font-size: 1.1rem; text-align: center;
  color: var(--ink);
}

/* ---- drive (Change): three cars and an engine that runs between them ---- */
.tt-train { position: relative; display: flex; align-items: stretch; gap: 0; padding-top: 26px; }
.tt-car {
  flex: 1 1 0; display: flex; flex-direction: column; align-items: center; gap: 4px;
  border: 2px solid var(--border-strong); border-radius: var(--radius-sm);
  background: var(--cream-light); padding: var(--su);
  transition: background .3s ease, border-color .3s ease;
}
.tt-car-label { font-size: 12px; font-weight: 700; color: var(--ink-mid); text-align: center; }
.tt-car-slot { font-family: var(--font-display); font-size: 1.2rem; min-height: 1.3em; }
/* The gap reads as a gap before anything happens, and STAYS one — the engine
   stops on it without ever filling it in. */
.tt-car-gap { border-style: dashed; background: var(--cream-mid); }
.tt-car[data-visited="1"] { border-color: var(--orange-deep); }
.tt-link { flex: 0 0 10px; align-self: center; height: 3px; background: var(--border-strong); }
.tt-loco {
  position: absolute; top: 0; left: 0; color: var(--orange-deep); font-size: 18px;
  transition: transform .42s ease-in-out;
}

@media (max-width: 640px) {
  .tt-car-label { font-size: 11px; }
  .tt-grid td.tt-cell { font-size: 1rem; }
}
.tt-count {
  margin: var(--su) 0 0; min-height: 1.4em;
  font-weight: 700; color: var(--ink-mid);
}

@media (prefers-reduced-motion: reduce) {
  /* No transitions and no stagger. testtrack.js also skips its timers under
     this query and resolves straight to the finished state, so the lesson
     arrives whole rather than not at all. */
  .tt-seg, .tt-seg-val, .tt-loco, .tt-car, .tt-cell { transition: none; }
  .tt-seg[data-held="1"] { transform: none; }
}

/* ---------- The Model Yard ----------
   An animated, interactive picture of the problem. Motion is decoration:
   every state is also carried by aria-pressed, by the readout, and by the
   written description underneath. */
.yard {
  background: var(--cream-light);
  border: 2px solid var(--border-strong);
  border-radius: var(--radius);
  padding: calc(var(--su) * 2.5);
  margin-bottom: calc(var(--su) * 3);
}
.yard-head { margin-bottom: var(--su); }
.yard-head h3 { margin: 0; font-size: 1.15rem; }
.yard-say { font-size: .95em; color: var(--ink-mid); margin: 0 0 calc(var(--su) * 2); max-width: 58ch; }

/* ---------- The Compare model: two amounts, and the gap ----------
   Bars are laid on a shared track so their ENDS line up vertically — that
   alignment is the whole point of the picture, and it is what makes the gap a
   thing you can see rather than a thing you are told about. Widths come from
   the number set at render time (see compare-model.js), never from CSS. */
.cmp-stack { display: grid; gap: calc(var(--su) * 1.25); margin: calc(var(--su) * 2) 0; }
/* THE VALUE COLUMN IS FIXED, AND THAT IS LOAD-BEARING.
   It was `auto`, so it sized to its own text — and every row is its own grid,
   so the 1fr track came out a different width on each one. The bars are drawn
   as PERCENTAGES, so percentages of different tracks are not comparable, and
   the whole point of this picture is that the two bars can be compared.

   Measured before the fix, on cp-bench-count: both bars 61%, tracks 887px and
   794px, because "?" is 12px wide and "31 benches" is 104px. The local platform
   drew at 541px against the express platform's 485px — the unknown, which the
   story says is the SMALLER, drawn longer than the amount it is measured
   against. A picture saying the opposite of its own problem. User-found.

   Fixed columns mean every track starts and ends at the same x, which is the
   only thing that makes two bars a comparison rather than two bars. */
.cmp-row {
  display: grid; grid-template-columns: 7.5em 1fr 7.5em; align-items: center;
  gap: calc(var(--su) * 1.5);
  font: inherit; color: inherit; text-align: left; cursor: pointer;
  background: var(--cream-light); border: 2px solid var(--border-strong);
  border-radius: var(--radius-xs); padding: calc(var(--su) * 1.25);
}
.cmp-row:hover:not(:disabled) { border-color: var(--line-compare); }
/* Narrow phones: both label columns shrink BY THE SAME AMOUNT, so every
   track still starts and ends at the same x (the rule above) — at 7.5em a row
   needed 333px and pushed a 320px screen sideways (island Plan, 2026-10-20). */
@media (max-width: 420px) {
  /* Label and value on one line, the bar full width under them. Shrinking
     the label columns instead left a 37px bar at 320px. Every row uses the
     same layout, so every track still spans the same x. */
  .cmp-row { grid-template-columns: minmax(0, 1fr) auto; grid-template-areas: "lab val" "bar bar";
    row-gap: 6px; column-gap: 8px; font-size: .92em; }
  .cmp-row > :nth-child(1) { grid-area: lab; }
  .cmp-row > :nth-child(2) { grid-area: bar; }
  .cmp-row > :nth-child(3) { grid-area: val; text-align: right; }
}
.cmp-name { font-weight: 700; }
.cmp-track {
  position: relative; height: 26px; border-radius: 3px;
  background: repeating-linear-gradient(90deg,
    var(--border) 0 4px, transparent 4px 13px);
}
.cmp-fill {
  position: absolute; inset: 0 auto 0 0; border-radius: 3px;
  background: var(--line-compare); border: 2px solid var(--ink);
}
/* Wraps rather than widening: a long value changes the row's height, never the
   track's width, so the comparison survives whatever units a problem uses. */
.cmp-val { font-variant-numeric: tabular-nums; font-weight: 700; text-align: right; }
/* The unknown amount is outlined, never filled, and says "?" — the same
   grammar the Model Yard and the ratio table use for a value not yet worked
   out. Outline plus the glyph means it survives greyscale (WCAG 1.4.1). */
.cmp-fill-unknown { background: transparent; border-style: dashed; }
.cmp-val-unknown { color: var(--orange-deep); }
/* The difference, marked off on the end of the longer bar. It carries the
   number because on these problems the difference is GIVEN — it is the total
   that is missing. */
.cmp-diff {
  position: absolute; top: 0; bottom: 0;
  display: flex; align-items: center; justify-content: center;
  background: repeating-linear-gradient(45deg,
    var(--cream-light) 0 5px, var(--cream-mid) 5px 10px);
  border: 2px dashed var(--ink); border-left: 0;
  font-size: .78em; font-weight: 700; white-space: nowrap; overflow: hidden;
}
/* Multiplicative compare: the longer bar is drawn as N copies of the referent,
   and these are the seams between the copies. They are the only thing that
   makes "four times as many" visible rather than merely "longer" — a bar four
   times the length of another says nothing a student can point at.

   Deliberately NOT labelled. A seam is a boundary; a number on each segment
   would be the answer to a division the Engine Room has not asked yet. */
.cmp-copy {
  position: absolute; top: 0; bottom: 0; width: 0;
  border-left: 2px dashed var(--cream-light);
  opacity: .85; pointer-events: none;
}
.cmp-row[data-result="right"] { border-color: var(--line-compare); border-width: 3px; background: var(--cream-mid); }
.cmp-row[data-result="wrong"] { opacity: .55; }
.cmp-row:disabled { cursor: default; }

/* The gap carries the QUESTION, never a value. It is the same stopping point
   the ratio table has at the scale factor. */
.cmp-gap {
  display: flex; align-items: center; gap: calc(var(--su) * 1.5);
  margin: calc(var(--su) * 2) 0 0; padding: calc(var(--su) * 1.5);
  border-left: 5px solid var(--line-compare); background: var(--cream-mid);
  border-radius: var(--radius-xs);
}
.cmp-gap-bracket {
  flex: 0 0 46px; height: 14px;
  border: 3px solid var(--line-compare); border-top: 0;
  border-radius: 0 0 4px 4px;
}
.cmp-gap-label { font-weight: 600; }

/* ---------- The loading screen ----------
   Covers the gap before the scripts run, then removes itself on `load`. It is
   in the HTML rather than built by JS for the obvious reason: a loader that
   needs JS cannot appear while JS is what you are waiting for. */
#loading-screen {
  position: fixed; inset: 0; z-index: 9999;
  display: grid; place-items: center;
  background: var(--cream);
  transition: opacity .4s ease;
}
#loading-screen[data-done="yes"] { opacity: 0; pointer-events: none; }
.load-inner { text-align: center; padding: calc(var(--su) * 2); }
/* `text-align: center` CENTRES THE TEXT INSIDE A BOX; IT DOES NOT CENTRE THE
   BOX. The global `p { max-width: var(--measure) }` caps every paragraph here
   at the 64ch reading measure, and a capped block with no auto margins sits
   hard left in a wider parent — so the words were centred within a box that
   was itself off-centre. Measured at 1280: "Welcome to" 195px left of centre
   and the status line 49px, while the title, the train, the lamps and the bar
   were all exactly centred, which is what made it read as only *some* of the
   screen being crooked. */
.load-inner p { max-width: min(var(--measure), 100%); margin-inline: auto; }
/* THE TRAIN FLOATS. It does not slide.
   The sideways rock read as the train skidding rather than travelling — there
   is no ground under it and nothing else on the screen moving past, so lateral
   motion had nothing to be relative to and just looked wrong. It hovers now,
   which is what a picture pinned in the middle of a screen can honestly do. */
.load-train {
  width: min(340px, 70vw); height: auto; margin: 0 auto;
  animation: load-float 3.6s ease-in-out infinite;
  /* Tone FIRST, then the shadow. A second `filter` declaration replaces the
     whole list rather than adding to it, so every art rule that wants a shadow
     has to restate the tone or silently lose it. */
  filter: var(--art-tone) drop-shadow(0 10px 18px rgba(44,34,20,.22));
}
@keyframes load-float {
  0%, 100% { transform: translateY(0) rotate(-.4deg); }
  50%      { transform: translateY(-14px) rotate(.4deg); }
}

/* THE SIGNAL LAMPS — the progress the train cannot show.
   Five dots above the train lighting one after another, the sister site's
   loading dots rebuilt in this site's own furniture. They run on the LINE
   colours rather than one accent, so the thing a student sees first is the
   palette of the five lines they are about to choose between. */
/* The lamps sit UNDER the title now, matching the sister site's order. The
   bottom margin that used to hold them off the train is gone with it. */
/* Below the bar now, matching the sister site's order — see the note in
   index.html. The margin is smaller than it was above it: the lamps and the bar
   are one progress group and should read as such, rather than as two things
   that happen to be stacked. */
.load-dots { display: flex; justify-content: center; gap: 12px; margin: calc(var(--su) * 1.5) 0 0; }
.load-dots span {
  width: 15px; height: 15px; border-radius: 50%;
  border: 2px solid var(--ink); background: var(--cream-mid);
  animation: load-lamp 2.2s ease-in-out infinite;
}
.load-dots span:nth-child(1) { animation-delay: 0s;    --lamp: var(--line-partwhole); }
.load-dots span:nth-child(2) { animation-delay: .18s;  --lamp: var(--line-ratio); }
.load-dots span:nth-child(3) { animation-delay: .36s;  --lamp: var(--line-compare); }
.load-dots span:nth-child(4) { animation-delay: .54s;  --lamp: var(--line-groups); }
.load-dots span:nth-child(5) { animation-delay: .72s;  --lamp: var(--line-change); }
@keyframes load-lamp {
  0%, 100%  { background: var(--cream-mid); transform: scale(1); }
  30%       { background: var(--lamp);      transform: scale(1.35); }
  60%       { background: var(--lamp);      transform: scale(1); }
}

/* ---------- The loading bar, from the sister site's `.loading-bar-wrap` ----------
   The Factory's is a 280x8 track with an `--orange` fill and a `width` transition,
   and it is the one thing its loader has that this one did not: five lamps say
   SOMETHING is happening, and nothing said how far. The track takes a border
   here because this site's cream-mid on cream is a fainter step than the
   Factory's #DDD0BA on white and the empty state needs an edge to read as a
   track rather than as a smudge. */
.load-bar-wrap {
  width: min(280px, 70vw); height: 10px;
  margin: calc(var(--su) * 2.5) auto 0;
  background: var(--cream-mid);
  border: 1.5px solid var(--ink);
  border-radius: 999px;
  overflow: hidden;
}
.load-bar-fill {
  width: 0; height: 100%;
  background: var(--orange-deep);
  border-radius: 999px;
  transition: width .3s ease;
}
/* Reduced motion keeps the BAR and drops the tween. The width still reports
   real progress — that is information, not decoration, and removing it would
   take the loader back to having nothing that says how far along it is. It
   simply steps rather than slides. */
@media (prefers-reduced-motion: reduce) { .load-bar-fill { transition: none; } }

.load-sub { animation: load-breathe 3.6s ease-in-out infinite; }
/* THE FLOOR IS .8, NOT .55, AND IT IS A CONTRAST FIX RATHER THAN A TASTE ONE.
   `--ink-mid` on `--cream` is 8.39:1 at full opacity. Faded to .55 it composites
   to 2.75:1 — below AA for normal text, for roughly a third of every cycle, on
   the first screen anybody sees. Measured across the range: .75 gives 4.39 and
   .80 gives 4.98, so the lowest value that holds AA throughout is .76 and .8 is
   the round number above it. The breath is still clearly visible; it just no
   longer dips under the line. */
@keyframes load-breathe { 0%,100% { opacity: .8; } 50% { opacity: 1; } }

/* ---------- The welcome, borrowed in shape from Mr. Fraction's Factory ----------
   Its loader runs a small tracked-out "Welcome to" over a display-face title
   with one accent word, and the two sites are meant to read as one family. The
   proportions are matched to the Factory's (12px / 0.2em tracking over a 42px
   display title at 0.06em) rather than invented, then expressed in this site's
   own tokens so the type scale and the palette stay this site's. */
.load-welcome {
  font-family: var(--font-body);
  font-size: 12px; letter-spacing: .2em; text-transform: uppercase;
  color: var(--ink-muted); margin: 0 0 calc(var(--su) * .75);
}
.load-title {
  font-family: var(--font-display, inherit);
  /* Fluid rather than the Factory's fixed 42px/34px pair: this title is three
     words longer than "MR. FRACTION'S FACTORY" and a hard size wraps badly in
     the band between phone and desktop. Clamped so it never outgrows the train
     underneath it. */
  font-size: clamp(1.6rem, 5.2vw, 2.6rem);
  font-weight: 400; letter-spacing: .04em; line-height: 1.05;
  color: var(--ink);
  margin: 0 0 calc(var(--su) * 2);
  text-wrap: balance;
}
/* One accent word, as the Factory colours FACTORY. `--orange-deep` rather than
   `--orange`, and the numbers are measured on `--cream` rather than guessed:
   4.58:1 against 3.25:1. At this size only 3:1 is required, so both would pass
   the letter of AA — the deeper one is chosen because a title is the thing a
   low-vision reader is most likely to be leaning in to read. */
.load-title span { color: var(--orange-deep); }
.load-sub { color: var(--ink-mid); margin: calc(var(--su) * 2) 0 0; }

/* ---------- The top-bar portrait ----------
   Was inline SVG; it is the painted Conductor now, set in the HTML so it is
   present before any script runs. */
.sign-mark { width: 58px; height: auto; display: block; flex: 0 0 auto; }
@media (max-width: 620px) { .sign-mark { width: 42px; } }

/* ---------- The ticket window ----------
   The booth on the route-choice screen, and the whole reason this section is
   no longer called "art that floats".

   FOUR ATTEMPTS TO PLACE IT BY ABSOLUTE POSITIONING, AND THE FOURTH WAS STILL
   WRONG. It was pinned to the top and cut the roof off; anchored below the top
   of a list whose height changes with the number of routes; centred by a
   transform that computed to the identity matrix; and finally centred by auto
   margins — which worked, against the wrong box. Measured on the rendered page
   before this rewrite:

     1280px — booth x1123–1280, hard against the right edge of the SHELL, not
              the 788px content column. 89px clear of the choices list, and
              vertically centred on the VIEWPORT rather than on the buttons it
              illustrates. The `.choices` containing block it was written
              against never applied: the <img> is a SIBLING of that list, so
              its offset parent was the shell all along.
     390px  — booth x312–406 against a 390px window. The page acquired a
              horizontal scroll (scrollWidth 406), the booth's right edge was
              cropped off, and it lay across both the "pick your own number of
              stops" summary and the Back to the map button.

   None of that is a positioning bug to be fixed with better offsets. It is
   what taking the art out of the flow costs: the layout cannot see it, so
   nothing can make room for it, and every placement is a guess at a height
   that is not knowable from the stylesheet.

   In the flow, no guess is needed. Two columns beside the buttons where there
   is room for two, one column above them where there is not, and the grid
   measures both. `align-items: center` is what all four attempts were reaching
   for and it costs one line here. */
.route-window {
  display: grid;
  align-items: center;
  gap: calc(var(--su) * 2);
  margin: 0 0 calc(var(--su) * 3);
  /* The window is the reading column, not the whole shell. Without this the
     booth column takes its place at the far end of a 1140px row while the
     buttons stop at their 788px measure — measured, a 208px hole between the
     two, and the booth back out in the page margin looking like scenery that
     belongs to nothing. Bounded, the booth stands at the end of the same line
     of text everything else on this screen is set to. */
  max-width: var(--measure);
}
/* AN AUTO MARGIN CANCELS STRETCH, and `.choices` carries `margin: 0 auto` for
   the reading-measure reason noted where it is defined. A grid item with an
   auto margin in an axis is sized to fit-content in that axis and then centred
   by the margin — so the route buttons came out 311px wide inside a 366px
   column on a phone, narrower here than the same buttons anywhere else on the
   site, and `justify-self: stretch` did nothing about it because stretch is
   the thing the auto margin turned off. Measured, not reasoned: 311.51px
   against a 366px track. The margin is what has to go, not the alignment. */
.route-window .choices { width: 100%; margin: 0; }
/* Centred in its own column on a phone; `justify-self` rather than a margin so
   it behaves the same in both layouts — and see above for why an auto margin
   is the wrong tool inside this grid. */
.route-booth { margin: 0; justify-self: center; line-height: 0; }
.route-booth img {
  display: block; width: auto; height: auto;
  /* Stacked, the booth grows with the screen it is standing on: 140px on a
     phone, up to 200px on a wide tablet, where a fixed phone size reads as a
     stamp lost in the middle of a 719px column. Height only; the width follows
     the art, so this cannot push anything sideways.

     This used to justify the 140px by matching `.art-band-booth`'s phone cap,
     "so the booth is one size at the route window and at the Ticket Booth
     station". That band is gone and the route window is now the only place the
     booth appears, so the number stands on its own. A comment that cites a
     rule which no longer exists is worse than one that cites nothing. */
  max-height: clamp(140px, 26vw, 200px);
  filter: var(--art-tone) drop-shadow(0 6px 14px rgba(44,34,20,.28));
}

/* 760px is where the content column is wide enough that taking ~145px for the
   booth still leaves the route buttons a comfortable line. Below it the booth
   stands over the choice instead of beside it. */
@media (min-width: 760px) {
  .route-window { grid-template-columns: minmax(0, 1fr) auto; gap: calc(var(--su) * 3); }
  /* Explicit placement, because the booth comes FIRST in the source (so that
     the phone layout puts it above the buttons with no reordering) and must
     come second on the wide one. */
  .route-window .choices { grid-column: 1; grid-row: 1; }
  .route-booth          { grid-column: 2; grid-row: 1; }
  .route-booth img { max-height: 230px; }
}

/* The station hero floats gently, as if idling at the platform. */
.art-hero .art { animation: art-hover 5.5s ease-in-out infinite; }
@keyframes art-hover { 0%,100% { transform: translateY(0); } 50% { transform: translateY(-9px); } }

/* A ticket on every line card. Tinted per line via a colour overlay, so five
   identical tickets do not read as five copies of one image. */
/* THE TEXT KEEPS ITS OWN COLUMN. The ticket was absolutely positioned over a
   card whose text ran the full width, so the two occupied the same space and
   the ticket won. Reserving the gutter is what fixes it — the ticket sits in
   space nothing else is using, rather than on top of the line name.
   110px = the ticket's 86px plus a gap either side. */
/* `.map-lines .line-card`, not `.line-card`, and that specificity is load-
   bearing. The base `.line-card` block is declared LATER in this file, so at
   equal specificity it won and the gutter was silently never reserved —
   computed padding-right came back 28.5px against the 110px asked for, and the
   ticket went straight back over the line name. */
/* THE LINE CARD IS A LAYOUT, NOT AN OVERLAY.
   The ticket used to be absolutely positioned on top of the card and the text
   given a gutter to hide behind it, which meant the badge ended up beside the
   ticket instead of under everything. Three bands now, and the grid puts each
   element where it belongs regardless of DOM order:

       title                 (full width)
       description | ticket  (text left, ticket right)
       orange pill           (full width, the base of the card)  */
.map-lines .line-card {
  position: relative; padding-right: 0;
  display: grid; grid-template-columns: 1fr auto;
  column-gap: calc(var(--su) * 2); align-items: start;
  text-align: left;
  /* FILL THE CELL, AND PUT THE PILL ON THE FLOOR OF IT.
     The `li` is a grid item and stretches to the row height, but the button
     inside it did not, so cards in the same row ended at different heights
     wherever a description ran to a different number of lines — measured, the
     five came out 155 to 175 and the three specials 202 to 250. A row of
     buttons whose bottoms do not line up reads as a set of unrelated things,
     which is the opposite of what grouping them was for.
     `space-between` then drops the badge to the bottom of every card, so the
     call-to-action pills align across the row as well as the frames. */
  height: 100%;
  align-content: space-between;
}
.map-lines .line-card .line-name { grid-column: 1 / -1; }
.map-lines .line-card .line-form,
.map-lines .line-card .line-desc { grid-column: 1; }
/* Spans the two text rows and sits centred against them, so it reads as
   belonging beside the description rather than floating over the card. */
.map-lines .line-card .line-ticket { grid-column: 2; grid-row: 2 / 4; align-self: center; }
.map-lines .line-card .badge,
.map-lines .line-card .soon { grid-column: 1 / -1; }

/* THE READY-TO-RIDE BADGE IS THE BUTTON'S CALL TO ACTION, so it stops being a
   small pill in the corner and becomes the base of the card: full width of the
   text column, centred, and big enough to read as the thing you are about to
   press. Same specificity trick as above — the base `.badge` rule is declared
   later in this file and would otherwise win. */
.map-lines .line-card .badge,
.map-lines .line-card .soon {
  display: block; width: auto; box-sizing: border-box;
  /* A gap on the right so the pill sits INSIDE the card rather than running
     into its edge. It spans both grid columns, so without this it stopped flush
     against the border and read as part of the frame instead of a control. */
  margin: calc(var(--su) * 2) calc(var(--su) * 2) 0 0;
  padding: calc(var(--su) * 1.5) calc(var(--su) * 1.5);
  font-size: 13px; letter-spacing: .08em; text-align: center;
  border-radius: var(--radius-xs);
}
/* The unavailable state keeps the same footprint, so the cards stay the same
   shape whether or not a line is running. */
.map-lines .line-card .soon {
  background: var(--cream-mid); color: var(--ink-mid);
  border: 2px dashed var(--border-strong); text-transform: none; letter-spacing: 0;
}
@media (max-width: 620px) {
  .map-lines .line-card .badge, .map-lines .line-card .soon { font-size: 12px; padding: calc(var(--su) * 1.25); }
}
/* In the flow now, not on top of it — the grid above gives it a column. */
.line-ticket {
  width: 86px; height: auto; pointer-events: none;
  transform: rotate(-7deg); opacity: .92;
  filter: var(--art-tone) drop-shadow(0 2px 4px rgba(44,34,20,.25));
  transition: transform .15s ease;
}
.line-card:hover:not(:disabled) .line-ticket { transform: rotate(-3deg) scale(1.04); }
.line-card:disabled .line-ticket { filter: grayscale(1); opacity: .4; }

/* MR FRACTION MOVING ROUND THE HUB.
   A different pose and a different spot on every page, so turning a page moves
   him rather than just swapping the words underneath a fixed portrait. He
   floats out of the flow, so wherever he stands he can never push the teaching
   text around or change where a button sits. */
/* HE GETS HIS OWN LANE, AND NOTHING ELSE IS ALLOWED IN IT.
   The previous version positioned him over the page and reserved space on the
   heading only — so he cleared the title and then sat on the diagrams and the
   body text underneath. Reserving the gutter on the PAGE means every child is
   inside the remaining width, and there is nothing left for him to overlap:
   not text, not a diagram, not a tap target.

   The lane is on the LEFT because the right is where the eye leaves a line of
   text and where the diagrams and rail already are. */
.hub-page { position: relative; padding-left: 108px; }
.hub-mf {
  position: absolute; left: 0; z-index: 2; pointer-events: none;
  width: 84px; height: auto;
  filter: var(--art-tone) drop-shadow(0 4px 8px rgba(44,34,20,.24));
  animation: hub-mf-in .4s ease backwards;
}
@keyframes hub-mf-in { 0% { opacity: 0; transform: translateY(10px) scale(.94); } 100% { opacity: 1; transform: none; } }
/* Three heights down the same lane. `low` is slightly smaller — further down
   the platform — which is what makes the set read as one person walking rather
   than three copies pinned at different heights. */
.hub-mf[data-spot="high"] { top: -8px; }
.hub-mf[data-spot="mid"]  { top: 34%; }
.hub-mf[data-spot="low"]  { top: 62%; width: 68px; opacity: .9; }

@media (max-width: 620px) {
  /* No width to share on a phone: a narrower lane and a smaller conductor,
     still reserved so nothing can slide underneath him. */
  .hub-page { padding-left: 64px; }
  .hub-mf { width: 52px; }
  .hub-mf[data-spot="low"] { width: 44px; }
}
@media (prefers-reduced-motion: reduce) { .hub-mf { animation: none; } }

@media (max-width: 620px) {
  /* The booth's phone size is no longer set here. It is the base size in the
     `.route-window` rule and the WIDE layout is the override — a decoration
     that has to be walked back at every breakpoint is a decoration whose
     default is wrong. This rule used to pull it 2 units PAST the right edge,
     which is how it came to hang 16px outside a 390px window. */
  .map-lines .line-card { column-gap: var(--su); }
  .line-ticket { width: 58px; }
  .hub-conductor { width: 64px; }
}
@media (prefers-reduced-motion: reduce) {
  /* Frozen, the lamps stay lit rather than dark — a row of dead dots reads as
     something broken, which is the opposite of what a loader is for. */
  .load-train, .art-hero .art, .load-sub { animation: none; }
  .load-dots span { animation: none; background: var(--lamp); }
  .line-ticket, .line-card:hover .line-ticket { transition: none; }
  #loading-screen { transition: none; }
}

/* ---------- The painted furniture ----------
   The station, loco, caboose, booth and ticket. Decorative, so they never take
   layout away from the work: every one is capped by height rather than width,
   which is what stops a 1711px-wide station from dictating the page. */
/* ---------- Toning the art to the interface ----------
   MEASURED, not guessed. The delivered art runs hotter than this site: its
   blues sample at #262CB9 and #4D52E9 against `--line-compare: #2A5FA0`, its
   green at #078E65 against `--line-change: #45742B`, and every outline is pure
   #000000 where the site's `--ink` is #241B10, a warm near-black. Side by side
   the interface reads washed out and the art reads loud.

   THE ART MOVES, NOT THE INTERFACE, and that is a constraint rather than a
   preference: the line colours are contrast-checked to WCAG AA in
   THEME-AND-ACCESSIBILITY, so shifting them risks ratios that were measured.
   The art carries no information at all — every piece is `alt="" aria-hidden`
   — so toning it costs nothing that can be read, and it is one variable to
   change back if it goes too far.

   `saturate(.86)` pulls the vivid indigo and emerald back toward the muted
   family. `contrast(.94)` lifts the pure-black outlines a little off the floor,
   which is what stops them looking harder than every border on the page. */
:root { --art-tone: saturate(.86) contrast(.94); }

.art { display: block; max-width: 100%; height: auto; filter: var(--art-tone); }
/* ONLY the piece that has no filter of its own. The blanket version of this
   rule listed every art element and sat LATER in the file than their individual
   `filter: … drop-shadow(…)` declarations, so it replaced them and quietly
   stripped the shadow off the ticket, the hub Conductor, the loader train and
   the companion — all four measured back as `saturate(.86) contrast(.94)` with
   no shadow at all. The rest carry the tone inside their own declarations. */
.sign-mark { filter: var(--art-tone); }
.art-hero {
  margin: 0 0 calc(var(--su) * 3); text-align: center;
}
.art-hero .art { margin: 0 auto; max-height: 260px; width: auto; }

.art-band { display: flex; align-items: flex-end; justify-content: center; gap: calc(var(--su) * 2);
  margin: calc(var(--su) * 2) 0 calc(var(--su) * 3); }
.art-band .art { width: auto; }
/* `.art-band-booth` and its `.art-booth` / `.art-ticket` rules lived here and
   went with the figure they styled — the booth-and-ticket band on the Ticket
   Booth phase, removed on the user's call 2026-08-16 (stations.js, phTicket).
   Nothing emits that class now, and a rule styling an element the site never
   renders is a rule the next person has to disprove before they can trust the
   sheet. The art files themselves are still in use: the booth at the route
   window, the ticket on every line card. */
.art-band-end .art-caboose  { max-height: 240px; }
.art-band-loco .art-loco    { max-height: 250px; }

@media (max-width: 620px) {
  .art-hero .art { max-height: 170px; }
  .art-band { gap: calc(var(--su)); }
  .art-band-end .art-caboose, .art-band-loco .art-loco { max-height: 165px; }
}

/* ---------- Mr Fraction, floating ----------
   The sister site's `mr-companion` pattern, adopted deliberately: fixed to the
   bottom right, bubble above the figure with its tail pointing down at him,
   `pointer-events: none` on the container so the page stays clickable through
   it and `auto` on the bubble so the text is still selectable.

   He used to render inline on every phase, which cost vertical space on every
   screen and put commentary in the middle of the reading order. */
.mf-companion {
  position: fixed; bottom: 20px; right: 20px; z-index: 200;
  display: flex; flex-direction: column; align-items: flex-end; gap: 8px;
  pointer-events: none;
  max-width: min(300px, calc(100vw - 40px));
}
/* SIZED AGAINST MR FRACTION, NOT AGAINST THE PAGE.
   The rule of thumb is that the bubble should not be much bigger than he is.
   He is 104px wide and about 120 tall, so the bubble is capped near that: it
   holds a couple of sentences and then stops. Anything longer is a second line
   he says next, not a taller bubble — see the rotation in companion.js.

   It was 46vh, which on the Five Situations hub let one aside fill nearly half
   the screen height and turned a companion into a document. */
.mf-c-bubble {
  pointer-events: auto;
  background: var(--cream-light);
  border: 2px solid var(--border-strong);
  border-radius: 14px 14px 4px;      /* the square corner is the tail side */
  padding: calc(var(--su) * 1.1) calc(var(--su) * 1.35);
  font-size: .86rem; line-height: 1.45;
  box-shadow: 0 4px 16px var(--shadow-lift);
  max-width: 252px;
  max-height: 170px; overflow-y: auto;
  animation: mf-c-in .3s ease;
}
/* POSITION MARKS, NOT CONTROLS. No tap target and no cursor change: they exist
   so the turnover reads as a rotation rather than the text mutating at random.
   A student never has to do anything with them. */
.mf-c-dots { display: flex; gap: 5px; justify-content: flex-end; margin: calc(var(--su)) 0 0; }
.mf-c-dots span {
  width: 6px; height: 6px; border-radius: 50%;
  background: var(--border-strong); opacity: .5;
  transition: opacity .3s ease, background .3s ease;
}
.mf-c-dots span[data-on] { background: var(--orange-deep); opacity: 1; }
/* Each new line fades in, so the change is legible as "he said something else"
   rather than a flicker. */
.mf-c-line { animation: mf-c-turn .45s ease; }
@keyframes mf-c-turn { 0% { opacity: 0; } 100% { opacity: 1; } }
@media (prefers-reduced-motion: reduce) {
  .mf-c-line { animation: none; }
  .mf-c-dots span { transition: none; }
}
@keyframes mf-c-in { 0% { opacity: 0; transform: translateY(8px) scale(.96); } 100% { opacity: 1; transform: none; } }
.mf-c-bubble::after, .mf-c-bubble::before {
  content: ""; position: absolute; border: 6px solid transparent;
}
.mf-c-line + .mf-c-line { margin-top: calc(var(--su) * 1.25); padding-top: calc(var(--su) * 1.25); border-top: 2px dashed var(--border); }
.mf-c-bubble p { margin: 0 0 calc(var(--su) * .75); }
.mf-c-bubble p:last-child { margin-bottom: 0; }
/* Tone is carried by a left rule rather than by colouring the text, which
   would fight the contrast rules. Same three tones the sister site uses. */
.mf-c-line[data-tone="probe"] { border-left: 4px solid var(--orange-deep); padding-left: calc(var(--su) * 1.25); }
.mf-c-line[data-tone="good"]  { border-left: 4px solid var(--line-change); padding-left: calc(var(--su) * 1.25); }

.mf-c-figure {
  pointer-events: auto; cursor: pointer;
  background: none; border: 0; padding: 0; line-height: 0;
  filter: var(--art-tone) drop-shadow(0 3px 6px rgba(44,34,20,.3));
}
.mf-c-figure img { width: 104px; height: auto; display: block; }
.mf-c-figure:focus-visible { outline: 3px solid var(--orange-deep); outline-offset: 4px; border-radius: 8px; }

/* COLLAPSED. Tapping him puts the bubble away — he stays, so it is obvious how
   to get it back, and the work underneath is never blocked by commentary the
   student has finished with. */
.mf-companion[data-open="no"] .mf-c-bubble { display: none; }
.mf-companion[data-open="no"] .mf-c-figure img { width: 76px; opacity: .9; }

/* HE ONLY MOVES WHILE HE IS TALKING. `data-speaking` is set when a line arrives
   and cleared a few seconds later. A 0.78s loop running forever beside a word
   problem is what THEME §3.3 restricts; a loop that marks "he just said this"
   is information. CSS cannot pause a GIF, so the still is swapped in instead —
   see the <picture> in companion.js. */
.mf-companion[data-speaking="no"] .mf-c-figure img { content: none; }

@media (max-width: 620px) {
  /* On a phone he must never sit over the answer box. Smaller, tighter to the
     corner, and the bubble takes the width it needs. */
  .mf-companion { bottom: 12px; right: 12px; max-width: calc(100vw - 24px); }
  .mf-c-figure img { width: 72px; }
  .mf-companion[data-open="no"] .mf-c-figure img { width: 56px; }
  .mf-c-bubble { max-height: 140px; max-width: 210px; font-size: .82rem; }
}
@media (prefers-reduced-motion: reduce) {
  .mf-c-bubble { animation: none; }
}

/* ---------- The Learning Hub, paged ----------
   One topic per screen, with a rail across the top so the whole shape of what
   is being taught is visible at once. The rail is a MAP, not a track: every dot
   is reachable from every page, nothing unlocks and nothing is scored. */
.hub-rail { margin: calc(var(--su) * 2) 0; }
.hub-rail ul {
  display: flex; flex-wrap: wrap; justify-content: center; gap: calc(var(--su));
  list-style: none; margin: 0; padding: 0;
}
.hub-dot {
  display: flex; align-items: center; gap: calc(var(--su));
  font: inherit; font-size: .82em; color: inherit; cursor: pointer; text-align: left;
  background: var(--cream-light); border: 2px solid var(--border-strong);
  border-radius: var(--radius-xs); padding: calc(var(--su) * .75) calc(var(--su) * 1.25);
  /* 44px IS THE SITE'S OWN MINIMUM (`--target-min`) AND THE RAIL WAS UNDER IT.
     Measured on a 375px screen: 46x38, on every dot of all four hubs — thirty
     controls below the size this stylesheet sets for everything else. The rail
     drops its labels on a phone (see the media query further down) and the
     height went with them, which is the shape of the bug: a rule written to
     make something narrower quietly made it shorter too. The label can go; the
     target cannot. */
  min-height: var(--target-min);
}
.hub-dot span {
  display: grid; place-items: center; flex: 0 0 auto;
  width: 1.55em; height: 1.55em; border-radius: 50%;
  background: var(--cream-mid); border: 2px solid var(--border-strong);
  font-weight: 700; font-size: .9em;
}
.hub-dot em { font-style: normal; }
.hub-dot:hover { border-color: var(--orange-deep); }
/* The current step is marked by weight, colour AND the filled counter, so it
   survives greyscale — the same rule the models follow (WCAG 1.4.1). */
.hub-dot[aria-current="step"] { border-color: var(--orange-deep); border-width: 3px; background: var(--cream-mid); font-weight: 700; }
.hub-dot[aria-current="step"] span { background: var(--orange-deep); color: var(--cream-light); border-color: var(--ink); }

.hub-pages { margin: calc(var(--su) * 2) 0; }
.hub-page { animation: hub-in .28s ease backwards; }
@keyframes hub-in { 0% { opacity: 0; transform: translateY(8px); } 100% { opacity: 1; transform: none; } }
.hub-page h3 { margin-top: 0; }
.hub-page h3:focus-visible { outline: 3px solid var(--orange-deep); outline-offset: 3px; }

.hub-art {
  border: 3px solid var(--ink); border-radius: var(--radius-sm);
  background: linear-gradient(180deg, #E8F2F7 0%, var(--cream-light) 78%);
  box-shadow: 0 3px 0 rgba(44,34,20,.18);
  overflow: hidden; margin: 0 0 calc(var(--su) * 2);
}
.hub-art svg { display: block; width: 100%; height: auto; }

/* The trip's own pictures in a hub (hub.js `pickerArt`): the line's shape
   large, captioned exactly as on the platform, and under it each place its
   missing piece can be. Framed like `.hub-art`, with the line's colour as the
   accent rule so the page still says which line it is. */
.hub-picture {
  border: 3px solid var(--ink); border-left: 8px solid var(--line-current, var(--ink));
  border-radius: var(--radius-sm); background: var(--cream-light);
  box-shadow: 0 3px 0 rgba(44,34,20,.18);
  margin: 0 0 calc(var(--su) * 2); padding: calc(var(--su) * 1.5);
}
.hub-picture .mp-svg { display: block; width: 100%; height: auto; color: var(--ink); }
.hub-shape { margin: 0 auto; max-width: 360px; text-align: center; }
.hub-shape figcaption { font-weight: 700; margin-top: 4px; }
.hub-slots { margin-top: calc(var(--su) * 1.5); border-top: 1px dashed var(--border); padding-top: calc(var(--su)); }
.hub-slots-head { font-size: .78em; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-muted);
  font-weight: 700; margin: 0 0 calc(var(--su)); max-width: none; }
.hub-slots ul, .hub-allfive ul { list-style: none; margin: 0; padding: 0; max-width: none;
  display: grid; gap: calc(var(--su)); grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
.hub-slots li, .hub-allfive li { background: var(--cream); border: 1.5px solid var(--border-strong);
  border-radius: var(--radius-xs); padding: 6px; text-align: center; font-size: .82em; line-height: 1.3; }
.hub-slots li .mp-svg, .hub-allfive li .mp-svg { max-width: 150px; margin: 0 auto 4px; }
.hub-allfive li { border-top: 4px solid var(--line-current, var(--border-strong)); }
/* THREE BY TWO, CENTRED — the same arrangement as the trip's picture cards
   (`.mp-grid`), so the five read as the same set in both places (user,
   2026-10-20). Six half-columns, each picture spanning two; the bottom pair
   starts at the second, which centres it. */
.hub-allfive ul { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.hub-allfive li { grid-column: span 2; }
.hub-allfive li:nth-child(4) { grid-column: 2 / span 2; }
/* Phones: two to a row, the fifth centred under them. */
@media (max-width: 560px) {
  /* Four columns, so a lone fifth card can sit exactly in the middle — on six,
     two-to-a-row leaves no centred span for one card (it measured 17px off). */
  .hub-allfive ul { grid-template-columns: repeat(4, minmax(0, 1fr)); }
  .hub-allfive li, .hub-allfive li:nth-child(4) { grid-column: span 2; }
  .hub-allfive li:nth-child(5) { grid-column: 2 / span 2; }
}
/* "Ask yourself" in a hub (hub.js `ART.askset`): the trip's own set, built by
   `ModelPicker.askHTML`, but as a list down the page with bigger pictures —
   the hub is where it is read slowly, not glanced at in a feedback bar. */
.hub-askset .ck-askset { flex-direction: column; flex-wrap: nowrap; align-items: flex-start; gap: 8px; }
.hub-askset .ck-askset li { font-size: 1em; gap: 12px; }
.hub-askset .ck-ask-thumb, .hub-askset .ck-ask-thumb .mp-svg { width: 72px; }
/* At 320px a 72px picture left 59px for the question beside it, and the set
   overflowed its frame (measured). Smaller pictures on phones. */
@media (max-width: 600px) {
  .hub-askset .ck-askset li { gap: 8px; }
  .hub-askset .ck-ask-thumb, .hub-askset .ck-ask-thumb .mp-svg { width: 44px; }
}

.hub-nav { display: flex; align-items: center; justify-content: space-between; gap: calc(var(--su) * 1.5); }
.hub-count { font-weight: 700; font-variant-numeric: tabular-nums; }

/* The tap on each topic. Every page has something to DO, because a hub a
   student only reads is a hub they skim. */
.hub-ask { font-weight: 700; margin-bottom: calc(var(--su)); }
.hub-opts { list-style: none; margin: 0 0 calc(var(--su) * 1.5); padding: 0; display: grid; gap: calc(var(--su)); }
.hub-opt {
  width: 100%; font: inherit; color: inherit; text-align: left; cursor: pointer;
  background: var(--cream-light); border: 2px solid var(--border-strong);
  border-radius: var(--radius-xs); padding: calc(var(--su) * 1.25);
}
.hub-opt:hover:not(:disabled) { border-color: var(--orange-deep); }
.hub-opt[data-result="right"] { border-color: var(--line-groups); border-width: 3px; background: var(--cream-mid); }
.hub-opt[data-result="wrong"] { opacity: .55; }
.hub-opt:disabled { cursor: default; }

/* The checklist, as one object. This is the page we point students at, so the
   five questions have to be readable as a single thing rather than recalled. */
.hub-checklist { list-style: none; margin: 0; padding: 0; counter-reset: none; }
.hub-cl-row {
  display: grid; grid-template-columns: auto 1fr; gap: calc(var(--su) * 1.5);
  padding: calc(var(--su) * 1.5) 0; border-bottom: 2px solid var(--border);
  animation: hub-in .3s ease backwards; animation-delay: calc(var(--cl-i) * .07s);
}
.hub-cl-row:last-child { border-bottom: 0; }
.hub-cl-n {
  display: grid; place-items: center; width: 2em; height: 2em; border-radius: 50%;
  background: var(--orange-deep); color: var(--cream-light);
  border: 2px solid var(--ink); font-weight: 700;
}
/* Since 2026-10-20 each row leads with the trip's own picture for that question
   (hub.js `asksetHTML`) instead of a number, accented in its line's colour. */
.hub-cl-pic { display: block; width: 88px; padding: 4px; background: var(--cream);
  border: 1.5px solid var(--border-strong); border-top: 4px solid var(--line-current, var(--border-strong));
  border-radius: var(--radius-xs); align-self: start; }
.hub-cl-pic .mp-svg { display: block; width: 100%; height: auto; color: var(--ink); }
/* Phones: the picture ABOVE its question. Beside it, the text column at 320px
   was about a hundred pixels wide and the list measured 2,246px tall. */
@media (max-width: 600px) {
  .hub-cl-row { grid-template-columns: 1fr; gap: calc(var(--su) * .75); }
  .hub-cl-pic { width: 72px; }
}
.hub-cl-row h4 { margin: 0 0 calc(var(--su) * .5); }
.hub-cl-row p { margin: 0 0 calc(var(--su) * .5); }
.hub-cl-tell { font-size: .92em; opacity: .85; }
.hub-note { margin-top: calc(var(--su) * 2); padding: calc(var(--su) * 1.5);
  border-left: 5px solid var(--orange-deep); background: var(--cream-mid); border-radius: var(--radius-xs); }

/* Vocabulary. The tier is DATA, not a judgement made at render time. */
.hub-tier { margin: 0 0 calc(var(--su) * 3); }
.hub-tier h4 { margin-bottom: calc(var(--su) * .5); }
.hub-words { list-style: none; margin: calc(var(--su) * 1.5) 0 0; padding: 0; display: grid; gap: calc(var(--su)); }
.hub-word-btn {
  width: 100%; display: flex; align-items: baseline; justify-content: space-between; gap: calc(var(--su));
  font: inherit; color: inherit; text-align: left; cursor: pointer;
  background: var(--cream-light); border: 2px solid var(--border-strong);
  border-radius: var(--radius-xs); padding: calc(var(--su) * 1.1) calc(var(--su) * 1.25);
}
.hub-word-btn:hover { border-color: var(--orange-deep); }
.hub-word-w { font-weight: 700; }
.hub-word-hint { font-size: .78em; text-transform: uppercase; letter-spacing: .04em; opacity: .7; }
.hub-word-body { padding: calc(var(--su) * 1.25); border: 2px dashed var(--border-strong); border-top: 0;
  border-radius: 0 0 var(--radius-xs) var(--radius-xs); background: var(--cream-mid); }
.hub-word-body p { margin: 0 0 calc(var(--su)); }
.hub-word-body p:last-child { margin-bottom: 0; }
.hub-word-ex { font-size: .95em; }
/* Tier 3 is hazard copy — rules that FAIL, quoted so they can be broken. It is
   marked so a student can never mistake it for advice. */
.hub-tier[data-tier="lies"] .hub-word-btn { border-color: #A33; }
.hub-tier[data-tier="lies"] .hub-word-hint { color: #A33; opacity: 1; }

@media (max-width: 560px) {
  .hub-dot em { display: none; }        /* the rail becomes numbers on a phone */
  .hub-nav { flex-wrap: wrap; }
}

/* ---------- Percent: the double number line ----------
   Amounts above the rule, percents below, sharing one axis. That IS the idea —
   the same journey measured two ways, so "20% of it" and "that many pounds" are
   visibly the same place on the same line.

   Grammar deliberately shared with the other four models: solid for an amount
   you have, dashed outline plus "?" for one you do not, and the picture stops
   before the arithmetic. A student arriving from any line should recognise what
   an outline means without being told. */
.pcl-line { margin: calc(var(--su) * 3) 0 calc(var(--su) * 2); padding: 0 calc(var(--su) * 2); }
.pcl-rule {
  position: relative; height: 12px; border-radius: 3px;
  background: repeating-linear-gradient(90deg, var(--border) 0 4px, transparent 4px 13px);
  border-top: 2px solid var(--ink); border-bottom: 2px solid var(--ink);
}
.pcl-fill { position: absolute; inset: 0 auto 0 0; background: var(--line-partwhole); opacity: .5; }
.pcl-axis { position: relative; height: 2.4em; }
.pcl-tick { position: absolute; transform: translateX(-50%); text-align: center; white-space: nowrap; }
.pcl-tick em { font-style: normal; font-weight: 700; font-size: .86em; }
/* The tick's stalk, so a label is tied to a POINT on the line rather than
   floating near it — the whole picture depends on the two axes agreeing. */
.pcl-tick::after {
  content: ""; position: absolute; left: 50%; width: 2px; height: 9px;
  background: var(--ink);
}
.pcl-amounts .pcl-tick { bottom: 0; }
.pcl-amounts .pcl-tick::after { bottom: -9px; }
.pcl-percents .pcl-tick { top: 0; }
.pcl-percents .pcl-tick::after { top: -9px; }
/* AT THE ENDS OF THE LINE THE LABEL IS ANCHORED, NOT CENTRED. Centring hangs
   half a label past the axis, and on a reverse-percent line the amount the
   student was GIVEN sits at the far end — measured at 772px, "660 passengers"
   ended fifty pixels outside its own card. The stalk stays on the point (it is
   pinned to the anchored edge below), so the label still names that place; only
   the text moves inward. `percent-model.js` adds the class. */
.pcl-tick.is-edge-l { transform: none; text-align: left; }
.pcl-tick.is-edge-l::after { left: -1px; }
.pcl-tick.is-edge-r { transform: translateX(-100%); text-align: right; }
.pcl-tick.is-edge-r::after { left: auto; right: -1px; }
/* The unknown: outlined and orange, never filled, exactly as the other models
   mark a value not yet worked out. Survives greyscale via the glyph. */
.pcl-tick.is-unknown em {
  color: var(--orange-deep); border: 2px dashed var(--orange-deep);
  border-radius: var(--radius-xs); padding: 1px 7px; background: var(--cream-light);
}
.pcl-mark em { color: var(--ink); }

.pcl-picks { display: grid; gap: calc(var(--su) * 1.25); margin: calc(var(--su) * 2) 0; }
.pcl-pick {
  display: grid; grid-template-columns: 1fr auto; align-items: center; gap: calc(var(--su) * 1.5);
  font: inherit; color: inherit; text-align: left; cursor: pointer;
  background: var(--cream-light); border: 2px solid var(--border-strong);
  border-radius: var(--radius-xs); padding: calc(var(--su) * 1.25);
}
.pcl-pick:hover:not(:disabled) { border-color: var(--line-partwhole); }
.pcl-pick-name { font-weight: 700; }
.pcl-pick-val { font-variant-numeric: tabular-nums; font-weight: 700; }
.pcl-pick[data-result="right"] { border-color: var(--line-partwhole); border-width: 3px; background: var(--cream-mid); }
.pcl-pick[data-result="wrong"] { opacity: .55; }
.pcl-pick:disabled { cursor: default; }
.pcl-settled {
  margin: calc(var(--su) * 2) 0 0; padding: calc(var(--su) * 1.5);
  border-left: 5px solid var(--line-partwhole); background: var(--cream-mid);
  border-radius: var(--radius-xs);
}
.pcl-settled-label strong { color: var(--orange-deep); }

/* ---------- Equal Groups: the Plan model for Equal Groups Express ----------
   A tray of identical boxes and a bracket under the lot. The visual weight is
   on the SAMENESS of the boxes, because that is the structure — not on the
   total, which is only where the story happens to stop.

   Grammar deliberately shared with the other three models: solid for an amount
   you have, dashed outline plus "?" for one you do not, and the bracket carries
   the question rather than a value. A student who has ridden another line
   should recognise what an outline means here without being told. */
.eg-tray-wrap { margin: calc(var(--su) * 2) 0; }
.eg-tray {
  display: flex; gap: 3px; height: 46px;
  background: repeating-linear-gradient(90deg, var(--border) 0 4px, transparent 4px 13px);
  border-radius: 3px;
}
.eg-box {
  display: flex; align-items: center; justify-content: center;
  background: var(--line-groups); border: 2px solid var(--ink); border-radius: 3px;
  color: var(--cream-light); font-weight: 700; font-size: .82em;
  overflow: hidden; white-space: nowrap; min-width: 0;
}
.eg-box-val { padding: 0 2px; text-overflow: ellipsis; overflow: hidden; }
/* Each group LAYS DOWN after the one before it. On this line that motion is the
   idea — the same amount, put down again and again — so a tray that arrives
   fully formed shows only the result of the act, never the act. `backwards`
   fill matters: without it a box waiting out its delay renders at full opacity
   in its final place, which is the freeze-frame the ratio scenes were bitten by. */
.eg-box, .ttg-box {
  animation: eg-lay .34s cubic-bezier(.32,0,.28,1) backwards;
  animation-delay: calc(var(--eg-i, 0) * .13s);
}
@keyframes eg-lay {
  0%   { opacity: 0; transform: translateY(-9px) scaleX(.86); }
  100% { opacity: 1; transform: none; }
}
/* The bracket and its label arrive after the last box, so the total reads as a
   consequence of the groups rather than a thing that was always there. */
.eg-total-bracket { animation: eg-lay .4s ease backwards; animation-delay: calc(var(--eg-n, 3) * .13s); }
/* An unknown group size: outlined, never filled, and it says "?" — outline plus
   the glyph means it survives greyscale (WCAG 1.4.1), same as .cmp-fill-unknown. */
.eg-box-unknown { background: transparent; border-style: dashed; color: var(--orange-deep); }
/* The part of the total one unit does NOT cover. It is hatched rather than
   boxed on purpose: boxes would be groups, and how many groups there are is the
   question this shape exists to ask. */
.eg-rest {
  display: flex; align-items: center; justify-content: center;
  background: repeating-linear-gradient(45deg,
    var(--cream-light) 0 5px, var(--cream-mid) 5px 10px);
  border: 2px dashed var(--ink); border-left: 0; border-radius: 0 3px 3px 0;
  font-size: .78em; font-weight: 700; color: var(--orange-deep);
  overflow: hidden; white-space: nowrap; min-width: 0;
}
.eg-rest-q { padding: 0 4px; text-overflow: ellipsis; overflow: hidden; }
.eg-total { display: block; margin-top: calc(var(--su)); }
/* Spans the whole tray, because the total is what ALL the groups come to —
   a bracket under part of the row would be a different claim. */
.eg-total-bracket {
  display: block; height: 10px;
  border: 3px solid var(--line-groups); border-top: 0; border-radius: 0 0 4px 4px;
}
.eg-total-label { display: block; margin-top: calc(var(--su)); font-weight: 600; }
.eg-total-label strong { color: var(--orange-deep); font-size: 1.15em; }

/* The tap targets are the named QUANTITIES, not the boxes — a row of boxes is
   already an answer to "which amount repeats?". */
.eg-picks { display: grid; gap: calc(var(--su) * 1.25); margin: calc(var(--su) * 2) 0; }
.eg-pick {
  display: grid; grid-template-columns: 1fr auto; align-items: center;
  gap: calc(var(--su) * 1.5);
  font: inherit; color: inherit; text-align: left; cursor: pointer;
  background: var(--cream-light); border: 2px solid var(--border-strong);
  border-radius: var(--radius-xs); padding: calc(var(--su) * 1.25);
}
.eg-pick:hover:not(:disabled) { border-color: var(--line-groups); }
.eg-pick-name { font-weight: 700; }
.eg-pick-val { font-variant-numeric: tabular-nums; font-weight: 700; }
.eg-pick[data-result="right"] { border-color: var(--line-groups); border-width: 3px; background: var(--cream-mid); }
.eg-pick[data-result="wrong"] { opacity: .55; }
.eg-pick:disabled { cursor: default; }
.eg-settled {
  margin: calc(var(--su) * 2) 0 0; padding: calc(var(--su) * 1.5);
  border-left: 5px solid var(--line-groups); background: var(--cream-mid);
  border-radius: var(--radius-xs);
}
.eg-settled-label strong { color: var(--orange-deep); }

/* The Equal Groups Test Track picture. Same classes as the Plan model on
   purpose — the demonstration and the student's own picture must read as one
   object. These only add the states the two questions set. */
.ttg { margin: calc(var(--su) * 2) 0; }
.ttg[data-have="group"] .ttg-box:first-child { box-shadow: 0 0 0 3px var(--cream-mid), 0 0 0 5px var(--line-groups); }
.ttg[data-have="total"] .eg-total-bracket { border-color: var(--orange-deep); }
.ttg[data-way] .eg-total-label strong { color: var(--orange-deep); }
.ttg[data-way="share"] .eg-rest { border-color: var(--orange-deep); color: var(--orange-deep); }

/* The Compare Test Track picture. Same grammar as the Plan model — solid for an
   amount you have, outline and "?" for one you do not, hatching for the gap —
   so the demonstration and the student's own picture read as the same object. */
.ttcmp { display: grid; gap: calc(var(--su) * 1.25); margin: calc(var(--su) * 2) 0; }
.ttc-row { display: grid; grid-template-columns: 8em 1fr 3em; align-items: center; gap: calc(var(--su) * 1.25); }
.ttc-name { font-size: .9em; font-weight: 700; }
.ttc-track { position: relative; height: 22px; border-radius: 3px;
  background: repeating-linear-gradient(90deg, var(--border) 0 4px, transparent 4px 13px); }
.ttc-fill { position: absolute; inset: 0 auto 0 0; border-radius: 3px;
  background: var(--line-compare); border: 2px solid var(--ink); }
.ttc-row[data-unknown] .ttc-fill { background: transparent; border-style: dashed; }
.ttc-gap { position: absolute; top: 0; bottom: 0;
  display: flex; align-items: center; justify-content: center;
  background: repeating-linear-gradient(45deg, var(--cream-light) 0 5px, var(--cream-mid) 5px 10px);
  border: 2px dashed var(--ink); border-left: 0;
  font-size: .72em; font-weight: 700; overflow: hidden; }
.ttc-copy { position: absolute; top: 0; bottom: 0; width: 0;
  border-left: 2px dashed var(--cream-light); opacity: .85; pointer-events: none; }
.ttc-val { font-weight: 700; font-variant-numeric: tabular-nums; }
.ttc-row[data-unknown] .ttc-val { color: var(--orange-deep); }
/* States set by the two questions — which amount you have, and which way you go. */
.ttcmp[data-have="known"] .ttc-known .ttc-fill { box-shadow: 0 0 0 3px var(--cream-mid), 0 0 0 5px var(--line-compare); }
.ttcmp[data-way] .ttc-gap { border-color: var(--orange-deep); color: var(--orange-deep); }
.cmp-gap-label strong { color: var(--orange-deep); font-size: 1.15em; }

/* the rail the train sits on */
.yard-track {
  height: 7px; border-radius: 3px; margin: 0 0 -4px;
  background: repeating-linear-gradient(90deg,
    var(--border-strong) 0 4px, transparent 4px 13px);
  border-bottom: 3px solid var(--ink-muted);
}
.yard-train { position: relative; padding-top: calc(var(--su) * 1.5); overflow-x: auto; }

.whole-car {
  display: flex; align-items: center; justify-content: center;
  min-height: 66px; padding: 0 calc(var(--su) * 2);
  border: 3px solid var(--ink); border-radius: var(--radius-sm);
  background: var(--cream-mid);
  font-weight: 700; letter-spacing: .02em;
  box-shadow: 0 3px 0 rgba(44,34,20,.18);
}
.whole-car[hidden] { display: none; }

.cars { display: flex; gap: 4px; min-width: min-content; }
.cars[hidden] { display: none; }

.car {
  position: relative; overflow: hidden;
  flex: 1 1 0; min-width: 34px; min-height: 66px;
  border: 3px solid var(--ink); border-radius: var(--radius-xs);
  background: var(--cream-light); color: var(--ink);
  font-family: var(--font-body); font-size: 13px; font-weight: 700;
  cursor: pointer; padding: 0;
  box-shadow: 0 3px 0 rgba(44,34,20,.18);
  /* cars slide in when the whole is split */
  animation: car-arrive .34s cubic-bezier(.2,.8,.3,1) backwards;
  animation-delay: calc(var(--car-i) * 55ms);
}
@keyframes car-arrive {
  from { transform: translateX(-14px) scaleX(.4); opacity: 0; }
  to   { transform: none; opacity: 1; }
}
.car:hover { border-color: var(--orange-deep); }
/* The value a part is worth: hidden until the student marks that part, then
   shown instantly. NOT a transition — this carries state, and a state that
   fades in can be caught mid-fade and read as "nothing happened". */
.car-val, .car-unit { position: relative; z-index: 1; visibility: hidden; }
.car[aria-pressed="true"] .car-val,
.car[aria-pressed="true"] .car-unit { visibility: visible; }
.car-unit {
  display: block; font-size: .62em; font-weight: 600;
  letter-spacing: .02em; opacity: .75; margin-top: 1px;
}

/* The fill is a separate layer so the pressed STATE never depends on a
   transition finishing — the lesson from the stuck border-colour bug. */
.car-fill {
  position: absolute; inset: 0; transform: scaleX(0); transform-origin: left center;
  background: var(--line-current, var(--line-partwhole));
  transition: transform .22s ease-out;
}
.car[aria-pressed="true"] { color: #fff; border-color: var(--ink); }
.car[aria-pressed="true"] .car-fill { transform: scaleX(1); }

/* While marking, cars must fill in the colour the student was ASKED for —
   not the line colour. Being told "blue" and watching parts turn red is worse
   than no colour at all. */
.yard[data-active="blue"] .car[aria-pressed="true"]:not([data-locked]) .car-fill { background: var(--line-compare); }
.yard[data-active="red"]  .car[aria-pressed="true"]:not([data-locked]) .car-fill { background: var(--line-partwhole); }

/* Multi-group marking: each colour keeps its own fill once locked in, so the
   two given fractions stay visually distinct and the sum is readable. */
.car[data-group] .car-fill { transform: scaleX(1); }
.car[data-group="blue"] .car-fill { background: var(--line-compare); }
.car[data-group="red"]  .car-fill { background: var(--line-partwhole); }
.car[data-group="blue"], .car[data-group="red"] { color: #fff; }
.car[data-group="unknown"] .car-fill {
  background: repeating-linear-gradient(45deg,
    var(--cream-light) 0 5px, var(--cream-mid) 5px 10px);
}
.car[data-group="unknown"] { color: var(--ink); }
.car[data-group="unknown"]::after { content: "?"; color: var(--ink); }
.car[data-locked] { cursor: default; opacity: 1; }

.ro-dot {
  display: inline-block; width: 11px; height: 11px; border-radius: 2px;
  border: 1.5px solid rgba(44,34,20,.45); margin-right: 5px; vertical-align: -1px;
}
.ro-dot.g-blue { background: var(--line-compare); }
.ro-dot.g-red  { background: var(--line-partwhole); }

/* Marked cars also carry a non-colour signal (WCAG 1.4.1). */
.car[aria-pressed="true"]::after {
  content: "\2713"; position: absolute; top: 2px; right: 4px;
  font-size: 11px; color: #fff; z-index: 1;
}

.yard[data-stage="done"] .cars { outline: 3px solid var(--go); outline-offset: 4px; border-radius: 4px; }

.yard-readout {
  display: flex; flex-wrap: wrap; gap: calc(var(--su) * 2);
  margin-top: calc(var(--su) * 2); padding-top: var(--su);
  border-top: 1px dashed var(--border-strong);
  font-size: 13px; color: var(--ink-mid);
}
.yard-readout[hidden] { display: none; }
.ro-item strong { color: var(--ink); font-variant-numeric: tabular-nums; }

@media (prefers-reduced-motion: reduce) {
  .car { animation: none; }
  .car-fill { transition: none; }
}

/* ---------- Bar model (legacy) ---------- */
.bar-model { margin: calc(var(--su) * 2) 0; }
.bar-row { display: flex; align-items: center; gap: var(--su); margin-bottom: var(--su); flex-wrap: wrap; }
.bar-label { min-width: 96px; font-size: 13px; font-weight: 700; color: var(--ink-muted); }
.bar {
  display: flex; border: 2px solid var(--ink); border-radius: var(--radius-xs);
  overflow: hidden; min-width: 220px; flex: 1;
  box-shadow: 0 1px 3px var(--shadow);
}
.seg {
  flex: 1; min-height: 48px; min-width: var(--target-min);
  display: flex; align-items: center; justify-content: center;
  font-family: var(--font-body); font-size: 13px; font-weight: 700;
  border-right: 2px solid var(--ink);
  background: var(--cream-light); color: var(--ink);
  cursor: pointer; transition: background .12s;
}
.seg:last-child { border-right: 0; }
.seg:hover { background: var(--cream-mid); }
.seg[aria-pressed="true"] { background: var(--line-current, var(--line-partwhole)); color: #fff; }

/* ---------- Hints ---------- */
.hints { margin-top: calc(var(--su) * 3); }
.hint-rung {
  border-left: 5px solid var(--info);
  background: #EDF2FA; padding: calc(var(--su) * 1.75) calc(var(--su) * 2);
  border-radius: var(--radius-sm); margin-bottom: var(--su); max-width: var(--measure);
}
.hint-rung .rung-name {
  display: block; font-size: 11px; font-weight: 700;
  letter-spacing: .12em; text-transform: uppercase; color: var(--info); margin-bottom: 4px;
}

/* ---------- Map ---------- */
.map-hero { margin-bottom: calc(var(--su) * 4); }
.map-figure {
  background: var(--cream-light);
  border: 1.5px solid var(--border);
  border-radius: var(--radius);
  padding: calc(var(--su) * 2);
  box-shadow: 0 2px 8px var(--shadow);
  overflow-x: auto;
}
.map-figure svg { display: block; min-width: 700px; width: 100%; height: auto; }

/* Moving water. Decorative only — nothing on this map is communicated by
   motion, so the reduced-motion rule can freeze it all with no loss. */
.map-figure .mf-flow { animation: mf-river-flow 3.4s linear infinite; }
.map-figure .mf-flow-slow { animation-duration: 6s; }
@keyframes mf-river-flow { to { stroke-dashoffset: -34; } }

/* Chimney smoke. Each puff leaves the stack, drifts back over the cab,
   swells and fades; staggered delays make one continuous plume. */
.map-figure .mf-smoke {
  transform-box: fill-box;
  transform-origin: center;
  animation: mf-smoke-rise 3.7s linear infinite;
  opacity: 0;
}
@keyframes mf-smoke-rise {
  0%   { transform: translate(0, 0)        scale(.3);  opacity: 0; }
  12%  { opacity: .52; }
  55%  { opacity: .38; }
  100% { transform: translate(42px, -58px) scale(2.15); opacity: 0; }
}
.map-figure .mf-smoke-still { display: none; }

.map-figure .mf-wave { animation: mf-wave-bob 4.6s ease-in-out infinite; transform-box: fill-box; }
@keyframes mf-wave-bob {
  0%, 100% { transform: translateY(0)    scaleX(1);    opacity: .8; }
  50%      { transform: translateY(2.4px) scaleX(1.05); opacity: .55; }
}

/* Clicking a running line on the map is a redundant convenience for mouse
   users. The button list below is the operable path and always works. */
.map-figure .map-hit { cursor: pointer; }
.map-figure .map-hit:hover { filter: brightness(1.06) saturate(1.15); }
.map-figure .map-hit:hover circle { stroke-width: 3; }

/* Two ROWS, not two columns. Five lines — six once the Grand Tour appears —
   so three across lands them in two rows either way. The old `1fr 1fr` put
   them in three tall columns of two, which read as a longer list than it is.
   Two columns is kept as the middle step down for narrower windows. */
/* CARD GRIDS ARE NOT PROSE, SO THEY DO NOT SIT AT THE MEASURE'S LEFT EDGE.
   `ul, ol { max-width: var(--measure) }` up at the top of this file is a
   READING rule — it stops a paragraph-like list running to 200 characters.
   These three lists are card grids that happen to be marked up as <ul>, so
   they inherited the 64ch cap and then sat flush LEFT inside a 1180px main:
   788px of cards with 352px of nothing beside them, under a map figure
   running the full 1140px. It read as a layout mistake because it was one.

   Centred rather than widened — the shorter row is easier to scan, and the
   cap itself is doing no harm once the block is centred. All three are fixed
   together: the same rule was pinning the route chooser's Local/Express/
   Limited buttons one click away.

   The `auto` goes in each rule's own margin shorthand, not a shared rule
   above them — a later `margin: 0` would reset margin-inline and silently
   undo it. */
.map-lines { list-style: none; padding: 0; margin: 0 auto; display: grid; gap: calc(var(--su) * 2); }
@media (min-width: 620px) { .map-lines { grid-template-columns: 1fr 1fr; } }
@media (min-width: 900px) { .map-lines { grid-template-columns: repeat(3, 1fr); } }

/* ---------- the five, three then two centred ----------
   THREE AND TWO, NOT FIVE ACROSS, AND THE TICKET IS THE REASON.

   The first version put all five on one row. It held the grouping — five cards
   side by side plainly reads as "these are the set" — but it cost 218px cards,
   and at that width the ticket is wider than the text beside it. So the ticket
   and the description were both hidden, and the blue ticket that every card is
   supposed to carry survived only on mobile, where the card goes full width
   again. The card had been made simpler to fit a layout, rather than the layout
   chosen to fit the card.

   Three then two keeps the grouping — a block of five, obviously five, obviously
   apart from the specials underneath — and buys ~299px per card, which is the
   width the specials already use to carry a ticket and a description
   comfortably. Nothing is hidden at any width now.

   SAME WIDTH AS THE SPECIALS, deliberately: both groups sit in a 920px column,
   so their card edges line up down the page and the two blocks read as one
   system rather than as two grids that happen to be near each other. */
/* 900px — the SAME breakpoint the base `.map-lines` grid uses to go to three
   columns, and matching it is the point. An earlier version broke at 1000,
   which left a 900–999 band where the base grid had already gone to three
   columns but this rule had not: the five still wrapped 3+2, but uncentred and
   inside the 64ch reading measure, so the cards came out 252px against 299
   either side of the band. A layout that is right above and below a range and
   wrong inside it is worse than one that is simply coarse.
   Below 900 the base grid takes over — two columns, then one on a phone. */
@media (min-width: 900px) {
  .map-lines-five,
  .map-lines-special {
    /* Six tracks so a card can span two and the odd row can be offset by one,
       which is what centres the last pair. */
    grid-template-columns: repeat(6, 1fr);
    width: min(920px, 100%);
    max-width: none;
    margin-inline: auto;
    gap: calc(var(--su) * 1.5);
  }
  .map-lines-five > li,
  .map-lines-special > li { grid-column: span 2; }

  /* CENTRE THE LAST TWO — and the selector is self-limiting on purpose.
     `:nth-child(4):nth-last-child(2)` matches the fourth item only when it is
     also second from last, which is true of a five-item list and of nothing
     else. Add a sixth line one day and this rule simply stops applying, leaving
     a clean 3+3; it cannot silently mis-lay-out a list it was not written for.
     Hardcoding `li:nth-child(4)` would have done exactly that. */
  .map-lines-five > li:nth-child(4):nth-last-child(2) { grid-column: 2 / span 2; }
}

/* The specials are laid out with the five above, in the one rule, because they
   now share a grid: same six tracks, same 920px column, same breakpoint. They
   were a separate `repeat(3, 1fr)` block here, which after the five moved to
   six tracks would have silently overridden the shared rule and put the two
   groups back on different grids — the exact "two grids that happen to be near
   each other" this was meant to stop. One rule, both lists. */

/* An announced-but-unbuilt line. UNHIGHLIGHTED is the whole brief: no line
   colour, no coloured stripe, and the card sits back rather than inviting a
   press. It keeps the same footprint as a running card so the row of three
   stays a row of three. */
/* `.map-lines .line-card-soon`, NOT `.line-card-soon`, and the extra class is
   load-bearing for the reason this file already records once, three hundred
   lines up: THE BASE `.line-card` BLOCK IS DECLARED LATER IN THIS FILE, so at
   equal specificity it wins. Written as a single class, `border-style: dashed`
   was overridden by the base rule's `border-left: 10px solid` and the card
   rendered with a solid stripe — measured, not guessed: computed
   border-left-style came back `solid` while the source said dashed. A rule that
   looks applied and is not.

   `opacity` is deliberately NOT set here. `.line-card[disabled]` already dims
   every unavailable card to .62, and that is the treatment a student has
   already met on a line with too few problems; a second value would lose to it
   on specificity anyway and would only be a second source of truth. */
.map-lines .line-card-soon {
  border-color: var(--border);
  border-style: dashed;
  border-left-color: var(--border);
  background: var(--cream-mid);
  box-shadow: none;
}
.map-lines .line-card-soon .line-name,
.map-lines .line-card-soon .line-form,
.map-lines .line-card-soon .line-desc { color: var(--ink-mid); }
/* No lift on hover: nothing here is pressable yet, and a card that animates
   under the cursor promises otherwise. */
.map-lines .line-card-soon:hover { transform: none; box-shadow: none; }
.line-card {
  display: block; width: 100%; text-align: center;
  font-family: var(--font-body); font-size: 1rem; cursor: pointer;
  background: var(--cream-light);
  border: 1.5px solid var(--border);
  border-left: 10px solid var(--border-strong);
  border-radius: var(--radius);
  /* The extra on the right is the left stripe, paid back.
     text-align centres within the CONTENT box, which starts after the 10px
     border — so centred text sits right of the card's true middle. The
     compensation is the DIFFERENCE between the two borders, 10px − 1.5px:
     padding it by the full 10 overshot and left everything 0.5px light. */
  padding: calc(var(--su) * 2.5);
  padding-right: calc(var(--su) * 2.5 + 8.5px);
  box-shadow: 0 1px 3px var(--shadow);
  transition: transform .15s, box-shadow .15s, border-color .15s;
}
.line-card:hover { transform: translateY(-3px); box-shadow: 0 6px 16px var(--shadow-lift); }
.line-card .line-name {
  font-family: var(--font-display); font-weight: 400; letter-spacing: .03em;
  font-size: 1.2rem; display: flex; align-items: center; gap: 10px; margin-bottom: 2px;
  /* flex, so text-align does not reach it — the marker and name are centred
     as a pair, keeping the marker tight against the name rather than pinned
     to the edge. */
  justify-content: center;
}
.line-card .line-form {
  display: block; font-size: 13px; font-weight: 700;
  letter-spacing: .04em; color: var(--ink-muted);
}
.line-card .line-desc { display: block; margin: 10px 0 12px; color: var(--ink-mid); font-size: 14px; }
.line-card[disabled] { cursor: not-allowed; opacity: .62; }
.line-card[disabled]:hover { transform: none; box-shadow: 0 1px 3px var(--shadow); }
.line-card .soon {
  display: inline-block; font-size: 11px; font-weight: 700;
  letter-spacing: .1em; text-transform: uppercase; color: var(--ink-muted);
  border: 1px dashed var(--border-strong); border-radius: 999px; padding: 4px 12px;
}
.badge {
  display: inline-block; font-size: 11px; font-weight: 700;
  letter-spacing: .1em; text-transform: uppercase;
  padding: 4px 12px; border-radius: 999px;
  background: var(--orange-deep); color: #fff;
}

/* Learning hubs */
/* ALL THE HUBS ON ONE CENTRED LINE. It was a two-column grid, which put an odd
   number of hubs out of balance — with three, one card sat alone on a second row
   looking like an afterthought rather than a peer. A centred flex row keeps them
   equal, keeps them together, and copes with a fourth without a rule change.
   `flex: 1 1 0` makes the cards share the width evenly rather than sizing to
   their text, so the longest blurb cannot make one card twice its neighbour. */
/* ONE ROW, OR AN EVEN GRID — NEVER THREE-AND-A-STRAY.
   Flex wrapping decided the columns from whatever happened to fit, which with
   four hubs gave three across and one orphaned underneath, looking like an
   afterthought rather than a peer. An explicit column count cannot do that:
   two abreast by default (four hubs make a clean 2x2), all in a single row on a
   wide screen, one per row on a phone. */
/* AND THEN A FIFTH HUB ARRIVED AND DID EXACTLY THAT ANYWAY — 2026-08-16.
   `repeat(4, 1fr)` was written when there were four hubs, so The Lighthouse
   rendered FOUR ACROSS AND ONE ORPHANED, left-aligned under the first card:
   measured at 1280px, four cards at y2757 and one at y2959, x239. The rule
   above says in its own words that this is the thing it exists to prevent. A
   fixed column count does not prevent a stray, it just picks which count of
   items produces one — the defect class this project files under
   "discovered, not listed".

   SO IT IS THE SIX-TRACK GRID THE LINE CARDS ALREADY USE, and the selectors
   below are self-limiting for the reason that one is: they name a shape by a
   property only that shape has, so a list of a different length does not match
   and falls back to a clean row rather than being mis-laid-out.

   It also puts the hub cards on the SAME six tracks, the same 920px column and
   the same 900px breakpoint as `.map-lines-five` above them, so on the map the
   two groups' card edges line up down the page instead of being two grids that
   happen to sit near each other. */
/* FOUR TRACKS, CARDS SPANNING TWO — so "two abreast" and "a centred odd one"
   are both expressible. A plain `repeat(2, 1fr)` cannot centre anything,
   because there is no half-column to offset by; that is why five hubs came out
   2 + 2 + 1-hard-left in the 620–899 band. */
.hub-list {
  list-style: none; padding: 0; margin: 0 auto;
  display: grid; grid-template-columns: repeat(4, 1fr);
  align-items: stretch; gap: calc(var(--su) * 1.5);
}
.hub-list > li { grid-column: span 2; }

/* THE MIDDLE BAND, and it is scoped to the band on purpose. `:nth-child(odd)`
   combined with `:last-child` is true of the final card only when the count is
   odd — five hubs, or the island's one — and false for an even count, which
   stays a clean 2×N. Confining it between the two breakpoints keeps it from
   fighting the six-track rules below, where item five must be allowed to flow
   to column four rather than being pinned to column two. */
@media (min-width: 621px) and (max-width: 899px) {
  .hub-list > li:nth-child(odd):last-child { grid-column: 2 / span 2; }
}

@media (min-width: 900px) {
  .hub-list {
    grid-template-columns: repeat(6, 1fr);
    width: min(920px, 100%);
    /* `max-width: none` IS LOAD-BEARING, and without it this rule was a lie.
       The global `ul, ol { max-width: var(--measure) }` caps every list at the
       64ch reading measure — 788px — so `width: min(920px, 100%)` resolved to
       788 and the hub cards sat 132px narrower than the line cards they were
       supposed to line up with. Measured 2026-08-16: hub list 788 against the
       card grids' 920. A reading measure is right for prose and wrong for a
       row of buttons. */
    max-width: none;
    margin-inline: auto;
  }
  .hub-list > li { grid-column: span 2; }

  /* THREE THEN TWO, CENTRED. `:nth-child(4):nth-last-child(2)` matches the
     fourth item only when it is also second from last — true of a five-item
     list and of nothing else. Five hubs today; a sixth makes a clean 3+3 and
     this rule simply stops applying. */
  .hub-list > li:nth-child(4):nth-last-child(2) { grid-column: 2 / span 2; }

  /* A TRAILING SINGLE CARD IS CENTRED RATHER THAN LEFT IN COLUMN ONE.
     `:nth-child(4):last-child` is the four-hub case (3 + 1). Without it, going
     back to four hubs would reinstate the very orphan described above. */
  .hub-list > li:nth-child(4):last-child { grid-column: 3 / span 2; }

  /* THE ISLAND'S HUB LIST HOLDS EXACTLY ONE CARD — The Lighthouse — and it was
     landing in column one of a four-column grid, hard left under a centred
     heading. `:first-child:last-child` is true only of a one-item list, so this
     centres that card and can never touch a longer one. */
  .hub-list > li:first-child:last-child { grid-column: 3 / span 2; }
}
/* ONE PER ROW ON A PHONE — and the span has to be UNDONE, not just the track
   count changed. A `grid-column: span 2` left in place against a single-track
   grid makes the browser generate an implicit second column to satisfy the
   span, which is a silent horizontal overflow on the narrowest screen there
   is. */
@media (max-width: 620px) {
  .hub-list { grid-template-columns: 1fr; }
  .hub-list > li,
  .hub-list > li:nth-child(odd):last-child,
  .hub-list > li:nth-child(4):nth-last-child(2),
  .hub-list > li:nth-child(4):last-child,
  .hub-list > li:first-child:last-child { grid-column: auto; }
}
.hub-list > li { display: flex; }
.hub-list .hub-card { height: 100%; }
.hub-card {
  display: block; width: 100%; text-align: left;
  font-family: var(--font-body); font-size: 1rem; cursor: pointer;
  background: var(--cream-light);
  border: 2px dashed var(--orange-deep);
  border-radius: var(--radius);
  padding: calc(var(--su) * 2.25);
  transition: transform .15s, box-shadow .15s, background .15s;
}
.hub-card:hover { background: #FCF3E4; transform: translateY(-2px); box-shadow: 0 5px 14px var(--shadow-lift); }
.hub-card strong { display: block; font-family: var(--font-display); font-weight: 400; letter-spacing: .03em; font-size: 1.08rem; }
.hub-card small { color: var(--ink-muted); display: block; margin-top: 4px; }

/* ---------- The map's hero, ported from the sister site ----------
   The Factory opens its home page at 52px: a tracked eyebrow with a rule, a
   two-tone display line, then a short intro. This site's map opened at 22.4px
   — its largest type was a section heading — so the eye had nowhere to land,
   and once the offer strip went in at the foot of the page the biggest thing
   on the screen was the LAST thing on it.

   THE PALETTE NEEDED NO TRANSLATION AGAIN. The Factory's heading is #2C2214
   with a #C96A1F accent word over a #5A3E28 intro; those are `--ink`,
   `--orange` and `--ink-mid` here, exactly.

   WHAT IS NOT PORTED: the Factory floats a 280px gear behind this text,
   spinning on a 35s loop. THEME §3.3 bans decorative animation near reading
   content, and the exception carved for scenes that ARE the problem does not
   cover a background gear. ROADMAP §5 already recorded that the gears were
   never imported; this is the first place one would have been.

   UPPERCASE IN CSS, NOT IN THE MARKUP. The Factory types its heading in
   capitals in the source. Set that way, some screen readers spell a short
   all-caps word letter by letter; `text-transform` leaves the accessible name
   as ordinary sentence case and changes only what is drawn. */
.home-hero { margin: 0 0 calc(var(--su) * 4); }
.home-eyebrow {
  display: flex; align-items: center; gap: calc(var(--su) * 1.5);
  font-family: var(--font-body); font-size: 12px; font-weight: 700;
  letter-spacing: .2em; text-transform: uppercase; color: var(--ink-muted);
  margin: 0 0 calc(var(--su) * 1.5); max-width: none;
}
.home-eyebrow-line { display: block; width: 44px; height: 3px; flex: none; background: var(--orange); border-radius: 2px; }
.home-heading {
  font-family: var(--font-display); font-weight: 400;
  /* Fluid rather than a breakpoint step: at a fixed 52px this line overflows
     a 320px screen, and the wrap point matters more than the size does. */
  font-size: clamp(28px, 5.2vw, 52px);
  line-height: 1.04; letter-spacing: .04em; text-transform: uppercase;
  color: var(--ink); margin: 0 0 calc(var(--su) * 2); max-width: none;
}
.home-heading-accent { color: var(--orange); }
.home-intro { font-size: 1rem; color: var(--ink-mid); max-width: var(--measure); margin: 0; }

/* ---------- The offer strip, ported from the sister site ----------
   Mr. Fraction Factory closes its home page with three boxes — 5 STATIONS,
   ∞ FRACTIONS TO EXPLORE, 1 MR. FRACTION — saying what the place holds in the
   fewest words it can. This site ended on a list of hubs and stopped.

   THE PALETTE NEEDED NO TRANSLATION, which is the strongest evidence yet that
   these two really are one design system: the Factory's strip is #FDF8F0 on a
   #C8B89A border with #C96A1F numerals, and those are this site's
   `--cream-light`, `--border` and `--orange` to the byte. The display face is
   Black Han Sans in both, and it is already self-hosted here.

   TWO THINGS ARE DELIBERATELY NOT COPIED, and they are the standing caveat in
   ROADMAP's sister-site section — match the look, not a rule this site holds
   and the Factory does not:

   1. THE LABEL COLOUR. The Factory sets `.stat-label` to #A08060 at 11px,
      which measures 3.45:1 on this cream — under the 4.5 that size needs.
      `--ink-muted` measures 6.95:1, and the label is 12px here. Both numbers
      taken off the rendered page, not from the spec.
   2. THE NUMERAL SIZE IS LOAD-BEARING, not decorative. `--orange` on
      `--cream-light` measures 3.57:1, which passes as LARGE text and fails as
      normal text. At 2rem it is large by a wide margin — but anyone shrinking
      these numerals below 24px breaks AA doing it, and the number would look
      fine the whole time. Same lesson the Platform Check's "you are here" row
      taught: a threshold is a claim about how something RENDERS. */
.stats-strip {
  list-style: none; padding: 0;
  display: grid; grid-template-columns: repeat(3, 1fr);
  background: var(--cream-light);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  /* CENTRED, AND ON THE SAME COLUMN AS EVERYTHING ABOVE IT. As first written
     this had side margins of 0 and inherited the global `ul` reading-measure
     cap, so it came out 788px wide and left-aligned inside a 1140px parent —
     measured at centre 456 while the hero, the explainer, both card grids and
     the hub list all sit at 633. It was the only block on the map that was not
     centred, and being a bordered box made that obvious in a way a paragraph
     would not have been. */
  width: min(920px, 100%);
  max-width: none;
  margin: calc(var(--su) * 5) auto calc(var(--su) * 2);
}
.stat-item {
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  padding: calc(var(--su) * 2.5); text-align: center;
}
/* THREE SECTIONS, SPLIT. The Factory runs its three cells together with
   nothing between them, which works there because they are one strip of
   trivia. Here they are three separate claims about what the site holds, and
   a rule between them says so. `+` rather than a border on every item, so the
   outer edges stay the container's own 1px and do not double up. */
.stat-item + .stat-item { border-left: 1px solid var(--border); }
.stat-value {
  font-family: var(--font-display); font-weight: 400;
  font-size: 2rem; line-height: 1.05; letter-spacing: .01em;
  color: var(--orange);
}
.stat-label {
  margin-top: 6px;
  font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: .1em;
  color: var(--ink-muted);
}
/* Stacked, the three need a line between them: the Factory's version relies on
   sitting side by side to read as three things, and that stops being true the
   moment they are in a column. */
@media (max-width: 620px) {
  .stats-strip { grid-template-columns: 1fr; }
  /* The divider has to TURN, not just appear. Left in place on a stacked
     column it draws a 1px stub down the left edge of every box but the first,
     which reads as a rendering fault rather than as a separator. */
  .stat-item + .stat-item { border-left: 0; border-top: 1px solid var(--border); }
}

/* ---------- The estimate gate: a band on a line, a field, and ink ----------
   `ROADMAP` §8 and `docs/ESTIMATE-INPUT.md`. The brief was that estimating and
   calculating should not look alike — the hand for a guess, the keyboard for a
   result — so this side is paper: soft edges, a drawn rule, no machined box.

   THE TYPED FIELD SITS BESIDE THE LINE, never behind a toggle (user,
   2026-08-16). For most students it is an alternative; for some it is the only
   door, and a door behind a toggle reads as the back way in. It gets equal
   width and equal weight here for that reason. */
.est-wrap {
  display: grid; gap: calc(var(--su) * 3);
  grid-template-columns: 1fr;
  margin: calc(var(--su) * 2) 0 calc(var(--su) * 3);
}
/* THE LINE SITS LEVEL BETWEEN THE FIELD AND THE PAD (user, 2026-08-16). It was
   `align-items: start`, which pinned the line to the top of a right-hand column
   more than twice its height, so it read as floating above the pad rather than
   as one of two things side by side. `center` is on the grid rather than on the
   column, so the line stays centred against whatever that column grows to. */
/* PAD LEFT, LINE RIGHT, and an EVEN split rather than a generous one for the
   pad. At 1.25fr the pad was 592px but the rail came out 241 — and the rail is
   what the student drags, so a line too short to sweep accurately is a worse
   failure than a pad that could have been bigger. Even columns leave the pad
   larger than it was before any of this and give the rail back most of its
   length. */
@media (min-width: 760px) { .est-wrap { grid-template-columns: 1fr 1fr; align-items: center; } }
/* The typed field sits under the line in the same column now. Capped so it
   reads as a field rather than as a second full-width control. */
.est-typed { max-width: 320px; margin-top: calc(var(--su) * 2.5); }

.est-lead { font-weight: 700; margin: 0 0 calc(var(--su) * 1.5); }

/* The rail is drawn, not boxed — a pencil line across the page. */
.est-track {
  position: relative; height: 54px; cursor: crosshair;
  /* Only the track: `touch-action: none` on anything larger stops the page
     scrolling on a phone. */
  touch-action: none;
  border-radius: var(--radius-sm);
}
.est-track:focus-visible { outline: 3px solid var(--orange-deep); outline-offset: 4px; }
.est-rail {
  position: absolute; left: 6%; right: 6%; top: 50%; height: 4px; transform: translateY(-50%);
  background: var(--ink); border-radius: 999px; opacity: .85;
}
.est-band {
  position: absolute; top: 50%; height: 26px; transform: translateY(-50%);
  background: var(--orange); opacity: .34; border-radius: 6px; pointer-events: none;
}
.est-thumb {
  position: absolute; top: 50%; width: 26px; height: 34px; margin-left: -13px;
  transform: translateY(-50%); padding: 0; cursor: grab;
  background: var(--cream-light); border: 2.5px solid var(--ink); border-radius: 5px;
  box-shadow: 0 2px 0 var(--shadow);
}
.est-thumb:focus-visible { outline: 3px solid var(--orange-deep); outline-offset: 3px; }
/* THE HIT AREA IS 44px, THE DRAWN THUMB IS NOT. Measured at 26x34 when first
   built, against the 44x44 floor this site claims in its own README — and a
   thumb is the worst place on the page to miss it, because dragging is the
   whole interaction. The drawn size stays small so the line still reads as a
   pencil mark rather than a widget; only the target grows. */
.est-thumb::before {
  content: ''; position: absolute; top: 50%; left: 50%;
  width: 44px; height: 44px; transform: translate(-50%, -50%);
}
.est-thumb::after {
  content: ''; position: absolute; left: 50%; top: 6px; bottom: 6px; width: 2px;
  margin-left: -1px; background: var(--ink); opacity: .55;
}

.est-ticks { position: relative; height: 20px; margin-top: -6px; }
.est-tick { position: absolute; transform: translateX(-50%); font-size: 12px; color: var(--ink-muted); }
.est-tick::before {
  content: ''; display: block; width: 2px; height: 6px; margin: 0 auto 2px;
  background: var(--border-strong);
}
/* The three between the ends carry no number — see the leak note in
   `estimate.js`. They are shorter so they read as minor marks rather than as
   labels whose text failed to render. */
.est-tick-minor::before { height: 4px; opacity: .6; }
.est-read { margin: calc(var(--su) * 1.5) 0 0; color: var(--ink-mid); min-height: 1.6em; }

/* THE SCRATCH PAD. Never parsed, never graded, never required — committing an
   estimate must not depend on a single mark. It is `aria-hidden` in the markup
   and out of the tab order, because it carries nothing anybody downstream
   reads and a keyboard equivalent would draw nothing. */
/* Tripled from `* 2` on the user's call, 2026-08-16 — the pad was crowding the
   typed field, and the two are different acts: one commits a number, the other
   is for working out what number to commit. Only needed where the pad sits
   BELOW something; in its own column it starts at the top. */
.est-pad-wrap { margin-top: calc(var(--su) * 6); }
.est-pad-col .est-pad-wrap,
.solve-wrap .est-pad-wrap { margin-top: 0; }

/* THE PAD MOVED INSTEAD OF SHRINKING — see the note in `estimate.js`.
   An earlier fix reserved up to 240px of right margin to keep the pad out from
   under Mr Fraction, which cost it a third of its width. Swapping the columns
   costs nothing instead: the pad is the tall element and now sits on the left
   where he never reaches.

   THE RESERVATION MOVES TO THE LINE, WHICH CAN AFFORD IT. Measured after the
   swap: the track still ran to 1178 with him starting at 993, so his bubble —
   which takes pointer events — covered the top 185px of the scale and a student
   could not sweep the high end of their own range. Giving that width up costs
   the line nothing that matters: the scale is drawn in percentages, so a
   narrower track is the same range at slightly coarser resolution, and the
   step already quantises what a sweep can land on. Giving it up on the PAD cost
   a third of the only surface whose value is its area. Same clamp as before,
   pointed at the element that can spare it. */
@media (min-width: 760px) {
  .est-line-col { margin-right: clamp(0px, calc(862px - 50vw), 200px); }
}
/* THE BUTTON IS PINNED TO THE TOP-RIGHT OUTER CORNER, not laid out beside the
   heading (user, 2026-08-16). As a flex item it dropped below the label the
   moment the column got narrow enough to wrap a 50-character heading — so the
   control moved depending on the width, which is the one thing a control
   should not do. Absolute against the wrapper, it is in the same place on
   every screen. The heading reserves room for it so the two never collide. */
.est-pad-wrap { position: relative; }
.est-pad-head {
  font-size: 12px; line-height: 1.45; color: var(--ink-muted);
  margin-bottom: 4px; padding-right: 62px;
}
.est-clear {
  position: absolute; top: 0; right: 0;
  font: inherit; font-size: 11px; cursor: pointer; padding: 2px 10px;
  background: var(--cream-light); border: 1.5px solid var(--border-strong); border-radius: 999px;
  color: var(--ink-mid);
}
/* The box exists so the hint has something to be positioned against — the
   canvas itself cannot hold a child. */
.est-pad-box { position: relative; }
/* Low and centred, inside the frame: a note on the paper rather than more
   title. `pointer-events: none` so a stroke that starts on top of it still
   draws, and it is not selectable so a drag across it does not highlight text
   instead of leaving ink. */
.est-pad-hint {
  position: absolute; left: 50%; bottom: 10px; transform: translateX(-50%);
  pointer-events: none; user-select: none; -webkit-user-select: none;
  font-size: 11px; letter-spacing: .08em; text-transform: uppercase;
  color: var(--ink-muted); opacity: .62; white-space: nowrap;
}

/* DOUBLED on the user's call 2026-08-16 — 130px was room for one line of
   working, which is not enough for the calculations and drawings the label now
   invites — then nudged again to 290 when the swap gave the pad the wider
   column. The width is the column's, so height is the only dial here. */
.est-pad {
  display: block; width: 100%; height: 290px; touch-action: none; cursor: crosshair;
  background: var(--cream-light);
  border: 2px dashed var(--border-strong); border-radius: var(--radius-sm);
}

/* The Engine Room: the field you type into, and somewhere to work it out. Same
   two-column shape as the estimate so the pad sits in the same place on both
   screens — but no number line here, because nothing is being swept. */
.solve-wrap {
  display: grid; gap: calc(var(--su) * 3); grid-template-columns: 1fr;
  align-items: start; margin-bottom: calc(var(--su) * 2);
}
/* Pad first, field second — see the note in `phSolve`. */
@media (min-width: 760px) { .solve-wrap { grid-template-columns: 1fr 320px; } }
.solve-wrap .field { max-width: 320px; margin: 0; }

/* THE SAME RESERVATION THE ESTIMATE LINE GOT, AND THE ENGINE ROOM NEVER DID.
   The swap above (pad left, field right) was copied from the estimate screen;
   the clamp that goes WITH it was not — see `.est-line-col` earlier in this
   file. So the field inherited the exact problem the swap was invented to
   solve, and Cycle 30 measured it by riding the site: at 1280 the input spans
   x858-1178 while `.mf-companion` spans x993-1245, and `elementFromPoint`
   across the field's vertical centre returns the bubble for 180 of 320px.
   56% OF THE ANSWER BOX WAS NOT CLICKABLE — and because the companion is
   `position: fixed`, the dead band MOVES WITH SCROLL: at some offsets it took
   only the right edge, at others the centre of the box.

   `.mf-companion` is `pointer-events: none` but `.mf-c-bubble` is
   `pointer-events: auto` (it must be — it scrolls), so the wrapper passes
   clicks through and the bubble catches them. The keyboard still reached the
   field, which is why every automated check passed it: this excluded mouse and
   touch only, which is almost every student.

   Reserved on the WRAP, not on the field, because unlike the estimate line a
   320px input cannot give up 200px and still be an input. The pad is the `1fr`
   column so it absorbs the reservation — roughly 740px to 540px at 1280, still
   far larger than the field, and the same cycle's art pass independently judged
   the pad over-weighted against the control beside it. Same clamp, same
   formula, pointed at the box that can spare it. */
@media (min-width: 760px) {
  .solve-wrap { margin-right: clamp(0px, calc(862px - 50vw), 200px); }
}

/* Section headings on the map */
.section-head { margin: calc(var(--su) * 5) 0 calc(var(--su) * 2); }
.section-head:first-of-type { margin-top: 0; }
.section-head h2 { margin: 0; font-size: 1.4rem; }
.section-head .rule { height: 4px; background: var(--orange); border-radius: 2px; width: 56px; margin-top: 10px; }

/* ---------- The Shunting Yard (factors / multiples tool) ---------- */
.fx-h { font-size: 13px; letter-spacing: .06em; text-transform: uppercase;
        color: var(--orange-deep); margin: calc(var(--su) * 2.5) 0 var(--su); }
.fx-row { display: flex; flex-wrap: wrap; align-items: center; gap: 5px; margin: 0 0 var(--su); max-width: none; }
.fx-lab { min-width: 44px; font-weight: 700; font-size: 13px; color: var(--ink-mid); }
.chip {
  display: inline-flex; align-items: center; justify-content: center;
  min-width: 30px; padding: 3px 7px; border-radius: var(--radius-xs);
  border: 2px solid var(--border-strong); background: var(--cream-light);
  font-size: 13px; font-weight: 700; font-variant-numeric: tabular-nums;
}
/* Shared values carry a border weight change as well as colour (1.4.1). */
.chip.is-shared { border-color: var(--info); background: #EDF2FA; color: var(--info); }
.chip.is-best {
  border-color: var(--go); background: var(--go); color: #fff; border-width: 3px;
}

/* ---------- Accessibility panel ---------- */
.a11y-panel {
  position: fixed; top: 0; right: 0; bottom: 0; width: min(370px, 94vw);
  background: var(--cream-light); border-left: 4px solid var(--orange-deep);
  padding: calc(var(--su) * 3); overflow-y: auto; z-index: 200;
  box-shadow: -10px 0 30px rgba(44,34,20,.22);
}
.a11y-panel[hidden] { display: none; }
.a11y-panel h2 { font-size: 1.15rem; }
.a11y-panel fieldset {
  border: 1.5px solid var(--border); border-radius: var(--radius-sm);
  margin: 0 0 calc(var(--su) * 2); padding: calc(var(--su) * 1.75);
}
.a11y-panel legend {
  font-size: 11px; font-weight: 700; letter-spacing: .12em;
  text-transform: uppercase; color: var(--orange-deep); padding: 0 6px;
}
.opt-row { display: flex; flex-wrap: wrap; gap: var(--su); }
.opt {
  font-family: var(--font-body); font-size: 13px; font-weight: 700; cursor: pointer;
  min-height: var(--target-min); padding: 8px 14px;
  border: 2px solid var(--border-strong); border-radius: var(--radius-sm);
  background: var(--cream-light); color: var(--ink); transition: background .15s, border-color .15s;
}
.opt:hover { background: var(--cream-mid); }
.opt[aria-pressed="true"] { border-color: var(--orange-deep); background: var(--orange-deep); color: #fff; }
input[type="range"] { accent-color: var(--orange-deep); }

/* Reading ruler */
.ruler-band { position: fixed; left: 0; right: 0; background: rgba(44,34,20,.52); pointer-events: none; z-index: 80; }
.ruler-band[hidden] { display: none; }

/* ---------- Trip report ---------- */
.report-line {
  display: flex; gap: calc(var(--su) * 2);
  padding: calc(var(--su) * 2) 0; border-bottom: 1px solid var(--border);
}
.report-line:last-child { border-bottom: 0; }
.report-line .rl-ico { flex-shrink: 0; font-size: 1.2em; color: var(--orange-deep); }
.report-line strong { display: block; }
.report-line small { color: var(--ink-muted); display: block; margin-top: 3px; }

/* ---------- Motion ---------- */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after {
    animation-duration: .001ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .001ms !important;
  }
  /* Ambient marks stay VISIBLE but frozen — the page must still look
     composed when nothing moves, not emptied out. */
  .ticker-track { animation: none; transform: none; }
  .btn:hover, .choice:hover, .line-card:hover, .hub-card:hover { transform: none; }
  /* Water stops moving but stays drawn — a frozen river must still look
     like a river, not vanish. */
  .map-figure .mf-flow, .map-figure .mf-wave { animation: none; }
  .map-figure .mf-wave { transform: none; opacity: .8; }
  /* Swap the animated plume for a drawn one, rather than freezing six puffs
     at opacity 0 and putting the fire out. */
  .map-figure .mf-smoke { display: none; }
  .map-figure .mf-smoke-still { display: block; }
}

/* ---------- Small screens ---------- */
@media (max-width: 640px) {
  main { padding: calc(var(--su) * 2.5) calc(var(--su) * 1.5) calc(var(--su) * 7); }
  .card { padding: calc(var(--su) * 2); }
  .problem-well { padding: calc(var(--su) * 2); }
  /* The desktop fix left the phone WORSE in proportion — 168px, 20.7% of a
     375x812 screen, because the nav dropped onto its own row and the header
     became three rows of furniture above the content.

     The sister site solves this by refusing to wrap: nav-bar is flex-wrap
     nowrap with overflow-x auto on the links, so the buttons scroll sideways
     instead of stacking. It holds 59px and six buttons at this width. Copied
     here, where there are two — three on the home map, where "For Teachers"
     joins them (see `.teach-for` / `.teach-word` near `.pill`).

     `min-width: 0` on the title block is what makes nowrap survivable — a flex
     item will not shrink below its content's intrinsic width without it, and
     "Mr Fraction's Word Problem Express" is long. It wraps to two lines rather
     than being truncated; the brand name is not something to clip. */
  .sign-inner {
    padding: calc(var(--su) * .75) calc(var(--su) * 1.5);
    gap: calc(var(--su) * 1.25);
  }
  .sign-mark { width: 34px; }
  /* The pills' side padding is what the title is short of, not the portrait.
     Measured at 375px: 16px padding left the title 95px and it wrapped to five
     lines, an 92px row. At 10px the title gets 119px, wraps to three, and the
     row is 57px — against 56px for hiding the brand mark altogether, which is
     not worth a pixel. Height is unchanged, so the touch target is too. */
  .sign-nav .pill { padding: 8px 10px; }
  .sign-nav { flex-wrap: nowrap; }
  /* The sister site lets its nav scroll sideways at this width, and with six
     page links that is fine — losing sight of one costs little. Ours are two,
     and the second is the READING AND ACCESS panel: the control a struggling
     reader needs most is the worst possible thing to park off-screen behind a
     scroll they have no reason to try.
     So the label sheds its second half instead. Visually-hidden, not
     display:none, so the button is still "Reading & access settings" to a
     screen reader — the same name at every width. Measured: both pills fit at
     375px with no horizontal scroll — and, since 2026-09-22, all three on the
     home map, from 320 to 1440px. */
  .pill-more {
    position: absolute !important; width: 1px; height: 1px;
    padding: 0; margin: -1px; overflow: hidden;
    clip: rect(0 0 0 0); white-space: nowrap; border: 0;
  }
  /* Repeats what the title already says, and the ticker says it again. */
  .sign-sub { display: none; }
}

/* ---------- Very narrow phones (up to 374px) ----------
   374 and not 365: at 365–374 the brand was back on and wrapping, a 94–108px
   band. No common handset sits in it, which is exactly why it would have
   shipped. 375 is the narrowest width that fits the wordmark at 83px, so the
   rule runs up to the pixel below it. */
@media (max-width: 374px) {
  /* There is genuinely not room here for a 34-character brand name beside two
     labelled buttons: the title block gets 48px and wraps to five lines, an
     108px header on the smallest screen of the lot. The wordmark stands down
     and the portrait carries the brand, which is what it is for.

     Visually-hidden, NOT display:none — the <h1> has to stay in the
     accessibility tree or the page loses its heading. Screen readers and the
     document outline are unchanged at every width. */
  .sign-text {
    position: absolute !important; width: 1px; height: 1px;
    padding: 0; margin: -1px; overflow: hidden;
    clip: rect(0 0 0 0); white-space: nowrap; border: 0;
  }
  .station-head { flex-direction: column; gap: var(--su); }
  .bar-label { min-width: 100%; }
  h1 { font-size: 1.25rem; }
  .station-meta h2 { font-size: 1.3rem; }
}

/* ---------- The Cockpit: one screen, panels that never move ----------
   docs/COCKPIT.md. A station used to be a header plus a card that every phase
   rebuilt top to bottom, story included, so a student scrolled between the
   problem and the question on every screen — the first thing three teachers
   said. Now the station fills the window and each panel keeps its place; only
   what is inside changes.

   Laptop/Chromebook landscape is the target (the user's call, 2026-10-18). At
   1024px and up the page itself does not scroll; a panel scrolls inside itself
   only as a last resort. Below 1024 the panels stack in reading order and the
   page scrolls as it always has. */
body:has(.cockpit) .rail-foot,
body:has(.cockpit) #ticker-host { display: none; }
/* No width cap: the cockpit uses the whole window (user, 2026-10-18). */
body:has(.cockpit) main { max-width: none; padding: calc(var(--su) * 1.5) calc(var(--su) * 2); }

.cockpit { display: grid; gap: calc(var(--su) * 1.25); }
/* The top strip spans the window: the map as a full-width band, and under it
   one bar holding the stop, its dots and the problem's name (user, 2026-10-18:
   "Title and Map needs to be larger and extend the whole window"). */
.ck-top { min-width: 0; }
.cockpit .journey { display: block; margin: 0; width: 100%; }
.cockpit .journey-map { width: 100%; }
/* Taller, so more of the map shows; stations.js frames the leg to this
   band's shape, using the same numbers (16vh, 88–184px). Every vertical size
   in the cockpit was pulled back 20% after "too vertical" (user, 2026-10-18). */
.cockpit .leg-svg { width: 100%; height: clamp(88px, 16vh, 184px); }
/* (The site header was compressed here while the cockpit had to fit one
   window height. It does not any more — see "think vertically" below — so
   the header is its ordinary self on station screens too.) */
/* The picture must not make the story's row taller than the story. */
/* The picture fills its 30% of the top row at the story's height, rather than
   sitting small in the middle of it ("better align and scale", user,
   2026-10-18). The row is exactly as tall as the story (the panel is
   size-contained), and the drawing grows to fill what is left above its
   caption, keeping its own proportions. */
.ck-scene .scene { height: 100%; display: flex; flex-direction: column; justify-content: center;
  padding: calc(var(--su) * .75); gap: 4px; }
.ck-scene .scene > svg, .ck-scene .scene > img, .ck-scene .scene > div:not(figcaption) {
  flex: 1 1 auto; min-height: 0; width: 100%; height: 100%; max-height: none; object-fit: contain; }
.ck-scene .scene figcaption { flex: 0 0 auto; margin: 0; }
/* A framed scene keeps the size `fitScene` gives it — the whole drawing, at
   its own proportions — and sits centred in the panel instead of stretching
   to fill it (2026-10-21; it overflowed and was cropped). */
@media (min-width: 1024px), (min-width: 900px) and (orientation: landscape) {
  .ck-scene .scene > .rsc-frame { flex: 0 0 auto; align-self: center; max-width: 100%; }
}
/* A station's art band (the Arrivals Board's caboose) sitting in the picture
   panel: it fits the panel instead of keeping its 240px band height. */
.ck-scene .art-band { margin: 0; height: 100%; width: 100%; align-items: center; }
.ck-scene .art-band img { max-height: 100%; max-width: 100%; width: auto; height: auto; object-fit: contain; }
/* Unit pictures (the quilt) are a grid of SQUARE cells sized from the width,
   so in a 30%-wide panel they grew to 348px and spilled out of a 236px row.
   Cells are capped instead, and the frame sits centred at its own size. */
.ck-scene .scene > .unit-frame { flex: 0 0 auto; width: auto; height: auto; margin: auto; }
.ck-scene .unit-grid { grid-template-columns: repeat(var(--u-cols), minmax(0, 30px)); }
.cockpit .journey-bar { flex-wrap: wrap; gap: calc(var(--su) * 1) calc(var(--su) * 2.5);
  padding: calc(var(--su) * .75) calc(var(--su) * 2); }
.ck-title { min-width: 0; display: flex; align-items: baseline; flex-wrap: wrap; gap: 2px calc(var(--su) * 2);
  padding-left: calc(var(--su) * 2.5); border-left: 2px solid var(--border-strong); }
.ck-title .eyebrow { margin: 0; }
.ck-title h2 { margin: 0; font-size: clamp(1.35rem, 1.05rem + .8vw, 1.85rem); line-height: 1.15; }
/* No map (a trip-less station): the title stands on its own, full width. */
.ck-top > .ck-title { border-left: 0; padding-left: 0; }

/* THE TOUCH PAD, BIGGER (user, 2026-10-18). Its label was the estimate's long
   one, "Sketch Pad: For Notes, Calculations, and/or Drawings", which wrapped
   to three lines in this column and left the drawing surface a dashed strip.
   One line of label and the rest is surface. */
.ck-pad .est-pad-head { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; padding-right: 72px; }
.ck-pad .est-pad-box { min-height: 120px; }

.ck-body { display: grid; gap: calc(var(--su) * 1.25); }
/* Stacked (under 1024px), in the order a student works down a phone: the
   story and its picture, the question, the thing they answer with (picture
   cards, the model), the lever, then what was said about it. The picture
   cards used to come after the feedback and the lever, ~700px below their
   own question. The grid areas at 1024px+ place everything explicitly, so
   this order is ignored there. */
.ck-text { order: 1; } .ck-scene { order: 2; } .ck-ask { order: 3; }
.ck-work { order: 4; } .ck-lever { order: 5; } .ck-bottom { order: 6; } .ck-pad { order: 7; }
.ck-pad-toggle { display: none; }

/* ---------- The cockpit on a phone or tablet (under 1024px) ----------
   ONE SCROLL, THE PAGE'S (user, 2026-10-20: "the scroll barely works; the
   dialogue and Feedback are oddly shaped and hard to read"). Measured at
   375px: the feedback was a 174px column scrolling inside itself (603px of
   text in a 113px box), Mr Fraction's speech a 78px-wide column 802px tall,
   and the sketch pad a 308x268 surface that took every touch for drawing.
   Any swipe that started on one of those scrolled it, or drew, instead of
   moving the page. Here nothing scrolls inside itself, the feedback and Mr
   Fraction run the full width, one above the other, and the pad starts
   closed behind a button. Desktop is untouched. */
/* `.cockpit` on every selector: the base panel rules sit later in this file,
   and at equal specificity they won — the feedback kept scrolling inside
   itself (128px showing of 329). */
/* WHO GETS THIS LAYOUT (2026-10-20): narrow windows, and portrait ones under
   1024. It was every window under 1024 — which caught Chromebooks. A school
   Chromebook with its display scaled reports ~911px across, landscape, and
   got this phone layout, so the feedback and Mr Fraction scrolled away with
   the page (user, from school: "not static as designed"). Landscape windows
   from 900px now get the cockpit; the two queries here and on the cockpit
   blocks below are exact complements, so every width gets exactly one. */
@media (max-width: 899px), (max-width: 1023px) and (orientation: portrait) {
  .cockpit .ck-panel { overflow: visible; overscroll-behavior: auto; }
  .cockpit .ck-bottom { flex-direction: column; gap: calc(var(--su)); }
  .cockpit .ck-feedback { order: 1; max-height: none; min-height: 72px; overflow: visible; }
  .cockpit .ck-say { order: 2; flex-basis: auto; width: 100%; }
  .mf-companion[data-docked] { flex-direction: row; align-items: flex-start; gap: 10px; }
  .mf-companion[data-docked] .mf-c-figure { flex: 0 0 auto; order: -1; }
  .mf-companion[data-docked] .mf-c-figure img { width: 44px; }
  .mf-companion[data-docked] .mf-c-bubble { flex: 1 1 auto; min-width: 0; max-height: none; overflow: visible;
    font-size: 1rem; line-height: 1.5; }
  .ck-lever-btn { min-height: 52px; }
  /* The pad, on request. Open, it is a fixed, comfortable size. */
  /* The stop's eyebrow wraps on a phone instead of running 17px off it. */
  .cockpit .journey-bar .eyebrow { white-space: normal; }
  .cockpit .ck-pad-toggle { display: inline-flex; width: 100%; justify-content: center; }
  .cockpit .ck-pad:not([data-open]) .est-pad-wrap { display: none; }
  .cockpit .ck-pad[data-open] .est-pad-box { height: 260px; flex: 0 0 auto; }
  .cockpit .ck-pad { flex-direction: column; gap: 8px; }
}
.ck-panel {
  background: var(--cream-light);
  border: 2px solid var(--border-strong); border-radius: var(--radius);
  padding: calc(var(--su) * 1.5); min-width: 0; min-height: 0;
  overflow: auto; overscroll-behavior: contain;
  /* Positioned, so an absolutely placed child (the ratio table has some) is
     clipped by its panel instead of escaping it and lengthening the page. */
  position: relative;
}
.ck-panel[hidden] { display: none; }
/* The well IS the panel here: its own box inside the panel's box was paying
   for padding and a border twice. */
.ck-text { padding: 0; border: 0; background: none; }
.ck-text .problem-well { margin: 0; height: 100%; }
.ck-scene { padding: 0; display: flex; align-items: center; justify-content: center; }
.ck-scene .scene { margin: 0; border: 0; box-shadow: none; width: 100%; }
.ck-ask #phase-host > :first-child { margin-top: 0; }
/* The question column holds a question and its answer controls; the site's
   1.3em paragraph rhythm is for reading, and here it only made the column
   (and so the whole working row) taller. */
/* THE QUESTION PANEL'S TYPE FITS THE PANEL (user, 2026-10-18: "these
   questions need to be resized for the viewport"). Section heads and card
   questions kept their full-page display sizes — a 1.4rem heading and a
   1.22em question in a ~300px column, four words a line. They scale with the
   window now, and the checklist's rail (a line of stops down the left, one per
   card) goes: one card at a time has nothing to string together. */
.ck-ask .section-head { margin: 0 0 calc(var(--su)); }
.ck-ask .section-head h2 { font-size: clamp(1.05rem, .85rem + .45vw, 1.3rem); }
.ck-ask .section-head .rule { display: none; }
.ck-ask h3 { font-size: clamp(1rem, .9rem + .3vw, 1.15rem); margin-top: 0; }
.ck-ask .eyebrow { font-size: .72rem; }
.ck-ask p { margin-bottom: .55em; }
.ck-ask h3 { margin-bottom: .5em; text-align: center; }
/* A step that is only a question and a lever (the first read) sits in the
   middle of its panel rather than at the top-left of an empty box. */
#phase-host:has(> .ck-center) { min-height: 100%; display: flex; flex-direction: column; justify-content: center; }
.ck-center { text-align: center; }
.ck-prompt { color: var(--ink-mid); max-width: 32ch; margin: 0 auto; }
.ck-ask .est-wrap { margin: .5em 0; gap: calc(var(--su)); grid-template-columns: 1fr; }
/* THE COMPANION RESERVATIONS COME OFF IN THE COCKPIT. The estimate line and
   the Engine Room's answer box each kept up to 200px clear on their right for
   Mr Fraction floating at the window's corner — and in a 430px question column
   that left the line ~110px wide, one word per line ("oddly aligned", user,
   2026-10-18). He is docked in the feedback bar here, so there is nothing to
   keep clear of. */
.ck-ask .est-line-col, .ck-ask .solve-wrap { margin-right: 0; }
/* The Engine Room's wrap was two columns, pad and field; the pad lives in the
   cockpit now, so the field takes the width (it measured 65px). */
.ck-ask .solve-wrap { display: block; }
/* The estimate line spans its panel and is centred in it ("should be centre
   and scaled to the viewport", user, 2026-10-18). */
.ck-ask .est-line-col { width: 100%; text-align: center; }
.ck-ask .est-track, .ck-ask .est-ticks { width: 100%; max-width: none; margin-left: auto; margin-right: auto; }
.ck-ask .est-typed { text-align: left; }
.ck-ask .solve-wrap .field input { width: 100%; max-width: none; }
/* EMPTY, THE WORK PANEL SHOWS MR FRACTION'S SILHOUETTE, as the feedback
   viewport does (user, 2026-10-18) — one empty state for the cockpit, not a
   sentence in one panel and a figure in another. Same art, same filter. */
.ck-work:empty::before {
  content: ""; position: absolute; inset: 16px;
  background: url("../art/Conductor_Mr_Fraction_(Front).png") center / contain no-repeat;
  filter: brightness(0); opacity: .1; pointer-events: none;
}
/* The bottom row: Mr Fraction docked on the left, the feedback bar beside him.
   Only the slot written most recently shows (see wireCockpit). */
.ck-bottom { display: flex; gap: calc(var(--su) * 1.25); min-width: 0; align-items: stretch; }
.ck-say { flex: 0 1 44%; min-width: 0; display: flex; }
.ck-say:empty { display: none; }
.ck-feedback { flex: 1; padding: calc(var(--su) * 1) calc(var(--su) * 1.5); max-height: 132px; }
.ck-feedback .feedback { margin: 0; }
.ck-feedback .feedback:not([data-latest]) { display: none; }
/* A FEEDBACK VIEWPORT, ALWAYS THERE (user, 2026-10-18). It used to vanish
   when there was nothing to say, so the place where the teaching happens kept
   appearing and disappearing under the work. It is a fixed panel now, named,
   and says what it is for until the first thing arrives. */
.ck-feedback { min-height: 102px; display: flex; flex-direction: column; gap: 6px; }
.ck-feedback::before {
  content: "Feedback"; font-family: var(--font-display); font-size: .78rem;
  letter-spacing: .12em; text-transform: uppercase; color: var(--ink-muted);
}
/* "I'd like a hint" lives in the viewport, under whatever is being said. */
.ck-fb-tools { display: flex; justify-content: flex-start; gap: calc(var(--su)); margin-top: auto; order: 99; }
.ck-feedback .hints { margin: 0; }
/* EMPTY, THE VIEWPORT SHOWS MR FRACTION'S SILHOUETTE (user, 2026-10-18) —
   the conductor's shape, waiting, rather than a blank box or a line of
   placeholder copy. The existing art, flattened to a soft shadow by a filter,
   so there is no second image to keep in step. Decorative: a pseudo-element
   carries no text, and the panel's own label still says what it is. */
.ck-feedback { position: relative; }
.ck-feedback:not(:has(.feedback[data-latest]))::after {
  content: ""; position: absolute; inset: 10px 0 10px 0;
  background: url("../art/Conductor_Mr_Fraction_(Front).png") center / contain no-repeat;
  filter: brightness(0); opacity: .1; pointer-events: none;
}
.mf-companion[data-docked] {
  position: static; z-index: auto; pointer-events: auto;
  flex-direction: row-reverse; justify-content: flex-end; align-items: center;
  max-width: none; width: 100%;
}
.mf-companion[data-docked] .mf-c-figure img { width: 58px; }
.mf-companion[data-docked][data-open="no"] .mf-c-figure img { width: 48px; }
.mf-companion[data-docked] .mf-c-bubble {
  /* 1rem, not the floating bubble's .86rem: docked, Mr Fraction has room, and
     the viewport is now where the teaching happens — 13.76px was under the
     site's 16px floor. */
  font-size: 1rem;
  max-width: none; flex: 1; max-height: none; border-radius: 14px 14px 14px 4px;
  box-shadow: none; animation: none;
}
.mf-companion[data-docked] .mf-c-bubble::before, .mf-companion[data-docked] .mf-c-bubble::after { display: none; }

/* THE LEVER. One control for "go on", wherever the step's own button was.
   The source button is hidden while the lever stands in for it; buttons on a
   carried-over (stale) picture are hidden outright. */
.ck-body [data-ck-src], .ck-body [data-ck-stale] .btn { display: none !important; }
/* A spent "go on" button in the question panel is hidden, not left faded:
   the lever already carries whatever comes next, and a disabled main button
   reads as broken (user, 2026-10-18, on "Show me why"). Quiet buttons keep
   their disabled look — they are options, not the way on. */
.ck-ask .btn:disabled:not(.btn-quiet):not(.btn-secondary) { display: none; }
.ck-lever { display: flex; }
.ck-lever-btn {
  flex: 1; display: flex; align-items: center; justify-content: center; gap: calc(var(--su) * 1.5);
  min-height: 52px; padding: calc(var(--su) * .75) calc(var(--su) * 1.5);
  font: inherit; font-weight: 700; font-size: 1rem; color: #fff; cursor: pointer;
  background: var(--orange-deep); border: 3px solid var(--ink); border-radius: var(--radius);
  box-shadow: 0 4px 0 var(--ink);
}
.ck-lever-btn:hover { filter: brightness(1.07); }
.ck-lever-btn:active, .ck-lever-btn[data-pulled] { transform: translateY(3px); box-shadow: 0 1px 0 var(--ink); }
.ck-lever-btn:disabled { opacity: .55; cursor: not-allowed; }
.ck-lever-btn:focus-visible { outline: 3px solid var(--ink); outline-offset: 3px; }
.ck-lever-btn[hidden] { display: none; }
/* A signal-box lever: a slot, and an arm that throws forward when pulled. */
.ck-lever-art { position: relative; width: 28px; height: 42px; flex: 0 0 auto;
  border-radius: 6px; background: rgba(0,0,0,.28); }
.ck-lever-arm { position: absolute; left: 50%; bottom: 6px; width: 6px; height: 34px; margin-left: -3px;
  background: var(--cream-light); border-radius: 3px; transform-origin: 50% 100%;
  transform: rotate(-22deg); transition: transform .25s ease; }
.ck-lever-arm::before { content: ""; position: absolute; top: -7px; left: -5px; width: 16px; height: 16px;
  border-radius: 50%; background: var(--cream-light); border: 2px solid var(--ink); }
.ck-lever-btn[data-pulled] .ck-lever-arm, .ck-lever-btn:active .ck-lever-arm { transform: rotate(22deg); }
@media (prefers-reduced-motion: reduce) { .ck-lever-arm { transition: none; } }

.est-wrap.no-pad { grid-template-columns: 1fr; }

/* The picture panel — the work area is the cockpit's viewport for seeing the
   problem: the five shapes to choose from, the Platform Check's map, then the
   bar model, which stays through the Engine Room. */
/* THREE BY TWO (user, 2026-10-18). Six half-columns, each card spanning two,
   so the bottom pair can sit centred under the top three instead of leaving a
   hole at the end of the row. Pictures are capped in height so both rows fit
   the picture panel without it scrolling. */
.mp-grid { list-style: none; margin: 0; padding: 0; max-width: none;
  display: grid; gap: calc(var(--su) * 1); grid-template-columns: repeat(6, minmax(0, 1fr)); }
.mp-grid > li { grid-column: span 2; }
.mp-grid > li:nth-child(4) { grid-column: 2 / span 2; }
.mp-grid .mp-svg { max-height: 64px; }
.mp-grid .mp-card { justify-content: center; padding: calc(var(--su) * .75); }
.mp-card {
  width: 100%; height: 100%; display: flex; flex-direction: column; align-items: center; gap: 6px;
  padding: calc(var(--su) * 1); font: inherit; font-size: .8em; line-height: 1.3; color: var(--ink);
  background: var(--cream); border: 2px solid var(--border-strong); border-radius: var(--radius-sm);
  cursor: pointer; text-align: center; min-height: var(--target-min);
}
.mp-card:hover:not(:disabled) { border-color: var(--ink); background: var(--cream-light); }
.mp-card:focus-visible { outline: 3px solid var(--orange-deep); outline-offset: 2px; }
.mp-svg { width: 100%; max-width: 160px; height: auto; color: var(--ink); }
.mp-card[data-result="right"] { border-color: var(--go); border-width: 3px; background: var(--cream-light); }
.mp-card[data-result="right"] .mp-cap::before { content: "\2713  "; font-weight: 700; }
.mp-card[data-result="wrong"] { opacity: .55; border-style: dashed; }
.mp-card[data-result="wrong"] .mp-cap::before { content: "\2715  "; font-weight: 700; }
.mp-card:disabled { cursor: default; }

/* LOAD THE PICTURE: the story's numbers, tappable at the start of the Plan. */
.ck-text .num-pick {
  cursor: pointer; border-radius: 6px; padding: 0 .2em;
  outline: 2px dashed var(--orange-deep); outline-offset: 1px;
}
.ck-text .num-pick:hover, .ck-text .num-pick:focus-visible { background: rgba(168,84,19,.14); }
.ck-text .num-pick[data-picked] { outline-style: solid; background: rgba(46,125,50,.16); }
.ck-text .num-pick[data-result="wrong"] { outline-color: var(--ink-muted); text-decoration: line-through; opacity: .7; }
.ck-chips { list-style: none; margin: calc(var(--su)) 0 0; padding: 0; display: flex; flex-direction: column; gap: 6px; max-width: none; }
.ck-chip { background: var(--cream); border: 1.5px solid var(--border-strong); border-radius: var(--radius-xs);
  padding: 6px 10px; font-size: .92em; }
.plan-shape { height: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 8px; }
.plan-shape .mp-svg { width: min(100%, 420px); height: auto; max-height: 75%; }
.plan-shape-cap { color: var(--ink-muted); margin: 0; }
/* A tool being suggested: highlighted, never opened for the student. */
.ck-tools .ck-offer { border-color: var(--orange-deep); border-width: 2px; background: rgba(168,84,19,.08); }
.ck-offer-tag { font-size: .78em; font-weight: 700; color: var(--orange-deep); text-transform: uppercase; letter-spacing: .06em; }
.mp-slots { grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)) !important; }
.mp-slots > li, .mp-slots > li:nth-child(4) { grid-column: auto !important; }

/* "Help me see it" — the optional steps, folded away until asked for. */
.ck-tools { margin-top: calc(var(--su) * 1.5); border-top: 1px dashed var(--border); padding-top: calc(var(--su)); }
.ck-tools summary { cursor: pointer; font-weight: 700; color: var(--ink-mid); min-height: var(--target-min);
  display: flex; align-items: center; }
.ck-tools-row { display: flex; flex-direction: column; gap: 6px; align-items: stretch; }
.ck-tools-row .btn { justify-content: flex-start; }
.ck-back-row { margin: 0 0 calc(var(--su)); }

/* "Ask yourself" — the checklist, short, beside the pictures it points to.
   One compact row that wraps, so it fits the slim feedback bar on a laptop. */
.ck-askset { list-style: none; margin: 0; padding: 0; max-width: none;
  display: flex; flex-wrap: wrap; align-items: center; gap: 6px 14px; }
.ck-askset li { display: flex; align-items: center; gap: 6px; font-size: .92em; line-height: 1.25; }
.ck-ask-thumb { flex: 0 0 auto; width: 40px; }
.ck-ask-thumb .mp-svg { width: 40px; height: auto; display: block; color: var(--ink); }
.ck-askset-slot li:not(.ck-askset-head)::before { content: "?"; display: inline-grid; place-items: center;
  width: 20px; height: 20px; border-radius: 50%; background: var(--orange-deep); color: #fff;
  font-weight: 700; font-size: .8em; flex: 0 0 auto; }
/* The pinned bar is ~84px (on every cockpit screen since 2026-10-20), so the
   set tightens to fit. */
@media (min-width: 1024px), (min-width: 900px) and (orientation: landscape) {
  .ck-askset { gap: 2px 12px; }
  .ck-askset li { font-size: .84em; }
  .ck-ask-thumb, .ck-ask-thumb .mp-svg { width: 30px; }
  /* The slim bar makes room for the set while it is the thing showing —
     measured 116px of content in 84 — and returns to slim once a pick is
     answered and the set gives way to the verdict. */
  .cockpit .ck-feedback:has(.ck-coach[data-latest] .ck-askset) { min-height: 143px; max-height: 143px; }  /* 124 x 1.15 */
}
/* Phones: a question a line, with a small picture. Two to a row squeezed
   each question into a 127px cell, 66–87px tall. */
@media (max-width: 600px) {
  .ck-askset { flex-direction: column; align-items: flex-start; gap: 4px; }
  .ck-ask-thumb, .ck-ask-thumb .mp-svg { width: 34px; }
}

/* The coaching note: a step's instructions, moved out of the question panel. */
.ck-coach .ck-coach-body > * { margin: 0 0 4px; font-size: inherit; color: inherit; }
.ck-coach .ck-coach-body > :last-child { margin-bottom: 0; }
.ck-pad { padding: calc(var(--su) * 1); display: flex; }
/* `min-width: 0`: on a Chromebook-width cockpit (900px+ landscape, since
   2026-10-20) the pad's column is ~225px, and a flex child that could not
   shrink below its one-line head pushed the whole pad, canvas and all,
   48–64px past its panel on every step (measured). The head already
   ellipsises; it only needed to be allowed to. */
.ck-pad .est-pad-wrap { flex: 1; display: flex; flex-direction: column; margin: 0; min-height: 0; min-width: 0; }
.ck-pad .est-pad-box { flex: 1; min-height: 0; }
.ck-pad .est-pad { width: 100%; height: 100%; }

@media (min-width: 1024px), (min-width: 900px) and (orientation: landscape) {
  /* THINK VERTICALLY (user, 2026-10-18: "there needs to be more space for
     every element but don't give on position"). The first cockpit squeezed
     every panel into one window height, and on a laptop that left the picture
     picker, the sketch pad and the question each a strip ~130px tall with a
     scrollbar inside. The POSITIONS stay exactly as they were; what changed is
     that rows now grow downward to fit what they hold, with generous minimums,
     and the page scrolls when it has to. No panel scrolls inside itself. */
  .ck-body {
    /* THE TOP ROW IS THE PROBLEM AND NOTHING ELSE (user, 2026-10-18: "only
       the Word Problem and Illustration should be at the top, everything else
       below it"). Everything the student works with sits underneath, as in the
       sketch: work area, touch pad, then the question and its answer, with the
       feedback bar and the lever along the bottom.

       The story's row sizes to the story, so it never scrolls; the row below
       takes what is left and is the one allowed to scroll inside a panel. The
       story spans two columns because wider prose is shorter prose — beside
       the picture in one column it wrapped to 267px on a 657px window.

       The question column was 1fr and read as the biggest thing on screen
       ("the right box is way too big"), so it gets a reading column and no
       more. */
    /* FOUR TRACKS SO TWO ROWS CAN SPLIT DIFFERENTLY AND STILL LINE UP.
       The top row is 70% story, 30% picture (user, 2026-10-18: "the Word
       Problem should take up 70% of the line and the Illustration… 30%").
       The row below is work 48%, pad 28% — the user found the pad too small
       to draw in — and the question column 24%, which keeps it from becoming
       "way too big" again. 48+22 = 70 and 6+24 = 30, so the top row's split
       falls on a real column edge and nothing is out of line. */
    grid-template-columns: minmax(0, 48fr) minmax(0, 22fr) minmax(0, 6fr) minmax(250px, 24fr);
    /* Minimums, not limits: the top row is at least tall enough for the
       picture to read, the working row at least tall enough to draw in. */
    /* THE FEEDBACK VIEWPORT RUNS ALONG THE BOTTOM, FULL WIDTH (user,
       2026-10-18: "there should be a feedback viewport… at the bottom"), with
       Mr Fraction docked at its left — the two places the teaching happens,
       together. The lever moves up to sit directly under the question and its
       answer, which is where the sketch puts it. */
    /* THE WORKING ROW HAS A SET HEIGHT, tied to the window. Sized to its
       content it swung both ways in one afternoon: "too vertical" when a long
       question column stretched it, "too much space" when that stretch left
       the work area and pad as tall empty boxes beside it. Now the row is the
       same comfortable height on every step, and only a panel whose content is
       genuinely longer scrolls inside itself. */
    grid-template-rows: minmax(192px, auto) clamp(300px, 44vh, 500px) auto auto;
    grid-template-areas:
      "text text scene scene"
      "work pad  pad   ask"
      "work pad  pad   lever"
      "fb   fb   fb    fb";
  }
  .ck-text { grid-area: text; }
  /* Contained so their content cannot set the row's height (above). */
  /* `both-edges` so a scrollbar, when there is one, does not push the
     content 15px off centre. */
  .ck-ask { grid-area: ask; contain: size; overflow: auto; scrollbar-gutter: stable both-edges; }
  .ck-work { grid-area: work; contain: size; overflow: auto; }
  .ck-pad { grid-area: pad; contain: size; }
  /* The picture alone stays size-contained: it should match the story's
     height, never set it. */
  .ck-scene { grid-area: scene; contain: size; }
  .ck-pad .est-pad-box { min-height: 0; }
  .ck-feedback { max-height: none; }
  .ck-bottom { grid-area: fb; }
  .ck-lever { grid-area: lever; }
  /* With no picture the story takes the whole top row. */
  .ck-body:has(.ck-scene[hidden]) { grid-template-areas:
      "text text text  text"
      "work pad  pad   ask"
      "work pad  pad   lever"
      "fb   fb   fb    fb"; }
  .ck-say { flex-basis: 34%; }
}

/* THE BOTTOM ROW STAYS IN VIEW, ON EVERY COCKPIT SCREEN. At 1366x768 the
   header and map took 281px before the story began, so the lever and every
   verdict started below the window and the page jumped when one arrived
   (Cycle 31, student-tester and theme-reviewer). So the lever joins the
   feedback row, and that row is pinned to the bottom of the window while the
   rest scrolls past — the verdict is always on screen, beside the control that
   moves on.
   This applied only under 860px tall until 2026-10-20; taller windows kept the
   lever under the question on the assumption the whole cockpit fit them. It
   did not — 1,165px of page in a 1920x960 window — so on a classroom monitor
   feedback and Mr Fraction started below the window and scrolled with the
   page (user, from school: "not static as designed"). The user chose this
   laptop layout for every screen over pinning a taller bar that covered the
   work. */
@media (min-width: 1024px), (min-width: 900px) and (orientation: landscape) {
  .ck-body {
    grid-template-areas:
      "text text scene scene"
      "work pad  pad   ask"
      "fb   fb   fb    lever";
    /* The working row gets the height the slimmer bar gives back ("more of
       the page should go to the other viewports", user, 2026-10-18). */
    grid-template-rows: minmax(160px, auto) clamp(300px, 46vh, 520px) auto;
  }
  .ck-body:has(.ck-scene[hidden]) { grid-template-areas:
      "text text text  text"
      "work pad  pad   ask"
      "fb   fb   fb    lever"; }
  .ck-bottom, .ck-lever {
    position: sticky; bottom: 0; z-index: 5;
    background: var(--cream); padding: 6px 0;
    box-shadow: 0 -10px 12px -10px rgba(44,34,20,.25);
  }
  /* A SLIM BAR (user, 2026-10-18: "the bottom static line seems too thick").
     It measured 97–167px, mostly Mr Fraction's docked speech wrapping and the
     feedback panel's minimum height. Each part is capped at a couple of lines
     and scrolls inside itself if a message is longer; the label sits inline. */
  /* 15% TALLER, AND ONE HEIGHT (user, 2026-10-21: "increase the height of the
     Feedback row… by 15% — a little scrolling is ok"). Was capped at 84px and
     sized to its message, so the row measured 72–136px from step to step;
     now 97px (84 x 1.15) on every step, so the bar itself never changes size,
     and a longer message scrolls inside it. */
  .ck-feedback { min-height: 97px; max-height: 97px; overflow: auto; padding: 6px 12px; gap: 2px; }
  .ck-feedback::before { font-size: .7rem; }
  .ck-feedback .msg { padding: 4px 8px; }
  /* MR FRACTION GETS MORE OF THE BAR (user, 2026-10-21: "more width in his
     dialogue boxes"). At 30% his speech was a narrow column that hid most of
     what he said. Measured over 34 screens, share of his words hidden /
     share of feedback hidden:
       1366x657   30% 23% / 15%    40% 6% / 18%
       1920x960   30%  3% / 11%    40% 1% / 11%
       911x450    30% 64% / 35%    45% 34% / 43%   (50%: 22% / 48%)
     40% on laptops and monitors; 45% on Chromebook-width windows, where the
     bar is small enough that neither side can show everything. */
  .ck-say { flex-basis: 40%; }
  .mf-companion[data-docked] .mf-c-figure img { width: 40px; }
  .mf-companion[data-docked] .mf-c-bubble { max-height: 97px; padding: 6px 10px; font-size: .95rem; line-height: 1.35; }
  .ck-lever { align-items: center; }
  .ck-lever-btn { min-height: 48px; }
  /* The empty-state silhouette fits the slimmer box. */
  .ck-feedback:not(:has(.feedback[data-latest]))::after { inset: 4px 0; }
  /* THE STORY ROW LOCKS UNDER THE HEADER when `lockTop` (stations.js) finds
     room for it. The spread shadow, in the page's own colour, fills the gaps
     between and above the two panels, so the work scrolling underneath does
     not show through them. */
  .cockpit[data-lock-top] .ck-text, .cockpit[data-lock-top] .ck-scene {
    position: sticky; top: var(--ck-lock-top, 0px); z-index: 4;
    box-shadow: 0 0 0 calc(var(--su) * 1.25) var(--cream);
  }
}

/* Chromebook-width cockpits (landscape 900–1199px): Mr Fraction's share of
   the bar is 45%, not 40% — see the measurements above `.ck-say` there. */
@media (min-width: 900px) and (max-width: 1199px) and (orientation: landscape) {
  .ck-say { flex-basis: 45%; }
}

/* ---------- Print ---------- */
@media print {
  .station-sign, footer.rail-foot, .a11y-panel, .btn-row, .route-strip, .ambient, .ticker { display: none; }
  body { background: #fff; }
}
