:root {
  --bg: #ffffff;
  --fg: #111111;
  --muted: #6b6b6b;
  --line: #2a2a2a;
  --line-soft: #d6d6d6;
  --accent: #1a73e8;

  /* Page surface / drawer surface tokens. `--page-bg` is applied to
     <body> only at the desktop breakpoint so the centered puzzle
     reads as a contained surface against a soft-gray field; mobile
     stays full-bleed white. `--bg-soft` is the drawer's daily-section
     panel. */
  --page-bg: #f1f1ef;
  --bg-soft: #f7f7f5;

  /* Per-shape-type colors */
  --c-circle:   #1a73e8;  /* blue */
  --c-lens:     #e8731a;  /* orange */
  --c-triangle: #d4361a;  /* red */
  --c-square:   #2da94f;  /* green */
  --c-pentagon: #8a3fc4;  /* purple */
  --c-hexagon:  #b07d10;  /* gold */

  /* Soft tints used by drawer polygon icons (the same palette as
     the puzzle shapes, but lighter for chip-sized contexts). */
  --c-circle-soft:   #dde9fb;
  --c-lens-soft:     #fef3c7;
  --c-triangle-soft: #fbe8e3;
  --c-square-soft:   #dff1e3;
  --c-pentagon-soft: #ece1f4;
  --c-hexagon-soft:  #fcecd4;

  --bond:    #1a1a1a;
}

* {
  box-sizing: border-box;
}

html, body {
  margin: 0;
  padding: 0;
  background: var(--bg);
  color: var(--fg);
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
}

html { height: 100%; }

body {
  min-height: 100%;
  display: flex;
  flex-direction: column;
}

.app {
  max-width: 1100px;
  width: 100%;
  margin: 0 auto;
  padding: 0.4rem 0.75rem;
  display: flex;
  flex-direction: column;
  flex: 1 0 auto;
  gap: 0.6rem;
}

/* ── Header (compact, single row) ──────────────────────────────── *
 * Three-column grid: left toolbar | centered title | right actions.
 * `minmax(0, 1fr)` (rather than plain `1fr`) is what keeps the title
 * truly centered — without it, an oversized left/right column grows
 * past its 1fr share and shoves the auto-sized title sideways. The
 * left/right contents always stay justified to their respective
 * edges via justify-self start/end, and `min-width: 0` on each side
 * lets them shrink (rather than overflow) on narrow screens.
 */

.app__header {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  align-items: center;
  gap: 0.5rem;
  padding: 0.4rem 0;
  min-height: 44px;
}

.app__header h1,
.app__header .app__title {
  /* Absolute-center the wordmark so asymmetric left/right widths can
     never push it off center. The grid columns still allocate space
     for the left/right groups. */
  position: absolute;
  left: 50%;
  top: 50%;
  transform: translate(-50%, -50%);
  margin: 0;
  font-size: 1.35rem;
  font-weight: 800;
  letter-spacing: 0.21em;
  line-height: 1;
  text-transform: uppercase;
  color: var(--fg);
  white-space: nowrap;
  pointer-events: none;
}

/* Header left/right grid cells. Left holds the pill + flyout (and on
   the Learn page, the puzzle picker too). Right holds Reset (when a
   puzzle is loaded) plus the `?` help button. Both are flex rows
   forced to a single line — the title stays centered as long as
   neither side is wider than the column allows. */
.header-toolbar--left {
  grid-column: 1;
  justify-self: start;
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  gap: 8px;
  min-width: 0;
  position: relative;
}

.header-actions {
  grid-column: 3;
  justify-self: end;
  display: flex;
  align-items: center;
  flex-wrap: nowrap;
  gap: 6px;
  min-width: 0;
}

.app__main {
  flex: 1 0 auto;
  display: flex;
  flex-direction: column;
}

/* `.app__footer` was retired — the copyright now lives in the drawer
   footer (see `.drawer__copyright`). About / Admin pages still render
   their own page footer locally. */
.app__footer {
  text-align: center;
  font-size: 0.7rem;
  color: var(--muted);
  padding: 0.25rem 0;
}

/* ── Sections (one shown per tab) ──────────────────────────────── */

.section {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
}

.section--puzzle {
  flex: 1;
  justify-content: center;
  gap: 0.4rem;
}

/* Desktop: maintain a constant canvas aspect ratio (1075 × 700, the
   1440p design) across all viewport heights. The whole canvas+
   spectrum stack scales together — narrower with side margins on
   shorter screens — and never squishes vertically. Below the floor
   the page scrolls instead of shrinking further.

   Critical: canvas-wrap uses `aspect-ratio` rather than letting flex
   compute its height from "fill remaining space". A flex-derived
   height is coupled to `section_height` (= 100vh − chrome), so any
   drift between the formula's assumed chrome and the actual rendered
   chrome creates a small phase where canvas height alone drifts.
   Locking aspect-ratio detaches canvas height from flex distribution
   — height is *always* section_width / 1.535, regardless of chrome.

   Layout shape: header sits flush above the canvas, padding around
   the whole card is consistent, and at large heights the card stops
   growing — `margin: auto` on `.app` centers it within the body and
   gray page-bg appears above and below. At the cutover, content
   exactly fills the viewport (gray gone). Below cutover the page
   scrolls. To make this work the .app/.puzzle-shell/.app__main
   stack does NOT flex-grow on desktop (mobile still does, so the
   canvas fills the phone viewport). The section's content height is
   driven entirely by `aspect-ratio` on the canvas-wrap and the
   spectrum's viewBox, both keyed off section_width.

   Formula derivation (so future-you can re-tune without spelunking):
     canvas_aspect   = 1075 / 700 = 1.535       (1440p design)
     spectrum_aspect = 1100 / 270 = 4.07        (spectrum SVG viewBox)
     K = canvas_aspect / (1 + canvas_aspect/spectrum_aspect)
       = 1.535 / 1.377 = 1.115
     section_width = (section_height - section_gap) × K
                   = (100vh - chrome - 6px) × 1.115
   Chrome on desktop: body vertical padding (16+16 = 32px) + header
   (~44px) + app padding + puzzle-shell gap (~22px) + section gap
   (6px) + section vertical padding (16+16 = 32px) ≈ 144 px
   subtracted from 100vh. The body padding is the minimum gray frame
   around the card; the section padding gives the puzzle/spectrum
   breathing room from the card edge.

   The min-width anchors the cutover at 100vh ≈ 660 (just above 720p
   minus the gray frame): formula yields 575 there, so section
   freezes at that width and canvas height (= 575/1.535 ≈ 375)
   freezes too. Below that, content overflows section, page scrolls. */
@media (min-width: 768px) {
  /* Don't grow the card chain to fill the viewport — let it size to
     content and let `.app`'s auto margins center it vertically when
     there's extra space (gray frame above/below). */
  .app {
    flex: 0 0 auto;
    margin: auto;
  }
  .app__main { flex: 0 0 auto; }
  .puzzle-shell { flex: 0 0 auto; }

  .section--puzzle {
    flex: 0 0 auto;
    justify-content: flex-start;
    width: 100%;
    min-width: 575px;
    max-width: calc((100vh - 144px) * 1.115);
    align-self: center;
    padding: 0 18px 16px;
  }
  .section--puzzle .puzzle-canvas-wrap {
    flex: 0 0 auto;
    aspect-ratio: 1075 / 700;
    max-height: 700px;
    min-height: 0;
  }
  /* Inset the header chrome from the card's rounded edges so the
     toolbar pill / reset / help buttons don't touch the white card
     border. Mobile keeps the existing edge-flush layout. */
  .app__header {
    padding-left: 18px;
    padding-right: 18px;
  }
}

.section__hint {
  margin: 0;
  font-size: 0.8rem;
  color: var(--muted);
  line-height: 1.4;
}

/* ── Canvas ────────────────────────────────────────────────────── */

.canvas-wrap {
  position: relative;
  width: 100%;
  border: 1px solid var(--line-soft);
  border-radius: 12px;
  overflow: hidden;
  background: var(--bg);
}

/* Puzzle canvas-wrap fills available vertical space; spectrum below
   stays at its natural aspect ratio. The min-height keeps the canvas
   playable on short viewports — when the window can't fit canvas +
   spectrum + chrome, the page scrolls instead of squishing the canvas
   to nothing. */
.section--puzzle .puzzle-canvas-wrap {
  flex: 1 1 auto;
  min-height: 280px;
}

/* The puzzle SVG fills its wrap unconditionally. CSS percentage height
   on an SVG inside a flex item is unreliable on some mobile browsers —
   the SVG falls back to its viewBox's intrinsic aspect ratio, leaving
   empty space below in portrait wraps. position: absolute + inset: 0
   pins the SVG to the wrap's edges; aspect-ratio: auto suppresses the
   implicit ratio modern browsers derive from the viewBox attribute,
   which would otherwise override the explicit sizing. */
.section--puzzle .canvas--fill {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  aspect-ratio: auto;
}

.section--puzzle .canvas-wrap--spectrum {
  flex: 0 0 auto;
}

/* Desktop: lock the spectrum block to the design aspect so the rounded
   card never resizes when renderSpectrum extends the SVG viewBox
   upward to fit tall stacks (which would otherwise change the SVG's
   intrinsic ratio and ripple into the section/shell height). The wrap
   gets a fixed aspect ratio; spectrum-scroll fills it; the SVG itself
   gets width:100%; height:100% (overriding `.canvas { height: auto }`)
   so its preserveAspectRatio="xMidYMid meet" scales any over-tall
   content down to fit rather than pushing the box. The puzzle-shell's
   height is now driven entirely by the viewport-height formula, no
   content can perturb it. */
@media (min-width: 768px) {
  .section--puzzle .canvas-wrap--spectrum {
    aspect-ratio: 1100 / 270;
  }
  .section--puzzle .canvas-wrap--spectrum .spectrum-scroll {
    height: 100%;
  }
  .section--puzzle .canvas-wrap--spectrum .spectrum-scroll > .canvas {
    height: 100%;
  }
}

/* The spectrum SVG can grow wider than its container when the player
   adds enough groups to push the per-slot minimums past the design
   width. The .canvas-wrap--spectrum stays a non-scrolling positioning
   ancestor (so the overflow arrows pinned in its corners don't scroll
   with content); the inner .spectrum-scroll owns the actual horizontal
   scroll. touch-action: pan-x lets touch swipes scroll the wrap
   instead of being swallowed by the .canvas default touch-action:
   none (which is needed for the puzzle canvas's drag). */
.spectrum-scroll {
  overflow-x: auto;
  overflow-y: hidden;
  touch-action: pan-x;
}
.spectrum-scroll .canvas {
  touch-action: pan-x;
}

/* When spectrum content actually overflows (toggled from JS in the
   render path), force the horizontal scrollbar to stay visible so the
   player has a cue that there's more line off to the side. */
.spectrum-scroll--scrollable {
  overflow-x: scroll;
  scrollbar-width: thin;
  scrollbar-color: rgba(0, 0, 0, 0.3) rgba(0, 0, 0, 0.06);
  cursor: grab;
}
.spectrum-scroll--dragging {
  cursor: grabbing;
  user-select: none;
}
.spectrum-scroll--scrollable::-webkit-scrollbar {
  -webkit-appearance: none;
  height: 8px;
}
.spectrum-scroll--scrollable::-webkit-scrollbar-track {
  background: rgba(0, 0, 0, 0.06);
  border-radius: 4px;
}
.spectrum-scroll--scrollable::-webkit-scrollbar-thumb {
  background: rgba(0, 0, 0, 0.3);
  border-radius: 4px;
}

/* Overflow indicator arrows in the upper corners of the spectrum wrap.
   Only visible when there's actually content to scroll to in that
   direction; pulsing draws the eye to the cue. Clicking nudges the
   wrap by ~70% of its visible width. JS (attachOverflowArrows in
   dragScroll.js) toggles the .canvas-wrap--spectrum--overflow-left /
   --overflow-right classes that CSS keys off. */
.spectrum-arrow {
  position: absolute;
  top: 4px;
  width: 26px;
  height: 26px;
  border: 1px solid var(--line-soft);
  border-radius: 50%;
  background: rgba(255, 255, 255, 0.92);
  font-family: inherit;
  font-size: 18px;
  font-weight: 600;
  line-height: 1;
  color: var(--accent);
  cursor: pointer;
  display: none;
  align-items: center;
  justify-content: center;
  padding: 0;
  z-index: 3;
}
.spectrum-arrow--left  { left: 6px;  }
.spectrum-arrow--right { right: 6px; }
.canvas-wrap--spectrum--overflow-left  .spectrum-arrow--left,
.canvas-wrap--spectrum--overflow-right .spectrum-arrow--right {
  display: flex;
  animation: spectrum-arrow-pulse 1.4s infinite ease-in-out;
}
.spectrum-arrow:hover {
  background: #f0f4fb;
  border-color: var(--accent);
}

@keyframes spectrum-arrow-pulse {
  0%, 100% { transform: scale(1);    opacity: 0.85; }
  50%      { transform: scale(1.12); opacity: 1;    }
}

.canvas-zoom {
  position: absolute;
  bottom: 10px;
  right: 10px;
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 6px;
  z-index: 4;
}

.canvas-zoom__btn {
  width: 34px;
  height: 34px;
  font-size: 14px;
  font-family: inherit;
  background: var(--bg);
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  cursor: pointer;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  color: var(--fg);
  transition: background 0.1s, border-color 0.1s;
}
.canvas-zoom__btn:hover:not(:disabled) {
  background: #fafafa;
  border-color: var(--fg);
}
.canvas-zoom__btn:disabled {
  opacity: 0.35;
  cursor: default;
}
.canvas-hint-btn__icon { font-size: 14px; line-height: 1; }

/* Cleanup pill — sits at the bottom of the .canvas-zoom column;
   matches the .canvas-hint-btn pill on the opposite side. The
   parent's `align-items: flex-end` lets this widen leftward
   without stretching the +/- zoom buttons above. */
.canvas-cleanup-btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 14px 6px 12px;
  font-size: 0.78rem;
  font-family: inherit;
  background: linear-gradient(180deg, #f0f6fd 0%, #c8dcef 100%);
  border: 1px solid var(--fg);
  border-radius: 999px;
  cursor: pointer;
  color: var(--fg);
  transition: background 0.1s;
}
.canvas-cleanup-btn:hover { background: linear-gradient(180deg, #dde9fb 0%, #b6cee5 100%); }
.canvas-cleanup-btn__icon { flex: 0 0 auto; font-size: 14px; line-height: 1; }
.canvas-cleanup-btn__label {
  font-family: inherit;
  font-weight: 700;
  letter-spacing: 0.1em;
  font-size: 12px;
  text-transform: uppercase;
}

.canvas {
  display: block;
  width: 100%;
  height: auto;
  touch-action: none;
}

/* The puzzle canvas has tabindex="0" for keyboard pan/zoom, which
   makes the browser draw a focus ring + mobile tap-highlight when a
   player taps an empty area. Suppress both — the SVG stays focusable
   so existing interactions (drag, wheel/pinch zoom, keyboard) keep
   working; only the residual visual selection state is hidden. */
#puzzle-canvas { -webkit-tap-highlight-color: transparent; }
#puzzle-canvas:focus { outline: none; }

/* ── Toolbar ───────────────────────────────────────────────────── */

.toolbar {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  flex-wrap: wrap;
  padding: 0.55rem 0.75rem;
  background: var(--bg);
  border: 1px solid var(--line-soft);
  border-radius: 10px;
}

.toolbar__label {
  font-size: 0.8rem;
  color: var(--muted);
  margin-right: 0.15rem;
  white-space: nowrap;
}

.toolbar__sep {
  width: 1px;
  height: 1.4rem;
  background: var(--line-soft);
  margin: 0 0.3rem;
}

.palette-btn,
.toolbar-btn {
  font-size: 0.82rem;
  font-family: inherit;
  padding: 0.28em 0.7em;
  border: 1px solid var(--line-soft);
  border-radius: 6px;
  background: var(--bg);
  color: var(--fg);
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.1s, border-color 0.1s, color 0.1s;
}

/* Per-shape palette button colors */
.palette-btn--circle   { color: var(--c-circle); }
.palette-btn--lens     { color: var(--c-lens); }
.palette-btn--triangle { color: var(--c-triangle); }
.palette-btn--square   { color: var(--c-square); }
.palette-btn--pentagon { color: var(--c-pentagon); }
.palette-btn--hexagon  { color: var(--c-hexagon); }

.palette-btn:hover {
  background: #f6f8fc;
  border-color: currentColor;
}

.toolbar-btn:hover:not(:disabled) {
  background: #fff0f0;
  border-color: #e06060;
  color: #c00;
}

.toolbar-btn:disabled {
  opacity: 0.35;
  cursor: default;
}

/* ── Shapes ────────────────────────────────────────────────────── */

.shape {
  cursor: grab;
}
.shape:active {
  cursor: grabbing;
}

.shape__outline {
  fill: #fff;
  stroke: #1a1a1a;
  stroke-width: 2;
  stroke-linejoin: round;
}

.shape__vertex {
  fill: #1a1a1a;
  pointer-events: none;
}

.shape__vertex--free {
  opacity: 0.35;
}

.shape__vertex--bonded {
  opacity: 1;
}

.shape__label {
  font-size: 14px;
  font-weight: 600;
  fill: #1a1a1a;
  text-anchor: middle;
  dominant-baseline: central;
  pointer-events: none;
  user-select: none;
}

/* Circle group letters are larger — sized to fill most of the circle (r=14)
   while staying well clear of the stroke border for every letter A-Z. */
.shape__label--circle {
  font-size: 20px;
}

/* Per-shape colors via data attribute on the .shape <g> */
.shape[data-shape-type="circle"]   .shape__outline { stroke: var(--c-circle);   fill: color-mix(in srgb, var(--c-circle)   18%, white); }
.shape[data-shape-type="lens"]     .shape__outline { stroke: var(--c-lens);     fill: color-mix(in srgb, var(--c-lens)     18%, white); }
.shape[data-shape-type="triangle"] .shape__outline { stroke: var(--c-triangle); fill: color-mix(in srgb, var(--c-triangle) 18%, white); }
.shape[data-shape-type="square"]   .shape__outline { stroke: var(--c-square);   fill: color-mix(in srgb, var(--c-square)   18%, white); }
.shape[data-shape-type="pentagon"] .shape__outline { stroke: var(--c-pentagon); fill: color-mix(in srgb, var(--c-pentagon) 18%, white); }
.shape[data-shape-type="hexagon"]  .shape__outline { stroke: var(--c-hexagon);  fill: color-mix(in srgb, var(--c-hexagon)  18%, white); }

.shape[data-shape-type="circle"]   .shape__vertex,
.shape[data-shape-type="circle"]   .shape__label  { fill: var(--c-circle); }
.shape[data-shape-type="lens"]     .shape__vertex,
.shape[data-shape-type="lens"]     .shape__label  { fill: var(--c-lens); }
.shape[data-shape-type="triangle"] .shape__vertex,
.shape[data-shape-type="triangle"] .shape__label  { fill: var(--c-triangle); }
.shape[data-shape-type="square"]   .shape__vertex,
.shape[data-shape-type="square"]   .shape__label  { fill: var(--c-square); }
.shape[data-shape-type="pentagon"] .shape__vertex,
.shape[data-shape-type="pentagon"] .shape__label  { fill: var(--c-pentagon); }
.shape[data-shape-type="hexagon"]  .shape__vertex,
.shape[data-shape-type="hexagon"]  .shape__label  { fill: var(--c-hexagon); }

/* Selection halo */
.shape--selected .shape__outline {
  stroke-width: 3;
  filter: drop-shadow(0 0 5px rgba(26, 115, 232, 0.55));
}

/* ── Bonds ─────────────────────────────────────────────────────── */

.bond__line {
  stroke: var(--bond);
  stroke-width: 2.5;
  stroke-linecap: round;
  pointer-events: none;
}

/* Magnetic snap preview shown while dragging a shape near another */
.snap-preview {
  stroke: var(--accent);
  stroke-width: 2.5;
  stroke-dasharray: 6 4;
  stroke-linecap: round;
  pointer-events: none;
}

/* ── Win celebration ────────────────────────────────────────────────
   Activated by play.js adding `.canvas--celebrating` to the puzzle SVG
   on the unsolved → solved transition. Each .shape and .bond carries a
   per-element `--celebrate-delay` set by JS (further-from-centre =
   later) so the pulse ripples outward from the structure's centroid.
   pointer-events are off for the duration so input doesn't fight the
   animation. */

@keyframes spectrum-celebrate-shape {
  0%   { transform: scale(1); }
  35%  { transform: scale(1.32); }
  100% { transform: scale(1); }
}

@keyframes spectrum-celebrate-bond {
  0%, 100% { stroke-width: 2.5; stroke: var(--bond); }
  50%      { stroke-width: 5;   stroke: #2da94f; }
}

@keyframes spectrum-celebrate-glow {
  0%   { filter: none; }
  50%  { filter: drop-shadow(0 0 14px rgba(45, 169, 79, 0.55)); }
  100% { filter: none; }
}

/* Immediate "yes!" flash that runs from the moment the solve
   registers. Punchier than the trailing glow (higher peak alpha,
   tighter timing) so the feedback feels instant. The trailing
   glow then bookends the wave as it settles. */
@keyframes spectrum-celebrate-flash {
  0%   { filter: none; }
  30%  { filter: drop-shadow(0 0 18px rgba(45, 169, 79, 0.75)); }
  100% { filter: none; }
}

.canvas--celebrating {
  pointer-events: none;
  animation:
    spectrum-celebrate-flash 1100ms ease-out,
    spectrum-celebrate-glow 1100ms 2200ms ease-out;
}
/* Two animations per element: an immediate pulse (no delay) so every
   shape and bond pops together with the flash — "boom, the whole
   structure is locked in" — and the staggered wave that follows.
   When both animations target the same property, the later-declared
   one wins, so the wave smoothly takes over for elements whose wave
   delay falls inside the immediate window (i.e. the centroid). */
.canvas--celebrating .shape {
  transform-box: fill-box;
  transform-origin: center;
  animation:
    spectrum-celebrate-shape 1100ms ease-out,
    spectrum-celebrate-shape 1000ms var(--celebrate-delay, 0ms) ease-out;
}
.canvas--celebrating .bond__line {
  animation:
    spectrum-celebrate-bond 1100ms ease-out,
    spectrum-celebrate-bond 1000ms var(--celebrate-delay, 0ms) ease-out;
}

/* ── Spectrum number line ──────────────────────────────────────── */

.spectrum {
  background: #fafafa;
}

.spectrum__axis {
  stroke: var(--line);
  stroke-width: 1.25;
  stroke-linecap: square;
}

.spectrum__tick {
  stroke: var(--line);
  stroke-width: 1;
}

.spectrum__tick-label {
  font-size: 21px;
  fill: var(--muted);
  text-anchor: middle;
  dominant-baseline: hanging;
  font-family: inherit;
  user-select: none;
}

/* Mini canvas-circle: same blue-on-light-blue look, just smaller */
.spectrum__dot {
  fill: color-mix(in srgb, var(--c-circle) 18%, white);
  stroke: var(--c-circle);
  stroke-width: 1.5;
}

/* Group letter centred inside each spectrum circle */
.spectrum__dot-letter {
  font-size: 26px;
  font-weight: 700;
  fill: var(--c-circle);
  text-anchor: middle;
  dominant-baseline: central;
  font-family: inherit;
  user-select: none;
  pointer-events: none;
}

/* Faint square border surrounding each group's stack */
.spectrum__group-border {
  fill: none;
  stroke: var(--line-soft);
  stroke-width: 1;
}

/* Coupling partners listed beneath each group's column on the axis */
.spectrum__coupling-letter {
  font-size: 23px;
  font-weight: 600;
  fill: var(--muted);
  text-anchor: middle;
  dominant-baseline: central;
  font-family: inherit;
  user-select: none;
  pointer-events: none;
}

/* Realised coupling: bonded pair already exists in the player's
   structure. Bold + green + checkmark (the checkmark comes from the
   text content, not a pseudo-element — SVG <text> can't ::after). */
.spectrum__coupling-letter--realized {
  font-weight: 800;
  fill: #2da94f;
}

/* ── Puzzle ghost-fill spectrum ──────────────────────────────────── */

.spectrum__group-border--ghost {
  fill: none;
  stroke: var(--c-circle);
  stroke-width: 1;
  stroke-dasharray: 4 3;
  opacity: 0.45;
}

.spectrum__group-border--matched {
  fill: none;
  stroke: #2da94f;
  stroke-width: 1.5;
  opacity: 1;
}

.spectrum__dot--ghost {
  fill: white;
  stroke: var(--c-circle);
  stroke-width: 1.25;
  stroke-dasharray: 2 2;
  opacity: 0.55;
}

.spectrum__dot-letter--ghost {
  font-size: 26px;
  font-weight: 700;
  fill: var(--c-circle);
  text-anchor: middle;
  dominant-baseline: central;
  font-family: inherit;
  user-select: none;
  pointer-events: none;
  opacity: 0.55;
}

/* Filled-correct dot in the puzzle ghost spectrum: green to signal
   "this circle has been placed in the right group". When every dot
   plus every coupling letter is green, the puzzle is solved. */
.spectrum__dot--correct {
  fill: color-mix(in srgb, #2da94f 18%, white);
  stroke: #2da94f;
  stroke-width: 1.5;
}

.spectrum__dot-letter--correct {
  font-size: 24px;
  font-weight: 700;
  fill: #2da94f;
  text-anchor: middle;
  dominant-baseline: central;
  font-family: inherit;
  user-select: none;
  pointer-events: none;
}

.spectrum__dot--guess {
  fill: color-mix(in srgb, var(--c-circle) 18%, white);
  stroke: var(--c-circle);
  stroke-width: 1.5;
  stroke-dasharray: 1 2;
}

/* ── Send button + validation surface ───────────────────────────── */

.toolbar-btn--primary {
  border-color: var(--accent);
  color: var(--accent);
  font-weight: 600;
}

.toolbar-btn--primary:hover:not(:disabled) {
  background: #eef4fd;
  color: var(--accent);
  border-color: var(--accent);
}

.send-error {
  font-size: 0.82rem;
  color: #c00;
  background: #fff0f0;
  border: 1px solid #f3c0c0;
  border-radius: 6px;
  padding: 0.4rem 0.7rem;
}

/* ── Export / import modal ──────────────────────────────────────── */

.modal {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.4);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
}
.modal[hidden] { display: none; }

.modal__panel {
  background: var(--bg);
  border: 1px solid var(--line-soft);
  border-radius: 12px;
  padding: 1.25rem 1.5rem;
  max-width: 600px;
  width: 90%;
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
}
.modal__title {
  font-size: 1.05rem;
  font-weight: 700;
  margin: 0 0 0.6rem 0;
}
.modal__body {
  font-size: 0.9rem;
  color: var(--fg);
  margin: 0 0 0.75rem 0;
  line-height: 1.4;
}
.modal__text {
  width: 100%;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.82rem;
  border: 1px solid var(--line-soft);
  border-radius: 6px;
  padding: 0.5rem;
  resize: vertical;
  background: #fafafa;
}
.modal__error {
  font-size: 0.82rem;
  color: #c00;
  background: #fff0f0;
  border: 1px solid #f3c0c0;
  border-radius: 6px;
  padding: 0.4rem 0.7rem;
  margin-top: 0.5rem;
}
.modal__buttons {
  display: flex;
  gap: 0.5rem;
  justify-content: flex-end;
  margin-top: 0.75rem;
}
.puzzle-meta {
  font-size: 0.8rem;
  color: var(--muted);
  padding: 0.1rem 0.2rem;
}
.puzzle-meta strong {
  color: var(--fg);
  font-weight: 600;
}
.puzzle-meta__score {
  margin-left: 0.4rem;
  font-variant-numeric: tabular-nums;
  opacity: 0.7;
}

/* ── Puzzle popup + toolbar ─────────────────────────────────────── */

.puzzle-canvas-wrap {
  position: relative;
}

/* Hamburger + timer pill — minimal monochrome chrome that opens the
   left drawer. Black 1px hairline, white fill, fully rounded ends. */
.toolbar-pill {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  height: 28px;
  padding: 0 10px;
  font-family: inherit;
  font-size: 0.78rem;
  font-weight: 500;
  border: 1px solid var(--fg);
  border-radius: 999px;
  background: var(--bg);
  color: var(--fg);
  cursor: pointer;
  white-space: nowrap;
  transition: background 0.1s;
}
.toolbar-pill:hover { background: #fafafa; }
.toolbar-pill__icon {
  display: inline-block;
  flex: 0 0 auto;
  color: var(--fg);
}
.toolbar-pill__timer {
  font-variant-numeric: tabular-nums;
  font-size: 0.78rem;
  color: var(--muted);
  min-width: 4ch;
  text-align: left;
}
.toolbar-pill__label {
  font-size: 0.78rem;
  color: var(--muted);
  text-align: left;
}
.toolbar-pill--no-timer { padding: 0 12px; }
.toolbar-pill--no-timer .toolbar-pill__timer { display: none; }

/* Round 36×36 hairline-bordered icon buttons used on the right side
   of the toolbar (Reset, Help). */
.toolbar-icon-btn {
  appearance: none;
  width: 28px;
  height: 28px;
  padding: 0;
  border: 1px solid var(--fg);
  border-radius: 50%;
  background: var(--bg);
  color: var(--fg);
  font-family: inherit;
  font-size: 0.85rem;
  font-weight: 600;
  line-height: 1;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: background 0.1s;
}
.toolbar-icon-btn:hover { background: #fafafa; }
.toolbar-icon-btn[hidden] { display: none; }
/* The ↺ glyph and ? glyph both render small at the base 0.85rem size;
   bump them up so they sit at the same optical weight as each other
   inside the 28px (36px desktop) circle. */
#btn-puzzle-reset,
#btn-tutorial-open { font-size: 1.15rem; line-height: 1; }
@media (min-width: 768px) {
  #btn-puzzle-reset,
  #btn-tutorial-open { font-size: 1.3rem; }
}

/* Difficulty band — colored pill text. Used both inside the new
   header-pill (as `.header-pill__band.puzzle-band`) and standalone
   wherever else the band gets surfaced. play.js drops a data-band
   attribute so the colour shifts per band. */
.puzzle-band {
  display: inline-flex;
  align-items: center;
  padding: 0.05rem 0.45rem;
  font-size: inherit;
  font-weight: 600;
  letter-spacing: 0.02em;
  border-radius: 999px;
  background: var(--line-soft);
  color: var(--fg);
  white-space: nowrap;
}
.puzzle-band[data-band="easy"]      { background: #d8efe2; color: #1e6b3a; }
.puzzle-band[data-band="medium"]    { background: #e3ebf8; color: #224b95; }
.puzzle-band[data-band="hard"]      { background: #fde7d4; color: #8a4612; }
.puzzle-band[data-band="very-hard"] { background: #fbd7d3; color: #92231a; }

/* Desktop: bump header chrome up to a more comfortable size. Mobile
   keeps the compact base sizes defined above so the centered wordmark
   has room next to the pill + icon buttons on narrow viewports. The
   flip is anchored to the same 768px threshold as the rest of the
   desktop layout (card surface, scaling formula). */
@media (min-width: 768px) {
  .app__header h1,
  .app__header .app__title { font-size: 1.7rem; letter-spacing: 0.28em; }
  .toolbar-pill { height: 36px; padding: 0 14px; font-size: 0.88rem; }
  .toolbar-pill__timer { font-size: 0.88rem; }
  .toolbar-pill__label { font-size: 0.88rem; }
  .toolbar-icon-btn { width: 36px; height: 36px; font-size: 0.95rem; }
}

/* Win modal — page-level overlay shown after the canvas celebration
   completes. The panel itself has a light-grey background so the
   decorative SVG reads as a distinct surface from the white inner
   frame. The backdrop is transparent: the panel's drop-shadow is the
   only thing separating it from the puzzle behind. */
.puzzle-solved { background: transparent; }

/* Modal element stays full-viewport (.modal inset: 0) so its
   transparent surface blocks toolbar + header clicks while the modal
   is open. The panel is absolutely positioned inside it by JS to the
   canvas+spectrum area minus a small inset. */
.puzzle-solved .puzzle-solved__panel {
  position: absolute;
  /* top/left/width/height set by positionSolvedModal() in main.js. */
  padding: 0;
  background: #fff;
  border: none;
  overflow: hidden;
  border-radius: 20px;
  box-shadow: 0 14px 44px rgba(0, 0, 0, 0.32);
  display: flex;
  align-items: center;
  justify-content: center;
}
.puzzle-solved__bg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  display: block;
}
.puzzle-solved__inner {
  position: relative;
  /* Frosted glass card sized to its content; confetti SVG shows
     softly through behind the text. */
  background: rgba(255, 255, 255, 0.72);
  backdrop-filter: blur(8px) saturate(1.1);
  -webkit-backdrop-filter: blur(8px) saturate(1.1);
  border: 1px solid rgba(255, 255, 255, 0.6);
  border-radius: 16px;
  padding: 1.4rem 1.5rem;
  text-align: center;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.7rem;
  width: min(82%, 360px);
  box-shadow: 0 6px 24px rgba(0, 0, 0, 0.12);
}
.puzzle-solved__title {
  font-size: 1.6rem;
  font-weight: 800;
  margin: 0;
  line-height: 1.15;
  color: #1c1c1c;
  /* Allow wrapping when the inner frame is narrow on phones. */
  overflow-wrap: anywhere;
}
/* Meta row above the timer: today's date · difficulty pill, one line. */
.puzzle-solved__meta {
  display: inline-flex;
  align-items: center;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.55rem;
  font-size: 0.82rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
  margin: -0.2rem 0 0;
}
.puzzle-solved__date { font-weight: 600; }
.puzzle-solved__band {
  padding: 0.1rem 0.55rem;
  font-size: 0.78rem;
  letter-spacing: 0.05em;
  text-transform: none;
}
.puzzle-solved__time {
  font-size: 1.1rem;
  color: #555;
  min-height: 1.4em;
  font-variant-numeric: tabular-nums;
  display: inline-flex;
  align-items: baseline;
  justify-content: center;
  gap: 0.45rem;
  flex-wrap: wrap;
}
.puzzle-solved__time-label {
  font-size: 0.95rem;
  letter-spacing: 0.02em;
}
.puzzle-solved__time-value { font-weight: 600; color: #2a2a2a; }
.puzzle-solved__streak {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  justify-content: center;
  flex-wrap: wrap;
}
.puzzle-solved__streak-flame { font-size: 1.7rem; line-height: 1; }
.puzzle-solved__streak-count {
  font-size: 2.4rem;
  font-weight: 800;
  color: #1c1c1c;
  font-variant-numeric: tabular-nums;
  line-height: 1;
}
.puzzle-solved__streak-label {
  font-size: 1rem;
  color: #555;
}
.puzzle-solved__buttons {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem 0.75rem;
  margin-top: 0.5rem;
  width: 100%;
}
.puzzle-solved__buttons .toolbar-btn {
  font-size: 0.95rem;
  /* flex-basis 0 + grow 1 makes both buttons the same width whether
     they sit side-by-side or each on their own row (wrap → each
     button is the only thing in its row, so it grows to 100%).
     min-width: max-content prevents either button from shrinking
     narrower than its label — the row wraps before that happens. */
  flex: 1 1 0;
  min-width: max-content;
  text-align: center;
  padding-left: 0.5rem;
  padding-right: 0.5rem;
}

/* Daily-only nudge to the About page once nothing's left to do. */
.puzzle-solved__about {
  margin-top: 0.85rem;
  text-align: center;
  font-size: 0.82rem;
  line-height: 1.4;
  color: #555;
}
.puzzle-solved__about-flavor {
  margin: 0 0 0.1rem;
}
.puzzle-solved__about-link {
  color: #555;
  text-decoration: none;
  border-bottom: 1px solid #aaa;
}
.puzzle-solved__about-link:hover,
.puzzle-solved__about-link:focus-visible {
  color: #1c1c1c;
  border-bottom-color: currentColor;
}

/* Learn page: "Next puzzle" is the primary CTA. Heavy blue border +
   tinted background so the player's eye lands here instead of on
   "View my solution". */
.puzzle-solved__next {
  background: #eff6ff;
  border: 2px solid #1d4ed8;
  color: #1d4ed8;
  font-weight: 700;
}
.puzzle-solved__next:hover:not(:disabled) {
  background: #dbeafe;
  border-color: #1e40af;
  color: #1e40af;
}
.puzzle-solved__next:disabled {
  background: #f1f5f9;
  border-color: #c7d2e2;
  color: #94a3b8;
}

/* Learn page: full-width "Play the daily puzzle" CTA sits beneath
   the two row buttons so the player has an obvious exit back to
   the main game. Neutral toolbar look with a subtle accent. */
/* Sits below the white/blue row buttons. Green tint + dashed
   separator above so it reads as a distinct secondary action
   ("leave the tutorial flow") rather than a third button in the
   same set — the stacked white/blue/white look on mobile was
   visually muddy. */
.puzzle-solved__daily-cta {
  display: block;
  width: 100%;
  margin-top: 0.9rem;
  padding: 0.55rem 0.75rem;
  font-size: 0.9rem;
  font-weight: 600;
  text-align: center;
  text-decoration: none;
  color: #166534;
  background: #f0fdf4;
  border: 1px solid #86efac;
  border-radius: 8px;
  position: relative;
}
.puzzle-solved__daily-cta::before {
  content: "";
  position: absolute;
  left: 10%;
  right: 10%;
  top: -0.5rem;
  border-top: 1px dashed #cbd5e1;
}
.puzzle-solved__daily-cta:hover {
  background: #dcfce7;
  border-color: #4ade80;
  color: #14532d;
}

/* Daily-only tints: Reset = soft red, View Solution = soft blue.
   Scoped via .puzzle-solved--daily so Learn's "→ Next puzzle"
   button (same id) keeps the neutral toolbar-btn look. */
.puzzle-solved--daily #btn-popup-reset {
  background: #fef2f2;
  border-color: #f5b5b5;
  color: #b91c1c;
}
.puzzle-solved--daily #btn-popup-reset:hover:not(:disabled) {
  background: #fee2e2;
  border-color: #ef8f8f;
  color: #991b1b;
}
.puzzle-solved--daily #btn-popup-solution {
  background: #eff6ff;
  border-color: #a9c8f0;
  color: #1d4ed8;
}
.puzzle-solved--daily #btn-popup-solution:hover:not(:disabled) {
  background: #dbeafe;
  border-color: #7faceb;
  color: #1e40af;
}

.puzzle-solved:not([hidden]) .puzzle-solved__panel {
  animation: puzzle-solved-pop 220ms cubic-bezier(.2, .9, .3, 1.2) both;
}
.puzzle-solved:not([hidden]) .puzzle-solved__inner {
  animation: puzzle-solved-fade 250ms ease-out 180ms both;
}
@keyframes puzzle-solved-pop {
  from { transform: scale(0.85); opacity: 0; }
  to   { transform: scale(1);    opacity: 1; }
}
@keyframes puzzle-solved-fade {
  from { opacity: 0; transform: translateY(6px); }
  to   { opacity: 1; transform: none; }
}
@media (prefers-reduced-motion: reduce) {
  .puzzle-solved:not([hidden]) .puzzle-solved__panel,
  .puzzle-solved:not([hidden]) .puzzle-solved__inner {
    animation: none;
  }
}

/* Phones: panel fills more of the screen; inner frame trims its
   horizontal inset so titles like "You Solved Today's Multiplet!"
   have room to read at the larger font without overflowing. */
/* Panel size is driven by the JS-set modal inset (canvas+spectrum
   bounds minus 14px), so the mobile breakpoint only retunes type
   scale and inner-card padding. */
@media (max-width: 480px) {
  .puzzle-solved__inner {
    padding: 1.1rem 1rem;
    gap: 0.55rem;
    width: min(88%, 320px);
  }
  .puzzle-solved__title { font-size: 1.35rem; }
  .puzzle-solved__meta { font-size: 0.74rem; gap: 0.45rem; }
  .puzzle-solved__band { font-size: 0.7rem; padding: 0.08rem 0.5rem; }
  .puzzle-solved__time { font-size: 1rem; }
  .puzzle-solved__time-label { font-size: 0.88rem; }
  .puzzle-solved__streak-flame { font-size: 1.45rem; }
  .puzzle-solved__streak-count { font-size: 2rem; }
  .puzzle-solved__streak-label { font-size: 0.9rem; }
  .puzzle-solved__buttons .toolbar-btn { font-size: 0.9rem; }
}

/* Solution view: drag-pickup is disabled by dragApi.setLocked(true)
   — at the JS level — so the canvas keeps pointer-events on for
   pan/zoom. CSS only needs to adjust the cursor affordance so
   shapes stop advertising as draggable. */
.puzzle-canvas-wrap--readonly .shape {
  cursor: default;
}

/* ── Generator panel ───────────────────────────────────────────────
   Appears below the spectrum on the author page. Always visible
   so the admin can scroll down to it; nothing else on the page
   resizes around it. */
.generator {
  margin-top: 1.5rem;
  padding-top: 1.25rem;
  border-top: 1px solid var(--line-soft);
}
.generator__title {
  font-size: 1.1rem;
  margin: 0 0 0.4rem 0;
}
.generator__intro {
  font-size: 0.85rem;
  color: var(--muted);
  margin: 0 0 1rem 0;
  line-height: 1.4;
}
.generator__heading {
  font-size: 0.75rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
  margin: 1.1rem 0 0.5rem 0;
}
.generator__heading:first-of-type {
  margin-top: 0;
}
.generator__presets {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 0.5rem;
}
@media (max-width: 720px) {
  .generator__presets {
    grid-template-columns: repeat(2, 1fr);
  }
}
.generator-preset {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: var(--bg);
  color: var(--fg);
  font-family: inherit;
  text-align: left;
  cursor: pointer;
  transition: border-color 0.1s, background 0.1s;
}
.generator-preset:hover {
  border-color: var(--accent);
  background: #f6f9ff;
}
.generator-preset strong {
  font-size: 0.95rem;
}
.generator-preset small {
  font-size: 0.75rem;
  color: var(--muted);
  line-height: 1.3;
}
.generator__counts {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0.4rem 1rem;
  margin-bottom: 0.75rem;
}
@media (max-width: 480px) {
  .generator__counts {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}
.generator__counts label {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  font-size: 0.85rem;
}
.generator__counts input[type="number"] {
  width: 4rem;
  padding: 0.2em 0.4em;
  font-family: inherit;
  font-size: 0.85rem;
  border: 1px solid var(--line-soft);
  border-radius: 4px;
  background: var(--bg);
}
.generator__options {
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem 1.25rem;
}
.generator__option {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  font-size: 0.85rem;
}
.generator__option select {
  font-family: inherit;
  font-size: 0.85rem;
  padding: 0.2em 0.4em;
  border: 1px solid var(--line-soft);
  border-radius: 4px;
  background: var(--bg);
}
.generator__error {
  margin-top: 0.75rem;
  padding: 0.5rem 0.7rem;
  background: #fdecec;
  color: #b8311c;
  border-radius: 4px;
  font-size: 0.85rem;
}

/* Inline daily-preview controls inside the author toolbar. */
.toolbar-date {
  font-family: inherit;
  font-size: 0.9rem;
  padding: 0.3em 0.5em;
  border: 1px solid var(--line-soft);
  border-radius: 4px;
  background: var(--bg);
}
.toolbar-status {
  font-size: 0.85rem;
  color: var(--muted);
  white-space: nowrap;
}

/* ── Header help (?) button ───────────────────────────────────────── */
.header-help {
  appearance: none;
  width: 26px;
  height: 26px;
  margin-left: 6px;
  padding: 0;
  border: 1px solid var(--line-soft);
  border-radius: 50%;
  background: var(--bg);
  color: var(--fg);
  font-family: inherit;
  font-size: 0.95rem;
  font-weight: 700;
  line-height: 1;
  cursor: pointer;
}
.header-help:hover {
  border-color: var(--accent);
  color: var(--accent);
}

/* ── Tutorial modal ───────────────────────────────────────────────── */
.tutorial__panel {
  position: relative;
  max-width: 460px;
  /* Give the modal real height so the illustration can breathe and
     each panel feels like a proper screen rather than a square
     embedded preview. Capped at 92vh and scrolls if any panel's
     content overflows on a short viewport. */
  max-height: 92vh;
  overflow-y: auto;
  padding: 1.25rem 1.4rem 1rem;
}
.tutorial__close {
  position: absolute;
  top: 0.5rem;
  right: 0.7rem;
  width: 28px;
  height: 28px;
  padding: 0;
  border: none;
  background: transparent;
  font-size: 1.4rem;
  line-height: 1;
  color: var(--muted);
  cursor: pointer;
}
.tutorial__close:hover { color: var(--fg); }

.tutorial__title {
  padding-right: 1.5rem;
  margin: 0 0 0.6rem 0;
}
.tutorial__art {
  width: 100%;
  background: #fafbfd;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  padding: 0.5rem;
  margin-bottom: 0.7rem;
}
.tutorial__svg {
  display: block;
  width: 100%;
  height: auto;
  /* Tall portrait illustrations — the SVG fills the modal width and
     extends down to make the panel feel near-fullscreen on mobile.
     `max-height` only kicks in on very wide viewports. */
  max-height: 540px;
}
/* Tutorial illustrations re-use the game's `.shape` and `.spectrum__*`
   classes for visual fidelity, but the shapes aren't draggable here
   and the spectrum is rendered at a smaller scale than the real game,
   so we pull the cursor and text sizes back to tutorial-appropriate
   values. */
.tutorial__svg .shape { cursor: default; }
.tutorial__tick-label    { font-size: 14px; }
.tutorial__dot-letter    { font-size: 18px; }
.tutorial__coupling-letter { font-size: 15px; }
.tutorial__body {
  font-size: 0.92rem;
  line-height: 1.45;
  margin: 0 0 1rem 0;
  /* Reserve space for ~5 lines of body text so panels with shorter
     copy don't shrink the modal — keeps the nav buttons pinned at
     the same screen Y position when navigating between panels. */
  min-height: 7.25em;
}

.tutorial__footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  flex-wrap: wrap;
}
.tutorial__progress {
  display: flex;
  gap: 0.35rem;
}
.tutorial__dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--line-soft);
}
.tutorial__dot--active {
  background: var(--accent);
}
.tutorial__nav {
  display: flex;
  gap: 0.4rem;
  margin-left: auto;
}
@media (max-width: 420px) {
  .tutorial__panel { padding: 1rem 1rem 0.85rem; }
  .tutorial__svg   { max-height: 480px; }
  .tutorial__body  { font-size: 0.88rem; }
}

/* ── Tutorial illustration animations ─────────────────────────────────
   Pacing model — every animated panel uses the SAME shape:
     0%–4%   quick grow (ease-out)        — 4% of cycle to scale-up
     4%–28%  HOLD at peak with green glow — 24% of cycle held
     28%–32% quick shrink                 — 4% of cycle to scale-down
     32%–rest of phase: at rest
   Three phases per cycle (A → B → C) staggered by 33% each so each
   group gets its full pop-and-hold before the next starts. Mirrors
   the live game's victory animation (`spectrum-celebrate-shape`,
   ~1.1s ease-out scale-up to 1.32 then settle): a snappy pop and a
   readable green glow rather than the previous "tiny vibrations".

   Every animated SVG element needs `transform-box: fill-box` so its
   transform-origin is its own bounding-box centre. The shared rule
   below applies that to every tutorial panel target. */
.tutorial__svg [class*="tut-p"] {
  transform-box: fill-box;
  transform-origin: center;
}

/* ── Shared building-block keyframe used for the "pop" effect.
   Time markers below are PHASE-relative (0%–100% of one phase). */
@keyframes tut-pop-a {  /* phase A: active 0–33% of cycle */
  0%, 100% { transform: scale(1); filter: none; }
  4%, 28%  { transform: scale(1.5); filter: drop-shadow(0 0 6px rgba(45,169,79,.55)); }
  32%      { transform: scale(1); filter: none; }
}
@keyframes tut-pop-b {  /* phase B: active 33–66% */
  0%, 33%, 100% { transform: scale(1); filter: none; }
  37%, 61%      { transform: scale(1.5); filter: drop-shadow(0 0 6px rgba(45,169,79,.55)); }
  65%           { transform: scale(1); filter: none; }
}
@keyframes tut-pop-c {  /* phase C: active 66–99% */
  0%, 66%, 100% { transform: scale(1); filter: none; }
  70%, 94%      { transform: scale(1.5); filter: drop-shadow(0 0 6px rgba(45,169,79,.55)); }
  98%           { transform: scale(1); filter: none; }
}

/* Spectrum-dot variant: same timing, but recolour to green at peak
   (using stroke + fill) so the pulse reads as "this is the matching
   column on the spectrum" — same look as a `--correct` dot mid-pulse. */
@keyframes tut-dot-pop-a {
  0%, 100%       { transform: scale(1); fill: white; stroke: var(--c-circle); opacity: .55; }
  4%, 28%        { transform: scale(1.5); fill: color-mix(in srgb, #2da94f 18%, white); stroke: #2da94f; opacity: 1; }
  32%            { transform: scale(1); fill: white; stroke: var(--c-circle); opacity: .55; }
}
@keyframes tut-dot-pop-b {
  0%, 33%, 100%  { transform: scale(1); fill: white; stroke: var(--c-circle); opacity: .55; }
  37%, 61%       { transform: scale(1.5); fill: color-mix(in srgb, #2da94f 18%, white); stroke: #2da94f; opacity: 1; }
  65%            { transform: scale(1); fill: white; stroke: var(--c-circle); opacity: .55; }
}
@keyframes tut-dot-pop-c {
  0%, 66%, 100%  { transform: scale(1); fill: white; stroke: var(--c-circle); opacity: .55; }
  70%, 94%       { transform: scale(1.5); fill: color-mix(in srgb, #2da94f 18%, white); stroke: #2da94f; opacity: 1; }
  98%            { transform: scale(1); fill: white; stroke: var(--c-circle); opacity: .55; }
}

/* ── Panel 2: gallery label + chain bonded-vertex pulse ─────────────
   9 s cycle, 3 s per shape (triangle → square → pentagon). The label
   pulse is panel-2-specific (rather than the shared `tut-pop-*`) so
   it can scale larger AND recolour to the same green the bonds flash,
   visually tying "this many bonds" to the highlighted connectors. */
@keyframes tut-p2-label-pop-a {
  0%, 100% { transform: scale(1); }
  4%, 28%  { transform: scale(2.1); }
  32%      { transform: scale(1); }
}
@keyframes tut-p2-label-pop-b {
  0%, 33%, 100% { transform: scale(1); }
  37%, 61%      { transform: scale(2.1); }
  65%           { transform: scale(1); }
}
@keyframes tut-p2-label-pop-c {
  0%, 66%, 100% { transform: scale(1); }
  70%, 94%      { transform: scale(2.1); }
  98%           { transform: scale(1); }
}
@keyframes tut-p2-label-fill-a {
  0%, 100% { fill: var(--fg); filter: none; }
  4%, 28%  { fill: #2da94f; filter: drop-shadow(0 0 6px rgba(45,169,79,.55)); }
  32%      { fill: var(--fg); filter: none; }
}
@keyframes tut-p2-label-fill-b {
  0%, 33%, 100% { fill: var(--fg); filter: none; }
  37%, 61%      { fill: #2da94f; filter: drop-shadow(0 0 6px rgba(45,169,79,.55)); }
  65%           { fill: var(--fg); filter: none; }
}
@keyframes tut-p2-label-fill-c {
  0%, 66%, 100% { fill: var(--fg); filter: none; }
  70%, 94%      { fill: #2da94f; filter: drop-shadow(0 0 6px rgba(45,169,79,.55)); }
  98%           { fill: var(--fg); filter: none; }
}
.tutorial__svg .tut-p2-label-tri,
.tutorial__svg .tut-p2-label-sq,
.tutorial__svg .tut-p2-label-pent,
.tutorial__svg .tut-p2-label-tri text,
.tutorial__svg .tut-p2-label-sq text,
.tutorial__svg .tut-p2-label-pent text {
  animation-duration: 9s;
  animation-iteration-count: infinite;
  animation-timing-function: ease-out;
}
.tutorial__svg .tut-p2-label-tri       { animation-name: tut-p2-label-pop-a; }
.tutorial__svg .tut-p2-label-pent      { animation-name: tut-p2-label-pop-b; }
.tutorial__svg .tut-p2-label-sq        { animation-name: tut-p2-label-pop-c; }
.tutorial__svg .tut-p2-label-tri text  { animation-name: tut-p2-label-fill-a; }
.tutorial__svg .tut-p2-label-pent text { animation-name: tut-p2-label-fill-b; }
.tutorial__svg .tut-p2-label-sq text   { animation-name: tut-p2-label-fill-c; }

/* Bond flash — each circle bond on the chain stretches green during
   its phase. Stroke flips from --bond (#1a1a1a) to green and a green
   drop-shadow halos the line for the "glow behind it" look. The
   visible-length stretch comes from the partner tut-p2-circ-* outward
   translate animation pulling the circle out: each bond is rendered
   ~14px past the circle vertex in JS, so the extra length is hidden
   inside the circle's fill at rest and is uncovered as the circle
   translates outward during this flash. */
@keyframes tut-p2-bond-flash-a {
  0%, 100% { stroke: var(--bond); filter: none; }
  4%, 28%  { stroke: #2da94f;     filter: drop-shadow(0 0 5px rgba(45,169,79,.75)); }
  32%      { stroke: var(--bond); filter: none; }
}
@keyframes tut-p2-bond-flash-b {
  0%, 33%, 100% { stroke: var(--bond); filter: none; }
  37%, 61%      { stroke: #2da94f;     filter: drop-shadow(0 0 5px rgba(45,169,79,.75)); }
  65%           { stroke: var(--bond); filter: none; }
}
@keyframes tut-p2-bond-flash-c {
  0%, 66%, 100% { stroke: var(--bond); filter: none; }
  70%, 94%      { stroke: #2da94f;     filter: drop-shadow(0 0 5px rgba(45,169,79,.75)); }
  98%           { stroke: var(--bond); filter: none; }
}
.tutorial__svg .tut-p2-bond-tri,
.tutorial__svg .tut-p2-bond-pent,
.tutorial__svg .tut-p2-bond-sq {
  animation-duration: 9s;
  animation-iteration-count: infinite;
  animation-timing-function: ease-out;
}
.tutorial__svg .tut-p2-bond-tri  { animation-name: tut-p2-bond-flash-a; }
.tutorial__svg .tut-p2-bond-pent { animation-name: tut-p2-bond-flash-b; }
.tutorial__svg .tut-p2-bond-sq   { animation-name: tut-p2-bond-flash-c; }

/* Circle outward-stretch animation for panel 2. Each circle wrapper
   carries --ux / --uy custom props (unit vector from polygon centre
   outward) set inline from JS, so the single keyframe translates each
   circle the correct radial direction without per-element keyframes. */
@keyframes tut-p2-circ-flash-a {
  0%, 100% { transform: translate(0,0) scale(1); filter: none; }
  4%, 28%  { transform: translate(calc(var(--ux)*9px),calc(var(--uy)*9px)) scale(1.2);
              filter: drop-shadow(0 0 5px rgba(45,169,79,.45)); }
  32%      { transform: translate(0,0) scale(1); filter: none; }
}
@keyframes tut-p2-circ-flash-b {
  0%, 33%, 100% { transform: translate(0,0) scale(1); filter: none; }
  37%, 61%      { transform: translate(calc(var(--ux)*9px),calc(var(--uy)*9px)) scale(1.2);
                  filter: drop-shadow(0 0 5px rgba(45,169,79,.45)); }
  65%           { transform: translate(0,0) scale(1); filter: none; }
}
@keyframes tut-p2-circ-flash-c {
  0%, 66%, 100% { transform: translate(0,0) scale(1); filter: none; }
  70%, 94%      { transform: translate(calc(var(--ux)*9px),calc(var(--uy)*9px)) scale(1.2);
                  filter: drop-shadow(0 0 5px rgba(45,169,79,.45)); }
  98%           { transform: translate(0,0) scale(1); filter: none; }
}
.tutorial__svg .tut-p2-circ-tri,
.tutorial__svg .tut-p2-circ-pent,
.tutorial__svg .tut-p2-circ-sq {
  animation-duration: 9s;
  animation-iteration-count: infinite;
  animation-timing-function: ease-out;
}
.tutorial__svg .tut-p2-circ-tri  { animation-name: tut-p2-circ-flash-a; }
.tutorial__svg .tut-p2-circ-pent { animation-name: tut-p2-circ-flash-b; }
.tutorial__svg .tut-p2-circ-sq   { animation-name: tut-p2-circ-flash-c; }

/* Polygon-polygon bond stroke flash. Each connector belongs to BOTH
   its polygons, so it lights up during either endpoint's phase:
   tri-sq flashes in tri (a) + sq (c) phases; sq-pent flashes in
   pent (b) + sq (c) phases. Two overlapping half-lines per bond
   share the same flash class so they highlight in unison. */
@keyframes tut-p2-bond-flash-trisq {
  0%, 100%       { stroke: var(--bond); filter: none; }
  4%, 28%        { stroke: #2da94f; filter: drop-shadow(0 0 5px rgba(45,169,79,.75)); }
  32%, 66%       { stroke: var(--bond); filter: none; }
  70%, 94%       { stroke: #2da94f; filter: drop-shadow(0 0 5px rgba(45,169,79,.75)); }
  98%            { stroke: var(--bond); filter: none; }
}
@keyframes tut-p2-bond-flash-sqpent {
  0%, 33%, 100%  { stroke: var(--bond); filter: none; }
  37%, 61%       { stroke: #2da94f; filter: drop-shadow(0 0 5px rgba(45,169,79,.75)); }
  65%, 66%       { stroke: var(--bond); filter: none; }
  70%, 94%       { stroke: #2da94f; filter: drop-shadow(0 0 5px rgba(45,169,79,.75)); }
  98%            { stroke: var(--bond); filter: none; }
}
.tutorial__svg .tut-p2-bond-trisq,
.tutorial__svg .tut-p2-bond-sqpent {
  animation-duration: 9s;
  animation-iteration-count: infinite;
  animation-timing-function: ease-out;
}
.tutorial__svg .tut-p2-bond-trisq  { animation-name: tut-p2-bond-flash-trisq; }
.tutorial__svg .tut-p2-bond-sqpent { animation-name: tut-p2-bond-flash-sqpent; }

/* Rigid-subtree translate. When a polygon is the focus, every
   neighbouring subtree (the partner polygon plus its circles + bonds)
   shifts ~9 px outward so polygon-polygon bonds visually stretch by
   the same amount as circle bonds. Phases: a=tri, b=pent, c=sq.
   In phase a sq + pent shift +x; in phase b tri + sq shift -x;
   in phase c tri shifts -x and pent shifts +x. */
/* Transition windows match the circle outward-pop keyframes exactly:
   phase-in 0→4 / 33→37 / 66→70, phase-out 28→32 / 61→65 / 94→98.
   With identical 4 % transitions across every shape's animation, the
   subtrees that need to move during a phase reach their target on the
   same frame the circle bonds finish stretching, with no element
   lagging behind. */
@keyframes tut-p2-sub-tri-move {
  0%, 33%, 100% { transform: translate(0, 0); }
  37%, 61%      { transform: translate(-9px, 0); }
  65%, 66%      { transform: translate(0, 0); }
  70%, 94%      { transform: translate(-9px, 0); }
  98%           { transform: translate(0, 0); }
}
@keyframes tut-p2-sub-sq-move {
  0%            { transform: translate(0, 0); }
  4%, 28%       { transform: translate(9px, 0); }
  32%, 33%      { transform: translate(0, 0); }
  37%, 61%      { transform: translate(-9px, 0); }
  65%, 100%     { transform: translate(0, 0); }
}
@keyframes tut-p2-sub-pent-move {
  0%            { transform: translate(0, 0); }
  4%, 28%       { transform: translate(9px, 0); }
  32%, 66%      { transform: translate(0, 0); }
  70%, 94%      { transform: translate(9px, 0); }
  98%, 100%     { transform: translate(0, 0); }
}
.tutorial__svg .tut-p2-sub-tri,
.tutorial__svg .tut-p2-sub-sq,
.tutorial__svg .tut-p2-sub-pent {
  animation-duration: 9s;
  animation-iteration-count: infinite;
  animation-timing-function: ease-out;
  /* Promote each animated subtree element to its own compositor layer.
     Without this the heavier <polygon> shape repaints a frame behind
     the lightweight <line> half-bonds it shares a subtree with, so
     the polygon-polygon connector appears to stretch before its
     anchor shape catches up. */
  will-change: transform;
}
.tutorial__svg .tut-p2-sub-tri  { animation-name: tut-p2-sub-tri-move; }
.tutorial__svg .tut-p2-sub-sq   { animation-name: tut-p2-sub-sq-move; }
.tutorial__svg .tut-p2-sub-pent { animation-name: tut-p2-sub-pent-move; }

/* ── Panel 3: group-identity pulse ─────────────────────────────────
   9 s cycle, 3 s per group. Each group's circles AND its spectrum
   dots run on the same timeline so they pop together. */
.tutorial__svg .tut-p3-pulse-a,
.tutorial__svg .tut-p3-pulse-b,
.tutorial__svg .tut-p3-pulse-c {
  animation-duration: 9s;
  animation-iteration-count: infinite;
  animation-timing-function: ease-out;
}
.tutorial__svg g.tut-p3-pulse-a  { animation-name: tut-pop-a; }
.tutorial__svg g.tut-p3-pulse-b  { animation-name: tut-pop-b; }
.tutorial__svg g.tut-p3-pulse-c  { animation-name: tut-pop-c; }
/* Spectrum dots (and their letters) get a colour-shift variant. */
.tutorial__svg circle.tut-p3-pulse-a { animation-name: tut-dot-pop-a; }
.tutorial__svg circle.tut-p3-pulse-b { animation-name: tut-dot-pop-b; }
.tutorial__svg circle.tut-p3-pulse-c { animation-name: tut-dot-pop-c; }
.tutorial__svg text.tut-p3-pulse-a   { animation-name: tut-pop-a; }
.tutorial__svg text.tut-p3-pulse-b   { animation-name: tut-pop-b; }
.tutorial__svg text.tut-p3-pulse-c   { animation-name: tut-pop-c; }

/* Spectrum group wrappers — scale the whole column (border + circles +
   coupling letters) from its bottom edge so circles push upward without
   overlapping. transform-origin overrides the generic fill-box/center. */
.tutorial__svg .tut-p3-grp-a,
.tutorial__svg .tut-p3-grp-b,
.tutorial__svg .tut-p3-grp-c {
  transform-box: fill-box;
  transform-origin: 50% 100%;
  animation-duration: 9s;
  animation-iteration-count: infinite;
  animation-timing-function: ease-out;
}
.tutorial__svg .tut-p3-grp-a { animation-name: tut-pop-a; }
.tutorial__svg .tut-p3-grp-b { animation-name: tut-pop-b; }
.tutorial__svg .tut-p3-grp-c { animation-name: tut-pop-c; }

/* ── Panel 4: shift-formula cycle ──────────────────────────────────
   18 s cycle, 6 s per phase. Each phase plays out:
     - circle letter pulse (parent-shape group)
     - parent polygon "N" pulse + first formula token
     - neighbour polygon "N" pulse + next token
     - ("=" + result) tokens fade in
     - matching spectrum column pulses green
   Per-token keyframes hand each formula character an exclusive
   on-window inside its phase, so tokens really do appear one by
   one rather than the whole formula popping at once. */
.tutorial__svg [class*="tut-p4-"] {
  animation-duration: 24s;
  animation-iteration-count: infinite;
  /* Default to step-end so every formula token, polygon-number pulse,
     tick pulse and arrow snaps on/off at its keyframe boundary instead
     of fading. The smooth pulse on the spectrum group wrapper opts
     back into ease-out below. */
  animation-timing-function: step-end;
}
.tutorial__svg g.tut-p4-grp-a,
.tutorial__svg g.tut-p4-grp-b,
.tutorial__svg g.tut-p4-grp-c {
  animation-timing-function: ease-out;
}

/* Group circle pulses — A circles in phase A, etc. */
.tutorial__svg g.tut-p4-grp-a { animation-name: tut-pop-a; }
.tutorial__svg g.tut-p4-grp-b { animation-name: tut-pop-b; }
.tutorial__svg g.tut-p4-grp-c { animation-name: tut-pop-c; }

/* Polygon centre numbers — each one pulses (scale + recolour to the
   bond green) at the moment its formula token reveals, not just once
   per phase. Triangle's "3" pulses in phase A (parent) and again in
   phase C (neighbour); square's "4" pulses in every phase; pentagon's
   "5" pulses in phase B (parent) and phase C (neighbour). Timings
   align with `tut-fa-*` / `tut-fb-*` / `tut-fc-*` token reveals so
   the structure number lights up exactly when its formula counterpart
   appears. */
/* Beat schedule (24 s cycle, step-end on most elements so every
   change snaps in / out instead of fading):
     Phase A  4 % prefix + circle group   8 % "3"  12 % "+"  16 % "4"
              20 % "="  24 % "7"  hold→32 %
     Phase B 37 % prefix + group  41 % "4"  45 % "+"  49 % "5"
              53 % "="  57 % "9"  hold→65 %
     Phase C 70 % prefix + group  74 % "3"  77 % "+"  80 % "4"
              83 % "+"  86 % "5"  89 % "="  92 % "12"  hold→98 %
   Each polygon centre number snaps to scale 1.6 + green at the
   instant its formula token reveals, and snaps back at phase end. */
@keyframes tut-p4-num-tri {
  0%   { transform: scale(1);   fill: var(--c-triangle); filter: none; }
  8%   { transform: scale(1.6); fill: #2da94f; filter: drop-shadow(0 0 5px rgba(45,169,79,.55)); }
  32%  { transform: scale(1);   fill: var(--c-triangle); filter: none; }
  74%  { transform: scale(1.6); fill: #2da94f; filter: drop-shadow(0 0 5px rgba(45,169,79,.55)); }
  98%  { transform: scale(1);   fill: var(--c-triangle); filter: none; }
}
@keyframes tut-p4-num-sq {
  0%   { transform: scale(1);   fill: var(--c-square); filter: none; }
  16%  { transform: scale(1.6); fill: #2da94f; filter: drop-shadow(0 0 5px rgba(45,169,79,.55)); }
  32%  { transform: scale(1);   fill: var(--c-square); filter: none; }
  41%  { transform: scale(1.6); fill: #2da94f; filter: drop-shadow(0 0 5px rgba(45,169,79,.55)); }
  65%  { transform: scale(1);   fill: var(--c-square); filter: none; }
  80%  { transform: scale(1.6); fill: #2da94f; filter: drop-shadow(0 0 5px rgba(45,169,79,.55)); }
  98%  { transform: scale(1);   fill: var(--c-square); filter: none; }
}
@keyframes tut-p4-num-pent {
  0%   { transform: scale(1);   fill: var(--c-pentagon); filter: none; }
  49%  { transform: scale(1.6); fill: #2da94f; filter: drop-shadow(0 0 5px rgba(45,169,79,.55)); }
  65%  { transform: scale(1);   fill: var(--c-pentagon); filter: none; }
  86%  { transform: scale(1.6); fill: #2da94f; filter: drop-shadow(0 0 5px rgba(45,169,79,.55)); }
  98%  { transform: scale(1);   fill: var(--c-pentagon); filter: none; }
}
.tutorial__svg .tut-p4-num-tri  { animation-name: tut-p4-num-tri; }
.tutorial__svg .tut-p4-num-pent { animation-name: tut-p4-num-pent; }
.tutorial__svg .tut-p4-num-sq   { animation-name: tut-p4-num-sq; }

/* Result-tick highlight — small base font expanded loud to 2.5 ×
   and recoloured red (matching the result token + result arrow) at
   the moment its formula "= N" reveals. */
@keyframes tut-p4-tick-7 {
  0%   { transform: scale(1); fill: var(--muted); filter: none; }
  24%  { transform: scale(2.5); fill: #d44132; filter: drop-shadow(0 0 5px rgba(212,65,50,.7)); }
  32%  { transform: scale(1); fill: var(--muted); filter: none; }
}
@keyframes tut-p4-tick-9 {
  0%   { transform: scale(1); fill: var(--muted); filter: none; }
  57%  { transform: scale(2.5); fill: #d44132; filter: drop-shadow(0 0 5px rgba(212,65,50,.7)); }
  65%  { transform: scale(1); fill: var(--muted); filter: none; }
}
@keyframes tut-p4-tick-12 {
  0%   { transform: scale(1); fill: var(--muted); filter: none; }
  92%  { transform: scale(2.5); fill: #d44132; filter: drop-shadow(0 0 5px rgba(212,65,50,.7)); }
  98%  { transform: scale(1); fill: var(--muted); filter: none; }
}
.tutorial__svg .tut-p4-tick-7  { animation-name: tut-p4-tick-7; }
.tutorial__svg .tut-p4-tick-9  { animation-name: tut-p4-tick-9; }
.tutorial__svg .tut-p4-tick-12 { animation-name: tut-p4-tick-12; }

/* "Circle group X:" prefix — pops in at the same beat the structure's
   circle group expands, before any equation token. */
@keyframes tut-p4-prefix-a { 0% { opacity: 0; }  4% { opacity: 1; } 32% { opacity: 0; } }
@keyframes tut-p4-prefix-b { 0% { opacity: 0; } 37% { opacity: 1; } 65% { opacity: 0; } }
@keyframes tut-p4-prefix-c { 0% { opacity: 0; } 70% { opacity: 1; } 98% { opacity: 0; } }
.tutorial__svg .tut-p4-prefix-a,
.tutorial__svg .tut-p4-prefix-b,
.tutorial__svg .tut-p4-prefix-c { opacity: 0; }
.tutorial__svg .tut-p4-prefix-a { animation-name: tut-p4-prefix-a; }
.tutorial__svg .tut-p4-prefix-b { animation-name: tut-p4-prefix-b; }
.tutorial__svg .tut-p4-prefix-c { animation-name: tut-p4-prefix-c; }

/* Formula token visibility — instant pop on/off via step-end. */
@keyframes tut-fa-3     { 0% { opacity: 0; }  8% { opacity: 1; } 32% { opacity: 0; } }
@keyframes tut-fa-plus  { 0% { opacity: 0; } 12% { opacity: 1; } 32% { opacity: 0; } }
@keyframes tut-fa-4     { 0% { opacity: 0; } 16% { opacity: 1; } 32% { opacity: 0; } }
@keyframes tut-fa-eq    { 0% { opacity: 0; } 20% { opacity: 1; } 32% { opacity: 0; } }
@keyframes tut-fa-7     { 0% { opacity: 0; } 24% { opacity: 1; } 32% { opacity: 0; } }
@keyframes tut-fb-4     { 0% { opacity: 0; } 41% { opacity: 1; } 65% { opacity: 0; } }
@keyframes tut-fb-plus  { 0% { opacity: 0; } 45% { opacity: 1; } 65% { opacity: 0; } }
@keyframes tut-fb-5     { 0% { opacity: 0; } 49% { opacity: 1; } 65% { opacity: 0; } }
@keyframes tut-fb-eq    { 0% { opacity: 0; } 53% { opacity: 1; } 65% { opacity: 0; } }
@keyframes tut-fb-9     { 0% { opacity: 0; } 57% { opacity: 1; } 65% { opacity: 0; } }
@keyframes tut-fc-3     { 0% { opacity: 0; } 74% { opacity: 1; } 98% { opacity: 0; } }
@keyframes tut-fc-plus1 { 0% { opacity: 0; } 77% { opacity: 1; } 98% { opacity: 0; } }
@keyframes tut-fc-4     { 0% { opacity: 0; } 80% { opacity: 1; } 98% { opacity: 0; } }
@keyframes tut-fc-plus2 { 0% { opacity: 0; } 83% { opacity: 1; } 98% { opacity: 0; } }
@keyframes tut-fc-5     { 0% { opacity: 0; } 86% { opacity: 1; } 98% { opacity: 0; } }
@keyframes tut-fc-eq    { 0% { opacity: 0; } 89% { opacity: 1; } 98% { opacity: 0; } }
@keyframes tut-fc-12    { 0% { opacity: 0; } 92% { opacity: 1; } 98% { opacity: 0; } }

.tutorial__svg .tut-p4-fa-3     { opacity: 0; animation-name: tut-fa-3; }
.tutorial__svg .tut-p4-fa-plus  { opacity: 0; animation-name: tut-fa-plus; }
.tutorial__svg .tut-p4-fa-4     { opacity: 0; animation-name: tut-fa-4; }
.tutorial__svg .tut-p4-fa-eq    { opacity: 0; animation-name: tut-fa-eq; }
.tutorial__svg .tut-p4-fa-7     { opacity: 0; animation-name: tut-fa-7; }
.tutorial__svg .tut-p4-fb-5     { opacity: 0; animation-name: tut-fb-5; }
.tutorial__svg .tut-p4-fb-plus  { opacity: 0; animation-name: tut-fb-plus; }
.tutorial__svg .tut-p4-fb-4     { opacity: 0; animation-name: tut-fb-4; }
.tutorial__svg .tut-p4-fb-eq    { opacity: 0; animation-name: tut-fb-eq; }
.tutorial__svg .tut-p4-fb-9     { opacity: 0; animation-name: tut-fb-9; }
.tutorial__svg .tut-p4-fc-4     { opacity: 0; animation-name: tut-fc-4; }
.tutorial__svg .tut-p4-fc-plus1 { opacity: 0; animation-name: tut-fc-plus1; }
.tutorial__svg .tut-p4-fc-3     { opacity: 0; animation-name: tut-fc-3; }
.tutorial__svg .tut-p4-fc-plus2 { opacity: 0; animation-name: tut-fc-plus2; }
.tutorial__svg .tut-p4-fc-5     { opacity: 0; animation-name: tut-fc-5; }
.tutorial__svg .tut-p4-fc-eq    { opacity: 0; animation-name: tut-fc-eq; }
.tutorial__svg .tut-p4-fc-12    { opacity: 0; animation-name: tut-fc-12; }

/* Green linking arrows — each pops in at the same beat as the
   formula token / structure number / tick label it ties together,
   and pops out with that token at the end of the phase. */
@keyframes tut-p4-arr-fa-3  { 0% { opacity: 0; }  8% { opacity: 1; } 32% { opacity: 0; } }
@keyframes tut-p4-arr-fa-4  { 0% { opacity: 0; } 16% { opacity: 1; } 32% { opacity: 0; } }
@keyframes tut-p4-arr-fa-7  { 0% { opacity: 0; } 24% { opacity: 1; } 32% { opacity: 0; } }
@keyframes tut-p4-arr-fb-4  { 0% { opacity: 0; } 41% { opacity: 1; } 65% { opacity: 0; } }
@keyframes tut-p4-arr-fb-5  { 0% { opacity: 0; } 49% { opacity: 1; } 65% { opacity: 0; } }
@keyframes tut-p4-arr-fb-9  { 0% { opacity: 0; } 57% { opacity: 1; } 65% { opacity: 0; } }
@keyframes tut-p4-arr-fc-3  { 0% { opacity: 0; } 74% { opacity: 1; } 98% { opacity: 0; } }
@keyframes tut-p4-arr-fc-4  { 0% { opacity: 0; } 80% { opacity: 1; } 98% { opacity: 0; } }
@keyframes tut-p4-arr-fc-5  { 0% { opacity: 0; } 86% { opacity: 1; } 98% { opacity: 0; } }
@keyframes tut-p4-arr-fc-12 { 0% { opacity: 0; } 92% { opacity: 1; } 98% { opacity: 0; } }
.tutorial__svg .tut-p4-arrow      { opacity: 0; }
.tutorial__svg .tut-p4-arr-fa-3   { animation-name: tut-p4-arr-fa-3; }
.tutorial__svg .tut-p4-arr-fa-4   { animation-name: tut-p4-arr-fa-4; }
.tutorial__svg .tut-p4-arr-fa-7   { animation-name: tut-p4-arr-fa-7; }
.tutorial__svg .tut-p4-arr-fb-5   { animation-name: tut-p4-arr-fb-5; }
.tutorial__svg .tut-p4-arr-fb-4   { animation-name: tut-p4-arr-fb-4; }
.tutorial__svg .tut-p4-arr-fb-9   { animation-name: tut-p4-arr-fb-9; }
.tutorial__svg .tut-p4-arr-fc-4   { animation-name: tut-p4-arr-fc-4; }
.tutorial__svg .tut-p4-arr-fc-3   { animation-name: tut-p4-arr-fc-3; }
.tutorial__svg .tut-p4-arr-fc-5   { animation-name: tut-p4-arr-fc-5; }
.tutorial__svg .tut-p4-arr-fc-12  { animation-name: tut-p4-arr-fc-12; }

/* ── Panel 5: coupling arrows + group / coupling-tag pulses ────────
   12 s cycle, 4 phases (3 s each):
     0–25 %  arrow A→B, A and B pulse, "B" tag above A column glows
     25–50 % arrow B→A, ditto, "A" tag above B column glows
     50–75 % arrow B→C, B and C pulse, "C" tag above B column glows
     75–100% arrow C→B, "B" tag above C column glows
   Both A↔B coupling letters live above the A / B columns; the B
   column has TWO tags (A and C), so we glow them alongside their
   phases via a single mixed keyframe. */
.tutorial__svg .tut-p5-arrow {
  opacity: 0;
  stroke-width: 4;
  animation-duration: 20s;
  animation-iteration-count: infinite;
  /* Snap on/off rather than ease — arrows just pop in and out. */
  animation-timing-function: step-end;
}
/* Only one structure arrow shows at a time — completely hidden
   outside its phase rather than ghosted in. 20 s cycle, 4 phases of
   5 s each, ~4 s "fully visible" hold within each phase. With
   step-end timing each segment holds the value of its starting
   keyframe, so an explicit "off" keyframe at the end of the
   on-period is required to make the arrow disappear before the
   next phase begins. */
@keyframes tut-p5-arrow-1 {
  0%, 22%, 100%      { opacity: 0; }
  1%                 { opacity: 1; }
}
@keyframes tut-p5-arrow-2 {
  0%, 25%, 47%, 100% { opacity: 0; }
  26%                { opacity: 1; }
}
@keyframes tut-p5-arrow-3 {
  0%, 50%, 72%, 100% { opacity: 0; }
  51%                { opacity: 1; }
}
@keyframes tut-p5-arrow-4 {
  0%, 75%, 97%, 100% { opacity: 0; }
  76%                { opacity: 1; }
}
.tutorial__svg .tut-p5-arrow-ac { animation-name: tut-p5-arrow-1; }
.tutorial__svg .tut-p5-arrow-ca { animation-name: tut-p5-arrow-2; }
.tutorial__svg .tut-p5-arrow-cb { animation-name: tut-p5-arrow-3; }
.tutorial__svg .tut-p5-arrow-bc { animation-name: tut-p5-arrow-4; }

/* Group circle pulses on the structure. A pulses in phases 1+2;
   B pulses in phases 3+4; C is in every phase since it couples
   to both A and B. */
@keyframes tut-p5-pop-a {
  0%, 100%      { transform: scale(1);   filter: none; }
  1%, 21%       { transform: scale(1.3); filter: drop-shadow(0 0 5px rgba(45,169,79,.5)); }
  24%, 25%      { transform: scale(1);   filter: none; }
  26%, 46%      { transform: scale(1.3); filter: drop-shadow(0 0 5px rgba(45,169,79,.5)); }
  49%           { transform: scale(1);   filter: none; }
}
@keyframes tut-p5-pop-c {
  0%, 49%, 100% { transform: scale(1);   filter: none; }
  51%, 71%      { transform: scale(1.3); filter: drop-shadow(0 0 5px rgba(45,169,79,.5)); }
  74%, 75%      { transform: scale(1);   filter: none; }
  76%, 96%      { transform: scale(1.3); filter: drop-shadow(0 0 5px rgba(45,169,79,.5)); }
}
@keyframes tut-p5-pop-b {
  0%, 100%      { transform: scale(1);   filter: none; }
  1%, 21%       { transform: scale(1.3); filter: drop-shadow(0 0 5px rgba(45,169,79,.5)); }
  24%, 25%      { transform: scale(1);   filter: none; }
  26%, 46%      { transform: scale(1.3); filter: drop-shadow(0 0 5px rgba(45,169,79,.5)); }
  49%, 50%      { transform: scale(1);   filter: none; }
  51%, 71%      { transform: scale(1.3); filter: drop-shadow(0 0 5px rgba(45,169,79,.5)); }
  74%, 75%      { transform: scale(1);   filter: none; }
  76%, 96%      { transform: scale(1.3); filter: drop-shadow(0 0 5px rgba(45,169,79,.5)); }
}
.tutorial__svg .tut-p5-group-a { animation: tut-p5-pop-a 20s ease-out infinite; }
.tutorial__svg .tut-p5-group-c { animation: tut-p5-pop-b 20s ease-out infinite; }
.tutorial__svg .tut-p5-group-b { animation: tut-p5-pop-c 20s ease-out infinite; }

/* Coupling-letter glow on the spectrum — only the single letter that
   matches the active phase's structure-arrow direction lights up
   (scale + green). Outside its phase the letter sits at the default
   grey/600 weight. */
@keyframes tut-p5-letter-A-to-C {
  0%, 22%, 100%      { fill: var(--muted); font-weight: 600; transform: scale(1); }
  1%                 { fill: #2da94f;       font-weight: 800; transform: scale(1.5); }
}
@keyframes tut-p5-letter-C-to-A {
  0%, 25%, 47%, 100% { fill: var(--muted); font-weight: 600; transform: scale(1); }
  26%                { fill: #2da94f;       font-weight: 800; transform: scale(1.5); }
}
@keyframes tut-p5-letter-C-to-B {
  0%, 50%, 72%, 100% { fill: var(--muted); font-weight: 600; transform: scale(1); }
  51%                { fill: #2da94f;       font-weight: 800; transform: scale(1.5); }
}
@keyframes tut-p5-letter-B-to-C {
  0%, 75%, 97%, 100% { fill: var(--muted); font-weight: 600; transform: scale(1); }
  76%                { fill: #2da94f;       font-weight: 800; transform: scale(1.5); }
}
.tutorial__svg text.tut-p5-letter-A-to-C { animation: tut-p5-letter-A-to-C 20s step-end infinite; }
.tutorial__svg text.tut-p5-letter-C-to-A { animation: tut-p5-letter-C-to-A 20s step-end infinite; }
.tutorial__svg text.tut-p5-letter-C-to-B { animation: tut-p5-letter-C-to-B 20s step-end infinite; }
.tutorial__svg text.tut-p5-letter-B-to-C { animation: tut-p5-letter-B-to-C 20s step-end infinite; }

/* Per-phase indicator arrow on the number line, pointing from above
   the matching group's column to the specific coupling letter that
   highlights this phase. */
.tutorial__svg .tut-p5-spec-arrow {
  opacity: 0;
  animation-duration: 20s;
  animation-iteration-count: infinite;
  animation-timing-function: step-end;
}
@keyframes tut-p5-spec-arrow-1 {
  0%, 22%, 100%      { opacity: 0; }
  1%                 { opacity: 1; }
}
@keyframes tut-p5-spec-arrow-2 {
  0%, 25%, 47%, 100% { opacity: 0; }
  26%                { opacity: 1; }
}
@keyframes tut-p5-spec-arrow-3 {
  0%, 50%, 72%, 100% { opacity: 0; }
  51%                { opacity: 1; }
}
@keyframes tut-p5-spec-arrow-4 {
  0%, 75%, 97%, 100% { opacity: 0; }
  76%                { opacity: 1; }
}
.tutorial__svg .tut-p5-spec-arrow-1 { animation-name: tut-p5-spec-arrow-1; }
.tutorial__svg .tut-p5-spec-arrow-2 { animation-name: tut-p5-spec-arrow-2; }
.tutorial__svg .tut-p5-spec-arrow-3 { animation-name: tut-p5-spec-arrow-3; }
.tutorial__svg .tut-p5-spec-arrow-4 { animation-name: tut-p5-spec-arrow-4; }

/* Helper captions above the structure — one per direction, so the
   wording mirrors which way the active arrow is pointing. step-end
   so each caption snaps in/out instead of fading. */
.tutorial__svg .tut-p5-helper-1,
.tutorial__svg .tut-p5-helper-2,
.tutorial__svg .tut-p5-helper-3,
.tutorial__svg .tut-p5-helper-4 { opacity: 0; }
@keyframes tut-p5-helper-1 {
  0%, 22%, 100%      { opacity: 0; }
  1%                 { opacity: 1; }
}
@keyframes tut-p5-helper-2 {
  0%, 25%, 47%, 100% { opacity: 0; }
  26%                { opacity: 1; }
}
@keyframes tut-p5-helper-3 {
  0%, 50%, 72%, 100% { opacity: 0; }
  51%                { opacity: 1; }
}
@keyframes tut-p5-helper-4 {
  0%, 75%, 97%, 100% { opacity: 0; }
  76%                { opacity: 1; }
}
.tutorial__svg .tut-p5-helper-1 { animation: tut-p5-helper-1 20s step-end infinite; }
.tutorial__svg .tut-p5-helper-2 { animation: tut-p5-helper-2 20s step-end infinite; }
.tutorial__svg .tut-p5-helper-3 { animation: tut-p5-helper-3 20s step-end infinite; }
.tutorial__svg .tut-p5-helper-4 { animation: tut-p5-helper-4 20s step-end infinite; }

/* Pause briefly before the first phase fires when the panel is
   first shown — gives the viewer a moment to take in the static
   structure + spectrum before arrows start cycling. Applies once
   per panel mount; subsequent loop iterations cycle without delay. */
.tutorial__svg [class*="tut-p5-"] {
  animation-delay: 1.5s !important;
}

/* ── Panel 6: build-and-match ──────────────────────────────────────
   12 s cycle:
     0–35 %   hold detached state (ghost spectrum + 2 leftover As @3)
     35–45 %  triangle slides into dock; bond fades in
     45–60 %  ghost layer fades out, matched layer fades in
     60–100%  hold full match (✓✓✓✓ visible) */
/* 20 s cycle, three discrete spectrum states. Spectrum / bond
   transitions snap (step-end), so the spectrum and bond visually
   change the instant a bond forms. The triangle and loose-A
   movements smoothly slide in/out (ease-in-out). Timeline:
     0–12 %   Step 0: triangle detached, one A floating.
     12–18 %  Triangle slides into dock.
     18 %     Step 0 spectrum snaps to step 1; tri-square bond
              snaps in.
     18–28 %  Step 1: triangle docked, partial A column shown.
     28–33 %  Floating A slides into the triangle's top vertex.
     33 %     Step 1 spectrum snaps to step 2; loose-A bond snaps
              in.
     33–100 % Step 2: everything matched; "Spectrum Solved! ✓"
              tag fades in above the structure. */
@keyframes tut-p6-tri-slide {
  0%, 12%   { transform: translateX(0); }
  18%, 100% { transform: translateX(60px); }
}
@keyframes tut-p6-loose-a-slide {
  0%, 28%   { transform: translate(var(--dx), var(--dy)); }
  33%, 100% { transform: translate(0, 0); }
}
@keyframes tut-p6-dock-bond-fade {
  0%        { opacity: 0; }
  18%, 100% { opacity: 1; }
}
@keyframes tut-p6-loose-bond-fade {
  0%        { opacity: 0; }
  33%, 100% { opacity: 1; }
}
@keyframes tut-p6-state-0-fade {
  0%        { opacity: 1; }
  18%, 100% { opacity: 0; }
}
@keyframes tut-p6-state-1-fade {
  0%, 17%, 33%, 100% { opacity: 0; }
  18%                { opacity: 1; }
}
@keyframes tut-p6-state-2-fade {
  0%, 32%   { opacity: 0; }
  33%, 100% { opacity: 1; }
}
@keyframes tut-p6-matched-text-fade {
  0%, 35%   { opacity: 0; transform: scale(.6); }
  40%, 100% { opacity: 1; transform: scale(1); }
}
@keyframes tut-p6-loose-vertex-fade {
  0%        { opacity: 0; }
  33%, 100% { opacity: 1; }
}

.tutorial__svg .tut-p6-tri          { animation: tut-p6-tri-slide        20s ease-in-out infinite; }
.tutorial__svg .tut-p6-loose-a      { animation: tut-p6-loose-a-slide    20s ease-in-out infinite; }
.tutorial__svg .tut-p6-dock-bond    { opacity: 0; animation: tut-p6-dock-bond-fade   20s step-end infinite; }
.tutorial__svg .tut-p6-loose-bond   { opacity: 0; animation: tut-p6-loose-bond-fade  20s step-end infinite; }
.tutorial__svg .tut-p6-state-0      { animation: tut-p6-state-0-fade     20s step-end infinite; }
.tutorial__svg .tut-p6-state-1      { opacity: 0; animation: tut-p6-state-1-fade     20s step-end infinite; }
.tutorial__svg .tut-p6-state-2      { opacity: 0; animation: tut-p6-state-2-fade     20s step-end infinite; }
.tutorial__svg .tut-p6-matched-text { opacity: 0; animation: tut-p6-matched-text-fade 20s ease-out infinite; }
/* Loose A's vertex dot — hide while floating (no bond yet); snap
   in once the circle docks against the triangle's top vertex. */
.tutorial__svg .tut-p6-loose-a .shape__vertex {
  opacity: 0;
  animation: tut-p6-loose-vertex-fade 20s step-end infinite;
}

/* ── Reduced motion: snap to a sensible static end-state ──────────── */
@media (prefers-reduced-motion: reduce) {
  .tutorial__svg [class*="tut-p"] {
    animation: none !important;
    opacity: 1 !important;
    filter: none !important;
    transform: none !important;
  }
  /* Snap panel 6 to its final matched state. */
  .tutorial__svg .tut-p6-tri          { transform: translateX(60px) !important; }
  .tutorial__svg .tut-p6-loose-a      { transform: translate(0, 0) !important; }
  .tutorial__svg .tut-p6-dock-bond,
  .tutorial__svg .tut-p6-loose-bond,
  .tutorial__svg .tut-p6-state-2,
  .tutorial__svg .tut-p6-matched-text { opacity: 1 !important; }
  .tutorial__svg .tut-p6-state-0,
  .tutorial__svg .tut-p6-state-1      { opacity: 0 !important; }
  /* Panel 4: hide the formula overlays, prefix labels and linking
     arrows so the panel reads cleanly when motion is disabled. */
  .tutorial__svg [class*="tut-p4-f"],
  .tutorial__svg [class*="tut-p4-prefix"],
  .tutorial__svg .tut-p4-arrow      { opacity: 0 !important; }
  /* Panel 5: structure arrows visible at low opacity; helper captions
     and per-phase indicator arrows hidden since they would otherwise
     all show at once outside their animation phases. */
  .tutorial__svg .tut-p5-arrow     { opacity: .65 !important; }
  .tutorial__svg [class*="tut-p5-helper"],
  .tutorial__svg .tut-p5-spec-arrow { opacity: 0 !important; }
}

/* ── Tutorial-puzzles page (Learn) ───────────────────────────────── */

/* Inline link inside the tutorial modal body — used by panel 7 to
   point players who're still confused at the Learn page. */
.tutorial__cta-section {
  text-align: center;
  margin-top: 0.5rem;
}
.tutorial__cta-btn {
  display: inline-block;
  padding: 0.35em 1em;
  border: 1px solid var(--accent);
  border-radius: 6px;
  color: var(--accent);
  text-decoration: none;
  font-size: 0.9em;
  font-weight: 600;
  line-height: 1.5;
}
.tutorial__cta-btn:hover {
  background: var(--accent);
  color: #fff;
}

/* Puzzle-picker dropdown that replaces the puzzle-band slot in the
   toolbar on `tutorial-puzzles.html`. */
.tutorial-dropdown {
  position: relative;
}
.tutorial-dropdown__btn {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
}
.tutorial-dropdown__chev {
  font-size: 0.75em;
  opacity: 0.7;
}
.tutorial-dropdown__menu {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  margin: 0;
  padding: 4px 0;
  list-style: none;
  background: var(--bg);
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  box-shadow: 0 4px 14px rgba(0, 0, 0, 0.12);
  min-width: 220px;
  max-height: 70vh;
  overflow-y: auto;
  z-index: 20;
}
.tutorial-dropdown__menu[hidden] { display: none; }
.tutorial-dropdown__item {
  display: flex;
  align-items: center;
  gap: 0.6em;
  width: 100%;
  padding: 0.4em 0.8em;
  border: 0;
  background: transparent;
  font: inherit;
  color: var(--fg);
  cursor: pointer;
  text-align: left;
}
.tutorial-dropdown__item:hover {
  background: #f0f4fb;
}
.tutorial-dropdown__item--current {
  background: #eef3fb;
  font-weight: 600;
}
.tutorial-dropdown__item--solved .tutorial-dropdown__mark {
  color: #1e6b3a;
  font-weight: 700;
}
.tutorial-dropdown__mark {
  display: inline-block;
  width: 1.2em;
  text-align: center;
  color: #aaa;
}
.tutorial-dropdown__title { flex: 1 1 auto; }
.tutorial-dropdown__sep {
  border-top: 1px solid var(--line-soft);
  margin: 4px 0;
  height: 0;
  list-style: none;
}
.tutorial-dropdown__item--reset {
  color: #b8311c;
}
.tutorial-dropdown__item--reset:hover {
  background: #fff0f0;
}

/* Hint modal — anchored inside the canvas-wrap (rather than the
   viewport) so it never grows wider than the canvas itself, and
   sits near the top so it doesn't obscure the hint button or zoom
   cluster at the bottom. */
/* Hint modal — paged content. Covers the full viewport so the panel
   can extend past the canvas bottom on tall puzzles (the canvas-wrap
   clips its children, so anchoring the modal there capped its height
   to the canvas). The panel itself is still capped at canvas-ish
   width and 100vh. */
.hint-modal {
  position: fixed;
  inset: 0;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  padding: 16px;
  background: rgba(0, 0, 0, 0.35);
  z-index: 100;
}
@media (min-width: 768px) {
  .hint-modal { align-items: center; }
}
.hint-modal[hidden] { display: none; }
.hint-modal__panel {
  position: relative;
  display: flex;
  flex-direction: column;
  background: var(--bg);
  border: 1px solid var(--line-soft);
  border-radius: 12px;
  padding: 1rem 1.25rem;
  width: 100%;
  max-width: min(520px, 100%);
  max-height: calc(100vh - 32px);
  overflow: hidden;
  box-shadow: 0 8px 30px rgba(0, 0, 0, 0.18);
}

/* Pin the page nav + accept/solution rows at the bottom so they stay
   in view on cramped viewports (landscape phones, text-zoom). The
   title + body + structure preview live in the scroll wrapper above
   and overflow internally only when content can't shrink any more. */
.hint-modal__scroll {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
}
.hint-modal__panel > .tutorial__footer,
.hint-modal__panel > .hint-modal__solution {
  flex: 0 0 auto;
}

/* The hint modal reuses .tutorial__body for type, but its prose is a
   single static line — so kill the 5.8em min-height the tutorial uses
   to keep its nav buttons pinned, and trim the bottom margin so the
   structure preview can sit closer to the body text. */
.hint-modal .tutorial__body {
  min-height: 0;
  margin-bottom: 0.5rem;
}

/* "View Solution" escape hatch lives at the bottom of the hint
   modal — players who tried hints first and still want to give up
   can do it without a separate top-bar button. The styling sits
   below the prev/next nav, separated by a hairline so it reads as
   a tertiary action. */
.hint-modal__solution {
  margin-top: 0.75rem;
  padding-top: 0.6rem;
  border-top: 1px solid var(--line-soft);
  display: flex;
  justify-content: center;
  gap: 0.5rem;
  flex-wrap: wrap;
}

/* Structure preview rendered into each progressive-reveal hint page.
   Explicit pixel dimensions avoid the `height: auto` + viewBox
   intrinsic-sizing trap that could collapse the SVG to zero height
   when its viewBox is rewritten at render time. renderCanvas writes a
   fresh viewBox to fit the actual shapes; preserveAspectRatio="meet"
   letterboxes the structure inside the box. */
.hint-modal__art {
  display: block;
  width: 100%;
  max-width: 420px;
  height: 280px;
  margin: 0.25rem auto 0;
}
.hint-modal__art[hidden] { display: none; }

/* Hint-accept confirmation: floats below the hint modal panel rather
   than dimming the page on top of it (the hint modal's own backdrop
   is already in place). main.js positions the panel via inline
   styles after measuring the hint panel's bottom. */
#hint-accept-confirm {
  background: transparent;
  pointer-events: none;
  z-index: 110;
}
#hint-accept-confirm .modal__panel {
  pointer-events: auto;
  width: auto;
  max-width: min(520px, calc(100vw - 32px));
}

/* Hint pill — sits in the lower-left of the canvas, mirroring the
   zoom cluster's placement on the lower-right. White fill, heavy
   black border, fully rounded; lightbulb glyph + uppercase HINT. */
.canvas-hint-btn {
  position: absolute;
  bottom: 10px;
  left: 10px;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 14px 6px 12px;
  font-size: 0.78rem;
  font-family: inherit;
  background: linear-gradient(180deg, #fff8d8 0%, #ffe79a 100%);
  border: 1px solid var(--fg);
  border-radius: 999px;
  cursor: pointer;
  color: var(--fg);
  z-index: 4;
  transition: background 0.1s;
}
.canvas-hint-btn:hover { background: linear-gradient(180deg, #ffefb0 0%, #ffd874 100%); }
.canvas-hint-btn__icon { flex: 0 0 auto; }
.canvas-hint-btn__label {
  font-weight: 700;
  letter-spacing: 0.1em;
  font-size: 12px;
}

/* ── About page ─────────────────────────────────────────────────────
   Science-journal / long-form-magazine treatment. Scoped to .about so
   the rest of the app keeps its sans-serif voice. No grey card frames
   — figures and Q/A blocks sit on the page background, separated by
   hairline rules and an editorial accent color. A CSS counter on
   `.about__figure, .about__compare-item` drives the FIG. N labels. */
.about {
  --about-rule: rgba(17, 17, 17, 0.18);
  --about-rule-soft: rgba(17, 17, 17, 0.1);
  --about-accent: #b3261e; /* journal-red, distinct from the app's blue */
  max-width: 720px;
  width: 100%;
  align-self: center;
  padding: 0.5rem 0 1rem;
  font-family: Georgia, "Iowan Old Style", "Palatino Linotype",
               "Book Antiqua", Palatino, "Source Serif Pro", serif;
  color: var(--fg);
  counter-reset: about-fig;
}

/* Section breaks — full-width hairline broken by a small NMR peak
   glyph. The glyph progresses singlet → doublet → triplet → quartet
   down the page, mirroring the splitting patterns the page teaches. */
.about__divider {
  display: flex;
  align-items: center;
  gap: 14px;
  width: 100%;
  margin: 2rem 0 1.1rem 0;
  color: var(--muted);
}
.about__divider:first-child { margin: 0.25rem 0 1.4rem 0; }
.about__divider-line {
  flex: 1 1 0;
  height: 1px;
  background: currentColor;
  opacity: 0.45;
}
.about__divider-glyph {
  flex: 0 0 auto;
  display: block;
  opacity: 0.75;
}

/* Headline. One word picked out in italic red mirrors the magazine
   look ("What every *peak* is saying"). Tight leading, no all-caps. */
.about__title {
  font-size: clamp(2rem, 5.2vw, 2.7rem);
  font-weight: 700;
  line-height: 1.08;
  margin: 0 0 0.9rem 0;
  letter-spacing: -0.005em;
}
.about__title-accent {
  font-style: italic;
  color: var(--about-accent);
  font-weight: 700;
}
.about__heading {
  font-size: 1.55rem;
  font-weight: 700;
  line-height: 1.15;
  margin: 0 0 0.7rem 0;
  letter-spacing: -0.003em;
}
.about__divider + .about__heading { margin-top: 0; }
/* Q/A sub-questions ("Where does each peak sit…"). A long red
   horizontal dash leads each one so the three sub-sections read as
   clearly separated editorial beats inside "Reading the peaks". */
.about__subheading {
  font-size: 1.25rem;
  font-weight: 700;
  line-height: 1.2;
  margin: 1.6rem 0 0.55rem 0;
}
.about__subheading::before {
  content: "";
  display: block;
  width: 3.25rem;
  height: 2px;
  background: var(--about-accent);
  margin: 0 0 0.55rem 0;
}

/* Lede / subtitle paragraph that sits just under a main section
   heading (e.g. "Both pictures answer the same questions." under
   "Reading the peaks"). Muted italic so it reads as a deck. */
.about__subtitle {
  font-size: 1.02rem;
  line-height: 1.5;
  font-style: italic;
  color: var(--muted);
  margin: -0.2rem 0 1.1rem 0;
}
.about__body {
  font-size: 1.02rem;
  line-height: 1.62;
  margin: 0 0 0.95rem 0;
}
.about__body em { font-style: italic; color: var(--fg); }

/* Figures. No card chrome — image floats on the page, separated from
   body copy by a thin top hairline. The figcaption opens with a small
   gray "FIG. N" label driven by a CSS counter, then runs into the
   italic caption text inline. */
.about__figure,
.about__compare-item {
  margin: 0.6rem 0 0.8rem 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  padding: 0;
  background: transparent;
  border: 0;
  border-radius: 0;
  counter-increment: about-fig;
}
.about__compare > .about__compare-item { margin: 0; }

/* Stacked panel pair (dexamethasone structure + NMR). Both figures
   sit inside one hairline frame divided by a single internal rule —
   they read as two equal panes of a diptych. Container has a fixed
   aspect-ratio so the two grid rows split evenly; each image scales
   via object-fit to fill its pane while preserving its own ratio.
   Aspect ratio is tuned so the NMR (~960×666) fills its row at the
   ~720px desktop column width without cropping. */
.about__panel-pair {
  margin: 1rem 0 1.2rem;
  border: 1px solid var(--about-rule);
  background: var(--bg);
  display: grid;
  grid-template-rows: 1fr 1fr;
  aspect-ratio: 720 / 1140;
  max-height: 1140px;
}
.about__panel-pair > .about__figure {
  margin: 0;
  padding: 0.9rem 1rem 0.85rem;
  min-height: 0;
  display: grid;
  grid-template-rows: 1fr auto;
  align-items: center;
  justify-items: center;
  gap: 0.45rem;
  counter-increment: about-fig;
  overflow: hidden;
}
.about__panel-pair > .about__figure + .about__figure {
  border-top: 1px solid var(--about-rule);
}
.about__panel-pair .about__img {
  width: 100%;
  height: 100%;
  max-width: 100%;
  max-height: 100%;
  object-fit: contain;
  border-radius: 0;
}
.about__panel-pair .about__img--narrow {
  /* Keep the structure visually narrow inside its pane. */
  max-width: 340px;
}
.about__panel-pair .about__img--white {
  background: #fff;
  padding: 0.5rem;
}
.about__panel-pair .about__caption {
  align-self: end;
  margin: 0;
}
.about__caption {
  font-size: 0.86rem;
  color: var(--muted);
  line-height: 1.5;
  text-align: center;
  max-width: 60ch;
  font-style: italic;
  font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif;
}
.about__caption::before {
  content: "FIG. " counter(about-fig) "  ";
  font-style: normal;
  font-weight: 700;
  letter-spacing: 0.14em;
  color: var(--fg);
  font-size: 0.74rem;
  margin-right: 0.15rem;
}
.about__img {
  display: block;
  width: 100%;
  max-width: 560px;
  height: auto;
  border-radius: 0;
}
.about__img--narrow { max-width: 320px; }
.about__img--wide   { max-width: 100%; }
.about__img--white  { background: #fff; padding: 0.5rem; }
.about__credit {
  display: block;
  margin-top: 0.4rem;
  font-size: 0.72rem;
  font-style: normal;
  letter-spacing: 0.02em;
  opacity: 0.7;
}

/* Stacked "puzzle vs. real spectrum" comparison. Each item still
   gets a FIG. N label (it counts in the same counter), and a small
   serif kicker heading above the image — no card background. */
.about__compare {
  display: flex;
  flex-direction: column;
  gap: 1rem;
  margin: 0.4rem 0 0.6rem 0;
}
.about__compare-head {
  font-size: 1.55rem;
  font-weight: 400;
  font-style: normal;
  line-height: 1.2;
  margin: 0;
  align-self: center;
  text-align: center;
  color: var(--fg);
}
.about__compare-accent {
  font-style: italic;
  font-weight: 700;
  color: var(--about-accent);
}

/* Two-column "Multiplet vs. NMR" answer blocks. Now an open editorial
   table: a vertical hairline between the columns, no fills, label row
   carries an accessory glyph (shape icons on the left, ¹H on the
   right) just like a real journal sidebar. */
.about__qa-cols {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 0;
  margin: 0.4rem 0 0.5rem 0;
  border-top: 1px solid var(--about-rule);
  border-bottom: 1px solid var(--about-rule);
}
.about__qa-col {
  padding: 0.85rem 1.1rem 0.95rem;
  background: transparent;
  border: 0;
  min-width: 0;
}
.about__qa-col + .about__qa-col {
  border-left: 1px solid var(--about-rule-soft);
}
.about__qa-col__label {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
               Helvetica, Arial, sans-serif;
  font-size: 0.74rem;
  font-weight: 700;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--fg);
  margin-bottom: 0.55rem;
  padding-bottom: 0.45rem;
  border-bottom: 1px solid var(--about-rule-soft);
}
/* Accessory glyphs on the right side of each label.
   Multiplet → three inline SVG shape outlines colored from the canvas
   palette (triangle red, circle blue, square green); the SVGs are
   sized identically so the row reads as a balanced trio. NMR → a
   serif "¹H" proton tag at matched height. */
.about__qa-icons {
  display: inline-flex;
  align-items: center;
  gap: 6px;
}
.about__qa-icons svg {
  display: block;
  width: 16px;
  height: 16px;
  flex: 0 0 16px;
}
.about__qa-tag {
  font-family: Georgia, "Iowan Old Style", serif;
  font-weight: 400;
  font-size: 1rem;
  letter-spacing: 0;
  color: var(--muted);
  text-transform: none;
}
.about__qa-tag sup {
  font-size: 0.7em;
  vertical-align: super;
  line-height: 0;
  margin-right: 0.05em;
}
.about__qa-col__body {
  font-size: 1rem;
  line-height: 1.55;
  margin: 0;
}
/* Editorial sidenote — centered serif italic, no flanking rule.
   `em` inside the italic line flips to bold roman so the emphasized
   term (e.g. "chemical shift") still stands out. */
.about__aside {
  font-family: inherit;
  font-size: 1rem;
  color: var(--fg);
  font-style: italic;
  line-height: 1.5;
  margin: 0.6rem auto 1.4rem;
  text-align: center;
  max-width: 56ch;
}
.about__aside em {
  font-style: normal;
  color: var(--fg);
  font-weight: 700;
}

/* "Where the name comes from" — restyled as a magazine pull-quote:
   top + bottom hairline rules, small sans-serif kicker, then a large
   serif body. No left bar, no blue tint. */
.about__namesake {
  margin: 1.4rem 0 1.9rem;
  padding: 1rem 0 1.1rem;
  border-left: 0;
  border-radius: 0;
  border-top: 1px solid var(--fg);
  border-bottom: 1px solid var(--fg);
  background: transparent;
}
.about__namesake-eyebrow {
  font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
               Helvetica, Arial, sans-serif;
  font-size: 0.74rem;
  font-weight: 700;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--fg);
  margin-bottom: 0.7rem;
}
.about__namesake-body {
  margin: 0;
  font-size: 1.22rem;
  line-height: 1.5;
  color: var(--fg);
  font-family: Georgia, "Iowan Old Style", "Palatino Linotype", serif;
}
.about__namesake-word {
  font-weight: 700;
  font-style: italic;
  letter-spacing: 0;
  color: var(--about-accent);
}

@media (max-width: 540px) {
  .about__title { font-size: 1.85rem; }
  .about__heading { font-size: 1.3rem; }
  .about__subheading { font-size: 1.1rem; }
  .about__namesake-body { font-size: 1.08rem; }
  .about__qa-cols { grid-template-columns: 1fr; }
  .about__qa-col + .about__qa-col {
    border-left: 0;
    border-top: 1px solid var(--about-rule-soft);
  }
}

/* ── Puzzle shell + desktop page background ──────────────────────
   `.puzzle-shell` wraps the header + main + drawer/backdrop so the
   drawer has a containing block to slide over. On all viewports,
   layout doesn't change — only `position: relative` is set. The
   desktop page background switches to soft gray so the drawer
   slides over a contrasting surface (mobile stays full-bleed
   white, per spec). */
.puzzle-shell {
  position: relative;
  display: flex;
  flex-direction: column;
  flex: 1 0 auto;
  gap: 0.35rem;
  min-height: 0;
  /* Contain the drawer's off-canvas (translated -100%) state so the
     page doesn't grow a horizontal scrollbar while it's animating
     in/out. */
  overflow-x: clip;
}

/* Aligned to 768px so the gray-bg/card transition lights up at the
   same width as the desktop scaling formula above (line 188). With
   the previous 720px threshold there was a 48px-wide window where the
   page showed the gray desktop background but the puzzle was still
   using mobile-fluid sizing — this collapses both transitions into a
   single one. */
@media (min-width: 768px) {
  body {
    background: var(--page-bg);
    /* Minimum gray frame above/below the card. Auto margins on .app
       absorb additional vertical space at taller viewports so the
       card stays centered. */
    padding: 16px 0;
  }
  /* Desktop: the puzzle shell reads as a contained card on the gray
     page surface — rounded corners, white fill, with `overflow: hidden`
     so the drawer's left edge gets clipped to the card's curve. The
     drawer's own right edge stays square (it's an internal panel
     boundary, not the card's outer corner). The shell sizes to its
     content (no flex-grow) so the card height tracks the canvas+
     spectrum stack rather than stretching to the viewport — this
     rule has to live here, after the base `.puzzle-shell` block, to
     win the cascade against the mobile `flex: 1 0 auto` default. */
  .puzzle-shell {
    flex: 0 0 auto;
    border-radius: 16px;
    overflow: hidden;
    background: var(--bg);
  }
}

/* ── Drawer (slides in from the puzzle-shell's left edge) ───────── */
.drawer__backdrop {
  position: absolute;
  inset: 0;
  background: rgba(20, 20, 20, 0.32);
  opacity: 0;
  transition: opacity 200ms ease;
  z-index: 60;
  pointer-events: none;
}
.drawer__backdrop.is-open {
  opacity: 1;
  pointer-events: auto;
}
.drawer__backdrop[hidden] { display: none; }

.drawer {
  position: absolute;
  top: 0;
  left: 0;
  height: 100%;
  width: 82%;
  max-width: 420px;
  background: var(--bg);
  border-right: 1px solid var(--line-soft);
  box-shadow: 2px 0 16px rgba(0, 0, 0, 0.06);
  display: flex;
  flex-direction: column;
  transform: translateX(-100%);
  transition: transform 200ms ease;
  z-index: 70;
  overflow-y: auto;
  overflow-x: hidden;
}
.drawer.is-open { transform: translateX(0); }
.drawer[hidden] { display: none; }

/* Mobile: escape the puzzle-shell so the drawer + dim cover the full
   viewport (the shell sits inside `.app`'s padding, which would
   otherwise leave a white frame around the dim). Desktop keeps the
   absolute scoping so the drawer slides inside the rounded card.
   Must come after `.drawer { position: absolute }` above — equal
   specificity, source order decides. */
@media (max-width: 767px) {
  .drawer__backdrop,
  .drawer { position: fixed; }
}

.drawer__top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  padding: 14px 16px;
  border-bottom: 1px solid var(--line-soft);
  background: var(--bg);
}
.drawer__title {
  font-weight: 800;
  letter-spacing: 0.32em;
  font-size: 0.95rem;
  text-transform: uppercase;
}
.drawer__close {
  width: 32px;
  height: 32px;
  border: 1px solid var(--line-soft);
  border-radius: 50%;
  background: var(--bg);
  color: var(--muted);
  font-size: 1.2rem;
  line-height: 1;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.drawer__close:hover { color: var(--fg); border-color: var(--fg); }

/* ─ Daily section (tinted-blue panel at top). Always a very subtle
   blue wash so the Daily Puzzle card + Timer rows below it read as
   one grouped panel; deepens to a more saturated blue when the Daily
   card is the current page so the active state still pops. Inline
   rows sit on a tight 6px gap so the trio reads as contiguous,
   matching the way the white nav rows below stack flush. */
.drawer__daily-section {
  background: var(--bg);
  padding: 14px;
  border-bottom: 1px solid var(--line-soft);
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.drawer__daily-section:has(.drawer__daily-card[data-current="true"]) {
  background: #f4f7fc;
}
.drawer__daily-card[data-current="true"] {
  border-color: var(--c-circle);
  box-shadow: 0 0 0 1.5px var(--c-circle);
}
/* Non-daily pages: the Timer + Disable Timer rows have no real
   timer to control, so they're shown as passive readouts — dimmed,
   italicized, non-interactive. The Daily Puzzle card above stays
   fully clickable (it's the link back to the puzzle). */
.drawer__daily-section--readonly .drawer__row--readout-only,
.drawer__daily-section--readonly .drawer__row--toggle {
  opacity: 0.55;
  font-style: italic;
  pointer-events: none;
  cursor: default;
}
.drawer__daily-section--readonly .drawer__row-status { display: none; }

/* The Daily Puzzle card is the only bordered/elevated row in the
   drawer. Soft blue border, white fill, 12px radius. */
.drawer__daily-card {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 12px 14px;
  background: var(--bg);
  border: 1px solid var(--c-circle);
  border-radius: 12px;
  text-decoration: none;
  color: var(--fg);
  cursor: pointer;
}
.drawer__daily-card:hover { background: #f6f9fe; }
.drawer__daily-text {
  flex: 1 1 auto;
  min-width: 0;
  display: flex;
  flex-direction: column;
  gap: 2px;
}
.drawer__daily-meta {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 0.78rem;
  color: var(--muted);
}
.drawer__daily-meta .puzzle-band {
  /* Inside the daily card the band is rendered as a small uppercase
     chip. The data-band colours below override the default. */
  font-size: 0.68rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  padding: 2px 8px;
}
.drawer__daily-meta .puzzle-band[hidden] { display: none; }
.drawer__date { font-weight: 600; color: var(--fg); }
.drawer__daily-title {
  font-size: 1rem;
  font-weight: 700;
}
.drawer__daily-sub {
  font-size: 0.82rem;
  color: var(--muted);
}
.drawer__chevron {
  flex: 0 0 auto;
  color: var(--c-circle);
  font-size: 1.4rem;
  font-weight: 700;
  line-height: 1;
}

/* ─ Inline rows (Pause, Disable) inside the soft section ─ */
.drawer__row {
  display: flex;
  align-items: center;
  gap: 14px;
  width: 100%;
  padding: 8px 4px;
  min-height: 48px;
  background: transparent;
  border: 0;
  font: inherit;
  color: var(--fg);
  text-align: left;
  cursor: pointer;
  box-sizing: border-box;
}
/* Inline rows live inside .drawer__daily-section's 16px padding; no
   horizontal padding of their own so the icon column aligns with the
   nav rows below (whose own 16px row-padding lands at the same X). */
.drawer__row--inline { padding: 6px 0; }
.drawer__row-label {
  flex: 1 1 auto;
  font-weight: 600;
  font-size: 0.95rem;
}
.drawer__pill-btn {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 5px 16px;
  border: 1px solid var(--fg);
  border-radius: 999px;
  background: var(--bg);
  color: var(--fg);
  font-size: 0.85rem;
  font-weight: 500;
  cursor: pointer;
}
.drawer__pill-btn:hover { background: #fafafa; }
.drawer__pill-btn-icon {
  display: inline-flex;
  align-items: center;
  margin-right: 6px;
  font-size: 0.7rem;
  line-height: 1;
}
/* Live timer reading shown on the Timer row. Tabular-nums keeps the
   width stable as digits tick over so the row doesn't jitter. */
.drawer__row-timer {
  flex: 0 0 auto;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  font-size: 0.95rem;
  color: var(--fg);
}
/* "Paused" status badge — sits where the Pause/Resume pill used to.
   Opening the drawer is the pause mechanism (main.js wires onOpen
   to timer.pause), so the badge always reads "Paused" while the
   drawer is visible. A soft amber chip matches the row's pause-glyph
   color token. */
.drawer__row-status {
  flex: 0 0 auto;
  padding: 3px 10px;
  border-radius: 999px;
  background: #fef3c7;
  color: #a16207;
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.04em;
}
/* Disable-Timer toggle on: hide the live reading + Paused badge so
   just the pause-shape icon and "Timer" label remain. State is
   preserved underneath — flipping the toggle back brings them back. */
.drawer__row--timer-off .drawer__row-timer,
.drawer__row--timer-off .drawer__row-status {
  display: none;
}

.drawer__row--toggle { cursor: pointer; }
.drawer__toggle {
  flex: 0 0 auto;
  position: relative;
  width: 42px;
  height: 24px;
  display: inline-block;
}
.drawer__toggle input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
  z-index: 2;
}
.drawer__toggle-track {
  position: absolute;
  inset: 0;
  background: var(--line-soft);
  border-radius: 999px;
  transition: background 0.15s;
}
.drawer__toggle-track::after {
  content: "";
  position: absolute;
  top: 2px;
  left: 2px;
  width: 20px;
  height: 20px;
  background: var(--bg);
  border-radius: 50%;
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.15);
  transition: transform 0.15s;
}
.drawer__toggle input:checked + .drawer__toggle-track { background: var(--accent); }
.drawer__toggle input:checked + .drawer__toggle-track::after { transform: translateX(18px); }

.drawer__hairline {
  height: 1px;
  background: var(--line-soft);
  margin: 0 12px;
}

/* ─ Nav rows (white) ─ */
.drawer__nav {
  display: flex;
  flex-direction: column;
}
.drawer__row--nav {
  padding: 14px 16px;
  border-bottom: 1px solid var(--line-soft);
  text-decoration: none;
}
.drawer__row--nav:hover { background: #fafafa; }
.drawer__row-text {
  flex: 1 1 auto;
  min-width: 0;
}
.drawer__row-sub {
  font-size: 0.82rem;
  color: var(--muted);
  font-style: italic;
  margin-top: 1px;
}
.drawer__row[data-current="true"] {
  background: #fafafa;
}
.drawer__row[data-current="true"] .drawer__row-label {
  color: var(--accent);
}

/* Read-only streak row: same chrome as nav rows but no chevron and no
   hover/cursor — players can see their current streak without leaving
   the drawer (tapping it does nothing). */
.drawer__row--readout { cursor: default; }
.drawer__row--readout:hover { background: transparent; }
.drawer__streak-flame {
  margin-left: 4px;
  font-size: 0.95rem;
  line-height: 1;
}
.drawer__streak-unit {
  margin-left: 4px;
  font-weight: 500;
  color: var(--muted);
}
.drawer__streak-box {
  flex: 0 0 auto;
  min-width: 38px;
  padding: 4px 12px;
  border: 1px solid var(--line-soft);
  border-radius: 8px;
  background: var(--bg);
  color: var(--fg);
  font-weight: 600;
  font-size: 0.95rem;
  text-align: center;
  font-variant-numeric: tabular-nums;
}

/* Polygon-icon container — fixed footprint so rows align. */
.drawer__icon {
  flex: 0 0 auto;
  width: 36px;
  height: 36px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}

/* ─ Footer (Author dev link + copyright) ─ */
.drawer__footer {
  margin-top: auto;
  padding: 14px 16px;
  border-top: 1px solid var(--line-soft);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.drawer__author {
  font-size: 0.75rem;
  color: var(--muted);
  text-decoration: none;
}
.drawer__author:hover { color: var(--fg); }
.drawer__copyright {
  font-size: 0.75rem;
  color: var(--muted);
}

/* ── tutorial-puzzles.html: dropdown picker row beneath the toolbar ── */
.tutorial-dropdown-row {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
  padding: 4px 0 8px 0;
}
.tutorial-nav {
  display: flex;
  align-items: center;
  gap: 8px;
}
.tutorial-nav__arrow {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 32px;
  height: 32px;
  border: 2px solid #1d4ed8;
  border-radius: 50%;
  background: #eff6ff;
  color: #1d4ed8;
  font-size: 22px;
  line-height: 1;
  font-weight: 700;
  cursor: pointer;
  padding: 0 0 2px 0;
}
.tutorial-nav__arrow:hover:not(:disabled) {
  background: #dbeafe;
  border-color: #1e40af;
  color: #1e40af;
}
.tutorial-nav__arrow:disabled {
  background: #f1f5f9;
  border-color: #c7d2e2;
  color: #94a3b8;
  cursor: default;
}
.tutorial-dropdown-row .tutorial-dropdown { position: relative; }
.tutorial-dropdown-row .tutorial-dropdown__menu { left: 50%; transform: translateX(-50%); }
.tutorial-dropdown-row .tutorial-dropdown__btn {
  display: inline-flex;
  align-items: center;
  gap: 0.4em;
  padding: 0.15em 0.5em;
  border: 0;
  background: transparent;
  font-size: 1.35rem;
  font-weight: 700;
  color: var(--fg);
  cursor: pointer;
  line-height: 1.2;
  border-radius: 6px;
}
.tutorial-dropdown-row .tutorial-dropdown__btn:hover {
  background: #f0f4fb;
}
.tutorial-dropdown-row .tutorial-dropdown__chev {
  font-size: 0.7em;
  opacity: 0.65;
  font-weight: 400;
}
.tutorial-tips {
  margin: 0;
  padding: 0 12px;
  font-size: 12px;
  line-height: 1.35;
  color: var(--muted);
  text-align: center;
  max-width: 640px;
}
@media (max-width: 480px) {
  .tutorial-dropdown-row .tutorial-dropdown__btn { font-size: 1.15rem; }
  .tutorial-nav__arrow { width: 28px; height: 28px; font-size: 20px; }
  .tutorial-tips { font-size: 11px; }
}

/* ── Pre-puzzle landing overlay ─────────────────────────────────
   Covers the puzzle area until the player clicks Play.
   Sits inside `.puzzle-shell` so on desktop it's
   clipped by the card's rounded corners and matches its bounds
   exactly. On mobile it escapes to `position: fixed` and fills
   the viewport, matching the drawer/backdrop pattern. */
.prepuzzle {
  position: absolute;
  inset: 0;
  background: var(--bg);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 24px;
  z-index: 80;
}
.prepuzzle[hidden] { display: none; }

.prepuzzle__panel {
  display: flex;
  flex-direction: column;
  align-items: center;
  text-align: center;
  gap: 14px;
  max-width: 460px;
  width: 100%;
}

/* Decorative SVG banner above the wordmark. Sizing rules:
   - Mobile: fills the panel width (panel already pads in from the
     viewport edges), so it reads as "full-width with padding".
   - Desktop: capped at 460px (~20% wider on each side than the
     MULTIPLET wordmark) and additionally height-clamped via the
     `vh` term so it can't push the rest of the panel off-screen on
     short browser windows. The viewport-height-based width takes
     over before the pixel cap on very short windows, keeping the
     whole landing page visible.
   `aspect-ratio` matches the SVG's viewBox so the rounded clip
   doesn't show any letterboxing. */
.prepuzzle__banner {
  display: block;
  width: min(100%, 460px, calc(34vh * 650 / 430));
  aspect-ratio: 650 / 430;
  border-radius: 16px;
  overflow: hidden;
}
.prepuzzle__title {
  margin: 0;
  font-size: 2.1rem;
  font-weight: 800;
  letter-spacing: 0.28em;
}
.prepuzzle__sub {
  margin: 0;
  font-size: 0.95rem;
  color: var(--muted);
  max-width: 30em;
  line-height: 1.45;
}
/* Date · No. XXX · [band] row sitting beneath the Play button. */
.prepuzzle__meta {
  display: flex;
  justify-content: center;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 4px;
  font-size: 0.85rem;
  color: var(--muted);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.prepuzzle__date,
.prepuzzle__number {
  font-weight: 600;
}
.prepuzzle__meta-dot {
  color: var(--muted);
  opacity: 0.6;
}
/* Reserve enough width for the widest band label ("Very Hard") so the
   row doesn't reflow horizontally when the real text swaps in. */
.prepuzzle__band {
  min-width: 5.25em;
  justify-content: center;
  padding: 0.15rem 0.6rem;
  font-size: 0.8rem;
  letter-spacing: 0.05em;
  text-transform: none;
}
.prepuzzle__streak {
  display: inline-flex;
  align-items: baseline;
  gap: 0.4rem;
  margin-top: 4px;
  font-size: 1.35rem;
  font-weight: 700;
  color: var(--fg);
}
.prepuzzle__streak-flame {
  font-size: 1.5rem;
  line-height: 1;
}
.prepuzzle__streak-count {
  font-size: 1.6rem;
  font-weight: 800;
}
.prepuzzle__streak-label {
  font-size: 1rem;
  font-weight: 600;
  color: var(--muted);
  letter-spacing: 0.04em;
}
/* Play button — chunky 3D feel built from geometry, not blur. Purple
   face sits on a solid darker-purple lip (6px hard shadow, no soft
   drop shadow); inset highlights bevel the face (white top, dark
   bottom). Press collapses the lip so the button literally sits down
   onto the surface. */
.prepuzzle__play {
  margin-top: 16px;
  margin-bottom: 12px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  appearance: none;
  border: none;
  background: linear-gradient(180deg, #b779e0 0%, #8a3fc4 55%, #5b2a86 100%);
  color: #fff;
  font-family: inherit;
  font-size: 1.1rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  padding: 16px 52px;
  border-radius: 14px;
  cursor: pointer;
  min-width: 220px;
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.30),
    inset 0 -2px 0 rgba(0, 0, 0, 0.18),
    0 6px 14px rgba(0, 0, 0, 0.18);
  transition: transform 0.08s ease, box-shadow 0.12s ease, background 0.12s ease, opacity 0.12s ease;
}
.prepuzzle__play-icon {
  font-size: 0.85rem;
  transform: translateY(-1px);
}
.prepuzzle__play:hover {
  background: linear-gradient(180deg, #c98ce8 0%, #9648d2 55%, #6d3296 100%);
  box-shadow:
    inset 0 2px 0 rgba(255, 255, 255, 0.36),
    inset 0 -2px 0 rgba(0, 0, 0, 0.18),
    0 8px 18px rgba(0, 0, 0, 0.22);
}
.prepuzzle__play:active {
  background: linear-gradient(180deg, #5b2a86 0%, #8a3fc4 100%);
  transform: translateY(1px);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.16),
    inset 0 -1px 0 rgba(0, 0, 0, 0.26),
    0 2px 6px rgba(0, 0, 0, 0.18);
}
.prepuzzle__play:focus-visible {
  outline: 2px solid #8a3fc4;
  outline-offset: 4px;
}

/* Entrance animation. Children start lifted + transparent; flipping
   `.is-ready` on the overlay (from JS, after the puzzle JSON resolves)
   plays a keyframe animation that eases each child into place with a
   small upward motion. Keyframes (rather than transitions) so the
   animation runs reliably even when the JSON resolves before the
   first paint — e.g. cached loads on fast mobile networks, where a
   transition would otherwise be skipped because the browser never
   committed the initial state. opacity + transform only =
   compositor-only, zero layout cost. */
@keyframes prepuzzle-enter {
  from { opacity: 0; transform: translateY(22px); }
  to   { opacity: 1; transform: none; }
}
.prepuzzle__banner,
.prepuzzle__title,
.prepuzzle__sub,
.prepuzzle__play,
.prepuzzle__meta,
.prepuzzle__streak {
  opacity: 0;
  transform: translateY(22px);
}
.prepuzzle.is-ready .prepuzzle__banner { animation: prepuzzle-enter 520ms ease-out   0ms forwards; }
.prepuzzle.is-ready .prepuzzle__title  { animation: prepuzzle-enter 520ms ease-out  70ms forwards; }
.prepuzzle.is-ready .prepuzzle__sub    { animation: prepuzzle-enter 520ms ease-out 140ms forwards; }
.prepuzzle.is-ready .prepuzzle__play   { animation: prepuzzle-enter 520ms ease-out 210ms forwards; }
.prepuzzle.is-ready .prepuzzle__meta   { animation: prepuzzle-enter 520ms ease-out 280ms forwards; }
.prepuzzle.is-ready .prepuzzle__streak { animation: prepuzzle-enter 520ms ease-out 350ms forwards; }

@media (prefers-reduced-motion: reduce) {
  @keyframes prepuzzle-enter {
    from { opacity: 0; transform: none; }
    to   { opacity: 1; transform: none; }
  }
  .prepuzzle.is-ready .prepuzzle__banner,
  .prepuzzle.is-ready .prepuzzle__title,
  .prepuzzle.is-ready .prepuzzle__sub,
  .prepuzzle.is-ready .prepuzzle__play,
  .prepuzzle.is-ready .prepuzzle__meta,
  .prepuzzle.is-ready .prepuzzle__streak {
    animation-duration: 160ms;
    animation-delay: 0ms;
  }
}

@media (max-width: 767px) {
  .prepuzzle { position: fixed; }
}

