/* Faraday Waves - annotated-journal-figure aesthetic.
 * Light technical-paper ground, hairline rules, one dark element: the dish.
 * Serif for display/body, mono for readouts and annotations.
 */

:root {
  --measure: none; /* text runs the full shell column - exactly the figure width */
  --paper: #e8eef2;
  --paper-deep: #dee4e8;
  --mono-size: 0.9775rem; /* 0.92 x body: the single mono register size,
                             in rem so nested .mono never compounds */
  --ink: #1b1b1f;
  --ink-soft: #50505a;
  --ink-faint: #63636d; /* >= 4.5:1 on --paper for small text (WCAG AA) */
  --hairline: rgba(27, 27, 31, 0.32);
  --hairline-soft: rgba(27, 27, 31, 0.14);
  --serif: "Charter", "Bitstream Charter", "Iowan Old Style", "Sitka Text",
           "Cambria", Georgia, serif;
  --mono: ui-monospace, "SF Mono", "Cascadia Mono", Menlo, Consolas,
          "DejaVu Sans Mono", monospace;
}

* { box-sizing: border-box; }

html {
  background: var(--paper);
  /* iOS Safari otherwise inflates text on its own judgement. */
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

body {
  margin: 0;
  color: var(--ink);
  font-family: var(--serif);
  font-size: 1.0625rem;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

.shell {
  /* 69rem: the two-column panel must hold the longest radio label
     ("fixed size (physics still reflected)") on one line at mono size. */
  max-width: 69rem;
  margin: 0 auto;
  padding: 0 1.25rem;
}

.mono { font-family: var(--mono); font-size: var(--mono-size); }

/* Maths symbols (f, R): always italic, immune to label uppercasing. */
.sym { font-style: italic; text-transform: none; }

/* Inline formulas in captions and prose: journal register - serif,
   italic variables via <i>, roman function names and units, never
   broken across lines. Controls and readouts keep the mono register. */
.fml { white-space: nowrap; }

/* Maths operator glyphs the serif stack lacks (proportional-to, root):
   the fallback renders them tiny (measured 2.7px ink vs 6.7px for x).
   Pull them from a maths-complete system font instead. */
.op {
  font-family: "STIX Two Math", "STIXGeneral", "Cambria Math",
    "Segoe UI Symbol", serif;
}

/* ---------- masthead ---------- */

.masthead {
  border-top: 3px solid var(--ink);
  border-bottom: 1px solid var(--hairline);
  padding: 0.8rem 0 0.55rem;
}

.masthead-row {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.4rem 1.5rem;
}

.wordmark {
  margin: 0;
  font-size: 1.06rem;
  font-weight: 700;
  letter-spacing: 0.02em;
  font-variant-caps: small-caps;
}

.wordmark-sub {
  font-weight: 400;
  font-variant-caps: normal;
  font-style: italic;
  color: var(--ink-soft);
  margin-left: 0.45rem;
}

.nav {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 1.15rem;
  list-style: none;
  margin: 0;
  padding: 0;
}

.nav a {
  color: var(--ink);
  text-decoration: none;
  border-bottom: 1px solid transparent;
  padding-bottom: 1px;
}

.nav a:hover { border-bottom-color: var(--ink); }

.nav a[aria-current="page"] {
  font-weight: 700;
  border-bottom: 2px solid var(--ink);
}

.nav a.prep { color: var(--ink-soft); }
.nav a sup { font-size: 0.62em; margin-left: 0.1em; }

.prep-key {
  margin: 0.35rem auto 0;
  color: var(--ink-faint);
}

/* ---------- lede ---------- */

.lede { padding: 2.6rem 0 1.4rem; }

.lede h1 {
  margin: 0 0 0.8rem;
  font-size: clamp(1.55rem, 3.3vw, 2.35rem);
  line-height: 1.22;
  font-weight: 700;
  letter-spacing: -0.008em;
  max-width: var(--measure);
}

.lede h1 em { font-style: italic; }

.standfirst {
  margin: 0;
  max-width: var(--measure);
  color: var(--ink-soft);
}

/* ---------- figure ---------- */

.fig1 {
  margin: 1.6rem 0 0;
  border-top: 1px solid var(--hairline);
  border-bottom: 1px solid var(--hairline);
  padding: 1.9rem 0 1.4rem;
}

/* Overview figure: the dish on the left, the Readout + controls on the
   right (session 8 - the old single stacked column left the space beside
   the square dish empty and pushed the page tall; the readout now fills
   it). align-items:start seats the readout level with the top of the
   dish. The caption sits below, capped to a comfortable measure and
   centred. Stacks under 52rem (see the responsive section).

   Session 9, the readout gutter (owner: "a bit more space between
   labels and numbers - f and the frequency numbers are very cramped").
   The column was 17rem (272px), which left the LONGEST row - detuning
   f0 - f/2 against its value - just 33px of clearance while the short
   rows had 70-140px; one tight row makes the whole panel read cramped.
   The width was free: the grid was capped at 58rem (928px) while the
   caption rule that brackets the figure spans 1064px, so ~136px sat
   unused. Grid -> 62rem and the readout column -> 19rem buys every row
   32px more gutter (detuning 33 -> 65px) and lets the DISH grow too
   (618 -> 650px) instead of paying for it. The column min rose with it
   so mid-widths cannot crush that row either.
   CEILING, if this is ever dialled again: keep the dish column at or
   under the stage's own 42rem cap - grid minus panel minus column-gap
   <= 672px - or the canvas stops filling its column and falls out of
   line with the caption rule on the left. At 19rem panel that caps the
   grid near 63rem. The sandbox readout is a different, roomier panel
   (480px) and is deliberately untouched. */
.fig1--showcase .fig-grid {
  max-width: 62rem;
  margin-left: auto;
  margin-right: auto;
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(17rem, 19rem);
  column-gap: 2.4rem;
  align-items: start;
}
/* Caption spans the FULL figure width - the same rules that bracket the
   figure and the mode-catalogue rows below it (owner: "the line before
   the mode catalogue is the width reference"), not the narrower 58rem
   fig-grid or the dish column. Left-aligned with those rules. */
.fig1--showcase > .caption {
  max-width: none;
  margin-left: 0;
  margin-right: 0;
}

/* Sandbox: two-tier console. The figure IS the grid (so the caption stays a
   valid direct child and can sit under the dish). Top tier: dish + caption
   beneath it on the left, the READOUT (plus the view toggles) on the right
   spanning both. Bottom tier: a full-width band holding ALL the controls -
   the drive/cell sliders first, then the configure-once ones. The panel
   spanning render+caption keeps the two top columns near equal height, so the
   old dead space under the short dish is gone.

   SESSION 9: the four sliders moved OUT of the panel and into the band
   (owner: "in the Sandbox page it is preferable to have sliders below the pic
   so that we do not have that long column effect"). Session 5 had balanced a
   readout plus THREE sliders against the dish, but the depth h slider
   (session 6) and a readout grown to 13 rows had pushed the column to 1234px
   against a 653px dish - twice its height, the long strip that layout exists
   to prevent. With the sliders below, the panel measures ~649px against the
   dish's 653px: balanced by construction rather than by luck, and it stays
   balanced because the readout is now the only thing that can grow.
   THE OVERVIEW IS A DIFFERENT LAYOUT AND MUST STAY ONE (.fig1--showcase,
   readout beside the dish with its slider): page-specific figure rules go on
   the modifier class, never on shared .fig1 / .fig-grid / .panel. That is the
   session-5 lesson and it is what keeps an Overview change off this page. */
.fig1--console {
  display: grid;
  grid-template-columns: minmax(0, 1.75fr) minmax(15.5rem, 1fr);
  grid-template-areas:
    "render panel"
    "caption caption"
    "setup setup";
  /* The caption SPANS both columns (session 9). It used to sit in the left
     column with the panel spanning two rows, which balanced only while the
     panel was tall: once the sliders moved down to the band the panel fell to
     685px against a 1002px dish-plus-caption, leaving a 373 x 338px void in
     the bottom right - the same dead space the session-5 two-tier layout was
     built to remove, just mirrored. Spanning the caption closes it: row one is
     dish 653px beside readout 685px, level to within 33px by construction, and
     the caption runs under both. Its line length widens 660 -> 1064px in doing
     so, which is the house measure anyway (--measure is deliberately `none`:
     prose runs the full shell column) and matches the overview's full-width
     caption. Figure height fell 1805 -> 1702px.
     APPARATUS VIEW is the one place the two top cells cannot be level - that
     figure is short (431px) while the panel gains the surface toggle (918px) -
     but the spanning caption simply follows the taller cell, verified sitting
     22px under it, which is why the old apparatus caption override is gone. */
  grid-template-rows: auto auto auto;
  column-gap: 2.4rem;
  row-gap: 1.4rem;
  align-items: start;
}
.fig1--console > .stage { grid-area: render; }
.fig1--console > .panel { grid-area: panel; }
/* The caption spans, so it simply follows the taller of the two top cells -
   no bottom-anchoring, and no excess to hide. This also retires the apparatus
   special case: that rule existed because a bottom-anchored caption tore away
   from the short apparatus figure by ~30rem, which cannot happen now. */
.fig1--console > .caption { grid-area: caption; margin-top: 0; align-self: start; }
.fig1--console > .fig-setup { grid-area: setup; }

/* The configure-once controls (cell, fluid, boundary, dissipation) in as many
   responsive columns as fit - wide and short, not a tall strip. */
.fig-setup {
  display: grid;
  /* 22rem min so the band settles into two columns at the full shell (never
     three): a column must clear the longest radio label, "fixed size (physics
     still reflected)" at ~23rem, the same label that sets the 69rem shell. */
  grid-template-columns: repeat(auto-fit, minmax(22rem, 1fr));
  column-gap: 2.4rem;
  row-gap: 1.4rem;
  /* Stretch each control to its row height and pin its explanatory hint to the
     bottom, so paired hints (contact line / dissipation) share a baseline. */
  align-items: stretch;
  margin-top: 0.4rem;
  padding-top: 1.7rem;
  border-top: 1px solid var(--hairline-soft);
}
.fig-setup > .control,
.fig-setup > fieldset { display: flex; flex-direction: column; }
.fig-setup .hint { margin-top: auto; }

/* CARDED (owner, session 9: "use the same technique we used in the tool
   section to organise sliders and sections... right now it is hard to
   distinguish pieces"). The band had grown to eight groups once the sliders
   came down, and on the open paper they ran together. Same idiom as the tools
   panel, deliberately: hairline outline, 10px radius, identical padding, and
   NO FILL - the filled card was A/B'd and rejected in session 7 and must not
   come back here either. Every group is wrapped in a .control in the markup
   (fieldsets sit INSIDE, or a bordered fieldset lets its legend interrupt the
   frame), so one selector carries the frame. NOT NUMBERED, unlike the tools
   cards: those number a configure-then-run sequence, while these are live
   controls poked in any order - numbering would imply a wizard. */
.fig-setup > .control {
  margin-top: 0;
  background: transparent;
  border: 1px solid var(--hairline);
  border-radius: 10px;
  padding: 1.05rem 1.35rem 1.2rem;
}
/* The cards supply their own separation, so the band gaps tighten to the
   tools spacing - wider gutters plus frames read as scattered. */
.fig-setup { column-gap: 1.3rem; row-gap: 1.3rem; }
/* A wrapped fieldset fills its card so the hint can still sink to the bottom
   (paired hints keep their shared baseline, s.16 session 5). */
.fig-setup > .control > .view-toggle { margin-top: 0; flex: 1; display: flex; flex-direction: column; }

/* Mode ladder (paper Figs. 3/9 orientation): full-width chart, drive f
   along x, azimuthal order m up y. */
.ladder-row {
  margin-top: 2rem;
  padding-top: 1rem;
  border-top: 1px solid var(--hairline-soft);
}

/* The catalogue label runs the full rule width. At the mono register's
   own size it is 1079px of text in a 1064px rule - fifteen pixels too
   long - so it wrapped, and it wrapped with the trailing symbol ALONE
   on line two (owner, session 9: "n went in the second line, which is
   dumb"). The owner's call, after the trade was put to them, is that it
   MUST FIT ON ONE LINE. So:
   1. font-size steps down to 0.93 of --mono-size (15.64 -> 14.55px),
      measuring 1001px against the 1064px rule: 5.9% clear, enough to
      survive the font-metric differences between browsers. The 0.14em
      TRACKING IS UNTOUCHED - it is the site's uppercase convention,
      shared with .panel-title and .control-label. Chosen over
      tightening it, measured: 0.12em = 1050px (only 1.3% clear, inside
      cross-browser variation), 0.10em = 1021px (4% clear but 29% off
      the convention).
   2. `count&nbsp;n` in the markup (index + sandbox) and text-wrap:
      pretty STAY, now as the safety net rather than the fix: below
      roughly a 1060px viewport the rule itself is too narrow for one
      line at any readable size, and when it wraps there the symbol
      still cannot be orphaned.
   THE BUDGET IS 1064px at the rule. If the copy ever grows, re-measure
   rather than stepping the size down again - past about 14px this
   label stops reading as the mono register and starts reading as fine
   print. Trimming the copy is the other lever and it is the owner's. */
.ladder-title {
  margin: 0 0 0.5rem;
  font-family: var(--mono);
  font-size: calc(var(--mono-size) * 0.93);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-faint);
  text-wrap: pretty;
}

.mode-ladder {
  display: block;
  width: 100%;
  height: auto;
  aspect-ratio: 1000 / 258; /* matches the viewBox exactly */
  cursor: pointer;
}

.l-axis { stroke: var(--hairline); stroke-width: 1; fill: none; }
.l-tick-text { font-family: var(--mono); font-size: 15px; fill: var(--ink-faint); }
.l-diag { fill: none; stroke: var(--hairline-soft); stroke-width: 1; }
.l-diag-label { font-family: var(--mono); font-size: 15px; fill: var(--ink-faint); }
.lc { fill: var(--ink-soft); }
.lc-win { fill: var(--ink); stroke: var(--ink); stroke-width: 2; }
.l-cursor { stroke: var(--ink-soft); stroke-width: 1; stroke-dasharray: 4 4; }
.l-win-text { font-family: var(--mono); font-size: 15px; fill: var(--ink); }

/* Static explainer figures (physics + research), generated from the
   engines by scripts/make_figures.mjs. Inline SVG so they inherit the
   .l-* register and the paper/ink theme vars; sized within the prose. */
.fig-chart {
  display: block;
  width: 100%;
  max-width: 32rem;
  height: auto;
  margin: 1.8rem auto 0.4rem;
}

/* The figure viewBox (1000 wide) renders at ~512 px, a 0.51x scale, so
   the shared 15px .l-* label would land at ~8px. Bump it here only,
   inside .fig-chart, leaving the wider sandbox ladder untouched. */
.fig-chart .l-tick-text,
.fig-chart .l-win-text { font-size: 28px; }

/* Captioned data figures: a larger chart plus a captioned description,
   framed like the live-simulation figures. Used by the research findings
   figures and the physics explainer figures (tongue, mode shapes), so the
   two pages carry one figure treatment. The wrapper resets the default
   figure margin; the chart centres inside it and the caption carries the
   rule. At 46rem the viewBox renders at ~0.74x, so the labels drop to 22px
   (~16px on the page) to stay in the register. */
.datafig { margin: 2rem 0 2.4rem; }
.datafig .fig-chart { max-width: 46rem; margin: 0 auto; }
/* Canvas figures (the animated setup) get the same 46rem treatment. */
.datafig .fig-anim { display: block; width: 100%; max-width: 46rem; height: auto; margin: 0 auto; }

/* The one INTERACTIVE explainer figure (rr-find): a slider + settle button
   under the canvas, capped to the canvas's own 46rem measure. */
.fig-controls {
  display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
  max-width: 46rem; margin: 0.9rem auto 0;
}
.fig-controls .fig-cap {
  flex: 0 0 auto; font-family: var(--mono); font-size: 0.86rem; color: var(--ink-soft);
}
.fig-controls input[type="range"] { flex: 1 1 12rem; min-width: 8rem; width: auto; }
.fig-btn {
  flex: 0 0 auto; font-family: var(--mono); font-size: 0.8rem; color: var(--ink);
  background: var(--paper-deep); border: 1px solid var(--hairline);
  border-radius: 6px; padding: 0.35rem 0.8rem; cursor: pointer;
}
.fig-btn:hover { background: var(--paper); }
.fig-btn:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.fig-select {
  flex: 0 0 auto; font-family: var(--mono); font-size: 0.8rem; color: var(--ink);
  background: var(--paper-deep); border: 1px solid var(--hairline);
  border-radius: 6px; padding: 0.3rem 0.5rem; cursor: pointer;
}
.fig-select:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
/* The pattern picker takes its own line (basis 100%) so the deformation label
   and its slider stay together on the row below. */
.fig-pattern { flex: 0 0 100%; display: flex; align-items: center; gap: 1rem; flex-wrap: wrap; }
.fig-views { display: inline-flex; align-items: center; gap: 0.6rem; }
.fig-views label {
  display: inline-flex; align-items: center; gap: 0.25rem;
  font-family: var(--mono); font-size: 0.8rem; color: var(--ink-soft); cursor: pointer;
}
.fig-views input { margin: 0; cursor: pointer; }

/* The apparatus view (sandbox third view; src/rig.js). The wrap sits as a
   sibling of .cell-assembly inside the square .stage - absolutely centred,
   so switching views never reflows the console - and OUTSIDE the assembly,
   so --cell-scale cannot double-apply (the rig scales its vessel in-scene). */
.rig-stage-wrap {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
}
.rig-stage-wrap[hidden] { display: none; }
/* Apparatus view keeps the stage SQUARE (the dish default). The rig is round
   when you orbit it overhead, so it needs equal room both ways; a wide box
   fills edge-to-edge at the default tilt but clips the floor disc the moment
   you turn it face-on. Square + a fixed bounding-sphere fit (rig.js) means the
   whole rig stays in frame at every angle, at the cost of a little sky above
   and below the wide default view - the owner's call: no truncation over fill.
   Kept as an explicit rule (equal specificity to the base .stage would lose to
   source order) even though the value now matches the square default. */
.stage.stage--rig { aspect-ratio: 1; }
/* Stacked radio lists (sandbox view + surface groups): a three-option
   group wraps raggedly inline; a deliberate column reads as a list. */
.view-toggle--stack label { display: block; margin-right: 0; margin-bottom: 0.15rem; }

/* Tools page - the sweep bench. New classes only; controls reuse the
   generic .control/.view-toggle registers as markup. */
.toolsfig { margin: 1.8rem 0 2.6rem; }
.tools-grid {
  display: grid;
  /* A column must clear the longest nowrap radio label - "damped
     Mathieu (Floquet) - Ac per point" measures 24.9rem - so 25rem
     minimum: the full shell holds exactly TWO ~32rem columns (the
     sandbox setup band's pattern) and can never pack a third that
     would collide labels across columns. */
  grid-template-columns: repeat(auto-fit, minmax(25rem, 1fr));
  column-gap: 2.4rem;
  row-gap: 0.4rem;
  align-items: start;
}
.tools-grid .control { margin-top: 0.9rem; }
.tools-grid select { width: 100%; max-width: 14rem; }
.tools-grid input[type="number"] { width: 6.2rem; }
.tools-grid .view-toggle { margin-top: 0; }
/* The fluid control spans the grid: the sandbox's .fluid-row (select +
   three parameter boxes) needs ~32rem, wider than one 16rem column. The
   input width override must sit AFTER .tools-grid input[type="number"]
   above: equal specificity, source order decides - without it the
   6.2rem tools width shrinks the 6.7rem field boxes and the "Surface
   tension" label overflows its box again. */
.tools-grid .control--fluid { grid-column: 1 / -1; }
.tools-grid .field-grid input { width: 6.7rem; }
/* Inside the card the five fields SPREAD to fill its width (owner:
   "space those boxes more, they fill the space and it looks better") -
   space-between reads intentional against the card frame, where on the
   bare grid it read scattered. The gap is the minimum when wrapping. */
.tools-grid .fluid-row { justify-content: space-between; gap: 1.15rem; flex-wrap: wrap; }

/* Card panel (owner mockup, session 7): each control group is a
   numbered card - hairline outline on the open paper (fill rejected by
   A/B); every rule is scoped to .tools-grid so the sandbox registers
   stay untouched. The base .tools-grid rule above sets align/gaps -
   this later block re-tunes them for cards (equal specificity, source
   order decides). */
.tools-grid { align-items: stretch; row-gap: 1.3rem; column-gap: 1.3rem; }
.tools-grid > .control {
  margin-top: 0;
  /* No fill - OWNER DECISION (A/B against the filled #f3f6f8 card and
     a borderless variant, session 7): the hairline outline speaks the
     site's rule idiom. Do not reintroduce the fill. */
  background: transparent;
  border: 1px solid var(--hairline);
  border-radius: 10px;
  padding: 1.05rem 1.35rem 1.2rem;
}
.card-idx { color: var(--ink-faint); font-weight: 400; margin-right: 0.55rem; }
/* Cards are flex columns so a lone control row can CENTRE in the slack
   left when the neighbour card is taller (margin auto): the header
   stays anchored top, the control balances the frame, left alignment
   kept. Applies to the single-row cards (R, drive range); no-op when
   there is no slack. */
.tools-grid > .control { display: flex; flex-direction: column; }
.tools-grid .tool-field, .tools-grid .tool-range { margin: auto 0; padding: 0.7rem 0; }
/* Exact centring: the label's own bottom margin (11.2px) would bias
   the auto-margin split; zero it in these cards - the field's block
   padding above restores the label gap when there is no slack.
   Browsers without :has() just centre a few px low - acceptable. */
.tools-grid > .control > .control-label:has(+ .tool-field),
.tools-grid > .control > .control-label:has(+ .tool-range) { margin-bottom: 0; }
/* Word labels go uppercase under the boxes; UNITS STAY lowercase-correct
   (MN/M would read as meganewtons per metre - SI case carries meaning). */
.tools-grid .field-name { text-transform: uppercase; letter-spacing: 0.05em; }
/* flex: none kills the sandbox's .fluid-row select flex-basis (9rem):
   inside the column-flex field label that basis would set HEIGHT, not
   width - a 144px-tall select. */
.tools-grid .field-grid select { width: 11rem; margin-bottom: 0; flex: none; }
.unit-after { color: var(--ink-faint); margin-left: 0.5rem; }
/* Doubled selector: the base .tool-btn rule sits LATER in the file, so
   equal specificity would lose to source order (the standing lesson). */
.tool-btn.tool-btn--primary { background: var(--ink); color: var(--paper); border-color: var(--ink); }
.tool-btn.tool-btn--primary:hover:not(:disabled) { background: var(--ink-soft); border-color: var(--ink-soft); }
.tool-btn.tool-btn--primary:disabled { background: var(--ink-faint); border-color: var(--ink-faint); color: var(--paper-deep); }
.tool-status::before {
  content: '●';
  font-size: 0.62em;
  margin-right: 0.5rem;
  vertical-align: 0.14em;
  color: var(--ink-faint);
}
.tool-unit { color: var(--ink-faint); text-transform: none; }
.tool-range { display: flex; align-items: center; gap: 0.5rem; }
/* display:flex outranks the UA [hidden] rule by source order, so the
   at-drive row needs the hide restated (session-7 gotcha). */
.tool-range[hidden] { display: none; }
.tool-range span { color: var(--ink-faint); }
/* margin-bottom 0: the base select margin (0.55rem) rides the flex
   centring up ~4px against the margin-less number inputs. */
.tool-range select { width: auto; margin-bottom: 0; }
/* The degree sign hugs the robin theta box: the base input margin-right
   (6.4px) plus collapsed markup whitespace read as a 16px gap. */
.tools-grid .view-toggle input[type="number"] { margin-right: 0.3rem; }
.tool-run {
  display: flex;
  align-items: center;
  gap: 1rem;
  margin: 1.6rem 0 0.4rem;
  padding-top: 1.2rem;
  border-top: 1px solid var(--hairline-soft);
}
.tool-btn {
  font-family: var(--mono);
  font-size: var(--mono-size);
  color: var(--ink);
  background: var(--paper-deep);
  border: 1px solid var(--hairline);
  border-radius: 2px;
  padding: 0.35rem 1rem;
  cursor: pointer;
}
.tool-btn:hover:not(:disabled) { border-color: var(--ink-soft); }
.tool-btn:disabled { color: var(--ink-faint); cursor: default; }
.tool-status { color: var(--ink-faint); }
.rig-canvas { display: block; width: 100%; height: auto; cursor: grab; touch-action: none; }
.rig-canvas:active { cursor: grabbing; }
.datafig .fig-chart .l-tick-text,
.datafig .fig-chart .l-win-text { font-size: 22px; }

/* Gallery - the mode atlas. An (m, n) table of live-rendered mode tiles; the
   canvases carry their own transparent-outside-rim disc, so cells need no
   frame - the discs sit straight on the page like the dish does. Wider than
   the column only on narrow viewports, where .wall-scroll takes over. (The
   .wall-* class names predate the "atlas" rename and were left as-is.) */
.wallfig { margin: 1.8rem 0 2.4rem; }
.wall-scroll { overflow-x: auto; padding-bottom: 0.4rem; }
.wall {
  border-collapse: collapse;
  margin: 0.4rem auto 0;
}
.wall th,
.wall td { border: 1px solid var(--hairline); }
.wall-h {
  font-family: var(--mono);
  font-size: var(--mono-size);
  font-weight: 400;
  color: var(--ink-soft);
  text-align: center;
  padding: 0.3rem 0.55rem;
}
/* Ruled corner: a diagonal slash splits the header cell, n (columns) in the
   upper-right triangle, m (rows) in the lower-left. */
.wall-corner { position: relative; min-width: 2.7rem; height: 2.2rem; padding: 0; }
.wall-corner-slash { position: absolute; inset: 0; width: 100%; height: 100%; }
.wall-corner-slash line {
  stroke: var(--hairline);
  stroke-width: 1;
  vector-effect: non-scaling-stroke;
}
.wall-corner-col,
.wall-corner-row {
  position: absolute;
  font-family: var(--serif);
  font-style: italic;
  font-size: 0.92rem;
  color: var(--ink-soft);
  line-height: 1;
}
.wall-corner-col { top: 5px; right: 7px; }   /* m names the columns */
.wall-corner-row { bottom: 6px; left: 7px; } /* n names the rows */
.wall-cell { padding: 6px; }
.wall-cell--empty { min-width: 96px; }

/* Tiles are buttons into the lightbox. */
.wall-tile {
  display: block;
  cursor: pointer;
  border-radius: 50%;
  transition: transform 0.12s ease;
}
.wall-tile:hover { transform: scale(1.05); }
.wall-tile:focus-visible { outline: 2px solid var(--ink); outline-offset: 3px; }
@media (prefers-reduced-motion: reduce) { .wall-tile { transition: none; } }

/* Lightbox: the animated dish over the wall, so a shape can be watched moving -
   and orbited in 3-D - without leaving the page. Closing returns you to the
   same tile, so you never lose your place on the wall. */
.lightbox {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 1.5rem;
}
.lightbox[hidden] { display: none; }
.lightbox-backdrop { position: absolute; inset: 0; background: rgba(18, 20, 24, 0.55); }
.lightbox-panel {
  position: relative;
  width: 100%;
  max-width: min(92vw, 32rem);
  background: var(--paper);
  border: 1px solid var(--hairline);
  border-radius: 7px;
  box-shadow: 0 20px 64px rgba(0, 0, 0, 0.4);
  padding: 0.9rem 1.15rem 1.2rem;
}
.lightbox-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
}
.lightbox-title { margin: 0; font-size: 1.1rem; color: var(--ink); }
.lightbox-close {
  border: 0;
  background: none;
  cursor: pointer;
  font-size: 1.6rem;
  line-height: 1;
  color: var(--ink-soft);
  padding: 0 0.15rem;
}
.lightbox-close:hover { color: var(--ink); }
.lightbox-close:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; }
.lightbox .lightbox-stage { width: min(100%, 56vh); max-width: 25rem; margin: 0.4rem auto 0; }
.lightbox-controls {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.6rem 1.4rem;
  flex-wrap: wrap;
  margin-top: 0.7rem;
}
.lightbox .view-toggle { margin: 0; }
.lightbox-speed { display: flex; align-items: center; gap: 0.5rem; }
.lightbox-speed input[type="range"] {
  width: 8rem;
  max-width: 42vw;
  accent-color: var(--ink);
  cursor: pointer;
}
.lightbox-speed-val { color: var(--ink-soft); min-width: 2.6rem; text-align: right; }
.lightbox-meta { margin: 0.7rem 0 0; color: var(--ink-faint); font-size: 0.82rem; }
.lightbox-config { margin: 0.15rem 0 0; color: var(--ink-faint); font-size: 0.78rem; }

.stage {
  position: relative;
  width: 100%;
  max-width: 42rem;
  aspect-ratio: 1;
  margin: 0 auto;
}

/* Sandbox: the dish-and-ring assembly scales linearly with the cell
   radius (100 mm = full stage) inside the fixed stage footprint, so a
   small cell LOOKS small. The landing page has no assembly wrapper and
   renders at full size. */
.cell-assembly {
  position: absolute;
  inset: 0;
  /* Horizontally centred, anchored to the top: a small cell keeps its
     0-degree mark level with the readout instead of sinking mid-stage. */
  margin: 0 auto;
  width: calc(var(--cell-scale, 1) * 100%);
  height: calc(var(--cell-scale, 1) * 100%);
}

.ring {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

.ring-line { fill: none; stroke: var(--hairline); stroke-width: 1; }
.tick { stroke: var(--hairline-soft); stroke-width: 1; }
.tick-medium { stroke: var(--hairline); }
.tick-major { stroke: var(--ink-soft); stroke-width: 1.4; }

.tick-label {
  font-family: var(--mono);
  /* viewBox units (1000): renders the mono size at the 672px stage */
  font-size: 23.3px;
  fill: var(--ink-faint);
}

.dish {
  position: absolute;
  inset: 12.4%;
  border-radius: 50%;
  /* Must equal the renderer's zero-field colour: paperTinted() lands
     the colormap's nodal white exactly on --paper, so the field
     continues seamlessly to the rim, as in the research pattern_sweep
     figures. */
  background: var(--paper);
  box-shadow: 0 0 0 1px rgba(27, 27, 31, 0.45);
}

.dish canvas {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  display: block;
}

.gl-fallback {
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  padding: 14%;
  color: var(--ink-soft);
  text-align: center;
}

/* ---------- readout panel ---------- */

.panel { min-width: 0; }

.panel-title {
  margin: 0 0 0.6rem;
  font-size: 1em; /* an h2 in role only - body size, not UA heading size */
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.readouts {
  margin: 0 0 1.6rem;
  border-top: 1px solid var(--hairline);
}

.ro-row {
  display: flex;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.42rem 0.1rem;
  border-bottom: 1px solid var(--hairline-soft);
}

.ro-row dt { color: var(--ink-soft); }
.ro-row dd { margin: 0; font-weight: 500; }

.ro-em { background: var(--paper-deep); }
.ro-em dd { font-weight: 700; }

/* Playback speed slider, riding in its readout row (owner, session 9). It
   grows to fill the middle between the "playback" label and the factor, so
   the row reads label - slider - number. A faint centre tick marks the
   strobe-safe default (the log-symmetric "1x" detent, main.js). Compact:
   shorter than the page sliders so the row height is unchanged. */
/* Selectors carry input[type="range"] so they outrank the page-slider block
   above (0,1,1); a bare .ro-speed (0,1,0) loses to it and the row inflates. */
.ro-row--speed { align-items: center; }
input[type="range"].ro-speed {
  flex: 1;
  min-width: 3.5rem;
  width: auto;
  height: 16px;
  /* centre detent tick, behind the 2px track the thumb pseudo-elements draw */
  background: linear-gradient(var(--hairline-soft), var(--hairline-soft))
    50% / 1px 9px no-repeat;
}
input[type="range"].ro-speed::-webkit-slider-thumb { width: 11px; height: 11px; margin-top: -4.5px; border-width: 2px; }
input[type="range"].ro-speed::-moz-range-thumb { width: 11px; height: 11px; border-width: 2px; }

/* Reset-to-default control: a small circular-arrow button that appears only
   once the slider is off-centre (main.js toggles [hidden]), so the row is
   clean at the default and the reset shows up exactly when it is useful.
   Double-clicking the slider resets too, matching the 3-D orbit idiom. */
.ro-reset {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  appearance: none;
  border: 0;
  background: none;
  padding: 0;
  margin: 0 -0.15rem 0 0.15rem;
  color: var(--ink-faint);
  cursor: pointer;
}
.ro-reset:hover { color: var(--ink); }
.ro-reset:focus-visible { outline: 2px solid var(--ink); outline-offset: 2px; border-radius: 3px; }
.ro-reset[hidden] { display: none; }

/* ---------- slider ---------- */

.control-label {
  display: block;
  font-weight: 700;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-soft);
  margin-bottom: 0.7rem;
}

input[type="range"] {
  width: 100%;
  margin: 0;
  appearance: none;
  -webkit-appearance: none;
  height: 28px;
  background: transparent;
  cursor: pointer;
}

input[type="range"]::-webkit-slider-runnable-track {
  height: 2px;
  background: var(--ink);
}

input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  appearance: none;
  width: 16px;
  height: 16px;
  margin-top: -7px;
  border-radius: 50%;
  background: var(--paper);
  border: 2.5px solid var(--ink);
}

input[type="range"]::-moz-range-track {
  height: 2px;
  background: var(--ink);
}

input[type="range"]::-moz-range-thumb {
  width: 12px;
  height: 12px;
  border-radius: 50%;
  background: var(--paper);
  border: 2.5px solid var(--ink);
}

input[type="range"]:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 4px;
}

/* Scale marks sit at their true value fraction --p of the track,
   corrected for the thumb's half-width inset (16px thumb), so the
   numbers line up with where the thumb actually is at that value.
   Units live in the label's live value, not on the axis. */
.slider-scale {
  position: relative;
  height: 1.1em;
  color: var(--ink-faint);
  margin-top: 0.35rem;
}

.slider-scale span {
  position: absolute;
  top: 0;
  left: calc(var(--p, 0) * (100% - 16px) + 8px);
  transform: translateX(-50%);
}

.hint {
  margin: 0.9rem 0 0;
  color: var(--ink-faint);
}

.control { margin-top: 1.3rem; }

.out {
  float: right;
  font-weight: 400;
  letter-spacing: 0;
  text-transform: none;
  color: var(--ink-faint);
}

select,
input[type="number"] {
  font-family: var(--mono);
  font-size: var(--mono-size);
  color: var(--ink);
  background: var(--paper);
  border: 1px solid var(--hairline);
  padding: 0.25rem 0.4rem;
  border-radius: 2px;
}

select { margin-bottom: 0.55rem; }

/* Fluid: the selector and the three parameter boxes share one row, each box's
   word and unit stacked BELOW it. For the four to read as evenly spaced, the
   select and the three boxes are ALL direct flex items of .fluid-row (the
   .field-grid lays out no box of its own - display:contents) so a single
   justify-content:space-between splits the free width into three equal gaps.
   Each box is sized to hold its own label: the widest, "Surface tension"
   (~6.5rem), overflowed the old 5.2rem box and opened a phantom gap after it -
   which is exactly what made the boxes look unevenly spaced. */
.fluid-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 0.9rem;
}
/* Selector trimmed to the common fluid names; the longest few - the silicone
   oils and the water-glycerol ratios - clip in the closed box but show in full
   in the open dropdown. Narrowing it frees the width the three boxes spread
   into. */
.fluid-row select { flex: 0 0 9rem; min-width: 0; margin-bottom: 0; }
.field-grid { display: contents; }
.field-grid label {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  color: var(--ink-faint);
}
.field-name { font-size: 0.72rem; line-height: 1.25; margin-top: 0.3rem; white-space: nowrap; }
.field-unit { font-size: 0.72rem; line-height: 1.25; }
.field-grid input {
  width: 6.7rem;
  flex: none;
  appearance: textfield;
  -moz-appearance: textfield;
}
.field-grid input::-webkit-outer-spin-button,
.field-grid input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

.view-toggle {
  border: 0;
  margin: 1.4rem 0 0;
  padding: 0;
}

.view-toggle legend { padding: 0; }

.view-toggle label {
  font-family: var(--mono);
  font-size: var(--mono-size);
  margin-right: 1.2rem;
  white-space: nowrap; /* radio labels never break mid-text */
  cursor: pointer;
}

/* Contact-line fieldset: three options outgrew one row - stack them,
   with the robin angle input inline on its own line. */
.bc-toggle label { display: block; margin: 0 0 0.35rem; }
.bc-toggle input[type="number"] { width: 3.8rem; margin: 0 0.15rem; }
.bc-toggle input[type="number"]:disabled { opacity: 0.45; }

.view-toggle input {
  accent-color: var(--ink);
  margin-right: 0.4rem;
}

/* Radios on paper: the native control's white fill is the one surface
   that ignores --paper, so draw it ourselves. */
.view-toggle input[type="radio"] {
  appearance: none;
  width: 0.85em;
  height: 0.85em;
  border: 1px solid var(--ink-soft);
  border-radius: 50%;
  background: var(--paper);
  vertical-align: -0.08em;
}
.view-toggle input[type="radio"]:checked {
  border-color: var(--ink);
  background: radial-gradient(circle closest-side,
    var(--ink) 0 55%, var(--paper) 62%);
}
.view-toggle input[type="radio"]:focus-visible {
  outline: 2px solid var(--ink-soft);
  outline-offset: 1px;
}

/* Perspective view: the tilted surface floats in the stage; the circular
   dish treatment belongs to the top-down figure only. The goniometer ring
   is drawn in-scene by the renderer, so the flat SVG one hides. */
.dish--3d {
  background: transparent;
  box-shadow: none;
}

.dish--3d canvas {
  border-radius: 0;
  cursor: grab;
  touch-action: none;
}

.dish--3d canvas:active { cursor: grabbing; }

.stage--3d .ring { visibility: hidden; }

/* ---------- caption & prose ---------- */

.caption {
  margin-top: 1.7rem;
  padding-top: 0.9rem;
  border-top: 1px solid var(--hairline-soft);
  line-height: 1.6;
  color: var(--ink-soft);
  max-width: var(--measure);
  text-align: justify; /* LaTeX-style captions, site-wide (shared by every page) */
}

.figlabel {
  font-weight: 700;
  color: var(--ink);
  font-variant-caps: small-caps;
  letter-spacing: 0.03em;
}

.prose { padding: 2.4rem 0 3rem; }
/* Running text is justified like the captions (LaTeX-style, site-wide).
   Scoped to paragraphs: headings are single-line and the gallery's atlas
   table lives inside a .prose section - neither should inherit justify. */
.prose p { text-align: justify; }

.prose h2 {
  font-size: 1.25rem;
  margin: 0 0 0.7rem;
}

.prose p { max-width: var(--measure); margin: 0 0 1rem; }

.prose-fine { color: var(--ink-soft); }

/* ---------- footer ---------- */

.footer {
  border-top: 1px solid var(--hairline);
  padding: 1.1rem 0 2.2rem;
  color: var(--ink-faint);
}

.footer-row {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 0.5rem 2rem;
}

.footer p { margin: 0; }

/* ---------- stub pages ---------- */

.stub { padding-block: 3.2rem 4rem; }

.stub a, .prose a, .caption a {
  color: var(--ink);
  text-decoration: underline;
  text-underline-offset: 2px;
}

.stub h1 { margin: 0 0 0.6rem; font-size: 1.8rem; }

.stub .badge {
  display: inline-block;
  font-family: var(--mono);
  font-size: var(--mono-size);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-soft);
  border: 1px solid var(--hairline);
  padding: 0.2rem 0.55rem;
  margin-bottom: 1.2rem;
}

.stub p { max-width: var(--measure); color: var(--ink-soft); }

/* ---------- responsive ---------- */

/* Below the full shell there is no width at which the panel column can
   hold the longest radio label, so the two-column figure exists only at
   the full shell; everything narrower stacks. */
@media (max-width: 69rem) {
  .fig1--console {
    grid-template-columns: 1fr;
    grid-template-areas: "render" "panel" "setup" "caption";
    grid-template-rows: auto; /* single column: no spanning panel to absorb */
    row-gap: 1.8rem;
  }
  .panel { max-width: 30rem; margin: 0 auto; width: 100%; }
  .fig1--console > .fig-setup { max-width: 30rem; margin: 0 auto; width: 100%; }
  .fig-setup { grid-template-columns: 1fr; padding-top: 1.4rem; }
}

/* Overview: the readout column is narrow, so the two-column figure holds
   well below the shell; stack it back into one column only when the dish
   would be squeezed (dish, then readout, then caption). */
@media (max-width: 52rem) {
  .fig1--showcase .fig-grid {
    grid-template-columns: 1fr;
    max-width: 42rem;
    row-gap: 1.6rem;
  }
  .fig1--showcase .panel { max-width: 30rem; margin: 0 auto; width: 100%; }
}

/* Phones are physically narrower than the longest radio label; only
   there may it wrap, hanging so continuation lines align with the text
   rather than the radio. */
@media (max-width: 26rem) {
  .view-toggle label {
    white-space: normal;
    padding-left: 2em;
    text-indent: -2em;
  }
}

/* ---------- touch devices ---------- */

@media (hover: none) and (pointer: coarse) {
  /* iOS Safari zooms the whole page when a focused field's text is
     under 16px and does not zoom back; field text rides at 16px on
     touch screens to defeat it (the register's 15.64px elsewhere is
     untouched). */
  select,
  input[type="number"] {
    font-size: max(16px, var(--mono-size));
  }

  /* Fingers, not pointers: the slider's hit area meets the 44px
     touch-target minimum and the thumb grows to match. The scale-mark
     inset follows the wider thumb (16px elsewhere). */
  input[type="range"] { height: 44px; }
  input[type="range"]::-webkit-slider-thumb {
    width: 24px;
    height: 24px;
    margin-top: -11px;
  }
  input[type="range"]::-moz-range-thumb {
    width: 20px;
    height: 20px;
  }
  .slider-scale span {
    left: calc(var(--p, 0) * (100% - 24px) + 12px);
  }
}

/* Narrow screens: the mode charts keep a legible size and pan sideways
   inside their own row instead of shrinking into illegibility (5px
   text at phone width). The page itself never scrolls horizontally. */
@media (max-width: 48rem) {
  .ladder-row {
    overflow-x: auto;
    -webkit-overflow-scrolling: touch;
  }
  .mode-ladder { min-width: 40rem; }
}

/* References page: journal back-matter setting. TWO COLUMNS at the
   full shell (the single 69rem column read stringy and left DOIs
   dangling on lone lines - owner-rejected twice); hanging indents;
   RAGGED RIGHT by design (justify makes whitespace rivers of the
   short last lines and unbreakable DOIs) - refs sit with the
   standfirst/hints/footer in the ragged register. Entries never
   break across columns. */
/* Validation panel (physics page): the receipts ledger. Wide numeric
   rows pan inside the wrap on narrow screens (house pattern). */
.vcheck-wrap { margin: 1.3rem 0; overflow-x: auto; }
.vcheck-run { display: flex; align-items: center; gap: 1rem; margin: 0 0 1rem; }
.vcheck-status { color: var(--ink-faint); }
.vcheck { border-collapse: collapse; width: 100%; min-width: 44rem; }
.vcheck th, .vcheck td {
  text-align: left;
  padding: 0.42rem 1.1rem 0.42rem 0;
  border-bottom: 1px solid var(--hairline-soft);
  vertical-align: baseline;
}
.vcheck thead th {
  letter-spacing: 0.14em;
  text-transform: uppercase;
  font-weight: 400;
  color: var(--ink-faint);
  border-bottom: 1px solid var(--hairline);
}
.vcheck .vc-group th {
  padding-top: 1.05rem;
  font-weight: 700;
  border-bottom: 1px solid var(--hairline);
}
.vcheck .vc-src { font-weight: 400; color: var(--ink-faint); }
.vcheck .vc-num { white-space: nowrap; }
.vcheck .vc-tick { text-align: right; }
.vcheck .vc-fail td { font-weight: 700; }

/* References list, the settled form (a four-round owner iteration -
   decision log): SINGLE column, each entry a flat-left ragged block
   spanning the full shell, separated by whitespace alone. Hanging
   indents, justify, two columns and hairline rules were ALL tried and
   rejected - do not reintroduce any of them. */
.refs p {
  margin: 0;
  padding: 0.7rem 0;
  text-align: left;
}
.refs a { overflow-wrap: anywhere; }
