/* ── Tokens ───────────────────────────────────────────────────────────── */
:root {
  --void:       #000000;
  --surface:    #0d0f12;
  --hairline:   #1e2227;
  --edge:       #55606e;   /* 3.28:1 on void, 3.00:1 on surface — WCAG 1.4.11 */
  --ink:        #e8eaed;
  --ink-label:  #8b9299;
  --ink-dim:    #727c88;
  --signal:     #ffb000;   /* candidate — confirmed on-device in Task 14 */
  --alert:      #ff4d4f;

  --pad:        max(1rem, env(safe-area-inset-left), env(safe-area-inset-right));
  --masthead-h: 3rem;
  /* The height an instrument actually gets. Subtracts the insets because body
     already pads by them, so the space available below the masthead is 100dvh
     minus both. .stage takes this as a min-height; an instrument that needs a
     *bounded* height (one with a scrolling region) takes it as a max-height,
     less .stage's own padding. */
  --stage-h:    calc(
                  100dvh - var(--masthead-h)
                  - env(safe-area-inset-top) - env(safe-area-inset-bottom)
                );

  color-scheme: dark;
}

@font-face {
  font-family: 'IBM Plex Mono';
  src: url('/fonts/IBMPlexMono-subset.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

/* ── Base ─────────────────────────────────────────────────────────────── */
*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

body {
  margin: 0;
  min-height: 100dvh;
  background: var(--void);
  color: var(--ink);
  font-family: 'IBM Plex Mono', ui-monospace, monospace;
  /* Tabular figures and slashed zero are mandatory site-wide: digits must not
     shift horizontally as a live readout updates. */
  font-variant-numeric: tabular-nums slashed-zero;
  font-feature-settings: 'tnum' 1, 'zero' 1;
  padding:
    env(safe-area-inset-top) env(safe-area-inset-right)
    env(safe-area-inset-bottom) env(safe-area-inset-left);
  overscroll-behavior: none;
}

a { color: inherit; text-decoration: none; }

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

/* #app is focused programmatically on every route change so screen readers
   announce the new view. That is not keyboard navigation, and the ring on a
   full-width element renders as two unexplained amber rules across the screen
   (its left and right edges fall outside the viewport). Suppress it here only. */
#app:focus, #app:focus-visible { outline: none; }

/* ── Masthead ─────────────────────────────────────────────────────────── */
.masthead {
  display: flex;
  align-items: center;
  height: var(--masthead-h);
  padding-inline: var(--pad);
  border-bottom: 1px solid var(--hairline);
}
.masthead__home {
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--ink-label);
}
/* On an instrument the chrome gets out of the way. */
body[data-route='instrument'] .masthead { border-bottom-color: transparent; }
/* And any sheet appended to body (install coach, update pill) must not sit
   over an instrument's controls — .sheet is fixed-positioned outside #app,
   so clear() cannot remove it; hiding by route is the only reach it has. */
body[data-route='instrument'] .sheet { display: none; }

/* ── Typographic roles ────────────────────────────────────────────────── */
.label {
  font-size: 0.6875rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--ink-label);
}
.readout {
  font-size: clamp(2.75rem, 18vw, 5rem);
  line-height: 1;
  color: var(--signal);
  font-variant-numeric: tabular-nums slashed-zero;
}
.readout__unit { font-size: 0.3em; color: var(--ink-label); margin-inline-start: 0.4em; }

/* ── Shelf ────────────────────────────────────────────────────────────── */
.shelf { padding: var(--pad) var(--pad) 4rem; }
.shelf__section + .shelf__section { margin-top: 2rem; }
.shelf__heading { margin: 0 0 0.75rem; }
.shelf__grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(9.5rem, 1fr));
  gap: 0.75rem;
}

.card {
  display: block;
  min-height: 7rem;
  padding: 0.875rem;
  background: var(--surface);
  border: 1px solid var(--edge);
  border-radius: 0.5rem;
}
.card__name { display: block; margin-bottom: 0.35rem; font-size: 0.9375rem; }
.card__blurb { display: block; font-size: 0.75rem; line-height: 1.4; color: var(--ink-label); }
/* All four margins, not just margin-top. This class is applied to <p> elements
   in four instruments and two sheets, and setting only the top longhand leaves
   the UA stylesheet's default bottom margin in place — roughly 18px of dead
   space that squeezed the Seismograph's trace to 12px in landscape before it
   was caught. Fixing it here rather than per-instrument removes the class of
   bug rather than one instance of it. */
.card__reason { display: block; margin: 0.5rem 0 0; font-size: 0.6875rem; color: var(--ink-dim); }
.card[aria-disabled='true'] { color: var(--ink-dim); }
.card[aria-disabled='true'] .card__name,
.card[aria-disabled='true'] .card__blurb { color: var(--ink-dim); }

/* ── Instrument stage ─────────────────────────────────────────────────── */
.stage {
  display: flex;
  flex-direction: column;
  min-height: var(--stage-h);
  padding: var(--pad);
}

/* ── Arm screen ───────────────────────────────────────────────────────── */
.arm {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 1rem;
  flex: 1;
  text-align: center;
  padding: var(--pad);
}
.arm__body { max-width: 22rem; font-size: 0.875rem; line-height: 1.5; color: var(--ink-label); }
.arm__button {
  font: inherit;
  font-size: 0.8125rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--void);
  background: var(--signal);
  border: 0;
  border-radius: 0.375rem;
  padding: 0.875rem 1.75rem;
  min-height: 2.75rem;   /* comfortable thumb target */
  cursor: pointer;
}
.arm__button--quiet { background: transparent; color: var(--ink-label); border: 1px solid var(--edge); }

/* ── Sheets and pills ─────────────────────────────────────────────────── */
.sheet {
  position: fixed;
  left: 0; right: 0;
  bottom: calc(env(safe-area-inset-bottom) + 0.75rem);
  margin-inline: var(--pad);
  padding: 1rem;
  background: var(--surface);
  border: 1px solid var(--edge);
  border-radius: 0.625rem;
  font-size: 0.8125rem;
  line-height: 1.5;
  z-index: 10;

  /* Sheets are built as a flat list of children (paragraphs then buttons), so
     lay them out here rather than requiring a wrapper element. Without this the
     action and the dismiss collide — "NOT NOW" ran into the RELOAD button. */
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem 1rem;
}
.sheet > p { flex: 1 1 100%; margin: 0; }
.sheet > button { flex: 0 0 auto; }
.sheet__dismiss {
  font: inherit;
  font-size: 0.6875rem;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  background: none;
  border: 0;
  color: var(--ink-label);
  padding: 0.5rem 0;
  cursor: pointer;
}

/* ── Motion ───────────────────────────────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
  }
}

/* ── Spirit Level ─────────────────────────────────────────────────────── */
.level {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 1.5rem;
  flex: 1;
  justify-content: center;
}
/* Two readouts share the width, so they cannot use .readout's 18vw sizing —
   that assumes a single readout spanning the screen. Worst case here is
   "-90.0°" ≈ 3.3em at IBM Plex Mono's 0.6em advance; 12vw fits that on every
   phone from 320px up. Only short values like "0.0" fit at 18vw, which is why
   this survived until the device was actually tilted. */
.level__readouts {
  display: flex;
  gap: 1.5rem;
  width: 100%;
  justify-content: space-evenly;
  overflow: hidden;   /* hard stop: nothing may bleed off-screen, ever */
}
.level__axis { min-width: 0; }
/* Worst case is ROLL, not pitch: roll is atan2(x, z) so it spans ±180, giving
   "-179.9" — six glyphs, 3.9em with the degree mark at Plex Mono's 0.6em
   advance. Sizing for pitch's ±90 (five glyphs) was the earlier mistake. */
.level__axis .readout { font-size: min(2.5rem, 10vw); white-space: nowrap; }
.level__axis { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; }
.level__vial { width: min(70vw, 20rem, 42dvh); aspect-ratio: 1; }
.level__actions { display: flex; gap: 0.75rem; }

/* ── Galton Board ─────────────────────────────────────────────────────── */
.galton {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.75rem;
  flex: 1;
  width: 100%;
}
.galton__stats {
  display: flex;
  justify-content: space-evenly;
  width: 100%;
  gap: 1rem;
  overflow: hidden;
}
.galton__stat { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; min-width: 0; }
/* Three readouts share the width — see the Spirit Level note: .readout's 18vw
   assumes a single readout spanning the screen. Worst case here is "-12.34",
   six glyphs at Plex Mono's 0.6em advance. */
.galton__stat .readout { font-size: min(1.75rem, 7vw); white-space: nowrap; }
.galton__theory { margin: 0; text-align: center; }
/* `flex: 1 1 0px` and `min-height: 0` are BOTH required, and neither is
   sufficient alone. `flex: 1` expands to `flex-basis: 0%`, and a percentage
   basis that cannot resolve against a definite ancestor (.stage uses min-height,
   not height) falls back to `content` — which for a canvas means its intrinsic
   300×150 attribute ratio. A length basis is always definite. Without both, the
   canvas rendered 377px inside a 324px landscape stage and the instrument
   screen scrolled. Same fix as .tuner__spec. */
.galton__board { width: 100%; flex: 1 1 0px; min-height: 0; }
.galton__controls { display: flex; flex-direction: column; gap: 0.5rem; width: 100%; }
.galton__control { display: flex; flex-direction: column; gap: 0.25rem; }
.galton__control input[type='range'] { width: 100%; accent-color: var(--signal); }
.galton__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; }

/* ── Sound Level ──────────────────────────────────────────────────────── */
.dbm { display: flex; flex-direction: column; align-items: center; gap: 1rem; flex: 1; width: 100%; justify-content: center; }
.dbm__main { display: flex; flex-direction: column; align-items: center; gap: 0.35rem; width: 100%; overflow: hidden; }
.dbm__main .readout { font-size: min(4rem, 20vw); white-space: nowrap; }
.dbm__caveat { text-align: center; max-width: 24rem; margin: 0; }
.dbm__bar { width: 100%; height: 2.5rem; }
.dbm__stats { display: flex; justify-content: space-evenly; width: 100%; gap: 1rem; overflow: hidden; }
.dbm__stat { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; min-width: 0; }
/* Three readouts share the width; worst case "-100.0" is six glyphs at Plex
   Mono's 0.6em advance. Same trap as the Spirit Level — .readout's 18vw
   assumes a single readout spanning the screen. */
.dbm__stat .readout { font-size: min(1.75rem, 7vw); white-space: nowrap; }
.dbm__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; }

/* ── Tuner ────────────────────────────────────────────────────────────── */
.tuner { display: flex; flex-direction: column; gap: 0.75rem; flex: 1; width: 100%; }
.tuner__note { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; overflow: hidden; }
/* One readout, but it holds "A#4" plus an accidental — three or four glyphs.
   The site-wide 18vw is fine here; the cap keeps it sane on a tablet. */
.tuner__note .readout { font-size: min(5rem, 26vw); white-space: nowrap; line-height: 1; }
.tuner__cents { text-align: center; }
.tuner__meter { width: 100%; height: 3.5rem; }
/* This shape — a flex:1 canvas inside a column — is what overflowed the
   Spirit Level in landscape (S1), but plain `min-height: 0` alone does NOT
   fix it here, and shipping it unverified would have reintroduced the same
   bug. Measured against the reference constraint from that fix (iPhone 14
   Pro landscape, 324px of stage height): `.tuner` and `.stage` are sized by
   `min-height`, never `height`, so neither establishes a definite main size.
   `flex: 1` expands to `flex-basis: 0%`, and a *percentage* flex-basis that
   can't resolve against a definite container falls back to `content` — and
   this canvas's content size is governed by its intrinsic 300×150 (2:1)
   attribute ratio, not by 0. The result: the canvas claimed `width / 2` of
   height regardless of available space (799px wide → 399px tall, verified),
   the instrument overflowed by hundreds of pixels, and the runtime resize()
   handler — which reads back clientWidth/clientHeight to size the bitmap —
   would have locked in that wrong ratio every frame after.
   `flex-basis: 0px` is a <length>, not a <percentage>, so it resolves to a
   definite 0 with no dependency on the ancestor chain, sidestepping the
   fallback entirely; `min-height: 0` is still required alongside it to
   remove the automatic minimum size, which otherwise reintroduces the same
   content-based floor. Verified with both rules present and re-verified with
   each removed individually — dropping either one reproduces the overflow. */
.tuner__spec { width: 100%; flex: 1 1 0px; min-height: 0; background: var(--void); }
.tuner__actions { display: flex; justify-content: center; }

/* ── Jump Height ──────────────────────────────────────────────────────── */
.jump { display: flex; flex-direction: column; align-items: center; gap: 1rem; flex: 1; width: 100%; justify-content: center; }
.jump__main { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; width: 100%; overflow: hidden; }
.jump__main .readout { font-size: min(5rem, 24vw); white-space: nowrap; line-height: 1; }
.jump__pm { text-align: center; }
.jump__stats { display: flex; justify-content: space-evenly; width: 100%; gap: 1rem; overflow: hidden; }
.jump__stat { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; min-width: 0; }
.jump__stat .readout { font-size: min(1.75rem, 8vw); white-space: nowrap; }
.jump__history { list-style: none; margin: 0; padding: 0; width: 100%; max-width: 22rem; }
.jump__row { display: flex; justify-content: space-between; padding: 0.3rem 0; border-bottom: 1px solid var(--hairline); font-size: 0.8125rem; }
.jump__row-hang { color: var(--ink-label); }
.jump__actions { display: flex; justify-content: center; }
/* A new best flashes, so it reads as an event rather than a number changing. */
.jump--best .jump__main .readout { color: var(--ink); }
.jump--best .jump__main { animation: jump-flash 0.45s ease-out 2; }
@keyframes jump-flash { 0%, 100% { opacity: 1 } 50% { opacity: 0.45 } }

/* ── Sky Pointer ──────────────────────────────────────────────────────── */
.sky { display: flex; flex-direction: column; align-items: center; gap: 0.6rem; flex: 1; width: 100%; }
.sky__id { display: flex; flex-direction: column; align-items: center; gap: 0.2rem; width: 100%; overflow: hidden; text-align: center; }
/* Longest catalogue name is ten glyphs ("Betelgeuse", "Rasalhague", "Menkalinan");
   "Whole sky" and "No match" are shorter fallbacks. Sized for the ten-glyph
   worst case at Plex Mono's 0.6em advance, with overflow as a hard backstop —
   see the Spirit Level note on .readout's 18vw assuming a single short value. */
.sky__name { font-size: min(2.5rem, 11vw); line-height: 1.1; color: var(--signal); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 100%; }
.sky__caveat { text-align: center; max-width: 26rem; }
/* .sky__view carries the flex/min-height pair that used to sit directly on
   the canvas (see the note this replaces, below) so a .sky__panel can be
   absolutely positioned over the canvas alone — never over .sky__id or
   .sky__stats, which are its siblings, not its descendants. */
.sky__view { position: relative; display: flex; width: 100%; flex: 1 1 0px; min-height: 0; }
/* Same shape as .tuner__spec / .galton__board: `flex: 1 1 0px` and `min-height: 0`
   are both required, neither is sufficient — a percentage flex-basis falls back
   to the canvas's intrinsic 300×150 when the ancestor chain has no definite
   height, which is exactly .stage's situation here (min-height, not height). */
.sky__canvas { width: 100%; flex: 1 1 0px; min-height: 0; background: var(--void); }
.sky__light { margin: 0; text-align: center; font-size: 0.9375rem; overflow: hidden; overflow-wrap: anywhere; }
.sky__stats { display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.4rem 1rem; width: 100%; max-width: 22rem; overflow: hidden; }
.sky__stat { display: flex; flex-direction: column; align-items: center; gap: 0.2rem; min-width: 0; }
/* Four readouts share two rows of two. Worst case per cell is six glyphs
   ("-90.0°", "359.9°"), well short of .readout's 18vw single-value assumption. */
.sky__stat .readout { font-size: min(2rem, 9vw); white-space: nowrap; }
/* Corner overlay rather than a flex row in .sky's column: this instrument's
   portrait layout already fills its landscape budget with text alone (see
   the note below), so a control that needs a place to live without costing
   any flow height goes here, absolute within .sky__view exactly like
   .sky__panel — confirmed this leaves zero-overflow landscape stages
   (down to a real 568×320 device) unchanged from before it existed. */
.sky__mode {
  position: absolute;
  top: 0.5rem; left: 0.5rem;
  max-width: calc(100% - 1rem);
  z-index: 2;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.25rem;
}
.sky__mode-btn {
  font: inherit;
  font-size: 0.6875rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  background: var(--surface);
  color: var(--ink-label);
  border: 1px solid var(--edge);
  border-radius: 0.375rem;
  padding: 0.45rem 0.7rem;
  min-height: 2rem;   /* still a real tap target, just not a full-width CTA */
  cursor: pointer;
}
.sky__mode-hint {
  display: block;
  font-size: 0.625rem;
  line-height: 1.3;
  color: var(--ink-dim);
  background: var(--surface);
  border-radius: 0.25rem;
  padding: 0.2rem 0.4rem;
  max-width: 11rem;
}
/* Tap-to-inspect panel: confined to .sky__view (the canvas's own box), so it
   can never cover the id block or the stats grid above/below it — those are
   outside this stacking context entirely, not merely visually avoided. */
.sky__panel {
  position: absolute;
  left: 0.5rem; right: 0.5rem; bottom: 0.5rem;
  max-height: calc(100% - 1rem);
  overflow: auto;
  background: var(--surface);
  border: 1px solid var(--edge);
  border-radius: 0.5rem;
  padding: 0.6rem 2rem 0.6rem 0.75rem;
  font-size: 0.8125rem;
  line-height: 1.4;
  z-index: 2;
}
.sky__panel[hidden] { display: none; }
.sky__panel-dismiss {
  position: absolute;
  top: 0.3rem; right: 0.4rem;
  font: inherit;
  font-size: 1rem;
  line-height: 1;
  background: none;
  border: 0;
  color: var(--ink-label);
  padding: 0.4rem;
  cursor: pointer;
}
.sky__panel-name {
  display: block;
  color: var(--signal);
  font-size: 1rem;
  margin-bottom: 0.3rem;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.sky__panel-row { display: flex; justify-content: space-between; gap: 0.5rem; overflow: hidden; }
.sky__panel-light { margin: 0.3rem 0 0; color: var(--ink-label); font-size: 0.75rem; overflow-wrap: anywhere; }
.sky__polaris, .sky__clock { margin: 0; text-align: center; }

/* Short landscape phones (this instrument carries more text rows than any
   other on the shelf — id block, light-left line, four stats, a caveat and
   two facts — and the plain portrait sizing above overflows a ~324px stage
   budget on its own, before the canvas claims any space at all. Compacting
   text here, not hiding it, keeps every honesty-critical line on screen: the
   compass caveat and the Polaris line are load-bearing, not decoration.
   .sky__mode and .sky__panel are absolute within .sky__view, so they cost no
   flow height and need no entry here — confirmed this leaves a real 568×320
   device (iPhone SE landscape) exactly as it was before either existed. */
@media (orientation: landscape) and (max-height: 500px) {
  .sky { gap: 0.3rem; }
  .sky__id { gap: 0.1rem; }
  .sky__name { font-size: min(1.5rem, 6vw); }
  .sky__light { font-size: 0.8125rem; }
  .sky__stats { grid-template-columns: repeat(4, 1fr); gap: 0.2rem 0.5rem; max-width: none; }
  .sky__stat .readout { font-size: min(1.125rem, 4.5vw); }
  .sky__caveat { max-width: none; font-size: 0.625rem; }
  .sky__polaris, .sky__clock { font-size: 0.625rem; }
}

/* ── Double Pendulum ──────────────────────────────────────────────────── */
.pend { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; flex: 1; width: 100%; }
.pend__readouts {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0.35rem 1rem;
  width: 100%;
  max-width: 26rem;
  overflow: hidden;
}
.pend__stat { display: flex; flex-direction: column; align-items: center; gap: 0.2rem; min-width: 0; }
/* Widest cell is energy drift, e.g. "-9.27e-6%" — nine glyphs at Plex Mono's
   0.6em advance. Same trap noted throughout this file: .readout's 18vw
   assumes a single value spanning the screen, not four sharing a grid. */
.pend__stat .readout { font-size: min(1.375rem, 6.5vw); white-space: nowrap; }
/* `flex: 1 1 0px` and `min-height: 0` are both required, neither is
   sufficient — see the identical note on .galton__board and .tuner__spec.
   touch-action: none stops the page from scrolling while a drag is setting
   the starting angle. */
.pend__view { width: 100%; flex: 1 1 0px; min-height: 0; background: var(--void); touch-action: none; }
/* A strip, not a growing panel: fixed height so it never competes with
   .pend__view for the vertical space the pendulum itself needs. */
.pend__strip { width: 100%; height: min(6rem, 18vh); flex: 0 0 auto; background: var(--void); }
.pend__hint { margin: 0; text-align: center; }
.pend__actions { display: flex; justify-content: center; gap: 0.5rem; }
/* Same shape as .sky__caveat: centered, capped width, quiet reason-text color.
   Empty and [hidden] between "tap to enable" and an actual outcome. */
.pend__caveat { text-align: center; max-width: 26rem; }

/* Same short-landscape-phone budget as the Sky Pointer (real 568×320 device,
   ~264px of stage height once the masthead and padding are subtracted):
   compact the readouts to one row and shrink the strip rather than letting
   either crowd out .pend__view, which is the point of the instrument. */
@media (orientation: landscape) and (max-height: 500px) {
  .pend { gap: 0.3rem; }
  .pend__readouts { grid-template-columns: repeat(4, 1fr); gap: 0.2rem 0.5rem; max-width: none; }
  .pend__stat .readout { font-size: min(1rem, 4vw); }
  .pend__strip { height: min(3.5rem, 14vh); }
  .pend__hint { font-size: 0.625rem; }
  .pend__caveat { max-width: none; font-size: 0.625rem; }
}

/* ── Seismograph ──────────────────────────────────────────────────────── */
.seis { display: flex; flex-direction: column; align-items: center; gap: 0.6rem; flex: 1; width: 100%; }
.seis__readouts { display: flex; justify-content: space-evenly; width: 100%; gap: 1rem; overflow: hidden; }
.seis__stat { display: flex; flex-direction: column; align-items: center; gap: 0.25rem; min-width: 0; }
/* Three readouts share the width — same trap noted throughout this file:
   .readout's 18vw assumes a single value spanning the screen. Worst case is
   the noise floor at three decimals, e.g. "0.012" — six glyphs. */
.seis__stat .readout { font-size: min(1.75rem, 7vw); white-space: nowrap; }
/* `flex: 1 1 0px` and `min-height: 0` are both required, neither is
   sufficient — see the identical note on .galton__board, .tuner__spec and
   .sky__canvas. .seis__view is the positioning root for the scale label,
   which is drawn as DOM text over the canvas rather than into it so it stays
   crisp at any zoom and never fights the canvas's own devicePixelRatio math. */
.seis__view { position: relative; display: flex; width: 100%; flex: 1 1 0px; min-height: 0; }
.seis__strip { width: 100%; flex: 1 1 0px; min-height: 0; background: var(--void); }
.seis__scale { position: absolute; top: 0.5rem; right: 0.75rem; color: var(--ink-dim); }
/* .card__reason supplies its own margin-top; the UA stylesheet's default
   <p> margin-bottom survives that because it's a different longhand. On the
   568×320 landscape budget below, that leftover ~18px was most of the
   difference between a usable strip and a 12px sliver — reset explicitly
   rather than relying on two unrelated rules to net out to something sane. */
.seis__caveat { text-align: center; max-width: 26rem; margin: 0; line-height: 1.35; }
.seis__actions { display: flex; justify-content: center; }

/* Same short-landscape-phone budget noted on the Sky Pointer and Double
   Pendulum (real 568×320 device, ~264px of stage height once the masthead
   and padding are subtracted). Unlike those two, the caveat here is never
   hidden — the equivalence-principle line is load-bearing, not optional —
   so it and the readouts both compete with the strip for the same budget;
   compact both as far as legibility allows rather than letting the strip,
   the point of the instrument, be squeezed to nothing. */
@media (orientation: landscape) and (max-height: 500px) {
  .seis { gap: 0.25rem; }
  .seis__readouts { gap: 0.5rem; }
  .seis__stat { gap: 0.1rem; }
  .seis__stat .readout { font-size: min(1.125rem, 4.5vw); }
  .seis__scale { font-size: 0.625rem; top: 0.3rem; right: 0.5rem; }
  .seis__caveat { max-width: none; font-size: 0.625rem; }
}

/* ── Heart Rate ───────────────────────────────────────────────────────── */
.pulse { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; flex: 1; width: 100%; }
.pulse__main { display: flex; flex-direction: column; align-items: center; gap: 0.3rem; width: 100%; overflow: hidden; }
.pulse__main .readout { font-size: min(5rem, 26vw); white-space: nowrap; line-height: 1; }
.pulse__gauge { width: 100%; max-width: 20rem; height: 0.4rem; background: var(--hairline); border: 1px solid var(--edge); border-radius: 999px; overflow: hidden; }
/* Dim while buffering or refused, --signal only once a reading is live —
   the gauge visually doubles as fill progress and confidence meter, so its
   color is itself part of the honesty gate: it never turns --signal for a
   number that hasn't earned it. */
.pulse__gauge-fill { height: 100%; width: 0%; background: var(--ink-dim); }
.pulse__gauge-fill--live { background: var(--signal); }
.pulse__gauge-label { text-align: center; }
/* Same shape as .seis__strip / .tuner__spec: `flex: 1 1 0px` and
   `min-height: 0` are both required, neither is sufficient — see the note on
   .tuner__spec for the full mechanism. This is the instrument's "wow" and
   gets whatever vertical space is left over. */
.pulse__wave { width: 100%; flex: 1 1 0px; min-height: 0; background: var(--void); }
/* Reserve a line so the layout doesn't jump when the hint text appears and
   disappears between states. */
.pulse__hint { text-align: center; max-width: 26rem; margin: 0; min-height: 1em; line-height: 1.35; }
.pulse__instructions { text-align: center; max-width: 26rem; margin: 0.3rem 0 0; line-height: 1.35; }
.pulse__torch-note { text-align: center; max-width: 26rem; margin: 0.2rem 0 0; line-height: 1.35; }
.pulse__actions { display: flex; justify-content: center; }

/* Same short-landscape-phone budget noted throughout this file (real
   568×320 device, ~264px of stage height once the masthead and padding are
   subtracted): compact the readout and text so the waveform — the point of
   the instrument — keeps most of the space. */
@media (orientation: landscape) and (max-height: 500px) {
  .pulse { gap: 0.25rem; }
  .pulse__main { gap: 0.15rem; }
  .pulse__main .readout { font-size: min(2rem, 8vw); }
  .pulse__gauge { max-width: none; }
  .pulse__gauge-label { font-size: 0.625rem; }
  .pulse__hint, .pulse__instructions, .pulse__torch-note { max-width: none; font-size: 0.625rem; }
}

/* ── Room Resonance ───────────────────────────────────────────────────── */
.room { display: flex; flex-direction: column; align-items: center; gap: 0.5rem; flex: 1; width: 100%; }
.room__main { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; width: 100%; overflow: hidden; }
/* One value, but four digits plus a unit: the sweep can put "4000 Hz" here,
   which is wider than .readout's 18vw single-short-value assumption allows.
   Same trap noted throughout this file, starting at the Spirit Level. */
.room__main .readout { font-size: min(3.5rem, 16vw); white-space: nowrap; line-height: 1; }
.room__sub { text-align: center; }
/* `flex: 1 1 0px` and `min-height: 0` are both required, neither is
   sufficient — see the full mechanism on .tuner__spec. .room__view is the
   positioning root for the axis caption, drawn as DOM text over the canvas
   exactly like .seis__scale so it stays crisp and never fights the canvas's
   own devicePixelRatio math. */
.room__view { position: relative; display: flex; width: 100%; flex: 1 1 0px; min-height: 0; }
.room__canvas { width: 100%; flex: 1 1 0px; min-height: 0; background: var(--void); }
.room__axis { position: absolute; top: 0.4rem; right: 0.6rem; color: var(--ink-dim); }
.room__modes {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: 0.3rem 1rem;
  width: 100%;
  max-width: 22rem;
  margin: 0;
  padding: 0;
  list-style: none;
  overflow: hidden;
}
.room__mode { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; min-width: 0; }
/* Up to four cells share the width. Worst case is "2000 Hz" — the band's own
   ceiling, four digits plus the unit. */
.room__mode .readout { font-size: min(1.5rem, 6.5vw); white-space: nowrap; }
.room__note { text-align: center; max-width: 26rem; margin: 0; line-height: 1.35; }
.room__invites {
  list-style: none;
  margin: 0;
  padding: 0;
  width: 100%;
  max-width: 26rem;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
}
.room__invite { font-size: 0.6875rem; color: var(--ink-dim); line-height: 1.35; }
.room__warn { text-align: center; max-width: 26rem; margin: 0; line-height: 1.35; }
/* Both of these carry text while hidden, and both set `display` in an author
   rule — `.card__reason { display: block }` for the warning, the flex column
   above for the invitations. An author `display` beats the UA stylesheet's
   `[hidden] { display: none }` regardless of source order, so without these
   two rules the `hidden` attribute does nothing at all and the loudness
   warning would sit under the button forever. */
.room__warn[hidden], .room__invites[hidden] { display: none; }
.room__actions { display: flex; justify-content: center; }

/* Same short-landscape-phone budget noted throughout this file (real 568×320
   device, ~264px of stage height once the masthead and padding are
   subtracted). This instrument's text rows are all load-bearing and none is
   hidden here: the loudness warning has to be read before the sweep, and the
   two invitations after a run are the experiments that let a user falsify
   the result. Compacting is the only lever, so the residual plot keeps
   whatever is left. */
@media (orientation: landscape) and (max-height: 500px) {
  .room { gap: 0.15rem; }
  /* The headline block turns into one baseline-aligned row here. Stacked, it
     costs 53px of a 272px stage and the residual plot is what pays; in a row
     it costs 26px and reads just as well. Measured on a real 568×320 stage:
     stacking left the plot 12px tall with four modes listed. */
  .room__main { flex-direction: row; align-items: baseline; justify-content: center; gap: 0.5rem; }
  .room__main .readout { font-size: min(1.5rem, 5vw); }
  .room__sub { font-size: 0.625rem; }
  .room__modes { grid-template-columns: repeat(4, 1fr); gap: 0.1rem 0.5rem; max-width: none; }
  .room__mode { gap: 0; }
  .room__mode .readout { font-size: min(1.125rem, 4vw); }
  .room__note, .room__warn { max-width: none; font-size: 0.625rem; }
  /* Side by side rather than stacked: at this width each invitation wraps to
     three short lines instead of two long ones, and three lines beside three
     lines is shorter than two above two. */
  .room__invites { max-width: none; display: grid; grid-template-columns: repeat(2, 1fr); gap: 0.1rem 0.75rem; }
  .room__invite { font-size: 0.625rem; }
  .room__axis { font-size: 0.625rem; top: 0.25rem; right: 0.4rem; }
}

/* ── Reaction Time ────────────────────────────────────────────────────── */
/* Bounded, not just grown into: .stage only ever sets a *min*-height, so a
   tall child simply makes the page longer and the panel's `overflow-y` below
   would have nothing to clip. Capping the instrument at exactly the height the
   stage offers, less the stage's own padding, is what keeps the tap target on
   screen on a short phone instead of pushing it up out of view. Every other
   instrument fits without this; this one carries a target that must stay put
   and prose that must stay readable, and one of the two has to give. */
.rxn {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  flex: 1;
  width: 100%;
  min-height: 0;
  max-height: calc(var(--stage-h) - 2 * var(--pad));
}
/* `flex: 1 0 auto`, and the zero shrink is the load-bearing part: with .rxn
   capped, a shrinkable side would collapse below the pad's own min-height and
   the pad would paint straight over the panel. It takes the surplus when there
   is one and gives up nothing when there is not. */
.rxn__side { display: flex; flex-direction: column; gap: 0.35rem; width: 100%; flex: 1 0 auto; }
/* The whole thing is the target. A small button would measure how fast a
   thumb finds a button, which is aim, not reaction. It takes every pixel the
   side leaves over (`flex: 1 1 0px`) and never gives back more than the floor
   below. */
.rxn__pad {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.6rem;
  width: 100%;
  flex: 1 1 0px;
  /* A floor, not a preference. On a short phone the prose below would
     otherwise squeeze the target down to a strip, and a target you have to
     look for measures aim rather than reaction. The panel scrolls instead. */
  min-height: 7rem;
  padding: 0.75rem;
  font: inherit;
  color: var(--ink);
  background: var(--surface);
  border: 1px solid var(--edge);
  border-radius: 0.5rem;
  cursor: pointer;
  /* manipulation, not none: this kills the double-tap-to-zoom hold-off
     without also killing the page scroll the panel below may need. */
  touch-action: manipulation;
  -webkit-tap-highlight-color: transparent;
  /* A fast second tap must not start selecting the words inside the pad. */
  user-select: none;
  -webkit-user-select: none;
}
/* The go state: a hard change of both hue and luminance across the entire
   target, not an element appearing somewhere in it. Nothing here moves or
   resizes, so the frame that paints it is a recolour and nothing else. */
.rxn__pad[data-phase='go'] { background: var(--signal); border-color: var(--signal); color: var(--void); }
.rxn__pad[data-phase='waiting'] { background: var(--void); color: var(--ink-dim); }
.rxn__word { font-size: min(4rem, 17vw); line-height: 1; white-space: nowrap; }
/* Twelve glyphs rather than the three or seven every other phase shows, so
   this one state gets its own size. Same worst-case-glyph arithmetic noted
   throughout this file. */
.rxn__pad[data-phase='idle'] .rxn__word { font-size: min(1.5rem, 6vw); }
.rxn__pad[data-phase='shown'] .rxn__word { color: var(--signal); }
.rxn__hint { font-size: 0.6875rem; line-height: 1.35; color: var(--ink-label); max-width: 24rem; text-align: center; }
.rxn__dots { display: flex; gap: 0.5rem; }
.rxn__dots i { width: 0.45rem; height: 0.45rem; border-radius: 50%; border: 1px solid var(--edge); background: transparent; }
.rxn__dots i.is-lit { background: var(--edge); }
/* Held in the layout rather than removed, so the pad's contents never shift
   between states. A jump on the go frame would be extra paint work on the
   one frame whose timestamp is the measurement. */
.rxn__pad:not([data-phase='waiting']) .rxn__dots { visibility: hidden; }
/* Two lines reserved: the verdict text appears and disappears every trial and
   must not shove the panel below it up and down while the user is tapping. */
.rxn__verdict { margin: 0; text-align: center; font-size: 0.75rem; line-height: 1.3; min-height: 2.6em; }
/* The reading half. It gives up height to the tap target first and scrolls
   within itself once there is nothing left to give, so no line of the latency
   budget or the comparison caveat is ever dropped to make room. */
.rxn__panel {
  display: flex;
  flex-direction: column;
  gap: 0.4rem;
  width: 100%;
  min-height: 0;
  flex: 0 1 auto;
  overflow-y: auto;
}
/* Nothing inside the scrolling half may shrink. A flex item with
   `overflow: hidden` (the stats grid, which needs it for worst-case glyphs)
   has an automatic minimum size of zero, so on a short phone it was crushed to
   a strip of clipped labels instead of scrolling out of sight honestly. */
.rxn__panel > * { flex: 0 0 auto; }
.rxn__stats {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 0.2rem 0.5rem;
  width: 100%;
  overflow: hidden;
}
.rxn__stat { display: flex; flex-direction: column; align-items: center; gap: 0.15rem; min-width: 0; }
/* Seven glyphs is the longest label here ("FASTEST", "COUNTED"), which fits a
   quarter of a 320px screen at this size. Wrapping instead made the four cells
   different heights and pushed one value out of line with the rest. */
.rxn__stat .label { white-space: nowrap; }
/* Four cells share the width. Worst case is a four-digit value plus the unit:
   nothing above 2999 ms is ever recorded, and anything longer than that is
   discarded before it reaches a readout. */
.rxn__stat .readout { font-size: min(1.5rem, 6.5vw); white-space: nowrap; }
.rxn__interval { margin: 0; text-align: center; font-size: 0.75rem; line-height: 1.3; }
.rxn__plot { width: 100%; height: min(4.5rem, 13vh); flex: 0 0 auto; background: var(--void); }
/* Every trial, in order, including the ones that counted towards nothing.
   One row that scrolls sideways rather than a block that wraps: a long run
   must not be able to push the tap pad off the screen. */
.rxn__trials {
  display: flex;
  gap: 0.25rem;
  width: 100%;
  margin: 0;
  padding: 0;
  list-style: none;
  overflow-x: auto;
  overflow-y: hidden;
}
.rxn__chip {
  flex: 0 0 auto;
  font-size: 0.6875rem;
  padding: 0.05rem 0.3rem;
  border: 1px solid var(--edge);
  border-radius: 0.25rem;
  color: var(--ink-label);
}
.rxn__chip--valid { color: var(--signal); border-color: var(--signal); }
.rxn__chip--anticipated,
.rxn__chip--false-start,
.rxn__chip--unusable { color: var(--alert); border-color: var(--alert); text-decoration: line-through; }
.rxn__compare, .rxn__latency { text-align: center; margin: 0; line-height: 1.35; }
.rxn__actions { display: flex; flex-wrap: wrap; gap: 0.5rem; justify-content: center; }

/* Same short-landscape-phone budget noted throughout this file (real 568×320
   device, ~264px of stage height once the masthead and padding are
   subtracted). Stacking cannot work here: this instrument's one irreducible
   requirement is a tap target big enough that finding it costs nothing, and
   in a column the prose would leave it about 50px tall. So the stage splits
   in two (target on one side, everything read afterwards on the other) and
   the reading side scrolls if the phone is short enough. Nothing is clipped
   and nothing is dropped; the target keeps the full height. */
@media (orientation: landscape) and (max-height: 500px) {
  .rxn { flex-direction: row; gap: 0.5rem; align-items: stretch; }
  .rxn__side { flex: 1 1 44%; width: auto; min-width: 0; }
  .rxn__pad { min-height: 0; }
  .rxn__word { font-size: min(3rem, 8vw); }
  .rxn__pad[data-phase='idle'] .rxn__word { font-size: min(1.25rem, 3.5vw); }
  .rxn__hint { font-size: 0.625rem; }
  .rxn__verdict { font-size: 0.625rem; min-height: 3.9em; }
  .rxn__panel { flex: 1 1 56%; width: auto; min-width: 0; gap: 0.25rem; }
  .rxn__stat .readout { font-size: min(1.25rem, 3.5vw); }
  .rxn__interval { font-size: 0.625rem; }
  .rxn__plot { height: 2.75rem; }
  .rxn__compare, .rxn__latency { font-size: 0.625rem; }
}
