/* ============================================================
   THE BUTTON LAB — button.css

   One button, one mechanism, written once. Nothing in this file is
   shared with site/kit/ — no import, no shared class name for the
   mechanics (only the two reference-comparison blocks near the
   bottom reuse the OLD look on purpose, under their own `.ref-`
   prefix, so the new button can be held up against it).

   -----------------------------------------------------------------
   THE SCALE
   Rise and sink are one signed value, --v, from -1.5 to 1.5. Zero is
   flat. Any button sets --v-rest / --v-hover / --v-press to any point
   on that scale independently. There is no separate "sink mode" —
   sink is just --v being negative.

   -----------------------------------------------------------------
   ROUND 2 — two bugs, and why the structure below now has FOUR boxes
   instead of three.

   Bug 1 (hairline): the round-1 face painted its coloured fill AND its
   dark edge on the exact same box (background + an inset box-shadow).
   Same box, two paint operations, and at a fractional devicePixelRatio
   they round independently — the fill won by a sub-pixel on every
   side. Fix: the fill is now a SEPARATE, physically smaller box
   (.fill) nested inside the edge box (.face). Two boxes can't share a
   rounding error because they don't share an edge to round.

   Bug 2 (shadow overtake): a flush row overlaps neighbours by exactly
   one stroke on purpose (facts 8, and the shared-seam rule). At rest
   that's invisible — DOM order alone decides who wins the shared
   sliver. Round 1 gave a HOVERED button's whole .btn (footprint
   included) a boosted z-index so its rising face could clear a
   neighbour, and that dragged the (never-moving) footprint above the
   neighbour too — the neighbour's face is what's supposed to win that
   sliver, always, and round 1's boost let the shadow beat it instead.
   Fix: only .face ever gets promoted (.btn.z-top/.z-drop now target
   `> .face`, not the button itself). The footprint's z-index (0) is
   never touched by hover, so it can never outrank ANY neighbour's
   face (minimum z 1) — see "STACKING" below for the full reasoning.

   THE PROPOSAL (docs/BUTTON-LAB-BRIEF-2.md) suggested rebuilding the
   whole button as two independently-moving stacked shapes. Adopted in
   spirit, not literally: rather than two elements that move
   separately, .face (dark) still moves as ONE unit exactly as before
   — rise, sink, the shared seam and z-stacking all still work for the
   reasons proved in round 1 — and .fill just rides along inside it,
   permanently centred, permanently --sw smaller. That gets every
   benefit named in the proposal (no coincident edges, a shape gets
   the same line weight as a rectangle, stroke weight is just "how
   much smaller") without touching the parts of the mechanism that
   were already measured correct. See shapes.js for the non-rectangle
   case, where "smaller" has to mean a true geometric inset, not a
   scaled copy — the proposal's own concern about the tag's point.

   -----------------------------------------------------------------
   THE MECHANISM (comments cite the brief's numbered MEASURED facts)

   .btn          the FRAME. Position: relative. NEVER transforms, NEVER
                 gets its own z-index (see STACKING). Its own box is
                 the box everything else is measured against.
   .btn::before  the FOOTPRINT/shadow. inset: 0, flat fill colour, no
                 spread, z-index 0, always. When the face lifts away
                 on rise, this static rectangle is what becomes
                 visible as "the shadow" — a shape, not a glow.
   .btn::after   the HIT-AREA EXTENDER (fact 6). Dead at rest. While
                 hovered it grows toward wherever --lift has visually
                 carried the face, so the pointer is never standing
                 over dead space between the moved face and the still
                 frame.
   .face         the EDGE box. Same box as the frame at rest, painted
                 with the edge colour as a plain background — no
                 box-shadow ring (that was Bug 1). RISE moves this
                 element outward; SINK never moves it, the frame is
                 the boundary a sunk face must stay inside (fact 5).
   .face > .fill the FILL box. Sits centred inside .face, smaller by
                 exactly --sw on every side (a real inset, either a
                 CSS margin for rectangles or a true polygon offset
                 for shapes — see shapes.js). This is what carries the
                 button's colour, its label, and the sink lip.
   .fill::before the SINK LIP (facts 5, 9). Directional, leading-edge
                 only, the theme's ink token, never moves a box.
   .lab          the label. Travels inward on sink so "pushed in"
                 still reads even though .fill's own box holds still.

   Colour discipline: nothing above is gated by :hover or :active.
   Fill / text / edge resolve once, from the *-rest tokens (now set on
   .btn itself, so the footprint can match a state colour too), and
   stay that way through hover and press. A real state change
   (selected / complete / disabled / danger) is a class — a different
   thing from a pointer passing over the button.

   -----------------------------------------------------------------
   STACKING (fact 7 + Bug 2) — why nothing here sets z-index on .btn.

   .face always has a `transform` (identity counts), which alone makes
   it establish its own stacking context regardless of z-index value.
   .btn does NOT establish one (position:relative with z-index:auto
   never does). That means .face does not compare against its OWN
   `.btn::before` any differently than it would against a NEIGHBOURING
   button's .face or ::before — every footprint and every face in a
   row is flattened into one shared stacking order, compared by
   z-index first (footprint 0, face 1, boosted face 4/5) and DOM order
   only as a tiebreak. A footprint can never be given a z-index high
   enough to beat a face without ALSO giving .btn one — and the moment
   .btn gets a real z-index, its whole subtree (footprint included)
   becomes one walled unit that outranks a neighbour's individual,
   unwalled children entirely, which is exactly Bug 2. Keeping the
   promotion on .face alone means the footprint's z-index (0) can
   never exceed even a resting neighbour's face (1) — the shared seam
   always belongs to whichever face is meant to own it.
   ============================================================ */

/* ---------------------------------------------------------------
   --v REGISTRATION (round 6, item 4). Plain custom properties jump
   instantly between values — only properties that consume them via
   calc() (like `transform`, when IT carries its own `transition`) ever
   visibly ease. That makes "where is --v RIGHT NOW, mid-animation"
   unanswerable from outside, which is exactly what the sink z-order fix
   needs (MEASURED fact 7: z-index doesn't interpolate, so JS has to
   stamp a class at the moment --v crosses zero — see lab.js). Registering
   --v with a typed syntax makes it a first-class animatable value: a
   CSS transition on --v itself now genuinely interpolates, frame by
   frame, and every dependent calc() (--lift, --push, and everything
   built from them) recomputes reactively in step — see .btn below,
   where --v's transition lives, and the STACKING v2 note further down.
   ------------------------------------------------------------- */
@property --v {
  syntax: "<number>";
  inherits: false;
  initial-value: 0;
}

/* ---------------------------------------------------------------
   A section becomes a --u container by carrying this class. --u is
   defined with a container-query length, so nesting a narrower
   .rise-scope inside a wider one re-bases every button under it
   with no per-breakpoint media query anywhere. Falls back safely
   (clamps to its floor) if no container ever exists.
   ------------------------------------------------------------- */
.rise-scope { container-type: inline-size; container-name: rise; }

/* --u lives in its OWN rule, scoped separately from the rest of the
   tokens. Bug caught by measurement in round 1: an earlier draft
   declared the whole palette on ".rise-scope" too. Since
   <body class="rise-scope"> sits below <html>, that gave body its own
   (light-hardcoded) copy of every token — a same-element declaration
   always beats an inherited one, no matter how low its specificity,
   so it silently overrode the dark theme applied to :root for
   everything inside body. --u must be re-computable per container,
   but colour and structure must not be re-asserted anywhere but
   :root, or a theme switch stops reaching into any scoped section. */
.rise-scope { --u: clamp(1.6rem, 4.6cqi, 2.75rem); }

:root {
  --u: 1.6rem; /* safety net if nothing ever establishes a .rise-scope container */
  --sw: 3px;
  --radius: 0px;
  --offset: 6px;
  --dur: 200ms;
  --dur-press: 70ms;
  --ease: cubic-bezier(.22, .68, .24, 1);

  /* the one signed scale + direction, per-button overridable */
  --rise-x: -1;
  --rise-y: -1;
  --v-rest: 0;
  --v-hover: .75;
  --v-press: -.25;

  /* theme: Light (default) */
  --ground: #FAF0D8;
  --panel: #FFF8E6;
  --panel-2: #F3E7CC;
  --stroke: #201A12;
  --text: #201A12;
  --muted: #6E5836;
  --faint: #9A8355;
  --accent: #FED700;
  --on-accent: #201A12;
  --ink: #201A12;
  --cream: #FFF8E6;
  --ok: #3F8A4C;
  --danger: #B33C24;

  /* round 6 — DARK MODE, A REAL DESIGN PASS. This indirection is the
     mechanism the redesign hangs on: --fill-default/--fg-default are
     what an ORDINARY (no state class) button's fill/text resolve to —
     see .btn's --fill/--fg below, which read these instead of hardcoding
     var(--accent)/var(--on-accent) directly. Light keeps the original,
     unchanged behaviour (every plain button is BB yellow with ink text —
     nothing here regresses light mode, byte for byte the same result).
     Dark overrides ONLY these two, right below, which is the entire
     redesign: no new colour VALUES anywhere, only where the existing
     ones get used. See the [data-theme="dark"] block for the reasoning. */
  --fill-default: var(--accent);
  --fg-default: var(--on-accent);

  /* Lexend everywhere (see font.css for the @font-face), mono reserved
     for things that specifically need it — code, technical readouts.
     Round 1/2 had button labels in mono for a "kit" look; round 3
     drops that in favour of the brief's explicit rule. */
  --font-body: 'Lexend', system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
  --font-mono: ui-monospace, "Cascadia Mono", Consolas, "SF Mono", Menlo, monospace;
}

/* ------------------------------------------------------------------
   DARK MODE — round 6 design pass. The brief: same palette (ink, cream,
   BB yellow, green), different roles. Light mode's mechanical opposite —
   every plain button solid yellow, exactly as bright on a dark ground as
   on a light one — is what got called out: a big saturated fill that
   anchors a light page just shouts on a near-black one, and yellow that
   never varies stops meaning anything once it's everywhere.

   The redesign, in one line: on dark, ONLY a button that has genuinely
   EARNED colour (selected, complete, danger) gets a big saturated fill.
   An ordinary button is quiet — a dark panel tone with cream text and
   the same cream edge every button already has — so when yellow (or
   green, or red) does show up, it reads as a real signal instead of
   wallpaper. This is exactly "workshop dark": ink and cream doing most
   of the talking, colour spent deliberately.

   What did NOT move, on purpose (all still governed by --edge = --stroke,
   completely untouched by this pass, per the brief's own constraint that
   outline and shadow follow one theme token and never carry state):
   - the outline weight and colour (still --stroke, still ink-on-light /
     cream-on-dark)
   - the footprint/shadow (still --edge, same as the outline)
   - hover/press (still geometry only, still never touches colour)

   What DID move:
   - --fill-default flips from --accent (yellow) to --panel-2 — an
     ordinary button's fill is now a dark, slightly-raised surface
     instead of a colour block. --fg-default flips from --on-accent
     (ink, only legible on yellow) to --text (cream), which is what the
     new dark fill actually needs. MEASURED contrast, cream --text
     (#F3E0BC) on --panel-2 (#322818): ~13:1 — comfortably legible.
   - .is-complete's text: on light, --ok (#3F8A4C) is dark enough that
     --cream text works (MEASURED ~4.0:1). On dark, --ok (#7BC47F) is a
     much LIGHTER green — the same cream text MEASURED at only ~1.9:1
     against it (fails outright), while --ink MEASURES ~8.2:1. Text
     colour for "complete" is the one place this pass had to become
     theme-conditional rather than just re-pointing a shared token, since
     the two themes' green sits on opposite sides of the "which text
     wins" line — see the override below and the matching one on THE
     ONE's .sel.full .nm.
   - .is-danger's text: same shape of problem, smaller margin. Light
     --danger (#B33C24) is dark enough that --cream MEASURES ~5.5:1
     (--ink only ~2.9:1, fails). Dark --danger (#E0654A) is lighter —
     --cream drops to ~3.1:1 (borderline) while --ink MEASURES ~5.0:1,
     the clearly safer choice — overridden the same way, below.
   - .is-selected is untouched in both themes: --accent (#FED700) is
     bright enough that --ink text MEASURES ~12:1 against it regardless
     of theme, so yellow-selected reads identically light or dark — which
     is exactly the point: selection is the one state that should look
     the same everywhere, since it's not "earned" the way complete/danger
     are, it's just "this one, right now."
   ------------------------------------------------------------------ */
[data-theme="dark"] {
  --ground: #201A12;
  --panel: #2A2318;
  --panel-2: #322818;
  --stroke: #F3E0BC;
  --text: #F3E0BC;
  --muted: #D8B987;
  --faint: #A0824B;
  --cream: #FFF3D2;
  --ok: #7BC47F;
  --danger: #E0654A;
  /* --accent / --on-accent / --ink stay put on purpose: the accent
     yellow and the ink it sits on are brand colour, not surface. */

  --fill-default: var(--panel-2);
  --fg-default: var(--text);
}
[data-theme="dark"] .btn.is-complete { --fg-rest: var(--ink); }
[data-theme="dark"] .btn.is-danger { --fg-rest: var(--ink); }

/* ============================================================
   THE FRAME
   ============================================================ */
.btn {
  position: relative;
  display: inline-block;
  line-height: 0;
  margin: 0; padding: 0; border: 0;
  background: none;
  cursor: pointer;
  appearance: none;
  font: inherit;
  vertical-align: top;
  overflow: visible;
  /* Round 6 (docs/ROUND6-BRIEF.md item 2) — a `<button>` never carries a browser
     underline, but a `.btn` built on an `<a>` (site/p/index.html's back/BrickLink/
     Rebrickable/card links, e.g.) does, and inherits the browser's link blue unless
     something overrides it. Only site/front/front.css's own `a.btn` rule did that,
     so any page that links this stylesheet without also linking front.css — the
     piece page is exactly that case — shipped underlined, blue-tinted "buttons".
     Moved here, onto `.btn` itself, so every consumer of this file gets it for
     free regardless of which other stylesheets it happens to load; nothing in the
     system relies on that underline existing (it was never a designed treatment,
     just a missing reset), so this is a pure bug fix, not a style change anyone
     depended on. front.css's own `a.btn` rule becomes a harmless duplicate. */
  text-decoration: none;
  color: inherit;

  /* state tokens live here (not on .face) so both the footprint AND
     the face/fill can read them — see the Bug 2 footprint-colour note.
     round 6: the no-state fallback now goes through --fill-default /
     --fg-default rather than hardcoding var(--accent)/var(--on-accent)
     directly — that indirection is what lets dark mode redefine what an
     ORDINARY button looks like without touching what a genuinely
     SELECTED one looks like (.is-selected still sets --fill-rest to
     var(--accent) explicitly, in both themes — see the dark-mode block
     above for why that one stays put). Light mode's fallback still
     resolves to --accent/--on-accent, so this is a no-op there. */
  --fill: var(--fill-rest, var(--fill-default));
  --fg: var(--fg-rest, var(--fg-default));
  --edge: var(--edge-rest, var(--stroke));

  --v: var(--v-rest);
  /* round 6, item 4: --v is registered via @property (top of file) with
     syntax:"<number>", which is what makes it a genuinely INTERPOLATING,
     sampleable value instead of one that jumps instantly while only its
     dependants (--lift, transform) visibly ease — see the STACKING v2
     note further down for why lab.js needs exactly that to fix the sink
     z-order bug. transitioning --v HERE, and nowhere else, is also why
     .face/.fill::before/.lab no longer carry their own `transition:
     transform|box-shadow ...` below: --lift/--push are calc()s of --v,
     so once --v itself eases smoothly, every dependant recomputes in
     lockstep, every frame, for free. Giving transform its OWN transition
     on top of an already-easing input would double-ease (the dependant
     transitions chasing an already-moving target) — MEASURED via
     Animation.currentTime scrubbing (this tab's document.hidden freezes
     both the compositor AND requestAnimationFrame, so real-time timing
     can't be observed directly, but the interpolation and every
     depending calc() were confirmed exact at 0/25/50/75/100% of a
     transition scrubbed programmatically). */
  transition: --v var(--dur) var(--ease);
  /* outward travel — only the positive half of the scale */
  --lift: calc(var(--offset) * max(0, var(--v)));
  /* inward depth — only the negative half, made positive */
  --push: calc(var(--offset) * max(0, calc(var(--v) * -1)));
}
.btn:hover { --v: var(--v-hover); }
.btn:active { --v: var(--v-press); transition-duration: var(--dur-press); }
.btn.block { display: block; width: 100%; }

/* THE FOOTPRINT — fact 2/3: exactly the frame box, no spread. Uses
   --edge (not a hardcoded --stroke) so a state-coloured button's
   shadow matches its edge instead of always reading as plain ink. */
.btn::before {
  content: "";
  position: absolute; inset: 0; z-index: 0;
  background: var(--edge);
  clip-path: var(--shape-clip, none);
  border-radius: var(--radius);
  pointer-events: none;
}

/* THE HIT-AREA EXTENDER — fact 6. Deliberately NOT clipped by
   --shape-clip: it has to be free to grow past the shape's
   silhouette on hover, or a shaped button reintroduces the vibration
   bug this exists to prevent. */
.btn::after {
  content: "";
  position: absolute; inset: 0; z-index: 3;
  pointer-events: none;
}
.btn:hover::after {
  pointer-events: auto;
  top:    calc(var(--lift) * max(0, calc(var(--rise-y) * -1)) * -1);
  bottom: calc(var(--lift) * max(0, var(--rise-y)) * -1);
  left:   calc(var(--lift) * max(0, calc(var(--rise-x) * -1)) * -1);
  right:  calc(var(--lift) * max(0, var(--rise-x)) * -1);
}

/* ============================================================
   THE FACE — the edge box. Plain background, no ring: painting the
   edge and painting the fill are now two different elements, so
   there is nothing left for a fractional-DPR rounding mismatch to
   happen BETWEEN (Bug 1).
   ============================================================ */
.btn > .face {
  position: relative; z-index: 1;
  /* align-items:stretch (the flex default — round 2 had left it at
     "center", found by measurement to be the cause of icon buttons and
     the flush row's short labels leaving an empty band top/bottom: a
     flex child with no grow/stretch takes only its OWN content size,
     so .fill never actually reached .face's edges on anything shorter
     than its own natural height). Stretch makes .fill's size come from
     .face's box, not from whatever happens to be inside it. */
  display: flex; align-items: stretch; justify-content: center;
  width: 100%; height: 100%; box-sizing: border-box;
  border: 0; border-radius: var(--radius);
  background: var(--edge);
  clip-path: var(--shape-clip, none);

  /* round 6: no `transition` of its own any more — `transform` is a pure
     calc() of --lift, and --lift is a pure calc() of --v, which now
     carries the ONLY transition in this chain (on .btn — see above). A
     second, independent transition here would retarget every frame as
     --v eased (a moving target), double-easing the motion; removing it
     makes rise/sink purely reactive to the one thing that's actually
     animating. Shape rotation (`rotate(var(--shape-rot))`, appended to
     this same property for [data-shape] buttons) loses its own smooth
     spin as a result — instant now instead of eased — a deliberate,
     minor trade-off flagged in the round-6 report, not an oversight. */
  transform: translate(calc(var(--lift) * var(--rise-x)), calc(var(--lift) * var(--rise-y)));
}
/* hover/press promotion targets .face ONLY — see the STACKING note
   in the header comment. .btn itself must never receive a z-index.
   round 6: promotion now has a THIRD level, not just on/off — see the
   STACKING v2 note below THE ONE's z-classes for the full reasoning.
   z-top (risen, promoted) and z-under (sunk, demoted) are stamped from
   JS by lab.js, which samples the SIGN of the live, now-interpolating
   --v every frame while its transition is in flight. z-under sits at
   z-index:0 rather than something negative — tied with the footprint's
   own z-index:0, but DOM order (face always follows ::before in the
   same element) still resolves that tie in the face's favour, so a
   sunk button never disappears behind its own shadow; it just can no
   longer outrank a resting neighbour's face (z-index:1).

   round 8: a FOURTH level, z-settle (4), added for the button lab.js is
   fixing. Round 7 held z-top for the whole ~240ms return-to-rest window
   after the pointer left, so a fast sweep across the grid left several
   buttons all sitting at z-index 5 at once — a genuine tie, resolved by
   DOM order, which is exactly the "later in the grid wins" bug the user
   reported. z-settle sits strictly between resting (0/1) and the single
   current z-top (5) so a button that's still easing back to rest stays
   above its resting neighbours without ever being able to tie the
   button actually under the pointer. Deliberately NOT 3, to avoid a new
   tie with the (invisible, paints nothing) hit-extender's z-index:3. */
.btn.z-top > .face { z-index: 5; }
.btn.z-settle > .face { z-index: 4; }
.btn.z-under > .face { z-index: 0; }

/* ============================================================
   THE FILL — a real, physically smaller box, centred in .face.
   Rectangles get there with a plain CSS margin (fully reactive to
   --sw, zero extra machinery). Shapes get an inline clip-path
   computed by shapes.js from a true polygon inset, because a
   percentage margin can't express "smaller by a constant number of
   pixels" on a non-rectangular path.
   ============================================================ */
.btn > .face > .fill {
  position: relative; z-index: 1;
  flex: 1 1 auto; /* along with .face's align-items:stretch above, this is
    what makes the fill region come from the BUTTON's box rather than
    its content — a flex child with neither grow nor stretch only ever
    takes its content's own size, which is the round-3 "fill doesn't
    reach the edges" bug. */
  /* round 5: flex items default to min-width/min-height:auto, which
     resolves to the MIN-CONTENT size (here, the label's own width) — so
     without an explicit override .fill refused to shrink below its own
     text at small button sizes, MEASURED stuck at 103.8px regardless of
     how small .face's box got, spilling out over the edge (the edge
     "disappearing" was the fill painting over it) and appearing not to
     "scale back" when grown again since it had never actually been
     tracking the button's box in the first place. min-width/min-height:0
     lets .fill's flex-basis/grow math (the thing that's supposed to size
     it from the button's box, per brief 3 §6) actually win over content
     size; overflow:hidden is the backstop brief 5 also asked for — if
     content ever still doesn't fit, it clips instead of spilling past
     the edge. */
  min-width: 0; min-height: 0; overflow: hidden;
  display: flex; align-items: center; justify-content: center;
  margin: var(--sw);
  box-sizing: border-box;
  border-radius: max(0px, calc(var(--radius) - var(--sw)));
  background: var(--fill);
  color: var(--fg);

  font-family: var(--font-body); font-weight: 700; letter-spacing: .02em;
  text-transform: none; white-space: nowrap;
  font-size: calc(var(--u) * .36);
  /* round 5, second finding (not in the brief — found by measuring past
     it): min-width/min-height:0 fixes the flex min-CONTENT floor, but
     padding is a fixed length, not a content size — `border-box` height
     can never go below its own padding-top + padding-bottom (nor width
     below padding-left + padding-right), no matter what flex-shrink or
     min-*:0 say, because CSS won't render a negative content box. At
     --u's ceiling (2.75rem), padding alone is ~37px block / ~62px
     inline — bigger than a 1-grid-cell button's entire 35px box, so the
     residual overflow just moved from "text" to "padding" at the two
     smallest snap-test sizes (measured: 2x1 face 67x35 vs fill
     61.58x36.96; 1x1 face 35x35 vs fill 61.58x36.96 — same fill size
     both times, because padding, not content, was now the floor).
     Fix: cap padding by the button's own actual box instead of only by
     --u. grid.js writes --box-w/--box-h (the exact pixel box it already
     computes for every grid-placed button) as custom properties on the
     element; a button that isn't grid-placed never defines them, so the
     huge fallback (9999px) makes min() a no-op and behaviour is
     unchanged everywhere else. */
  padding-block: min(calc(var(--u) * .42), max(0px, calc((var(--box-h, 9999px) - var(--sw) * 2 - 1.2em) / 2)));
  padding-inline: min(calc(var(--u) * .7), max(0px, calc((var(--box-w, 9999px) - var(--sw) * 2 - 1.2em) / 2)));
}

/* THE SINK LIP — facts 5, 9. Lives on .fill now (it needs to read
   against the FILL colour, not against the edge colour it would be
   nearly invisible on).
   Round 3 fix: the lip used to default to --ink, which never flips
   with the theme — in dark mode a sunk button's lip stayed dark
   ink-on-dark instead of following the light stroke colour everything
   else uses. It now defaults to --edge, the SAME token the outline and
   the footprint read from (declared once on .btn), so outline,
   footprint and lip cannot drift apart from the theme, or from each
   other, again. --lip remains a distinct token in case a future state
   ever needs to override it — nothing sets it today. */
.btn > .face > .fill::before {
  content: "";
  position: absolute; inset: 0; z-index: 2; pointer-events: none;
  border-radius: inherit;
  box-shadow: inset calc(var(--push) * var(--rise-x) * -1) calc(var(--push) * var(--rise-y) * -1)
                     0 0 var(--lip, var(--edge));
  /* round 6: no transition of its own — see the note on .btn's --v
     transition above. --push is a calc() of --v, same as --lift, so the
     lip already eases in lockstep with the rise/sink translate; giving
     box-shadow its own separate transition here would be the identical
     double-easing risk. */
}

/* THE LABEL — travels inward on sink so depth still reads. */
.btn > .face > .fill > .lab {
  position: relative; z-index: 1;
  /* .lab is itself a flex item of .fill (which is display:flex) — same
     min-content trap as .fill described above, one level deeper (an icon
     + text label won't shrink below ITS OWN content either unless told
     to). min-width:0 here is what actually lets .fill's overflow:hidden
     backstop matter: without it, .lab would hold its full content size
     and .fill would just clip a label that never got smaller. */
  min-width: 0;
  display: flex; align-items: center; justify-content: center; gap: .5em;
  /* round 6: no transition here either, same reasoning — .lab's push
     reacts to the same animating --v/--push chain as everything else. */
  transform: translate(calc(var(--push) * var(--rise-x) * -1), calc(var(--push) * var(--rise-y) * -1));
}
.btn > .face > .fill > .lab svg,
.icon {
  width: 1.1em; height: 1.1em; fill: none;
  stroke: currentColor; stroke-width: 2.4;
  stroke-linecap: round; stroke-linejoin: round;
}
/* round 9: MEASURED every icon button 4.01px too high (above 16.57px,
   below 20.58px), identical across every icon, ~0.01px off horizontally
   (noise, already within tolerance). Cause: an icon is authored as
   <i data-icon><svg class="icon">…</svg></i> — icons.js fills the <i>,
   never replaces it — so .lab (display:flex) blockifies its direct
   child, the <i>, not the <svg> sitting two levels down. The <i>
   becomes a block box, but the <svg> INSIDE it stays a plain inline
   replaced element in an ordinary anonymous line box, which reserves
   descender space below its baseline (the classic inline-image gap
   bug) — MEASURED: svg display:inline, vertical-align:baseline, line
   box at .lab's inherited 15.84px font-size/line-height:normal, ~0.25em
   descender ≈ 3.96px, matching the 4.01px almost exactly. CONFIRMED via
   the bare icon row, where the svg IS the flex container's direct
   child (no <i> in between) and is blockified for free: MEASURED
   above==below==0 there already, proof this is specific to the
   <i>-wrapped case, not the icon paths themselves.
   First fix tried and REJECTED by measurement: `display:block` on the
   svg. That does remove it from line-box layout (above/below balance
   to 21.573/21.583), but it also changes how the browser sizes the now
   block-level <i> as a flex item — MEASURED <i>'s width jump from
   ~31.6px (content-hugging) to the full 68.79px available in .fill,
   which broke horizontal centering (left 3px / right 40.156px, pinned
   to the edge instead of centred) on every non-grid .sq button. Fix
   used instead: `line-height: 0` on the <i> wrapper itself (below).
   That collapses the anonymous line box's strut to zero, so a
   vertical-align:baseline svg has nothing left to reserve underneath
   it, while leaving the <i>'s own WIDTH computation (and therefore
   .lab's centring) completely untouched — MEASURED above 21.573 / below
   21.583 (0.01px apart) AND left 21.573 / right 21.583 (0.01px apart),
   both within the 0.5px tolerance, on every icon button checked: the
   bare row (already 0/0, unaffected), the 5x5 icon grid, the button
   size grid's icon cells, and at both .sq default and .sq.sm/.sq.lg
   sizes. Text labels were already symmetric before this change (a text
   node is .lab's OWN direct flex child, no intervening inline wrapper)
   and this rule doesn't touch them — it's scoped to the icon's <i>
   wrapper only, not .lab itself, so .lab's line-height (inherited by
   any sibling text flex item) is untouched. */
.btn > .face > .fill > .lab > i {
  line-height: 0;
}

@media (prefers-reduced-motion: reduce) {
  /* round 6: --v's own transition (declared on .btn) is what actually
     drives all of rise/sink/lip/label now, so THIS is the one rule that
     has to be disabled for reduced motion to genuinely stop the
     animation — the child elements below no longer have transitions of
     their own to turn off, but are listed too in case a future change
     ever gives one of them a transition back. */
  .btn, .btn > .face, .btn > .face > .fill > .lab, .btn > .face > .fill::before { transition: none; }
}

/* ============================================================
   SIZES — every one a multiple of --u
   ============================================================ */
.btn.xs > .face > .fill { font-size: calc(var(--u) * .24); padding: calc(var(--u) * .26) calc(var(--u) * .5); }
.btn.sm > .face > .fill { font-size: calc(var(--u) * .28); padding: calc(var(--u) * .34) calc(var(--u) * .6); }
.btn.lg > .face > .fill { font-size: calc(var(--u) * .40); padding: calc(var(--u) * .50) calc(var(--u) * .85); }
.btn.xl > .face > .fill { font-size: calc(var(--u) * .46); padding: calc(var(--u) * .58) calc(var(--u) * 1); }

/* icon-only square — fixed footprint, no text to size it */
.btn.sq { width: calc(var(--u) * 1.7); height: calc(var(--u) * 1.7); }
.btn.sq > .face > .fill { padding: 0; }
.btn.sq > .face > .fill > .lab svg { width: 46%; height: 46%; }
.btn.sq.sm { width: calc(var(--u) * 1.3); height: calc(var(--u) * 1.3); }
.btn.sq.lg { width: calc(var(--u) * 2.2); height: calc(var(--u) * 2.2); }

/* ============================================================
   SHAPES — round 3 rewrite. Non-rectangular buttons no longer draw
   two clipped polygons (round 2's offsetPolygon() inverted at reflex
   vertices — L, T, cross, step, notch all have them, and the round-2
   screenshot shows the missing edges that produced). Instead: ONE svg
   <path> per shape (see shapes.js), painted with
     fill="var(--fill)" stroke="var(--edge)"
     stroke-width="calc(var(--sw)*2)" paint-order="stroke"
   — the fill paints over the stroke's inner half in a single
   rasterisation, leaving exactly --sw outside on every edge, reflex
   corners included, computed by the renderer instead of by hand.

   Consequence: .face no longer paints its own background or clips
   itself — the svg IS the visible edge+fill now, and it must be free
   to bleed --sw past .face's own (nominal, unpadded) box, or the
   stroke's outer half gets clipped again (brief 1 fact 5, the original
   shaped-button bug). --shape-clip (still consumed by .btn::before,
   for the footprint, and by .fill, for the sink lip) is now the exact
   fill path — always a valid simple polygon, never a computed offset —
   so a shape's footprint and lip can no longer produce stray geometry
   outside the shape either.
   ============================================================ */
.btn[data-shape] > .face {
  background: none; /* the svg paints the edge; .face's own background would
    show as a stray rectangle in the padding between the shape and .face's
    bounding box */
  clip-path: none; /* do NOT clip .face — that would cut off the stroke's
    outward bleed, undoing the whole point of paint-order:stroke */
  /* rotation (L/T/cross/step: square shapes, safe to spin without
     changing the button's own box) rides on the SAME transform as
     rise/sink, applied after translate so it spins in place. */
  transform: translate(calc(var(--lift) * var(--rise-x)), calc(var(--lift) * var(--rise-y))) rotate(var(--shape-rot, 0deg));
}
.btn[data-shape]::before {
  /* the footprint rotates too, so the shadow matches the (possibly
     rotated) shape — but never translates; it's still the static frame. */
  transform: rotate(var(--shape-rot, 0deg));
}
.btn[data-shape] > .face > .shape-svg {
  position: absolute; inset: calc(var(--sw) * -1); /* padded viewBox, matching inset */
  width: calc(100% + var(--sw) * 2); height: calc(100% + var(--sw) * 2);
  overflow: visible; pointer-events: none; z-index: 0;
}
.btn[data-shape] > .face > .shape-svg path { fill: var(--fill); stroke: var(--edge); }
.btn[data-shape] > .face > .fill {
  position: absolute; inset: 0; margin: 0; /* sized in real px by shapes.js to
    match the path's own coordinate space exactly, so clip-path: path(...)
    (also set by shapes.js) lines up with the svg pixel-for-pixel */
  background: none; /* the svg already painted the fill colour; this box exists
    to centre the label and to host the sink lip, clipped to the same path */
  border-radius: 0;
  z-index: 1;
}
.btn[data-shape].tag > .face > .fill { padding-right: calc(var(--u) * .8); } /* room for the point */

/* the rotate control shown under L/T/cross/step in the shape grid */
.shape-rotate-btn {
  margin-top: 6px; font-family: var(--font-mono); font-size: .58rem; letter-spacing: .1em;
  text-transform: uppercase; color: var(--faint); background: none; border: 1px solid var(--faint);
  padding: 3px 8px; cursor: pointer;
}

/* ============================================================
   REAL STATES — the only things allowed to change colour. Not
   hover, not press: selected / complete / disabled / danger. Set on
   .btn (not .face) so the footprint/shadow picks up the same colour.
   ============================================================ */
/* Round 3 finding: is-complete and is-danger were setting --edge-rest,
   recolouring the outline (and, via it, the footprint) — the user
   flagged this as broken. Fixed rule now: earned state changes --fill
   and --fg only. --edge-rest exists as a token so a future state COULD
   theme it, but nothing here sets it — --edge always falls through to
   --stroke, the plain theme token, for every state below. */
.btn.is-selected { --fill-rest: var(--accent); --fg-rest: var(--ink); }
.btn.is-complete { --fill-rest: var(--ok); --fg-rest: var(--cream); }
.btn.is-danger { --fill-rest: var(--danger); --fg-rest: var(--cream); }
.btn.is-quiet { --fill-rest: var(--ground); --fg-rest: var(--muted); }
.btn.is-line { --fill-rest: var(--ground); --fg-rest: var(--text); }

/* disabled: fact 10 — identical box, it just stops moving. */
.btn[disabled], .btn.is-disabled { pointer-events: none; }
.btn[disabled] > .face > .fill, .btn.is-disabled > .face > .fill { opacity: .42; }
.btn[disabled] > .face, .btn.is-disabled > .face,
.btn[disabled] > .face > .fill > .lab, .btn.is-disabled > .face > .fill > .lab,
.btn[disabled] > .face > .fill::before, .btn.is-disabled > .face > .fill::before { transform: none !important; }

/* soft-lock: occupies the same box, inert, but full colour (a step
   short of disabled — e.g. "not yet, but almost"). */
.btn.soft-lock { pointer-events: none; }
.btn.soft-lock > .face,
.btn.soft-lock > .face > .fill > .lab,
.btn.soft-lock > .face > .fill::before { transform: none !important; }

/* ============================================================
   FLUSH ROWS — buttons that sit edge to edge and share one line.
   The overlap is exactly one stroke; nothing here ever changes
   the .btn box itself (only .face transforms), so a risen button
   and a sunk button can sit in the same row without ever breaking
   the shared line between them — see the brief's hardest case.
   ============================================================ */
.row-flush { display: inline-flex; align-items: stretch; max-width: 100%; }
.row-flush > .btn + .btn { margin-left: calc(var(--sw) * -1); }

/* ============================================================
   STATE PREVIEW HELPERS — force a value of --v so rest/hover/press
   can be shown side by side without needing a live pointer.
   ============================================================ */
.show-rest  { --v: var(--v-rest) !important; }
.show-hover { --v: var(--v-hover) !important; }
.show-press { --v: var(--v-press) !important; }

/* ============================================================
   THE ONE — the flagship sorting-tree row (site/kit/registry9.js:20).
   Round 3: substantially reworked. A row is now built from EXACTLY the
   same frame/footprint/face/fill/lip pieces as .btn, at the same
   --offset scale — round 2's row had its own dampened (×.55) lift and
   NO lip or label-push at all, which is the "rows barely move" bug.
   The only thing still row-specific is that movement is horizontal
   only (a list row sliding sideways, not lifting into open space).

   Indent: round 2 narrowed .face for lvl2/lvl3 but left the OUTER row
   (and therefore its footprint) full width — the footprint's own dark
   background then showed as a permanent block to the left of every
   indented row's face. Fixed by indenting the ROW itself (footprint
   included), so an indented row is, in the brief's words, "a normal
   button that simply starts further in" — nothing about it differs
   from lvl1 except its own box being narrower and shifted.
   ============================================================ */
/* Round 4: two changes to the mechanism itself.
   (1) A row's travel used to be hardcoded to horizontal (translate
   x-only, lip x-only, label-push x-only) with a comment calling that
   "a deliberate scope choice for a list row" — the user disagreed
   explicitly. A row now reads --rise-x/--rise-y and moves on both
   axes, with the exact same formulas .btn uses, so it responds to the
   direction pad and rises/sinks identically to every other button.
   (2) The indent used to be an arbitrary --u-based inset. It's now
   exactly one grid point (--gp) per level — see the grid section
   below for why that keeps a row's edges landing on grid lines
   without any extra bookkeeping. */
.the-one { position: relative; }
.the-one-row {
  position: absolute; /* placed by grid.js's placeGridChildren(), like every
    other grid-derived element on this page — see the .the-one[data-grid-auto]
    wrapper in lab.html */
  display: block;
  margin: 0; padding: 0; border: 0; background: none; text-align: left;
  cursor: pointer; font: inherit; appearance: none;
  box-sizing: border-box;

  /* --edge here (not --stroke directly) so it stays exactly what .btn
     uses too — see the colour-discipline note above .btn.is-selected:
     one token drives outline + footprint + lip everywhere on the page,
     and nothing on a row overrides it, exactly like .btn. */
  --edge: var(--stroke);
  --v: var(--v-rest);
  /* round 6: same mechanism as .btn — see the long note there. --v
     transitions HERE (and nowhere else in a row's own chain), which is
     what lets lab.js sample its live sign for the sink z-order fix. */
  transition: --v var(--dur) var(--ease);
  --lift: calc(var(--offset) * max(0, var(--v)));
  --push: calc(var(--offset) * max(0, calc(var(--v) * -1)));
}
/* round 5: no bespoke fallback values here either — a row now reads
   --v-hover/--v-press exactly like .btn does (compare .btn:hover / .btn:active
   above), inheriting whatever the panel's sliders set on :root. The bug this
   fixes lived in the MARKUP, not here (see #the-one's style attribute in
   lab.html, which pinned --v-hover:.6/--v-press:-.35 on the row's own
   container and cascaded down by inheritance) — removed there. These two
   rules were never themselves the cause (the , .5 / , -.3 fallbacks only
   apply if --v-hover/--v-press were unset anywhere in the chain, which they
   never are — :root always defines them) but they're rewritten to drop the
   fallback anyway so nothing here even LOOKS bespoke. */
.the-one-row:hover { --v: var(--v-hover); }
.the-one-row:active { --v: var(--v-press); transition-duration: var(--dur-press); }

.the-one-row::before {
  content: "";
  position: absolute; inset: 0; z-index: 0;
  background: var(--edge);
  pointer-events: none;
}
/* the hit-area extender — identical purpose to .btn::after (fact 6):
   now that a row can rise diagonally, not just slide left, it needs
   the same protection against the pointer ending up over dead space
   as the face moves away from the static frame. */
.the-one-row::after {
  content: ""; position: absolute; inset: 0; z-index: 3; pointer-events: none;
}
.the-one-row:hover::after {
  pointer-events: auto;
  top:    calc(var(--lift) * max(0, calc(var(--rise-y) * -1)) * -1);
  bottom: calc(var(--lift) * max(0, var(--rise-y)) * -1);
  left:   calc(var(--lift) * max(0, calc(var(--rise-x) * -1)) * -1);
  right:  calc(var(--lift) * max(0, var(--rise-x)) * -1);
}
.the-one-row > .face {
  position: relative; z-index: 1;
  /* flex, not block: a block .face let .fill's vertical margin collapse
     straight through it (found by measurement — .face's own rect came
     out 6px shorter than the row's, because the collapsed margin
     escaped upward instead of being spent opening up room inside
     .face). A flex formatting context never collapses margins with
     its children, which is also why .btn > .face never had this
     problem — same fix, applied here for the same reason. */
  display: flex; align-items: stretch; width: 100%; height: 100%; box-sizing: border-box;
  background: var(--edge);
  /* round 6: no transition of its own — reactive to the row's own
     animating --v/--lift, exactly like .btn > .face. */
  transform: translate(calc(var(--lift) * var(--rise-x)), calc(var(--lift) * var(--rise-y)));
}
/* round 6: z-drop retired — see the STACKING v2 note above .btn's
   z-under. z-under here is the row equivalent: demoted (z-index:0, still
   above the row's own footprint via DOM order) while the row is sunk.
   round 8: z-settle here is the row equivalent of .btn's z-settle —
   see that note for why 4, and why a tie at 5 is the bug being fixed. */
.the-one-row.z-top > .face { z-index: 5; }
.the-one-row.z-settle > .face { z-index: 4; }
.the-one-row.z-under > .face { z-index: 0; }
.the-one-row > .face > .fill {
  position: relative; z-index: 1;
  flex: 1 1 auto; /* .face is a flex row of one — without grow, .fill would
    only take its content's natural width and leave .face's dark edge
    exposed down the right side instead of just at the --sw margin */
  margin: var(--sw);
  display: flex; align-items: center; gap: .8em;
  padding: .65em .8em;
  background: var(--ground);
  transition: background var(--dur) var(--ease); /* NOT triggered by :hover — only .sel/.full toggle it */
}
/* the sink lip — identical mechanism to .btn's, same token, same
   two-axis formula. */
.the-one-row > .face > .fill::before {
  content: "";
  position: absolute; inset: 0; z-index: 2; pointer-events: none;
  box-shadow: inset calc(var(--push) * var(--rise-x) * -1) calc(var(--push) * var(--rise-y) * -1) 0 0 var(--edge);
  /* round 6: no transition of its own — reactive to --push, same as .btn's lip. */
}

.the-one-row .nm {
  position: relative; z-index: 1;
  flex: 1 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis;
  font-weight: 700; font-size: .82rem; letter-spacing: .01em;
  color: var(--text);
  /* label push, matching .btn's — sink still reads even though the
     row's own box holds still. round 6: no transition of its own, same
     reasoning as .btn > .face > .fill > .lab. */
  transform: translate(calc(var(--push) * var(--rise-x) * -1), calc(var(--push) * var(--rise-y) * -1));
}

/* ------------------------------------------------------------------
   THE BAR — round 5 rebuild #2. Round 5's first pass fixed the LINE
   WEIGHT (border -> box geometry) but round 4's reading of the SHAPE
   was wrong: it only outlined the filled portion and closed the box at
   100%. The user's own mock (Screenshots/THE ONE fixed.png) shows the
   other reading, and it's the one that ships now:
   - the TRACK is ALWAYS a complete, fully outlined rectangle, at every
     percentage including 0% (see the IRREGULAR row — empty, still
     framed);
   - the FILL sits inside that track and has NO stroke of its own at
     its leading edge — it just ends;
   - the unfilled remainder reads as the ordinary background colour,
     not a darker track.

   Still box geometry, not `border` (round 5's first fix stands) — the
   outline is a padding-box/content-box split, which is real box
   geometry (padding is an ordinary layout length, not something Chrome
   snaps to a device pixel the way border-width is), just expressed as
   two background LAYERS on one element instead of two nested elements:
   - `.bar` supplies the padding (`--sw` on all sides) and its own
     background is `var(--edge)` — painted across the full border box,
     so the padding band reads as the outline, unconditionally, with no
     dependency on whether there's any fill at all.
   - `.bar`'s content box (the area inside that padding) is repainted
     `var(--ground)` by a second background layer clipped to
     `content-box` — the "unfilled remainder is the background colour"
     requirement, satisfied for the WHOLE content box before any fill
     is considered.
   - `.bar > i` is a plain block, confined to that same content box by
     the parent's padding (not by any inset math of its own, so it can
     never drift from the ring by a rounding error) — it paints the
     fill colour over the left portion, width-driven exactly as before,
     with no border/box-shadow of its own: no stroke at its leading
     edge, by construction.
   - Round 6: this construction had one seam left. `i`'s own edge and
     the ring's inner boundary (produced by `.bar`'s padding, above)
     were two INDEPENDENTLY-computed coordinates that were meant to
     coincide exactly — MEASURED to disagree at fractional DPR, the
     same class of bug as the original button hairline, just between
     two different elements instead of within one. Per the user's own
     fix: overlap them instead of abutting them, and paint the ring on
     top. `.bar` is now a plain flat `var(--ground)` box (the unfilled
     remainder colour, unconditionally); `i` is confined by a padding
     of `--sw` MINUS half a pixel, so it pokes half a pixel past where
     the ring's true inner edge is; and the ring itself moved to a
     `::after` — an inset box-shadow, which is a real computed length
     (not a border-width, so it isn't subject to the device-pixel snap
     that caused the ORIGINAL hairline either) painted at the full
     `--sw`. A positioned `::after` with z-index:auto is a later
     stacking category than an in-flow child by default (no z-index
     needed to enforce "ring above fill" — it falls out of ordinary box
     order), so it paints over that half-pixel overlap completely: the
     rasteriser never has a shared coordinate to round two ways, because
     there IS no shared coordinate — there's a deliberate overlap, fully
     covered by an opaque layer on top. (Note for future edits: do NOT
     go back to a transparent-content-box `::after` to build the ring —
     multiple `background` layers on one element only composite against
     EACH OTHER, not against elements behind them; that was tried
     earlier in round 5 and painted solid colour over everything.)
   - `--bar-fill` still lives on `i` (untouched: `.full`, `.sel`,
     `.sel.full` all keep working); `.full` additionally forces the
     fill to 100% width so "complete rows span the full track" is a
     rule, not just a property of today's markup.
   ------------------------------------------------------------------ */
.the-one-row .bar {
  position: relative; z-index: 1;
  flex: 0 0 auto; width: calc(var(--u) * 1.6); height: calc(var(--sw) * 3.2);
  background: var(--ground); /* the unfilled remainder colour, always */
  padding: calc(var(--sw) - 0.5px); box-sizing: border-box; /* confines `i` to
    HALF A PIXEL past the ring's true inner edge, on purpose — see the round-6
    note above. */
  overflow: hidden;
}
.the-one-row .bar > i {
  display: block; height: 100%; box-sizing: border-box;
  background: var(--bar-fill, var(--accent));
  transition: width var(--dur) var(--ease);
}
.the-one-row.full .bar > i { width: 100%; }
.the-one-row .bar::after {
  content: "";
  position: absolute; inset: 0; pointer-events: none;
  /* the ring, painted ABOVE `i` (a positioned, z-index:auto ::after is a
     later stacking category than an in-flow, non-positioned child by
     default — see brief 6, item 1). box-shadow's inset spread is a real
     computed length, exempt from the border-width device-pixel snap
     (brief 5's THE BAR note), so this stays exactly --sw at every value. */
  box-shadow: inset 0 0 0 var(--sw) var(--edge);
}
/* BB yellow while incomplete, green once full — never brown. Every
   state below sets the --bar-fill TOKEN, never `background` directly —
   found by measurement in round 3 that hardcoding `background` on a
   `.full` rule silently out-ranked a later `.sel.full` override (same
   specificity family, but the override only touched the token, and
   the hardcoded rule never read it). Routing everything through one
   token means whichever class is more specific wins correctly. */
.the-one-row.full .bar > i { --bar-fill: var(--ok); }
.the-one-row.full .nm { color: var(--ok); } /* complete rows may turn their text green too */

/* selection: real state, real colour change on the FILL only — the
   row's own --edge (outline + footprint + lip + bar border) is never
   touched, per the colour-discipline rule above .btn.is-selected. The
   bar's yellow would vanish against the now-yellow row, so on select
   it switches to the themed colour (dark in light mode, light in
   dark) instead — still --edge, so it's the exact same colour the
   outline already is. */
.the-one-row.sel > .face > .fill { background: var(--accent); }
.the-one-row.sel .nm { color: var(--ink); }
.the-one-row.sel .bar > i { --bar-fill: var(--edge); }
/* round 6: selected AND complete no longer shares the plain "selected"
   yellow — it takes the SAME green/cream pairing `.btn.is-complete` uses
   everywhere else on the page (`--fill-rest: var(--ok); --fg-rest:
   var(--cream);`), so "this is done" reads the same way whether or not
   it's also the row you've got selected. `.sel.full` (two classes) beats
   `.sel` alone on specificity, so this rule doesn't need `!important` to
   win. The bar's fill still goes themed (`--edge`) — green-on-green would
   vanish exactly like yellow-on-yellow would have — and the outline is
   still untouched by any of this, per the colour-discipline rule that's
   held since brief 3 §7. Deselecting drops `.sel` and the row falls
   straight back through to the plain `.full` rules above (green fill,
   green text, cream row) or, if it's not complete either, all the way to
   rest — nothing here is a state that needs its own "undo". */
.the-one-row.sel.full > .face > .fill { background: var(--ok); }
.the-one-row.sel.full .nm { color: var(--cream); }
/* dark mode's --ok is much lighter than light mode's (see the dark-mode
   design note near the top of the file) — cream-on-that-green MEASURES
   ~1.9:1 (fails), ink MEASURES ~8.2:1. Same override as .btn.is-complete,
   applied here too since this rule sets the text colour directly rather
   than through --fg-rest. */
[data-theme="dark"] .the-one-row.sel.full .nm { color: var(--ink); }
.the-one-row.sel.full .bar > i { --bar-fill: var(--edge); }

@media (prefers-reduced-motion: reduce) {
  /* round 6: --v's own transition (declared on .the-one-row) drives
     everything below it now — see the matching note on .btn's media
     query. `.face > .fill`'s `background` transition (selection/complete
     colour changes, not rise/sink) is intentionally NOT included here:
     it isn't part of the --v chain and reduced-motion is about motion,
     not colour settling. */
  .the-one-row, .the-one-row > .face,
  .the-one-row > .face > .fill::before, .the-one-row .nm { transition: none; }
}

/* ============================================================
   THE GRID — 32px pitch. The whole site will snap to this, so the
   relationship below is the one thing in this file that has to be
   provably exact, not just visually close.

   The grid's own line and a button's edge share a CENTRELINE — picture
   the thin grid hairline running straight through the middle of the
   thick button edge. That means a button spanning grid lines a..b is
   LARGER than the cell span by exactly one stroke, split evenly:

     grid line n's centreline sits at   x = n * --gp
     outer width of a span a..b      =  (b - a) * --gp + --sw
     outer left edge                 =  a * --gp - --sw / 2

   (See #grid-check in lab.html / the snap-test JS for this computed
   and asserted against the live DOM, not just asserted in a comment.)

   --gsw (grid stroke) is deliberately thinner than --sw (element
   stroke) — the grid is background structure, the button edge is
   foreground — but both are painted centred on the same line, via the
   same background-position trick: draw a solid --gsw-wide bar at the
   START of each --gp repeat, then shift the whole pattern left/up by
   --gsw/2 so that bar straddles 0 (and therefore every multiple of
   --gp) symmetrically instead of starting there.
   ============================================================ */
:root {
  --gp: 32px;   /* grid pitch */
  --gsw: 1px;   /* grid stroke — always thinner than --sw */
}
.grid-bg {
  background-color: var(--ground);
  background-image:
    repeating-linear-gradient(to right, var(--grid-line, var(--faint)) 0 var(--gsw), transparent var(--gsw) var(--gp)),
    repeating-linear-gradient(to bottom, var(--grid-line, var(--faint)) 0 var(--gsw), transparent var(--gsw) var(--gp));
  background-position: calc(var(--gsw) * -0.5) calc(var(--gsw) * -0.5);
  position: relative;
}
/* a button placed on the grid by pixel math (JS writes left/top/width/
   height directly — grid units aren't expressible as a single CSS
   calc() from a..b without duplicating the JS, and the whole point of
   this section is that the number is asserted, not trusted) */
.grid-btn { position: absolute; }

/* ============================================================
   CONTAINER BUTTONS — the brief for round 3: the real site will be
   built out of these buttons, with non-interactive sections as
   soft-locked buttons holding ordinary content. A button therefore has
   to work as a plain container — arbitrary markup inside, still with
   its edge/footprint/snapping — not only as a single centred label.
   .btn.container turns off label centring on .fill (block flow,
   left-aligned) while everything about the edge/footprint/fill-inset/lip
   stays exactly the same mechanism.

   Round 6: this used to be `overflow: auto`, which is exactly the "grew
   an internal scrollbar" bug — a soft-locked section in the button size
   grid picked up a scrollbar the moment its content outgrew its
   grid-cell box. The user's rule is absolute: a button-derived box may
   EITHER grow to fit its content OR let content overflow visibly, but it
   may never scroll internally — the real site is going to be built from
   sections composed of these, responsive across screen sizes, and a
   scrollbar on a layout primitive is a defect at that scale, not a
   cosmetic one. Grid-placed containers can't be the "grows to fit"
   option (that's precisely what round 4 fixed the OTHER way — a grid
   cell's size comes from its gx/gy/gw/gh, provably, never from its
   content), so the only consistent choice left is `overflow: visible`:
   content that doesn't fit is still fully visible, just no longer
   confined to the box, and never gated behind a scrollbar. */
.btn.container > .face > .fill {
  display: block; text-align: left; overflow: visible;
  font-family: var(--font-body); font-weight: 400; letter-spacing: normal; text-transform: none;
  white-space: normal;
}
.btn.container > .face > .fill > .lab {
  display: block; transform: none; /* content doesn't push on sink — only
    the label of an ordinary button does; a whole panel of content
    shifting a few px on press would read as a bug, not a nicety */
}
.btn.container h3, .btn.container h4 {
  margin: 0 0 .4em; font-family: var(--font-mono); font-size: .72rem;
  letter-spacing: .08em; text-transform: uppercase; color: var(--faint);
}
.btn.container p { margin: 0 0 .6em; font-size: .8rem; line-height: 1.5; color: var(--text); }
.btn.container p:last-child { margin-bottom: 0; }
