/* ErgIntz web — the next-step material, Manrope, dark-mode friendly. */

/* **Manrope, self-hosted.** The landing page has shipped this face since
   2026-09-10 and the app pages wore the system stack; one product in two
   typefaces is what the next-step pass is for (Martin, 2026-09-11). Declared
   here as well as in landing.css because the two stylesheets are never loaded
   together — the same file, the same unicode-range, one download. */
@font-face {
  font-family: 'Manrope'; font-style: normal; font-weight: 200 800; font-display: swap;
  src: url('fonts/manrope-latin.woff2') format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

:root {
  /* --- THE MATERIAL --------------------------------------------- 2026-09-11
     The next-step redesign's ten values, the same ten `AppTheme.swift` took on
     2026-09-10, read off the design canvas's own stylesheet
     (`docs/mockups/web-next-step/gen.py` and `light.py`) rather than off a
     picture — a 6 % hairline and a 3.5 % surface are invisible in a PNG.

     **The role tokens below are REPOINTED, not replaced.** `--bg`, `--card`,
     `--text`, `--muted`, `--border`, `--accent` and `--shadow` keep their
     names and every one of the ~900 rules that reads them takes the material
     without being touched. That is the whole reason this block exists; a
     redesign that has to visit every template is a redesign that half-lands.

     Light is a plain neutral off-white with NO tint and NO glow (Martin,
     2026-09-10) and separates its surfaces with a shadow; dark is a near-black
     violet with three soft washes, and separates its surfaces by making them
     LIGHTER than the ground. The two are not each other's inverse, which is
     exactly where a "just invert it" palette goes wrong. */
  --bg: #F7F7F8;
  --card: #FFFFFF;
  --text: #16181D;
  /* Ink at 60 % — a value's label, a sub-line, a secondary number. A rgba of
     the ink rather than a grey of its own, so the two themes' muted inks are
     the same statement about the same ink. iOS mirror: `Palette.inkMuted`. */
  --muted: rgba(22, 24, 29, 0.60);
  /* Ink at 42 % — a section label, a chevron, a "never done". **Two muted
     inks, not one**: 60 % is text you are meant to read and 42 % is text you
     are meant to be able to FIND, and drawing them alike is what makes a dense
     screen read as a wall. iOS mirror: `Palette.inkFaint`.
     **Never at the 10 px step** (Martin, 2026-09-14: "fix it"): 42 % measures
     2.65:1 on the light sheet and 3.81:1 on the dark ground, and 10 px text
     needs 4.5. The column head, the field caption and the head-to-head label
     take `--muted` (4.55:1 / 6.84:1) — the drawing's `.mu2` there was a
     contrast failure, not a concept. */
  --ink-faint: rgba(22, 24, 29, 0.42);
  /* **The accent is the material's violet** (Martin, 2026-09-11) — the same
     pair iOS took into `AccentColor.colorset` the same day: #6D28D9 on the
     light sheet, #B07CFF on the dark ground. */
  --accent: #6D28D9;
  /* The account disc in the rail. Its own value rather than a generic accent
     wash: a 10 % chip behind a 15 px glyph reads as nothing, and
     the drawing gives the disc its own value (`gen.py`: `.25` on dark,
     `light.py`: `.16`). */
  --avatar-wash: rgba(109, 40, 217, 0.16);
  /* The primary rung's own light, on iOS `Elevation.go`'s geometry (y 6,
     radius 12) — `0 6px 14px` of the fill at 16 % on the light sheet and 18 %
     on the dark ground. It was the drawing's `0 10px 30px` at 22 / 28 %, which
     lit a halo wider than the button (Martin, 2026-09-14: "shadow/glow is too
     prominent"). */
  --go-glow: 0 6px 14px rgba(34, 197, 94, 0.16);
  /* The run's edge on the go button — iOS `Elevation.goHighlight` / `.goShade`. */
  --go-highlight: 0.28;
  --go-shade: 0.18;
  /* The destruction rung's slab. iOS mirrors: `Palette.dangerFill` /
     `.dangerBorder`; the drawing's `.btn.danger` (12 %/30 % dark, 9 %/35 %
     light — `light.py` steps the fill DOWN and the border UP, because a tint
     that reads on a dark ground disappears on paper). */
  --danger-fill: rgba(217, 38, 79, 0.09);
  --danger-border: rgba(217, 38, 79, 0.35);
  --work: #f59e0b;
  /* **Rest is cyan** (the material), not a second blue: rest and the accent
     were two blues a row apart. iOS mirror: `Palette.rest`. */
  --rest: #0891B2;
  --error: #dc2626;
  --warning: #d97706;
  --success: #16a34a;
  /* A HEART RATE, wherever one is printed — `AppTheme.Palette.heart`. It said
     "Apple's systemRed, which ADAPTS (#FF3B30 light, #FF453A dark)"; that was
     true until 2026-09-11, when the web took the material and the heart became
     its own red, #DC2F4B / #FF5A6E, pinned to iOS in both themes by
     `test_ramp_token_parity.py`. Not `--error`: the error colour means
     something went wrong, and a pulse is not an error.
     The session page's ♥ column and its HR chart drew the error red, and the
     head-to-head page reads `--h2h-heart`, which reads from here.

     **There are still two hearts, and this sentence used to claim there was
     one.** `--card-hr` is `#FF453A` — Apple's old systemRed — and the share
     card does NOT read `:root` on purpose: it is theme-independent, pinned by
     `parity/export_card_vectors.json` and its geometry twin, because a card is
     an image that leaves the app. So the card keeps its own red until somebody
     decides that image should change, which is a product call and not a
     palette one. Recorded in `docs/web-next-step/STATE.md`. */
  --heart: #DC2F4B;
  /* BETTER and WORSE, where a number is genuinely two-valued: the `vs this`
     column on a session's page, and the ± diffs on the phone's Compare. Not
     --success / --error: nothing has succeeded or failed, and these are the
     contrast-pushed variants iOS uses at caption sizes (system green is too
     light on white, system red too washed out). iOS mirror:
     `Palette.diffGood` / `Palette.diffBad`, pinned by
     `tests/test_ramp_token_parity.py` in both themes. */
  --diff-good: #0D8033;
  --diff-bad:  #C81E1E;
  --border: rgba(22, 24, 29, 0.11);
  /* The material's light lift — `0 1 5 −1` ink at 6 %. One shadow, not two
     stacked ones: a white surface on an off-white ground needs a single soft
     edge, and the old pair drew a halo under every card.
     iOS mirror: `Elevation.surface` (y 1, radius 2, 6 %). */
  --shadow: 0 1px 5px -1px rgba(22, 24, 29, 0.06);
  --shadow-hover: 0 2px 10px -2px rgba(22, 24, 29, 0.10);
  /* **The card's own drop** — the one place the material throws a real shadow,
     and the only shadow that exists in DARK. `--shadow` above is the surface
     lift and is LIGHT ONLY (`AppTheme.Elevation`: "the shadow is drawn in dark
     too only where the drawing draws one … which is `cardDark`, not this"), so
     in the dark blocks `--shadow` is `none` and this carries the 22-radius
     card. iOS mirror: `Elevation.cardDark`. */
  --shadow-card: 0 1px 5px -1px rgba(22, 24, 29, 0.06);

  /* --- The material's surfaces -------------------------------------------
     **A hairline is not a border.** They were one value on this side, and the
     drawing gives them different weights: a row divider has to be nearly
     invisible or a ten-row band reads as a grid, while a band's own edge has
     to state where the band stops. iOS mirrors in brackets. */
  /* A list band or a panel: the surface a row sits on. Opaque white in light,
     where `--shadow` separates it; a 3.5 % white FILM in dark, so the ground's
     washes carry through. [`Palette.surface`] */
  --surface: #FFFFFF;
  /* A control lifted off the ground — an icon button, the secondary rung, a
     toolbar pill. One step brighter than `--surface` in dark (6 %); the same
     white in light, where the shadow does the lifting.
     [`Palette.surfaceRaised`] */
  --surface-raised: #FFFFFF;
  /* The edge of a CARD — the one component that keeps its radius. A step
     stronger than `--border` in dark (12 % against 9 %), because a card is an
     object and a band is a surface. [`Palette.cardBorder`] */
  --card-border: rgba(22, 24, 29, 0.11);
  /* The rule BETWEEN two rows — 6 % in both themes. [`Palette.hairline`] */
  --hairline: rgba(22, 24, 29, 0.06);
  /* The unfilled part of a progress bar. **Not symmetric**: 8 % white on the
     dark ground, 5 % ink on the light one — a track has to be quieter than a
     hairline on white and louder than one on black, because a light ground has
     less room below it than a dark ground has above it. [`Palette.track`] */
  --track: rgba(22, 24, 29, 0.05);

  /* --- The two fills the material allows ---------------------------------
     **The primary action's green** — the one on the button that starts a
     piece, and the ONLY saturated fill in the app. Deliberately not
     `--success`, four lines up and nearly the same hue: "this worked" and
     "start rowing" are two sentences, and one green with two meanings is how a
     palette stops meaning anything. [`Palette.go` / `Palette.onGo`] */
  /* **Lighter in light mode — #22C55E** (Martin, 2026-09-12, iOS `71c7a8f4`).
     `#17A75A` was the drawing's own light go and read as a dull slab on paper;
     it lives on as `--go-deep`, the switch's ring and the rung's far end. */
  --go: #22C55E;
  /* The DEEP green — the ring round an ON switch. iOS mirror: `Palette.goDeep`. */
  --go-deep: #17A75A;
  --on-go: #FFFFFF;
  /* **Red is destruction, and nothing else** outside the ramp — Delete,
     Remove, Disconnect, Revoke. [`Palette.danger`] */
  --danger: #D9264F;
  /* **The label on an INK fill** — the primary rung, a selected segment, a
     strong tag, the play disc.

     It was `--card`, on the reasoning that "paper flips with the theme, so the
     label flips with the ink". That held while `--card` was an opaque surface
     and broke the moment the material made it a 3.5 % white FILM: a film on a
     near-white slab IS the slab, so all six of those labels went blank in dark
     at once. **The ink's opposite is the GROUND**, and naming it is what stops
     the next surface token being borrowed for the job. Sibling of
     `--on-go`. iOS mirror: `Palette.onInk`, which hit this
     the same way on 2026-09-10. */
  --on-ink: var(--bg);
  /* **The same surface, flattened** — for an OCCLUDER, which is all this token
     has left. It was introduced for form controls, on the belief that "Chrome
     paints its own opaque field backdrop under `input`/`select`/`textarea`, and
     a translucent background lets that white through". **Measured 2026-09-11,
     that is not true**: a `rgba(255,0,0,0.2)` background on a text input, a
     select, a date input, a number input and a textarea composites over the
     washed ground to within a unit of a plain `<div>` carrying the same colour
     — (58,6,12) at the page's left edge against (69,13,27) under the top-right
     wash, the div's own gradient — and forcing `color-scheme: light` changes
     nothing. Whatever made every field a white slab the day the material
     landed, it was not the UA's backdrop, and the fields take `--surface` now.
     If a field ever goes pale again in a browser that is not Chrome, this is
     the measurement to repeat before reaching for this token.
     **The percentage is `--surface`'s, not `--surface-raised`'s.** It was 6 %
     in dark — `--surface-raised` flattened — so every field, menu and
     share-link on the site sat one step ABOVE the surface this token is named
     after, and the drawing draws a field on the plain one
     (`a_screens.py`'s `field()`: `background:{SURF}`). Light was already
     right, because there `--surface`, `--surface-raised` and `--card` are all
     `#FFFFFF` and a drift cannot show. */
  --surface-opaque: #FFFFFF;
  /* What it IS for: anything drawn OVER page content — a menu, a dialog, a
     pinned header, a slider's thumb — plus a GAP ring, which is opaque by
     definition. `--card` is the right token for
     a surface sitting IN FLOW on the ground (the film is what lets the washes
     through); it is the wrong one for a surface whose whole job is to hide
     what is behind it. Compare's rep picker read straight through to the
     chart's axis labels and the hero delta underneath it. */

  /* Spacing scale — every margin/padding/gap references these. */
  --sp-1: 4px;
  --sp-2: 8px;
  --sp-3: 12px;
  --sp-4: 16px;
  --sp-5: 24px;
  --sp-6: 32px;
  /* Half-steps — the de-facto 14/18px insets used by notices, cards and the
     card-link padding. Named between sp-3/4 and sp-4/5. */
  --sp-35: 14px;
  --sp-45: 18px;
  /* Sub-scale steps. Named because both platforms were already spelling them
     out — 6px alone appears 25 times in this file — and an unnamed constant is
     how the two sides drift apart without anything noticing. `--sp-05` and
     `--sp-1r` are the gap between two lines of ONE label, never between two
     elements; reach for `--sp-1` upwards for that.
     iOS mirror: Spacing.hairline / .tight / .microPlus (added 2026-08-17). */
  --sp-05: 1px;
  --sp-1r: 2px;
  --sp-15: 6px;
  /* The half-step between sp-2 and sp-3. It was USED twice and never
     DECLARED — `.open-remove` and the fold row's bar both asked for
     `var(--sp-25)`, both declarations were invalid at computed-value time,
     and both margins silently resolved to 0, so the bar sat flush against
     the title row. iOS mirror: Spacing.xsPlus, which the gate had recorded as
     "iOS-only, the web has no equivalent" — the web had two, spelled as a
     typo. */
  --sp-25: 10px;

  /* Corner radius scale. */
  --r-sm: 8px;
  --r-md: 10px;
  /* The smallest rounding either platform draws — the chart ribbon's
     chips. iOS mirror: Radius.micro. */
  --r-micro: 4px;
  /* The rounding of a mark drawn INSIDE something else — the version
     chip, the plan's count slab, the banded value. It was commented
     "iOS-only" over there while this file spelled 6px for the same
     components. iOS mirror: Radius.inner. */
  --r-inner: 6px;
  --r-lg: 14px;
  /* **The material's own three radii**, which `--r-lg` was standing in for.
     A button is 16, a panel 18, a card 22 — the drawing spells all three out
     and they are three different objects. iOS mirrors: `Radius.button`,
     `Radius.panel`, `Radius.cardLarge`, added on the iOS pass 2026-09-10 and
     unpaired here until this one. */
  --r-button: 16px;
  --r-panel: 18px;
  --r-card-lg: 22px;
  --r-pill: 999px;

  /* Control heights — buttons, inputs and selects share these so rows of
     mixed controls always line up. */
  /* **44, the drawing's control** (Martin, 2026-09-14: "take it" — fields and
     buttons at 44 / r14 / 14 px). WEB ONLY for now: iOS `ControlHeight.md` is
     still 38, and the parity gate carries the gap in `WEB_AHEAD` until the
     phones take the same step. */
  --control-h: 44px;
  --control-h-sm: 30px;
  --control-h-lg: 46px;
  /* **The rung that starts a piece is its own size** — the material's
     `.btn{height:52px}`, six pixels above `lg`. It is the one control on a
     screen you reach for without looking, and the drawing gives it a height no
     other control has for exactly that reason. iOS mirror:
     `ControlHeight.primary`. */
  --control-h-primary: 52px;
  /* The app's FLOOR for a reachable control — 36, not the HIG's 44. Not a
     button height: it is the minimum a tap target may be, which is why it sits
     beside the heights rather than among them. iOS mirror: ControlHeight.tap. */
  --control-h-tap: 36px;
  /* The three figure columns on a club's weekly top list, each only as wide
     as what it prints ↔ `Control.topListWatts` / `…Time` / `…Meters`. One
     shared width broke every name at 360 pt on the phone. */
  --wt-fig-w: 50px;
  --wt-fig-t: 64px;
  --wt-fig-m: 76px;

  /* One number for "this control is off", named so the two platforms can be
     compared. It was .55 here against iOS's 0.38 — the same ladder, the same
     rung, two values, and nothing to compare because this side never named it.
     0.38 wins: the ladder was designed on iOS and ported here (round 5, W5). */
  --btn-disabled-opacity: 0.38;
  /* A rest asks for nothing, and greying it is what lets work and rest be
     told apart down a column. Named so it can be compared: it has agreed
     with iOS's Control.restRowOpacity by luck since W7. */
  --plan-row-rest-opacity: 0.62;

  /* --- A component's own inside ----------------------------------- P0, 2026-09-08
     The numbers a chip, a wash or a rung is MADE of. Every one of them was a
     literal in this file and a `static let` over there, which is the shape a
     drift takes when nothing is looking: W5 closed the ladder's section of
     docs/redesign-web-debt.md by writing 5.5 %, 18 % and .86 into three rules
     rather than by naming them, so the gate could not see them at all.

     A chip's INSET is off the spacing scale on purpose, on both platforms:
     the scale measures the distance BETWEEN elements, and these measure how
     much room a two-letter word gets inside its own capsule. `-v-tall` is the
     same inset for a chip whose text sits on a larger type step.
     iOS mirror: Control.chipInsetH / .chipInsetV / .chipInsetVTall. */
  --chip-inset-h: 7px;
  --chip-inset-v: 2px;
  --chip-inset-v-tall: 3px;
  /* The band chip — the strip that places a row on the ramp. Its height is
     `--bar-h` and its rounding `--bar-seg-r`, so only the width and the hang
     below the baseline are its own. iOS mirror: Laps.chipWidth (the design's
     5 pt chip) / Bar.chipBaselineHang. */
  --chip-w: 5px;
  --chip-hang: 3px;
  /* **A log row's chip is a 4 × 24 BAR.** `BandChip`'s height is a PARAMETER
     on iOS — "the plan table's rows carry a taller chip" — and this side
     hard-coded `--bar-h`, so History drew a 5 × 14 dot beside a two-line block
     that is 43.5 px deep. iOS's own comment for these two: "a band that is a
     4 × 24 BAR rather than the plan table's 6 × 18 chip — it stands beside a
     two-line block, so it answers to the block's height". The drawing says
     4 × 30; 24 is the app's, and the app is who this has to agree with.
     iOS mirrors: `Control.logBandWidth` / `.logBandHeight`. */
  --log-chip-w: 4px;
  --log-chip-h: 24px;
  /* The TALLER chip's corner — `--bar-seg-r` is the bar segment's 2, and a
     chip standing on its own gets a point more (iOS `Control.planChipRadius`,
     passed by `LogRowView` and by `WorkoutPlanTable`). The web's `.plan-chip`
     still rounds at 2 and is still 5 × 14 against iOS's 6 × 18 — the same
     parameter gap one component over, recorded for 4.4. */
  --chip-tall-r: 3px;
  /* A value chip's fill — the mark that separates "you can change this" from
     the plain grey facts beside it. iOS mirror: Control.valueWash. */
  --value-wash: 5.5%;
  /* An insertion row's dashes. Lighter than a separator on purpose: it is a
     gap you may fill, not a division between things. iOS mirror:
     Control.insertionBorder. (Its dash PATTERN, [4, 4], has no CSS spelling —
     the UA picks one — so only the ink is a token.) */
  --insertion-border: 18%;
  /* The pressed state, for the filled variants. iOS mirror:
     Control.pressedOpacity. */
  --pressed-opacity: 0.86;
  /* A range input's thumb, which is also how far it insets its own track at
     each end. iOS mirror: Control.sliderThumb. */
  --slider-thumb: 22px;
  /* The switch: the drawing's 44 × 26 with a 3 px inset (Martin, 2026-09-14:
     iOS's look, the drawing's size). iOS's own head switch is 34 × 20, so the
     size does not pair; the knob's shadow does. iOS mirror:
     `Control.headSwitchKnobShadow`. */
  --switch-w: 44px;
  --switch-h: 26px;
  --switch-inset: 3px;
  --switch-knob-shadow: 0.30;
  /* **Compare, rep by rep** (ruling 01, 2026-09-14) — iOS
     `CompareAttemptsView`'s own numbers, `Control.compare*` / `.rep*` and
     `Chart.compare*`, so the two screens are one screen. */
  --cmp-head-value: 28px;
  --cmp-edge: 3px;
  --cmp-strip-h: 11px;
  --cmp-readout-h: 40px;
  --rep-row-h: 42px;
  --rep-head-h: 34px;
  --rep-n-col: 26px;
  --rep-v-col: 56px;
  --rep-d-col: 36px;
  --rep-bar-h: 6px;
  --rep-bar-track: 20px;
  --rep-bar-share: 0.58;
  --cmp-this-w: 2.5px;
  --cmp-best-w: 2px;
  --cmp-glow-w: 6px;
  --cmp-glow-o: 0.30;
  --cmp-wedge-o: 0.06;

  /* Focus ring — one definition reused by every focusable control. */
  /* 80%, not 35%. The ring was genuinely there at 35% — it just composited to
     rgb(48,74,108) on a rgb(22,25,31) card, i.e. 1.95:1, under the 3:1 WCAG 2.2
     asks of a focus indicator. Both themes were checked with a pixel probe
     rather than by eye, because "I can see it" is exactly the judgement this
     threshold exists to replace: 60% fixed dark (3.31:1) but left light at
     2.55:1 against white. 80% clears both (dark 4.9:1, light 3.6:1). */
  --focus-ring: 0 0 0 3px color-mix(in srgb, var(--accent) 80%, transparent);

  /* Typography scale. The two smallest steps are the phone's own: `--text-3xs`
     is `Typography.metricLabel` (a caps label above or beside a metric, and a
     badge's number) and `--text-2xs` is `.caption2` (the count slab, the PB
     badge, a version chip, a bar's scale). They were spelled 9.5, 10, 10.5 and
     11 across nine rules, all meaning one of these two. */
  /* Also the COLUMN HEAD's size. The drawing gives the section label 11 px and
     the head 10 (`gen.py`: `.k{font-size:11px}` against
     `.tr.hd{font-size:10px}`) — a head names a column while a label names a
     section, and the head has to stay out of the way of the numbers under it. */
  --text-3xs: 10px;
  --text-2xs: 11px;
  --text-xs: 12px;
  --text-sm: 13px;
  /* De-facto step between sm and base — table cells, card meta, notices. */
  --text-sm-plus: 14px;
  --text-base: 15px;
  --text-md: 16px;
  /* iOS `.headline` at Large — a list row's name, and the one step the scale
     was missing between 16 and 18. Both were reached for instead: the workout
     row's title sat at body 15 while the phone's sat at 17. */
  --text-md-plus: 17px;
  --text-lg: 18px;
  --text-xl: 22px;
  --text-2xl: 26px;
  /* **The display steps** (Martin, 2026-09-14: "try drawing") — the drawing's
     `.big`, the one number a page is about: 44 on the workout page's hero, 72
     on Compare's delta. Its own weight, tracking and leading, because a number
     that size set like body text reads heavy and loose (`gen.py`:
     `.big{font-weight:600;letter-spacing:-.04em;line-height:.95}`). Web-only
     for now: iOS sets its live numbers from `Typography`, which has no step
     here yet. */
  --text-display: 44px;
  --text-display-xl: 72px;
  --display-tracking: -0.04em;
  --display-leading: 0.95;
  --weight-medium: 500;
  --weight-semibold: 600;
  --weight-bold: 700;
  /* The tracked-caps labels — the section band and the column head — are 800 in
     the drawing (`gen.py`: `.k` and `.tr.hd`). At `bold` the column head came
     out the LIGHTEST tracked-caps label on the site, under a section label
     this file hard-codes at 750. */
  --weight-heavy: 800;

  /* Icon size — referenced by the .icon class and icon-buttons. */
  --icon-size: 18px;

  /* Motion. */
  --t-fast: 120ms ease-out;
  --t-med: 160ms ease-out;

  /* --- The intensity ramp ---------------------------------------- W1, 2026-08-14
     Every colour in the redesigned app comes from this scale. Chosen by
     `IntensityBand` — pure domain on both sides (W2 ports it to Python), so a
     bar on the phone and a bar on this page cannot land on different bands.

     The offsets are seconds per 500 m against the READER'S OWN 5 km best, never
     the leader's and never the club's average. That is the only scale this
     palette has ever meant.

     THE SIX HUES ARE PINNED IN BOTH THEMES, on purpose: this is a scale, and a
     scale that shifts with the theme is not one. They are therefore in `:root`
     ONLY — do not add them to either dark block. The two that DO adapt are
     `--band-unplaceable` and `--bar-channel`, and both appear below in all
     three places (the light root, the media query, and [data-theme="dark"]).

     Values are the iOS hexes verbatim (AppTheme.Palette), checked value for
     value against docs/redesign-web-debt.md. */
  --band-grey:        #6C788A;   /* slower than 5 km pace +25 s */
  --band-blue:        #55A3FF;   /* +12 … +25 s */
  --band-green:       #22D96E;   /* +4 … +12 s  */
  --band-lime:        #CBF03A;   /* 0 … +4 s    */
  --band-orange:      #FF8A1F;   /* −3 … 0 s    */
  --band-red:         #FF5580;   /* −3 s and faster */
  /* A fixed watt or pace with NO 5 km best to measure it against. Adapts,
     because its job is to recede rather than to be compared — a pale #C9CFD8
     on a dark row would be the brightest bar on the screen. Two greys, never
     drawn alike: --band-grey says "this is easy", this one says "we have
     nothing to measure this against". Keeping them far apart in lightness is
     load-bearing. */
  --band-unplaceable: #C9CFD8;
  /* The INK of the 45° hatch that means *nothing was written here*, not a
     fill. A rest asks for nothing; a work step with no target is the grey
     above. Two marks, two meanings. */
  --band-hatch:       #78849A;

  /* The trough a bar sits in on a list row. Present in both themes — it is
     what makes the bar read as a bar — but DROPPED on a live surface, where
     the ground is already near-black. A per-surface decision, not a
     per-theme one. */
  --bar-channel:      #12171E;
  --bar-lit-highlight: rgba(255,255,255,0.42);
  --bar-marker:       #FFFFFF;
  /* Without the ring the marker vanishes on lime. */
  --bar-marker-ring:  rgba(10,12,16,0.9);

  /* --- Bar geometry ---------------------------------------------- W1
     `AppTheme.Bar`, value for value. One size everywhere: the design draws the
     workouts list, the builder's preview and the live timeline all at 14 px. */
  --bar-h:              14px;
  --bar-inset:          2px;    /* the channel only */
  --bar-gap:            1px;
  --bar-gap-live:       1.5px;
  --bar-seg-r:          2px;
  --bar-channel-r:      4px;
  --bar-highlight-h:    1px;
  --bar-marker-overhang: 3px;
  /* The repeating-linear-gradient stops, measured perpendicular as CSS
     measures them: 2 px of ink every 5 px. */
  --bar-hatch-stripe:   2px;
  --bar-hatch-period:   5px;
  /* iOS has `Bar.minimumSegment = 1`; the web had no floor at all, so a very
     short step could vanish. W3 applies this as a min-width. */
  --bar-min-seg:        1px;

  /* --- The ramp's marker ------------------------------------------ P0
     `RampMarker` — the pin on the intensity ramp, one shape at two weights on
     the three surfaces that mark a position on the SAME scale (two shapes
     would imply two scales). iOS mirror: Rail.pinWidth / .pinHeight /
     .pinRing. */
  --rail-pin-w:   5px;
  --rail-pin-h:   26px;
  /* The TARGET's mark on the same rail: thinner and shorter than the pin —
     iOS writes it inline in `IntensityRampView` and only Android has named it,
     so there is no Swift `static let` to pair with (the same reason
     `--rail-pin-ring` is absent from the gate). */
  --rail-notch-w: 3px;
  --rail-notch-h: 20px;
  --rail-pin-ring: 1px;

  /* --- Washes ----------------------------------------------------- R9 → W1
     YOUR row on a board or a series table. Lime, not accent: the ramp already
     marks you on the live screen, and lime is the colour of holding your own
     5 km pace. 16 % rather than the accent's 10 % because lime is a pale hue
     and a tenth of it is not a row you can pick out at a glance. */
  --me-wash:   color-mix(in srgb, var(--band-lime) 16%, transparent);
  /* Behind a row you PICKED — the two reps a head-to-head is about. Accent at
     9 %, which is `AppTheme.Palette.accent.opacity(0.09)` on the phone: a
     wash, not a fill, because the row is still to be read. */
  --pick-wash: color-mix(in srgb, var(--accent) 9%, transparent);
  /* Behind a marked FACT — the PB badge. Heavier than a value chip's wash
     because a fact is read, not tapped: a chip has a caret to say what it is
     and a badge has only its fill. */
  --fact-wash: color-mix(in srgb, var(--text) 10%, transparent);
  /* A section band's caps title. The design's `.sect` is .06em at 12.5px. */
  --band-tracking: 0.75px;
  /* The column head's tracking, and NOT `--band-tracking` above it: that one
     is 0.75 px chosen for a 12.5 px section label, and tracking is a ratio to
     the size it is set at — the same absolute value at 10 px reads as a wider
     gap than it does at 12.5. The drawing states this one in ems for exactly
     that reason (`gen.py`: `.tr.hd{letter-spacing:.14em}`). */
  --band-head-tracking: 0.14em;
  /* The gap between a cell's two LINES — not between two elements, which is
     what the spacing scale measures, and a step the scale does not have (its
     neighbours are 2 and 4). iOS tightens a stacked pair with a negative
     `Spacing.stackedLines` instead, so there is nothing to pair it with. */
  --cell-line-gap: 3px;
  /* The segmented switch's own two numbers — 4 px of padding round a 32 px
     pill, so the box is 42 and the chosen cell floats with air on all four
     sides. **From iOS, not from the drawing** — `Control.segInset` /
     `Control.segPillHeight` are the pairing partners, and `gen.py`'s cell is
     30 (a 40 px box). 2 px, and iOS is who this has to agree with. */
  --seg-inset: 4px;
  --seg-pill-h: 32px;
  /* The plan's leading gutter — iOS `Control.planGutter`. 44, not 22: 22 fits
     the count alone, 44 also fits the group's own total, which is the one
     thing the deleted header row was for. The gutter is dead space on every
     row but a group's first, so the second 22 costs nothing that was in use.
     The bracket rail it replaced (`--plan-bracket-w`) is gone: a bracket
     indents what it contains, so the label column stepped in and out down a
     dense workout. iOS keeps its rail in the BUILDER, which the web has not
     got. */
  /* **The shell's own gutter** — the distance between the page and its rail,
     and between the page and the window. `gen.py`'s
     `padding:30px 40px 40px 40px`. It was a literal in `.container`, in
     `.sitefoot` and (as `--sp-6`'s 32) in the workout page's two columns, which
     is three answers to one question; 4.4 named it. No iOS twin: the phone has
     no rail. */
  --shell-gutter: 40px;
  --plan-gutter: 44px;
  /* The count slab — iOS `Control.slabRadius` / `.slabCountMinWidth` /
     `.slabCountHeight`. The width IS the gutter's, because the slab is what
     the rail hangs from rather than a tag beside it. */
  --slab-radius: 7px;
  --slab-count-min-w: 44px;
  --slab-count-h: 26px;
  /* The builder's repeat mark — the web half of the 2026-08-20 iOS redesign
     ("Marking a Repeat", aligned variant). The spine and its cap reuse the
     bracket's own geometry; the bar wash is a step darker than a section band
     so a group's head reads as INSIDE the Intervals section, not as a second
     one. Pairs: --builder-bar-wash ↔ Control.groupBarWash (7 % ink — whose
     "no web counterpart" comment predates this file catching up),
     --builder-spine-w ↔ Control.bracketWidth, --builder-spine-dot ↔
     Control.bracketDot. */
  --builder-bar-wash: color-mix(in srgb, var(--text) 7%, var(--card));
  --builder-spine-w: 2px;
  --builder-spine-dot: 9px;
  /* The fold-open step's ground — barely off the card, "open" without a
     card. iOS's open step wash, same reading. */
  --builder-open-wash: color-mix(in srgb, var(--text) 2%, var(--card));
  /* The wash a repeat's rows sit on — iOS `Control.planGroupWash`. */
  --plan-group-wash: color-mix(in srgb, var(--text) 3.5%, transparent);
}
/* Dark tokens. Two routes in: the OS prefers dark (unless the user forced
   light via the Settings → Appearance cookie, rendered as html[data-theme]),
   or the user forced dark. CSS can't share a block across a media query and
   a plain selector, so the token set is duplicated — KEEP THE TWO IN SYNC. */
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    /* Makes the browser draw its OWN widgets dark too — without it the date
       and time inputs get a black calendar glyph on the dark field (measured
       1.19:1) and a white picker panel over the dark page. */
    color-scheme: dark;
    --bg: #09070F;
    --card: rgba(255, 255, 255, 0.035);
    --text: #F3F6FF;
    --muted: rgba(243, 246, 255, 0.60);
    --ink-faint: rgba(243, 246, 255, 0.42);
    --accent: #B07CFF;
    --avatar-wash: rgba(176, 124, 255, 0.25);
    --go-glow: 0 6px 14px rgba(47, 207, 120, 0.18);
    --danger-fill: rgba(255, 77, 109, 0.12);
    --danger-border: rgba(255, 77, 109, 0.3);
    /* Semantic colors brightened for dark backgrounds (parallel to --accent;
       these match the hexes already used by .notice/.lap-kind-pill in dark). */
    --success: #4ade80;
    --work:    #fbbf24;
    --rest:    #22D3EE;
    --error:   #f87171;
    --warning: #fbbf24;
    --diff-good: #6BE680;
    --diff-bad:  #FF7A6E;
    --border: rgba(255, 255, 255, 0.09);
    /* The surface lift is LIGHT ONLY — dark separates a surface by making
       it lighter than the ground, not by dropping a shadow behind it. It
       carried the CARD's `0 20px 50px` here, which `.stat` (a tile) and
       `.rep-list` (a dropdown) then wore. */
    --shadow: none;
    --shadow-hover: none;
    --shadow-card: 0 20px 50px rgba(0, 0, 0, 0.45);
    --surface: rgba(255, 255, 255, 0.035);
    --surface-raised: rgba(255, 255, 255, 0.06);
    --surface-opaque: color-mix(in srgb, #FFFFFF 3.5%, var(--bg));
    --card-border: rgba(255, 255, 255, 0.12);
    --hairline: rgba(255, 255, 255, 0.06);
    --track: rgba(255, 255, 255, 0.08);
    --go: #2FCF78;
    --go-deep: #1FB866;
    --on-go: #FFFFFF;
    --danger: #FF4D6D;
    --ground-washes:
      radial-gradient(1000px 560px at 78% 8%, rgba(176, 124, 255, 0.13), transparent 60%),
      radial-gradient(700px 500px at 8% 40%, rgba(34, 211, 238, 0.09), transparent 60%),
      radial-gradient(900px 600px at 70% 95%, rgba(176, 124, 255, 0.07), transparent 60%);
    /* The only two ramp tokens that adapt — see the note in :root. The
       unplaceable grey has a FLOOR here: it is drawn inside --bar-channel, and
       too close to it the bar reads as an empty channel ("we did not draw
       this") rather than as "we cannot place this". */
    --band-unplaceable: #4A5462;
    --bar-channel: #1E242C;
    /* A HEART RATE, in the material's own red. */
    --heart: #FF5A6E;
    --h2h-heart: var(--heart);
  }
}
:root[data-theme="dark"] {
  color-scheme: dark;      /* see the note in the media-query block above */
  --bg: #09070F;
  --card: rgba(255, 255, 255, 0.035);
  --text: #F3F6FF;
  --muted: rgba(243, 246, 255, 0.60);
  --ink-faint: rgba(243, 246, 255, 0.42);
  --accent: #B07CFF;
  --avatar-wash: rgba(176, 124, 255, 0.25);
  --go-glow: 0 6px 14px rgba(47, 207, 120, 0.18);
  --danger-fill: rgba(255, 77, 109, 0.12);
  --danger-border: rgba(255, 77, 109, 0.3);
  /* Semantic colors brightened for dark backgrounds (parallel to --accent;
     these match the hexes already used by .notice/.lap-kind-pill in dark). */
  --success: #4ade80;
  --work:    #fbbf24;
  --rest:    #22D3EE;
  --error:   #f87171;
  --warning: #fbbf24;
  --diff-good: #6BE680;
  --diff-bad:  #FF7A6E;
  --border: rgba(255, 255, 255, 0.09);
  /* The surface lift is LIGHT ONLY — dark separates a surface by making
     it lighter than the ground, not by dropping a shadow behind it. It
     carried the CARD's `0 20px 50px` here, which `.stat` (a tile) and
     `.rep-list` (a dropdown) then wore. */
  --shadow: none;
  --shadow-hover: none;
  --shadow-card: 0 20px 50px rgba(0, 0, 0, 0.45);
  --surface: rgba(255, 255, 255, 0.035);
  --surface-raised: rgba(255, 255, 255, 0.06);
  --surface-opaque: color-mix(in srgb, #FFFFFF 3.5%, var(--bg));
  --card-border: rgba(255, 255, 255, 0.12);
  --hairline: rgba(255, 255, 255, 0.06);
  --track: rgba(255, 255, 255, 0.08);
  --go: #2FCF78;
  --go-deep: #1FB866;
  --on-go: #FFFFFF;
  --danger: #FF4D6D;
  --ground-washes:
    radial-gradient(1000px 560px at 78% 8%, rgba(176, 124, 255, 0.13), transparent 60%),
    radial-gradient(700px 500px at 8% 40%, rgba(34, 211, 238, 0.09), transparent 60%),
    radial-gradient(900px 600px at 70% 95%, rgba(176, 124, 255, 0.07), transparent 60%);
  /* The only two ramp tokens that adapt — see the note in :root. The
     unplaceable grey has a FLOOR here: it is drawn inside --bar-channel, and
     too close to it the bar reads as an empty channel ("we did not draw
     this") rather than as "we cannot place this". */
  --band-unplaceable: #4A5462;
  --bar-channel: #1E242C;
  /* A HEART RATE, in the material's own red. */
  --heart: #FF5A6E;
  --h2h-heart: var(--heart);
}

/* The interval bar ------------------------------------------------ W3
   Geometry entirely from W1's tokens; colour entirely from W2's bands.
   **No dark channel behind it** (Martin, 2026-09-29, iOS b91eac73 — the web
   followed on 2026-09-30): the segments sit on the page's own ground. The
   channel stays a token and an opt-in (`.channel`) because the share card
   keeps it on the phones (`SessionSummaryCard`); the web's card draws its own
   bars and never used `.ibar`. */
.ibar {
  display: flex;
  gap: var(--bar-gap);
  height: var(--bar-h);
  padding: 0;
  border-radius: var(--bar-channel-r);
  background: none;
  overflow: hidden;
}
.ibar.channel { padding: var(--bar-inset); background: var(--bar-channel); }
.ibar.live { gap: var(--bar-gap-live); padding: 0; background: none; }
.ibar-seg {
  border-radius: var(--bar-seg-r);
  /* THE FLOOR. Without it a 10 s rest beside a 4:00 work rounds to zero pixels
     and the bar draws a workout that is not the one you have. It shrinks
     before the bar does — a 50-interval workout's minimums can ask for more
     room than the row has, and the bar must not run off the end. */
  min-width: var(--bar-min-seg);
  flex-grow: 0;
  flex-shrink: 1;
  position: relative;
}
/* The 1 px top highlight that makes a lit segment read as raised. */
.ibar-seg::after {
  content: "";
  position: absolute;
  inset: 0 0 auto 0;
  height: var(--bar-highlight-h);
  background: var(--bar-lit-highlight);
  border-radius: inherit;
}
.ibar-seg.band-grey        { background: var(--band-grey); }
.ibar-seg.band-blue        { background: var(--band-blue); }
.ibar-seg.band-green       { background: var(--band-green); }
.ibar-seg.band-lime        { background: var(--band-lime); }
.ibar-seg.band-orange      { background: var(--band-orange); }
.ibar-seg.band-red         { background: var(--band-red); }
.ibar-seg.band-unplaceable { background: var(--band-unplaceable); }
/* Hatched is a TEXTURE, not a fill: nothing was written here. The stops are
   measured perpendicular, as CSS measures them — which is what --bar-hatch-
   stripe/period are in. No highlight: it is not a lit segment. */
.ibar-seg.band-hatched {
  background: repeating-linear-gradient(
    45deg,
    var(--band-hatch) 0 var(--bar-hatch-stripe),
    transparent var(--bar-hatch-stripe) var(--bar-hatch-period));
}
.ibar-seg.band-hatched::after { display: none; }

/* The bar's time axis — iOS `BarTimeScale`. `caption2`, secondary, tabular,
   spread under the bar it labels. */
.bar-scale {
  display: flex; justify-content: space-between; gap: var(--sp-1);
  margin-top: var(--sp-1);
  font-size: var(--text-2xs); color: var(--muted);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
/* The midpoint is the first thing to go: unlike the ends it carries no number
   you cannot work out from them, and three five-digit labels do not fit a card
   at 320 px. iOS drops it past the stacked-layout threshold for the same
   reason. */
@media (max-width: 360px) { .bar-scale-mid { display: none; } }

/* The intensity ramp — iOS `IntensityRampView`. The six placeable bands laid
   flat at the bar's own height, gap and rounding, so the scale and the bars
   drawn against it are visibly the same object. */
/* The strip is clipped and the PIN is not: the marker stands 2 px proud of the
   ramp at each end, the way the bar's own marker overhangs its channel. One
   box cannot do both, so the bands get their own. */
.ramp { position: relative; padding-block: var(--bar-marker-overhang); }
/* **Six pills, not one bar cut into six.** The drawing's ramp is separate
   segments with their own rounding (`ramp_bar`: `border-radius:3px`, `gap:3px`)
   — the same anatomy the interval bar has — and this drew one rounded channel
   with hairline cuts in it, so the ends were round and the four inner
   boundaries were square. */
.ramp-bands { display: flex; gap: var(--bar-gap); height: var(--bar-h); }
.ramp-cell { flex: 1 1 0; min-width: 0; border-radius: var(--bar-seg-r); }
.ramp-cell.band-grey   { background: var(--band-grey); }
.ramp-cell.band-blue   { background: var(--band-blue); }
.ramp-cell.band-green  { background: var(--band-green); }
.ramp-cell.band-lime   { background: var(--band-lime); }
.ramp-cell.band-orange { background: var(--band-orange); }
.ramp-cell.band-red    { background: var(--band-red); }
/* Without the ring the marker vanishes on lime. CLAMPED to the strip rather
   than merely centred on the fraction: centred, half the pin hung outside at
   0 and at 1, where the clip sliced it flat — off the scale it marks AND a
   different shape from itself. */
/* **The TARGET's mark** — narrower and shorter than the pin, and BEHIND it,
   because where you landed is the answer and what was asked for is the
   question. Same clamp as the pin: half of it hangs outside the strip at 0 and
   at 1 otherwise.

   **It carries the pin's RING.** The first version was 55 % of `--bar-marker`
   with no ring, which is a rule the dark theme hides: `--bar-marker` is
   `#FFFFFF` in BOTH themes and the pin survives a white card only because of
   its ring, so the notch came out white-on-white in light and the panel lost
   one of its two marks. Three signals separate them instead, and all three
   hold on either ground — 3 px against 5, 20 against 26, and behind rather
   than in front. */
.ramp-notch {
  position: absolute; top: 50%;
  left: clamp(calc(var(--rail-notch-w) / 2), var(--at, 50%),
              calc(100% - var(--rail-notch-w) / 2));
  translate: -50% -50%;
  width: var(--rail-notch-w); height: var(--rail-notch-h);
  border-radius: var(--r-pill);
  background: var(--bar-marker);
  box-shadow: 0 0 0 var(--rail-pin-ring) var(--bar-marker-ring);
}
.ramp-pin {
  position: absolute; top: 50%; z-index: 1;
  left: clamp(calc(var(--rail-pin-w) / 2), var(--at, 50%),
              calc(100% - var(--rail-pin-w) / 2));
  translate: -50% -50%;
  width: var(--rail-pin-w); height: var(--rail-pin-h);
  border-radius: var(--r-pill);
  background: var(--bar-marker);
  box-shadow: 0 0 0 var(--rail-pin-ring) var(--bar-marker-ring);
}

/* The plan ------------------------------------------- W4, REDRAWN by P1,
   the MARK redrawn again 2026-09-08
   One drawing, on both platforms — iOS `WorkoutPlanTable` + `RepeatBracket`.

   A repeat is a TINT and a GUTTER since 2026-08-15: no indent, no header row,
   so the label column runs dead straight from a workout's first step to its
   last. That much this file had.

   What it did not have is the MARK, which came back on 2026-08-17 in the shape
   Martin drew and this side never followed: **a small ink slab carrying the
   count, a rail hanging from it down the rows it covers, and a dot closing it
   on the last one.** A wash says where a group IS and cannot say where it
   ENDS, and where it ends is the whole of what a repeat means; a bare bold
   `3×` in a margin says neither. It lives in the gutter, so it still indents
   nothing.

   **The group's total is not here**, on Martin's instruction (iOS,
   2026-08-17): it was the second line of a two-line mark and made the gutter a
   paragraph in a margin. The estimate beside the workout's name already
   answers the cost.

   **Full-bleed, and squared off.** The tint and the hairlines are as wide as
   the rows are, and a row on these pages is as wide as its sheet. Inset and
   rounded, the tint floated with a margin either side while every hairline ran
   past it — Martin ringed both edges. `--plan-bleed` is how far the paint
   escapes the host's own gutter; `--sp-4` covers `.sect-body` and
   `.fold-panel`, and a host that insets by something else says so (iOS passes
   the same measure as `washBleed`). */
.plan { display: flex; flex-direction: column;
  margin-inline: calc(-1 * var(--plan-bleed, var(--sp-4))); }
.plan-group { display: flex; flex-direction: column; position: relative; }
/* The wash. No radius and no inset — see the note above. */
.plan-group.bracketed { background: var(--plan-group-wash); }
/* A group that follows a group gets a gap of ground — iOS draws the same 2 px.
   "The repeat's tint is full-bleed and its bottom edge IS the boundary" holds
   against the page and fails against another group's identical tint: two
   adjacent repeats merge into one slab whose internal hairlines are
   indistinguishable from the one where they meet. */
.plan-group.bracketed + .plan-group.bracketed { margin-top: var(--sp-1r); }
/* A hairline BETWEEN rows — not above the first one. It used to be above
   every row, on the reasoning that a first row with no rule floats. It does
   not: the table's first row sits directly under a section band or a bar,
   which already closes it, and the extra rule read as a second edge a few
   pixels below the band's own (Martin, 2026-08-17: "a border above the first
   warm-up row"). And there is NO rule under the last repeat either
   (2026-08-19: "a border on last row look strange") — the tint's bottom edge
   IS the boundary, and an inset hairline hanging under a full-bleed tint was
   a short line floating across a page of edge-to-edge shapes. */
.plan-row {
  display: flex; align-items: baseline; gap: var(--sp-2);
  padding: var(--sp-2) var(--plan-bleed, var(--sp-4));
  /* A HAIRLINE, like every other divider between two rows (3.1). The drawing's
     plan rows are `border-top:1px solid HAIR` — 6 % in both themes, against the
     9 % (dark) / 11 % (light) a container's own edge gets — and here it
     matters twice over: these rules
     run under a full-bleed TINT, and at 9 % a repeat's rows read as a grid
     drawn on the wash rather than as rows sharing it. */
  border-top: 1px solid var(--hairline);
  /* 15, not 14 — iOS's `.subheadline`, which is what the row is. */
  font-size: var(--text-base);
  font-variant-numeric: tabular-nums;
}
/* **It missed every plan that OPENS WITH A REPEAT.** The mark is the group's
   first element child, so `.plan-row:first-child` cannot match there, and the
   first row drew the very rule the paragraph above says it must not — "a
   border above the first warm-up row" (Martin, 2026-08-17). A plan that opens
   with a warm-up was always fine, because an unbracketed group has no mark;
   3.5 first recorded this as "zero hits", which was true only of the eleven
   fixture workouts, all of which open with a repeat.

   CSS cannot say "a row with no row before it", so both shapes a first row can
   take are named: straight after the mark, or first in a group without one. */
.plan > .plan-group:first-child > .plan-row:first-child,
.plan > .plan-group:first-child > .plan-mark + .plan-row { border-top: 0; }
/* The gutter is on EVERY row and EMPTY on all of them — that is what keeps
   the label column straight whether or not a block repeats. The mark is drawn
   over it by the group, as ONE shape: a rail assembled per row cannot meet
   itself across a row's own padding ("the lines doesn't fit together"). */
.plan-gutter { flex: none; width: var(--plan-gutter); }
/* The mark: slab, rail, dot, in that order down a centred column — so the
   rail hangs from the middle of the count whatever the count is wide.
   LEADING-aligned in the 44 px gutter, not centred: centred put the slab
   about 22 px in and the row had far more air on the left than the right.

   `bottom` is the LAST REPEATED ROW'S CENTRE, and `--plan-rows` is how the
   group says where that is: with N rows of equal height, half a row is
   `50 % / N` of the group. Equal heights is the same assumption iOS's anchor
   makes and the reason its target column is clamped to one line.

   The dot's CENTRE goes on that row's centre, so it is pulled down by its own
   radius — a shape's frame is its extent, not its centreline, and iOS hung it
   3.5 pt high the first time for exactly this reason. */
.plan-mark {
  position: absolute; z-index: 1; pointer-events: none;
  top: var(--sp-1);
  left: var(--plan-bleed, var(--sp-4));
  bottom: calc(50% / var(--plan-rows, 1));
  display: flex; flex-direction: column; align-items: center;
}
.plan-mark::before {
  content: ""; order: 2; flex: 1 0 var(--builder-spine-dot);
  width: var(--builder-spine-w); background: var(--text);
}
.plan-mark::after {
  content: ""; order: 3; flex: none;
  width: var(--builder-spine-dot); height: var(--builder-spine-dot);
  margin-bottom: calc(-0.5 * var(--builder-spine-dot));
  border-radius: 50%; background: var(--text);
}
/* `N×` in a slab — the app's one way of putting a structural COUNT on ink,
   and it has exactly two callers on each platform: the plan's gutter mark and
   the builder's group bar. iOS `BandSlab.count`. They were two drawings of one
   badge here (a bare bold 12 px in the plan, 12 px/800 on a 1.5/6 inset and a
   literal 6 px radius in the builder), which is the drift `BandSlab` was
   created to close on the other side. */
.plan-count, .grp-badge {
  order: 1; flex: none;
  display: inline-flex; align-items: center; justify-content: center;
  background: var(--text); color: var(--on-ink);
  font-size: var(--text-2xs); font-weight: var(--weight-heavy); line-height: 1.2;
  font-variant-numeric: tabular-nums; white-space: nowrap;
  /* **A SLAB THE RAIL HANGS FROM, not a tag.** It was 1/4 on `--r-inner` and
     sized to its own text — 20 × 15 against iOS's 44 × 26 — and `BandSlab`'s
     own doc-comment names that exact state as the one it was written to fix:
     "it was 4/1 and `Radius.inner`, which made the count read as a tag rather
     than as a slab the rail hangs from". The web still had the tag.

     Framed to the gutter (`--slab-count-min-w` = `--plan-gutter`), which also
     retires the rule invented to compensate for it: the note above about
     leading-alignment because "centred put the slab about 22 px in" does not
     arise once the slab IS the gutter's width. Radius 7, deliberately not
     `--r-inner`'s 6. The two insets were declared and unused. */
  min-width: var(--slab-count-min-w); min-height: var(--slab-count-h);
  padding: var(--chip-inset-v-tall) var(--chip-inset-h);
  border-radius: var(--slab-radius);
}
/* A rest asks for nothing and holds nothing; greying it is what lets work and
   rest be told apart down a column at a glance, without a second colour.

   **The LABEL, not the row.** `opacity` on the row takes its BORDER with it,
   so the rule above every rest came out at 62 % of the hairline — 3.7 % where
   its neighbours are 6 %, effectively gone at 1× while theirs are not. 3.5's
   own 9 % → 6 % made that worse, which is how it was caught. iOS ruled this
   already (`WorkoutPlanTable`: "A rest mutes its LABEL, not its row. The whole
   row used to carry `restRowOpacity`, which dimmed the numbers too"), and the
   drawing greys the word while giving every row a full `HAIR`. The amount and
   the target are `--muted` in their own right and must not be dimmed twice. */
.plan-row.rest .plan-what { opacity: var(--plan-row-rest-opacity); }
.plan-link { display: contents; color: inherit; text-decoration: none; }
/* A hover LIFTS. It read `--bg`, which is the opaque ground UNDER the washes,
   so on a dark screen the highlight came out ~Δ(21,17,26) DARKER than the row
   it was highlighting — `.sect`'s hole in miniature, pointing the wrong way. */
.plan-row:has(.plan-link):hover { background: var(--surface-raised); }
/* The band chip's own geometry — `--chip-w` × `--bar-h`, hung `--chip-hang`
   below the baseline, exactly as `BandChip` draws it. It was a 4 px stripe
   stretched to the row's full height, which reads as a rule down the column
   rather than as a mark on the ramp. `position: relative` for the hang so the
   row's own baseline is untouched. */
.plan-chip {
  flex: none; align-self: baseline; position: relative;
  top: var(--chip-hang);
  width: var(--chip-w); height: var(--bar-h);
  border-radius: var(--bar-seg-r);
}
/* nowrap: "Warm-up" and "Cool-down" broke at their hyphens on a phone. */
.plan-what { font-weight: var(--weight-semibold); flex: 1; white-space: nowrap; }
@media (max-width: 30rem) {
  /* **The amounts in one column** (2026-09-24): with the label taking the
     slack, each amount sat wherever its target's width left it — 10:00, 8:00
     and 1:30 started at three x positions. On a phone the label is a fixed
     column and the amount takes the slack, so every amount starts at one x. */
  .plan-what { flex: none; width: 5.5em; }
  .plan-amount { flex: 1; font-variant-numeric: tabular-nums; }
  .plan-target { min-width: 0; }
}
/* **The step's own affordance.** The row was a `display: contents` link with
   nothing to say so — you could click it and nothing on screen told you. The
   drawing gives every row a 36 px trailing cell with a pencil in it, in the
   faint ink, and lifts to the reading ink under the cursor. */
.plan-edit { flex: none; width: 36px; display: flex; align-items: center;
  justify-content: center; align-self: center; color: var(--ink-faint); }
.plan-link:hover .plan-edit { color: var(--text); }
.plan-amount { color: var(--muted); }
.plan-target { color: var(--muted); text-align: right; min-width: 7em; }
.plan-footnote { font-size: var(--text-sm); color: var(--muted);
  margin: var(--sp-2) 0 0; }
/* The opener for a plan longer than its cap — iOS `WorkoutPlanTable.rowCap`'s
   "Show all N intervals" row. A ROW of the table, not a button on top of it:
   it is the table saying there is more of itself, which is the same grammar
   the builder's insertion rows use. `list-style: none` on both engines because
   a disclosure triangle beside a plan reads as a second gutter mark. */
.plan-more > summary {
  list-style: none; cursor: pointer;
  min-height: var(--control-h-tap); display: flex; align-items: center;
  /* CENTRED, with air above and a rule below (Martin, 2026-08-28). Left
     aligned in the row grammar it read as one more plan row — the one thing it
     is not. It is the end of the table and the way past it, so it sits on an
     axis nothing else here uses, and the rule below separates it from the next
     section with equal air on both sides of the line. */
  justify-content: center; text-align: center;
  padding: var(--sp-4) var(--plan-bleed, var(--sp-4)) 0;
  color: var(--accent); font-size: var(--text-base); font-weight: 600;
}
.plan-more > summary::after {
  content: ""; display: block;
}
.plan-more:not([open]) { border-bottom: 1px solid var(--border);
  margin-bottom: var(--sp-4); padding-bottom: var(--sp-4); }
/* Every rule and every wash bleeds; the FOOTNOTE does not — it is prose about
   the table rather than part of it. It sits outside `.plan`, so it keeps the
   host's gutter with nothing to say about it here. */
.plan-more > summary::-webkit-details-marker { display: none; }
.plan-more[open] > summary { display: none; }
/* The chip takes the ramp's colours — the same eight the bar uses. */
.plan-chip.band-grey        { background: var(--band-grey); }
.plan-chip.band-blue        { background: var(--band-blue); }
.plan-chip.band-green       { background: var(--band-green); }
.plan-chip.band-lime        { background: var(--band-lime); }
.plan-chip.band-orange      { background: var(--band-orange); }
.plan-chip.band-red         { background: var(--band-red); }
.plan-chip.band-unplaceable { background: var(--band-unplaceable); }
.plan-chip.band-hatched {
  background: repeating-linear-gradient(
    45deg,
    var(--band-hatch) 0 var(--bar-hatch-stripe),
    transparent var(--bar-hatch-stripe) var(--bar-hatch-period));
}

/* Section bands & the attempts table -------------------------------- W6
   §7 rebuilt a workout's page as hairline rows on white with grey bands doing
   the separating that a stack of card edges was doing. **The grey is gone**
   (2026-09-11): a filled band punches a hole in the material's washes, so what
   separates now is the tracked caps and the air around them. */
.sect {
  display: flex; align-items: center; gap: var(--sp-2);
  font-size: 12.5px; font-weight: 750; letter-spacing: var(--band-tracking);
  text-transform: uppercase; color: var(--muted);
  padding: var(--sp-35) var(--sp-4) var(--sp-2);
  /* **No fill.** It was `var(--bg)`, which worked while the ground was a flat
     colour and broke the day the ground grew washes (2026-09-11): `--bg` is
     the opaque near-black UNDER the washes, so every caps band punched a dark
     rectangle through the light — a hard vertical edge of ΔRGB (19,13,27) down
     the side of the list, on every dark screen. Worse, a `--bg` band directly
     above a `--card` band sent the two section markers in opposite directions
     from the same ground. The drawing gives `.sect` no fill at all
     (`gen.py`'s `.sect` rule is padding and layout only); the tracked caps and
     the air around them are what makes it a band. */
  background: none;
}
/* The trailing fact is the band's own voice, not a second one: regular
   weight, the SAME ink as the title (iOS `SectionBand`: `.caption`,
   `titleInk ?? textSecondary`). It was 600 on a lightened grey, which read as
   a value being announced rather than as the band counting what is under it. */
.sect-n { margin-left: auto; font-weight: 400; letter-spacing: 0;
  text-transform: none; font-size: 12.5px; color: var(--muted);
  font-variant-numeric: tabular-nums; }

/* One grid for the WHOLE table, rows on a subgrid: every row — the head
   included — shares the same resolved tracks. Each .at-row used to be its
   own grid, so `1fr` and `auto` resolved per row: the head's visually-hidden
   Marks header made ITS last track zero wide and pushed the four numeric
   headers right of the numbers they name (Martin's screenshot, 2026-08-21) —
   and a PB row's wider marks cell mis-set its row the same way.

   FIVE tracks, and the DATE takes the slack. There was a sixth, trailing,
   holding the PB badge and the version chip — and a trailing track absorbs
   every point of slack, so one attempt with no PB left its `v1` marooned
   against the right edge with an inch of white in front of it. The marks are
   not a measurement: they qualify WHICH attempt this is, which is what the
   date says, so they ride with it and the numbers reach the right edge
   (iOS `AttemptsTableView`, 2026-08-17). */
.attempts-table { display: grid;
  grid-template-columns: minmax(4.5em, 1fr) auto auto auto auto; }
/* The SESSION page's two variants (2026-09-08). `compact` drops `ø /500` and
   `ø ♥` — the phone does the same to make room for the delta, and both are one
   tap away on the workout's own page; `with-delta` adds the `vs this` column.
   The macro is the same one, so a row cannot be drawn two ways. */
.attempts-table.with-delta {
  grid-template-columns: minmax(4.5em, 1fr) auto auto auto auto auto; }
.attempts-table.compact { grid-template-columns: minmax(4.5em, 1fr) auto auto; }
.attempts-table.compact.with-delta {
  grid-template-columns: minmax(4.5em, 1fr) auto auto auto; }
/* The one coloured column on this table — better or worse than the session you
   are reading. Equal carries no colour: it is an answer, and it is not a win. */
/* `.at-row > ` so this outranks the muted-column rule below, which colours
   cells by POSITION and would otherwise grey a delta out — the delta's column
   index moves with `compact`. */
.at-row > .at-delta.good { color: var(--diff-good); }
.at-row > .at-delta.bad  { color: var(--diff-bad); }
/* The row you are standing in. No wash: the tag says it, and a tint under one
   row of a four-column subgrid stripes at every gap. */
.at-row.reading { font-weight: var(--weight-semibold); }
/* Which workout the table under it is about — printed only on a session that
   held more than one, where the rows above cannot answer it on their own. */
.at-subject { margin: 0; font-weight: var(--weight-semibold); }
/* The session page's workout rows sit closer than a list of them does: they
   are a doorway, not the page's subject. */
.session-workouts { display: flex; flex-direction: column; gap: var(--sp-2); }
.at-row {
  display: grid;
  grid-template-columns: subgrid; grid-column: 1 / -1;
  align-items: baseline; gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-4);
  border-top: 1px solid var(--border);
  color: var(--text); text-decoration: none;
  font-variant-numeric: tabular-nums; font-size: var(--text-sm-plus);
}
a.at-row:hover { background: var(--surface-raised); text-decoration: none; }
/* 10 px, not 12 — iOS `metricLabel`, bold, tracked 0.5. Two pixels bigger, a
   header is wider than the number under it and the head, not the data, is what
   sets a content-sized column's width: `SMART ØW` alone took 14 px off the
   date column, which is where the version chip lives. And a column head names
   a column, so it never wraps. */
.at-head {
  border-top: 0; text-transform: uppercase; letter-spacing: .5px;
  font-size: var(--text-3xs); font-weight: var(--weight-bold); color: var(--muted);
  white-space: nowrap;
}
.at-row > :nth-child(n+2) { text-align: right; }
.at-row > :first-child { text-align: left; }
/* No weight of its own, and no slab. The band said nothing here — every
   attempt at ONE workout sits in more or less the same power range, so a
   column of banded results is a colour that barely changes down the page
   (Martin, 2026-08-27, on the phone). One `rowFont` for every cell. */
.at-result { font-weight: inherit; }
/* The date, the PB badge and the version chip, in one leading cell. */
/* **The marks sit UNDER the date, not beside it.** They rode the same line
   until the date grew its year on 2026-09-08: at a phone's width
   `18 Aug 2026` plus a `This session` tag plus `v1` had nowhere to go, and the
   cell wrapped mid-date. The date is what this column is FOR, so it keeps the
   first line to itself and never wraps. iOS stacks the same two rows. */
.at-when { display: flex; flex-direction: column; align-items: flex-start;
  gap: var(--sp-05); min-width: 0; }
.at-marks { display: flex; align-items: baseline; gap: var(--sp-1); flex-wrap: wrap; }
.at-date { white-space: nowrap; }
/* A partial attempt is a fact, not a failure — the same grey as everything
   else. Red here would collide with the bands, where red means a sprint. */
.at-row > :nth-child(3), .at-row > :nth-child(4), .at-row > :nth-child(5) {
  color: var(--muted);
}
/* …but a delta is never one of those, whatever position it lands in. */
.at-row > .at-delta { color: var(--text); }
/* The structure diff — what was different about the workout the day you rowed
   it. The only thing that explains why two attempts at "the same" workout are
   not comparable, so it stays, UNDER its row rather than beside it. */
/* `text-align: left` explicitly: it spans every column, and the rule above
   right-aligns everything after the first — which put the diff hard against
   the marks column instead of under the date it belongs to. */
/* `.at-row > .at-struct`, not `.at-struct`: the nth-child rule above that
   right-aligns every column after the first is (0,2,0) and wins against a bare
   class. Specificity, not order — the first fix looked right in the CSS and
   changed nothing in the picture. */
.at-row > .at-struct { grid-column: 1 / -1; text-align: left;
  font-size: var(--text-xs); color: var(--muted); }
/* Below the app's one reflow threshold the columns stop being columns: a table
   squeezed to five truncated numbers is less readable than the cards it
   replaced, so at those widths it IS rows again. */
@media (max-width: 23rem) {
  .at-row { grid-template-columns: 1fr auto; row-gap: var(--sp-1); }
  .at-head { display: none; }
  .at-row > :nth-child(n+3) { text-align: left; }
}
/* `.visually-hidden` went with the Marks column on 2026-09-08 — it had exactly
   one caller, the hidden header over the PB badge and the version chip, and
   those ride with the date now. A screen-reader-only utility with nothing to
   hide is a rule waiting to be misapplied; it comes back with its next caller.
   */

/* A workout's page ---------------------------------------------------- W6
   §7: rows on white, grey bands between the sections, no cards. */
.page-sheet {
  background: var(--card);
  border: 1px solid var(--border);
  border-radius: var(--r-lg);
  overflow: hidden;
  margin-top: var(--sp-4);
}
.wk-head { padding: var(--sp-4); display: flex; flex-direction: column;
  gap: var(--sp-2); align-items: flex-start; }
/* A tag is a chip, not a band. In a flex COLUMN the default `stretch` made it
   the full width of the page, which reads as a status bar rather than a badge. */
.wk-head > .tag { align-self: flex-start; }
.wk-head > .ibar, .wk-head > .wk-title, .wk-head > .wk-meta,
.wk-head > .bar-scale { align-self: stretch; }
/* The scale belongs TO the bar, not beside it: iOS stacks the two `micro`
   apart inside one container, and this head's own `--sp-2` gap between
   children is four times that. The negative half pulls it back to 2 px. */
.wk-head > .bar-scale { margin-top: calc(var(--sp-05) - var(--sp-2)); }
/* The name and what it costs, in the pairing the list row uses. Wraps rather
   than truncating: the name is the one thing you already knew and came to
   confirm, and a fragment of it confirms nothing. */
.wk-title { display: flex; align-items: baseline; gap: var(--sp-3); flex-wrap: wrap; }
.wk-title h1 { font-size: var(--text-xl); font-weight: var(--weight-bold); margin: 0; }
.wk-cost { margin-left: auto; color: var(--muted); font-weight: var(--weight-semibold);
  font-variant-numeric: tabular-nums; white-space: nowrap; }
.wk-meta { margin: 0; font-size: var(--text-sm); color: var(--muted); }
/* WHOSE session — the crest and the club's name on a line above the title,
   which is iOS `ClubWorkoutView.headerSection`'s first row. You arrive here
   from a list of several clubs' workouts, and which of them set this one is
   the first question. */
.wk-club { display: flex; align-items: center; gap: var(--sp-2);
  align-self: stretch; }
.wk-club-name { font-size: var(--text-base); font-weight: var(--weight-semibold); }
/* The window, with its clock glyph, under Before you start. */
.wk-window { display: flex; align-items: center; gap: var(--sp-15);
  margin: var(--sp-3) 0 0; }
/* The scoring clause ends the line, in ink. */
.wk-meta strong { color: var(--text); font-weight: var(--weight-semibold); }
.sect-body { padding: var(--sp-2) var(--sp-4) var(--sp-35); }
/* The head's tag row — the phone's `HStack(spacing: .sm)` under the meta
   line. `.wk-head` is a flex COLUMN, so without this the tags stacked one per
   line and each one stretched, which reads as a status bar rather than a pair
   of badges. */
.wk-tags { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2); }
/* "Available until Thu 13 Aug" — the muted clock line a workout outside its
   window prints under the plan, in place of the START it cannot lend
   (iOS `OfficialWorkoutView`'s `Label(…, systemImage: "clock")`). */
.wk-when { display: flex; align-items: center; gap: var(--sp-2);
  margin: 0; font-size: var(--text-sm-plus); color: var(--muted); }
/* The signed-out nudge — value first, then a secondary button. iOS
   `SignInPrompt`, whose spacing is `Spacing.xs` between the two. */
.signin-prompt { display: flex; flex-direction: column; align-items: flex-start;
  gap: var(--sp-2); }
.signin-prompt p { margin: 0; font-size: var(--text-sm-plus); }
/* "Already in your workouts" — what stands where the Add button was once the
   copy exists. A statement, not a disabled control: the phone swaps the
   button for this label rather than greying it (`SharedWorkoutSheet`). */
.claimed { display: flex; align-items: center; gap: var(--sp-2);
  margin: 0; font-size: var(--text-sm-plus);
  font-weight: var(--weight-semibold); }
.wk-whose { margin: 0 0 var(--sp-1); font-size: var(--text-sm); }
/* **A workout's description** (2026-09-29) — the writer's own plain text,
   under the bar and above the plan. iOS `WorkoutDescriptionText`: body size,
   secondary ink, wrapped, the writer's line breaks kept (`pre-line`) and
   nothing else interpreted. */
/* **A thin line under it, even space around it** (Martin, 2026-09-29) —
   everywhere it is shown, as on the phones: room above the words, the same
   room down to an ordinary hairline, and the plan below. */
.wk-desc { margin: var(--sp-2) 0 0; align-self: stretch; font-size: var(--text-base);
  line-height: 1.45; color: var(--muted); white-space: pre-line;
  overflow-wrap: anywhere;
  padding-bottom: var(--sp-4); border-bottom: 1px solid var(--border); }
/* On the personal page it sits in the main column between the bar's scale and
   the plan's band: the same space above as down to its line. */
.wk-main > .wk-desc { margin: var(--sp-4) 0 var(--sp-4); }
/* Inset from the band's edge like every other row (2026-09-24: the icons sat
   flush on the card's edge). */
.wk-actions { display: flex; flex-direction: column; padding-inline: var(--band-gutter, 20px); }
/* One action per row, full width — three equal buttons truncated "Duplicate"
   to "Duplica…" and had no room for the actions beside them. */
.wk-action {
  display: flex; align-items: center; gap: var(--sp-3);
  width: 100%; min-height: var(--control-h);
  padding: var(--sp-2) 0;
  border: 0; border-top: 1px solid var(--border);
  background: none; color: var(--text); font: inherit; text-align: left;
  cursor: pointer; text-decoration: none;
}
.wk-action:hover { text-decoration: none; color: var(--text); }
.wk-actions > :first-child .wk-action,
.wk-actions > .wk-action:first-child { border-top: 0; }
/* The one chevron, on the one row that goes anywhere. */
.wk-go { margin-left: auto; width: .5em; height: .5em;
  border: 0 solid var(--muted); border-width: 1.5px 1.5px 0 0; rotate: 45deg; }
.wk-action-form { display: block; }

/* The chip sits beside a name, not against it. */
.cell-chip { display: flex; align-items: baseline; gap: var(--sp-2); }
.leader-row .band-chip { margin-right: 0; }

/* Band chip — W7. The same eight the bar and the plan use, and the same
   geometry `BandChip` draws: `--chip-w` wide, `--bar-h` tall, `--bar-seg-r`
   rounded, hung `--chip-hang` below the baseline. It was 4 px on a 1.1 em
   height here and 5 pt on 14 over there — one component, two sizes, and no
   token to compare them with. */
.band-chip {
  display: inline-block; flex: none;
  width: var(--chip-w); height: var(--bar-h); border-radius: var(--bar-seg-r);
  vertical-align: calc(-1 * var(--chip-hang));
}
/* **The two-line row's chip is the bar form.** Not a modifier class: the
   condition is the CELL it stands beside, and a class would have to be
   remembered at every call site — the same argument `.tr:has(.cell2-b)` makes
   one section down. (4.1.) */
.log-name .band-chip {
  width: var(--log-chip-w); height: var(--log-chip-h);
  border-radius: var(--chip-tall-r); vertical-align: baseline;
}
/* Hatched is a TEXTURE — *nothing was written here* — and this chip had no
   rule for it at all, so a hatched band drew an empty box. The stops are the
   bar's, measured perpendicular as CSS measures them. */
.band-chip.band-hatched {
  background: repeating-linear-gradient(
    45deg,
    var(--band-hatch) 0 var(--bar-hatch-stripe),
    transparent var(--bar-hatch-stripe) var(--bar-hatch-period));
}
.band-chip.band-grey        { background: var(--band-grey); }
.band-chip.band-blue        { background: var(--band-blue); }
.band-chip.band-green       { background: var(--band-green); }
.band-chip.band-lime        { background: var(--band-lime); }
.band-chip.band-orange      { background: var(--band-orange); }
.band-chip.band-red         { background: var(--band-red); }
.band-chip.band-unplaceable { background: var(--band-unplaceable); }
/* Club branding ------------------------------------------------- *
 * A club's own colour and picture. The containment rule, which is the whole
 * reason this is only three selectors: club colour may say WHOSE, never how
 * hard or how good. It is allowed on a badge, on a ring and in a club header.
 * It must never reach the interval bar, a value, a result or the button
 * ladder — those belong to the intensity ramp, the app's one colour language.
 *
 * **The badge is a TINT of the club's colour, not a fill of it** (2026-09-14,
 * ruling 09 of the next-step questions: the drawing tints it). A solid brand
 * slab was the loudest object on a row whose loud thing is the ramp; a wash
 * with an edge in the club's colour still says WHOSE, and the name stays in
 * ink, so no club's pick can make it unreadable — which is also why the badge
 * no longer needs `brand.label_ink` (the picture's initials still use it). */
.tag.club { background: color-mix(in srgb, var(--club-colour, var(--text)) 16%, transparent);
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--club-colour, var(--text)) 45%, transparent);
  color: var(--text); }

/* The picture. Circle with a paper ring, which is what keeps it off whatever
 * it is drawn on — a bare circle on a white card reads as a hole. The soft
 * sweep is one gradient and no asset; it is deliberately soft, because the
 * hard highlight is the 2008 app-icon look and it fights a design whose whole
 * character is flat ink on paper. */
.club-pic {
  /* `inline-block`, and it is load-bearing: this is a <span>, and width and
     height do not apply to an inline non-replaced element. Without it the size
     classes below were ignored and the picture rendered as a ~10 px box —
     ring, no image (Martin, 2026-08-19: "I uploaded an image but preview
     doesn't work"). */
  display: inline-block; vertical-align: middle;
  flex: none; position: relative; overflow: hidden; border-radius: 50%;
  background: var(--card) center/cover no-repeat;
  box-shadow: 0 0 0 2px var(--surface-opaque), 0 0 0 3.5px var(--club-colour, rgba(21,23,26,.12));
}
.club-pic::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(155deg, rgba(255,255,255,.34) 0%,
              rgba(255,255,255,.08) 38%, rgba(255,255,255,0) 55%);
}
.club-pic.sm { width: 36px; height: 36px; }
.club-pic.md { width: 40px; height: 40px; }
.club-pic.lg { width: 56px; height: 56px; }
/* No picture: the club's INITIALS in its own colour on a 14 % wash of it —
   iOS `ClubPicture`'s fallback, which is `InitialsAvatar`. Same disc, same two
   rings, so a club with no upload still reads as a club instead of as a name
   with a gap where the others have a crest. The 14 % is `InitialsAvatar`'s own
   `tint.opacity(0.14)`. */
.club-pic.initials {
  display: inline-flex; align-items: center; justify-content: center;
  background-image: none;
  background-color: color-mix(in srgb, var(--club-colour, var(--text)) 14%, var(--card));
  color: var(--club-colour, var(--text));
  font-weight: var(--weight-bold); line-height: 1;
}
.club-pic.sm.initials { font-size: var(--text-md); }
.club-pic.md.initials { font-size: var(--text-lg); }
.club-pic.lg.initials { font-size: var(--text-xl); }

/* A club as a row — iOS `ClubsView.clubRow`. The crest leads, the name and its
   member count stack beside it, and the role tag sits at the trailing edge.
   Built on `.member-item` because it is the same anatomy: a picture, a person
   or a club, and one qualifier. */
.club-item-link { display: flex; align-items: center; gap: var(--sp-3);
  flex: 1; min-width: 0; color: inherit; text-decoration: none; }
/* `.club-item-who` was the two-line cell's first private spelling; the
   component replaced it (3.2). What is left is the basis it carried — a name
   with controls beside it must not shrink to min-content.

   **Scoped to `.member-item`, not `.club-item`.** Only `club_row` adds the
   second class; the series rows are bare `<li class="member-item">`, so a
   `.club-item` scope left them at `flex: 0 1 auto` and their chevron landed
   mid-row instead of at the trailing edge. The rule the deleted one carried
   was unscoped, and the replacement has to cover the same rows. */
.member-item .cell2 { flex: 1 1 9rem; }

/* The picker's swatch grid — 8 across, and a hex field beside it. */
.sw-grid { display: grid; grid-template-columns: repeat(8, 1fr); gap: var(--sp-2);
           max-width: 420px; }
.sw-grid button {
  aspect-ratio: 1; border: 0; border-radius: var(--r-sm); cursor: pointer;
  box-shadow: inset 0 0 0 1px rgba(21,23,26,.10);
}
.sw-grid button[aria-pressed="true"] { box-shadow: 0 0 0 2px var(--surface-opaque), 0 0 0 4px var(--text); }

/* The value ON its band ----------------------------------------- *
 * iOS moved the ramp off a 4 px marker at the far left of the row and onto the
 * number it grades (Martin, 2026-08-17: "move the color code so the avg power
 * value has this color as a background", then "you can remove the marker to the
 * left on each row"). This is that, here.
 *
 * The ink flips with the band because the ramp's two halves cannot take the
 * same label — lime and orange are bright, blue and red are dark. Mirrors
 * AppTheme.Palette.bandLabelInk exactly; change one, change the other.
 *
 * `unplaceable` and `hatched` get NO slab: they are the absence of a reading,
 * and a grey box around a dash reads as a measurement. Same rule as iOS's
 * `band.isPlaced` guard. */
.band-value {
  display: inline-block; padding: var(--sp-05) var(--sp-1);
  border-radius: var(--r-inner); font-variant-numeric: tabular-nums;
}
.band-value.band-green,
.band-value.band-lime,
.band-value.band-orange { color: #0a0d12; }
.band-value.band-grey,
.band-value.band-blue,
.band-value.band-red    { color: #fff; }
.band-value.band-grey   { background: var(--band-grey); }
.band-value.band-blue   { background: var(--band-blue); }
.band-value.band-green  { background: var(--band-green); }
.band-value.band-lime   { background: var(--band-lime); }
.band-value.band-orange { background: var(--band-orange); }
.band-value.band-red    { background: var(--band-red); }
/* No background, no padding: it must not read as a slab with nothing in it. */
.band-value.band-unplaceable,
.band-value.band-hatched { background: none; padding: 0; color: inherit; }

.band-chip.band-hatched {
  background: repeating-linear-gradient(
    45deg, var(--band-hatch) 0 var(--bar-hatch-stripe),
    transparent var(--bar-hatch-stripe) var(--bar-hatch-period));
}
/* YOUR row. Lime at 16 %, not the accent: the ramp already marks you on the
   live screen, and lime is the colour of holding your own 5 km pace. */
.leader-row.me, tr.me, .log-row.me { background: var(--me-wash); }

* { box-sizing: border-box; }
/* The hidden attribute always wins, even over rules that set display on the
   element's class (the UA's plain `[hidden]` rule loses that fight). */
[hidden] { display: none !important; }
body {
  margin: 0;
  font-family: "Manrope", -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  font-size: var(--text-base);
  line-height: 1.5;
  color: var(--text);
  background: var(--bg);
  /* **Tabular figures everywhere.** This is a page of numbers read down a
     column — a pace, a watt, a split — and proportional digits make the same
     figure a different width on every row. Set once on the body rather than on
     the dozen classes that print a number, which is how three of them were
     missed before. */
  font-variant-numeric: tabular-nums;
  /* macOS renders text noticeably heavier without this — fixes "AI slop" body
     feel and lets the serif/mono choices we set elsewhere read cleanly. */
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

/* **The dark ground's three washes** — a violet at the top right, a cyan at
   the left third, a violet at the bottom, each a soft ellipse fading to
   nothing well before the opposite edge. Read off the canvas's `.site` rule
   (`docs/mockups/web-next-step/gen.py`), whose sizes are for the 1440 px
   artboard the boards are drawn at.

   **Light has none** — "plain neutral #F7F7F8, no tint, no glows" (Martin,
   2026-09-10) — so this rule exists only in the two dark blocks.

   `fixed` rather than scrolling: the drawing is one screenful and a page that
   scrolls would otherwise carry its own sunrise down with it. iOS mirror:
   `AppTheme.Ground.washes`, which is the same three lights placed
   proportionally on a 390 pt artboard. */
@media (prefers-color-scheme: dark) {
  html:not([data-theme="light"]) body {
    background: var(--ground-washes), var(--bg);
    background-attachment: fixed;
  }
}
:root[data-theme="dark"] body {
  background: var(--ground-washes), var(--bg);
  background-attachment: fixed;
}
/* **A form control does not inherit type, and `<button>` is the one nobody
   remembers.** Chrome's UA sheet gives it `font: 400 13.333px Arial`, and that
   SHORTHAND resets `font-variant-numeric` to `normal` along the way — so both
   halves of the body rule above stop at a button's edge. `input`, `select` and
   `textarea` escape it only because the field rule further down says
   `font: inherit`; nothing said it for buttons, so Copy JSON, Pace|Power,
   Save/Cancel and Show all 8 time benchmarks were Arial on a Manrope page, in
   both themes, on four different screens.

   The figures matter as much as the face and have to move WITH it: Arial's own
   digits are monospaced, which masked the missing `tabular-nums` — Manrope's
   are strongly proportional (a `1` is 36 % narrower than a `0`), so fixing the
   family alone would have introduced a wobble the day it landed. */
button, input, select, textarea, optgroup {
  font-family: inherit;
  font-variant-numeric: inherit;
}

p { text-wrap: pretty; }   /* prevents lonely orphan word on the last line */
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
/* **A row is not a word.** The underline is how a link in running text says
   "this goes somewhere"; a whole row that is a link says it with its hover
   wash and its chevron, and underlining every name, date and count on it read
   as a page of broken prose (Martin, 2026-09-14: "remove the underline"). */
a.tr:hover, .wk-item a:hover, .club-item a:hover, a.compare-card:hover,
.rep-list a:hover, a.cmp-block:hover, .pick-table tr:hover a { text-decoration: none; }
code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: 0.92em;
       /* Inline code carries URLs and API paths (sync errors, endpoint names).
          Without this a long unbreakable token pushes past the card and off the
          viewport — the message becomes unreadable AND the page scrolls
          sideways. .code-block already breaks; inline code must too. */
       overflow-wrap: anywhere; }

/* THE SHELL ---------------------------------------------------------- 2.1
   **A 236 px rail at ≥1024 px, a top bar below it.** One `<nav>` in the
   markup, two shapes here: `.shell` is a flex ROW on a wide screen with the
   rail as its first column, and a plain block on a narrow one where the rail
   becomes the top bar's drop-down.

   **The rail runs the full height of the VIEWPORT, never the content's**
   (Martin, 2026-09-11) — a rail that stops where a short page stops reads as a
   panel rather than as the side of the app, and one that runs to the bottom of
   a LONG page scrolls its own navigation off the screen. `min-height: 100dvh`
   here answers the first; `position: sticky` + `height: 100dvh` on `.rail`
   answers the second. `dvh`, not `vh`, so a mobile browser's collapsing
   toolbar does not leave a gap under it. */
.shell { min-height: 100dvh; }
/* `flex: 1` is what makes the content column take the rest of the row. Without
   it the column is sized to its own content and a 1040 px band came out 500 px
   wide beside a full-height rail. `min-width: 0` so a wide table scrolls
   inside it rather than pushing the rail off the page. */
/* A BLOCK, not a flex column. It was flex so the footer could be pushed down
   by `.container { flex: 1 }`; that went when the footer moved back under the
   content, and flex was then costing more than it bought — **flex items'
   margins do not collapse**, so the gap under the content became "the last
   block's margin PLUS the footer's padding" and no rule could state it in one
   place. As a block, the content's trailing margin and the footer's top margin
   are adjacent siblings and collapse to the larger of the two, which is the
   34 px the drawing asks for, at any depth and whatever the last block is. */
.shell-body { flex: 1; min-width: 0; }

.topbar {
  display: flex; align-items: center; justify-content: space-between;
  padding: var(--sp-3) var(--sp-5);
  background: var(--card);
  border-bottom: 1px solid var(--border);
}
.brand { font-weight: 700; font-size: var(--text-lg); color: var(--text); }
nav { display: flex; gap: var(--sp-4); align-items: center; min-width: 0;
  overflow-x: auto; scrollbar-width: none; }
nav::-webkit-scrollbar { display: none; }
/* The rail's own parts are hidden until the rail exists; below 1024 px the
   same element is a drop-down list of links and nothing else. */
.rail-brand, .rail-gap, .rail-rule, .rail-me { display: none; }

@media (min-width: 1024px) {
  .shell { display: flex; align-items: stretch; }
  /* **Only where a rail replaced it.** `.topbar { display: none }` was
     unconditional, and the rail is inside `{% if user %}` — so every SIGNED-OUT
     page that extends this shell (the public share link, /welcome, /android,
     an official workout, the pay link) lost its header entirely: no brand, no
     way home, and the content hard against x 0 with 240 px of dead ground
     where the rail would have been. One of those is the only page a stranger
     ever sees. */
  .shell-rail .topbar { display: none; }
  /* Signed out there is no rail, and the top bar is a BAR: across the top,
     not a flex column beside the page (it drew a lone "ErgIntz" halfway
     down an empty strip on every signed-out page — found 2026-09-24 on the
     published live screen). */
  .shell:not(.shell-rail) { flex-direction: column; }

  .rail {
    width: 236px; flex: none;
    /* **STICKY, and exactly one screen tall.** `.shell`'s `min-height` only
       guarantees the SHORT case: on a long log the rail stretched to the
       document's height and scrolled away with the page — at 1440×700 it was
       1172 px tall and the account block sat 366 px below the fold, and a few
       hundred more pixels took History and Workouts off the screen too. The
       comment three rules up claimed the opposite, and the direction's own
       blurb sells this rail as "the nav is always in view".
       **No pair can catch this**: a drawing cannot scroll and every artboard
       is exactly one screenful. `align-self: flex-start` is what stops the
       flex row stretching it, and `overflow-y: auto` is for the day the rail
       itself does not fit. */
    position: sticky; top: 0; align-self: flex-start;
    height: 100dvh; overflow-y: auto;
    display: flex; flex-direction: column; gap: var(--sp-1r);
    /* **`stretch`, explicitly.** The shared `nav` rule above is
       `align-items: center` — right for a row of links in a top bar, and in a
       COLUMN it centres and shrink-wraps every item: each destination sat at
       its own indent, the current-page highlight was 96 px of a 208 px row,
       and `.rail-rule` — a `<span>` with a height and no width — collapsed to
       nothing, so the divider above the account was simply not drawn. */
    align-items: stretch;
    padding: 22px 14px;
    border-right: 1px solid var(--border);
    /* **Two different answers, because the two grounds are.** On dark it is
       2 % of the ink — the faintest wash the material has, and nearly nothing
       on purpose: the rail is the side of the page, not a panel on it. On
       light a 2 % ink film would make the rail DARKER than the page, so it
       takes the surface white instead and the hairline does the separating.
       `light.py` states the swap in as many words: "the rail's own tint: 2 %
       white on dark → the surface white on light". */
    background: var(--surface);
    overflow-x: visible;
  }
  .rail-brand {
    display: flex; align-items: center; gap: var(--sp-3);
    padding: 4px 12px 26px;
    font-weight: 800; font-size: 16px; color: var(--text);
  }
  .rail-brand img { border-radius: 9px; }
}
@media (min-width: 1024px) and (prefers-color-scheme: dark) {
  html:not([data-theme="light"]) .rail {
    background: color-mix(in srgb, var(--text) 2%, transparent);
  }
}
@media (min-width: 1024px) {
  :root[data-theme="dark"] .rail {
    background: color-mix(in srgb, var(--text) 2%, transparent);
  }
  .rail-brand:hover { text-decoration: none; }
  /* **The destinations, and only them.** It was `.rail a`, which also caught
     the brand and the account block and beat both on specificity — so the
     brand rendered muted at 15/700 instead of ink at 16/800 and lost its 26 px
     of air, and the account block was 40 px tall instead of 48. A class is
     cheaper than a specificity fight, and it says which of the three kinds of
     row in this rail a rule is about. */
  .rail .rail-dest, .rail .nav-logout button {
    display: flex; align-items: center; gap: var(--sp-3);
    height: 40px; padding: 0 12px;
    border-radius: 12px;
    font-size: var(--text-sm-plus); font-weight: var(--weight-bold);
    color: var(--muted); white-space: nowrap;
  }
  .rail a:hover, .rail .nav-logout button:hover {
    background: color-mix(in srgb, var(--text) 5%, transparent);
    color: var(--text); text-decoration: none;
  }
  /* WHERE YOU ARE. `aria-current` is the markup that already says it, so the
     highlight reads off the same attribute a screen reader does rather than
     off a second class nothing else knows about. */
  .rail .rail-dest[aria-current="page"] {
    background: color-mix(in srgb, var(--text) 8%, transparent);
    color: var(--text);
  }
  .rail .rail-dest[aria-current="page"] .icon { color: var(--text); }
  .rail .icon { color: inherit; }
  /* Everything after this is pinned to the bottom of the viewport. */
  .rail-gap { display: block; flex: 1; }
  .rail-rule { display: block; height: 1px; margin: 10px 0 8px;
    background: var(--border); }
  /* The account is a ROW of the rail, so it pays the same 12 px as every other
     one: without it the avatar sat 12 px left of the five destinations' icon
     column, and a hover painted a square where every other row rounds at 12. */
  .rail-me { display: flex; height: 48px; gap: var(--sp-3); align-items: center;
    padding: 0 12px; border-radius: 12px; }
  .rail-avatar {
    width: 28px; height: 28px; border-radius: var(--r-pill); flex: none;
    display: flex; align-items: center; justify-content: center;
    background: var(--avatar-wash); color: var(--accent);
  }
  .rail-who { display: flex; flex-direction: column; line-height: 1.2; min-width: 0; }
  .rail-who b { color: var(--text); font-size: var(--text-sm); font-weight: var(--weight-bold); }
  /* An address is long and the rail is 236 px: it ELLIPSES rather than
     wrapping the account block to two rows or pushing the rail wider. */
  .rail-who span { color: var(--ink-faint); font-size: 11px;
    font-weight: var(--weight-semibold);
    overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .rail .nav-logout button { height: 34px; font-size: var(--text-xs);
    font-weight: var(--weight-bold); }
  /* Privacy, Terms, Support, just above the account (2026-09-14) — rows of
     the rail like the destinations, stacked, with NO icon (Martin: "vertical
     like the others but no icons"), left-aligned on the row's own 12 px
     inset with no glyph column held open ("left align them so there is no
     gap for icon"). */
  .rail .rail-legal { display: flex; flex-direction: column; }
  .rail .rail-legal a {
    display: flex; align-items: center; height: 40px;
    padding: 0 12px;
    border-radius: 12px;
    font-size: var(--text-sm-plus); font-weight: var(--weight-bold);
    color: var(--muted); white-space: nowrap;
  }
}
/* In the narrow menu, the same stack under the destinations. */
.rail-legal { display: flex; flex-direction: column; }
/* Sign out is a POST (CSRF), so it is a <button> in a form that lays out and
   styles exactly as if it were another nav link. `display: contents` removes
   the form from layout; `all: unset` strips the button chrome so the shared
   rule below can paint it like an <a>. */
nav form.nav-logout { display: contents; }
nav .nav-logout button { all: unset; cursor: pointer; box-sizing: border-box; }
nav a, nav .nav-logout button { display: inline-flex; align-items: center; gap: var(--sp-15); white-space: nowrap;
  color: var(--muted); font-weight: var(--weight-medium); }
nav a:hover, nav .nav-logout button:hover { color: var(--text); text-decoration: none; }
nav a .icon, nav .nav-logout button .icon { color: var(--muted); }
nav a.muted, nav .nav-logout button { font-weight: 400; }

/* Hamburger menu — hidden on desktop (nav is inline). The checkbox stays
   focusable (visually hidden, not display:none) so the menu is keyboard-
   operable; the label is the tappable icon. */
.nav-toggle { display: none; }
.nav-toggle-cb { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
/* Breakpoints — the system has exactly three:
     1024px the SHELL flip (the rail appears; the top bar and its menu go)
     640px  layout flip (hamburger nav, summary-grid 2→4 columns)
     560px  density polish (tighter gutters, denser tables)
   New media queries should reuse one of these, not invent a fourth.
   1024 was added on 2026-09-11 by the next-step redesign and is the one the
   rail needs: 236 px of rail plus the drawing's 1040 px band plus two 40 px
   gutters does not fit in less. **Under 1024 the shell is the top bar and its
   hamburger, at every width** — 640 no longer flips the nav (it never had a
   bar to sit inside once the rail moved the nav out), and keeps only the
   summary grid. */

/* THE MENU, below the rail's width -------------------------------------- 2.1
   **Every one of these used to be written `.topbar nav`**, which was right
   while the nav lived inside the header. The rail moved it out to `.shell`, so
   none of them matched: at every width under 1024 px the menu was an
   always-open full-width row that ran off the side of a 600 px phone, and the
   hamburger did nothing at all — the exact failure the markup's own comment
   says was fixed. The nav is `.shell > nav` now, and the selectors say so.

   **It is one shape for the whole band, not two.** The hamburger used to
   appear at 640 and the links sat inline in the bar between 641 and 1023;
   with the nav outside the header there is no bar to sit inside, so the menu
   is the answer everywhere the rail does not fit. `.98` because the rail's own
   query is `min-width: 1024px` and a viewport is not always an integer. */
@media (max-width: 1023.98px) {
  .topbar { position: relative; }
  .nav-toggle {
    display: inline-flex; align-items: center; justify-content: center;
    /* 44 = the touch-target minimum. Below the rail's width this is the ONLY
       route to History / Workouts / Benchmarks / Clubs / Settings, so it is
       the most tapped control in the mobile web app; it was 40x40. */
    width: 44px; height: 44px; margin: -8px -6px -8px 0;
    border-radius: var(--r-md); color: var(--text); cursor: pointer;
  }
  .nav-toggle:hover { background: color-mix(in srgb, var(--text) 7%, transparent); }
  .nav-toggle-cb:focus-visible ~ .topbar .nav-toggle { outline: 2px solid var(--accent); outline-offset: 2px; }
  .shell > nav {
    /* **No `top`.** An absolutely-positioned box with no offset sits at its
       STATIC position — which, for the immediate next sibling of the top bar,
       is exactly under the top bar. `top: 100%` was right while the nav was
       INSIDE the header; against `.shell` it means the bottom of the whole
       shell, i.e. the menu would have opened at the foot of the page. */
    display: none; position: absolute; left: 0; right: 0; z-index: 50;
    flex-direction: column; align-items: stretch; gap: 2px; overflow: visible;
    padding: var(--sp-2); background: var(--surface-opaque); border-bottom: 1px solid var(--border);
    /* Deliberately stronger than --shadow-hover (alpha .14 vs .07 on the same
       0 10px 24px layer): the open menu must separate from page content. */
    box-shadow: 0 10px 24px rgba(0, 0, 0, 0.14);
  }
  /* The menu hangs off the TOP BAR, so the bar is what it is positioned
     against — and `.shell` is what actually contains them both. */
  .shell { position: relative; }
  .nav-toggle-cb:checked ~ nav { display: flex; }
  .shell > nav a, .shell > nav .nav-logout button { padding: var(--sp-3) var(--sp-35); border-radius: var(--r-md); }
  .shell > nav a:hover, .shell > nav .nav-logout button:hover { background: color-mix(in srgb, var(--text) 7%, transparent); }
}

/* The content column. Centred and 900 px wide while the shell was a top bar;
   under a rail it is LEFT-ALIGNED against it, because the rail is the page's
   left edge and centring the content inside what is left puts a second,
   invisible margin beside a real one. 40 px gutter and a 1120 px cap are the
   drawing's (`gen.py`: `padding:30px 40px 40px 40px`). */
/* `width: 100%` as well as `margin: 0 auto`: the column is a FLEX ITEM now,
   and an auto cross-axis margin cancels `stretch`, so both this and the footer
   shrink-wrapped to their content — at 500 px the footer came out 200 px wide,
   centred, instead of running the column. */
/* **One page, one shape** (Martin, 2026-09-14: "all pages are of different
   width — make sure they are handled the same"). Every page runs the column to
   `--page-max`; every two-column page gives its aside `--page-aside`. The six
   grids (`.yx-`, `.sx-`, `.wk-`, `.ed-`, `.cx-`, `.cmp-cols`) spelled four
   asides — 400, 420, 440 — and History, Benchmarks and Clubs capped their own
   lists shorter still, so the main column's edge moved from page to page. */
:root { --page-max: 1120px; --page-aside: 420px; }
.container { width: 100%; max-width: 900px; margin: 0 auto; padding: 28px 20px 60px; }
@media (min-width: 1024px) {
  /* 1200, not 1120: `max-width` is BORDER-box here, so a 1120 cap spends the
     two 40 px gutters out of the measure and leaves 1040 px of content where
     the drawing has 1120. AHistory hides this — that board caps its own band
     at 1040 — and AWorkouts is where it shows. */
  /* No bottom padding: the footer is the last thing in this column, and the
     column's own 40 px belongs BELOW it. Carrying it here as well put 74 px
     between the content and the footer where the drawing has 34. */
  .container { max-width: 1200px; margin: 0;
    padding: 30px var(--shell-gutter) 0; }
}

/* The site footer. Every page carries it, because the legal pages used to be
   reachable only by typing the URL. Deliberately quiet — it is a place to
   find something, not something to read: muted ink, one hairline, and the
   same 900px measure as .container so the links sit under the content rather
   than under the viewport. */
.sitefoot {
  width: 100%;
  max-width: 900px;
  margin: 0 auto;
  padding: var(--sp-5) 20px var(--sp-6);
  border-top: 1px solid var(--border);
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-4);
  font-size: var(--text-sm);
}
.sitefoot a { color: var(--muted); text-decoration: none; }
.sitefoot a:hover { color: var(--text); text-decoration: underline; }
/* Under a rail the footer belongs to the COLUMN, not to the viewport: under
   what you were reading, aligned with it. **After the base rule, not before
   it** — the shell's block sits higher up the file, so an override written
   there loses to these two declarations and the links drifted to the right
   edge of a 1120 px box while the content sat at its left. */
@media (min-width: 1024px) {
  /* **Under what you were reading, not at the bottom of the window.** The
     content column used to carry `flex: 1`, which pushed this to the viewport
     edge — ~450 px below the last row on a short page, where the drawing puts
     it 34 px below (`a_screens.py`: `padding:34px 0 0`). `.shell`'s own
     `min-height` is what keeps the RAIL full height; the footer does not have
     to be dragged down with it. */
  /* **34 px as a MARGIN, not as padding.** Padding is added to whatever the
     content left behind; a margin collapses with it. `.band` carries 32 px
     of its own and put the footer 66 px down — twice, because the first fix
     zeroed `:last-child` and `/workouts` wraps its band one level deeper, so
     the rule landed on a `<script>`. Collapsing is the mechanism that does not
     care how deep the last block is. */
  .sitefoot { max-width: 1200px; margin: 34px 0 0;
    padding: 0 var(--shell-gutter) var(--shell-gutter);
    border-top: 0; gap: 22px; font-size: var(--text-xs); }
  .sitefoot a { color: var(--ink-faint); }
}
h1 { font-size: var(--text-2xl); margin: 0 0 16px; }
h2 { font-size: var(--text-lg); margin: 24px 0 12px; }
.muted { color: var(--muted); }
.error { color: var(--error); }

.page-header { margin-bottom: 20px; }
.page-header h1 { margin-bottom: 2px; }

/* min-height, not padding: the breadcrumb is the only way out of a detail page
   on mobile (there is no in-app back button), and as bare inline text its hit
   box was 21px tall. The negative margin keeps the text where it was. */
/* 44, not the app's own 36 floor: this is the browser back-affordance at the
   top of every page, the one control a thumb reaches for without looking. A
   web decision, like `.nav-toggle` beside it. */
.back { display: inline-flex; align-items: center; gap: 3px; font-size: var(--text-sm-plus); color: var(--muted); min-height: 44px; margin-block: -11px; }
.back:hover { color: var(--text); text-decoration: none; }

/* Hero / login --------------------------------------------------- */
.hero { max-width: 520px; margin: 60px auto; text-align: center; }
/* line-height: the body's 1.5 is a reading value and leaves a chasm between
   two 48px lines — which only shows up once a hero title wraps, as a club
   name on the invite landing does. */
.hero h1 { font-size: 48px; line-height: 1.1; margin-bottom: 8px;
  /* A club name is arbitrary text set at 48px in a 350px column:
     "Ergometersällskap" is ~450px wide and ran off the invite landing. */
  overflow-wrap: anywhere; }
.hero .lede { color: var(--muted); margin-bottom: var(--sp-6); }
/* Was the only place `big` geometry existed, and it disagreed with iOS on the
   padding (--sp-6 / 32 against Spacing.lg / 24). It is the `big` rung now. */
/* The hero's primary is `size="big"` at its call sites — the ladder's own
   rung, not a fourth height retyped here. It was `--control-h-lg` (46) beside
   two 38 px secondaries on the page every new account sees. */
/* A figures list inside a .hero: the hero centres its prose, but a
   label/value grid has to read down the left edge like every other .kv. */
.pay-figures { text-align: left; margin: var(--sp-5) 0; }

/* The two sign-in screens. A .hero centres its prose, and a form read down a
   centre line is unreadable — the label sits over the middle of its field and
   the eye has no left edge to return to. So the form inside a hero goes back
   to reading left, and only the prose around it stays centred. */
.auth-form { text-align: left; margin: var(--sp-5) 0 var(--sp-4); }
/* A band inside a hero labels the block under it (ACCOUNT / CONNECTION on
   /welcome), so it has to start on the same left edge the form does — its own
   `--sp-4` is the inset of a full-bleed page, and this column has none. */
.hero .sect { padding-left: 0; padding-right: 0; }
.auth-form .field { margin-bottom: var(--sp-4); }
.auth-form .btn { margin-top: var(--sp-2); }
.auth-form .auth-note, .hero .auth-note { margin-top: var(--sp-3); font-size: var(--text-sm); }
/* Six digits, read back off a phone screen against what was typed. Tabular so
   the two line up, and wide-tracked so a transposed pair is visible. */
.auth-form input.code-input {
  font-size: var(--text-xl); letter-spacing: .3em; text-align: center;
  font-variant-numeric: tabular-nums;
}
/* The Logbook offer under the welcome form: its own block, so the pitch is not
   read as a caption on the name field above it. */
.connect-offer { margin-top: var(--sp-6); }
.connect-offer .notice p { margin: var(--sp-2) 0 0; }

/* A notice ROW — iOS `ListNoticeRow`. Not `.notice`, which is a flash: this
   is the list explaining ITSELF ("No 5 km benchmark on record", "Nothing here
   counts yet"), so it is a full-width row on the list's own ground with no
   radius and no margin, a semibold title over a caption message, and a
   `--warning` wash at 12 % when it is tinted. A rounded box floating on 20 px
   of air in the middle of a band of rows says "something happened"; this has
   to say "this is how the rows below are". */
.list-notice { display: block; width: 100%; text-align: left;
  padding: var(--sp-3) var(--sp-4); background: var(--card); color: var(--text);
  border: 0; border-top: 1px solid var(--border); font: inherit; }
.list-notice:first-child { border-top: 0; }
.list-notice.tinted { background: color-mix(in srgb, var(--warning) 12%, var(--card)); }
.list-notice .ln-title { display: flex; align-items: center; gap: var(--sp-2);
  font-size: var(--text-base); font-weight: var(--weight-semibold); }
.list-notice .ln-message { margin: var(--sp-05) 0 0; font-size: var(--text-xs);
  color: var(--muted); }
/* The door variant — a notice you can walk through grows a chevron, and only
   then is it a button. */
a.list-notice, button.list-notice { cursor: pointer; text-decoration: none; }
a.list-notice:hover, button.list-notice:hover {
  background: color-mix(in srgb, var(--text) 4%, var(--card)); text-decoration: none; }
a.list-notice.tinted:hover, button.list-notice.tinted:hover {
  background: color-mix(in srgb, var(--warning) 18%, var(--card)); }
.list-notice .ln-go { margin-left: auto; width: .5em; height: .5em; flex: none;
  border: 0 solid var(--muted); border-width: 1.5px 1.5px 0 0; rotate: 45deg; }

.notice {
  margin: 20px 0; padding: var(--sp-35) var(--sp-4); border-radius: var(--r-md); text-align: left;
  font-size: var(--text-sm-plus);
  /* Neutral default so a bare .notice (no variant) is still visible. */
  background: color-mix(in srgb, var(--text) 6%, transparent); color: var(--text);
}
/* Variants derive from the semantic tokens (bg = a tint of the token over the
   card, ink = the token deepened toward text), so the dark tokens absorb the
   dark-mode shift — no per-rule overrides. */
.notice.warning { background: color-mix(in srgb, var(--warning) 14%, var(--card));
                  color: color-mix(in srgb, var(--warning) 72%, var(--text)); }
.notice.error   { background: color-mix(in srgb, var(--error) 13%, var(--card));
                  color: color-mix(in srgb, var(--error) 72%, var(--text)); }
.notice.success { background: color-mix(in srgb, var(--success) 13%, var(--card));
                  color: color-mix(in srgb, var(--success) 72%, var(--text)); }
/* Accent — the import review's "You already have “X”" (iOS
   `BuilderView.duplicateNotice`, accent at 10 %). Neither a warning nor a
   result: it names something that exists and leaves the decision alone. */
.notice.accent  { background: color-mix(in srgb, var(--accent) 12%, var(--card));
                  color: color-mix(in srgb, var(--accent) 78%, var(--text)); }
/* The server's own notes, as bullets INSIDE the one banner — the phone's
   `importNotes`. They were separate notices stacked under it, so a parse with
   three remarks drew four boxes. */
.notice ul { margin: var(--sp-2) 0 0; padding-left: var(--sp-4); }

/* Buttons -------------------------------------------------------- *
 * One component, two axes:
 *   size    — base | .small | .big     (drives min-height + padding)
 *   variant — (neutral default) | .primary | .danger | .ghost
 * Plus .block for full-width. min-height (not vertical padding) sets the
 * size, so <a> and <button>, and any mix of variants, are always the same
 * height in a row. */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: var(--sp-2);
  min-height: var(--control-h);
  padding: 0 var(--sp-4);
  border: 1px solid var(--border);
  /* SEMIBOLD, like every rung on the phone (`AppButtonStyle`: bold for
     primary, semibold for the rest). It sat at 500 here, so `.secondary`,
     `.danger` and `.ghost` were a weight lighter than their twins. TWO rungs
     are deliberately a step down now: `.insertion` (iOS
     `.footnote.weight(.medium)`) and, since 3.3, `.ghost` — the drawing steps
     its quietest rung down in both colour and weight, and this comment said
     only one did for as long as that was true. */
  /* r14 and 14 px with the 44 height — the drawing's control (2026-09-14). */
  border-radius: var(--r-lg); font-weight: var(--weight-semibold);
  font-size: var(--text-sm-plus);
  /* Labels may wrap. `nowrap` here meant a long one ("Add repeating group",
     194px on one line) could never fit a magnified viewport and pushed the
     whole PAGE sideways instead — at 200% zoom that broke 6 of 7 routes. In
     normal widths a button still sizes to its content and never wraps. */
  line-height: 1.2; cursor: pointer; text-decoration: none;
  transition: background var(--t-fast), border-color var(--t-fast),
              filter var(--t-fast), box-shadow var(--t-fast);
}
.btn:hover { text-decoration: none; }
.btn:focus-visible { outline: none; box-shadow: var(--focus-ring); }
/* No press-nudge. Nothing in this stylesheet moves on interaction any more —
   the hover wash and the focus ring say everything the 0.5px dip did. */
.btn:disabled { opacity: var(--btn-disabled-opacity); cursor: default; pointer-events: none; }

/* The three sizes, for real ------------------------------------- *
 * `AppButtonStyle.Size` says it mirrors these — "same heights, same paddings,
 * same type sizes" — and the header above this section repeated the claim,
 * but only `.btn.small .icon` existed: no height, no padding, no font-size for
 * `small`, and NO RULE AT ALL for `big`. `edit.html` has been shipping
 * `class="btn small"` and rendering it at the base 38/16/15 against iOS's
 * 30/12/13 (parity audit, 2026-08-18).
 *
 * Regular is the base `.btn` above, which is why it is not repeated here. */
.btn.small { min-height: var(--control-h-sm); padding: 0 var(--sp-3); font-size: var(--text-sm); }
/* **The rung that starts a piece has a size of its own** — 52 at radius 16,
   the material's `ControlHeight.primary` / `Radius.button` and the drawing's
   `.btn.big`. It is the one control on a screen you reach for without looking. */
.btn.big   { min-height: var(--control-h-primary); padding: 0 var(--sp-5);
  font-size: var(--text-md); border-radius: var(--r-button);
  /* Heavier, and the SIZE says so rather than the variant — iOS
     `AppButtonStyle`'s `Size.big.fontWeight = .heavy`, which beats the
     variant's weight. `.big` inherited the base 600, so START carried the same
     stroke as the secondary beside it. */
  font-weight: var(--weight-bold); }

/* --- The ladder ------------------------------------------------- W5, 2026-08-14
 *
 * Six rungs, and every button on the site is one of them. The iOS half is
 * `AppButtonStyle`; this is the same ladder in CSS, and the argument is the
 * same: **colour is the ramp, controls are ink and paper.**
 *
 * `--accent` `#2563eb` is a hue with no meaning in a design where
 * `--band-blue` `#55A3FF` means "+12 … +25 s off 5 km pace". A blue button
 * spends a colour that carries meaning in this app on a link to a page. So the
 * BUTTON FAMILY stops reaching for `--accent`; the token stays in `:root`
 * because links, the focus ring and a chart series still use it legitimately.
 *
 * `--error` survives, because destruction is not on the scale.
 *
 * | rung | shape | for |
 * |---|---|---|
 * | `.primary` | ink fill, `--r-lg` | the one thing the page exists to do. AT MOST ONE PER PAGE. |
 * | (default) / `.secondary` | one step off its ground + hairline | Share, Export, Sign in |
 * | `.value` | 5.5 % ink pill + caret | a value you can change (RowErg ⌄) |
 * | `.insertion` | dashed hairline + ＋ | Add step, Add to group |
 * | `.danger` | red label, no fill, no hairline | Delete, Sign out |
 * | `.ghost` | ink label, no fill | a quiet action inside a row |
 *
 * The cost, and how it is paid: blue is the web's universal "you can click
 * this", so every rung earns a SHAPE instead — a fill, a hairline, a pill with
 * a caret, a dashed border. Nowhere is a plain word left doing the job alone.
 */

/* **THE PRIMARY RUNG IS GREEN** — the material's `go`, and the only saturated
   fill the app has (CLAUDE.md, 2026-09-10; red is destruction alone). It was
   an INK fill, which was right while the app had no colour to spend on a
   control and is not what the drawings ask for: `gen.py`'s `.btn.go` is the
   green with `#03210F` on it and a glow of its own.

   **Its own height and radius, too.** 52 px and 16 — `--control-h-primary`
   and `--r-button`, declared with the material in Phase 1 and drawn by nothing
   until now. It is the one control on a screen you reach for without looking,
   and the drawing gives it a size no other control has for exactly that
   reason.

   The glow is the one coloured shadow in the material (`Elevation.go`): the
   primary action is the only thing on any screen that lights its own ground.
   Weaker in light (22 %) than in dark (28 %) — a glow on white is a smudge at
   the dark value. */
.btn.primary {
  /* **iOS `AppButtonStyle`'s go run** (Martin, 2026-09-14: "import style of
     button … from iOS"): `go` at the top-left into `goDeep` at the bottom-
     right, white type, and the run's edge — a highlight along the top and a
     shade along the foot (`Elevation.goHighlight` / `.goShade`). */
  background: linear-gradient(135deg, var(--go), var(--go-deep));
  color: var(--on-go); border-color: transparent;
  border-radius: var(--r-button);
  /* **No radius here.** Neither canvas keys a corner to the variant — the web
     drawing's `.go`, `.sec` and `.danger` override nothing and only `.big`
     steps up, and the app drawing gives all three one radius. Setting it on
     the rung put a near-pill beside a rounded rectangle 8 px away (Save next
     to Cancel), and at `small` a full pill beside one: `r16` on a 30 px box is
     more than half its height. Radius belongs to the size, which is the same
     argument this piece made for height and for weight two lines down. */
  font-weight: var(--weight-bold);
  /* **The colour is the rung; the SIZE is a separate axis.** The first version
     pinned `--control-h-primary` (52) here, which broke the ladder's own rule
     four sections up — "min-height (not vertical padding) sets the size, so any
     mix of variants are always the same height in a row". `.btn.primary.small`
     came out 52 px beside a 30 px `.small` on nine shipped call sites, and a
     52 px green overhung every 38 px field and toolbar control by 14.
     The drawing agrees: `gen.py`'s `.btn.go` is the BASE rung. Its `.btn.big`
     is called twice in the whole canvas and both are `btn sec big` — a
     SECONDARY at that size, which argues the point better than the "no caller
     anywhere" this comment used to claim (it was wrong; `CSession` has them).
     52 is the size of the button that STARTS something, which is `.big`, and
     the two axes are independent. */
  box-shadow: var(--go-glow),
    inset 0 1px 0 rgba(255, 255, 255, var(--go-highlight)),
    inset 0 -1px 0 rgba(0, 0, 0, var(--go-shade));
}
.btn.primary:hover { filter: none; opacity: var(--pressed-opacity); }

/* THE RULE: one step off whatever ground you are on, plus a hairline.
 *
 * A fixed colour cannot work — the same button (Share, Export FIT, Sign in)
 * appears on a card AND on the page background, and any single value vanishes
 * on one of them. The default assumes a card, which is where most buttons are;
 * a container that sits directly on the page background says so with `.on-bg`.
 *
 * The neutral no-variant `.btn` IS this rung. There were never two things.
 *
 * **The step goes UP, not down** (2026-09-11). It used to read `--bg` on a
 * card and `--card` on the ground, which was "one step off" in both directions
 * and worked while the ground was the LIGHTER of the two. The material
 * inverted that: the ground is now the darkest thing on a dark screen and it
 * carries washes, so a `--bg` button painted a hole punched through the light
 * — Import from Concept2, Export as FIT and Share image all read as gaps in
 * the page rather than as controls lifted off it, while `.on-bg .btn` beside
 * them went the other way. One rung cannot point two directions.
 *
 * So both read the surface above their own ground, which is exactly iOS's
 * `ControlGround` (`AppTheme.swift`: `.background → Palette.surface`,
 * `.card → Palette.surfaceRaised`) — the phone hit this on 2026-09-10 and its
 * comment records the same cause. The GREEN pill and the 52 px height are
 * still Phase 3.3's; this is only the direction. */
/* `--card-border`, not `--border`: a control is an OBJECT on its ground, and
   the material gives an object's edge a step more than a container's — 12 %
   against 9 % in dark, which is what `gen.py`'s `.btn.sec` draws. */
.btn,
.btn.secondary { background: var(--surface-raised); color: var(--text); border-color: var(--card-border); }
/* **In LIGHT the step up is a shadow, not a colour.** `--card`, `--surface`,
   `--surface-raised` and `--surface-opaque` are all `#FFFFFF` there, so "one
   step off whatever ground you are on" is a no-op and a rung inside a card is
   white on white with nothing but an 11 % border. The drawing's light
   secondary is white PLUS the lift (`light.py`'s `@@BTNSEC@@`); the dark one
   separates by being lighter than the ground and needs none. */
/* **The rungs with a BOX.** It was a bare `.btn`, which is every rung — so it
   put a drop shadow under `.ghost`, `.danger`, `.value` and `.insertion`, all
   of which are transparent, and, worse, it OVERRODE the primary's glow: `.btn`
   inside this prefix outranks `.btn.primary`, so `--go-glow` was declared,
   paired, taken off the undrawn list and painted nowhere. */
:root:not([data-theme="dark"]) .btn:not(.primary):not(.ghost):not(.danger):not(.value):not(.insertion) {
  box-shadow: var(--shadow);
}
/* `:not(.primary)` — these came after the go rung at the same or higher
   specificity, so hovering the green button painted the secondary's grey over
   its gradient (Martin, 2026-09-14: "hover of button turns it gray"). The go
   rung's hover is its own: the pressed opacity. */
.btn:not(.primary):hover,
.btn.secondary:hover { background: color-mix(in srgb, var(--text) 5%, var(--surface-raised)); }
.on-bg .btn:not(.primary),
.on-bg .btn.secondary { background: var(--surface); }
.on-bg .btn:not(.primary):hover,
.on-bg .btn.secondary:hover { background: color-mix(in srgb, var(--text) 5%, var(--surface)); }

/* A value you can change — not a call to action, which is what an accent pill
   was saying. These sit beside facts you CANNOT change, and the pill plus its
   caret is the entire distinction: plain grey text is a fact, ink on a pill
   with a caret is a value. */
/* 36 and 12, not 38 and 16: a value chip stands in a ROW of facts, and the
   phone sizes it to the tap floor rather than to a button's height
   (`AppButtonStyle`: `ControlHeight.tap`, `Spacing.sm`, `Radius.chip`). */
.btn.value {
  min-height: var(--control-h-tap); padding: 0 var(--sp-3);
  background: color-mix(in srgb, var(--text) var(--value-wash), transparent);
  border-color: transparent; border-radius: var(--r-sm);
  font-weight: var(--weight-semibold);
}
/* The caret IS the affordance now that the colour is gone. `rotate` only, and
   `margin-bottom` for the optical centring a `translateY` would have done:
   `test_the_stylesheet_does_not_move_anything` forbids translate/scale outright
   rather than only on `:active`, and keeping that guard tight is worth more
   than the two characters. */
.btn.value::after {
  content: ""; flex: none; width: .45em; height: .45em;
  margin: 0 0 .18em .1em;
  border: 0 solid var(--muted); border-width: 0 1.5px 1.5px 0;
  rotate: 45deg;
}
/* Pressed is twice the wash — there is no fill to darken. */
.btn.value:hover { background: color-mix(in srgb, var(--text) calc(2 * var(--value-wash)), transparent); }

/* An insertion point. The dashes were always right — it should look like a gap
   you can fill — and only the colour was wrong. Explicit ink, never a relative
   colour: the iOS twin asked for "secondary" and rendered BLUE for months,
   because `.secondary` resolves against the current foreground and a menu's
   label inherits the accent. */
/* A ROW, not a pill: the rung is "a gap you can fill", and a gap in a list is
   as wide as the list (iOS `.frame(maxWidth: .infinity)` unless `inline`).
   13, not 15 — `.footnote`, a step under the row type it sits between. */
.btn.insertion {
  display: flex; width: 100%;
  background: transparent; color: var(--muted);
  border: 1px dashed color-mix(in srgb, var(--text) var(--insertion-border), transparent);
  border-radius: var(--r-lg); font-weight: var(--weight-medium);
  font-size: var(--text-sm);
}
/* The pill form — the same offer made INSIDE a row of controls (iOS's
   `inline` flag: pill radius, hugs its label, on the tap floor). */
.btn.insertion.inline {
  display: inline-flex; width: auto;
  min-height: var(--control-h-tap); padding: 0 var(--sp-3);
  border-radius: var(--r-pill);
}
.btn.insertion::before { content: "+"; font-weight: var(--weight-bold); }
.btn.insertion:hover { background: color-mix(in srgb, var(--text) 5%, transparent); }

/* Destruction is the one thing off the ramp that keeps a colour — and it is
   the LABEL that carries it, not a fill. A filled red slab was the
   second-loudest thing on screens whose subject is a workout. */
/* Red label, no fill, and NO BORDER — the ladder gives a hairline to
   `.secondary` and to nothing else (iOS: `border = variant == .secondary`).
   The workout page zeroed it in a page rule of its own, which is why only
   that page looked right. */
/* `--danger`, not `--error`: "something went wrong" and "this destroys
   something" are two sentences, and the material gives the second its own red
   (#D9264F / #FF4D6D against `--error`'s #dc2626 / #f87171 — a crimson against
   a salmon, on the one rung whose whole identity is its colour). The token was
   declared in Phase 1 and drawn by nothing until now. */
/* **A SLAB, not a bare word.** It drew a transparent label on the reasoning
   that iOS gives a border only to `.secondary` — which stopped being true in
   this tree: `AppButtonStyle` draws `Palette.dangerFill` AND
   `Palette.dangerBorder` for this rung, at `Radius.button`. `gen.py`'s
   `.btn.danger` is the same slab (12 % fill, 30 % border), and it DOES have
   callers — Delete beside Duplicate on the `AEditor` boards. The earlier note
   here said it had none on any board, and that was simply wrong.

   The drawing uses two idioms and they are two components: a tinted slab where
   destruction is a BUTTON (the editor's actions), a bare red word where it is
   an ACTION ROW (a workout's page, Settings). The row is not this rung with
   its box taken away — `.wk-action-form .btn` is the host that flattens it. */
.btn.danger {
  background: var(--danger-fill); color: var(--danger);
  border-color: var(--danger-border);
  /* `Radius.button` — iOS gives the two rungs drawn as a `.btn` (primary and
     danger) the button radius, and every other rung the control's. */
  border-radius: var(--r-button);
}
.btn.danger:hover { background: color-mix(in srgb, var(--danger) 18%, transparent); }

/* **THE ACTION ROW — the host that flattens a rung, and it has to come AFTER
   the ladder to do it.** It sat 888 lines earlier at the same specificity
   (0,2,0), so source order decided and `.btn.danger` won: Delete on a workout's
   page came out a rounded 12 % slab with a hairline across its TOP ONLY, which
   is neither idiom. The comment claiming this rule flattened the rung was
   describing a rule that lost.

   **The drawing has two destruction idioms and this is the other one.** Counted
   on the chosen boards: the SLAB appears once (`AEditor`'s step panel,
   `Remove`) and the bare red WORD eight times — every `.act` row and all five
   of Settings'. The rung is the slab; a row is a red label on the page's own
   ground, full width and leading-aligned (`WorkoutActionRowLabel`). */
.wk-action-form .btn {
  display: flex; align-items: center; gap: var(--sp-3);
  width: 100%; min-height: var(--control-h); padding: var(--sp-2) 0;
  border: 0; border-top: 1px solid var(--border); border-radius: 0;
  background: none; justify-content: flex-start;
}
.wk-action-form .btn:hover { background: none; }

/* A quiet action inside a row: ink label, no fill, no border. */
/* **The quietest rung is quiet in TWO ways** — `gen.py`'s `.btn.ghost` is
   `MU` (the 60 % ink) at weight 700 against the base's 800 — a step down in
   both colour AND weight. It was full ink at the base weight here, which made
   the site's ghost a step LOUDER than the drawing's. The step is what matters,
   not the absolute: this ladder's base is 600, so its ghost is 500. */
.btn.ghost { border-color: transparent; background: transparent;
  color: var(--muted); font-weight: var(--weight-medium); }
.btn.ghost:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }

/* Full-width within its container. */
.btn.block { width: 100%; }

/* Form controls -------------------------------------------------- *
 * Inputs & selects share the button control height + radius so any row
 * mixing inputs, selects and buttons lines up. Compact contexts (the
 * interval editor) opt down to the small height via their own rules.
 *
 * `input:not([type])` is in the list because an attribute selector needs the
 * ATTRIBUTE, not the default: `<input>` with no `type` is a text field to the
 * browser and was none of these to the stylesheet, so every `.share-link` —
 * the club's join-code message, a club invite, a pay link, and the share link
 * on a published workout — rendered as a raw 21 px browser box with a square
 * black border, beside the rounded fields it was meant to match. */
input:not([type]),
input[type="text"], input[type="number"], input[type="email"],
input[type="password"], input[type="search"], input[type="date"],
input[type="datetime-local"], input[type="time"], select, textarea {
  min-height: var(--control-h);
  padding: 0 var(--sp-35);
  /* `--card-border`, not `--border`: a field is an OBJECT ON ITS GROUND, the
     same sentence 3.4 gave the chip and the segment box, and the drawing gives
     all three the same 12 % edge (`a_screens.py`'s `field()`:
     `border:1px solid rgba(255,255,255,.12)`). At `--border`'s 9 % the box
     under a caption was fainter than the hairline between two rows of a band. */
  border: 1px solid var(--card-border);
  border-radius: var(--r-lg);
  /* The FILM, not the flattened copy: the drawing's `field()` is
     `background:{SURF}`, and the ground under a field is not flat — three
     radial washes run across it, so one opaque colour is lighter than its
     ground at the left of a 1088 px field and DARKER at the right. Measured on
     `ASettings-1440-dark.png`: a flat (18,16,23) box over a ground running
     (9,7,15) → (23,17,36). The film follows them. See `--surface-opaque`. */
  background: var(--surface); color: var(--text);
  font: inherit; font-size: var(--text-sm-plus);
  /* **The drawing's field, whole**: 44 high, radius 14, 14 px text on a 14 px
     inset (`a_screens.py`'s `field()`). It was 38 / 10 / 15 on a 12 inset, held
     to iOS's `ControlHeight.md` / `Radius.md`; Martin took the drawing's on
     2026-09-14, web first. */
  /* A text input carries a browser-default intrinsic width (~193px from
     `size`), which a flex row cannot shrink past — so one bare <input> pushed
     /settings sideways under zoom. Controls must always be allowed to shrink;
     .share-link needed this individually before it was a rule. */
  min-width: 0;
}
textarea { padding: var(--sp-2) var(--sp-3); min-height: calc(var(--control-h) * 2); line-height: 1.5; }
/* **A placeholder is a hint, not a value.** There was no rule at all, so it
   came out at the UA's own fraction of `--text` — Chrome's is a fixed grey
   that ignores the theme entirely. The drawing draws the two states as two
   inks (`field(..., placeholder=True)` swaps `INK` for `MU2`), which is what
   lets "e.g. 82" and a weight be told apart at a glance. `opacity: 1` because
   Firefox applies 0.54 of its own on top. */
input::placeholder, textarea::placeholder { color: var(--ink-faint); opacity: 1; }
input:focus-visible, select:focus-visible, textarea:focus-visible {
  outline: none; border-color: var(--accent); box-shadow: var(--focus-ring);
}

/* Icons ---------------------------------------------------------- *
 * Inline SVGs from the icon() Jinja global. One stroke weight, sized by
 * --icon-size, coloured by currentColor so they inherit their context.
 * An icon(name, size=N) call carries an inline width/height that beats every
 * rule here — see icons.icon_svg. */
.icon {
  width: var(--icon-size); height: var(--icon-size);
  flex: none; vertical-align: middle; stroke-width: 2;
}
.btn .icon { width: 1.05em; height: 1.05em; }
.btn.small .icon { width: 15px; height: 15px; }

/* `.icon-btn` and `.btn-group` stood here — a SEVENTH and EIGHTH button shape,
   and the only thing that ever wore them was the builder's block toolbar: four
   30×30 icon buttons (up, down, duplicate, a red trash) on every block header.
   The phone draws one ⋯ menu there, for the reason its own file gives —
   "destroy this" was the highest-contrast element in the card — and the eight
   lines of CSS below `.btn-group` existed to hold the delete one pixel further
   from duplicate than the other three, which is a rule you only need when the
   destructive action is on the surface. Both classes went with the menu on
   2026-09-08; the six rungs are the contract. */

/* Toolbar / page header — title on the left, actions on the right, always
 * aligned and wrapping gracefully. The fix for headers with mismatched,
 * unaligned buttons: every page uses this one component. */
.toolbar {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--sp-3); flex-wrap: wrap; margin: var(--sp-2) 0 var(--sp-4);
}
.toolbar .toolbar-title { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
/* The name and, where there is one, the mark that belongs to it. A row rather
   than a bare h1 so a PB badge sits BESIDE the name and wraps under it on a
   narrow page, the way `AdaptiveStack` does on the phone. */
.toolbar .toolbar-name { display: flex; align-items: baseline; gap: var(--sp-2);
  flex-wrap: wrap; min-width: 0; }
/* Names and club names land here, and a 52-character one is 681px of
   unbroken h1 — wider than a phone. */
.toolbar h1, .toolbar h2 { margin: 0; overflow-wrap: anywhere; }
.toolbar .toolbar-sub { color: var(--muted); font-size: var(--text-sm); }
/* The one clause of the subtitle that is an ANSWER rather than a description —
   what makes one attempt at this workout better than another. Ink, which is
   what the drawing gives it (`<b style="color:INK">scored by smart average
   watts</b>`); the accent would spend a hue that means something else. */
.toolbar .toolbar-sub strong { color: var(--text); font-weight: var(--weight-semibold); }
/* HOW MANY — "16 sessions", on the title's own line in the faint ink. A step
   under the subtitle's size and two inks down from the name, because it is the
   one fact about the page that is arithmetic rather than a sentence
   (`gen.py`: `font-size:13px` in MU2, 4 px off the name). */
.toolbar .toolbar-count { color: var(--ink-faint); font-size: var(--text-sm);
  font-weight: var(--weight-medium); white-space: nowrap; }
.toolbar .toolbar-actions { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
/* The filter and the actions are two groups, not one strip of five controls —
   the drawing puts a 12 px spacer between the last chip and the first rung
   (`h1(right=…)`: `<span style="width:12px">`), against the 8 the actions use
   between themselves. */
.toolbar .toolbar-actions .chips + .btn { margin-left: var(--sp-1); }
/* **Beside a segment, a rung is the segment's height** (Martin, 2026-09-14:
   "make the import button same size as history/benchmark outer edge"). The
   segment's box is its pill plus its inset on both sides plus its edge; a
   control standing next to it takes exactly that, so the two read as one row
   of controls rather than a 42 px box beside a 30 or 44 px one. */
.toolbar .toolbar-actions:has(> .seg) > .btn {
  min-height: calc(var(--seg-pill-h) + 2 * var(--seg-inset) + 2px); }

/* Switch — accessible toggle built on a checkbox.
   **GO when on, ringed; white knob; the drawing's 44 × 26** (Martin,
   2026-09-14). This REVERSES the ink-and-paper ruling of 2026-09-08 ("colour
   is the ramp, controls are ink and paper"): iOS took a new look on
   2026-09-12 (`39950e62`, Martin's picks) — "the go track ringed in the deep
   green when on, the plain track with the card's edge when off, and a white
   knob with a small shadow in both states" — and the web takes that look at
   the drawing's size rather than iOS's 34 × 20 head switch. The ramp's green
   (`--band-green`) and `--go` are different values; a ring and a white knob
   are what make this read as a control and not as a band. */
.switch { position: relative; display: inline-flex; flex: none;
  width: var(--switch-w); height: var(--switch-h); }
.switch input { position: absolute; opacity: 0; width: 100%; height: 100%; margin: 0; cursor: pointer; }
.switch .track {
  position: absolute; inset: 0; border-radius: var(--r-pill);
  background: var(--track);
  box-shadow: inset 0 0 0 1px var(--card-border);
  transition: background var(--t-fast), box-shadow var(--t-fast);
}
.switch .track::after {
  content: ""; position: absolute;
  top: var(--switch-inset); left: var(--switch-inset);
  width: calc(var(--switch-h) - 2 * var(--switch-inset));
  height: calc(var(--switch-h) - 2 * var(--switch-inset));
  /* WHITE in both themes and both states — the pick. It was paper
     (`--on-ink`), which flipped with the ground; on a go track a white knob is
     the contrast in dark and light alike. */
  border-radius: 50%; background: #FFFFFF;
  box-shadow: 0 1px 3px rgba(0, 0, 0, var(--switch-knob-shadow));
  transition: transform var(--t-fast);
}
.switch input:checked + .track {
  background: var(--go); box-shadow: inset 0 0 0 1px var(--go-deep); }
.switch input:checked + .track::after {
  transform: translateX(calc(var(--switch-w) - var(--switch-h))); }
.switch input:focus-visible + .track {
  box-shadow: inset 0 0 0 1px var(--card-border), var(--focus-ring); }
/* **The one switch that is RED when ON**: heart-rate consent, where turning it
   OFF destroys data, so ON is the loaded position. iOS passes
   `onColor: AppTheme.Palette.error` for this same row and nothing else. */
.switch input.hr-switch:checked + .track {
  background: var(--error); box-shadow: inset 0 0 0 1px var(--error); }

/* Checkbox — larger, styled (native input via appearance:none). */
.check { display: inline-flex; align-items: center; gap: var(--sp-2); cursor: pointer; }
.check-input {
  appearance: none; -webkit-appearance: none; margin: 0;
  width: 22px; height: 22px; flex: none; position: relative;
  border: 2px solid var(--border); border-radius: var(--r-inner); background: var(--card);
  cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast);
}
.check-input:hover { border-color: color-mix(in srgb, var(--accent) 55%, var(--border)); }
.check-input:checked { background: var(--accent); border-color: var(--accent); }
.check-input:checked::after {
  content: ""; position: absolute; left: 6px; top: 2px; width: 6px; height: 11px;
  border: solid #fff; border-width: 0 2px 2px 0; transform: rotate(45deg);
}
.check-input:focus-visible { outline: none; box-shadow: var(--focus-ring); }

/* Tag / chip — small neutral label. iOS `AppTag`: 11 pt BOLD and UPPERCASE,
   always, on an 8 / 3 capsule at 8 % ink. It was 12/600 sentence case here, so
   "former member", "admin" and "you" printed in lower case on this side and in
   caps on the phone — the same word in two voices. */
.tag {
  /* One line, never squeezed (2026-09-24): a club's name broke into a
     two-line block inside the pill and squashed the title beside it. A name
     too long for the row ends in an ellipsis instead. */
  white-space: nowrap; flex: none; max-width: 100%; overflow: hidden; text-overflow: ellipsis;
  display: inline-flex; align-items: center; gap: var(--sp-1);
  padding: var(--chip-inset-v-tall) var(--sp-2); border-radius: var(--r-pill);
  font-size: var(--text-2xs); font-weight: var(--weight-bold); text-transform: uppercase;
  background: color-mix(in srgb, var(--text) 8%, transparent); color: var(--muted);
}
/* The INLINE form — a tag riding a row of text rather than standing on its
   own line: a step smaller on the chip's own inset (iOS `AppTag.Style.inline`,
   `metricLabel` on `chipInsetH`/`chipInsetV` at `Radius.chip`). */
.tag.inline { font-size: var(--text-3xs); padding: var(--chip-inset-v) var(--chip-inset-h);
  border-radius: var(--r-sm); }
.tag.accent { background: color-mix(in srgb, var(--accent) 14%, transparent);
  color: var(--accent); }
/* Ink on paper — iOS AppTag's `.strong`. A role or an origin is a FACT, not a
   state, so it takes weight rather than a semantic colour (the green the
   admin tag wore said "success" about a job title). */
.tag.strong { background: var(--text); color: var(--on-ink); }
.tag.success { background: color-mix(in srgb, var(--success) 16%, transparent); color: var(--success); }
.tag.error { background: color-mix(in srgb, var(--error) 14%, transparent); color: var(--error); }
.tag.warning { background: color-mix(in srgb, var(--warning) 18%, transparent);
               color: color-mix(in srgb, var(--warning) 72%, var(--text)); }

/* Data table. */
.table { width: 100%; border-collapse: collapse; font-size: var(--text-sm); }
.table th, .table td {
  padding: var(--sp-2) var(--sp-3); text-align: left;
  border-bottom: 1px solid var(--border);
}
.table th { color: var(--muted); font-weight: var(--weight-medium); }
/* A right-aligned, tabular-figure column. `.laps-table` and the Logbook's own
   table each spelled this for themselves — two twins of one rule, which is how
   a third table ends up with left-aligned numbers. It belongs to `.table`. */
.table .num { text-align: right; font-variant-numeric: tabular-nums; }
.table tr:last-child td { border-bottom: none; }
/* Your own row, the same cue the ranked list uses. */
/* `--me-wash` (lime), set once near the band chips. Was accent at 10 %. */

/* Horizontal-scroll wrapper for wide tables on narrow screens: the table
   scrolls INSIDE its card instead of widening the whole page. Wrap any wide
   <table> in <div class="table-scroll">. */
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.table-scroll > table { min-width: max-content; }

/* Code / payload block. */
.code-block {
  /* PINNED to block. Unpinned it inherited from its element — block on <pre>,
     inline on <code> — and an inline box's padding and border do not affect
     line height, so on <code> the border sliced straight through the sentence
     above and read as a strikethrough. inline-block fixed that and broke the
     <pre> call sites instead (shrink-to-fit, with the Copy button floating up
     beside a 200px block). One display, and .code-chip below is the hug. */
  display: block; max-width: 100%;
  white-space: pre-wrap; word-break: break-word; overflow: auto; max-height: 22rem;
  padding: var(--sp-3); border: 1px solid var(--border); border-radius: var(--r-md);
  background: color-mix(in srgb, var(--text) 4%, transparent);
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size: var(--text-sm);
}

/* Styleguide page ------------------------------------------------ */
.sg-section { padding: var(--sp-5) 0; border-top: 1px solid var(--border); }
.sg-h { font-size: var(--text-xs); text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--muted); font-weight: var(--weight-bold); margin: 0 0 var(--sp-3); }
.sg-note { font-size: var(--text-sm); margin: -4px 0 var(--sp-3); }
.sg-stack { display: flex; flex-direction: column; gap: var(--sp-2); }
.sg-stack p { margin: 0; }
.sg-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2);
  margin-bottom: var(--sp-3); }
.sg-row:last-child { margin-bottom: 0; }
.sg-swatches { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: var(--sp-3); }
.sg-swatch { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); }
.sg-chip { width: 28px; height: 28px; border-radius: var(--r-sm); border: 1px solid var(--border); flex: none; }
.sg-icons { display: grid; grid-template-columns: repeat(auto-fill, minmax(96px, 1fr)); gap: var(--sp-2); }
.sg-icon { display: flex; flex-direction: column; align-items: center; gap: var(--sp-15);
  padding: var(--sp-3) var(--sp-2); border: 1px solid var(--border); border-radius: var(--r-md);
  color: var(--text); }
.sg-icon code { font-size: 11px; color: var(--muted); }

/* Layout utilities ---------------------------------------------- */
.between { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); flex-wrap: wrap; }
.inline { display: inline; }
/* Secondary helper text under a control. **A hint is the caption's sibling,
   not a paragraph**: the drawing hangs it off the field at 12 px in the faint
   ink with a 1.5 line (`field(..., hint=…)`), where this side wrote a
   body-size `<p class="muted">` standing on its own — 15 px of 60 % ink under
   a 44 px box, which reads as prose the field happens to sit above rather than
   as a note about it. It carries its own ink now, so a caller does not pair it
   with `.muted` and get 60 % back.

   **`--muted`, where the drawing writes `.mu2`.** This file's own rule for the
   two muted inks decides it: "60 % is text you are meant to READ and 42 % is
   text you are meant to be able to FIND". A caption is found — it names the
   box under it and the reader's eye goes to the value — and it takes
   `--ink-faint` above. A hint is a SENTENCE, and at 12 px in 42 % ink it
   measures 2.65:1 against the light ground, under the 4.5:1 AA asks of body
   text. The drawing's generator reaches for `.mu2` on everything small; the
   size and the place are its call, the ink is the ladder's. */
.hint { font-size: var(--text-xs); color: var(--muted); line-height: 1.5; }
.mt-3 { margin-top: var(--sp-3); }
.mt-1 { margin-top: var(--sp-1); }
.mt-2 { margin-top: var(--sp-2); }
.mt-4 { margin-top: var(--sp-4); }
.mt-6 { margin-top: var(--sp-6); }
.mb-1 { margin-bottom: var(--sp-1); }
/* `.mb-4` went with the editor's Erg / Rest field-row on 2026-09-08 — the two
   selects are chips in the head now, and nothing else asked for it. */
.mb-6 { margin-bottom: var(--sp-6); }
/* Row of action buttons / sibling action <form>s outside a .toolbar.
   (Distinct from .editor-actions, which carries vertical margin.) */
.inline-actions { display: flex; gap: var(--sp-2); align-items: center; flex-wrap: wrap; }
/* Form fields: stack a small-caps caption above the control, anywhere.
   Scoped to .field so it can't clobber other bare `.label` spans (e.g. button
   labels); the field macros always wrap the caption in .field. */
/* 6, not 4: the drawing's `field()` puts `margin-bottom:6px` under the
   caption, and 4 read as the caption BELONGING to the box in a way that made a
   stack of fields one grey texture. */
.field { display: flex; flex-direction: column; gap: var(--sp-15); }
/* Field caption — global so any labelled field/control reuses it (the field
   macros wrap it in .field; settings/editor labels rely on it too). */
/* UPPERCASE, so a caption must never carry a value the reader has to
   reproduce: a field labelled "Type AnnaC2 to confirm" renders "TYPE ANNAC2"
   and typing that back is rejected. Values go in the body text. `anywhere`
   because a 62-character caption otherwise ran off the card and scrolled the
   page sideways at 390px. */
/* **The caption is the 10 px caps step, not a 12 px one of its own.** It was
   12 px, 0.06em and the body weight in `--muted` — the site's THIRD spelling of
   a tracked-caps label, beside the section band's and the column head's, and
   the only one that was neither 800 nor `--ink-faint`. The drawing writes it
   with the same `.k` rule it writes the section label with, at the head's size
   (`a_screens.py`'s `field()`: `class="k" style="font-size:10px"`), so it is
   the head's numbers it takes: `--text-3xs`, `--band-head-tracking` (a ratio
   to the size it is set at, which is the whole reason that token is in ems),
   `--weight-heavy`. **The drawing's own tracking here is
   `.16em`, i.e. 1.6 px, and this is 1.4** — `field()` overrides only the SIZE
   and inherits `.k`'s tracking, which `.k` carries for 11 px; the drawing
   states the 10 px step as `.14em` where it authors one (`.tr.hd`). 0.2 px a
   letter, and the alternative is a third tracking at one size.
   **Its ink is `--muted`, not the drawing's faint** (2026-09-14): 42 % at 10 px
   fails AA in both themes; see `--ink-faint`.
   iOS mirror: `appCapsLabel` (`Typography.metricLabel` + `Control.tagTracking`
   in `Palette.inkFaint`) — same size, same case; its ink and its tracking (1.2
   where this is 1.4) are owed to an iOS pass. */
.label { font-size: var(--text-3xs); font-weight: var(--weight-heavy);
  text-transform: uppercase; letter-spacing: var(--band-head-tracking);
  color: var(--muted); overflow-wrap: anywhere; }

/* Cards ---------------------------------------------------------- */
/* minmax(0, 1fr), not the implicit 1fr: a grid item's automatic minimum is its
   MIN-CONTENT, so under magnification each .card refused to shrink past its
   own content and overflowed the list — and the page with it. Same automatic-
   minimum trap as the `min-width: 0` on inputs above. */
.cards { list-style: none; padding: 0; margin: 0; display: grid;
         grid-template-columns: minmax(0, 1fr); gap: var(--sp-3); }
/* FLAT, with a hairline. iOS's `appCard()` is a fill and a radius and nothing
   else, and `.band` — the anatomy every banded list on this site already
   uses — had already stopped raising its rows. A shadow under a card of rows
   on a page of rows is elevation claiming a hierarchy the page does not have. */
.card {
  background: var(--card); border-radius: var(--r-lg);
  /* A CARD's edge, which is a step stronger than a container's — 12 % against
     9 % in dark. This was 3.1's owed work and its ledger entry was deleted in
     3.3 when the BUTTON started reading the token, which cleared the record
     without paying it. A card is an object; a band is a surface. */
  border: 1px solid var(--card-border);
  padding: var(--sp-4);
  /* Nothing here transforms: a card used to scale to 0.985 while pressed,
     which read as the page flinching on every tap — and on a card whose whole
     face is a link, the zoom fired on the way to a new page, animating
     something the reader was already done with. */
  transition: background var(--t-med);
}
/* Same fix the profile card once carried: a first-child heading's top margin
   stacks on the card padding and adds a phantom inch above the title. */
.card > h2:first-child { margin-top: 0; }
/* Padding contract: when a card's direct child is a .card-link, the card's
   own padding is zeroed and the link brings its own (so the whole card face
   is clickable, hit area included). Anything placed next to a .card-link
   inside the same card therefore renders flush against the card edge. */
.card:has(> .card-link) { padding: 0; }
.card-link { display: block; padding: var(--sp-4) var(--sp-45); color: inherit; }
.card-link:hover { text-decoration: none; }
.card-header {
  display: flex; align-items: baseline; justify-content: space-between;
  margin-bottom: var(--sp-15);
  /* The last of the wrap guards. Title + meta side by side gave this row a
     186px min-content, which under magnification forced every .card wider than
     the page. Same treatment as .between, .entry-target, .block-head,
     .entry-top, .session-actions and .share-box. */
  flex-wrap: wrap; gap: 0 var(--sp-2);
}
.card-title { font-weight: 600; font-size: var(--text-md); text-wrap: balance; }
.card-meta { color: var(--muted); font-size: var(--text-sm); }
.card-stats { display: flex; gap: var(--sp-45); color: var(--muted); font-size: var(--text-sm-plus); }
.card-stats strong { color: var(--text); font-variant-numeric: tabular-nums; }

/* Summary grid (session detail) ---------------------------------- */
.summary-grid {
  display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--sp-3);
  margin-bottom: var(--sp-5);
}
@media (min-width: 640px) { .summary-grid { grid-template-columns: repeat(4, 1fr); } }
.stat {
  background: var(--card); padding: var(--sp-4); border-radius: var(--r-lg);
  text-align: center; box-shadow: var(--shadow);
}
.stat-value { font-size: 24px; font-weight: 600; font-variant-numeric: tabular-nums; }
.stat-label { color: var(--muted); font-size: var(--text-xs); text-transform: uppercase;
              letter-spacing: 0.06em; margin-top: 4px; }

/* Logbook card / forms ------------------------------------------ */

/* Set apart, and it has to LOOK set apart: this holds the only actions
   nothing can undo, and as a plain white card it read as one more section
   between "Admin actions" and the end of the page. The rule matches
   .state-band.error, so "this one is different" uses a language already on
   the page. */
.danger-zone { margin-top: var(--sp-6); border-left: 3px solid var(--danger);
  /* `--danger`, matching the rung it WRAPS: a `--error` wash and rail around a
     `--danger` slab is a salmon touching a crimson, which `AppTheme` describes
     as "close enough that nobody would catch it by eye and far enough apart to
     measure". This piece is what moved them adjacent. */
  background: color-mix(in srgb, var(--danger) 5%, var(--card)); }
.danger-zone h2 { color: color-mix(in srgb, var(--danger) 60%, var(--text)); }
/* A bare confirm form AS the zone (a session's Delete) had no padding, so the
   button touched the red rail; the club's zone is a padded section. Same
   inset now (2026-09-24). */
form.danger-zone { padding: var(--sp-3) var(--sp-4); }

/* The profile card's rules stood here. Settings is bands and section bodies
   now (the 2026-08 rebuild), and nothing has rendered `.profile-card` since —
   including the recessed-control treatment, whose own comment records the bug
   it fixed. Removed 2026-08-25. If a card ground is wanted again, `.card` is
   the component; this was a private twin of it. */

/* Settings ------------------------------------------------------- */
/* Admin per-user token list — plain stacked rows, no bullets. */
.token-list { list-style: none; padding: 0; margin: 0;
  display: flex; flex-direction: column; gap: var(--sp-2); }
.tokens-table { width: 100%; border-collapse: collapse; margin-top: var(--sp-3); }
.tokens-table th, .tokens-table td {
  padding: 8px 10px; border-bottom: 1px solid var(--border);
  text-align: left; font-size: var(--text-sm-plus);
}
.tokens-table th { color: var(--muted); font-weight: 500; }
/* Horizontal input+button row. align-items:flex-end so a stacked labelled
   .field composes (button lines up with the input's bottom, not the caption);
   for a bare single-line input the result is identical to center. */
/* wrap: without it the second button of a two-button row broke out of
   its card and off the viewport at 390px. */
.inline-form { display: flex; flex-wrap: wrap; gap: var(--sp-2);
  margin: var(--sp-3) 0; align-items: flex-end; }
.inline-form input { flex: 1; }
/* Vertical form: stacked labelled fields with the button on its own row below.
   Use for ui.field/ui.select_field; .inline-form is only for bare single-line
   input+button rows (where center-alignment is correct). */
.stack-form { display: flex; flex-direction: column; align-items: flex-start; gap: var(--sp-3); margin: var(--sp-3) 0; }
.stack-form > .field { width: 100%; }   /* direct children fill; nested .field-row fields stay compact */
.stack-form > p { margin: 0; }          /* gap is the only spacing — don't stack p-margin on top of it */
/* A row of labelled fields, each natural/compact width, wrapping on narrow
   screens. The third form primitive: .inline-form = input+button row,
   .stack-form = vertical (button on its own row), .field-row = fields side by side. */
.field-row { display: flex; gap: var(--sp-3); flex-wrap: wrap; align-items: flex-end; }
.field-row .field { width: 180px; }
/* A benchmark field carries its erg in the caption ("5 KM BENCHMARK · ROWERG"),
   which is ~20px past what 180px holds — so the first caption wrapped to two
   lines and its neighbour did not, and the row read as ragged even though
   `align-items: flex-end` kept the inputs themselves level. Widened to fit the
   longest of the four rather than shortening the label: "5 km benchmark" is
   the vocabulary, and the erg is the whole point of there being four. */
.field-row.bench-fields .field { width: 210px; }
/* The benchmark, ONE erg at a time — the phone's sheet, where a picker over
   `ErgType.allCases` swaps the field and re-reads that erg's own number. The
   panels are siblings and the script shows the chosen one; with no script all
   four show, which is the honest degradation. The ramp under each is what
   `FiveKHeroView` draws under the same sentence. (2026-09-08.) */
.bench { width: 100%; }
.bench > .label { display: block; margin-bottom: var(--sp-1); }
.bench-erg { margin-top: var(--sp-3); }
.bench-erg > p { margin: var(--sp-2) 0 0; }
.bench-erg .ramp { margin-top: var(--sp-2); }
/* 180px suits the values .field-row was built for — a date, an org.nr, a
   Stripe id. It is wrong for a control holding arbitrary user-supplied text:
   a club picker cut every name at ~18 characters, so two clubs sharing a
   prefix were indistinguishable in the control whose job is telling them
   apart. Caps at the row width so it still wraps on a phone. */
.field-row .field.wide { width: min(340px, 100%); }
/* A short code as a chip — a join code, a fingerprint — where a full-width
   .code-block would be a grey bar with four characters in it. Same skin, sized
   to its own text. */
.code-chip {
  display: inline-block; max-width: 100%;
  padding: var(--sp-2) var(--sp-3); border: 1px solid var(--border);
  border-radius: var(--r-md);
  background: color-mix(in srgb, var(--text) 4%, transparent);
  font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
  font-size: var(--text-sm); overflow-wrap: anywhere;
}

/* minmax(0, 1fr), not 1fr: a grid track's implicit min-width is min-content,
   and an e-mail or a uuid has no break opportunity Chrome will take — so the
   value column got a 440px floor and pushed the whole page sideways at 390.
   The wrap rule then has room to do its job. */
.kv { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--sp-15) var(--sp-4); }
.kv dd { overflow-wrap: anywhere; min-width: 0; }
/* Two columns need room for both. Under magnification (browser zoom shrinks
   the CSS viewport) label + value could not both fit and the page scrolled
   sideways, so stack them instead. `minmax(0, auto)` is NOT the fix — its max
   is still max-content, which made the track grow rather than wrap. */
@media (max-width: 340px) {
  .kv { grid-template-columns: 1fr; gap: 2px var(--sp-4); }
  .kv dd { margin-bottom: var(--sp-2); }
}
.kv dt { color: var(--muted); }

/* The admin console's section nav. A row of links, the current one marked —
   not buttons, because these navigate rather than act, and a row of five
   primary-looking buttons reads as five things to press. Scrolls rather than
   wraps on a phone: five short labels in one line is the point. */
.console-nav {
  display: flex; gap: var(--sp-1); margin: 0 0 var(--sp-4);
  overflow-x: auto; -webkit-overflow-scrolling: touch;
  border-bottom: 1px solid var(--border);
}
.console-nav a {
  padding: var(--sp-2) var(--sp-3); white-space: nowrap;
  color: var(--muted); text-decoration: none; font-size: var(--text-sm);
  border-bottom: 2px solid transparent; margin-bottom: -1px;
}
.console-nav a:hover { color: var(--text); }
.console-nav a.here {
  color: var(--accent); border-bottom-color: var(--accent); font-weight: 600;
}

/* h3 had no rule, so it fell to the browser default 1.17em — 17.5px
   against h2's 18px — and a sub-heading inside a card read as a peer
   of the card's own heading. */
h3 { font-size: var(--text-base); margin: var(--sp-4) 0 var(--sp-2); }

/* The empty state — iOS `EmptyStateView`. A 96 px disc of ink at 15 % with a
   40 px glyph in it, `Spacing.md` down the stack, `.headline` title over a
   `.subheadline` message. It was a bare grey glyph on half the air with a
   title a step small, which read as a caption rather than as the screen
   saying what it is for. */
.empty-state { padding: 60px var(--sp-4); text-align: center; color: var(--muted);
  display: flex; flex-direction: column; align-items: center; gap: var(--sp-4); }
.empty-state p { margin: 0; }
.empty-state .empty-title { font-size: 17px; font-weight: var(--weight-semibold);
  color: var(--text); margin: 0; }
/* The disc — also `ClubsView`, `ClubDetailView` and the Planless row on the
   phone, so it is a component and not the empty state's own drawing. */
.icon-disc {
  display: inline-flex; align-items: center; justify-content: center;
  width: 96px; height: 96px; flex: none; border-radius: 50%;
  background: color-mix(in srgb, var(--text) 15%, transparent);
  color: var(--text);
}
.icon-disc.sm { width: 44px; height: 44px; }
/* The compact form — a glyph and two lines, for a state INSIDE a section
   rather than on a screen of its own (iOS's `compact`). */
.empty-state.compact { padding: var(--sp-5) var(--sp-4); gap: var(--sp-2); }
.empty-state.compact .empty-icon { color: color-mix(in srgb, var(--muted) 60%, transparent); }
.empty-state.compact .empty-title { font-size: var(--text-base); }
.empty-state.compact p { font-size: var(--text-xs); }

/* Laps table ----------------------------------------------------- */

/* A session with several performed workouts (+ free/cooldown stretches) reads
   as ONE card: each segment is a lightweight labelled group (title + one inline
   metric) separated by a hairline, not a separate shadowed block. */
.lap-group + .lap-group { margin-top: var(--sp-4); padding-top: var(--sp-4);
              border-top: 1px solid var(--border); }
.lap-group-head { display: flex; align-items: baseline; justify-content: space-between;
              gap: var(--sp-3); flex-wrap: wrap; margin: 0 0 var(--sp-2); }
.lap-group-head h3 { margin: 0; font-size: var(--text-sm-plus); font-weight: var(--weight-semibold);
              display: flex; align-items: baseline; gap: var(--sp-15); }
/* The glyph that says which KIND of stretch this is — a stopwatch for a
   programmed workout, an infinity for a free one, as on the phone; and ink for
   the workout against muted for the free stretch, which is the same ranking
   the phone gives them. */
.lap-group-head h3 svg { color: var(--text); flex: none; align-self: center; }
.lap-group-head.free h3 svg { color: var(--muted); }
.lap-group-head .done { color: var(--muted); font-weight: 500; }
.lap-group-metric { color: var(--muted); font-size: var(--text-sm-plus);
              font-variant-numeric: tabular-nums; }
.lap-group-metric strong { color: var(--text); font-weight: var(--weight-semibold); }
.laps-table { width: 100%; border-collapse: collapse; font-size: var(--text-sm-plus); }
.laps-table th, .laps-table td { padding: 8px 10px; text-align: left; }
/* A hairline under the HEADER and zebra below it — the phone's table
   (`SplitsTable.table`: one `HairlineDivider`, then a 4 % stripe on every
   other row). A border under every row was a rule between numbers that are
   meant to be read down a column. */
.laps-table th { color: var(--muted); font-weight: 500;
  border-bottom: 1px solid var(--border); }
.laps-table tbody tr:nth-child(even) td {
  background: color-mix(in srgb, var(--text) 4%, transparent); }
.laps-table .num { text-align: right; font-variant-numeric: tabular-nums; }
/* The index cell: the rep number, or `WU` / `CD` / `rest`, quiet and leading.
   The role used to be a coloured pill in its own column — a work pill in the
   orange `--work` wash beside a watts slab on the ramp, two colour systems in
   one row. */
.laps-table .lap-n { color: var(--muted); }
/* `WU` / `CD` in the REST tint, as the phone names them
   (`indexCell` → `Palette.rest`): they are not reps, and the colour is what
   says so before you have read the letters. */
.laps-table .lap-n.role { color: var(--rest); }
/* A work row that IS a piece opens its shape, and the whole ROW is the door on
   the phone. The stretched `::after` is what makes the row tappable while the
   link itself stays a link. */
.laps-table tr.lap-work { position: relative; }
.laps-table .lap-open { color: inherit; text-decoration: none; }
.laps-table .lap-open::after { content: ""; position: absolute; inset: 0; }
.laps-table tr.lap-work:hover .lap-open { color: var(--accent); }
/* A rest asks for nothing and holds nothing; greying it is what lets work and
   rest be told apart down a column at a glance. `--plan-row-rest-opacity` is
   the phone's `Control.restRowOpacity`, already pinned by the token gate — the
   blue wash this replaces was a fourth sentence beside the two greys and the
   hatch. */
.laps-table tr.lap-rest { opacity: var(--plan-row-rest-opacity); }
/* The heart cue: the same `Palette.heart` the phone tints this column with,
   and the red the card already spells out as `--card-hr`. The class is on the
   cell only when there IS a reading — a dash is an ABSENCE and must not wear
   the colour of the thing that is missing, which is the rule the card's
   `.sc-absent` states from the other side. */
.laps-table td.hr { color: var(--heart); }

/* A band's body since 2026-08-21. Two SECONDARY buttons, stacked and
   full-width — §4 on the phone: "two secondaries, not a prominent and a
   bordered", because this session is already rowed and neither of these is
   what you came to the page for. */
/* **Side by side**, as the drawing draws them (`display:flex;gap:10px` over
   two `.btn.sec`). They were two full-width 52 px slabs stacked, which in a
   400 px aside is 112 px of button for two things you do to a session once. */
.session-actions { display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--sp-25); }
.session-actions .btn { flex: 1 1 auto; }

/* Logbook import -------------------------------------------------- */
/* This page's own table stood here — a shadowed rounded CARD with literal
   10/12/4 px paddings, which is `.table` written a second time, off the scale.
   The phone draws these rows as plain caption lines inside a band, so the two
   tables that remain are `.table` and the group rows below are the band. */

/* A group's face is a LINK — one tap opens the import, as `ImportFromConcept2View`'s
   row does. The face line rides under the name, the way the phone stacks them. */
.lb-face { display: block; margin-top: var(--sp-1r);
  font-size: var(--text-xs); font-weight: normal; color: var(--muted); }
/* The kept sessions, listed under the face: they are what importing this adds.
   `--sp-4` of leading inset is the phone's `Spacing.sm` against a row that is
   already inset by the band's own gutter. */
/* **Three columns, not a flex line.** The completion was pushed right by an
   `auto` margin, so the three facts landed wherever each row's own figure
   ended and nothing lined up down a group — which is the whole reason to group
   them. The drawing gives this table six columns; a Logbook result carries no
   pace and no watts, so it has the three that exist. */
.lb-runs { --tr-cols: 130px minmax(0, 1fr) 90px;
  padding: 0 var(--sp-2) var(--sp-3); }
.lb-run { min-height: 36px; font-size: var(--text-xs); }
.lb-runs > .tr.hd { min-height: 28px; }
.lb-run .lb-day { font-weight: var(--weight-semibold); }
.lb-run .lb-fig { color: var(--muted); min-width: 0; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; }
.lb-run .lb-pct { font-weight: var(--weight-semibold); color: var(--muted); }
/* Set aside, not hidden: dimmed is the whole distinction between "this is
   coming with the workout" and "this was an abandoned start". */
.lb-run.aside, .lb-run.aside .lb-day, .lb-run.aside .lb-fig, .lb-run.aside .lb-pct {
  color: var(--muted); font-weight: normal; }
.lb-junk { padding-left: var(--sp-3); }
.lb-junk > summary { cursor: pointer; font-size: var(--text-xs); color: var(--muted);
  min-height: var(--control-h-tap); display: flex; align-items: center;
  gap: var(--sp-1); list-style: none; }
.lb-junk > summary::-webkit-details-marker { display: none; }
/* The chevron IS the affordance — a fold whose head looks like a caption is a
   fold nobody opens. It turns down when the fold is open, as the workouts
   list's does. */
.lb-cv { color: var(--muted); display: inline-flex; align-self: center;
  transition: transform 0.15s; }
details[open] > summary .lb-cv { transform: rotate(90deg); }

/* The axis switch is a pair of LINKS — each half is a different page, not a
   client-side toggle — so the box's cells have to read as `.seg`'s buttons do.
   One hairline box with the chosen half in ink; iOS draws one `SegmentPair`,
   and two button rungs side by side was this side's second drawing of it. */
/* The LINK spelling of the same switch — a segment whose cells are hrefs
   rather than buttons. One control, so one set of metrics; only the element
   differs. */
.seg a { display: inline-flex; align-items: center; justify-content: center;
  min-height: var(--seg-pill-h); padding: 0 var(--sp-4);
  border-radius: var(--r-pill);
  font-size: var(--text-sm); font-weight: var(--weight-bold);
  color: var(--muted); text-decoration: none; }
.seg a.on { background: var(--text); color: var(--on-ink); }

/* One choice of an import's destination, and one merge offer — iOS's
   `choiceRow` in `LogbookImportSections`. A radio for the destination
   (mutually exclusive, and on this side each option is its own page) and a box
   for a merge (additive): the shape says which kind of choice it is before the
   label does. */
.lb-choice { display: flex; align-items: baseline; gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-4); min-height: var(--control-h-tap);
  color: var(--text); text-decoration: none; cursor: pointer;
  border-bottom: 1px solid var(--border); }
.lb-choice.off { cursor: default; color: var(--muted); }
.lb-choice .lb-mark { color: var(--muted); display: inline-flex;
  align-self: flex-start; }
.lb-choice.on .lb-mark { color: var(--accent); }
.lb-choice .lb-choice-body { flex: 1; min-width: 0; }
.lb-choice .lb-choice-title { display: block; font-weight: var(--weight-semibold);
  font-variant-numeric: tabular-nums; }
.lb-choice .lb-choice-detail { display: block; margin-top: var(--sp-05);
  font-size: var(--text-xs); color: var(--muted); }
/* A past result's figures, pushed to the far edge so a column of them reads
   down rather than across. */
.lb-choice .lb-choice-figs { margin-left: auto; color: var(--muted);
  font-size: var(--text-sm-plus); font-variant-numeric: tabular-nums; }
/* Said on the ROW that is about to be overwritten, not only on the button:
   this is the one action on the screen that destroys something. */
.lb-replace { color: var(--accent); font-size: var(--text-2xs);
  font-weight: var(--weight-semibold); }
/* The three groups of `PastResultsList`, each with its own head — they are
   three different things, and one list with a flag on some rows said so far
   less clearly. */
.lb-group-head { margin: var(--sp-3) 0 var(--sp-1); padding: 0 var(--sp-4);
  font-size: var(--text-2xs); font-weight: var(--weight-semibold);
  letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
.lb-group-note, .lb-past-note { margin: var(--sp-2) 0 0; padding: 0 var(--sp-4);
  font-size: var(--text-xs); }

/* "Sessions coming with this" — one row per run, opening to what the erg
   recorded per interval. A wrong proposal is invisible in a total and obvious
   in a list, which is the whole reason this section exists. */
.lb-session { border-bottom: 1px solid var(--border); }
.lb-session > summary { display: flex; align-items: baseline; gap: var(--sp-2);
  padding: var(--sp-2) var(--sp-4); min-height: var(--control-h-tap);
  cursor: pointer; font-variant-numeric: tabular-nums; list-style: none; }
.lb-session > summary::-webkit-details-marker { display: none; }
.lb-session-body { flex: 1 1 auto; min-width: 0; }
.lb-session > summary .lb-when { display: block; font-size: var(--text-sm-plus);
  font-weight: var(--weight-semibold); }
.lb-session > summary .lb-sum { display: block; margin-top: var(--sp-05);
  font-size: var(--text-xs); color: var(--muted); }
/* `flex: none` and no wrap: the completion is a two-character fact, and a
   percentage that breaks after its digits reads as two. */
.lb-session > summary .tag { flex: none; margin-left: auto; white-space: nowrap; }
.lb-steps { list-style: none; margin: 0; padding: 0 var(--sp-4) var(--sp-2); }
.lb-steps li { display: flex; align-items: baseline; gap: var(--sp-2);
  padding-left: var(--sp-4);
  font-size: var(--text-xs); font-variant-numeric: tabular-nums; }
/* 18 and 58 are the phone's own `.frame(width:)` on these two cells, copied
   rather than derived: they are column widths that keep a list of reps in
   line, not spacing, so there is no token on the scale that means them. */
.lb-steps .lb-i { width: 18px; text-align: right; color: var(--muted);
  font-size: var(--text-2xs); }
.lb-steps .lb-pace { margin-left: auto; font-weight: var(--weight-semibold); }
/* The rep far off its siblings, inked: it is what drags the median a target
   was proposed from, and the reader is being asked to approve that number. */
.lb-steps .lb-pace.outlier { color: var(--error); }
.lb-steps .lb-rest { width: 58px; text-align: right; color: var(--muted);
  font-size: var(--text-2xs); }

.pager { display: flex; justify-content: space-between; align-items: center;
         margin-top: var(--sp-4); gap: var(--sp-3); }

/* Template editor ------------------------------------------------- */
/* The name, on the type SCALE and in the field's own weight — iOS's
   `.title3` on a plain `TextField`. It was a literal 24 px / 700, which is
   heavier and larger than the workout page's own H1 and reads as a heading
   somebody has already written rather than the one field you must fill. */
.editor .title-input {
  font-size: var(--text-xl); font-weight: var(--weight-medium);
  border: none; background: transparent;
  /* §8's ⑤: a hairline UNDER it at rest, not only on focus. Without one,
     "New workout" is indistinguishable from a heading — the one field you must
     fill on a new workout looked like a title somebody had already written.
     Two pixels of it at rest would shift the layout on focus, so the resting
     rule is 1 px of --border and focus thickens it in ink. */
  border-bottom: 2px solid transparent;
  box-shadow: inset 0 -1px 0 var(--border);
  width: 100%; padding: 4px 0; color: var(--text);
}
.editor .title-input:focus { outline: none; border-bottom-color: var(--text);
  box-shadow: none; }
/* The DESCRIPTION, under the name in the name's own idiom (2026-09-29): no
   box, a hairline under it at rest and ink on focus, at body size — iOS's
   `BuilderView` field. It overrides the generic textarea's boxed field. */
.editor .desc-input {
  display: block; width: 100%; min-height: 0; resize: vertical;
  /* One line when empty, growing with the text (Martin, 2026-09-29: the
     line under it tight to the placeholder). */
  field-sizing: content;
  margin: 0 0 var(--sp-3); padding: var(--sp-1) 0;
  font-size: var(--text-base); line-height: 1.45; color: var(--text);
  border: none; border-radius: 0; background: transparent;
  border-bottom: 2px solid transparent;
  box-shadow: inset 0 -1px 0 var(--border);
}
.editor .desc-input:focus { outline: none; border-bottom-color: var(--text);
  box-shadow: none; }

/* Editor — REDRAWN 2026-08-20 to the approved designs, matching the iOS
   builder landed the same day: a repeating group is marked by its header BAR
   (badge + count ± + total) and a SPINE from the badge's centre to a CAP on
   the last step; steps are one-line rows — band chip, label, authored
   target — that FOLD OPEN in place to edit. One step open at a time; the
   old always-open form rows are gone, and with them the work/rest rail:
   the chips carry the app's one colour language now (the band rule is
   ported into edit.html's JS and PINNED by test_editor_band_parity.py). */
.build-summary {
  color: var(--muted); font-size: var(--text-sm); font-variant-numeric: tabular-nums;
  margin: -4px 0 12px;
}
.build-summary .warn { color: var(--error); font-weight: 600; }

/* The editor's HEAD — the phone's, at last: the workout's own bar with its
   time scale, then the four facts as one wrapping chip row. Two of them are
   grey text with no pill and no caret because they are facts you cannot
   change (iOS `factChip`); the other two are `.vchip` selects because they
   are settings you can. It replaced a `.field-row` of two labelled form
   fields ABOVE the band and the same two values repeated in the line below
   it. `flex-wrap`, not a scroller: a clipped chip reads as a bug, not as
   "scroll me" — the same call `FlowLayout` makes over there. */
.editor-head { margin: var(--sp-2) 0 var(--sp-4); }
.editor-head .ibar { margin-bottom: 0; }
.build-facts {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--sp-2); margin-top: var(--sp-2);
}
.build-facts .fact {
  color: var(--muted); font-size: var(--text-sm);
  font-variant-numeric: tabular-nums;
  min-height: var(--control-h-tap); display: inline-flex; align-items: center;
}
.build-facts .fact:empty { display: none; }
.editor-head .rest-note { display: block; color: var(--muted); margin-top: var(--sp-15); }
.build-warn { color: var(--error); font-weight: 600; font-size: var(--text-sm);
  margin: var(--sp-15) 0 0; }
.build-warn:empty { display: none; }

/* A block is a SLAB: paper ground, square, hairline-bounded on all four
   sides ("Slabs, Squared" variant A, Martin's pick 2026-08-21) — where one
   group ends and the next begins is a change of ground, not an inferred
   gap. */
.builder-block {
  position: relative; margin-bottom: var(--sp-3);
  background: var(--card); border: 1px solid var(--border);
}
.builder-block + .builder-block { margin-top: var(--sp-4); }

/* The group's header bar — full width of the editor column, hairlines top
   and bottom, the wash a step darker than a section band. Only a block that
   repeats gets the bar (bar ⟺ badge ⟺ spine ⟺ it repeats); a plain block
   gets the quiet head below. */
/* Header and footer breathe by the same measure the sides use: 16 on ALL
   four sides ("the vertical IS the horizontal", Martin 2026-08-21). Step
   rows stay 44px tight, so a block reads breathe · list · breathe. */
/* "A verb until it repeats" (Martin's pick, 2026-08-21): a plain block's
   header carries no stepper — a value control only where a value exists.
   This button births the group (repeatCount 1 -> 2); dropping a 2x back to
   1 dissolves badge, spine and pair, and the verb returns. */
/* The insertion rung's INLINE form, not a dashed box of its own: same ink at
   18 %, same pill, same tap floor (iOS draws the Repeat verb with the very
   modifier the add-a-step rows use). Its dashes were `--border` grey while the
   full-width rung's were ink — two dash colours for one idea, on one page. */
/* Everything the badge IS comes from the shared count slab up in the plan
   section — this is only where it sits. The slab carries `order: 1` for the
   PLAN, where it is the last thing in a leading gutter; in this bar it is the
   first thing on the line, and inherited order put it on a wrapped second row
   under the header with the spine hanging off it. */
.grp-badge { position: relative; z-index: 2; }   /* the spine passes beneath it */
/* The block's own word — `Repeat` / `Work` / `Warm-up` / `Cool-down`, on the
   band's type, exactly as `repeatHeader` and `segmentHeader` set it. It was a
   `<select>`, which made a block's role editable at any time: a workout could
   hold three warm-ups and a cool-down could be dragged above the work. The
   role is fixed at creation now, so the bar states it rather than offering it
   — and the 117 px the select spent on a caret is what was pushing the ⋯ onto
   a second line at 375. */
.entry-add .btn.insertion { flex: 1; }
.spacer { flex: 1; }

/* The spine: from the badge's centre to a cap centred on the last step.
   Positioned by edit.html's sizeSpines() — only the laid-out DOM knows where
   the last row's centre is once a step can fold open. */
.grp-spine {
  position: absolute; width: var(--builder-spine-w); margin-left: calc(-0.5 * var(--builder-spine-w));
  background: var(--text); z-index: 1; pointer-events: none;
}
.grp-cap {
  position: absolute; width: var(--builder-spine-dot); height: var(--builder-spine-dot);
  margin-left: calc(-0.5 * var(--builder-spine-dot));
  margin-top: calc(-0.5 * var(--builder-spine-dot));
  border-radius: 50%; background: var(--text); z-index: 1; pointer-events: none;
}

/* ---- step rows ---- */
.entries { display: flex; flex-direction: column; }
.step-row {
  display: flex; align-items: center; gap: var(--sp-2);
  /* sp-4 leading when there is no spine to clear — iOS's `Spacing.md`, the
     same page gutter its stepIndent falls back to. */
  /* 44, not the app's own 36 floor: a builder row is a whole step — a chip, a
     label, a target and a chevron — and it is the list you drag a workout into
     shape in. A deliberate web decision, kept because iOS's builder row is
     36 pt of CONTENT inside the same air. */
  min-height: 44px; padding: 0 var(--sp-4);
  border-top: 1px solid var(--border);
  cursor: pointer;
  font-variant-numeric: tabular-nums;
}
.entries .step-row:first-child { border-top: none; }
/* Steps clear the spine: the plan's own gutter, the measure iOS reuses. */
.builder-block.repeating .step-row,
.builder-block.repeating .step-open .step-row { padding-left: var(--plan-gutter); }
/* The builder's rows are 44 px and carry a chip, a target and a chevron, so
   the mark is centred and taller than the plan's — the width is the same
   token either way. */
.step-row .plan-chip { width: var(--chip-w); height: 22px; min-height: 0;
  align-self: center; top: 0; }
.step-row .step-lbl { font-weight: 500; }
.step-row .step-tgt { font-size: var(--text-sm); color: var(--muted); white-space: nowrap; }
/* `--ink-faint`, with `.tr-cv` — see its note. A chevron in a border ink is
   a chevron nobody can see. */
.step-row .step-cv { color: var(--ink-faint); font-size: var(--text-sm); transition: transform .18s; }
.step-row .step-cv.open { transform: rotate(90deg); }
.step-row:hover .step-cv { color: var(--muted); }

/* ---- the open step ---- */
/* The hero IS the row — same hairline, same weight, same ground as it had
   collapsed (Martin, 2026-08-21: "do not change style of row headers on
   expand"). Only the BODY below it gets the wash. */
.step-open .step-row { cursor: pointer; }
.entries > .step-open:first-child > .step-row { border-top: none; }
.step-open + .step-row { border-top: none; }
.step-open-body {
  background: var(--builder-open-wash);
  border-top: 1px solid var(--border); border-bottom: 1px solid var(--border);
  padding: 2px var(--sp-4) var(--sp-3);
}
.builder-block.repeating .step-open-body { padding-left: var(--plan-gutter); }
.open-ctl { display: flex; align-items: center; gap: var(--sp-2); margin-top: var(--sp-15);
  flex-wrap: wrap; }
/* **The open step has 285 px, not 343.** It sits inside the plan gutter that
   clears the group's spine, so a control row here pays 58 px the same row pays
   nowhere else — and the ± pair was landing on a line of its own at 375 with
   no text scaling involved, where the phone keeps the four controls on one
   line. The same components, an inset tighter, in this one context. */
.step-open-body .open-ctl { gap: var(--sp-15); }
.step-open-body .vchip { padding: 0 var(--sp-2); }
/* Re-tightened for 3.4's capsule: the cell's base padding went 12 → 16 and the
   box gained 8 px of inset, which widened `.seg2` by 10 px into a row that had
   3.8 px of slack — so the ± pair wrapped to a line of its own at 375 px, the
   exact failure this block exists to prevent and which its comment above
   names. `--sp-15` (6) here, and the box's own inset halved, buys back more
   than the 10. */
.step-open-body .seg2 button { padding: 0 var(--sp-15); }
.step-open-body .seg2 { padding: 2px; }
.open-hair { height: 1px; background: var(--border); margin: var(--sp-3) 0 var(--sp-2); }
.open-remove { margin-top: var(--sp-25); }
.linklike {
  border: none; background: none; padding: 4px 0; cursor: pointer;
  font-size: var(--text-sm); color: var(--text);
}
.linklike.danger { color: var(--error); }

/* The segment box: StepperPair's geometry with the badge's ink for its
   active cell — not a stock grey capsule. `.seg2` was the two-cell Work ⇄ Rest
   switch; `.seg` is the same box with any number of cells, and the head-to-
   head's Pace | Power, average, and None · ♥ · spm are all it. It said "the ink
   cell fills edge to edge and is clipped by the frame — only the outer corners
   are round", which was the joined box 3.4 replaced: the cell is its own pill
   inside the box now, rounded on all four sides. (iOS twin: `SegmentPair`.) */
/* THE SEGMENTED SWITCH -------------------------------------------------- 3.4
   **An INSET CAPSULE, not a joined box.** It was one frame with hairlines
   between the cells and square-ish corners; the drawing gives it 4 px of
   padding all round, a full pill for the box, and the chosen cell as a second
   pill floating inside it with air on all four sides.

   That inset is what answers Martin's 2026-08-28 note — "refine it so it's not
   rounded corners in the middle": the chosen cell is rounded on ALL four
   sides, so there is no join left to round wrongly. Martin took the drawing on
   2026-09-11. iOS mirrors: `Control.segInset` / `Control.segPillHeight`. */
.seg, .seg2 {
  /* No gap: `gen.py`'s `.seg` has none, and `--sp-1r`'s own declaration says it
     is "the gap between two lines of ONE label, never between two elements".
     It was also half of the 10 px that wrapped the builder's open step. */
  display: inline-flex;
  padding: var(--seg-inset);
  border: 1px solid var(--card-border);
  border-radius: var(--r-pill);
  background: color-mix(in srgb, var(--text) 5%, transparent);
}
/* **A cell may be a LINK.** History | Benchmarks is navigation between two
   readings of the same sessions, and a segment that needs script to move is a
   control pretending to be one. `inline-flex` because an `<a>` is inline and
   would not take the pill's height. (4.1.) */
.seg button, .seg2 button, .seg a {
  border: none; background: none; cursor: pointer;
  border-radius: var(--r-pill);
  font-size: var(--text-sm); font-weight: var(--weight-bold);
  color: var(--muted);
  min-height: var(--seg-pill-h); padding: 0 var(--sp-4);
}
.seg a { display: inline-flex; align-items: center; text-decoration: none; }
.seg button.on, .seg2 button.on, .seg a.on {
  background: var(--text); color: var(--on-ink); }

/* Chips — "one of these", as separate capsules. (iOS twin: `ChipPicker`.)
   The sibling of `.seg` and not a variant of it: a segment box is one frame
   that cannot express "both", chips can. Ink, not accent — the chosen chip is
   a FILLED capsule, paper on ink (it was "ink on a 14 % ink wash" until 3.4:
   a tint says "slightly more" where a fill says "this one"), the rest grey on
   a surface with a hairline, because hue on
   these pages means how hard a piece is. `--control-h-tap` is the app's floor
   for something you hit, and the horizontal inset matches it: a chip you can
   read but not comfortably tap is what that token exists to prevent. */
.chips { display: flex; flex-wrap: wrap; gap: var(--sp-2); margin: 0 0 var(--sp-3); }
/* **A chip has an EDGE.** It was a bare wash — 10 % of the muted ink, no
   border — which on the material's ground reads as a smudge rather than as
   something you can press. The drawing gives it the surface it sits on plus a
   hairline (`gen.py`'s `.chip`: `border:1px solid rgba(255,255,255,.12)`,
   `background:SURF`), which is the same sentence as the secondary rung's: a
   control is an object on its ground.

   **The chosen chip is ink on paper**, not a heavier wash of the ink. A 14 %
   tint says "slightly more"; a filled capsule says "this one". `.chip.on` in
   the drawing is `background:INK;color:BG;border-color:INK`, the same mark the
   chosen segment cell carries — they are one idea in two containers. */
.chips button {
  border: 1px solid var(--card-border);
  border-radius: var(--r-pill); cursor: pointer; font-family: inherit;
  font-size: var(--text-sm); font-weight: var(--weight-bold);
  min-height: var(--control-h-tap); padding: 0 var(--sp-35);
  color: var(--muted); background: var(--surface);
}
/* In LIGHT it is white on off-white, so it takes the same lift the secondary
   rung takes — the sentence this chip shares with that rung is the whole
   argument for its fill and its hairline, and the shadow is the third part of
   it. (The drawing's light chip is a GREY, but only because `light.py`'s KEEP
   list matched `.pill` verbatim and not `.chip`, so the chip fell through to
   the generic ink-swap regex. The authored light rule wins over a string
   match; `AWorkoutsLight.dc.html` wants regenerating.) */
:root:not([data-theme="dark"]) .chips button,
:root:not([data-theme="dark"]) .chips label.chip span { box-shadow: var(--shadow); }
:root:not([data-theme="dark"]) .chips button.on,
:root:not([data-theme="dark"]) .chips label.chip input:checked + span { box-shadow: none; }
.chips button.on {
  color: var(--on-ink); background: var(--text); border-color: var(--text);
}
/* The same capsules as a FORM control, driven by radios so the choice submits
   with the page's one Save. `ChipPicker` is what the phone draws for Units and
   for Appearance, and this side was drawing a native `<select>` for both —
   which is the stacked segmented control `ChipPicker`'s own header says it
   exists to replace. One skin, two mechanisms: the button form filters a list,
   the radio form answers a question. (2026-09-08.) */
.chips label.chip { position: relative; display: inline-flex; align-items: center; }
.chips label.chip input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
/* **The RADIO spelling of the same chip.** 3.4 unified `.seg a` with
   `.seg button` and left this one behind for a round: Settings drew three
   groups (the benchmark's erg, Units, Appearance) where the chosen chip did
   NOT invert — it kept the 14 % tint that the rule eight lines up calls
   "slightly more" rather than "this one". One class name, two opposite answers
   to which one is chosen. Same defect as the two `.seg` spellings, in the
   component that had just fixed them. */
.chips label.chip span {
  display: inline-flex; align-items: center;
  border: 1px solid var(--card-border);
  border-radius: var(--r-pill); cursor: pointer;
  font-size: var(--text-sm); font-weight: var(--weight-bold);
  min-height: var(--control-h-tap); padding: 0 var(--sp-35);
  color: var(--muted); background: var(--surface);
}
.chips label.chip input:checked + span {
  color: var(--on-ink); background: var(--text); border-color: var(--text);
}
.chips label.chip input:focus-visible + span { box-shadow: var(--focus-ring); }

/* The fold-downs are native selects styled as the value rung — hairline box
   + the browser's own caret. "You can change this", in the app's words. */
/* The compact value rung — `AppButtonStyle`'s `compact`: the card's own fill,
   a hairline, `Radius.md` and `Spacing.sm`. The border is the compact form's
   whole difference: it is what makes the box agree with the ± pair standing
   next to it, which is why it rounds like the pair and not like the pill. */
.vchip {
  min-height: var(--control-h-sm); padding: 0 var(--sp-3);
  border: 1px solid var(--card-border); border-radius: var(--r-md);
  background: var(--card); color: var(--text);
  font-size: var(--text-sm); font-weight: var(--weight-semibold);
}

/* The ± pair: two joined cells in one hairline box — the same object in the
   group's bar (reps), the open step (duration) and the target row. */
/* `--card-border` on the frame AND on the divider, with the field and the
   vchip: the drawing's ± is `border:1px solid rgba(255,255,255,.12)` outside
   and `border-right` of the same on the first cell, and the vchip's own note
   above says its border exists "to make the box agree with the ± pair standing
   next to it" — which it could not do while the two were 9 % and 9 % of a
   different ink from the chips beside them. */
/* **72 × 30 OVERALL, and the edge is drawn INSIDE it.** iOS strokes this
   frame with `.strokeBorder`, which paints within the bounds, so `StepperPair`
   is exactly `tap × 2` by `ControlHeight.sm`. A CSS `border` sits outside the
   box its children size, so the pair came out 74 × 32 and stood 1 px proud of
   the `.vchip` and the `.tgt-value` either side of it in the open step's
   target row (measured: tops 572 / 572 / 571, heights 30 / 30 / 32). An inset
   `box-shadow` is the same ring `.strokeBorder` draws — it paints over the
   parent's background and under the children, so each half keeps its full
   36 px of tap and the ring lies on top of the outer two. */
.pair { display: inline-flex; height: var(--control-h-sm);
  box-shadow: inset 0 0 0 1px var(--card-border);
  border-radius: var(--r-md); overflow: hidden; background: var(--card); }
.pair button {
  border: none; background: none; cursor: pointer;
  /* The tap floor, not a button height — iOS `StepperPair` gives each half
     `tap × ControlHeight.sm`, so the pair is 72 wide and 30 tall. */
  width: var(--control-h-tap); height: 100%;
  font-size: var(--text-sm); font-weight: var(--weight-bold);
  line-height: 1; color: var(--text); padding: 0;
  font-variant-numeric: tabular-nums;
}
.pair button + button { border-left: 1px solid var(--card-border); }
.pair button:hover { background: color-mix(in srgb, var(--text) 5%, var(--card)); }
/* A half AT ITS BOUND fades and stops answering — the ladder's one meaning for
   "off" (iOS `Control.disabledOpacity`). There was no rule at all here:
   `.btn:disabled` does not reach a bare <button>, so a pair at its limit
   looked exactly like a pair with room left. */
.pair button:disabled { opacity: var(--btn-disabled-opacity); cursor: default; }
.pair button:disabled:hover { background: none; }

/* Typed exact values — borderless inside the hero row, boxed in the target
   row where the value sits between a select and the pair. */
.step-open .dur-value {
  border: none; background: transparent; color: var(--text);
  /* Pull back the row's flex gap so "Work · 4:00" sits exactly as tight as
     the collapsed row prints it — the input must not read as a layout. */
  margin-left: calc(-1 * var(--sp-2));
  font-size: inherit; font-weight: 500; width: 84px;
  padding: 0; min-height: var(--control-h-sm);
  font-variant-numeric: tabular-nums;
}
.step-open .dur-value:focus { outline: none; box-shadow: inset 0 -1px 0 var(--text); }
/* `--card-border` with the vchip and the pair it stands between: the target
   row is a select, a typed value and a ± side by side, and at `--border`'s 9 %
   the one in the MIDDLE was the faintest of the three. */
.step-open .tgt-value {
  min-height: var(--control-h-sm); padding: 0 var(--sp-2); width: 56px;
  /* `--r-md` with them too: the vchip's own note says its border is there "to
     make the box agree with the ± pair standing next to it, which is why it
     rounds like the pair" — and this box stands BETWEEN those two, at the same
     height and the same ink, rounding at 8 where they round at 10. */
  border: 1px solid var(--card-border); border-radius: var(--r-md);
  background: var(--card); color: var(--text); font-size: var(--text-sm);
  font-variant-numeric: tabular-nums;
}

/* The coarse grain: a full-width exponential slider, ink on a hairline
   track, landmark labels at their log positions beneath it. More air above
   it than the row gap — Martin's call on the mockups. */
.open-slider { margin-top: var(--sp-4); padding-bottom: 18px; position: relative; }
.dur-slider {
  -webkit-appearance: none; appearance: none;
  width: 100%; height: 4px; border-radius: 2px;
  background: var(--border); outline: none; display: block;
}
.dur-slider::-webkit-slider-thumb {
  -webkit-appearance: none; appearance: none;
  width: var(--slider-thumb); height: var(--slider-thumb); border-radius: 50%;
  background: var(--surface-opaque); border: 1px solid var(--border);
  box-shadow: 0 1px 3px rgba(0,0,0,.18); cursor: pointer;
}
.dur-slider::-moz-range-thumb {
  width: var(--slider-thumb); height: var(--slider-thumb); border-radius: 50%;
  background: var(--surface-opaque); border: 1px solid var(--border);
  box-shadow: 0 1px 3px rgba(0,0,0,.18); cursor: pointer;
}
.dur-slider::-moz-range-track { height: 4px; border-radius: 2px; background: var(--border); }
.slider-marks { position: relative; height: 14px; margin-top: 7px; }
/* Centred by fixed width + negative margin, NOT transform — the stylesheet's
   nothing-moves test reserves transform for the switch knob, and a static
   centring that uses it is indistinguishable from motion to a rule that
   reads source. */
.slider-marks i {
  position: absolute; font-style: normal;
  width: 48px; margin-left: -24px; text-align: center;
  font-size: var(--text-xs); color: var(--muted);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.slider-marks i:first-child { margin-left: 0; text-align: left; }
.slider-marks i:last-child { margin-left: -48px; text-align: right; }

/* The block's ⋯ — iOS `blockMenu`. A native <details>, so it opens and closes
   with no script and closes on Escape; the list is the ink-and-paper card the
   rest of the page is made of. Four items on a work block, one on a bookend
   ("Remove Warm-up"), and Delete is the only red thing in it. */
.blk-menu { position: relative; }
.blk-menu > summary {
  display: inline-flex; align-items: center; justify-content: center;
  width: var(--control-h-sm); height: var(--control-h-tap);
  color: var(--muted); cursor: pointer; list-style: none;
}
.blk-menu > summary::-webkit-details-marker { display: none; }
.blk-menu > summary:hover { color: var(--text); }
.blk-menu > summary:focus-visible { outline: none; box-shadow: var(--focus-ring); }
.blk-menu[open] > summary { color: var(--text); }
.blk-menu-list {
  position: absolute; right: 0; top: 100%; z-index: 5;
  display: flex; flex-direction: column; min-width: 150px;
  background: var(--surface-opaque); border: 1px solid var(--border);
  border-radius: var(--r-md); box-shadow: var(--shadow-hover);
  padding: var(--sp-1) 0;
}
.blk-menu-item {
  border: none; background: none; cursor: pointer; text-align: left;
  font: inherit; font-size: var(--text-sm); color: var(--text);
  min-height: var(--control-h-tap); padding: 0 var(--sp-3);
}
.blk-menu-item:hover { background: color-mix(in srgb, var(--text) 6%, transparent); }
.blk-menu-item.danger { color: var(--error); }

/* The unit beside the typed target — iOS reserves a slot printing `200 W` /
   `100 %` / `28 spm` where this side showed a bare number. */
.step-open .tgt-unit {
  font-size: var(--text-sm); color: var(--muted);
  font-variant-numeric: tabular-nums; white-space: nowrap;
}
.step-open .tgt-readonly { margin: var(--sp-2) 0 0; }

/* "Add to group" — ONE insertion row, the phone's rung and the phone's
   wording, opening a Work / Rest choice. It was two `.btn.small` (the
   SECONDARY rung) standing where a gap you can fill should be. */
.entry-add {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-15);
  padding: var(--sp-4); border-top: 1px solid var(--border);
}
.entry-add-menu { display: inline-flex; gap: var(--sp-15); }
.builder-block.repeating .entry-add { padding-left: var(--plan-gutter); }

/* THE LIST ("One List", 2026-08-21) --------------------------------
   One anatomy for every band-headed list on the web: a white band with a
   hairline border on ALL four edges (the slabs' own rule, Martin), 32px of
   air before whatever follows, hairlines between rows, and the 16-inset /
   14-vertical row padding. The row's CONTENT differs per list — a workout's
   bar, a session's result, a club's Open — the anatomy never does. */
/* THE BAND ------------------------------------------------------------ 3.1
   **A list sits on a band: the surface edge to edge across its column, a
   hairline top and bottom, and no side edges.** It was `.band-list`, a box with
   a border on all four sides, and the redesign's move for lists is away from
   that: four edges say "this is one object", and a log is not one object, it
   is a column of them. A single component that IS an object keeps its radius
   (the charts card, the race card, the attempt pair, the invite panel); a list
   does not — though `.band-list` never actually had one, so `border-radius: 0`
   below is a guard rather than a removal.

   The name is the drawing's and iOS's (`gen.py`'s `.band`, `SectionBand`), so
   the three platforms call it the same thing. */
.band {
  background: var(--surface);
  border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border);
  border-radius: 0;
  margin: 0 0 var(--sp-6);
  list-style: none; padding: 0;
}

/* **The band pays its own gutter, and the rows sit flush inside it.** The
   alternative — a full-bleed band with a negative margin on each row — was
   tried on the app pass and is what its notes warn about: every row then has
   to know how wide the band's inset is, and one that forgets sticks out.
   20 px is the drawing's (`gen.py`: `.row{padding:10px 20px}` and
   `.tr{padding:0 20px}`) and is deliberately NOT on the spacing scale, which
   measures the distance BETWEEN things; this is how much room a band gives the
   content it contains. Recorded in UNPAIRED_CSS. */
.band { --band-gutter: 20px; }

/* **Edge to edge across its COLUMN, including when something else has inset
   it.** Six of the fifteen bands live inside `section.sect-body`, which pays
   16 px of its own — so they stopped 16 px short on each side, and a squared
   band that stops short of nothing reads as a slab rather than as the width of
   the list. `.plan` escapes this exact host the same way three sections up,
   and names `.sect-body` in its comment; the band has to do it too. */
/* A DESCENDANT, not a child. `>` reached five of the six: the heart-rate
   consent band is wrapped in its own `<form>`, so it kept the 16 px and sat
   1088 px wide under two siblings at 1120 — the very defect this rule was
   added to fix, on the very screen it was found on. */
.sect-body .band { margin-inline: calc(-1 * var(--sp-4)); }

/* THE COLUMN HEAD ----------------------------------------------------- 3.1
   A band of rows with columns says what the columns ARE, once, at the top:
   tracked caps in the faint ink, no rule above it (the band's own edge is
   already there), and shorter than a row because it is a label rather than a
   thing you can open.

   `.tr` is the grid row the head belongs to — one `grid-template-columns` on
   the band, inherited by every row, so a head and its rows cannot drift out of
   alignment by being laid out twice. */
.tr {
  display: grid; align-items: center;
  gap: var(--sp-25);
  min-height: 44px;
  /* No fallback: a `.tr` outside a `.band` is not a thing this site has, and a
     fallback would be a second opinion about the gutter that goes stale the
     day the token moves. */
  padding: 0 var(--band-gutter);
  border-top: 1px solid var(--hairline);
  font-size: var(--text-sm-plus);
  /* **The columns are the BAND's, inherited.** The head and its rows are laid
     out by one declaration, so they cannot drift out of alignment by being
     laid out twice — which is the whole reason this is a grid and not six
     flex rows. A band that has columns sets `--tr-cols`; `.log-rows` is the
     first (4.1). `none` is the grid default and gives a single column, which
     is what a `.tr` outside such a band should look like. */
  grid-template-columns: var(--tr-cols, none);
}
.tr:first-child { border-top: 0; }
/* **THE ROW YOU ARE ON** — a squared band with a 4 px ink edge down its left.
   `gen.py`'s `.row.sel` and `.tr.now` are the same mark: the selected split,
   the tapped rep, the "this session" row in a list of attempts.

   A WASH plus a RULE, not one or the other. The wash alone is a tint anyone
   scrolling past reads as a hover; the rule alone is a mark with nothing
   behind it. Squared, because it belongs to the band it is in and a radius
   would make it an object sitting on one.

   One rule, on `.tr`, rather than a `.row-now` beside it: the first version
   declared both and only one could have a caller, which is the shape the
   `badge` parameter was caught in. A non-grid row
   that needs this mark can take the class when it exists. */
.tr.now { background: color-mix(in srgb, var(--text) 8%, transparent);
  box-shadow: inset 4px 0 0 var(--text); }
/* **A ROW WITH A SECOND LINE IS 64 PX — once, for every host.**
   `min-height:64px` with 10 px above and below is the drawing's
   (`gen.py`'s `cols` branch, `a_screens.py`'s clubs row), against the 44 a
   one-line row gets.

   Three things made this one rule. It began as a `.tr` override, which left
   `/styleguide` drawing the cell at 45 px on the page whose paragraph asserts
   64. Then the height was re-asserted per host — `.tr` here, `.log-row`'s
   padding there, `.band .member-item`'s somewhere else — and the host nobody
   edited kept 14 px, so `club_row` shipped the SAME component at 71.5 while
   `/sessions` shipped it at 64.5. That is precisely the drift `cell_2l` was
   created to end, reappearing one level up in the rows that host it.

   **Keyed on `.cell2-b`, not `.cell2`**: the second LINE is what makes a row
   two lines. On `.cell2` a one-line cell got the two-line height, which
   contradicts the macro's own documentation and would have handed 4.1 a table
   of one-line cells 20 px per row too deep.

   `:has()` rather than a modifier class because the condition IS the content —
   a class would have to be remembered at every call site 4.1 is about to add,
   which is the failure this rule already had once. */
.tr:has(.cell2-b),
.log-row:has(.cell2-b),
.band .member-item:has(.cell2-b) {
  min-height: 64px;
  padding-top: var(--sp-25);
  padding-bottom: var(--sp-25);
}
.tr .r { text-align: right; }
/* The sync column: a mark, centred under a centred head. `justify-items` would
   reach every cell, and only this one wants it. */
.tr .c { text-align: center; justify-self: center; }


/* THE TWO-LINE CELL --------------------------------------------------- 3.2
   Primary over secondary, in one column of a row. The drawing's own metrics
   (`gen.py`'s `cols` row): 15/700 over 12 in the faint ink, 3 px apart, the
   primary ellipsing rather than wrapping — a name that wraps turns a 64 px row
   into a 90 px one and the column stops scanning. */
.cell2 { display: flex; flex-direction: column; gap: var(--cell-line-gap);
  min-width: 0; }
.cell2-a { display: flex; align-items: center; gap: var(--sp-2); min-width: 0;
  font-size: var(--text-base); font-weight: var(--weight-bold); }
/* **The text ellipses, not the row.** `text-overflow` does nothing on a FLEX
   container — its children are anonymous flex items, not an inline formatting
   context — so the first version clipped a name mid-glyph with no "…", while
   the second line (a block) ellipsed correctly two pixels below it. Three
   places said "the primary ellipses" and none of them was what shipped. The
   text needs its own non-flex box; `min-width: 0` on both is what lets it
   actually shrink. */
.cell2-t { min-width: 0; overflow: hidden; text-overflow: ellipsis;
  white-space: nowrap; }
.cell2-b { font-size: var(--text-xs); color: var(--ink-faint);
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* **A secondary that is a SENTENCE wraps.** `nowrap` is right for the fact the
   cell is for — a date, an erg, a shape — and wrong for prose: "An admin has
   to let you in" would ellipse rather than wrap on a narrow screen, and half a
   sentence says nothing. `.card-meta`, which this replaced, could wrap. */
.cell2-b.prose { white-space: normal; overflow: visible; }

.tr.hd {
  min-height: 36px;
  border-top: 0;
  font-size: var(--text-3xs);
  letter-spacing: var(--band-head-tracking);
  text-transform: uppercase;
  font-weight: var(--weight-heavy);
  /* `--muted`, not faint: 10 px at 42 % fails AA (see `--ink-faint`). */
  color: var(--muted);
}
/* member rows ride in it with the canonical padding — their bare form keeps
   `8px 0` for the sect-body contexts that already supply the inset. */
.band .member-item { padding: var(--sp-35) var(--sp-4); }
.band .member-item:first-child { border-top: none; }
/* A setting's DETAIL line — iOS `SettingRow`'s `detail`, the sentence under a
   label that says what the control does. It rides inside `.member-name`, which
   is the row's label column and is set at 600; a caption in the label's weight
   reads as a second label. (2026-09-08.) */
.member-name .muted.small {
  display: block; font-weight: 400; font-size: var(--text-sm);
  color: var(--muted); margin-top: var(--sp-1);
}
/* A row's STATE, said rather than actioned — `SettingRow`'s `.state`
   accessory: the glyph and the word, in the semantic colour. The Logbook row
   was a paragraph of prose and a neutral button; the phone draws a status row
   with a green check. (2026-09-08.) */
.row-state { display: inline-flex; align-items: center; gap: var(--sp-1);
  font-size: var(--text-sm); font-weight: var(--weight-semibold);
  color: var(--muted); }
.row-state.ok { color: var(--success); }

/* Workouts list folds (2026-08-21, mock 1; iOS-banded 2026-08-21 take two)
   The list is a BANDED LIST — iOS's Workouts tab, translated: the section's
   rows are ONE white band on the grey page, split by full-bleed hairlines.
   A row's face carries what iOS's carries: title + estimate + chevron, THE
   BAR, and one shape line that yields to the plan when the row is open.
   Opening a row grows the same white around its content, and the OPEN
   workout is bracketed in ink — 2px rules top and bottom (Martin's
   refinement on "The Open Workout" mock). */
/* container chrome comes from .band; only the fold behaviour lives here */
.fold + .fold { border-top: 1px solid var(--hairline); }
/* A band whose rows are bare `.fold-row`s — a club's posted sessions put
   `ui.workout_row` straight into the band, and that macro is an `<a
   class="fold-row">` rather than a `.fold`, so the rule above reached none of
   them and N rows drew ZERO rules between them. A band's anatomy is a hairline
   between its rows, whatever the row happens to be made of. */
.band > .fold-row + .fold-row { border-top: 1px solid var(--hairline); }
.fold.open { border-top: 2px solid var(--text); border-bottom: 2px solid var(--text); }
.fold.open + .fold { border-top: none; }
/* `--sp-3` / `--sp-4`, which is iOS's `Spacing.sm` / `Spacing.md` on
   `WorkoutRowView`. It was 14 vertical, two pixels taller per row than the
   phone. */
.fold-row {
  display: block; width: 100%;
  padding: var(--sp-3) var(--sp-4);
  cursor: pointer; color: var(--text);
  font-variant-numeric: tabular-nums;
}
.fold-row:hover { background: color-mix(in srgb, var(--text) 3%, transparent); }
/* The same face as a LINK — a club's posted sessions navigate rather than
   fold. The chevron is what says it goes somewhere; an underline under a row
   with a bar in it would be a second, louder claim. */
a.fold-row { text-decoration: none; }
.fold-trow { display: flex; align-items: baseline; gap: var(--sp-2); }
/* The name is `.headline` (17/semibold, two lines) and what it costs is
   `.subheadline` (15/semibold, secondary) — iOS's `titleLine`, resolved at
   Large and adopted as pixels the way the card's geometry is. The row said
   both at body size, so the estimate and the title weighed the same. */
.fold-title { flex: 1; min-width: 0;
  font-size: var(--text-md-plus); font-weight: var(--weight-semibold); }
/* `--muted`, not `--border`. A chevron drawn in the hairline colour is the
   same ink as the line under the row: iOS's is `.tertiary`, which is a quiet
   FOREGROUND, and the difference is whether it reads as a control at all. */
.fold-cv { color: var(--muted); display: inline-flex; align-self: center;
  transition: transform 0.15s; }
.fold.open > .fold-row .fold-cv { transform: rotate(90deg); }
/* Who set it, on its OWN line under the name — iOS's `sourceLine`. It was in
   the title row's right-hand slot, which is where the estimate goes, so a
   club's workout printed its club where every other row printed its cost and
   never printed its cost at all. */
.fold-source { display: flex; align-items: baseline; gap: var(--sp-2);
  margin-top: var(--sp-15); flex-wrap: wrap; }
.fold-row .ibar { margin: var(--sp-25) 0 0; }
/* **The fold's own rules went with the Workouts table (4.3).** The face that
   folded open, the panel under it, the inline editor's fields and Save, the
   shape line that yielded to the plan, the sticky open header — all of it
   belonged to a list that folds, and the drawings make the list a table and
   put the editor in the workout's own page. What is left is the LINK form:
   `/logbook` still draws a group as a `.fold-row`, which is this same face
   with an href instead of a panel. */
/* The list explaining itself, above the first band: iOS's notice rows sit on
   the list's own white ground and hard against the band below. `.band`'s
   32 px of air would read as a card floating over the list instead. (It was
   `.fold-notices` until 4.3 — a name from the fold era on something that was
   never a fold.) */
.list-notices { background: var(--card); border: 1px solid var(--border); }

/* SETTINGS ------------------------------------------------------------ 4.7
   **Two columns.** Who you are and what the app does with it on the left — the
   account, the Concept2 connection, the units and the appearance — and what
   other people can see, what has a key to your data, and the way out on the
   right. It was one column of seven sections, so Delete account sat at the
   bottom of a page you had to scroll past everything to reach. The drawing's
   own template: `minmax(0,1fr) 440px`, 40 px apart, capped at 1120. */
.yx-cols { display: grid; grid-template-columns: minmax(0, 1fr) var(--page-aside);
  gap: 0 var(--shell-gutter); max-width: var(--page-max); }
.yx-main { min-width: 0; }
.yx-side { min-width: 0; }
@media (max-width: 64rem) {
  .yx-cols { grid-template-columns: minmax(0, 1fr); }
}

/* THE SESSION PAGE ---------------------------------------------------- 4.2
   **Two columns.** The session and its shape on the left — the hero, the
   stats, the charts, the Logbook line — and the numbers you read row by row on
   the right: the splits, the workout this was, what you can do to it. It was
   one column of seven stacked sections, so the splits table stood BETWEEN the
   summary and the charts it indexes, and everything you do to a session was
   below all of it. The drawing's own template: `minmax(0,1fr) 400px`, 40 px
   apart, capped at 1120. */
.sx-cols { display: grid; grid-template-columns: minmax(0, 1fr) var(--page-aside);
  gap: 0 var(--shell-gutter); max-width: var(--page-max); }
.sx-main { min-width: 0; }
.sx-side { min-width: 0; }
/* One column under the shell's own breakpoint, for the reason the workout
   page's aside folds there: 400 px of splits beside a chart is two things
   neither of which fits. */
@media (max-width: 64rem) {
  .sx-cols { grid-template-columns: minmax(0, 1fr); }
}
/* A band inside a column pays the column's own inset, not the page's — the
   same rule `.sect-body .band` states one section up. */
.sx-side .sect:first-child { padding-top: var(--sp-25); }
/* **The hero starts where the title starts.** `.session-summary` and
   `.sect-body` each pay 16 px of their own, so the page's biggest number sat
   16 px right of the h1 above it — an indent with nothing to its left to
   explain it. The bands keep their inset; what sits loose on the ground does
   not. */
.sx-main > .session-summary { padding-inline: 0; }
.sx-main > .sect-body { padding-inline: 0; }
/* The splits table has 368 px here and seven columns. `--text-2xs` on the
   cells buys the ~21 px the widest session (eight splits at `2:25.x`) was
   clipping; the table is the one place on the page where a number may be a
   step smaller than the rest, because it is read in a column and not in a
   sentence. */
.sx-side .laps-table { font-size: var(--text-2xs); }
.sx-side .laps-table th, .sx-side .laps-table td { padding-inline: var(--sp-1); }
/* **The workout row, in 400 px.** Its own five columns need 830 before they
   start shrinking, so the bar ran off the aside's right edge. Three here: the
   name over its shape, the bar over the erg, and the door. `show_est` is
   already a parameter for the club board's four; this is the same component
   narrowing again rather than a second row. */
.sx-side .wk-rows { --tr-cols: minmax(0, 1fr) 120px 30px; }
.sx-side .wk-last, .sx-side .wk-est { display: none; }
/* On a phone the bar column is hidden too, and this selector outranked the
   narrow rule — so the card kept 120 px for a column it no longer drew and cut
   the title to half the width (2026-09-24). */
@media (max-width: 30rem) { .sx-side .wk-rows { --tr-cols: minmax(0, 1fr) auto; } }

/* THE WORKOUT PAGE ---------------------------------------------------- 4.4
   **Two columns.** The plan and its history on the left, what you DO to the
   workout and where your best sits on the ramp on the right. It was one column
   of stacked sections, so the actions sat below a table that grows without
   limit: on a workout with thirty attempts, Delete was off the bottom of the
   screen. The drawing's own template — `minmax(0,1fr) 420px`, 40 px apart,
   capped at 1120. */
/* 40, not `--sp-6`'s 32: the drawing's `gap:0 40px` is the SHELL's own gutter
   — the page's two columns are as far apart as the page is from its rail. */
.wk-cols { display: grid; grid-template-columns: minmax(0, 1fr) var(--page-aside);
  gap: 0 var(--shell-gutter); max-width: var(--page-max); }
.wk-side { min-width: 0; }
.wk-main { min-width: 0; }
/* One column under the shell's own breakpoint: 420 px of aside beside a plan
   table is two things neither of which fits. */
@media (max-width: 64rem) {
  .wk-cols { grid-template-columns: minmax(0, 1fr); }
}

/* The HERO — what the workout will cost, at the size of the one number the
   page is about, with what it is made of beside it. It was a clause beside the
   name in the type of a subtitle. */
.wk-hero { display: flex; align-items: baseline; gap: var(--sp-35);
  margin: var(--sp-2) 0 var(--sp-4); }
.wk-hero-v { font-size: var(--text-display); font-weight: var(--weight-semibold);
  letter-spacing: var(--display-tracking); line-height: var(--display-leading);
  font-variant-numeric: tabular-nums; }
.wk-hero-sub { color: var(--muted); font-size: var(--text-sm-plus); }

/* ON THE BOARD — your best at this workout, and the two marks on one ramp.
   The attempts table says how many and how fast; it cannot say how HARD that
   was, and the distance between the pin and the notch is the only comparison
   this page owes you. */
.wk-board { display: flex; flex-direction: column; gap: var(--sp-25);
  padding: var(--sp-3) var(--band-gutter) var(--sp-35); }
.wk-board-top { display: flex; align-items: center; gap: var(--sp-3); }
.wk-board-v { font-size: var(--text-2xl); font-weight: var(--weight-heavy);
  font-variant-numeric: tabular-nums; }
/* Pushed right by the `auto` margin rather than by a spacer element: the date
   is the last thing on the line and a `<span style="flex:1">` is furniture the
   drawing needs and a stylesheet does not. */
.wk-board-when { margin-left: auto; color: var(--ink-faint);
  font-size: var(--text-xs); }
/* What was ASKED and what was HELD, under the ends of the scale they are
   marked on. `space-between`, so each sits over its own half. */
.wk-board-cap { display: flex; justify-content: space-between;
  gap: var(--sp-3); color: var(--muted); font-size: var(--text-xs);
  margin-top: var(--sp-1); }
.wk-board-cap span:last-child { text-align: right; }

/* THE WORKOUTS TABLE -------------------------------------------------- 4.3
   Five columns, the drawing's own template. A workout was a folding card face
   here; the drawings make the list a table and put the editor in the workout's
   own page, so a row is a link and the grid is `.tr`'s like every other band
   with columns. `a_screens.py`'s `wrow`, and the club page's posted list is
   the same component with `show_est=false`. */
/* **No `max-width` here**, unlike History's band. It is not an oversight on
   one of them: `a_history` wraps its band in `<div style="max-width:1040px">`
   and `a_workouts` puts its two straight into the shell body, so the Workouts
   table runs the full measure and History's stops short. The difference is
   the columns — History's five right-hand tracks are all fixed, so every spare
   pixel lands in the name; this one's `≈ time` is the last of four fixed
   tracks and the band has 40 px less to give away. */
.wk-rows { --tr-cols: minmax(280px, 1fr) 240px 190px 90px 30px; }
.wk-item { position: relative; }
/* The rule belongs to the list item, as History's does and for the same
   reason: the grid is an `<a>` that is its item's only child, so
   `.tr:first-child` would match every one of them. */
.wk-rows > li + li { border-top: 1px solid var(--hairline); }
.wk-item > .tr { border-top: 0; }
.wk-row { color: var(--text); text-decoration: none;
  font-variant-numeric: tabular-nums; }
.wk-item:hover { background: color-mix(in srgb, var(--text) 3%, transparent); }
.wk-name { min-width: 0; }
.wk-lines { display: flex; flex-direction: column; margin-top: var(--sp-05);
  font-size: var(--text-xs); color: var(--muted); }
.wk-lines span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* **Column 3 is QUIETER than column 1.** `cell_2l` sets its primary at
   15/700, which is the NAME's rank — the drawing gives the date 13 px at the
   body weight, because "when you last did this" is a fact about the workout
   and not the thing you are scanning for. Only the rank changes; the cell is
   the same component. */
.wk-last { min-width: 0; }
.wk-last .cell2-a { font-size: var(--text-sm); font-weight: var(--weight-medium); }
/* **Never done is not a date.** The drawing draws it in MU2 — it is the
   "nothing to measure this against" of this column, and at the same weight as
   a real date it claims to be one. */
.wk-last .cell2-a.never { color: var(--ink-faint); }
/* What it will COST — the drawing's 14/700 in the reading ink, a step under
   the result column on History because an estimate is not a result. */
.wk-est { font-size: var(--text-sm-plus); font-weight: var(--weight-bold);
  color: var(--muted); white-space: nowrap; }
/* A club's board spends this column on who has done it instead, so the row is
   four columns there and the head must agree. */
.wk-rows.no-est { --tr-cols: minmax(200px, 1fr) 190px 150px 30px; }
.wk-erg-narrow { display: none; font-size: var(--text-xs); color: var(--muted); }
@media (max-width: 30rem) {
  /* Phase 5 draws the narrow table properly; until then the band keeps the
     name and drops the three middle columns rather than squeezing them. */
  .wk-rows { --tr-cols: minmax(0, 1fr) auto; }
  .wk-rows .tr.hd, .wk-row .tr-shape, .wk-last { display: none; }
  .wk-erg-narrow { display: block; }
  /* **The club's chip goes under the title on a phone** (2026-09-24 — the
     two-line-rows rule: stack a secondary fact rather than squeeze a column).
     Beside it, the chip left the title "Tröskelpass me…". */
  .wk-name .cell2-a { flex-wrap: wrap; row-gap: var(--sp-1); }
  .wk-name .cell2-a .tag { order: 1; }
  .wk-name .cell2-t { flex-basis: 100%; }
}

/* THIS STEP — the editor's right-hand panel ---------------------------- 4.11b
   The engine builds the open step's controls as part of its row (one tree, so
   no stylesheet can put them in a sibling column); after every render it moves
   that body in here. The row it leaves behind is the highlighted step, which
   is what the drawing shows.

   The body arrives with the rules it wore inside a row — a wash, hairlines top
   and bottom, and an inset that cleared the group's spine. In a panel of its
   own it needs none of them: the band IS the container. */
.ed-step { margin-bottom: var(--sp-4); }
.ed-step .sect { padding-top: var(--sp-25); }
.ed-step-body > .step-open-body {
  background: none; border: 0; padding: var(--sp-35) var(--band-gutter); }
/* The panel has 420 px and no spine to clear, so the controls take the room
   the open row had to squeeze them into. */
.ed-step-body .open-ctl { gap: var(--sp-25); }
.ed-step-body .seg2 button { padding: 0 var(--sp-3); }

/* THE SPLIT ZOOM ------------------------------------------------------ 4.12
   A split is a DOOR INTO THE CHART: clicking its row narrows every pane to
   that split's own window. The traces ran the whole session, so a bump inside
   one rep was a few pixels wide and the splits table — which knows exactly
   where each rep begins — could not say so to the chart beside it.

   A zoom is a STATE of this page, not a screen you go to: the table stays put,
   the chosen row is marked, and Escape or a second click on the same row puts
   the domain back. */
.charts-head { display: flex; align-items: center; gap: var(--sp-2);
  margin-bottom: var(--sp-25); }
.charts-head .zoom-what { flex: 1; text-align: center;
  font-size: var(--text-sm-plus); font-weight: var(--weight-bold); }
.charts-head .zoom-clear { margin-left: auto; }
/* The row you are looking at, marked the way every other chosen row on the
   site is — a wash and a 4 px ink edge (3.3). */
tr.lap-work[data-zoom-label] { cursor: pointer; }
tr.lap-work.zoomed > td:first-child { box-shadow: inset 4px 0 0 var(--text); }
tr.lap-work.zoomed > td {
  background: color-mix(in srgb, var(--text) 8%, transparent); }
tr.lap-work[data-zoom-label]:hover > td {
  background: color-mix(in srgb, var(--text) 4%, transparent); }
tr.lap-work[data-zoom-label]:focus-visible { outline: 2px solid var(--accent);
  outline-offset: -2px; }

/* THE EDITOR ---------------------------------------------------------- 4.11
   **Two columns.** The plan you are editing on the left, and what you can DO
   to the workout on the right — quiet, because while you are editing the rest
   waits. Save and Cancel moved to the header with the name: they were a footer
   under the whole editor, so on a workout with thirty steps the way out was
   off the bottom of the screen.

   The drawing also moves the OPEN STEP's controls into that right-hand panel.
   That is a change to the rendering engine (`_editor_js.html` draws the row
   and its open body as one tree) rather than to this stylesheet, and is
   recorded in STATE.md as the piece's remaining half. */
.ed-cols { display: grid; grid-template-columns: minmax(0, 1fr) var(--page-aside);
  gap: 0 var(--shell-gutter); max-width: var(--page-max); align-items: start; }
.ed-cols > form { min-width: 0; }
.ed-side { min-width: 0; }
/* While you are editing, the rest of the page is still there and not the
   subject — the drawing dims it (`opacity:.55`) rather than hiding it, because
   what you can do to the workout has not changed, only what you are doing. */
.ed-side { opacity: 0.65; }
.ed-side:hover, .ed-side:focus-within { opacity: 1; }
@media (max-width: 64rem) {
  .ed-cols { grid-template-columns: minmax(0, 1fr); }
}
/* The name, its state and the two rungs on one line. */
.editor .page-header { display: flex; align-items: center; gap: var(--sp-25);
  flex-wrap: wrap; }
.editor .page-header .title-input { flex: 1 1 320px; width: auto; }
.editor-rungs { display: flex; align-items: center; gap: var(--sp-2);
  margin-left: auto; }

/* THE CLUB PAGE ------------------------------------------------------- 4.9
   **Two columns for what everyone reads.** What the club IS on the left — what
   is posted, how each series stands, what members have suggested — and who is
   IN it on the right: the join code, the people, the asks. It was one column,
   so the members list sat below every workout the club has ever posted. The
   admin half stays full width below, where its own comment already put it. */
.cx-cols { display: grid; grid-template-columns: minmax(0, 1fr) var(--page-aside);
  gap: 0 var(--shell-gutter); max-width: var(--page-max); }
.cx-main { min-width: 0; }
.cx-side { min-width: 0; }
@media (max-width: 64rem) {
  .cx-cols { grid-template-columns: minmax(0, 1fr); }
}
/* The posted list is `ui.workout_row`'s four-column form and has 400 px less
   than it did; its own `.no-est` template already fits. */
.cx-side .sect:first-child { padding-top: var(--sp-25); }

/* CLUBS --------------------------------------------------------------- 4.8
   Four columns, the drawing's own: the crest and the name over how many people
   are in it, what is happening there right now, what YOU are in it, and the
   door. The row was a flex `.member-item` — the dense members list's anatomy,
   which has no columns to line up. */
.club-rows { --tr-cols: minmax(260px, 1fr) 200px 140px 30px; }
.club-rows > li + li { border-top: 1px solid var(--hairline); }
.club-item { position: relative; }
.club-item > .tr { border-top: 0; color: var(--text); text-decoration: none; }
.club-item:hover { background: color-mix(in srgb, var(--text) 3%, transparent); }
.club-who { display: flex; align-items: center; gap: var(--sp-35); min-width: 0; }
.club-now { color: var(--ink-faint); font-size: var(--text-sm); min-width: 0; }
/* A row you cannot open has no chevron — there is nowhere to go until an admin
   answers — so the cell is empty rather than absent, and the columns hold. */
.club-you { min-width: 0; }
@media (max-width: 30rem) {
  /* The name, what you are, and the chevron — three cells, three tracks. Two
     tracks sent the chevron onto a line of its own under the role tag. */
  .club-rows { --tr-cols: minmax(0, 1fr) auto 16px; }
  .club-rows .tr.hd, .club-now { display: none; }
}

/* BENCHMARKS ---------------------------------------------------------- 4.6
   Four columns, the drawing's own template: the shape over its erg, your best
   and when you did it, how many reps there have been, and the door. The rows
   share History's `.log-item` chrome — the hairline, the hover wash, the
   position the narrow chevron is placed against — because a benchmark row IS a
   log row: a thing in the log, repeated. */
.shape-rows { --tr-cols: minmax(200px, 1fr) 240px 120px 30px; }
.shape-rows > li + li { border-top: 1px solid var(--hairline); }
.shape-rows .log-item > .tr { border-top: 0; }
.shape-best { min-width: 0; }
.shape-reps { color: var(--muted); font-size: var(--text-sm); white-space: nowrap; }
@media (max-width: 30rem) {
  /* Phase 5 draws the narrow table properly; the best column is the one to
     keep, because it is the answer. */
  .shape-rows { --tr-cols: minmax(0, 1fr) auto; }
  .shape-rows .tr.hd, .shape-reps { display: none; }
}

/* HISTORY'S ROW ------------------------------------------------------- 4.1
   **Six columns on one grid, no recency groups.** It was a flex row of a chip,
   a two-line cell and a travelling group of facts, under THIS WEEK / EARLIER
   headings. The headings are gone on every platform (Martin, 2026-09-11) and
   the facts are columns now, because a list you scan down is a list whose
   numbers line up: the workout over its date, the shape over its erg, this
   attempt against the previous one, the result, the sync mark, the door.

   The grid lives on the BAND (`--tr-cols`), so the head row and every session
   row are laid out once. The drawing's own template, to the pixel
   (`gen.py`'s `A_COLS`). The first column is `minmax(260px, 1fr)`: 260 is what
   a workout name needs before it starts ellipsing, and the 1fr is what makes
   it — and not a number column — absorb a wide window. */
/* **1040, not the shell's 1120.** Six columns whose five right-hand tracks
   are fixed put every spare pixel into the name — at 1440 the first column
   computed 490 against the drawing's 410, and the gap between the name and the
   shape bar was 80 px of nothing. The drawing caps this band and only this
   band (`a_history`: `<div style="max-width:1040px">`); the shell's measure is
   still the page's. */
.log-rows { --tr-cols: minmax(260px, 1fr) 240px 130px 100px 40px 30px; }

/* The row and its one row action. The <form> cannot nest in the <a> that is
   the rest of the row, so it is a sibling — and an OVERLAY rather than a
   column, because a column costs every row 47 px of width whether or not
   anybody ever deletes anything. */
.log-item { display: flex; align-items: stretch; position: relative; }
/* **The rule belongs to the `<li>`, not to the `.tr` inside it.** Every row's
   grid is an `<a>` that is its list item's only child, so `.tr:first-child`
   matches all of them and would zero every border in the band. The list draws
   its own rules between items — which also gets the head right, since the head
   IS an item and `li + li` starts at the first row under it. */
.log-rows > li + li { border-top: 1px solid var(--hairline); }
.log-item > .tr { border-top: 0; }
.log-row {
  flex: 1; min-width: 0;
  color: var(--text); text-decoration: none;
  font-variant-numeric: tabular-nums;
}
.log-item:hover { background: color-mix(in srgb, var(--text) 3%, transparent); }
/* Column 1 — the chip and the name over the date. A flex box INSIDE the grid
   cell: the chip is 4 px of colour that must not become a grid column of its
   own, because then the name's ellipsis point would move with it. */
.log-name { display: flex; align-items: center; gap: var(--sp-35); min-width: 0; }
/* **The SHAPE column** — the workout's bar over the machine. `gap: 6` and the
   erg under it, as the drawing stacks them; the bar is the site's own `.ibar`,
   not a second drawing of a bar. Named for the `.tr` row rather than for the
   log, because History and Workouts and a club's posted list are the same
   column in three bands (`a_screens.py`'s `wrow` is one function for all
   three). */
.tr-shape { display: flex; flex-direction: column; gap: var(--sp-15);
  min-width: 0; padding-right: var(--sp-6); }
/* Column 3 — Δ against the previous attempt. The magnitude over the date it
   is against, on the two-line cell, because 130 px does not hold
   "+13 W vs 10 Aug 2026" on one line and the rule for a second fact is
   already "stack it". */
.log-delta { font-size: var(--text-sm); }
.log-delta .cell2-a { font-size: var(--text-sm); font-weight: var(--weight-bold); }
/* **The sign is not the colour.** Higher wins on power and distance, LOWER on
   time (`parity/format_vectors.json`, `attempt_delta`), so the route decides
   which of these two a row gets and the stylesheet only paints it. An equal
   attempt takes neither and stays in the ink it inherits.

   `--diff-good` / `--diff-bad`, which this file's own declaration describes as
   "BETTER and WORSE, where a number is genuinely two-valued: the `vs this`
   column on a session's page" — the same column, one screen over, and the
   attempts table has drawn it in these since 2026-09-08. The first version
   reached for `--go` and `--error`: the primary rung's green and "something
   went wrong", neither of which is what a slower attempt is. `.at-delta` on
   the attempts table is the same ink under a different class; consolidating
   the two is 4.4's, where that table is redrawn. */
.delta-v.good { color: var(--diff-good); }
.delta-v.bad { color: var(--diff-bad); }
/* Nothing to compare against — the first time you rowed a workout. A dash, in
   the faint ink, so the column is occupied without being answered. */
.log-none { color: var(--ink-faint); }
.log-result { font-weight: var(--weight-bold); white-space: nowrap;
  font-size: var(--text-md); }
/* The UNIT, a step quieter than the number it belongs to: `205` is what you
   are comparing down the column and ` W` is what it is measured in, and at one
   weight and one ink the column reads as sixteen short sentences instead of
   sixteen numbers. The drawing's own `mu2` at 500. Not `.log-unit`: the board
   panel on a workout's page draws the same pair, and `value_unit` is the one
   filter both go through. */
.unit { color: var(--ink-faint); font-weight: var(--weight-medium); }
/* **The machine**, and what the row's band chip was graded against — 12 px,
   a step under the 13 the rest of the site's secondary text uses, because it
   shares a row with a name and a result that are already a size apart.

   The three comments that stood here described `.log-when`, which 3.2 deleted,
   and called this "beside the date" after the date moved into the cell two
   rules above. Its faintness is the date's: they were both `--muted` until
   that move, and the drawing draws `when` and `erg` in one ink. */
.log-erg { color: var(--ink-faint); font-size: var(--text-xs); white-space: nowrap; }
/* The tick is a MARK, not text — `--ink-faint`, the drawing's MU2. The words
   beside it are the other case and keep the reading ink. */
.log-sync { color: var(--ink-faint); display: flex; justify-content: center; }
/* **A failed sync keeps its WORDS**, where a synced one is a mark: it needs
   acting on, and a red tick says nothing. Two lines in a 40 px column is what
   the drawing's own `sync(ok=False)` does too; centred and tight so it reads
   as a label rather than as an overflow. */
.log-sync .error { color: var(--error); font-size: var(--text-xs);
  line-height: 1.15; text-align: center; }
/* The row's DOOR, at the end of any `.tr` that is a link. */
/* **`--ink-faint`, not `--border`.** A border ink on a GLYPH measures about
   1.2:1 in both themes — the chevron was the mark that says the row is a door
   and it was not there until you hovered. The drawing draws it in MU2
   (`ic("chev",16,2,MU2)`), which is this token. Recorded as an open item on
   1.1 for `.step-cv`, which takes it in the same commit. */
.tr-cv { color: var(--ink-faint); display: flex; justify-content: flex-end; }
.log-item:hover .tr-cv { color: var(--muted); }
/* The phone's swipe, read for a pointer: the row's trailing affordance turns
   from the chevron into the delete while the row is under the cursor or the
   keyboard is in it, and is nothing otherwise. Where there is no hover there
   is no swipe either, so a touch reader deletes from the session's own page —
   one tap further, and the only place the phone offers it too. */
.log-del { position: absolute; right: var(--sp-15); top: 0; bottom: 0;
  display: flex; align-items: center;
  opacity: 0; transition: opacity 0.12s; }
.log-item:hover .log-del, .log-del:focus-within { opacity: 1; }
/* The chevron yields only while the delete is actually THERE — tabbing to the
   row itself must not blank the affordance that says the row is a door. */
.log-item:hover .tr-cv,
.log-item:has(.log-del:focus-within) .tr-cv { visibility: hidden; }
@media (hover: none) { .log-del { display: none; } }
/* Narrow, the row REFLOWS — the phone's `AdaptiveStack`, and for the same
   reason: side by side the numbers hold their width and the name takes what is
   left, which at 375 px was about two characters, and past that the name and
   the result simply overlapped ("Freestyle" written through "1 938 m"). The
   numbers travel as one group, so they drop under the name TOGETHER and only
   when they no longer fit beside it — a row that fits stays on one line.
   `flex: 1 1 auto` on the title is what makes the line break happen at all:
   `flex: 1` bases the title at 0, so flexbox always thought everything fitted
   and shrank the name to nothing instead of wrapping. The chevron leaves the
   flow and centres on both lines, which is where a list's disclosure sits. */
@media (max-width: 30rem) {
  /* Six columns do not fit a phone, and Phase 5 draws the narrow History
     properly. Until then the band drops to the name and the result — the two
     the old flex row kept longest — and the three middle columns fold away
     rather than squeezing a name to two characters. */
  .log-rows { --tr-cols: minmax(0, 1fr) auto; }
  .log-rows .tr.hd,
  .tr-shape, .log-delta, .log-sync { display: none; }
  /* The chevron leaves the flow only on the rows built for it — a list item
     that positions it — and the row keeps room for it on the right. It was
     every `.tr-cv`, so a club's series row sent its chevron to the foot of the
     band, and History's result ran under it ("205 W" read "205 Ẃ"); found by
     the first real 430 capture (2026-09-14). */
  .log-item .tr-cv, .wk-item .tr-cv { position: absolute; right: var(--sp-4); top: 0; bottom: 0;
    align-items: center; }
  .log-item > .tr, .wk-item > .tr { padding-right: calc(var(--sp-4) + 24px); }
}

/* Session detail (2026-08-21) — one hero, one flat grid, bands. The stat
   cards this replaces were the treatment iOS retired everywhere: same
   numbers, same captions, no chrome. Caption BELOW the value — the caption
   ruling both platforms share. */
.session-summary { padding: var(--sp-2) var(--sp-4) var(--sp-35); }
/* Whether this session turned up for a club: an iconed CAPTION inside the
   summary, between the date line and the grid, which is where the phone draws
   it (`Label(note, systemImage:)` in `.caption`). It was `ui.notice` — the
   transient flash, in a tinted box on 20 px of air above the whole summary —
   and a flash says "something just happened" about a standing fact. */
.session-club { display: flex; align-items: baseline; gap: var(--sp-15);
  margin: 0 0 var(--sp-3); font-size: var(--text-xs); color: var(--muted); }
.session-club svg { flex: none; align-self: center; }
.session-club.counted { color: var(--success); }
.session-hero .v { font-size: 40px; font-weight: 800; letter-spacing: -0.5px;
  line-height: 1.05; font-variant-numeric: tabular-nums; }
.session-hero .c { font-size: var(--text-2xs); font-weight: 700; letter-spacing: 1.6px;
  color: var(--muted); margin-top: var(--sp-1r); text-transform: uppercase; }
/* **One row of equal columns**, as the drawing draws it
   (`repeat(6,minmax(0,1fr))`). It was `flex-basis: 25%`, so seven cells fell
   4 + 3 onto two rows of different widths and the caption you were scanning
   for moved between them. `auto-fit` keeps that from becoming a squeeze on a
   narrow page: the cells wrap only when 88 px each is no longer there. */
.detail-grid { display: grid; margin-top: var(--sp-3);
  grid-template-columns: repeat(auto-fit, minmax(88px, 1fr));
  gap: var(--sp-25) var(--sp-2); }
.detail-grid .cell { min-width: 0; }
.detail-grid .v { font-size: var(--text-lg); font-weight: 700;
  font-variant-numeric: tabular-nums; }
.detail-grid .c { font-size: var(--text-3xs); font-weight: 700; letter-spacing: 1.3px;
  color: var(--muted); margin-top: var(--sp-05); text-transform: uppercase; }
@media (max-width: 480px) {
  .detail-grid { grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); }
}
/* **The series page's figures as a 2 x 2 panel** (Martin, 2026-09-29) — iOS
   `ClubFigureStrip(columns: 2)`: `appPanel` (surface, `--border`, the
   panel radius) with a hairline between the cells. Each cell is TWO lines,
   the value and ONE line of label: the label shrinks with the panel's width
   (the phone's `minimumScaleFactor`) rather than wrapping, and never below
   0.8 of its size. A missing rank lets the rest flow up. */
.detail-grid.fig-panel { container-type: inline-size;
  grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-panel); overflow: hidden; }
.detail-grid.fig-panel .cell { padding: var(--sp-3); }
.detail-grid.fig-panel .cell:nth-child(even) { border-left: 1px solid var(--hairline); }
.detail-grid.fig-panel .cell:nth-child(n+3) { border-top: 1px solid var(--hairline); }
.detail-grid.fig-panel .v { white-space: nowrap; }
.detail-grid.fig-panel .c { white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  /* Sized so the longest label (WORKOUTS COMPLETED BY YOU, ~18.5 em wide
     with this tracking) fits half the panel less the cell's padding. */
  font-size: clamp(calc(var(--text-3xs) * 0.8), calc((50cqi - 25px) / 18.5), var(--text-3xs));
  letter-spacing: 0.04em; }

.editor-actions { margin: var(--sp-4) 0; display: flex; flex-wrap: wrap;
  align-items: center; gap: var(--sp-2); }
/* What "Add step" opens — the four things that can legitimately go there,
   with the two bookends hidden once they exist. */
.editor-add-menu { display: inline-flex; flex-wrap: wrap; gap: var(--sp-15); }
.form-footer { margin-top: var(--sp-5); display: flex; gap: var(--sp-2); align-items: center; }

/* Charts --------------------------------------------------------- */
/* ONE block of panes over ONE x domain, on the page's own ground — the phone's
   `sessionCharts`. They were four shadowed cards, the treatment the one-page
   idiom retired everywhere else, each repeating an axis for a scale it had
   picked for itself. */
.charts { position: relative; display: grid; gap: var(--sp-2); margin: var(--sp-2) 0; }
.chart-pane header {
  display: flex; align-items: baseline; gap: var(--sp-15);
}
.chart-pane h3 { font-size: var(--text-sm-plus); margin: 0; }
/* The pane's own tint rides its glyph and its average, and nothing else: four
   stacked panes with their own titles and units are not told apart by hue. */
.chart-glyph { display: inline-flex; align-self: center; }
.chart-avg { margin-left: auto; font-size: var(--text-2xs);
  font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums; }
.chart-unit { font-size: var(--text-2xs); color: var(--muted); }
.chart { display: block; width: 100%; }
.chart-tick { font-size: var(--text-2xs); }
/* Off-layout: the block measures every label all four panes can print through
   it, so the gutter is as wide as the widest of them and identical on all
   four. That is what puts the plots on one x origin — and what makes the tick
   times under the bottom pane true of the three above it. */
.chart-ruler { position: absolute; visibility: hidden; width: 0; height: 0; }
/* Which workout each stretch of the traces belongs to. Hidden until the script
   has placed the chips, because an unplaced chip is a label over the wrong
   trace. */
.chart-ribbon { position: relative; height: var(--sp-4); visibility: hidden; }
.chart-seg { position: absolute; top: 0; height: var(--sp-4); overflow: hidden;
  white-space: nowrap; padding: 0 var(--sp-1); border-radius: var(--r-micro);
  background: color-mix(in srgb, var(--text) 6%, transparent);
  color: var(--muted); font-size: var(--text-2xs); line-height: var(--sp-4); }

/* --- Workouts list — interval preview cards ----------------------------- */

/* The chips that used to live here went with `ui.workout_preview` in W4:
   the app draws a plan ONE way now, and the web's copy is `.plan` above. The
   `.workout-summary` rules below survive because the summary line survives —
   it was never part of the drawing, it is four workout-level facts. */

.workout-summary {
  margin-top: var(--sp-35);
  color: var(--muted);
  font-size: var(--text-xs);
  letter-spacing: 0.2px;
  font-variant-numeric: tabular-nums;
}
/* The ETA leads the summary — same font size, but full text colour + bold
   so it pops against the muted "8 intervals · Timed rest" suffix. */
.workout-eta {
  color: var(--text);
  font-weight: 600;
}

/* The workout list used to enter staggered — each card easing up 6px and
   fading in 55ms after the one above it, ~260ms before the last one settled.
   It delayed the read of a list the reader had just asked for, so it is gone,
   along with the reduced-motion block that existed to switch it (and the two
   press-transforms) off: there is nothing left here to reduce. */

/* --- Workout detail page (personal history) ---------------------------- */
/* Edit / Share as an equal-width left-right row under the workout card. */
/* Wraps: the QR is fixed-size (`.qr { flex: none }`) and the meta column's
   "Clone to my workouts" button can't shrink below its content, so at 375 the
   pair pushed the PUBLIC share page sideways. Stack them instead. */
.share-box { display: flex; gap: var(--sp-5); align-items: center; margin: var(--sp-4) 0;
  flex-wrap: wrap;
  padding: var(--sp-5); background: var(--card); border: 1px solid var(--border);
  border-radius: var(--r-lg); }
.share-box .qr { flex: none; line-height: 0; }
.share-box .qr svg { display: block; border-radius: var(--r-sm); }
/* Basis 220px, not 0: with `flex: 1` the column was crushed to ~108px beside
   the fixed QR and its input + Clone button spilled out of the page. A real
   basis makes it wrap under the QR once there isn't room for it. */
.share-meta { display: flex; flex-direction: column; gap: var(--sp-3); min-width: 0;
  flex: 1 1 220px; }
.share-code { font-size: var(--text-sm-plus); color: var(--muted); }
.share-code strong { color: var(--text); font-variant-numeric: tabular-nums; letter-spacing: 0.04em; }
/* min-width:0 defeats the input's intrinsic `size` width, which otherwise
   keeps it ~188px wide however narrow the column gets. */
/* "Here is a link, go and send it" — a readonly input rather than a code
   block, because it selects on one tap. Unscoped: club invites need the same
   control, and there is no mail transport, so handing the link over IS the
   distribution mechanism. */
/* The one native control the field rule does not reach, and it was the only
   field on the site drawn on the ground rather than on a surface. */
/* A field, so the film — with `input`/`select`/`textarea` above. */
.share-link { width: 100%; min-width: 0; background: var(--surface);
  font-size: var(--text-sm); }
/* A short tinted fact beside a toolbar subtitle — a session's completion
   percentage, which used to live inside the title and follow the workout
   everywhere the title went.

   `--work`, at the line's own weight: `Text(done).foregroundStyle(Palette.work)`
   in `.caption` on the phone. It was `--warning` semibold here, which says
   "something needs attention" about a fact that needs none, and shouted it
   against the muted line it sits on. */
.toolbar-note { color: var(--work); font-weight: inherit; }

/* A card insets by 17, not 16, so it tells the plan how far its wash and its
   hairlines have to travel; `overflow: hidden` is what makes them stop on the
   card's own rounded corner instead of squaring it off. */
.detail-card { margin: var(--sp-35) 0 0; padding: 15px 17px 13px;
  --plan-bleed: 17px; overflow: hidden; }
/* The trend chart — iOS `AttemptTrendChart`, and the geometry is
   `AppTheme.Chart`'s, carried in the SVG's own attributes by
   `workout_history.trend_chart` and gated by `test_ramp_token_parity.py`. What
   is here is only what a shape is made OF: type, ink and stroke.

   It replaces a 120×32 polyline sparkline that plotted a different number
   (the stored `value`, not the smart watts the page is scored by) over a
   different population (this version's complete attempts only), with no scale
   and no labels — so the same eleven attempts drew two different pictures on
   the two platforms.

   `overflow: visible` because the first and last dots are inset by half a tap
   target and their labels are centred on them: the same overhang SwiftUI
   leaves unclipped, and `.sect-body`'s own 16 px of padding is what it hangs
   into. */
.trend { display: block; width: 100%; overflow: visible;
  margin: var(--sp-1) 0 var(--sp-2); }
.trend-grid { stroke: var(--border); stroke-width: .5; opacity: .45; }
.trend-floor { stroke-width: 1; opacity: 1; }
.trend-tick { font-size: var(--text-2xs); font-weight: 600; fill: var(--muted);
  font-variant-numeric: tabular-nums; }
/* Hollow for an earlier version of the workout, filled for this one, heaviest
   and inked for the best. No band colour: every attempt at one workout sits in
   more or less the same range, so the ramp would paint every dot alike. */
.trend-dot { fill: var(--muted); }
.trend-dot.best { fill: var(--text); }
/* The stroke WIDTH comes with the shape, from `trend_chart` — it is geometry,
   and every other number this chart is made of is in the markup for the same
   reason. */
.trend-dot.older { fill: var(--card); stroke: var(--muted); }
.trend-value { font-size: var(--text-2xs); font-weight: 600; fill: var(--text);
  font-variant-numeric: tabular-nums; }
.trend-date { font-size: var(--text-3xs); font-weight: 700; fill: var(--muted); }

/* Leaderboard */
.leaderboard { list-style: none; margin: 4px 0 8px; padding: 0;
  border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden; }
/* On the tap floor, on the row inset every other list uses — iOS
   `LeaderRowView`: `Spacing.sm`/`Spacing.xs` inside a `ControlHeight.tap`
   frame. 9 px of padding and no floor made a board row shorter than anything
   else you can touch on the page. */
.leader-row { display: flex; align-items: baseline; gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-3); min-height: var(--control-h-tap);
  border-top: 1px solid var(--border); font-variant-numeric: tabular-nums; }
.leader-row:first-child { border-top: none; }
/* `--me-wash` (lime), set once near the band chips. Was accent at 10 %. */
/* Wide enough for a 3-digit rank — a club board is every member, not a top 10. */
.leader-rank { width: 2.2em; text-align: right; color: var(--muted);
  font-weight: 700; font-size: var(--text-sm); }
/* A display name is user-supplied and shown on OTHER people's screens, so it
   must not be able to push the result off the row. `anywhere` breaks a long
   unspaced name rather than letting it overflow the clipped .leaderboard —
   the same defence `code` already carries. */
.leader-name { flex: 1; font-weight: 600; overflow-wrap: anywhere; }
/* `.headline` — the row's one loud thing (iOS `LeaderRowView`). It was body
   type at 700, which is a different sentence: bolder, not bigger. */
/* And under it, when it was rowed: iOS `LeaderRowView` stacks `valueStr` and
   `whenLabel` in one trailing column, so `flex-end` keeps the two right-aligned
   to each other the way its `VStack(alignment: .trailing)` does. */
.leader-val { font-size: var(--text-md); font-weight: var(--weight-semibold);
  display: flex; flex-direction: column; align-items: flex-end; }
.leader-when { font-size: var(--text-sm); font-weight: normal; color: var(--muted); }
/* "former member" is a CAPTION under the name on the phone, not a badge: it
   is a fact about the person rather than a state of the row, and a capsule
   beside a name reads as a role. */
.leader-note { display: block; font-size: var(--text-sm); font-weight: 400;
  color: var(--muted); }

/* A control on a section band's right, where a figure would otherwise sit
   (ui.section_band_with). The band is a flex row, so this just claims the
   end of it. */
.sect-ctl { margin-left: auto; display: inline-flex; align-items: center;
  gap: var(--sp-1); text-transform: none; letter-spacing: 0; }

/* The week's sort — two links reading as one control, the chosen half in ink.
   Same sentence `SegmentPair` says on the phone: ink and paper, no accent. */
.wt-sort { display: inline-flex; border: 1px solid var(--border);
  border-radius: var(--r-pill); overflow: hidden; }
.wt-sort a { padding: 3px 10px; font-size: var(--text-2xs);
  font-weight: var(--weight-semibold); color: var(--muted);
  text-decoration: none; line-height: 1.7; }
.wt-sort a.on { background: var(--text); color: var(--bg); }
.wt-sort a:not(.on):hover { color: var(--text); }

/* The week the list covers, on its own line under the band — it moved out of
   the band's detail slot when the sort moved in. */
.wt-week { margin: var(--sp-1) 0 0; font-size: var(--text-sm);
  color: var(--muted); font-variant-numeric: tabular-nums; }

/* The club's week, per member — four columns (ui.week_board, 2026-09-22).
   Borrows `.leaderboard`'s geometry on purpose: it sits under the same
   section bands on the same page, so the tap floor, the inset, the hairline
   and the `--me-wash` are all the board's. What it adds is three figure
   columns instead of one value, each on its own fixed width so the numbers
   line up down the list rather than each row finding its own edge. */
.wt-rows { list-style: none; margin: 4px 0 8px; padding: 0;
  border: 1px solid var(--border); border-radius: var(--r-md); overflow: hidden; }
.wt-row { display: flex; align-items: baseline; gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-3); min-height: var(--control-h-tap);
  border-top: 1px solid var(--border); font-variant-numeric: tabular-nums; }
.wt-row:first-child { border-top: none; }
.wt-row.me { background: var(--me-wash); }
/* A member who rowed nothing this week: present, and quiet. Muted rather
   than hidden — the list is every member, and a missing row is a kinder lie
   than a row of dashes. */
.wt-row.quiet .wt-name, .wt-row.quiet .wt-fig, .wt-row.quiet .wt-rank {
  color: var(--muted); }
.wt-row.hd { min-height: 28px; font-size: var(--text-2xs); color: var(--muted);
  text-transform: lowercase; letter-spacing: .02em; }
.wt-row.hd .wt-fig, .wt-row.hd .wt-name { font-weight: 400; }
.wt-rank { width: 2.2em; text-align: right; color: var(--muted);
  font-weight: 700; font-size: var(--text-sm); }
/* Same defence `.leader-name` carries: a display name is user-supplied and
   must not push the figures off the row. */
.wt-name { flex: 1; min-width: 0; font-weight: 600; overflow-wrap: anywhere; }
.wt-fig { width: var(--wt-fig-w); flex: none; text-align: right;
  font-weight: var(--weight-semibold); }
.wt-fig.t { width: var(--wt-fig-t); }
.wt-fig.m { width: var(--wt-fig-m); }
/* Narrow: the name keeps the first line and the three figures take the
   second, rather than three columns fighting over 320 px. Same move the
   phone makes at the larger text sizes. */
@media (max-width: 480px) {
  .wt-row { flex-wrap: wrap; row-gap: var(--sp-05); }
  .wt-name { flex: 1 1 60%; }
  .wt-fig, .wt-fig.t, .wt-fig.m { width: auto; min-width: 4em; }
  .wt-row.hd { display: none; }
}

/* Meter — a labelled progress bar (series progress, club average). The fill
   width is set inline because it is the datum; everything else is tokens. */
.meter { margin: var(--sp-2) 0; }
.meter-head { display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--sp-3); margin-bottom: 5px; }
.meter-label { font-size: var(--text-sm); color: var(--muted); }
.meter-value { font-weight: 700; font-variant-numeric: tabular-nums; }
.meter-track { height: 8px; border-radius: var(--r-pill); overflow: hidden;
  background: color-mix(in srgb, var(--text) 10%, transparent); }
.meter-fill { height: 100%; border-radius: var(--r-pill); background: var(--accent);
  /* A zero-width fill would vanish; a sliver says "nothing yet" honestly. */
  min-width: 2px; }
.meter.success .meter-fill { background: var(--success); }
.meter.warning .meter-fill { background: var(--warning); }
.meter-caption { margin: 5px 0 0; font-size: var(--text-sm); color: var(--muted); }
/* Inline variant — a bar that sits IN a row of stats rather than owning a
   block. A bare .meter is a block with no intrinsic width, so as a flex item
   it collapses to its fill's 2px floor (measured); this gives it one. */
.meter.inline { display: inline-block; width: 78px; flex: none; vertical-align: middle; margin: 0; }

/* State band — a standing condition for the whole page (read-only, trial
   ending), as opposed to .notice, which reports what just happened. Squarer
   and flatter than a notice so the two never read as the same thing. */
.state-band {
  display: flex; flex-direction: column; gap: 3px;
  margin: 0 0 var(--sp-4); padding: var(--sp-3) var(--sp-4);
  border-left: 3px solid var(--border); border-radius: var(--r-sm);
  background: color-mix(in srgb, var(--text) 5%, transparent);
  font-size: var(--text-sm-plus);
}
.state-band-title { font-weight: var(--weight-semibold); }
/* --text, not --muted: --muted is calibrated against --bg/--card and drops to
   4.08:1 on the band's own tint (measured). .notice makes the same choice. */
.state-band-body { color: var(--text); }
.state-band.warning { border-left-color: var(--warning);
  background: color-mix(in srgb, var(--warning) 10%, var(--card)); }
.state-band.warning .state-band-body { color: color-mix(in srgb, var(--warning) 65%, var(--text)); }
.state-band.error { border-left-color: var(--error);
  background: color-mix(in srgb, var(--error) 10%, var(--card)); }
.state-band.error .state-band-body { color: color-mix(in srgb, var(--error) 65%, var(--text)); }

/* Choice dialog — a confirmation whose buttons carry the meaning, for when
   there are more than two answers. Native <dialog>, so Esc and focus
   trapping come free. */
.choice-dialog {
  /* Wide enough for three actions in one row — the buttons ARE the
     explanation here, so wrapping them splits the sentence. Still shrinks to
     the viewport on a phone, where stacking is the right answer anyway. */
  width: min(34rem, calc(100vw - var(--sp-6))); padding: var(--sp-5);
  border: 1px solid var(--border); border-radius: var(--r-lg);
  background: var(--surface-opaque); color: var(--text);
}
.choice-dialog { box-shadow: 0 12px 32px rgb(0 0 0 / 28%); }
.choice-dialog::backdrop { background: rgb(0 0 0 / 55%); }
.choice-dialog-title { margin: 0 0 var(--sp-3); font-size: var(--text-lg); }
.choice-dialog-body { color: var(--muted); font-size: var(--text-sm-plus); }
.choice-dialog-body p { margin: 0 0 var(--sp-3); }
.choice-dialog-body p:last-child { margin-bottom: 0; }
.choice-dialog-actions { display: flex; flex-wrap: wrap; gap: var(--sp-2);
  justify-content: flex-end; margin-top: var(--sp-4); }
.choice-dialog-actions form { display: contents; }

/* One series per row, with a rule between them: without this the per-series
   Archive and Board buttons ran together and it wasn't clear which series a
   given button belonged to. */

.switch-field { display: inline-flex; align-items: center; gap: var(--sp-2); cursor: pointer; }
/* A switch's second line — what the setting does in its current position, the
   phone's toggle subtitle ("You approve each request."). */
.switch-hint { display: block; margin-top: 1px; }
/* Ink, not accent — §4: "a PB is a fact, and ink is how facts are marked".
   Blue meant "+12 … +25 s off 5 km pace" in the band chip on the same row.
   `--fact-wash` is the wash W1 added for exactly this. */
/* `caption2` heavy on the chip's own inset — 11 px, no tracking, 7/3. It was
   9.5 px with 0.06 em of it, which is a badge a size smaller than the phone's
   and spelled out where the phone's is set solid. */
.pb-badge { font-size: var(--text-2xs); font-weight: 800; letter-spacing: 0; color: var(--text);
  background: var(--fact-wash);
  border: 0; padding: var(--chip-inset-v-tall) var(--chip-inset-h);
  border-radius: var(--r-pill); white-space: nowrap; }
/* `.pb-delta` went with the table's copy of it on 2026-09-08 — `+8 W` is the
   CARD form's fact on the phone (`AttemptRowView`), and in a 375 px date cell
   it was what pushed the version chip onto a second line. It comes back with
   the card form the narrow layout still owes. */
/* Borderless, on the inner card fill — a version is a quiet fact beside the
   result, and a hairline round it made it the second-loudest thing in the row.
   iOS `AttemptsTableView`: `metricCaption` medium on `Palette.cardInner`,
   8 / 4, `Radius.inner`, no stroke. */
.ver-chip { font-size: var(--text-2xs); font-weight: var(--weight-medium); color: var(--muted);
  background: color-mix(in srgb, var(--text) 6%, transparent);
  border: 0; padding: var(--sp-1) var(--sp-2); border-radius: var(--r-inner);
  font-variant-numeric: tabular-nums; }
.ver-chip.old { opacity: 0.7; }
.ver-chip.legacy { border-style: dashed; }
/* The structure line's tokens. Scoped to `.at-struct`, which is what
   `ui.attempts_table` renders — these rules sat under `.attempt-struct`, a
   class nothing has emitted since the `.attempt-*` -> `.at-*` rewrite, so the
   diff highlight (the only thing that explains why two attempts at "the same"
   workout are not comparable) rendered as plain unstyled text on
   /workouts/{id}, /clubs/{id}/workouts/{id} and /workouts/official/{id}.
   Found 2026-08-25. */
.at-struct .tok-sep { margin: 0 4px; opacity: 0.6; }
/* Ink and weight, and nothing else. The 12 % accent wash behind a differing
   token was the last blue on a workout's page, and iOS took the same colour
   off the same line: the weight already marks it, and a token is a word in a
   sentence rather than a chip. */
.at-struct .tok.diff { color: var(--text); font-weight: 600; }

/* Club detail ---------------------------------------------------- */
.member-list { list-style: none; margin: 0; padding: 0; }
/* **A divider INSIDE a band is a hairline, not a border.** They were one value
   until the material named them apart, and the band is where the difference
   does its work: with both at 9 % the band's own top and bottom edge weighed
   exactly as much as every rule between its rows, so the edge stopped reading
   as an edge and a ten-row band read as a grid. 6 % against 9 % is the whole
   point of having two tokens. */
.member-item { display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--sp-2); padding: 8px 0; border-top: 1px solid var(--border); }
/* **Inside a band**, and only there. The hairline exists so a row rule can be
   told apart from the band's own edge; a roster drawn bare on the page ground
   — which five of a club's lists still are until 4.9 gives them bands — has no
   edge to be distinguished from, and halving its contrast there just makes it
   fainter. Measured Δ13 against Δ25 on `/styleguide`'s own demo. */
.band .member-item { border-top: 1px solid var(--hairline); }
.member-item:first-child { border-top: none; }
/* A basis, not a bare `flex: 1`: with three controls beside it and
   `overflow-wrap: anywhere`, a plain `flex: 1` name shrinks to min-content and
   a long one renders about ten characters wide, one fragment per line. The
   controls wrap under it instead. */
.member-name { flex: 1 1 9rem; min-width: 0; font-weight: 600;
  overflow-wrap: anywhere; }
/* Someone asking to join: a person, what they said, and two decisions.
   Deliberately NOT .member-item — that row is a name and a role, and pushing
   four things through its `flex: 1` name made "Stina Lund" render one letter
   per line. Here the who-block keeps a 12rem basis and the actions wrap
   underneath instead of squeezing it. */
.request-item { display: flex; flex-wrap: wrap; align-items: center;
  gap: var(--sp-2); padding: 10px 0; border-top: 1px solid var(--border); }
.request-item:first-child { border-top: none; }
/* Both children wrap: a long single-word name breaks, and so does whatever
   the asker typed — which would otherwise paint outside the card and give the
   page a horizontal scrollbar. Wrapped, never truncated: the admin is
   deciding about a person and needs all of what they said. */
.request-who { flex: 1 1 12rem; min-width: 0; display: flex;
  flex-direction: column; gap: 2px; overflow-wrap: anywhere; }
.request-name { font-weight: 600; }
/* The actions are .inline-actions; only the shove to the right is new. */
.request-item .inline-actions { margin-left: auto; gap: var(--sp-3); }

/* The club page's head: the crest beside the name and the "23 members · you
   are an admin" line — iOS `ClubDetailView.headerSection`. The toolbar keeps
   its own type; this only puts the picture next to it. */
.club-head { display: flex; align-items: flex-start; gap: var(--sp-3); }
.club-head-text { flex: 1; min-width: 0; }
.club-head .club-pic { margin-top: var(--sp-3); }


/* Small-screen polish ------------------------------------------------
   Tighter gutters/cards and dense, non-wrapping table cells so wide tables
   stay legible and scroll (inside .table-scroll) rather than wrap into tall,
   misaligned rows or widen the page. */
@media (max-width: 560px) {
  .container { padding: var(--sp-45) var(--sp-35) 48px; }
  .laps-table, .table { font-size: var(--text-sm); }
  .laps-table th, .laps-table td,
  .table th, .table td { padding: 7px 9px; white-space: nowrap; }
}

/* Off-screen share card (rendered to PNG by share-card.js). Fixed width, dark,
   variable height — always dark so the exported image is consistent, and it
   grows with the session rather than picking a second layout for a long one.

   The GEOMETRY here is the web half of the card; the CELLS are `export_card`,
   pinned against the phone by parity/export_card_vectors.json. */
/* Offset on the HOST, not the card — html-to-image keeps the captured node's
   own left offset, which would blank the PNG. The card itself is static. */
.share-card-host { position: absolute; left: -9999px; top: 0; }
.share-card {
  /* PINNED, not inherited. The card is dark whatever the reader's theme is —
     a light version is a different picture, not this one inverted — so its
     palette is defined HERE rather than taken from `:root`, which flips. The
     ramp tokens it uses for the bar are already theme-independent.
     Mirrors AppTheme's card palette; parity/export_card_vectors.json pins what
     the two platforms PRINT, and this pins what they print it on. */
  /* THE NEXT-STEP MATERIAL, dark, stated. The card carried a near-black of its
     own (#16191F) and two purple ellipses sampled off the app icon — right
     while the icon was the only purple in the product, and a second material
     from the day every screen became the near-black violet under three washes.
     A shared picture that does not look like the app it came from says nothing
     about the app (Martin, 2026-09-14: "update style to match new glow
     theme"). The alphas are AppTheme.Card's, which names each one for this
     gate to read. */
  --card-ground: #09070F; --card-ink: #F3F6FF;
  --card-muted: rgba(243,246,255,.6); --card-hair: rgba(255,255,255,.06);
  /* HAND-SYNCED with AppTheme.Palette.heart / .rest, resolved for the dark
     scheme the card always uses. They were Apple's systemRed and systemBlue,
     which is what the phone drew before the material — the material's heart is
     #FF5A6E and its rest cyan #22D3EE. One colour, one meaning: the phone's
     splits table and this one are the same table, and a heart that is a
     different red depending where you opened the session is the drift this
     whole contract exists to stop. */
  --card-hr: #FF5A6E; --card-rest: #22D3EE;
  width: 360px; position: relative; overflow: hidden;
  padding: 20px; color: var(--card-ink);
  background: radial-gradient(425px 314px at -12% 38%,
                rgba(34,211,238,.08), rgba(34,211,238,0) 60%),
              var(--card-ground);
  /* NO border and NO radius: a rounded, bordered card is a card ON something,
     and a shared image is not on anything — whatever it lands in gives it its
     own frame, and a radius over that reads as a screenshot of a card rather
     than as the picture (Martin, 2026-09-14). `overflow:hidden` stays, for the
     washes' rims. */
  border-radius: 0; font-variant-numeric: tabular-nums;
  font-family: -apple-system, system-ui, sans-serif; box-sizing: border-box; }
/* THE GLOW — the three radial washes every screen in the app is drawn on
   (`AppTheme.Ground.washes`, which `AppGround` paints and `AppTheme.Card`
   points at rather than copying): violet off the top right, cyan off the left,
   violet again off the bottom. Each ellipse is the artboard's size scaled to
   the card (360/390) and its CENTRE is a percentage of the card's own box, so
   the composition holds however tall the session makes it — which is how the
   phone places them. `overflow:hidden` takes the rims.

   It was TWO ellipses in a different purple, centred on the top and bottom
   edges: the card's own composition from before the material existed, and a
   second material the day the app adopted this one.

   An element has only two pseudo-elements, so the cyan rides the card's own
   `background` alongside the ground colour. */
.share-card::before, .share-card::after { content: ""; position: absolute;
  z-index: 0; pointer-events: none; }
.share-card::before { left: 88%; top: -6%; width: 517px; height: 332px;
  margin: -166px 0 0 -258px;
  background: radial-gradient(50% 50% at 50% 50%,
    rgba(176,124,255,.17), rgba(176,124,255,0) 62%); }
.share-card::after { left: 70%; top: 104%; width: 480px; height: 351px;
  margin: -175px 0 0 -240px;
  background: radial-gradient(50% 50% at 50% 50%,
    rgba(176,124,255,.08), rgba(176,124,255,0) 60%); }
.share-card > * { position: relative; z-index: 1; }

/* A masthead, not a bar: icon and wordmark on one line, centred. */
.share-card .sc-head { display: flex; align-items: center; justify-content: center;
  gap: 11px; padding-bottom: 4px; }
.share-card .sc-icon { width: 40px; height: 40px; border-radius: 9px; display: block; flex: none; }
.share-card .sc-brand { color: var(--card-ink); font-weight: 600; font-size: 20px;   /* title3 */
  letter-spacing: -.02em; }
/* Peaks under the centred lockup and is gone by the card's sides — a rule that
   stops where the composition stops, rather than a border. Not the divider
   above the table, which has to reach both edges to separate the rows. */
.share-card .sc-rule { height: 1px; margin: 14px 0 15px;
  background: linear-gradient(90deg, transparent, rgba(255,255,255,.15) 50%, transparent); }

.share-card .sc-title { font-size: 22px;  /* title2 */ font-weight: 700; letter-spacing: -.02em;
  margin: 0 0 1px; line-height: 1.08; text-align: center; text-wrap: balance; }
.share-card .sc-sub { color: var(--card-muted); font-size: 12px; /* caption1 */ text-align: center; }
/* The bar's caption belongs to the full-width bar above it and the totals
   below, so it stays left while the title block is centred. */
.share-card .sc-cap { color: var(--card-muted); font-size: 11px; /* caption2 */ margin: 4px 0 12px; }
.share-card .ibar { margin-top: 12px; }

.share-card .sc-metrics { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px;
  margin: 0 0 14px; }
.share-card .sc-metrics b { display: block; font-size: 20px; /* title3 */ font-weight: 700; line-height: 1.1; }
.share-card .sc-metrics span { color: var(--card-muted); font-size: 11px; /* caption2 */ font-weight: 600;
  letter-spacing: .04em; margin-top: 1px; display: block; }

/* FIXED, and every data column the same width — the phone gives each cell
   `.frame(maxWidth: .infinity)`, so its columns divide the row evenly. Left to
   size to content, this table crowded `spm` and the heart together at one end
   and spread `time` at the other, so the same session had two different
   rhythms depending where you opened it. */
.share-card .sc-splits { width: 100%; border-collapse: collapse; table-layout: fixed; }
.share-card .sc-splits th, .share-card .sc-splits td { width: 15.4%; }
.share-card .sc-splits th.sc-n, .share-card .sc-splits td.sc-n { width: 7.6%; }
/* The hairline sits under the HEADER and nowhere else — one rule separating
   the headings from the rows, with the zebra below doing the rest. A border on
   every `td` ruled between numbers that are meant to be read down a column,
   where the phone draws a single `HairlineDivider`. */
.share-card .sc-splits th { color: var(--card-muted); font-size: 11px; /* caption2 */
  letter-spacing: .04em; text-align: right; padding: 0 0 4px; font-weight: 600;
  border-bottom: 1px solid var(--card-hair); }
.share-card .sc-splits td { text-align: right; font-size: 13px; /* footnote */ padding: 3.5px 0; }
/* Zebra rather than a hairline between every row — far less busy while staying
   scannable, and the same 4% the phone's table uses. Full-bleed to the card's
   padding, because a row is as wide as the card. */
.share-card .sc-splits tbody tr:nth-child(even) td { background: rgba(233,234,236,.04); }
.share-card .sc-splits tbody tr td:first-child { box-shadow: -20px 0 0 0 transparent; }
/* **One size and one ink for every data cell**, which is `SplitsTable.cell`:
   `cellFont` throughout, `.primary` for a value, and `.secondary` only for the
   leading `#`. This side had four exceptions — a muted LEFT-aligned time, a
   muted spm, a bold watts and a `#` a size smaller than the row — so the same
   session's table had a different rhythm depending which platform you opened
   it on, and `test_export_card_geometry_parity` could not see it, because it
   pins sizes and is blind to alignment, ink and weight.
   The `#` keeps its leading alignment and muted ink and drops the 10.5 px, so
   the heading takes the header size and the cell takes the row's. */
.share-card .sc-splits .sc-n { text-align: left; color: var(--card-muted); }
/* The band slab's FILL is the emphasis, not its weight: `BandSlab` takes the
   row's own font and the phone draws the watts at the same weight as its
   neighbours. This rule stays so the geometry gate can read it. */
.share-card .sc-splits .sc-watts { font-weight: inherit; }
/* The two columns that are not intensity: a heart is red, a rest is quiet
   blue. Deliberately NOT the ramp's red and blue — those mean "how hard" in
   the same table, and one colour cannot say two things. */
.share-card .sc-splits .sc-hr { color: var(--card-hr); }
.share-card .sc-splits .sc-rest { color: var(--card-rest); }
/* AFTER them, and it wins: a dash is an ABSENCE, and an absence must not wear
   the colour of the thing that is missing. Nine unrested intervals printing
   nine blue dashes read as nine rests until you looked twice. */
.share-card .sc-splits .sc-absent { color: var(--card-muted); }

.share-card .sc-date { color: var(--card-muted); font-size: 12px; /* caption1 */ padding-top: 11px;
  text-align: center; }
/* The sign-off is the one place the card speaks as the product rather than
   about the session, so mark and domain are both card ink — the colour under
   them is the wash, not the type. */
.share-card .sc-foot { color: var(--card-ink); font-size: 11px; /* caption2 */ display: flex;
  flex-direction: column; align-items: center; gap: 7px; padding-top: 20px; }
.share-card .sc-mark { display: block; height: 58px; width: auto; }

/* --- Section jump bar (the sourced Workouts page) ----------------------- */
/* Sticky under the header so it stays reachable in a long library. Scroll
   padding keeps a jumped-to heading clear of it rather than under it. */
html { scroll-padding-top: 64px; }

/* --- Comparing pieces ---------------------------------------------------- */
/* **The head-to-head chart's geometry, and it is a CONTRACT.** The iOS twin is
   `AppTheme.Chart`'s "Two pieces, head to head" block, which draws the same
   chart from the same numbers; change one and change the other in the same
   commit. The page's script READS these rather than respelling them, so a
   value that moves here moves the drawing. */
:root {
  --h2h-pane-h: 150px;
  --h2h-pane2-h: 80px;
  --h2h-average-w: 2.4;
  --h2h-stroke-w: 1.4;
  --h2h-stroke-o: 0.22;
  --h2h-swatch-h: 3px;
  /* The FASTER piece's ink — the app's GO, not a band. The other piece is
     `currentColor`; there are no dashes. It was `--success` (#4ade80 dark),
     which is "this worked" and was chosen before the material had a green of
     its own; the drawing writes this line in `GO` and `--go` IS that green on
     both themes. One green for the primary rung and for the piece that won. */
  --h2h-faster: var(--go);
  /* HAND-SYNCED with AppTheme.Palette.heart / .heartDim — the ♥ overlay's two
     reds (bright = the faster piece, dim-dashed = the other), the phone's
     Option 2 (Martin, 2026-08-31). It said "heart resolves Apple's systemRed,
     which ADAPTS (#FF3B30 light, #FF453A dark)" — true until 2026-09-11, when
     the material gave the heart its own red (#DC2F4B / #FF5A6E). This token
     reads `--heart`, so it followed; the sentence did not. heartDim is
     (0.72, 0.30, 0.40) and is one value on both platforms. */
  --h2h-heart: var(--heart);
  --h2h-heart-dim: #B84D66;
  /* The crosshair's dot, as iOS states it: `symbolSize` is an AREA in pt², so
     the page takes its radius from `√(a/π)` rather than carrying a second
     number that could drift from it. The rule under it is textSecondary at
     half strength, 1 unit wide — the phone's `RuleMark`. */
  --h2h-crosshair-dot: 42;
  --h2h-crosshair-rule-o: 0.5;
  --h2h-crosshair-rule-w: 1;
  /* How far past the readings the y scale runs — a share of their SPAN, which
     is what keeps two close curves from flattening into one line
     (`ChartDomain.Padding.ofSpan`, `Chart.compareDomainPad`). */
  --h2h-domain-pad: 0.10;
  /* The gridlines behind a pane, and how many. `AttemptTrendChart` draws
     `gridLines + 1` and labels every one; so does this. */
  --h2h-grid-o: 0.08;
  --h2h-grid-lines: 3;
  /* The dot that says which effort a readout row is — the phone's `pieceDot`,
     10 pt of the graph's own ink. */
  --h2h-readout-dot: 10px;
}

/* The picker row: exclusive on the left, additive on the right, so the two
   tiers read as two tiers even when they wrap. */
.mpick {
  display: flex; flex-wrap: wrap; gap: var(--sp-3);
  align-items: center; justify-content: space-between;
  padding: var(--sp-3) var(--sp-4);
}

/* The two pieces, named. `--h2h-a` / `--h2h-b` are the two INKS the whole
   screen is drawn in: solid for this piece, dashed for the other. Not two
   hues — `comparisonBaseline` is near-white and unusable as a series colour,
   and the ramp's colours mean intensity, which a series identity is not. The
   two swatches beside the names ARE the legend. */
/* THE TWO HEADS ------------------------------------------------------- 4.5
   **A band of two cells, each wearing its own line at the top.** It was two
   loose columns with a 3 px swatch UNDER each — a legend printed below the
   thing it labels, which is the one place it cannot be read as belonging to
   it. The drawing runs the line along the top edge of the cell
   (`box-shadow: inset 0 3px 0`), which makes the cell itself the swatch, and
   divides the two with the band's own hairline instead of a 16 px gap. */
.h2h-heads { display: grid; grid-template-columns: 1fr 1fr; gap: 0; }
.h2h-head { padding: var(--sp-35) var(--band-gutter);
  box-shadow: inset 0 var(--h2h-swatch-h) 0 var(--text); }
/* `--border`, not the hairline: it divides two CELLS, not two rows, and at
   6 % it was lighter than the band's own edge on the same element
   (`gen.py`: `border-right:1px solid {BD}`). */
.h2h-head + .h2h-head { border-left: 1px solid var(--border); }
/* The caps label, the 10 px step every other tracked-caps label on the site
   takes (3.6). */
.h2h-head .lbl { color: var(--muted); font-size: var(--text-3xs);
  text-transform: uppercase; letter-spacing: var(--band-head-tracking);
  font-weight: var(--weight-heavy); }
.h2h-head .val { font-size: var(--text-2xl); font-weight: var(--weight-semibold);
  letter-spacing: -0.03em; }
.h2h-head .when { color: var(--ink-faint); font-size: var(--text-xs); }
/* The state is a class on the CELL. It was a `<span class="h2h-swatch">` the
   rules reached through `:has()` — an element that drew nothing, measured 0×0
   and existed only to be selected, which is a hook pretending to be a
   component. */
.h2h-head.faster { box-shadow: inset 0 var(--h2h-swatch-h) 0 var(--h2h-faster); }
.h2h-head.faded { opacity: 0.55; }
/* The split table: each metric cell stacks this piece over the other, so the
   columns stay narrow enough for pace + Δ + spm + ♥ + the running total. */
.h2h-parts td, .h2h-parts th { font-size: var(--text-xs); }
.h2h-parts .stack b { display: block; font-weight: 600; line-height: 1.15; }
.h2h-parts .stack i { display: block; font-style: normal; color: var(--muted);
  font-size: calc(var(--text-xs) - 1px); line-height: 1.1; }
.h2h-parts .tot { background: var(--wash, color-mix(in srgb, var(--text) 4%, transparent)); }
.h2h-parts .tpos { color: var(--success); }
.h2h-parts .tneg { color: var(--error); }
.h2h-parts .dneg { color: var(--muted); }
.h2h-parts .hide { display: none; }
.h2h-parts tfoot td { border-top: 2px solid var(--border); font-weight: 700; }

/* The scrub: a crosshair on the chart, and a docked readout of every measure
   at that one instant. Hidden until a first drag; the hint stands in for it. */
.h2h-pane { position: relative; touch-action: none; }
.h2h-scrub { background: var(--wash, color-mix(in srgb, var(--text) 5%, transparent));
  border-radius: var(--r-md); padding: var(--sp-3) var(--sp-4); margin-top: var(--sp-2); }
.h2h-scrub-head { display: flex; justify-content: space-between; align-items: baseline;
  gap: var(--sp-2); margin-bottom: var(--sp-2); }
.h2h-scrub-head .pos { font-size: var(--text-lg); font-weight: 700;
  font-variant-numeric: tabular-nums; }
.h2h-scrub-head .lead { font-size: var(--text-sm); font-weight: 600; color: var(--muted); }
.h2h-scrub-head .lead.a { color: var(--success); }
.h2h-scrub table { width: 100%; border-collapse: collapse; }
.h2h-scrub td, .h2h-scrub th { font-size: var(--text-sm); padding: var(--sp-1) 0;
  font-variant-numeric: tabular-nums; text-align: right; }
/* TRANSPOSED (Martin, 2026-08-31): a ROW per effort, a COLUMN per measure. The
   layout before this put one piece green and the other ink in adjacent cells,
   so a row of numbers was two colours and hard to follow. Now every number is
   one ink and a dot at the leading edge — the graph's own two inks — says
   which effort the row is. */
.h2h-scrub th { font-size: var(--text-2xs); font-weight: 400; color: var(--muted); }
.h2h-scrub td:first-child, .h2h-scrub th:first-child {
  text-align: left; width: var(--sp-5); color: var(--muted);
  font-size: var(--text-2xs); }
.h2h-scrub tr.mine td { font-weight: var(--weight-semibold); }
.h2h-scrub tr.delta td { color: var(--muted); }
.h2h-scrub .dot { display: inline-block; width: var(--h2h-readout-dot);
  height: var(--h2h-readout-dot); border-radius: 50%; background: currentColor;
  color: var(--text); }
.h2h-scrub .dot.faster { color: var(--h2h-faster); }
.h2h-scrub td.d { color: var(--muted); }
/* Placement only — the button itself is the ghost rung (`.btn.ghost`), not a
   seventh hand-rolled kind. */
.scrub-clear { display: block; margin: var(--sp-2) 0 0 auto; }
/* **THE ANSWER, at the size of an answer.** It was `--text-md` — the size of
   a list row's name — for the one number this whole page computes. The drawing
   gives it the display step and sets the sentence beside it rather than under
   it, so the two read as one line: `−14 W · on smart average watts`.

   The drawing's `.big` at 72 — `--text-display-xl` (2026-09-14). */
.h2h-delta { display: flex; align-items: baseline; gap: var(--sp-45);
  flex-wrap: wrap; padding: var(--sp-4) var(--sp-4) var(--sp-3);
  font-size: var(--text-display-xl); font-weight: var(--weight-semibold);
  letter-spacing: var(--display-tracking); line-height: var(--display-leading);
  font-variant-numeric: tabular-nums; }
.h2h-delta small { font-weight: var(--weight-medium); color: var(--muted);
  font-size: var(--text-md); letter-spacing: normal; }
/* **Which side won**, in the tokens this file declares for the question — the
   same pair the part table's own deltas take. The number was plain ink beside
   a coloured `+4` two sections down, on one page. A TIE takes neither. */
.h2h-delta.good { color: var(--diff-good); }
.h2h-delta.bad { color: var(--diff-bad); }
.h2h-delta.good small, .h2h-delta.bad small { color: var(--muted); }

/* A pane is one plot. The primary and the accompanying ♥/spm share the x axis
   and NEVER a y axis — a second scale on one plot invites reading a crossing
   that means nothing. */
/* The plot is inset by a GUTTER on the leading edge, which is where the tick
   labels live — over the curves they would be reading the numbers off the
   thing they describe. The trailing gutter is opened only while ♥ rides the
   plot and its bpm scale needs a column. Both panes carry the leading inset,
   whether or not they are labelled, so the two share one x axis. */
.h2h-pane { padding: var(--sp-2) var(--sp-4);
  padding-left: calc(var(--sp-4) + var(--sp-6)); }
.h2h-pane.with-hr { padding-right: calc(var(--sp-4) + var(--sp-6)); }
.h2h-pane svg { display: block; width: 100%; }
.h2h-axis { display: flex; justify-content: space-between; gap: var(--sp-2);
  color: var(--muted); font-size: var(--text-xs); padding-top: var(--sp-1); }
/* **The y scale, read off the gridlines.** The labels ride OVER the plot
   rather than inside the SVG, which is `preserveAspectRatio="none"` and would
   stretch type with the viewport; its height attribute is the pane's, so a
   user unit is a pixel down the y axis and a label sits on its own line. The
   trailing column is bpm, in red, while ♥ rides the plot. */
.h2h-ticks { position: absolute; top: var(--sp-2); left: 0;
  width: calc(var(--sp-4) + var(--sp-6) - var(--sp-2)); text-align: right;
  pointer-events: none; }
.h2h-ticks.right { left: auto; right: 0; text-align: left; }
/* Centred on its line by its own half-line rather than a transform: the
   stylesheet's standing rule is that nothing translates, and `line-height: 1`
   makes `-0.5em` exactly half the label's box. */
.h2h-ticks span { position: absolute; line-height: 1; margin-top: -0.5em;
  right: var(--sp-2); white-space: nowrap; color: var(--muted);
  font-size: var(--text-2xs); font-variant-numeric: tabular-nums; }
.h2h-ticks.right span { right: auto; left: var(--sp-2); }
/* The pane picker and the Clear share ONE row under the readout, so they line
   up (Martin, 2026-08-31). Clear is plain tertiary text with a glyph — not a
   rung, because it undoes a reading rather than doing anything. */
.h2h-under { display: flex; align-items: center; gap: var(--sp-3);
  padding: var(--sp-2) var(--sp-4) var(--sp-3); }
.h2h-under .mpick { padding: 0; }
.h2h-clear { margin-left: auto; display: inline-flex; align-items: center;
  gap: var(--sp-1); background: none; border: 0; cursor: pointer; padding: 0;
  color: var(--muted); font: inherit; font-size: var(--text-2xs);
  font-weight: var(--weight-semibold); }

/* A rep of one shape, picked. Two at most; the wash is the whole row wide
   because the marking is the row, not a cell in it — iOS's `accent` at 9 %. */
.pick-table tbody tr[aria-selected="true"] { background: var(--pick-wash); }
/* The fixed dimension is the rep's RESULT, so it carries the weight; the pace
   and the heart rate beside it are secondary, as they are on the phone. */
.pick-table td.strong { font-weight: var(--weight-semibold); }
/* How a rep was rowed sits in the slot the attempts table keeps for `v2`, at
   the caption size the phone gives it — it is there to be read, not weighed. */
.pick-table .ctx { margin-left: var(--sp-1); font-size: var(--text-2xs);
  white-space: nowrap; }
.pick-table .door { width: var(--sp-6); }
.pick-table .door a { display: inline-flex; color: var(--muted); }
/* The row is the target (Martin, 2026-09-14: "you cannot select a session by
   clicking on the row"). The pick link's box covers its row; the door sits
   above it, because the door is the one thing on the row that is not a pick. */
.pick-table tbody tr { position: relative; cursor: pointer; }
.pick-table tbody tr:hover { background: color-mix(in srgb, var(--text) 4%, transparent); }
.pick-table tbody tr[aria-selected="true"]:hover { background: var(--pick-wash); }
.pick-table .pick-link::after { content: ""; position: absolute; inset: 0; }
.pick-table .door a { position: relative; z-index: 1; }

/* **The compare card** — the black card, doing what that card does: one
   control, carrying what it is about, leading somewhere. Before two reps are
   picked it is the SAME card, dimmed and inert, saying how to light it up
   (Martin, 2026-08-31), so the door never jumps in when the second pick
   lands. */
.compare-card { display: flex; align-items: baseline; gap: var(--sp-3);
  margin: var(--sp-3) var(--sp-4); padding: var(--sp-4);
  border-radius: var(--r-lg); background: var(--text); color: var(--on-ink);
  text-decoration: none; }
a.compare-card:hover { text-decoration: none; }
.compare-card[aria-disabled="true"] { opacity: var(--btn-disabled-opacity); }
.compare-card > span:first-child { flex: 1; min-width: 0; }
.compare-card .cc-title { display: block; font-size: var(--text-sm-plus);
  font-weight: var(--weight-semibold); }
.compare-card .cc-sub { display: block; margin-top: var(--sp-05);
  font-size: var(--text-xs); opacity: 0.7; }
.compare-card .cc-go { margin-left: auto; width: .5em; height: .5em;
  flex: none; border: 0 solid currentColor; border-width: 1.5px 1.5px 0 0;
  rotate: 45deg; opacity: 0.7; }

/* **The head's rep picker** — the phone's `PiecePickerSheet`, which is the
   shape's own table in a sheet, "so the numbers you choose from are the
   numbers you just saw". The piece already on the other side is dimmed and
   says so rather than being dropped, because a list that silently loses a row
   is a list you cannot count. */
.h2h-head { position: relative; }
.rep-pick > summary { display: flex; align-items: center; gap: var(--sp-1);
  cursor: pointer; list-style: none; color: var(--accent); }
.rep-pick > summary::-webkit-details-marker { display: none; }
.rep-pick > summary .val { color: var(--text); }
.rep-list { position: absolute; z-index: 5; margin-top: var(--sp-1);
  min-width: 15rem; max-width: 92vw; max-height: 60vh; overflow-y: auto;
  background: var(--surface-opaque); border: 1px solid var(--border);
  border-radius: var(--r-md); box-shadow: var(--shadow); }
/* The Against head's list hangs from the RIGHT, so neither runs off a phone. */
.h2h-head:last-child .rep-list { right: 0; }
/* The sheet's own row: date over context on the left, the figure and the pace
   trailing — `PiecePickerSheet`'s HStack, so the numbers you choose from are
   laid out like the numbers you just read. */
.rep-list .rep { display: grid; grid-template-columns: 1fr auto auto;
  gap: 0 var(--sp-3); padding: var(--sp-2) var(--sp-3); text-decoration: none;
  color: var(--text); border-top: 1px solid var(--border);
  min-height: var(--control-h-tap); align-content: center; }
.rep-list .rep:first-child { border-top: 0; }
.rep-list .rep:hover { background: color-mix(in srgb, var(--text) 4%, var(--card)); }
.rep-list .rep.taken { opacity: 0.4; pointer-events: none; }
.rep-list .rp-when { grid-area: 1 / 1; font-size: var(--text-sm-plus);
  white-space: nowrap; }
.rep-list .rp-how { grid-area: 2 / 1; font-size: var(--text-xs); }
.rep-list .rp-fig { grid-area: 1 / 2 / span 2 / span 1; align-self: center;
  font-weight: var(--weight-semibold); font-variant-numeric: tabular-nums;
  text-align: right; white-space: nowrap; }
.rep-list .rp-pace { grid-area: 1 / 3 / span 2 / span 1; align-self: center;
  font-size: var(--text-xs); text-align: right; white-space: nowrap;
  font-variant-numeric: tabular-nums; }
.h2h-head .when a { display: inline-flex; align-items: center; gap: var(--sp-1);
  color: var(--muted); }

/* The sort switch over the rep table — `Sort by: Best | Date`. */
.sort-row { display: flex; align-items: center; gap: var(--sp-2);
  padding: var(--sp-4) var(--sp-4) var(--sp-2); color: var(--muted);
  font-size: var(--text-sm); }

/* The halo on a picked dot, and the door a rep's dot opens. Ink, so the chart
   stays one colour — the rule the rest of it already follows. */
.trend-ring { fill: none; stroke: var(--text); }



/* ── COMPARE, REP BY REP ─────────────────────────────────────────────── 01
   `/sessions/{id}/compare` — this session against another attempt at its
   workout (Martin, 2026-09-14: "I want the drawing"). The drawing's columns:
   the heads, the answer, the strip and the race on the left; the rep table in
   a 420 px aside. Go-green is this session, cyan the other, red a lost rep —
   one attempt, one colour, everywhere on the page. */
.cmp-cols { display: grid; grid-template-columns: minmax(0, 1fr) var(--page-aside);
  gap: 0 var(--shell-gutter); max-width: var(--page-max); }
.cmp-main, .cmp-side { min-width: 0; }
@media (max-width: 64rem) {
  .cmp-cols { grid-template-columns: minmax(0, 1fr); }
}
.cmp-heads { display: grid; grid-template-columns: 1fr 1fr; }
.cmp-head { position: relative; min-width: 0; padding: var(--sp-35) var(--band-gutter); }
.cmp-head + .cmp-head { border-left: 1px solid var(--border); }
.cmp-head.this { --cmp-ink: var(--go); box-shadow: inset 0 var(--cmp-edge) 0 var(--go); }
.cmp-head.best { --cmp-ink: var(--rest); box-shadow: inset 0 var(--cmp-edge) 0 var(--rest); }
.cmp-label { display: flex; align-items: center; gap: var(--sp-15); color: var(--muted); }
.cmp-swatch { flex: none; width: var(--sp-25); height: var(--cmp-edge);
  border-radius: var(--bar-seg-r); background: var(--cmp-ink); }
.cmp-label .lbl { font-size: var(--text-3xs); font-weight: var(--weight-heavy);
  text-transform: uppercase; letter-spacing: var(--band-head-tracking); color: var(--muted); }
.cmp-label .when { margin-left: auto; font-size: var(--text-2xs); white-space: nowrap; }
.cmp-pick > summary { list-style: none; cursor: pointer; }
.cmp-pick > summary::-webkit-details-marker { display: none; }
.cmp-pick .rep-list { right: var(--band-gutter); }
.rep-group { padding: var(--sp-2) var(--sp-3) var(--sp-1); font-size: var(--text-3xs);
  font-weight: var(--weight-heavy); text-transform: uppercase;
  letter-spacing: var(--band-head-tracking); color: var(--muted); }
.cmp-head-v { margin-top: var(--sp-15); font-size: var(--cmp-head-value);
  font-weight: var(--weight-semibold); letter-spacing: -0.03em;
  font-variant-numeric: tabular-nums; white-space: nowrap; }
.cmp-head-sub { margin-top: var(--sp-05); font-size: var(--text-xs); color: var(--muted); }
/* The answer takes the display step with its sentence UNDER it, as the
   drawing sets it — go and danger, the pair every verdict below wears. */
.h2h-delta.cmp-delta { padding: var(--sp-5) var(--sp-4) 0; }
.h2h-delta.cmp-delta small { flex-basis: 100%; font-size: var(--text-base); }
.h2h-delta.cmp-delta.good { color: var(--go); }
.h2h-delta.cmp-delta.bad { color: var(--danger); }
.cmp-unit { font-size: 0.4em; font-weight: var(--weight-medium); letter-spacing: normal; }
.cmp-note { margin: 0; padding: var(--sp-4) var(--sp-4) 0; color: var(--muted); font-size: var(--text-sm); }
.cmp-strip { margin-top: var(--sp-45); padding: var(--sp-35) var(--band-gutter) var(--sp-3); }
.cmp-blocks { display: flex; gap: var(--bar-gap-live); margin-top: var(--sp-1); }
/* The block is 11 px; the thing you aim at is a control's height. */
.cmp-block { flex: 1; display: block;
  padding-block: calc((var(--control-h) - var(--cmp-strip-h)) / 2); }
.cmp-block::before { content: ""; display: block; height: var(--cmp-strip-h);
  border-radius: var(--bar-seg-r); }
/* Only the WINS glow: a lit block and a flat one is a second reading of the
   strip — which reps you took, before you have counted the colours. */
.cmp-block.won::before { background: var(--go);
  box-shadow: 0 0 var(--sp-25) color-mix(in srgb, var(--go) 53%, transparent); }
.cmp-block.lost::before { background: var(--danger); }
.cmp-block.none::before { background: repeating-linear-gradient(45deg,
  var(--band-hatch) 0 var(--bar-hatch-stripe),
  transparent var(--bar-hatch-stripe) var(--bar-hatch-period)); }
.cmp-block.on::before { outline: 2px solid var(--text); outline-offset: 1px; }
.cmp-strip-note { margin: 0; font-size: var(--text-sm); color: var(--muted); }
.cmp-race { padding: var(--sp-3) var(--band-gutter) var(--sp-2); }
.cmp-readout { display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--sp-2); min-height: var(--cmp-readout-h); margin-bottom: var(--sp-25);
  padding: var(--sp-25) var(--sp-35); border: 1px solid var(--card-border);
  border-radius: var(--r-lg); background: color-mix(in srgb, var(--text) 3.5%, transparent);
  font-variant-numeric: tabular-nums; }
.cmp-readout b { font-size: var(--text-sm); }
.cmp-readout .this, .cmp-readout .best, .cmp-readout .d {
  font-size: var(--text-base); font-weight: var(--weight-bold); }
.cmp-readout .this { color: var(--go); }
.cmp-readout .best { color: var(--rest); }
.cmp-readout .vs { font-size: var(--text-xs); color: var(--muted); }
.cmp-readout .d { font-weight: var(--weight-heavy); }
.cmp-readout .d.won, .cmp-reps .d.won { color: var(--go); }
.cmp-readout .d.lost, .cmp-reps .d.lost { color: var(--danger); }
/* Inset by the drawing's 10 so the first and last dot are not half-clipped;
   the gridlines reach back out to the band's padding. */
.cmp-plot { position: relative; height: var(--h2h-pane-h); margin-inline: var(--sp-25); }
.cmp-plot svg { position: absolute; inset: 0; width: 100%; height: 100%; overflow: visible; }
.cmp-plot path { fill: none; vector-effect: non-scaling-stroke;
  stroke-linejoin: round; stroke-linecap: round; }
.cmp-plot path.wedge { fill: var(--text); fill-opacity: var(--cmp-wedge-o); stroke: none; }
.cmp-plot path.best { stroke: var(--rest); stroke-width: var(--cmp-best-w); }
.cmp-plot path.glow { stroke: var(--go); stroke-width: var(--cmp-glow-w); stroke-opacity: var(--cmp-glow-o); }
.cmp-plot path.this { stroke: var(--go); stroke-width: var(--cmp-this-w); }
.cmp-grid { position: absolute; left: calc(-1 * var(--sp-25)); right: calc(-1 * var(--sp-25));
  border-top: 1px solid var(--hairline); }
.cmp-grid span { position: absolute; right: 0; bottom: var(--sp-05);
  font-size: var(--text-3xs); color: var(--muted); }
.cmp-scrub { position: absolute; top: 0; bottom: 0;
  border-left: 1px dashed color-mix(in srgb, var(--text) 55%, transparent); }
.cmp-dot { position: absolute; width: var(--sp-3); height: var(--sp-3);
  margin: calc(var(--sp-3) / -2) 0 0 calc(var(--sp-3) / -2);
  border: 2px solid var(--bg); border-radius: 50%; }
.cmp-dot.this { background: var(--go); }
.cmp-dot.best { background: var(--rest); }
.cmp-x { position: relative; height: var(--sp-45); margin: var(--sp-1) var(--sp-25) 0;
  font-size: var(--text-2xs); color: var(--muted); font-variant-numeric: tabular-nums; }
/* Centred on its point with no transform — a zero-width box whose number
   overflows it evenly — because this sheet moves nothing
   (`test_the_stylesheet_does_not_move_anything`). */
.cmp-x span { position: absolute; width: 0; display: flex; justify-content: center;
  white-space: nowrap; }
.cmp-legend { display: flex; gap: var(--sp-4); padding: var(--sp-1) 0 var(--sp-05);
  font-size: var(--text-xs); color: var(--muted); }
.cmp-legend i { display: inline-block; width: var(--sp-35); height: var(--cmp-edge);
  margin-right: var(--sp-15); border-radius: var(--bar-seg-r); vertical-align: middle; }
.cmp-legend i.this { background: var(--go); }
.cmp-legend i.best { background: var(--rest); }
.cmp-legend .r { margin-left: auto; }
.cmp-reps { --tr-cols: var(--rep-n-col) var(--rep-v-col) var(--rep-v-col) minmax(0, 1fr) var(--rep-d-col); }
.cmp-reps .tr { min-height: var(--rep-row-h); color: var(--text); text-decoration: none;
  font-variant-numeric: tabular-nums; }
.cmp-reps .tr.hd { min-height: var(--rep-head-h); }
.cmp-reps .r { text-align: right; }
.cmp-reps .c { text-align: center; }
.cmp-reps .n { font-size: var(--text-sm); color: var(--muted); }
.cmp-reps .d { font-weight: var(--weight-bold); }
/* The Δ as a bar on a centreline, every row to the widest difference's scale
   — at 58 % of the half, so the column never reads heavier than its numbers. */
.cmp-bar { position: relative; height: var(--rep-bar-track); }
.cmp-bar::before { content: ""; position: absolute; top: 0; bottom: 0; left: 50%;
  border-left: 1px solid color-mix(in srgb, var(--text) 18%, transparent); }
.cmp-bar i { position: absolute; top: 50%; height: var(--rep-bar-h);
  margin-top: calc(var(--rep-bar-h) / -2); border-radius: var(--bar-seg-r); opacity: 0.7;
  width: calc(50% * var(--rep-bar-share) * var(--f, 0)); }
.cmp-bar i.won { left: 50%; background: var(--go); }
.cmp-bar i.lost { right: 50%; background: var(--danger); }


/* ── CLUBS, TIDIED ─────────────────────────────────────────── 2026-09-14
   Martin, on the mockup: "looks good". Every block on a club page and on the
   Clubs page is a band with rows; the right column holds no full form. */
.cx-cap { margin: 0; padding: var(--sp-3) var(--band-gutter) var(--sp-35); font-size: var(--text-sm); }
.cx-invite .cx-code { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-25);
  padding: var(--sp-35) var(--band-gutter) var(--sp-1); }
.cx-invite .cx-code .label { margin: 0; }
.cx-invite .cx-sp { flex: 1; }
.cx-invite form { margin: 0; }
.cx-invite .cx-cap { padding-top: var(--sp-2); }
.cx-create { --tr-cols: minmax(0, 1fr) auto; }
.cx-create > summary { list-style: none; cursor: pointer; border-top: 0; }
.cx-create > summary::-webkit-details-marker { display: none; }
.cx-create-form { padding: 0 var(--band-gutter) var(--sp-4); }
.cx-create[open] > summary .btn { display: none; }
.cx-join { padding: var(--sp-4) var(--band-gutter); }
.cx-join form { margin: 0; }
.cx-join-line { display: flex; align-items: flex-end; gap: var(--sp-25); flex-wrap: wrap; }
/* A club code is eight characters: the line stops at the drawing's measure
   rather than stretching a field across the whole page. */
.cx-join .cx-join-line, .cx-join .cx-note, .cx-create-form { max-width: 40rem; }
.cx-join-line .field { flex: 1; min-width: 12rem; margin: 0; }
.cx-note { margin-top: var(--sp-25); }
.cx-note > summary { display: inline-flex; align-items: center; gap: var(--sp-1); cursor: pointer;
  color: var(--accent); font-size: var(--text-sm); font-weight: var(--weight-semibold); list-style: none; }
.cx-note > summary::-webkit-details-marker { display: none; }
.cx-note[open] > summary { margin-bottom: var(--sp-2); }
.cx-gives { --tr-cols: 32px minmax(0, 1fr); }
.cx-gives > li + li { border-top: 1px solid var(--hairline); }
.mem-av { display: inline-grid; place-items: center; flex: none; width: 32px; height: 32px;
  border-radius: 50%; background: var(--track); box-shadow: inset 0 0 0 1px var(--card-border);
  color: var(--muted); font-size: var(--text-3xs); font-weight: var(--weight-heavy); }
.member-item .mem-av { margin-right: var(--sp-3); }


/* ── BANDS EVERYWHERE ─────────────────────────────────────── 2026-09-14
   Martin: "Bands everywhere". Settings, a session's page and a workout's page
   drew their sections straight on the ground while every list page sat its
   rows on a band. A section body that is a band takes the band's surface and
   edges; a `.band` of rows INSIDE one stops being a second band and becomes
   its rows, reaching the section's edges. */
/* `--band-gutter` is declared on `.band`, not on the page, so a section that
   BECOMES a band has to declare it too — without it the padding was invalid
   and every band's content sat on its left edge. */
/* `.sect-body.sect-band`, two classes: `.sx-main > .sect-body` zeroes the
   session column's inset, and a one-class band lost to it. */
.sect-body.sect-band { --band-gutter: 20px; background: var(--surface); border-top: 1px solid var(--border);
  border-bottom: 1px solid var(--border); padding: var(--sp-35) var(--band-gutter);
  margin: 0 0 var(--sp-6); }
.sect-band .band { background: none; border: 0; margin: 0 calc(-1 * var(--band-gutter));
  --band-gutter: 20px; }
.sect-band .band + p, .sect-band p + .band { margin-top: var(--sp-3); }
.sect-band > :last-child { margin-bottom: 0; }
/* A choice of one as a segment: radios in the segment's own cells, so the
   form still submits the value and a keyboard still moves between them. */
.seg-radio label { position: relative; display: inline-flex; }
.seg-radio input { position: absolute; inset: 0; opacity: 0; margin: 0; cursor: pointer; }
.seg-radio span { display: inline-flex; align-items: center; border-radius: var(--r-pill);
  font-size: var(--text-sm); font-weight: var(--weight-bold); color: var(--muted);
  min-height: var(--seg-pill-h); padding: 0 var(--sp-4); white-space: nowrap; }
.seg-radio input:checked + span { background: var(--text); color: var(--on-ink); }
.seg-radio input:focus-visible + span { box-shadow: var(--focus-ring); }


/* ── THE EDITOR IS THE PLAN TABLE ─────────────────────────── 2026-09-14
   4.11's remainder. The builder drew a card per block with a header bar; the
   drawing edits the workout page's own plan table: the repeat's count in the
   gutter with its rail and dot, the group's ± beside its first step, the
   chosen step as the row highlight, and dashed rows where a step can go. */
.ed-plan { padding: 0; }
.ed-plan .builder-block { margin: 0; background: none; border: 0;
  border-top: 1px solid var(--hairline); }
.ed-plan .builder-block:first-child { border-top: 0; }
.ed-plan .builder-block + .builder-block { margin-top: 0; }
.ed-plan .builder-block.repeating { background: var(--plan-group-wash); }
.ed-plan .step-row { display: grid; align-items: center; gap: var(--sp-3);
  grid-template-columns: var(--plan-gutter) var(--chip-w) minmax(0, 1fr) auto minmax(6.5em, auto) 12px;
  min-height: 48px; padding: 0 var(--band-gutter) 0 0; border-top: 1px solid var(--hairline); }
.ed-plan .builder-block.repeating .step-row,
.ed-plan .builder-block.repeating .step-open .step-row { padding-left: 0; }
.ed-plan .entries > .step-row:first-child,
.ed-plan .entries > .step-open:first-child > .step-row { border-top: 0; }
.ed-plan .step-row .plan-gutter { display: grid; place-items: center; align-self: stretch; }
.ed-plan .step-row .plan-chip { height: 18px; }
.ed-plan .step-what { display: flex; align-items: center; gap: var(--sp-25); min-width: 0; }
.ed-plan .step-row .step-lbl { font-weight: var(--weight-semibold); }
.ed-plan .step-row.rest .step-lbl { color: var(--muted); }
.ed-plan .step-amt { color: var(--muted); text-align: right; white-space: nowrap;
  font-variant-numeric: tabular-nums; }
.ed-plan .step-row .step-tgt { text-align: right; }
.ed-plan .step-row.open-hero { background: color-mix(in srgb, var(--text) 8%, transparent);
  box-shadow: inset 4px 0 0 var(--text); }
.ed-plan .step-row.open-hero .step-cv { visibility: hidden; }
.ed-plan .step-amt .dur-value { width: 6em; text-align: right; }
.blk-tools { display: inline-flex; align-items: center; gap: var(--sp-15); }
.ed-plan .entry-add, .ed-plan .editor-actions {
  margin: 0; padding: var(--sp-15) var(--band-gutter) var(--sp-25) var(--plan-gutter); }
.ed-plan .entry-add .btn.insertion, .ed-plan .editor-actions .btn.insertion { width: 100%; }
.ed-plan .editor-actions { border-top: 1px solid var(--hairline); padding-top: var(--sp-25); }

/* At a phone's width the editor's row keeps what it needs to be edited — the
   step, the group's ± and ⋯, the amount — and leaves the target to the open
   step: in four columns ± and ⋯ ran over the amount ("5̶00 m"). */
@media (max-width: 30rem) {
  .ed-plan .step-row { grid-template-columns: var(--plan-gutter) var(--chip-w) minmax(0, 1fr) auto 12px; }
  .ed-plan .step-row .step-tgt { display: none; }
  .ed-plan .step-open-body { padding-left: var(--plan-gutter); }
}

/* A line of fields on a phone is a column of them: date fields 200 px wide
   with their Save squeezed in beside the last one read as a broken grid on a
   club's settings page. Each field takes the line; the buttons follow. */
@media (max-width: 30rem) {
  /* Two classes and `width` too: `.field-row .field { width: 180px }` sets a
     fixed width the flex basis alone does not beat, which left a series'
     own Name / Opens / Closes at 310 px inside a 400 px band. */
  .field-row > .field, .field-row .field { flex: 1 1 100%; width: 100%; }
}


/* ── INK BLOCKS ─────────────────────────────────────────────── 2026-09-15
   The iOS app's pick (App-Design-v3 a57c0eac; Martin, via the iOS session):
   every band bar and band chip is a block with a 3 px corner, the strip's
   gap is 3 px, and there is no top highlight. A work step is its band at full
   height. A rest — *nothing was written* — is NO hatch but a 4 px bar on the
   block's floor in a faint ink; a step with no 5 km to place it is a SOLID
   ink block. Compare's won/lost strip keeps its own explicit colours. */
:root {
  --ink-block-r: 3px;
  --ink-block-gap: 3px;
  --ink-rest-h: 4px;
  --ink-rest: rgba(22, 24, 29, 0.16);
  --ink-unplaceable: rgba(22, 24, 29, 0.88);
}
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --ink-rest: rgba(243, 246, 255, 0.20);
    --ink-unplaceable: rgba(243, 246, 255, 0.60);
  }
}
:root[data-theme="dark"] {
  --ink-rest: rgba(243, 246, 255, 0.20);
  --ink-unplaceable: rgba(243, 246, 255, 0.60);
}
.ibar { gap: var(--ink-block-gap); padding: 0; background: none; border-radius: 0;
  align-items: stretch; }
.ibar.live { gap: var(--ink-block-gap); }
.ibar-seg, .plan-chip, .band-chip, .log-name .band-chip { border-radius: var(--ink-block-r); }
.ibar-seg::after { display: none; }
.ibar-seg.band-unplaceable, .plan-chip.band-unplaceable,
.band-chip.band-unplaceable { background: var(--ink-unplaceable); }
/* The rest: a floor, not a texture. The block keeps its width (and so the
   bar keeps its time) and draws only its bottom 4 px. */
.ibar-seg.band-hatched, .plan-chip.band-hatched, .band-chip.band-hatched {
  background: linear-gradient(var(--ink-rest), var(--ink-rest)) bottom / 100% var(--ink-rest-h) no-repeat;
}


/* ── CLUB OVERVIEW ROWS ────────────────────────────────────── 2026-09-17
   A series and what is open in it now; the workouts in no series. The band
   sits under the name at a readable width, and the second column is the share
   of members who have rowed it. */
.cw-rows { --tr-cols: minmax(0, 1fr) 96px 170px 16px; }
/* The rows answer to their COLUMN, not the window: the club page's main column
   is ~590 px beside the aside on a laptop, and a window media query can't see
   that — the rows overflowed and their chevrons landed in the aside. */
.cx-main, .page-content-cq { container-type: inline-size; }
.cw-rows > li + li { border-top: 1px solid var(--hairline); }
.cw-row { color: var(--text); text-decoration: none; min-height: 72px;
  padding-block: var(--sp-3); font-variant-numeric: tabular-nums; border-top: 0; }
.cw-row:hover { background: color-mix(in srgb, var(--text) 3%, transparent); }
.cw-row.cur { box-shadow: inset 3px 0 0 var(--text); background: var(--surface-raised); }
.cw-name { display: flex; flex-direction: column; gap: var(--sp-15); min-width: 0; }
.cw-name .ibar { width: 240px; max-width: 100%; }
.cw-row > * { min-width: 0; }
.cw-place { font-size: var(--text-xs); font-weight: var(--weight-bold); color: var(--ink-faint); }
.cw-title { font-size: var(--text-base); font-weight: var(--weight-bold); }
.cw-pct, .cw-when { display: flex; flex-direction: column; gap: var(--sp-05); }
.cw-pct b { font-size: var(--text-lg); font-weight: var(--weight-heavy); }
.cw-pct span, .cw-when span { font-size: var(--text-xs); color: var(--muted); }
.cw-when b { font-size: var(--text-sm); font-weight: var(--weight-bold); }
.cw-when b.open { color: var(--text); }
.cw-when b.soon { color: var(--warning); }
.cw-when .you { color: var(--go-deep); font-weight: var(--weight-bold); }
.cw-foot { display: flex; align-items: center; gap: var(--sp-25); min-height: 48px;
  padding: 0 var(--band-gutter); border-top: 1px solid var(--hairline);
  font-size: var(--text-sm); color: var(--muted); text-decoration: none; }
.cw-foot b { color: var(--text); }
.cw-foot .cx-sp { flex: 1; }
.cw-foot .go-to { color: var(--text); font-weight: var(--weight-bold); }
.cw-none { padding: var(--sp-35) var(--band-gutter); font-size: var(--text-sm); color: var(--muted); margin: 0; }
.cf-rows { --tr-cols: minmax(0, 1fr) auto 150px; }
.cf-rows > li + li { border-top: 1px solid var(--hairline); }
.cf-row { min-height: 60px; border-top: 0; font-variant-numeric: tabular-nums; }
.cf-row .cf-what { display: flex; flex-direction: column; gap: var(--sp-05); min-width: 0; }
.cf-row .cf-what a { color: var(--text); font-weight: var(--weight-bold); text-decoration: none; }
.cf-row .cf-note { font-size: var(--text-xs); color: var(--muted); }
.cf-row .cf-note.miss { color: var(--danger); }
.cf-row .cf-val { font-size: var(--text-base); font-weight: var(--weight-heavy); text-align: right; }
.cf-row .cf-when { font-size: var(--text-xs); color: var(--ink-faint); text-align: right; }
@container (max-width: 640px) {
  /* Two lines: the name and its band, then when — with the share and the door
     on the right spanning both. */
  .cw-rows { --tr-cols: minmax(0, 1fr) auto 16px; }
  .cw-row { grid-template-rows: auto auto; row-gap: var(--sp-15); }
  .cw-name { grid-column: 1; grid-row: 1; }
  .cw-when { grid-column: 1; grid-row: 2; flex-direction: row; flex-wrap: wrap; gap: 0 var(--sp-25);
    align-items: baseline; }
  .cw-pct { grid-column: 2; grid-row: 1 / span 2; text-align: right; }
  .cw-row > .tr-cv { grid-column: 3; grid-row: 1 / span 2; }
  .cf-rows { --tr-cols: minmax(0, 1fr) auto; }
  .cf-row { grid-template-rows: auto auto; padding-block: var(--sp-25); }
  .cf-row .cf-what { grid-column: 1; grid-row: 1; }
  .cf-row .cf-val { grid-column: 2; grid-row: 1; }
  .cf-row .cf-when { grid-column: 1 / span 2; grid-row: 2; text-align: left; }
}
.cx-all > summary { list-style: none; cursor: pointer; }
.cx-all > summary::-webkit-details-marker { display: none; }

/* ── A SERIES PAGE ─────────────────────────────────────────── 2026-09-17 */
.sr-desc { margin: calc(-1 * var(--sp-3)) 0 var(--sp-4); }
.sr-of { font-size: var(--text-sm); color: var(--muted); font-weight: var(--weight-medium); }
.cw-rows > li.sr-later .cw-title, .cw-rows > li.sr-later .ibar { opacity: 0.45; }
.sr-grid-wrap { overflow-x: auto; }
.sr-grid { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums;
  font-size: var(--text-sm); }
.sr-grid th, .sr-grid td { padding: var(--sp-2) var(--sp-15); text-align: left; vertical-align: middle; }
.sr-grid th:first-child, .sr-grid td:first-child { padding-left: var(--band-gutter); }
.sr-grid th:last-child, .sr-grid td:last-child { padding-right: var(--band-gutter); }
.sr-grid tbody tr { border-top: 1px solid var(--hairline); }
.sr-grid tbody tr.me { background: color-mix(in srgb, var(--text) 3%, transparent); }
.sr-grid .sr-rank { width: 28px; color: var(--ink-faint); font-weight: var(--weight-bold); }
.sr-grid .sr-who { min-width: 160px; font-weight: var(--weight-bold); white-space: nowrap; }
/* **Rank and name stay put while the workouts scroll** (Martin, 2026-09-30).
   The rank cell has a fixed border-box width so the name knows where to pin;
   both paint the band's own surface (and the "me" tint) so columns slide under. */
.sr-grid .sr-rank, .sr-grid .sr-who { position: sticky; z-index: 1;
  background: linear-gradient(var(--surface), var(--surface)), var(--bg); }
.sr-grid .sr-rank { left: 0; box-sizing: border-box;
  width: calc(28px + var(--band-gutter) + var(--sp-15));
  min-width: calc(28px + var(--band-gutter) + var(--sp-15)); }
.sr-grid .sr-who { left: calc(28px + var(--band-gutter) + var(--sp-15));
  box-shadow: inset -1px 0 0 var(--hairline); }
.sr-grid tr.me .sr-rank, .sr-grid tr.me .sr-who {
  background: linear-gradient(color-mix(in srgb, var(--text) 3%, transparent), color-mix(in srgb, var(--text) 3%, transparent)),
    linear-gradient(var(--surface), var(--surface)), var(--bg); }
.sr-grid td.sr-who .sr-name, .sr-grid td.sr-who span { display: block; overflow: hidden;
  text-overflow: ellipsis; white-space: nowrap; }
.sr-grid td.sr-who .sr-name { font-size: inherit; font-weight: inherit; color: inherit; }
.sr-grid .sr-who span { display: block; font-size: var(--text-xs); font-weight: var(--weight-medium); color: var(--muted); }
.sr-grid th { font-weight: var(--weight-medium); vertical-align: bottom; }
.sr-grid th.sr-who, .sr-grid th.sr-prog { font-size: var(--text-xs); letter-spacing: .06em;
  text-transform: uppercase; color: var(--muted); }
.sr-col { min-width: 8em; }
.sr-col a { display: flex; flex-direction: column; gap: var(--sp-1); color: var(--text); text-decoration: none; }
/* **Columns as narrow as their content** (Martin, 2026-09-30: "shrink the
   boxes when possible"). The bar set every column to 200 px; a result box
   needs only its time and "Wed 16 Sep", so the bar now fits the column the
   title and the cells ask for, never widening it past that. */
.sr-col .ibar { width: 100%; min-width: 7em; max-width: 9.5em; }
.sr-grid th.sr-col { width: 1%; }
.sr-col-t { font-weight: var(--weight-bold); }
/* One line: the icon, the date and the share never wrap — the column widens
   to fit them instead (Martin, 2026-09-30). */
.sr-col-s { font-size: var(--text-xs); color: var(--muted); display: inline-flex;
  align-items: center; gap: var(--sp-1); white-space: nowrap; }
/* On a tight screen the grid's column heads drop the workout's bar — the
   title and the dates are what a narrow column can carry (Martin, 2026-09-30). */
@media (max-width: 640px) {
  .sr-col .ibar { display: none; }
  /* The pinned rank + name must leave room for the workouts on a phone. */
  .sr-grid .sr-who { min-width: 0; max-width: 7.5em; }
  .sr-grid .sr-rank { width: calc(18px + var(--band-gutter) + var(--sp-1));
    min-width: calc(18px + var(--band-gutter) + var(--sp-1)); padding-right: var(--sp-1); }
  .sr-grid .sr-who { left: calc(18px + var(--band-gutter) + var(--sp-1)); }
}
.sr-prog { text-align: right; white-space: nowrap; }
.sr-cell { display: flex; flex-direction: column; gap: var(--sp-05); min-height: 40px; justify-content: center;
  padding: 0 var(--sp-25); border-radius: var(--r-sm); background: color-mix(in srgb, var(--text) 4%, transparent); }
.sr-cell span { font-size: var(--text-xs); color: var(--muted); }
.sr-cell.done { background: color-mix(in srgb, var(--text) 8%, transparent); }
.sr-cell.open, .sr-cell.na { background: transparent; }
.sr-cell.open b { color: var(--text); }
.sr-cell.na b, .sr-cell.none b { color: var(--ink-faint); font-weight: var(--weight-medium); }
.sr-cell.miss { background: repeating-linear-gradient(135deg, var(--danger-fill) 0 6px, transparent 6px 12px); }
.sr-cell.miss b { color: var(--danger); }
.sr-note { margin-top: var(--sp-25); font-size: var(--text-sm); }

/* A club workout's place in its series ─────────────────────── 2026-09-17 */
.wk-when { font-size: var(--text-sm); font-weight: var(--weight-bold); align-self: center; }
.wk-series { display: grid; grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
  align-items: center; gap: var(--sp-4); padding: var(--sp-3) var(--band-gutter); }
.wk-series-side { display: flex; flex-direction: column; gap: var(--sp-05); color: var(--text);
  text-decoration: none; font-size: var(--text-sm); min-width: 0; }
.wk-series-side.r { text-align: right; }
.wk-series-side span { font-size: var(--text-xs); }
.wk-steps { display: flex; gap: var(--sp-1); }
.wk-steps i { display: block; width: 22px; height: 10px; border-radius: var(--r-micro);
  background: color-mix(in srgb, var(--text) 12%, transparent); }
.wk-steps i.done { background: var(--text); }
.wk-steps i.this { background: transparent; box-shadow: inset 0 0 0 2px var(--text); }
.wk-steps i.done.this { background: var(--text); }
.wk-steps i.later { opacity: 0.5; }

/* ── SERIES: CREATE AND EDIT ───────────────────────────────── 2026-09-17 */
.sn-band { padding: var(--sp-4) var(--band-gutter); }
.sn-grid { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 2fr); gap: var(--sp-4); }
.sn-modes { display: flex; flex-direction: column; gap: var(--sp-25); }
.sn-mode { border: 1px solid var(--border); border-radius: var(--r-md); padding: var(--sp-3) var(--sp-35); }
.sn-mode[open] { border-color: var(--text); }
.sn-mode > summary { list-style: none; cursor: pointer; display: flex; flex-direction: column; gap: var(--sp-05); }
.sn-mode > summary::-webkit-details-marker { display: none; }
.sn-mode > summary span { font-size: var(--text-sm); }
.sn-mode .field-row { margin-top: var(--sp-3); }
.sn-band .field-row .field { margin: 0; }
.sn-actions { display: flex; justify-content: flex-end; gap: var(--sp-25); }
.se-rows > li + li { border-top: 1px solid var(--hairline); }
.se-row { display: flex; flex-wrap: wrap; align-items: center; gap: var(--sp-2) var(--sp-4); min-height: 68px;
  padding: var(--sp-25) var(--band-gutter); font-variant-numeric: tabular-nums; }
.se-n { width: 34px; flex: none; display: inline-flex; align-items: center; gap: var(--sp-1);
  font-weight: var(--weight-heavy); color: var(--muted); }
.se-name { flex: 1 1 260px; min-width: min(250px, 100%); display: flex; flex-direction: column; gap: var(--sp-15); }
.se-name a { color: var(--text); font-weight: var(--weight-bold); text-decoration: none; }
.se-name .ibar { width: 240px; max-width: 100%; }
.se-when { width: 190px; flex: none; display: flex; flex-direction: column; gap: var(--sp-05); font-size: var(--text-sm); }
.se-when span { font-size: var(--text-xs); color: var(--muted); }
.se-when span.own { color: var(--text); font-weight: var(--weight-bold); }
.se-pct { width: 52px; flex: none; text-align: right; font-weight: var(--weight-bold); }
.se-act { width: 150px; flex: none; display: flex; justify-content: flex-end; align-items: center; gap: var(--sp-05); }
@media (max-width: 30rem) {
  /* **Two rows on a phone, with the actions where the eye expects them**
     (2026-09-24). The desktop line wrapped, and the ⋯ and "Dates" fell onto a
     centred line of their own under everything else. Now: the name with its
     actions beside it, then when and how many under it. The place number
     (series page only) runs down the left of both. */
  .se-row { display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-areas: "n name act" "n when pct"; column-gap: var(--sp-3); }
  .se-n { grid-area: n; align-self: start; width: auto; }
  .se-name { grid-area: name; min-width: 0; }
  .se-when { grid-area: when; width: auto; }
  .se-pct { grid-area: pct; align-self: end; }
  .se-act { grid-area: act; width: auto; align-self: start; }
}
.se-more { position: relative; }
.se-more > summary { list-style: none; }
.se-more > summary::-webkit-details-marker { display: none; }
.se-menu { position: absolute; right: 0; top: calc(100% + var(--sp-1)); z-index: 5; min-width: 240px;
  display: flex; flex-direction: column; padding: var(--sp-15); background: var(--surface-opaque);
  border: 1px solid var(--card-border); border-radius: var(--r-md); box-shadow: var(--shadow-hover); }
.se-menu form { margin: 0; }
/* A menu row: icon, label, the whole width is the target. */
.mi { display: flex; align-items: center; gap: var(--sp-25); width: 100%; min-height: 36px;
  padding: 0 var(--sp-25); border: 0; border-radius: var(--r-sm); background: none; cursor: pointer;
  color: var(--text); font: inherit; font-size: var(--text-sm); text-align: left; text-decoration: none;
  white-space: nowrap; }
.mi svg { color: var(--muted); flex: none; }
.mi:hover, .mi:focus-visible { background: color-mix(in srgb, var(--text) 6%, transparent); }
.mi.danger, .mi.danger svg { color: var(--danger); }
.mi-sep { height: 1px; margin: var(--sp-1) 0; background: var(--hairline); }
.mi-inline { cursor: default; }
.mi-inline:hover { background: none; }
.mi-inline input { width: 56px; height: 30px; padding: 0 var(--sp-15); border: 1px solid var(--border);
  border-radius: var(--r-sm); font: inherit; background: var(--surface); color: var(--text); }
.se-dates { display: flex; align-items: flex-end; flex-wrap: wrap; gap: var(--sp-3);
  padding: 0 var(--band-gutter) var(--sp-4) calc(var(--band-gutter) + 50px); }
.se-dates .field { margin: 0; }
/* A date change that would drop results asks first — a question in the row,
   not a banner over the page. */
.sn-off { display: flex; flex-direction: column; gap: var(--sp-1); opacity: .6; }
.se-confirm { flex-direction: column; align-items: flex-start; gap: var(--sp-2); }
.se-confirm p { margin: 0; }
.se-confirm p .muted { display: block; }
.band.se-confirm { display: flex; padding: var(--sp-3) var(--band-gutter); margin-bottom: var(--sp-4); }
.se-confirm-act { display: flex; flex-wrap: wrap; gap: var(--sp-2); }
.se-also { display: block; font-size: var(--text-sm); }
.se-pick { display: flex; align-items: flex-end; gap: var(--sp-25); margin: 0; }
.se-item.empty .se-row { color: var(--muted); }
.se-archive { margin-top: var(--sp-3); }
.cx-rows-plain > li + li { border-top: 1px solid var(--hairline); }
.sn-save { margin-top: var(--sp-3); justify-content: flex-start; }

/* ── CLUB PULSE ────────────────────────────────────────────── 2026-09-17 */
.pu-kpis { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.pu-weeks { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: var(--sp-4);
  align-items: end; height: 220px; padding: var(--sp-4) var(--band-gutter); }
.pu-week { height: 100%; display: flex; flex-direction: column; justify-content: flex-end; align-items: center;
  gap: var(--sp-15); }
.pu-bar { width: 100%; min-height: 2px; border-radius: var(--r-sm) var(--r-sm) 0 0;
  background: color-mix(in srgb, var(--text) 18%, transparent); }
.pu-week.now .pu-bar { background: var(--text); }
.pu-n { font-weight: var(--weight-bold); font-variant-numeric: tabular-nums; }
.pu-l { font-size: var(--text-xs); color: var(--muted); }
.pu-series-row { display: grid; grid-template-columns: minmax(0, 260px) minmax(0, 1fr) 90px; gap: var(--sp-4);
  align-items: center; padding: var(--sp-35) var(--band-gutter); color: var(--text); text-decoration: none; }
.pu-series-name { display: flex; flex-direction: column; gap: var(--sp-05); font-size: var(--text-sm); }
.pu-cur { font-size: var(--text-xs); font-weight: var(--weight-bold); }
.pu-spread { display: flex; flex-direction: column; gap: var(--sp-15); font-size: var(--text-xs); }
.pu-split { display: flex; height: 10px; border-radius: var(--r-micro); overflow: hidden;
  background: color-mix(in srgb, var(--text) 10%, transparent); }
.pu-split i.full { background: var(--text); }
.pu-split i.part { background: color-mix(in srgb, var(--text) 45%, transparent); }
.pu-avg { display: flex; flex-direction: column; align-items: flex-end; font-variant-numeric: tabular-nums; }
.pu-avg b { font-size: var(--text-lg); }
.pu-avg span { font-size: var(--text-xs); }
.pu-pop { display: grid; grid-template-columns: minmax(0, 220px) minmax(0, 1fr) 52px; gap: var(--sp-4); align-items: center;
  min-height: 40px; padding: 0 var(--band-gutter); font-size: var(--text-sm); }
.pu-pop a { color: var(--text); text-decoration: none; font-weight: var(--weight-bold); }
.pu-pop b { text-align: right; font-variant-numeric: tabular-nums; }
.pu-track { height: 10px; border-radius: var(--r-micro); background: color-mix(in srgb, var(--text) 8%, transparent); }
.pu-track i { display: block; height: 10px; border-radius: var(--r-micro); background: color-mix(in srgb, var(--text) 55%, transparent); }
.pu-person { --tr-cols: minmax(0, 1fr) auto; min-height: 56px; }
.pu-when { font-size: var(--text-xs); }
@media (max-width: 40rem) {
  .pu-kpis { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .pu-series-row { grid-template-columns: minmax(0, 1fr) auto; }
  .pu-spread { grid-column: 1 / span 2; grid-row: 2; }
  .sn-grid { grid-template-columns: minmax(0, 1fr); }
  .wk-series { grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); }
  .wk-steps { display: none; }
}
.se-addrow { min-height: 52px; gap: var(--sp-2); color: var(--text); font-weight: var(--weight-bold); text-decoration: none; }

/* ---- The power profile (2026-09-23) — the phone's PowerProfileView. ---- */
.pp-controls { display: flex; flex-wrap: wrap; gap: var(--sp-2); padding: 0 var(--band-gutter, 20px) var(--sp-2); }
.pp-since { margin: 0; padding: 0 var(--band-gutter, 20px) var(--sp-2); font-size: var(--text-sm); }
.pp-chart { margin: 0; padding: var(--sp-3) var(--band-gutter); }
.pp-chart svg { display: block; width: 100%; height: auto; overflow: visible; }
.pp-legend { display: flex; flex-wrap: wrap; gap: var(--sp-3); margin-top: var(--sp-2); font-size: var(--text-xs); }
.pp-key { display: inline-block; width: 18px; height: 3px; border-radius: 2px; margin-right: var(--sp-1); vertical-align: middle; }
.pp-ramp { background: linear-gradient(90deg, var(--band-red), var(--band-orange), var(--band-lime), var(--band-green), var(--band-blue)); }
.pp-dash { height: 0; border-top: 2px dashed var(--muted); }
.pp-five { padding: var(--sp-3) var(--band-gutter); }
.pp-five p { margin: 0 0 var(--sp-1); }
.pp-big { font-size: var(--text-xl); font-weight: 800; font-variant-numeric: tabular-nums; }
.pp-big .muted { font-size: var(--text-xs); font-weight: 400; }
.pp-strengths { padding: var(--sp-3) var(--band-gutter); }
.pp-strengths > p { margin: 0 0 var(--sp-2); }
.pp-bars { list-style: none; margin: 0; padding: 0; }
.pp-bars li { display: grid; grid-template-columns: 64px minmax(0, 1fr) 56px; gap: var(--sp-2); align-items: center; padding: var(--sp-1) 0; font-variant-numeric: tabular-nums; }
.pp-track { position: relative; height: 22px; }
.pp-track::before { content: ""; position: absolute; left: 50%; top: 0; bottom: 0; width: 1px; background: var(--border); }
.pp-fill { position: absolute; top: 6px; height: 10px; border-radius: 6px; background: var(--text); left: 50%; width: max(var(--len), 2px); }
.pp-fill.neg { left: auto; right: 50%; background: var(--ink-faint); }
.pp-pct { text-align: right; font-weight: 700; }
.pp-d { font-weight: 700; }
.pp-rows { list-style: none; margin: 0; padding: 0; }
.pp-rows .tr { display: grid; grid-template-columns: 64px minmax(0, 1fr) minmax(0, 1.2fr); gap: var(--sp-2); padding: var(--sp-2) var(--band-gutter); border-bottom: 1px solid var(--hairline); font-variant-numeric: tabular-nums; }
.pp-rows .r { text-align: right; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* A row that is only a link (New bests' "Power profile ›") spans the grid —
   in the first column it broke across two lines. */
.pp-rows .tr > a:only-child { grid-column: 1 / -1; }

/* ── Live and results (2026-09-24) ──────────────────────────────────────────
   One table for the live screen, a club workout's results and your own
   attempts (`results/_table.html`, `results_table.py`) — oringen-live's
   split matrix in rowing's units. Wide on purpose: it is the screen a club
   puts on the wall, so the page drops the reading measure. */
.live-body .container { max-width: none; }
.lv-pill { display: inline-flex; align-items: center; gap: var(--sp-15);
  padding: var(--sp-1) var(--sp-25) var(--sp-1) var(--sp-2); border-radius: 999px;
  background: color-mix(in srgb, var(--accent) 12%, transparent); color: var(--accent);
  font-size: var(--text-2xs); font-weight: 800; letter-spacing: var(--band-tracking);
  text-transform: uppercase; }
.lv-pill i { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.lv-head { display: flex; flex-direction: column; gap: var(--sp-2); margin-bottom: var(--sp-4); }
.lv-head h1, .lv-head p { margin: 0; }
.lv-head-top { display: flex; align-items: center; justify-content: space-between;
  gap: var(--sp-3); flex-wrap: wrap; }
.lv-wall { display: inline-flex; align-items: center; gap: var(--sp-2); }
.lv-head-line { display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--sp-4); margin-bottom: var(--sp-3); flex-wrap: wrap; }
.lv-clock { font-size: var(--text-2xl); font-weight: 700; font-variant-numeric: tabular-nums; }
.lv-foot { margin-top: var(--sp-3); }
.lv-event { display: flex; align-items: center; gap: var(--sp-3); }
.lv-event .wk-action { flex: 1; }
.lv-public { display: flex; align-items: center; flex-wrap: wrap; gap: var(--sp-2);
  padding: 0 0 var(--sp-2); font-size: var(--text-sm); }
.lv-public-link { flex: 1; min-width: 14em; min-height: var(--control-h-sm);
  font: inherit; font-size: var(--text-sm); }
.lv-form { display: flex; flex-wrap: wrap; align-items: flex-end; gap: var(--sp-3);
  padding: var(--sp-3) var(--band-gutter, 20px) 0; }
.lv-form label, .rt-from label { display: flex; flex-direction: column; gap: var(--sp-1);
  font-size: var(--text-sm); color: var(--muted); }
.lv-form + p { padding-inline: var(--band-gutter, 20px); }
.rt-from select { min-height: var(--control-h); min-width: 16em; }
.rt-you { color: var(--accent); }
.rt-link { margin-top: var(--sp-2); }

/* The graph: behind the leader at every interval, flat through the rests. */
.rt-graph { margin: 0 0 var(--sp-4); padding: var(--sp-3) var(--sp-4);
  background: var(--surface); border: 1px solid var(--card-border); border-radius: var(--r-lg); }
.rt-graph figcaption { display: flex; justify-content: space-between; gap: var(--sp-3);
  flex-wrap: wrap; font-size: var(--text-2xs); font-weight: 700; letter-spacing: var(--band-tracking);
  text-transform: uppercase; color: var(--ink-faint); margin-bottom: var(--sp-2); }
.rt-graph figcaption .muted { font-weight: 500; text-transform: none; letter-spacing: 0; }
.rt-graph { overflow-x: auto; }
/* Below ~720 px the graph keeps a readable size and scrolls sideways, like
   the table under it, rather than shrinking its names to specks. */
.rt-graph svg { display: block; width: 100%; min-width: 720px; height: auto; }
.rt-rest { fill: color-mix(in srgb, var(--text) 4%, transparent); }
.rt-grid { stroke: var(--hairline); stroke-width: 1; }
.rt-grid.zero { stroke: color-mix(in srgb, var(--text) 35%, transparent); }
.rt-axis { font-size: 10px; fill: var(--muted); }
.rt-line { fill: none; stroke: color-mix(in srgb, var(--text) 38%, transparent); stroke-width: 1.5;
  stroke-linejoin: round; vector-effect: non-scaling-stroke; }
.rt-line.lead { stroke: var(--text); stroke-width: 2.5; }
.rt-line.me { stroke: var(--accent); stroke-width: 3; }
.rt-line.faded { stroke-dasharray: 4 4; opacity: .6; }
.rt-dot { fill: color-mix(in srgb, var(--text) 45%, transparent); }
.rt-dot.lead { fill: var(--text); }
.rt-dot.me { fill: var(--accent); }
.rt-name { font-size: 11px; font-weight: 800; fill: var(--text);
  paint-order: stroke; stroke: var(--surface-raised); stroke-width: 3px; stroke-linejoin: round; }
.rt-name.me { fill: var(--accent); }
.rt-name-gap { font-weight: 600; fill: var(--muted); }

/* The table. */
.rt-scroll { overflow-x: auto; background: var(--surface); border: 1px solid var(--card-border);
  border-radius: var(--r-lg); box-shadow: var(--shadow); }
.rt-table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.rt-table th, .rt-table td { padding: var(--sp-2) var(--sp-3); text-align: right;
  white-space: nowrap; border-bottom: 1px solid var(--hairline); vertical-align: top; }
.rt-table thead th { font-size: var(--text-2xs); font-weight: 700; letter-spacing: var(--band-tracking);
  text-transform: uppercase; color: var(--ink-faint); vertical-align: bottom; }
.rt-table thead a { color: inherit; text-decoration: none; }
.rt-table thead span { display: block; font-weight: 600; text-transform: none; letter-spacing: 0; }
/* The sorted column says so in its header's ink alone — no accent underline
   (Martin, 2026-09-30: "remove the purple line"). */
.rt-table thead th.on { color: var(--text); }
.rt-table td.on { background: color-mix(in srgb, var(--accent) 5%, transparent); }
/* **The place leads the name, in the name's own cell** (2026-09-30) — the
   table opens at its right-hand end (`data-scroll-end`) with the name pinned. */
.rt-table .rt-place { display: inline-block; min-width: 1.6em; margin-right: var(--sp-1);
  font-weight: 800; color: var(--muted); font-variant-numeric: tabular-nums; }
.rt-livedot { display: inline-block; width: 8px; height: 8px; border-radius: 50%;
  background: var(--rest); vertical-align: middle; }
.rt-cell .rt-livedot { width: 6px; height: 6px; margin-right: var(--sp-1); }
/* The name stays put when a long workout scrolls sideways. The dark ground's
   surface is translucent, so it is composited over the page ground: the same
   colour, opaque. */
.rt-table .rt-who { position: sticky; left: 0; z-index: 1;
  box-shadow: inset -1px 0 0 var(--hairline); text-align: left; min-width: 11em;
  font-weight: 400; background: linear-gradient(var(--surface), var(--surface)), var(--bg); }
/* A board row's band chip (the reader's ramp, `live.board_results`), a step off the name. */
.rt-table .rt-who .band-chip { margin-right: var(--sp-1); }
.rt-who b { font-size: var(--text-base); }
.rt-status { display: block; font-size: var(--text-sm); color: var(--muted); }
.rt-status.rt-quiet { color: var(--warning); }
.rt-row.me .rt-who b { color: var(--accent); }
.rt-row.me td { background: color-mix(in srgb, var(--accent) 5%, transparent); }
.rt-row.me .rt-who { background: linear-gradient(color-mix(in srgb, var(--accent) 5%, transparent), color-mix(in srgb, var(--accent) 5%, transparent)),
  linear-gradient(var(--surface), var(--surface)), var(--bg); }
.rt-row.quiet { opacity: .6; }
.rt-row.flash td { background: color-mix(in srgb, var(--band-lime) 22%, transparent); }
.rt-row.flash .rt-who { background: linear-gradient(color-mix(in srgb, var(--band-lime) 22%, transparent), color-mix(in srgb, var(--band-lime) 22%, transparent)),
  linear-gradient(var(--surface), var(--surface)), var(--bg); }
.rt-total { font-weight: 700; vertical-align: middle !important; }
.rt-total.go { color: var(--rest); }
.rt-cell { min-width: 6.5em; }
.rt-cell span { display: block; }
.rt-l1 { font-size: var(--text-sm-plus); font-weight: 650; }
.rt-l1 i { font-style: normal; font-size: var(--text-xs); color: var(--ink-faint); font-weight: 600; }
.rt-l2 { font-size: var(--text-xs); font-weight: 700; color: var(--muted); min-height: 1.3em; }
.rt-l2.lead { color: var(--accent); }
.rt-l3 { font-size: var(--text-xs); color: var(--muted); }
.rt-l3.first { color: var(--diff-good); font-weight: 700; }
.rt-l3.best { color: var(--diff-good); font-weight: 800; }
.rt-cell.live .rt-l1, .rt-cell.live .rt-l2 { color: var(--rest); }
.rt-cell.live { background: color-mix(in srgb, var(--rest) 6%, transparent); }
.rt-none { display: inline-block !important; width: 1.2em; height: 2px; background: var(--hairline);
  vertical-align: middle; }
.rt-empty td { text-align: left; color: var(--muted); }
@media (max-width: 600px) {
  .rt-table th, .rt-table td { padding: var(--sp-2) var(--sp-2); }
  .rt-table .rt-who { min-width: 8.5em; }
}
/* WALL SCREEN: the page alone, for the TV in the club room. */
.wall .rail, .wall .topbar, .wall .sitefoot, .wall .back { display: none !important; }
.wall .shell-rail .shell-body, .wall .shell-body { margin: 0 !important; padding: 0 !important; }
.wall body { font-size: 118%; }
.wall .lv-wall span { display: none; }

/* **View clubs as** — the super-admin's switch in the rail (2026-09-29). A
   `.seg` under its own caption, inset on the rail's 12 px like the account
   row; three short words fit the 236 px rail in one line. */
.rail-viewas { display: flex; flex-direction: column; gap: var(--sp-1);
  padding: var(--sp-2) 12px; }
.rail-viewas-label { color: var(--ink-faint); font-size: 11px;
  font-weight: var(--weight-semibold); letter-spacing: .04em; text-transform: uppercase; }
.rail-viewas .seg { align-self: flex-start; }

/* **A club workout's Manage band** (club navigation A, 2026-09-29) — rows in
   the same `.wk-action` box as Edit the plan, each with its control at the
   end; the two act-on-the-whole-workout buttons sit apart below. */
.wk-manage { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
.wk-manage > span { flex: 1 1 auto; min-width: 0; }
.wk-manage .field { flex: 1 1 200px; margin: 0; }
form.wk-manage { align-items: flex-end; }
.wk-manage-foot { display: flex; gap: var(--sp-2); flex-wrap: wrap; padding-top: var(--sp-3); }

/* A row that opens something, with its chevron on the right — Manage
   workouts, Manage series (the web follows the app, 2026-09-30). */
.cx-link-row { grid-template-columns: minmax(0, 1fr) auto; padding-block: var(--sp-2); }
/* The club page's Members row (club navigation A). */
/* The ⋯ beside a page's title (the web follows the app, 2026-09-30). */
/* After the cost, which keeps the right edge; with no cost it takes it. */
.wk-title .wk-more { align-self: center; }
.wk-title h1 + .wk-more { margin-left: auto; }
.cx-own { margin: var(--sp-2) 0 var(--sp-3); }
.cx-own .se-menu { right: auto; left: 0; }
.cx-members-row { display: flex; align-items: center; justify-content: space-between;
  gap: var(--sp-3); min-height: var(--control-h-tap); padding: 0 var(--sp-4); }

/* The training-platforms guide (2026-09-29) — `_connection_guide.html`, the web
   half of iOS `ConnectionGuideView`. Its rows are `.member-item` in a `.band`;
   what is new is the step MARK, iOS `NumberBadge` at 24 pt: an outlined number
   while a step is open, an ink disc with a check once done, and an outline with
   a dash for "I don't use it" — answered, but not a check. */
.guide { margin-top: var(--sp-3); }
.guide-head { display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--sp-2); flex-wrap: wrap; }
/* Done / Not done — a state, green and red (Martin, 2026-09-29), like the
   app's Settings row (`SettingRow.valueTint`). */
.guide-status { font-weight: 600; }
.guide-status.done { color: var(--success); }
.guide-status.open { color: var(--error); }
.guide-steps .guide-step { align-items: flex-start; }
.guide-mark { flex: none; display: inline-grid; place-items: center;
  width: 24px; height: 24px; border-radius: 50%;
  border: 1.5px solid var(--ink-faint); color: var(--muted);
  font-size: var(--text-xs); font-weight: var(--weight-semibold); }
.guide-mark.done { background: var(--text); border-color: var(--text); color: var(--on-ink); }
.guide-mark .icon { width: 14px; height: 14px; }
/* The actions sit under the words, aligned with them rather than with the
   mark: a full-width line, indented by the mark and the row's gap. */
.guide-actions { flex: 1 1 100%; display: flex; flex-wrap: wrap; gap: var(--sp-2);
  padding-left: calc(24px + var(--sp-2)); }
.guide-actions form { display: contents; }
/* The line above the test session, on the same indent as the buttons. */
.guide-test-line { flex: 1 1 100%; padding-left: calc(24px + var(--sp-2));
  font-size: var(--text-sm); line-height: 1.5; }
