/* ==========================================================================
   test.css — the test stage and every module's stimulus layout
   --------------------------------------------------------------------------
   The stage splits into dark chrome (instructions, progress, controls) and a
   neutral L*≈50 field that holds nothing but the stimuli. Keeping all text and
   UI out of the field is deliberate: an adjacent bright or coloured element
   shifts the adaptation state and biases a colour judgement.
   ========================================================================== */

.stage { display: flex; flex-direction: column; height: 100%; min-height: 0; }

/* The instruction and the field, centred together between the app bar and the
   footer. See the note in main.js: this is what lets the stimulus sit in the
   middle of the window AND directly under the instruction, which anchoring it
   to the top of a full-bleed field could not do. */
/* Three rows, with the field in the middle one.
   Centring head and field together as a block leaves the field itself sitting
   half a heading below the centre line — arithmetically centred, visibly not.
   Putting the field in an `auto` row between two equal `1fr` rows centres the
   FIELD, which is the thing the eye reads as the subject, and lets the
   instruction hang in the space above it. Row one keeps a `min-content` floor
   so a short window shrinks the empty row rather than clipping the heading. */
.stage-body {
  flex: 1 1 auto; min-height: 0;
  display: grid;
  /* Four rows: empty, panel, read-out, empty. The read-out gets a row of its
     own at `min-content` rather than hanging off the panel, because the row
     it used to hang into is a `1fr` that collapses the moment a module's
     content fills the body — it looked right at 1440x900 and was clipped on
     five of nine modules at 1024x640. A row the grid has to allocate cannot
     be squeezed out; the panel gives up the twenty pixels instead. */
  grid-template-rows: minmax(min-content, 1fr) minmax(0, auto) min-content minmax(0, 1fr);
  justify-items: center; align-items: center;
  padding: clamp(.4rem, 1.2vh, .9rem) clamp(.8rem, 2vw, 1.6rem);
  overflow: hidden;
  /* One width for the head and the field both, declared here so the two
     cannot drift apart. The head's corner slots are only legible as brackets
     on the stimulus if they land on the stimulus panel's own edges; when the
     head ran wider they floated off to the side of it, attached to nothing. */
  --field-w: 56rem;
}
/* The arrangement task asks for a wider panel; the head follows it. */
.stage-body:has(.field.is-wide) { --field-w: 76rem; }
.stage-body > .stage-head {
  grid-row: 1; align-self: end;
  /* Real air, not a hairline. The instruction and the stimulus are two
     different kinds of thing and the gap is what says so; at half this the
     heading read as a label attached to the panel's top edge. */
  padding-bottom: clamp(var(--s-4), 3.2vh, var(--s-6));
}
/* The wrapper carries the panel's width so the read-out below can match it
   exactly. The read-out is its sibling rather than its child — see main.js:
   the field's children belong to the running module and are cleared out from
   under anything else put there. */
.stage-body > .field-wrap {
  grid-row: 2;
  display: flex; min-height: 0; max-height: 100%;
  width: min(100%, var(--field-w));
}

/* ---------- chrome above the field ----------
   Three columns. The middle one holds the centred stack — module name, trial
   kind, instruction — and the right one holds the task recap, the only thing
   up here that is neither. The left column is empty and exists purely to
   balance the right, which is what keeps the instruction on the same centre
   line as the stimulus below it rather than pushed left by whatever sits
   opposite. The trial read-outs used to take a second row here; they sit
   under the field now.

   No background and no border of its own: the app bar above it already
   provides one band of chrome, and stacking a second identically-toned bar
   under it made the top of the stage read as two competing headers. */
.stage-head {
  flex: 0 0 auto;
  width: min(100%, var(--field-w));
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  column-gap: var(--s-5);
  align-items: center;
}
.stage-head-main {
  grid-column: 2;
  display: flex; flex-direction: column; align-items: center;
  gap: var(--s-2);
  text-align: center;
  min-width: 0;
}
/* A slot for something that is neither instruction nor stimulus. */
.stage-aux {
  display: flex; align-items: center; gap: var(--s-2);
  min-width: 0;
}
.stage-aux.is-top { grid-column: 3; justify-self: end; align-self: center; }
/* Optical, not geometric. The button's own padding would stop its label short
   of the field edge that everything else in this column lines up with. Pull
   the box out by that padding so the words align; the hit area is unchanged. */
.stage-aux.is-top .btn { margin-right: calc(var(--ctl-pad-sm) * -1); }
/* Quiet: none of this is read on every trial, and all of it sits within a
   few degrees of the stimulus. */
.stage-aux .btn { color: var(--txt-4); font-weight: 500; }
.stage-aux .btn:hover { color: var(--txt-2); }
/* The read-out, under the panel's bottom-right corner.
   Outside the panel, not on it. The panel is an adapting surround, and the
   reason it is a defined grey rather than the page colour is that nothing
   inside it may be anything else — a label in the corner is a luminance patch
   in the surround, and on a short window or a module that fills the panel it
   stops being in the corner at all and lands on the stimulus. Under the
   bottom edge, aligned to it, the read-out is still attached to the field it
   describes and can touch neither. */
/* Same width as the panel, contents pushed to its right edge — which is what
   makes the two right edges line up exactly at any viewport width. The row
   carries the read-outs and, where a module has one, its action button: both
   belong to the task rather than to the run, so both sit against the panel
   they describe rather than down in the footer. */
.stage-aux.is-corner {
  grid-row: 3;
  width: min(100%, var(--field-w));
  justify-content: flex-end;
  gap: var(--s-4);
  padding-top: var(--s-3);
  /* The read-outs are not targets; the button inside them is. */
  pointer-events: none;
}
.stage-aux.is-corner .stage-actions { pointer-events: auto; }
/* Nothing when the module has no action, rather than a gap where one is not. */
.stage-actions:empty { display: none; }

/* During a test the app bar sheds everything that is not a control.
   Between the rail, the "3 / 9" beside it, the trial counter and the trial
   progress bar there were four separate progress read-outs on screen at once,
   which is most of what made the top of the stage feel busy. The two that
   survive are the two that change while you are working: which trial you are
   on, and how far through the module you are. */
#app[data-route="test"] .appbar { border-bottom-color: transparent; }
#app[data-route="test"] .rail,
#app[data-route="test"] .rail-count { display: none; }

/* The two label lines share one style — same size, same tracking, same
   colour — because they are the same kind of thing: a quiet caption over the
   instruction. Giving them different sizes made the head read as a hierarchy
   of three when it is really one heading and one note. */
.stage-name,
.stage-kicker {
  font-size: var(--t-micro); font-weight: 620; letter-spacing: .14em;
  text-transform: uppercase; color: var(--txt-4);
  line-height: var(--lh-tight);
  max-width: 100%;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* The module name is fixed for the whole module; the kicker changes per
   trial, so it gets the accent-free brighter step to mark it as live. */
.stage-kicker { color: var(--txt-3); }

.stage-prompt {
  font-size: var(--t-sub); font-weight: 500;
  color: var(--txt); letter-spacing: -.014em; line-height: var(--lh-tight);
  text-wrap: balance;
  max-width: 56ch;
}
.stage-prompt em { font-style: normal; color: var(--accent); font-weight: 580; }

/* ---------- the neutral field ----------
   A panel sized to what it holds, plus a wide neutral margin, rather than a
   full-bleed area. The margin is the adapting surround and is deliberately
   generous; the min-height stops a small stimulus — the four patches of the
   threshold task — from getting a thin one. Everything inside is centred,
   and the panel itself is centred, so the stimulus lands in the middle of
   the window a short way under the instruction. */
.field {
  flex: 0 1 auto; min-height: min(56vh, 30rem); max-height: 100%;
  /* Wide enough for a real surround, not so wide that a nine-swatch grid
     floats in a letterbox. The one module that genuinely needs more — eleven
     caps in a row — asks for it with .is-wide. */
  width: 100%;
  position: relative;
  background: var(--stage);
  border-radius: var(--radius-lg);
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  padding: clamp(1.4rem, 4.5vh, 3.5rem) clamp(1.2rem, 3.5vw, 3.5rem);
  gap: clamp(.8rem, 2vh, 1.6rem);
  overflow: hidden;
  /* a very slight vignette stops the flat fill from banding on 8-bit panels */
  box-shadow: inset 0 0 140px rgba(0,0,0,.06);
  /* The one ink permitted on the panel: a transparent black on the L*50
     surround, flipped to a transparent white on the dark variant. Achromatic
     either way, so a caption cannot tint the field it sits in. Anything drawn
     on the panel takes this rather than picking its own. */
  --on-field: rgba(0, 0, 0, .55);
}
/* A normal width, not fit-content: the arrangement task derives its cap size
   from the width this panel gives it, so a panel that sized itself to the caps
   would be defining its width in terms of its own width. The dead band that
   fit-content was meant to remove is gone anyway now that the caps grow to
   fill whatever they are given. */
/* The width now lives on --field-w (see .stage-body), which the wrapper and
   the head both read; this class is what switches it. */
.field.is-wide { width: 100%; }
/* A dark field is within a couple of code values of the page behind it, so
   without a hairline the panel has no edge and the stimulus looks like it is
   floating on the page rather than sitting on a controlled surround. */
.field.is-dark {
  background: #0e0e0e; box-shadow: none;
  border: 1px solid rgba(255, 255, 255, .07);
  --on-field: rgba(255, 255, 255, .42);
}
.field.is-tight { padding: clamp(1rem, 3vh, 2rem) clamp(1rem, 3vw, 2.4rem); }

/* A field that may scroll. Only the quiz asks for this, and it asks because
   its content GROWS after you answer — an explanation of unknown length plus
   a Next button appear under the options — so there is no figure size that
   guarantees a fit at every window height. Clipping it silently was the
   alternative, and a truncated explanation is worse than a scrollbar.

   `flex-start`, because a centred flex column clips its own first child when
   the content overflows: the question would have been unreachable in the one
   direction a scrollbar cannot go. No other module gets this — a stimulus you
   have to scroll to see is not a stimulus under experimental control. */
.field.is-scroll {
  justify-content: flex-start;
  overflow-y: auto;
  scrollbar-width: thin; scrollbar-color: var(--line-2) transparent;
}
.field.is-scroll::-webkit-scrollbar { width: 10px; }
.field.is-scroll::-webkit-scrollbar-track { background: transparent; }
.field.is-scroll::-webkit-scrollbar-thumb {
  background: var(--line-2); border-radius: var(--radius-pill); border: 3px solid transparent;
  background-clip: content-box;
}

/* answer feedback flash — luminance only, never a colour, so it cannot
   contaminate the next trial's adaptation state */
.field::after {
  content: ''; position: absolute; inset: 0; pointer-events: none;
  background: #fff; opacity: 0; transition: opacity .18s var(--ease);
}
.field.flash-yes::after { opacity: .16; transition-duration: .06s; }
.field.flash-no::after  { background: #000; opacity: .22; transition-duration: .06s; }

/* The control strip is now a normal child of the field rather than something
   floating over the bottom of it — the field is sized to its contents, so
   there is nothing to float over, and in the flow it keeps a predictable
   distance from the thing it controls. */
.field-controls {
  width: min(38rem, 100%);
  background: rgba(18,20,22,.9); backdrop-filter: blur(10px);
  border: 1px solid rgba(255,255,255,.09);
  border-radius: var(--radius);
  /* The strip pays for the wider gap between the sliders out of its own
     padding rather than out of the stimulus: a memory-colour object that
     shrinks to make room for chrome is a changed measurement. */
  padding: var(--s-2) var(--s-4); color: var(--txt);
  box-shadow: 0 8px 30px rgba(0,0,0,.35);
}

/* ---------- chrome below the field ---------- */
/* Three columns: hint, controls, read-outs. The outer two are equal `1fr`,
   which is what keeps the middle column on the same centre line as the
   stimulus regardless of how long the hint runs or how wide the counter gets.
   A flex row with a spacer cannot do that — it centres nothing, it just
   pushes. */
.stage-foot {
  flex: 0 0 auto;
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: var(--s-4);
  min-height: var(--bar-foot);
  /* clear of the home indicator on a phone */
  padding: var(--s-2) var(--s-4) calc(var(--s-2) + env(safe-area-inset-bottom));
  background: transparent;
  border-top: 1px solid transparent;
}
.stage-foot-side { display: flex; align-items: center; gap: var(--s-2); min-width: 0; }
.stage-foot-side.is-end { justify-content: flex-end; }
.stage-foot-mid { display: flex; align-items: center; justify-content: center; gap: var(--s-2); min-width: 0; }
.stage-actions { display: flex; align-items: center; gap: var(--s-2); }

.stage-controls { display: flex; align-items: center; gap: var(--s-1); }
/* Quiet by default and legible on hover: these are always-present controls
   sitting under a stimulus, so they should not compete with it for attention. */
.stage-controls .btn { color: var(--txt-4); font-weight: 500; }
.stage-controls .btn:hover { color: var(--txt-2); }

/* One line, always. A hint that wraps changes the footer's height, which
   moves the controls and the field above them — and it does that on exactly
   the modules with the longest hints, so the bar is a different height from
   one module to the next. It is a hint: if it does not fit, the full wording
   is one click of "Task" away. */
.stage-hint {
  font-size: var(--t-small); color: var(--txt-3);
  min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  text-align: center;
}

/* The read-outs. Tabular figures so the counter does not jitter as it counts,
   and a fixed-width bar so the group's left edge holds still too. */
.stage-count {
  font-family: var(--mono); font-variant-numeric: tabular-nums;
  font-size: var(--t-tiny); color: var(--txt-3); white-space: nowrap;
}
.stage-aux .tprog { width: 6rem; flex: 0 0 auto; }
/* Keyboard sentences are removed in JS on a coarse pointer (see touchHint),
   so what is left here is only ever prose that applies. */
@media (max-width: 760px), (pointer: coarse) {
  .stage-hint { font-size: var(--t-tiny); }
}
/* Below this there is no room beside a centred instruction: the outer tracks
   squeeze to nothing and the corner slots ride over the prompt. So the head
   becomes one column and the two slots drop to a row of their own underneath,
   pinned to the same left and right edges as the field. Same information,
   same alignment, one row taller instead of overlapping. */
@media (max-width: 640px) {
  .stage-head { grid-template-columns: minmax(0, 1fr); row-gap: var(--s-2); }
  .stage-head-main { grid-area: 1 / 1 / 2 / 2; }
  /* Right-aligned, so it brackets the field's top-right corner the way the
     read-out brackets the bottom-right one. */
  .stage-aux.is-top { grid-row: 2; grid-column: 1; justify-self: end; }


}

@media (max-width: 560px) {
  /* The touch block has the same rule, but a narrow window driven by a mouse
     matches neither `pointer: coarse` nor the desktop's spare height. */
  .slider-stack { gap: var(--s-2); }
  .slider-row { row-gap: 0; }
  .stage-aux .tprog { width: 4rem; }
  .stage-controls .btn > span { display: none; }
  .stage-controls .btn { padding: 0 var(--s-2); }
}
/* Below this the three columns cannot all fit, so the hint goes: it is the
   only one of the three that repeats what the instruction already said. The
   outer track stays in the grid, empty, which is what keeps the controls on
   the centre line rather than sliding left. */
@media (max-width: 420px) { .stage-hint { display: none; } }

/* ---------- pause ---------- */
.stage-veil {
  position: absolute; inset: 0; z-index: 5;
  display: grid; place-items: center;
  /* an opaque neutral, not a translucent scrim: the point of pausing is that
     the stimulus stops being looked at, and a see-through overlay would keep
     the adaptation state drifting */
  background: var(--bg-1);
  animation: fade-in .18s var(--ease);
}
.field.is-dark .stage-veil { background: #0b0c0d; }
.stage-veil-card { width: min(24rem, calc(100% - 2rem)); text-align: left; }
@keyframes fade-in { from { opacity: 0; } to { opacity: 1; } }

/* ==========================================================================
   1 · Hue arrangement (cap sorting)
   ========================================================================== */
/* Rows carry their own pixel width from the layout pass, so the column hugs
   them rather than stretching — which lets the panel hug the column. */
.caps {
  display: flex; flex-direction: column;
  gap: clamp(.5rem, 1.6vh, 1rem);
  align-items: center; max-width: 100%;
}
/* The track is sized by JS and its caps are absolutely placed, so reordering
   animates through the `translate` property while `transform` stays free for
   hover and drag emphasis — the two compose rather than fight. */
/* No track behind the caps.
   It was decoration, and it was decoration in the one place this test cannot
   afford any: a band two per cent darker than the field, sitting directly
   behind every cap, means each cap's immediate surround is not the L*≈50
   field the rest of the module is careful to provide. The row is now pure
   geometry — position and size for the absolutely-placed caps, nothing drawn. */
.caps-row { position: relative; max-width: 100%; }
/* The cap size is computed in layout() from the width actually available,
   because the row's total width depends on it and on the anchor gaps; a vw
   guess in CSS cannot know either, and overflowed on a phone. The clamp here
   is only the value used for the first measuring pass. */
.cap {
  position: absolute; top: 0; left: 0;
  width: var(--cap-size, clamp(1.4rem, 7vw, 5rem));
  height: var(--cap-size, clamp(1.4rem, 7vw, 5rem));
  border-radius: 99px;
  background: var(--cap-color);
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.16), 0 1px 3px rgba(0,0,0,.2);
  cursor: grab; touch-action: none;
  transition: translate .22s var(--ease), transform .18s var(--ease),
              box-shadow .18s var(--ease);
}
.cap:hover { transform: translateY(-3px) scale(1.04); z-index: 3; }
/* While a cap is being dragged, every other cap it passes over was lifting on
   hover as well — so the row appeared to churn under the pointer. During a
   drag nothing reacts to hover except the cap in hand. */
.caps.is-dragging .cap:hover { transform: none; }
.caps.is-dragging .cap { transition-duration: .13s; }
/* The pinned caps stay flush in the run — a gap beside them would change the
   viewing conditions for their neighbours — so they are marked instead: a
   dark rim on the cap and a bar beneath it. */
.cap.is-anchor {
  cursor: default;
  box-shadow: inset 0 0 0 2px rgba(0, 0, 0, .45), 0 1px 3px rgba(0, 0, 0, .2);
}
.cap.is-anchor::after {
  content: ''; position: absolute; inset: auto 12% -.42rem; height: 3px;
  background: rgba(0, 0, 0, .38); border-radius: 2px;
}
.cap.is-anchor:hover { transform: none; }
.cap.is-drag {
  cursor: grabbing; z-index: 20;
  transform: scale(1.16); box-shadow: 0 10px 26px rgba(0,0,0,.4), inset 0 0 0 1px rgba(0,0,0,.2);
}
.cap.is-selected {
  box-shadow: inset 0 0 0 2px #fff, 0 0 0 3px rgba(0,0,0,.5), 0 6px 18px rgba(0,0,0,.3);
  transform: translateY(-4px) scale(1.06);
}
.cap.is-target { box-shadow: inset 0 0 0 1px rgba(0,0,0,.16), 0 0 0 3px rgba(255,255,255,.75); }

/* ==========================================================================
   2 · Forced-choice grids (ΔE staircase, odd-one-out, banding)
   ========================================================================== */
.fc {
  display: grid; gap: clamp(.4rem, 1.1vw, .8rem);
  --cell: clamp(4rem, 19vh, 12.5rem);
}
.fc-cell {
  width: var(--cell); height: var(--cell);
  border-radius: 10px;
  background: var(--cell-color);
  cursor: pointer; position: relative;
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.1);
  transition: transform .13s var(--ease), box-shadow .13s var(--ease);
  -webkit-tap-highlight-color: transparent;
}
/* No hover state.
   A white ring appearing under the cursor on a colour-judgement surface is
   indistinguishable from feedback — people read it as "this one", and it also
   puts a high-contrast edge next to the patch being judged, which is the one
   thing the rest of this project spends its time keeping away from a stimulus.
   The pointer changing to a hand is enough affordance. */
.fc-cell:active { transform: scale(.99); }
.fc-cell:focus-visible { outline: 3px solid #fff; outline-offset: 2px; }
/* Shown only after a wrong answer — see the reveal rule in the modules. */
.fc-cell.is-reveal-yes { box-shadow: 0 0 0 3px #ffffff, 0 0 0 6px rgba(0,0,0,.3); }
.fc-cell.is-reveal-no  { box-shadow: 0 0 0 3px #000000, 0 0 0 6px rgba(255,255,255,.28); }
/* The numeral each patch used to carry named the key that selected it. With
   no keys there is nothing for it to name — and a glyph painted on a patch is
   a mark inside the very thing being judged, which is what the field's own
   no-text-on-the-stimulus rule exists to prevent. Gone with the shortcut. */

/* The staircase read-out that lives quietly under the grid. It is hidden
   until it has something to say — on trial one it was just the word "Recent"
   floating alone in the middle of an empty field. */
.fc-track {
  display: flex; align-items: center; gap: .45rem;
  font-size: .72rem; color: rgba(0,0,0,.42); font-family: var(--mono);
  letter-spacing: .06em;
  transition: opacity .3s var(--ease);
}
.fc-track:not(:has(.fc-track-dot)) { opacity: 0; }
.fc-track-dots { display: flex; gap: 3px; }
.fc-track-dot { width: 5px; height: 5px; border-radius: 99px; background: rgba(0,0,0,.2); }
.fc-track-dot.is-hit  { background: rgba(0,0,0,.62); }
.fc-track-dot.is-miss { background: rgba(255,255,255,.85); }

/* ==========================================================================
   3 · Pseudoisochromatic plates
   ========================================================================== */
.plate-wrap { display: flex; flex-direction: column; align-items: center; gap: 1.1rem; }
.plate {
  border-radius: 50%; overflow: hidden;
  width: clamp(13rem, 50vh, 26rem); height: clamp(13rem, 50vh, 26rem);
  box-shadow: 0 4px 24px rgba(0,0,0,.28), inset 0 0 0 1px rgba(0,0,0,.08);
  background: #8a8a8a;
}
.plate canvas { width: 100%; height: 100%; }
/* Two rows of five and then the opt-out on its own line. Left to wrap, it
   came out seven and four, which reads as an accident. */
.digit-pad {
  display: grid; gap: .4rem;
  grid-template-columns: repeat(5, 3rem);
  justify-content: center;
}
.digit-key {
  width: 3rem; height: 2.7rem; border-radius: var(--radius-sm);
  background: rgba(20,22,24,.88); color: #eef0f2;
  border: 1px solid rgba(255,255,255,.12);
  font-family: var(--mono); font-size: 1.05rem; font-weight: 500;
  display: grid; place-items: center;
  transition: all .13s var(--ease);
}
.digit-key:hover { background: rgba(40,44,48,.95); transform: translateY(-2px); }
.digit-key.is-wide {
  grid-column: 1 / -1; width: auto; padding: 0 1rem;
  font-family: var(--sans); font-size: .86rem; font-weight: 500;
  color: var(--txt-2);
}

/* ==========================================================================
   4 · Matching (grey point, memory colour)
   ========================================================================== */
.match {
  display: flex; align-items: center; justify-content: center; gap: clamp(1rem, 4vw, 3rem);
  width: 100%;
}
.match-patch {
  width: clamp(9rem, 33vh, 22rem); height: clamp(9rem, 33vh, 22rem);
  border-radius: 14px; background: var(--patch-color);
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.12), 0 6px 22px rgba(0,0,0,.16);
}
.match-patch.is-round { border-radius: 99px; }
.match-ref {
  width: clamp(9rem, 33vh, 22rem); height: clamp(9rem, 33vh, 22rem);
  border-radius: 14px; display: grid; place-items: center;
  background: rgba(0,0,0,.06); border: 1px dashed rgba(0,0,0,.22);
  color: rgba(0,0,0,.5); font-size: .82rem; text-align: center; padding: 1rem;
}
.match-scene { border-radius: 14px; overflow: hidden; box-shadow: 0 6px 22px rgba(0,0,0,.18); }
/* Was a fixed transparent black — correct on the L*50 panel and invisible on
   the dark one, which is the panel the neutral-point module actually uses. It
   takes the field's own ink now, so it is legible on both. */
.match-label { font-size: var(--t-tiny); color: var(--on-field); text-align: center; margin-top: var(--s-2); }

/* Memory-colour object canvas.
   Without an explicit size the canvas keeps its intrinsic 300×150 and sits in
   the top-left of its box, so the sky was drawn into the upper third of a
   square frame and the rest read as an empty panel. */
.memory-obj {
  width: clamp(12rem, 42vh, 27rem); height: clamp(12rem, 42vh, 27rem);
  border-radius: var(--radius); overflow: hidden; box-shadow: 0 8px 28px rgba(0,0,0,.2);
}
.memory-obj canvas { width: 100%; height: 100%; }

/* ==========================================================================
   5 · Gradient / banding
   ========================================================================== */
.ramps { display: grid; gap: clamp(.5rem, 1.4vh, 1rem); width: min(52rem, 100%); }
.ramp {
  height: clamp(2.6rem, 11vh, 6.5rem); border-radius: 10px; overflow: hidden;
  cursor: pointer; position: relative;
  display: block; width: 100%; padding: 0; border: 0; background: none;
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.12);
  transition: transform .13s var(--ease), box-shadow .13s var(--ease);
}
/* No hover ring here either, for the same reason: a bright edge along a
   gradient is exactly the artefact this module asks you to look for. */
.ramp canvas { width: 100%; height: 100%; }
/* The numeral on each gradient is gone for the same reason as .fc-key: it
   named a key, and it sat on the stimulus. */

/* ==========================================================================
   6 · Depth & 3D perception
   ========================================================================== */
.depth-canvas {
  border-radius: 14px; overflow: hidden; box-shadow: 0 8px 30px rgba(0,0,0,.4);
  width: min(46rem, 100%); aspect-ratio: 16 / 9; background: #0a0a0a;
}
.depth-canvas canvas { width: 100%; height: 100%; }
/* Answers and the note about the cue, stacked and centred under the canvas.
   The note used to be a bare grey span sharing a flex row with the buttons,
   which made it read as a third option. */
.depth-options {
  display: flex; flex-direction: column; gap: .5rem;
  align-items: center; justify-content: center; width: 100%;
}
.depth-answers { display: flex; gap: .55rem; flex-wrap: wrap; justify-content: center; }
.depth-note {
  font-size: .76rem; line-height: 1.45; color: rgba(255,255,255,.42);
  text-align: center; max-width: 46ch; text-wrap: balance;
}
.opt-btn {
  padding: .58rem 1.15rem; border-radius: 10px;
  background: rgba(22,25,28,.9); border: 1px solid rgba(255,255,255,.13);
  color: #e8eaec; font-size: .9rem; font-weight: 500;
  transition: all .14s var(--ease);
}
.opt-btn:hover { background: rgba(38,42,47,.95); border-color: rgba(255,255,255,.24); transform: translateY(-1px); }
.opt-btn.is-on { background: #eef0f2; color: #101214; border-color: #fff; }
.field:not(.is-dark) .opt-btn { background: rgba(20,22,24,.86); }

/* ==========================================================================
   7 · Colour-space literacy quiz
   ========================================================================== */
.quiz { width: min(52rem, 100%); display: flex; flex-direction: column; gap: 1.1rem; align-items: center; }
.quiz-visual {
  /* Smaller than it was. The panel has to hold the question, the figure, four
     options and — once answered — an explanation and a Next button, and at
     38vh the figure took enough of that to push the question off the top of
     the field. This one is a diagram rather than a colour judgement, so it is
     the thing in the stack that can afford to give up the height. */
  width: 100%; height: clamp(8rem, 25vh, 17rem);
  border-radius: 14px; overflow: hidden; background: rgba(0,0,0,.05);
  box-shadow: inset 0 0 0 1px rgba(0,0,0,.1);
}
.quiz-visual canvas { width: 100%; height: 100%; }
.quiz-opts { display: grid; grid-template-columns: repeat(2, 1fr); gap: .55rem; width: 100%; }
@media (max-width: 640px) { .quiz-opts { grid-template-columns: 1fr; } }
/* The question, at the top of its own panel and above the figure that
   illustrates it. Sized between the stage prompt and body text: it is the
   thing you read first on every trial, but the stage prompt above it is the
   instruction and should not be outranked. */
.quiz-q {
  width: 100%;
  font-size: var(--t-lede); font-weight: 560; line-height: var(--lh-snug);
  letter-spacing: -.012em; color: #eef0f2; text-wrap: balance;
}
/* Same emphasis the stage prompt uses, so a stressed phrase reads the same
   wherever the app puts it. */
.quiz-q em { font-style: normal; color: var(--accent); font-weight: 600; }

.quiz-opt {
  display: flex; align-items: center; gap: .7rem;
  padding: .72rem .9rem; border-radius: 11px; text-align: left;
  background: rgba(20,22,24,.86); border: 1px solid rgba(255,255,255,.11);
  color: #e8eaec; font-size: .89rem; line-height: 1.4;
  transition: all .14s var(--ease);
}
.quiz-opt:hover { background: rgba(36,40,44,.94); border-color: rgba(255,255,255,.22); transform: translateY(-1px); }
/* A disabled option after the answer is a record, not a dead control: it
   keeps its colour instead of fading out with the rest. */
.quiz-opt[disabled] { opacity: 1; }
.quiz-opt-text { flex: 1 1 auto; }

/* The result marker takes the slot the key numeral used to occupy. It is a
   shape as well as a colour on purpose — see the note in literacy.js. */
.quiz-mark {
  width: 1.5rem; height: 1.5rem; flex: 0 0 auto; border-radius: 6px;
  display: grid; place-items: center; color: #fff;
}
.quiz-mark svg { width: 1rem; height: 1rem; stroke-width: 2.4; }
.quiz-opt.is-right { background: #24382c; border-color: #4f8a67; }
.quiz-opt.is-wrong { background: #3a2626; border-color: #8a5252; }
.quiz-opt.is-right .quiz-mark { background: #4f8a67; }
.quiz-opt.is-wrong .quiz-mark { background: #8a5252; }
.quiz-explain {
  width: 100%; padding: .75rem .95rem; border-radius: 11px;
  background: rgba(16,18,20,.9); border: 1px solid rgba(255,255,255,.1);
  color: #b9c0c7; font-size: .85rem; line-height: 1.5;
  animation: pop .24s var(--ease-out);
}
.quiz-explain b { color: #eef0f2; }

/* ==========================================================================
   Module intro / interstitial cards
   --------------------------------------------------------------------------
   The brief is a two-column page on anything wider than a tablet: a running
   animation of the module on the left, the words on the right. Below that
   width the diagram goes first and the words follow, because seeing the task
   is worth more than reading about it and should not be scrolled past.
   ========================================================================== */
/* `safe center` so a short brief sits in the middle of the window and a long
   one still starts at the top instead of having its heading scrolled off. */
/* Top-aligned, not centred.
   `align-content: center` re-centres the whole card whenever its height
   changes — so opening "Method and reasoning" slid the heading, the diagram
   and the Begin button upward, and the reader lost their place in the act of
   asking for more. Anchored to the top with a generous margin, the card grows
   downward and everything above the fold stays exactly where it was. */
.intro {
  height: 100%; display: grid;
  justify-items: center; align-content: start;
  padding: clamp(var(--s-5), 7vh, var(--s-8)) clamp(.9rem, 3vw, 1.5rem) var(--s-8);
  overflow-y: auto;
}
.intro-card { width: min(58rem, 100%); }
.intro-top {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--s-4);
}
.intro-num {
  font-family: var(--mono); font-variant-numeric: tabular-nums;
  font-size: var(--t-tiny); color: var(--txt-4); letter-spacing: .06em;
}
.intro h2 { margin: var(--s-1) 0 0; letter-spacing: -.024em; }
.intro-lede { margin-top: var(--s-1); font-size: var(--t-body); max-width: 44ch; }

.intro-body {
  display: grid; gap: clamp(1.2rem, 3vw, 2.2rem);
  grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
  align-items: start;
  margin-top: var(--s-5);
}
@media (max-width: 880px) { .intro-body { grid-template-columns: 1fr; } }

.intro-steps {
  counter-reset: s; display: flex; flex-direction: column; gap: var(--s-2);
  list-style: none;
}
.intro-step {
  display: flex; gap: var(--s-3); align-items: flex-start;
  font-size: var(--t-body); line-height: 1.45;
  /* Dimmed by default so the lit one reads as lit. A list where every line is
     at full strength has nowhere to go when one of them becomes current. */
  color: var(--txt-3);
  padding: var(--s-2) var(--s-3);
  margin-inline: calc(var(--s-3) * -1);
  border-radius: var(--radius-sm);
  transition: color var(--mid) var(--ease), background var(--mid) var(--ease);
}
.intro-step::before {
  counter-increment: s; content: counter(s);
  width: 1.45rem; height: 1.45rem; flex: 0 0 auto; border-radius: var(--radius-pill);
  background: var(--bg-2); border: 1px solid var(--line-2);
  display: grid; place-items: center;
  font-family: var(--mono); font-size: var(--t-micro); color: var(--txt-3); margin-top: var(--s-0);
  transition: background var(--mid) var(--ease), border-color var(--mid) var(--ease),
              color var(--mid) var(--ease);
}

/* The step the picture is on. The accent is carried by the numeral and a
   faint wash, not by the prose: three lines of body text that change colour
   under you are harder to read than one marker that moves. */
.intro-step.is-now {
  color: var(--txt);
  background: var(--accent-dim);
}
.intro-step.is-now::before {
  background: var(--accent); border-color: var(--accent);
  color: #0d0f11; font-weight: 620;
}

/* Clicking a step takes the animation to the moment that shows it. */
.intro-step.is-seekable { cursor: pointer; }
.intro-step.is-seekable:hover { color: var(--txt-2); background: var(--bg-2); }
.intro-step.is-seekable.is-now:hover { color: var(--txt); background: var(--accent-dim); }

@media (prefers-reduced-motion: reduce) {
  .intro-step, .intro-step::before { transition: none; }
}

/* Everything that is true but not needed before pressing Begin. */
.intro-more { margin-top: var(--s-5); border-top: 1px solid var(--line); padding-top: var(--s-4); }
.intro-more > summary {
  cursor: pointer; list-style: none;
  font-size: var(--t-tiny); font-weight: 560; color: var(--txt-3);
  display: flex; align-items: center; gap: var(--s-2);
  padding: var(--s-1) 0;
  transition: color .15s var(--ease);
}
.intro-more > summary::-webkit-details-marker { display: none; }
.intro-more > summary::before {
  content: ''; width: .42rem; height: .42rem; flex: 0 0 auto;
  border-right: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor;
  transform: rotate(-45deg) translate(-1px, -1px);
  transition: transform .18s var(--ease);
}
.intro-more[open] > summary::before { transform: rotate(45deg) translate(-1px, -1px); }
.intro-more > summary:hover { color: var(--txt); }

.intro-what {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
  gap: var(--s-2); margin-top: var(--s-4);
}
.intro-fact {
  padding: .6rem var(--s-3); border-radius: var(--radius-sm);
  background: var(--bg-2); border: 1px solid var(--line);
}
.intro-fact dt { font-size: var(--t-micro); letter-spacing: .1em; text-transform: uppercase; color: var(--txt-4); margin-bottom: var(--s-1); }
.intro-fact dd { font-size: var(--t-small); color: var(--txt-2); line-height: 1.35; }

.intro-actions {
  display: flex; gap: var(--s-2); align-items: center;
  margin-top: var(--s-5); flex-wrap: wrap;
}

/* ---------- the live preview ---------- */
.demo { position: sticky; top: 0; }
@media (max-width: 880px) { .demo { position: static; } }

.demo-stage {
  position: relative; width: 100%;
  aspect-ratio: 4 / 3;
  border-radius: var(--radius); overflow: hidden;
  border: 1px solid var(--line-2);
  background: var(--stage);
  box-shadow: var(--shadow-1);
}
.demo.is-failed .demo-stage { display: none; }
.demo-canvas { display: block; width: 100%; height: 100%; }

.demo-badge {
  position: absolute; top: .55rem; left: .55rem;
  font-size: var(--t-micro); letter-spacing: .1em; text-transform: uppercase;
  padding: var(--s-1) var(--s-2); border-radius: 99px;
  background: rgba(10,11,13,.62); color: rgba(232,234,236,.82);
  border: 1px solid rgba(255,255,255,.14);
  backdrop-filter: blur(6px);
  pointer-events: none;
}
.btn-icon.demo-play {
  position: absolute; top: .4rem; right: .4rem;
  background: rgba(10,11,13,.62); border: 1px solid rgba(255,255,255,.14);
  color: rgba(232,234,236,.9);
  backdrop-filter: blur(6px);
  opacity: 0; transition: opacity .18s var(--ease);
}
.demo-stage:hover .demo-play,
.demo-play:focus-visible { opacity: 1; }
@media (pointer: coarse) { .btn-icon.demo-play { opacity: 1; } }

/* The caption under the animation, and the row of phase dots under that, are
   both gone: the written steps beside the picture are the caption now, and the
   step lit up is the dot. Two narrations of the same three points, one of
   which had to be sized to a fixed two lines so the page would stop twitching
   as it changed — the duplication was the cause of that, not the height. */

@media (prefers-reduced-motion: reduce) {
  .demo-badge::after { content: ' · paused'; }
}

/* ==========================================================================
   Narrow screens and touch
   --------------------------------------------------------------------------
   The layout is built on clamp() and mostly takes care of itself, so what is
   left here is the handful of places where a phone is not just a small
   desktop: hit targets that need to be finger-sized, a control strip that
   must stop floating over the stimulus once the field gets short, and the
   chrome above and below the field, which is fixed-height and therefore eats
   a much larger share of a 667px-tall screen than of a 900px one.
   ========================================================================== */

@media (max-width: 640px) {
  /* The panel keeps its shape but not its generous margin — on a phone the
     surround has to give way to the stimulus. */
  .stage-body { padding: var(--s-1) var(--s-2); }
  .field {
    padding: clamp(.9rem, 2.5vh, 1.6rem) clamp(.7rem, 2.5vw, 1.4rem);
    min-height: min(46vh, 22rem);
    border-radius: var(--radius);
  }

  /* The stimuli were enlarged to close the gap between the instruction and
     the thing it describes, which is a large-screen problem. On a phone the
     field is about 480px tall and those sizes do not fit, so the vh terms are
     wound back here rather than compromising the desktop. */
  .plate { width: clamp(9rem, 32vh, 22rem); height: clamp(9rem, 32vh, 22rem); }
  .memory-obj { width: clamp(9rem, 29vh, 20rem); height: clamp(9rem, 29vh, 20rem); }
  .match-patch, .match-ref { width: clamp(7rem, 24vh, 15rem); height: clamp(7rem, 24vh, 15rem); }
  .fc { --cell: clamp(3rem, 15vh, 8rem); }
  .ramp { height: clamp(2.2rem, 8vh, 4.4rem); }

  .stage-head { column-gap: var(--s-4); }
  .stage-foot { padding: var(--s-1) var(--s-3); gap: var(--s-2); }
  .stage-prompt { font-size: var(--t-lede); }
  .field { padding: clamp(.6rem, 2vh, 1.2rem) .7rem; }
}

/* Once the field is short, a floating control strip covers the very thing it
   is controlling. Put it back in the flow instead — but only in the modules
   that actually have one, or the modules that do not get their stimulus
   shoved to the top of an empty field for no reason. */
/* The control strip is in the flow everywhere now, so all that is left here
   is trimming it on a narrow screen. */
@media (max-width: 560px) {
  .field-controls { width: 100%; max-width: 34rem; padding: var(--s-3) var(--s-3); }
}

@media (pointer: coarse) {
  /* 44px is the smallest target most people can hit reliably; several of
     these were under it. */
  .digit-pad { grid-template-columns: repeat(5, 44px); }
  .digit-key { min-width: 44px; height: 44px; }
  .opt-btn { min-height: 44px; padding: var(--s-3) 1.2rem; }
  .ramp { min-height: 44px; }
  /* The touch-sized track already carries plenty of vertical space, so the
     label sits straight on top of it here. Keeping the desktop row-gap as
     well pushed the memory module's stimulus five pixels past its panel. */
  /* 2.2rem, not 2.4: still a comfortable finger target on the track, and the
     0.2rem back from each of the two sliders is what lets the memory object
     keep its full size on a phone rather than overflowing the panel. */
  .slider-row input[type="range"] { height: 2.2rem; }
  .slider-row { row-gap: 0; }
  /* Still clear of the old cramped spacing, but a touch phone spends its
     panel height on finger-sized tracks — 2.4rem each rather than 1.5rem —
     and the desktop step on top of that pushes the object past its own edge.
     The labels sitting above each track already do most of the separating. */
  .slider-stack { gap: var(--s-2); }
  /* hover transforms fire on tap and stick on touch devices */
  .fc-cell:hover, .ramp:hover, .cap:hover, .digit-key:hover, .opt-btn:hover { transform: none; }
}

/* ---------- landscape on a phone ----------
   The hard case. A 667×375 window has about 375px of height to spend, and the
   app bar, the two chrome strips and the stimulus all want a share of it.
   Measured on that viewport, the untouched layout left the field 193px tall
   with stimuli whose clamp minimums alone came to more than that, so every
   module overflowed.

   Two things happen here. The chrome collapses to one row — the metadata
   strip moves alongside the instruction rather than above it — and every
   stimulus's clamp minimum is lowered, since a minimum expressed in rem is
   a promise the viewport cannot keep at this height. */
@media (max-height: 500px) and (orientation: landscape) {
  .appbar { height: 2.5rem; }
  .brand-mark { width: 1.25rem; height: 1.25rem; }
  .rail, .rail-count { display: none; }

  /* Every vertical pixel here belongs to the stimulus. The head keeps the
     instruction and drops the two caption lines — the module name is on the
     brief you just came from and in the footer's counter, so it is the one
     thing in the head that can go. */
  /* One row, not two. Stacked corners cost the field about thirty pixels of
     height, and on a 390px-tall window that is the difference between a
     stimulus fitting and being clipped. The recap moves into the left track
     — which was empty and only there for balance — so the head stays
     symmetric about the instruction while losing a row. */
  .stage-head { column-gap: var(--s-3); grid-template-rows: auto; }
  /* The generous gap under the instruction is a large-screen luxury. Here the
     panel needs every pixel: at the desktop value three modules' stimuli
     overflowed it. */
  .stage-body > .stage-head { padding-bottom: var(--s-1); }
  /* And the action button drops to the small control height — a full-size
     button in the row under the panel costs it forty pixels of stimulus. */
  .stage-aux.is-corner { padding-top: var(--s-1); }
  .stage-aux.is-corner .btn { min-height: var(--ctl); font-size: var(--t-small); }
  .stage-head-main { gap: 0; grid-area: 1 / 2 / 2 / 3; }
  .stage-aux.is-top { grid-column: 1; justify-self: start; }
  .stage-name { display: none; }
  .stage-prompt { font-size: var(--t-body); max-width: 46ch; }

  .stage-body { padding: 0 var(--s-2); }
  /* The read-out's row costs the panel about twenty pixels, which a 390px-tall
     window does not have — three modules' stimuli were overflowing by nine.
     The bar goes (the counter says the same thing in less height) and the row
     loses its padding, which buys back more than the row cost. */
  .stage-aux.is-corner { padding-top: 0; }
  .stage-aux.is-corner .tprog { display: none; }
  .field {
    padding: var(--s-2) var(--s-3); min-height: 0;
    border-radius: var(--radius-sm); gap: var(--s-2);
  }
  .field.is-tight { padding: var(--s-1) var(--s-2); }
  /* The bar loses its padding, not its controls: a landscape phone is short,
     but a button you have to hit is the last thing that should absorb that. */
  .stage-foot { min-height: 0; padding: var(--s-1) var(--s-3); }
  .stage-foot .btn { min-height: var(--ctl); font-size: var(--t-small); }

  .plate { width: clamp(6rem, 60vh, 22rem); height: clamp(6rem, 60vh, 22rem); }
  .plate-wrap { flex-direction: row; align-items: center; gap: var(--s-3); }
  /* the pad sits beside the plate here, so it can afford real key heights */
  .digit-pad { grid-template-columns: repeat(5, 2.5rem); gap: var(--s-1); }
  .digit-key { width: 2.5rem; height: 2.5rem; min-width: 0; font-size: var(--t-body); }
  .digit-key.is-wide { height: 2.2rem; font-size: var(--t-tiny); }

  /* Object beside its controls rather than above them. In landscape the
     window is short and wide, which is the wrong shape for a stacked column
     and exactly the right one for a row. */
  .field:has(.field-controls) { flex-direction: row; gap: var(--s-3); }
  .field-controls { width: min(22rem, 48%); flex: 0 0 auto; }
  .memory-obj { width: clamp(5rem, 46vh, 20rem); height: clamp(5rem, 46vh, 20rem); }
  .match-patch { width: clamp(4.5rem, 44vh, 15rem); height: clamp(4.5rem, 44vh, 15rem); }
  /* height drives, not width: with aspect-ratio set, constraining the width
     and adding max-height leaves the box overflowing rather than shrinking */
  .depth-canvas { width: auto; height: 40vh; max-width: 100%; }
  /* the note is the first thing to go when there is no room for it */
  .depth-note { display: none; }
  .depth-options { gap: var(--s-1); }
  .opt-btn { min-height: 2.1rem; padding: .4rem var(--s-3); font-size: var(--t-small); }
  .fc { --cell: clamp(2rem, 22vh, 8.4rem); }
  .ramps { gap: var(--s-1); }
  .ramp { height: clamp(1.5rem, 13vh, 4.4rem); min-height: 0; }
  .caps { gap: var(--s-1); }
  .quiz { gap: var(--s-2); }
  .quiz-visual { height: clamp(4rem, 26vh, 24rem); }
  .quiz-opts { gap: var(--s-1); }
  .quiz-opt { padding: .4rem var(--s-3); font-size: var(--t-small); }
  .fc-track { display: none; }
  .field-controls { padding: var(--s-2) var(--s-3); }
}

/* A short portrait window — a small phone with a browser toolbar — hits the
   same wall on the one module that stacks a figure above four answers. */
@media (max-height: 700px) and (orientation: portrait) {
  .quiz-visual { height: clamp(6rem, 26vh, 24rem); }
  .quiz { gap: var(--s-2); }
  .quiz-opt { padding: var(--s-2) var(--s-3); }
}

/* between-module confirmation */
.tick {
  width: 2.6rem; height: 2.6rem; border-radius: 99px; flex: 0 0 auto;
  display: grid; place-items: center;
  background: color-mix(in srgb, var(--good) 18%, transparent);
  border: 1px solid color-mix(in srgb, var(--good) 40%, transparent);
  color: var(--good);
}
.tick svg { width: 1.2rem; height: 1.2rem; }
