/* ==========================================================================
   Fitness Tracker

   Two rules drive this stylesheet:

   1. NOTHING SCROLLS unless the content genuinely can't fit. The window is
      locked; each screen is a fixed header, an optional fixed top region, one
      flexible middle, and an optional fixed footer. The middle only gains a
      scrollbar when it actually overflows.
   2. NO BOXES. Structure comes from hairline rules, spacing and weight — not
      from nested bordered cards. Every border saved is ~2px of height and one
      less thing between the reader and the number.
   ========================================================================== */

:root {
  --ground:      #0F1214;
  --surface:     #161A1C;
  --raised:      #1E2427;
  --raised-hi:   #282F33;
  --ink:         #ECEFEE;
  --ink-soft:    #C3CACE;
  /* ⚠️ CHANGED 2026-08-20 (was #6B7479) — the first accessibility audit this
     project has ever had measured it at 3.94:1 on --ground and 3.29:1 on
     --raised, against the 4.5:1 WCAG AA needs for text under 18.66px. EVERY use
     of this token is under 18.66px. See the note on the light values below;
     the three-step scale and its measured ratios are documented there. */
  --ink-faint:   #A7B0B5;
  --rule:        #242A2E;
  --rule-soft:   #1C2225;

  /* The body diagram is a printed poster: dark ink on light paper, in BOTH
     themes. Its shading assumes shadows are darker than the paper, so inverting
     for dark mode would turn every fibre striation into a highlight. Dark mode
     gets a dimmer sheet instead, so it reads as paper rather than glare. */
  --body-paper:  #C2C6C0;
  --body-ink:    #121618;
  /* "No data" ON THE FIGURE is not the same swatch as "no data" in the legend:
     the legend sits on the dark page, the muscle sits on light paper. Reusing
     --lv-none here painted the unranked muscles near-black and they read as
     holes punched in the body. */
  --body-none:   #A5AAA6;

  --accent:      #D99A3E;
  --accent-ink:  #1A1206;
  --accent-dim:  #2E2413;

  --good:        #4FB783;
  --good-dim:    #16281F;
  --danger:      #D9614A;

  --sans: -apple-system, BlinkMacSystemFont, "Segoe UI Variable Text", "Segoe UI", system-ui, sans-serif;
  /* Code-like text only (a link to copy, a technical error). Numbers are set in
     the sans with tabular figures, so columns still line up (2026-09-24). */
  --mono: ui-monospace, "SF Mono", "Cascadia Mono", Consolas, monospace;

  /* TYPE SCALE — 2026-09-24. Five sizes and three weights; everything under
     20px uses these. Display figures (21px and up: the big numbers and screen
     heroes) stay literal, as do the 16px form controls (iOS zooms into any
     input under 16px) and the year grid's 8.5/9px labels, which sit in 6px rows. */
  --fs-xs: 10px;
  --fs-sm: 11.5px;
  --fs-md: 13px;
  --fs-lg: 15px;
  --fs-xl: 19px;
  --fw-reg:  400;
  --fw-med:  600;
  --fw-bold: 700;

  --pad: 14px;
  --gap: 10px;
  --r: 9px;
  --bar-h: 54px;
  --safe-b: env(safe-area-inset-bottom, 0px);

  /* MOTION — 2026-09-01. The full argument is in the Motion section at the
     bottom of this file; these are the four numbers everything uses.
     ⚠️ THEY ARE SHORT ON PURPOSE. Tim asked for movement and then asked twice
     for it not to get in the way: *"keep it quick… I don't want it to be
     something that is distracting or slow."* Nothing in this app animates for
     longer than a quarter of a second. */
  --t-fast: 100ms;   /* a press answering back */
  --t:      170ms;   /* the default — fades, slides, expansions */
  --t-slow: 240ms;   /* a whole surface: a sheet, a full-screen picture */
  /* Decelerate: something ARRIVING. Enters at speed, settles gently. */
  --ease-out:  cubic-bezier(.2, 0, 0, 1);
  /* Accelerate: something LEAVING. Eases off, then goes. */
  --ease-in:   cubic-bezier(.4, 0, 1, 1);
  /* Both ends: something MOVING from one place to another while you watch it.
     This is the one that reads as a real object — it has to start and stop. */
  --ease-both: cubic-bezier(.4, 0, .2, 1);

  /* DEPTH AND LIGHT — 2026-09-25, polish pass V. The components that use these
     are in the "Polish · depth and colour" section near the end of this file;
     the reasoning is written there. In short: light comes from above, depth
     replaces borders, and a tint only ever moves a surface AWAY from the text
     on it (darker under light text, lighter under dark text), so no contrast
     measured in tests/a11y.test.mjs can go down.
     ⚠️ Not hex values on purpose: the a11y parser reads `--x: #RRGGBB;` pairs
     as text/surface colours, and none of these is one. */
  --shadow-rgb: 6 4 0;                         /* shadow tint — warm, for the gold */
  --hl:     rgb(255 255 255 / .055);           /* 1px top "reflection" on a panel */
  --gloss:  rgb(255 255 255 / .30);            /* the same line on a filled accent */
  --glow-a: 22%;                               /* accent share of a selected state's inner glow */
  --lit:    linear-gradient(180deg, rgb(0 0 0 / 0), rgb(var(--shadow-rgb) / .14));
  --recess: inset 0 1px 2px rgb(var(--shadow-rgb) / .35);
  --elev-1: inset 0 1px 0 var(--hl), 0 1px 2px rgb(var(--shadow-rgb) / .35);
  --elev-2: inset 0 1px 0 var(--hl), 0 1px 2px rgb(var(--shadow-rgb) / .40),
            0 6px 14px -4px rgb(var(--shadow-rgb) / .45);
  --elev-3: inset 0 1px 0 var(--hl), 0 2px 6px rgb(var(--shadow-rgb) / .30),
            0 16px 36px -8px rgb(var(--shadow-rgb) / .60);
  /* The accent as two stops. Dark themes put DARK ink on the accent, so the
     second stop is lighter (top); light themes put WHITE ink on it, so it is
     darker (bottom). Either way every stop reads at or above the flat accent. */
  --accent-top:   color-mix(in srgb, var(--accent) 86%, #fff);
  --accent-bot:   var(--accent);
  --accent-press: var(--accent);

  color-scheme: dark;
}

:root[data-theme="light"] {
  --ground:      #F3F4F1;
  --surface:     #FFFFFF;
  --raised:      #E7E9E4;
  --raised-hi:   #DBDED7;
  --ink:         #171A1C;
  /* ⚠️ THE TEXT SCALE, MEASURED — 2026-08-20, the first accessibility audit this
     project has ever had. It drove 44 screen/width/theme combinations and read
     the ratio of every rendered text element against the colour actually painted
     behind it, and --ink-faint failed WCAG AA on all of them: 3.94:1 dark and
     3.05:1 light against --ground, 3.29 / 2.75 against --raised, where AA wants
     4.5:1 for text under 18.66px. Every one of this token's 75 uses is under
     18.66px, and they include .field-help and .req-source — the caveats and the
     citations, which is to say the load-bearing honesty this whole app rests on.

     ⚠️ RAISING FAINT ALONE WOULD HAVE COLLAPSED THE HIERARCHY. Light --ink-soft
     was only 5.46:1, so an AA-passing faint (4.97) landed half a step below it
     and the two were indistinguishable. There was not room in the light palette
     for three levels above AA, so soft moved too. Solved against --raised — the
     worst background either token is actually painted on, where .tag and
     .cal-day sit — and verified by re-running the audit, not by eye.

       ground / surface / raised / rule-soft   dark                    light
       ink                                     16.25 15.14 13.58 13.91  15.84 17.49 14.30 13.78
       soft                                    11.33 10.56  9.47  9.70  11.32 12.50 10.22  9.85
       faint                                    8.52  7.94  7.12  7.30   8.53  9.41  7.70  7.42

     ⚠️ RAISED AGAIN 2026-08-25, and this time not to reach AA — it was already
     past it — but because passing AA is not the same as being READABLE. Tim,
     after a gym session: "the pure white text is fine to read, the grey text is
     really challenging to spot and read". Faint went 5.44 -> 8.52 dark and
     5.24 -> 8.53 light; soft went 7.15 -> 11.33 and 7.20 -> 11.32. The worst
     cell in the whole table is now 7.12, above what the BEST faint cell managed
     before. (was: dark soft #98A1A6 faint #848B8F, light soft #4B5256 faint
     #62666A.)
     ⚠️ The three steps stay strictly ordered and the two themes stay within
     0.01 of each other at each step — tests/a11y.test.mjs enforces both, and it
     rejected the first attempt at these values for drifting 1.9 apart.

     ⚠️ --rule-soft is in that table because tests/a11y.test.mjs put it there and
     the browser could not have. It is the darkest light surface and the lightest
     dark one, a calendar cell paints on it, and light faint measured 4.37:1
     against it — below AA, but LATENT: no faint text sits on it today, so all 44
     browser combinations came back clean. A token pair that fails is one edit
     away from failing on screen, which is why the palette is pinned at the token
     level and not only where it is currently used.

     Three visibly separated steps, every one above AA, and the two themes now
     sit within 0.4 of each other at each step rather than 0.9 apart.
     (was: --ink-soft #5C6469, --ink-faint #868D92) */
  --ink-soft:    #333433;
  --ink-faint:   #45464B;
  --rule:        #D6D9D3;
  --rule-soft:   #E3E5E0;

  --body-paper:  #FFFFFF;
  --body-ink:    #121618;
  --body-none:   #DBDED7;

  --accent:      #96660F;
  --accent-ink:  #FFFFFF;
  --accent-dim:  #F0E4CB;

  --good:        #2A7551;
  --good-dim:    #DCEBE2;
  --danger:      #AF3B26;

  /* Depth and light, light theme — see the note in the dark block. */
  --shadow-rgb: 64 46 18;
  --lit-rgb:    255 252 244;
  --hl:     rgb(255 255 255 / .70);
  --gloss:  rgb(255 255 255 / .24);
  --glow-a: 40%;
  --lit:    linear-gradient(180deg, rgb(var(--lit-rgb) / .55), rgb(var(--lit-rgb) / 0) 75%);
  --recess: inset 0 1px 2px rgb(var(--shadow-rgb) / .10);
  --elev-1: inset 0 1px 0 var(--hl), 0 1px 2px rgb(var(--shadow-rgb) / .12);
  --elev-2: inset 0 1px 0 var(--hl), 0 1px 2px rgb(var(--shadow-rgb) / .14),
            0 6px 14px -4px rgb(var(--shadow-rgb) / .18);
  --elev-3: inset 0 1px 0 var(--hl), 0 2px 6px rgb(var(--shadow-rgb) / .08),
            0 16px 36px -8px rgb(var(--shadow-rgb) / .24);
  --accent-top:   var(--accent);
  --accent-bot:   color-mix(in srgb, var(--accent) 86%, #000);
  --accent-press: color-mix(in srgb, var(--accent) 84%, #000);

  color-scheme: light;
}

/* ==========================================================================
   COLOUR PALETTES — Tim's pick, 2026-08-26: all three options from the
   colourways page ship as a Settings choice ("Colour"), alongside the
   original gold. The default palette is the two blocks above, untouched;
   boot sets data-palette on <html> ONLY for a non-default choice, so the
   default keeps its scoped light-theme fixes (.row-start's darkened gold)
   and an account that never touches the setting renders exactly as before.

   ⚠️ EVERY TOKEN A PALETTE TOUCHES APPEARS IN BOTH ITS BLOCKS (dark and
   light). The dark-palette and plain-light selectors tie on specificity
   (0,2,0), so a token defined in one and not the other would resolve by
   source order — a bug that only shows in one theme. Defining the full set
   twice makes order irrelevant.

   ⚠️ ALL VALUES ARE CONTRAST-CHECKED IN tests/a11y.test.mjs, which sweeps
   every palette in both themes through the same token-level checks the
   default has always had — text scale on every surface, the hierarchy, the
   theme parity, the accent pairs. The body-poster tokens are deliberately
   NOT overridden: the figure is a printed poster and does not tint with
   the room (see the --body-paper note above).
   ========================================================================== */

/* ── Teal — cool sea-green accent, faintly tinted field ─────────────────── */
:root[data-palette="teal"] {
  --ground: #0D1414; --surface: #141B1A; --raised: #1D2725; --raised-hi: #263230;
  --rule: #24312D; --rule-soft: #1B2622;
  --accent: #3BC0AB; --accent-ink: #062019; --accent-dim: #123129;
  --good: #55BC86; --good-dim: #14291F;
  --shadow-rgb: 0 8 7;
}
:root[data-palette="teal"][data-theme="light"] {
  --ground: #EFF4F2; --surface: #FFFFFF; --raised: #E1EAE6; --raised-hi: #D4E0DB;
  --rule: #D1DAD5; --rule-soft: #E0E7E3;
  --accent: #0E7264; --accent-ink: #FFFFFF; --accent-dim: #D6EAE4;
  --good: #2A7551; --good-dim: #DCEBE2;
  --shadow-rgb: 14 56 48; --lit-rgb: 246 255 252;
}

/* ── Indigo — night-sky field, periwinkle accent ────────────────────────── */
:root[data-palette="indigo"] {
  --ground: #0F1219; --surface: #151A26; --raised: #1E2432; --raised-hi: #272E3F;
  --rule: #262E3F; --rule-soft: #1C2331;
  --accent: #93A5FF; --accent-ink: #10142E; --accent-dim: #202741;
  --good: #57BB8A; --good-dim: #142A20;
  --shadow-rgb: 3 5 20;
}
:root[data-palette="indigo"][data-theme="light"] {
  --ground: #F0F1F7; --surface: #FFFFFF; --raised: #E2E5F1; --raised-hi: #D5D9EA;
  --rule: #D3D6E4; --rule-soft: #E3E5F0;
  --accent: #4451C8; --accent-ink: #FFFFFF; --accent-dim: #DEE2F8;
  --good: #2A7551; --good-dim: #DCEBE2;
  --shadow-rgb: 28 34 92; --lit-rgb: 248 249 255;
}

/* ── Ember — the gold kept, the whole field warmed around it ────────────── */
:root[data-palette="ember"] {
  --ground: #141110; --surface: #1B1715; --raised: #262019; --raised-hi: #302921;
  --rule: #2C251D; --rule-soft: #221C16;
  --ink: #F0ECE5; --ink-soft: #CFC7BA; --ink-faint: #B3AA9B;
  --accent: #D99A3E; --accent-ink: #1A1206; --accent-dim: #33270F;
  --good: #63B573; --good-dim: #1C2A18;
  --shadow-rgb: 10 5 0;
}
:root[data-palette="ember"][data-theme="light"] {
  --ground: #F4F0E7; --surface: #FFFDF8; --raised: #EAE3D4; --raised-hi: #DED6C3;
  --rule: #DBD4C2; --rule-soft: #E8E2D3;
  --ink: #1B1814; --ink-soft: #37342D; --ink-faint: #4A463D;
  /* Darker than the default light gold (#96660F measures 4.39 on this warmer
     ground — below AA). #8A5D0C clears 5.06 on ground and 5.75 under white. */
  --accent: #8A5D0C; --accent-ink: #FFFFFF; --accent-dim: #F0E4CB;
  --good: #2A7551; --good-dim: #DFEADC;
  --shadow-rgb: 72 46 12; --lit-rgb: 255 250 238;
}

* { box-sizing: border-box; -webkit-tap-highlight-color: transparent; }

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  overflow: hidden;
  overscroll-behavior: none;
}

body {
  background: var(--ground);
  color: var(--ink);
  font-family: var(--sans);
  font-size: var(--fs-lg);
  line-height: 1.4;
  -webkit-font-smoothing: antialiased;
  touch-action: manipulation;
}

button, input, select, textarea { font-family: inherit; font-size: 16px; color: inherit; }
button { cursor: pointer; border: 0; background: none; padding: 0; }
/* ⚠️ NO `border-radius` HERE, AND THAT IS A BUG FIX (2026-08-29).
 *
 * Tim, on a laptop: *"the profile picture in the upper left-hand corner has a
 * blue box around the circle making it look a little weird… This isn't an
 * error on the iPhone."*
 *
 * This rule used to carry `border-radius: 3px`, which sets the radius on the
 * ELEMENT rather than on the ring — so focusing any round control **squared it
 * off**: the circular avatar became a 3px-radius box, and the pill-shaped
 * chips, `.add-set`, `.row-start` and the rest with it. It only shows on a
 * pointer/keyboard device because a tap does not raise `:focus-visible`, which
 * is exactly why the iPhone was fine and the laptop was not.
 *
 * It was there to give a square control's ring soft corners, and it is not
 * needed for that: every current browser draws an outline following the
 * element's OWN border-radius. Dropping it means each control keeps its shape
 * and the ring follows it — round stays round, square gets a square ring.
 *
 * ⚠️ The ring itself stays. Removing focus indication to fix its shape would
 * trade a cosmetic complaint for the accessibility failure this project
 * already had once, in 2026-08-20. */
:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

.mono { font-variant-numeric: tabular-nums; }

/* ==========================================================================
   Shell
   ========================================================================== */

/* ⚠️ `--kb` IS HOW MUCH OF THE SCREEN THE SOFTWARE KEYBOARD IS COVERING, and
   without it this whole layout is wrong on a phone the moment anybody types.
   Every screen here is a fixed header, one scrolling middle and a fixed footer
   inside a box locked to the viewport height — and on iOS the keyboard does NOT
   shrink that box. It is drawn on top of it. So the footer, which is where this
   app puts the primary action of every screen, goes underneath it: measured
   2026-08-21 on a 393×852 iPhone, the keyboard's top edge lands at y=516 and
   the session runner's "Next exercise" sits at 789–852. Same for every Save
   changes, every Done, and the exercise picker's results.

   app.js keeps the value in sync from window.visualViewport — the only API that
   reports this — and it stays 0 on a desktop, where the rule is a no-op. */
#app {
  height: calc(100dvh - var(--kb, 0px));
  max-height: calc(100dvh - var(--kb, 0px));
  display: flex;
  flex-direction: column-reverse;
  overflow: hidden;
}

.screen { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: hidden; }

.topbar {
  flex: none;
  background: var(--ground);
  border-bottom: 1px solid var(--rule-soft);
  padding: calc(env(safe-area-inset-top, 0px) + 9px) var(--pad) 9px;
  display: flex;
  align-items: center;
  gap: 8px;
  min-height: 48px;
}
.topbar h1 {
  font-size: var(--fs-xl);
  font-weight: var(--fw-bold);
  letter-spacing: -0.022em;
  margin: 0;
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.topbar-sub {
  font-size: var(--fs-sm);
  color: var(--ink-faint);
  font-weight: var(--fw-med);
  margin-top: 1px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* A screen may put a control in the header instead of a heading. */
.topbar-slot { flex: 1; min-width: 0; display: flex; align-items: center; gap: 10px; }
.topbar-slot h1 { flex: none; }

.cal-topbar { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.cal-topbar h1 { flex: none; }
.cal-topbar .legend { flex: 1; justify-content: flex-end; gap: 10px; }

.icon-btn {
  width: 36px; height: 36px;
  display: grid; place-items: center;
  border-radius: 7px;
  color: var(--ink-soft);
  flex: none;
}
.icon-btn:active { background: var(--raised); }
.icon-btn svg { width: 21px; height: 21px; }

/* ⚠️ THE HIT AREA IS 44px; THE PAINTED BUTTON STAYS 36px. Measured 2026-08-20 by
   the first accessibility audit this project has had: these clear WCAG 2.2 AA
   (24px) and miss Apple's 44 and Android's 48, which is the difference between
   legal and comfortable on the device this app is actually used on.

   Grown with a pseudo-element rather than by resizing, because the header lays
   these out against a title and a 44px box would move real pixels on a screen
   nobody has yet checked on hardware — and progress.md is explicit that a
   headless screenshot says nothing about touch. This changes what a thumb hits
   and nothing about what an eye sees.

   Verified by hit-testing the corners of the 44px box with elementFromPoint,
   not by reading the rule back. */
/* ⚠️ ::before, NOT ::after. The avatar's ::after is already the red "not backed
   up" dot — the one badge in this app — and .avatar-btn.at-risk::after wins on
   specificity, so an ::after hit area silently vanished in exactly the state the
   audit had caught it in. The dot was never at risk; the hit area was. Caught by
   re-measuring after the fix rather than by trusting it. */
/* ⚠️ `.seg` is deliberately NOT in this list, and the reason is worth keeping:
   it needs `overflow: hidden` for its ellipsis backstop, and an overflow-hidden
   box CLIPS a pseudo-element grown past its own height — the hit area would
   silently measure 36px again while the rule claimed 44. It is given a real
   44px instead, further down. Two mechanisms, and only one of them survives
   contact with `overflow: hidden`. */
.icon-btn, .avatar-btn, .chip, .btn.small { position: relative; }
.icon-btn::before, .avatar-btn::before, .chip::before, .btn.small::before {
  content: '';
  position: absolute;
  top: 50%; left: 50%;
  width: 100%; height: 44px;
  min-width: 44px;
  transform: translate(-50%, -50%);
}

/* Profile button — top-left of every main screen. Round, so it reads as an
   account rather than another toolbar control. */
.avatar-btn {
  width: 34px; height: 34px;
  flex: none;
  overflow: hidden;
  display: grid; place-items: center;
  border-radius: 50%;
  background: var(--raised);
  color: var(--ink-soft);
  text-decoration: none;
  position: relative;
}
.avatar-btn:active { background: var(--raised-hi); }
.avatar-btn svg { width: 19px; height: 19px; }
/* ⚠️ ABSOLUTE, INSET 0 — NOT an auto-sized grid item, and this is a bug fix
   rather than a tidy-up. `.avatar-btn` is `place-items: center`, which sizes its
   item to its CONTENT; the content is the photo, whose own `width: 100%` then
   resolves against a parent that is being sized from it. That is a cyclic
   percentage, and an engine is free to break the cycle by falling back to the
   image's INTRINSIC size — 256px. Blink resolves it to 34px and WebKit was
   reported rendering it huge on an iPhone, which is exactly the shape of that
   disagreement. Pinning the glyph to the button's own box makes the containing
   block definite, so there is no cycle left for either engine to resolve. */
.avatar-glyph {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  font-size: var(--fs-lg); font-weight: var(--fw-bold); line-height: 1;
}
/* Signed in: filled, and showing the initial. */
.avatar-btn.secured { background: var(--accent); color: var(--accent-ink); }
/* Not backed up: a dot you notice, not a subtlety you don't. This is the only
   state where a user can lose everything, so it gets the one badge in the app. */
.avatar-btn.at-risk::after {
  content: '';
  position: absolute; top: -1px; right: -1px;
  width: 10px; height: 10px;
  border-radius: 50%;
  background: var(--danger);
  box-shadow: 0 0 0 2px var(--ground);
}

/* Exactly one flexible middle. It scrolls only if it has to. */
.pane-top {
  flex: none;
  padding: 11px var(--pad) 9px;
  display: flex;
  flex-direction: column;
  gap: var(--gap);
}

.pane-scroll {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  /* 🚨 SIDEWAYS IS OFF, EXPLICITLY — 2026-09-10, Tim: *"in the data section
     (specifically volume and research), when the user scrolls, it allows the
     user to drag the screen left and right which covers up a lot of stuff and
     doesn't show anything new. Only allow the user to drag the screen up and
     down, not left and right to the sides."*

     ⚠️ AND NOBODY WROTE `overflow-x: auto` — THE BROWSER DID, which is why this
     went unnoticed for months. CSS says that when one axis is set to a
     non-`visible` value and the other is left `visible`, the `visible` one
     computes to `auto`. So `overflow-y: auto` on its own quietly made this pane
     scrollable in BOTH directions, and every few pixels of bleed anywhere on a
     screen became a drag across the whole page. Measured before it was
     changed: the Research pane ran to 363px of scroll width in a 360px box and
     Volume to 393 in 390.

     🛑 CLIPPING IS ONLY HALF A FIX AND THE OTHER HALF IS BELOW. Research's
     age-by-muscle table is genuinely 407px wide at 360px, so hiding the
     overflow without giving that table its own scroller would put two columns
     of real data somewhere no gesture could reach — which is exactly the fault
     the sixth Data segment had on 2026-09-08. `.research-scroll` already had
     `overflow-x: auto`; what it was missing is `min-width: 0`. See it. */
  overflow-x: hidden;
  overscroll-behavior: contain;
  padding: 11px var(--pad);
  display: flex;
  flex-direction: column;
  gap: var(--gap);
}
.pane-top + .pane-scroll { padding-top: 2px; }

/* 🚨 EVERY OTHER BOX IN THIS SHEET WITH `.pane-scroll`'S SHAPE, CLIPPED FOR
   `.pane-scroll`'S REASON — 2026-09-02, the "make sure it doesn't happen across
   the site" half of Tim's report.
   Each of these sets `overflow-y: auto` and says nothing about the other axis,
   so CSS computes `overflow-x: auto` for all of them and every one has been
   silently draggable sideways since it was written. NONE of them holds anything
   wider than itself — they are vertical lists of text, a muscle panel, a
   footnote popover and a comment thread. Three pixels of bleed anywhere inside
   one is a drag across the whole surface with nothing at the end of it.

   🛑 THE ONE THING THAT MUST NOT BE DONE TO THIS LIST is to add a box whose
   `overflow-y` is NOT already set. The propagation rule runs both ways: writing
   `overflow-x: hidden` on a box with `overflow-y: visible` computes the Y axis
   to `auto` and invents a VERTICAL scroller. `.navbar` is exactly that case —
   it only takes `overflow-y` inside the desktop media query — so it is clipped
   down there, beside the rule that makes it a sidebar, and not here.

   🔒 What is DELIBERATELY not here, because these are real horizontal scrollers
   and clipping them would hide content no gesture could reach:
     .segmented       the Data tab's six segments (363px of labels in 290px)
     .research-scroll the age-by-muscle table (407px of columns in 332px)
     .chips-scroll    the filter chip row
     .people-bar      the friend strip
   All four keep their own `overflow-x: auto`. Clipping a genuinely wide thing
   is the fault the sixth Data segment had on 2026-09-08 — see docs/state.md. */
.bars,
.bests-list,
.body-foot,
.search-results,
.comment-list,
.help-pop { overflow-x: hidden; }

.pane-bottom {
  flex: none;
  padding: 9px var(--pad) 10px;
  border-top: 1px solid var(--rule);
  display: flex;
  flex-direction: column;
  gap: 7px;
}
.screen.no-nav .pane-bottom { padding-bottom: calc(10px + var(--safe-b)); }
.screen.no-nav .pane-scroll:last-child { padding-bottom: calc(11px + var(--safe-b)); }

/* ==========================================================================
   Navigation — bottom bar on phones, sidebar on desktop (same element)
   ========================================================================== */

.navbar {
  flex: none;
  height: calc(var(--bar-h) + var(--safe-b));
  padding-bottom: var(--safe-b);
  display: flex;
  background: var(--surface);
  border-top: 1px solid var(--rule);
}
.navbar a {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  /* ⚠️ BOTTOM-ALIGNED, so all five LABELS share one baseline. Centring each
     tab's own content looks identical while every icon is the same size, and
     falls apart the moment one of them is bigger: the taller middle tab
     centred its stack lower and pushed its label ~7px below the other four,
     which reads as a mistake rather than as emphasis. Aligning the words and
     letting the icon grow upward is what makes the bigger tab look deliberate. */
  justify-content: flex-end;
  padding-bottom: 8px;
  gap: 2px;
  text-decoration: none;
  color: var(--ink-faint);
  font-size: var(--fs-xs);
  font-weight: var(--fw-med);
}
.navbar a svg { width: 21px; height: 21px; }
.navbar a[aria-current="page"] { color: var(--accent); }

/* ⚠️ THE MIDDLE TAB IS BIGGER BECAUSE D4 SAYS IT IS THE POINT — the logging
   loop is the single thing this app beats a spreadsheet at, so recording
   training is the largest and most central target on every screen. Tim asked
   for five tabs with a bigger middle on 2026-08-22; the app's own rules had
   been arguing for it since the beginning.

   ⚠️ It grows the ICON, not the row. A taller navbar would cost every screen
   the same pixels for the sake of one control, and `--bar-h` is what every
   `100dvh` calculation and every `--safe-b` inset in this file is measured
   against. The circle is drawn inside the height the bar already had. */
/* 30px against 21px — half again as big, and a filled circle besides, which
   reads as bigger still. Sized so that the icon, the gap and the label clear
   the bar's own height with the labels aligned; a larger circle would either
   push the word out of the bar or break that alignment. */
/* No gap override: the 2px it inherits is what puts its label on exactly the
   same baseline as the other four. A 3px gap here left it one pixel low. */
.navbar a.nav-primary svg {
  width: 22px; height: 22px;
  padding: 4px;
  box-sizing: content-box;
  border-radius: 50%;
  background: var(--accent);
  color: var(--accent-ink);
}
/* The selected state is already carried by the fill; recolouring the glyph on
   top of it would leave accent-on-accent. The LABEL still moves, so the
   selected tab is never signalled by colour alone (Rule 5's general form). */
.navbar a.nav-primary[aria-current="page"] svg { background: var(--accent); color: var(--accent-ink); }
.navbar a.nav-primary[aria-current="page"] { color: var(--accent); }

/* ==========================================================================
   A workout in progress — the bar above the nav (2026-09-07)

   Tim's ask, modelled on Hevy: put a workout down with the ▾ in the runner's
   corner, walk around the app, and this is the way back in.

   ⚠️ A FULL-WIDTH STRIP, WHERE HEVY DRAWS A FLOATING ROUNDED PILL, and the
   difference is Rule 2 rather than taste — this app builds structure from
   hairlines and spacing, and the two things it already docks to the bottom of a
   screen (.navbar) and the top (.demo-bar) are both strips with one rule and a
   surface. A pill here would be the only card in the app.

   ⚠️ IT IS THE LAST CHILD OF .screen, NOT A SIBLING OF THE NAVBAR. #app is
   column-reverse on a phone and row on a desktop, so a sibling lands above the
   nav on one and beside it on the other. Inside the screen it is the bottom of
   the content area in both.

   ⚠️ NO ENTRANCE ANIMATION, deliberately, and this is the Rule 7 call: the bar
   is rebuilt on every route change, so a slide would replay every time anybody
   tapped anything for the length of a workout. Motion has to say what just
   happened, and "you are still on the same screen you were" is not an event.
   ========================================================================== */

.session-mini {
  flex: none;
  display: flex;
  align-items: stretch;
  /* --raised, not --raised-hi: every text token in the app is proved against
     --raised in both themes and all four palettes (tests/a11y.test.mjs), and
     --raised-hi is not in that matrix. */
  background: var(--raised);
  border-top: 1px solid var(--rule);
}
/* The way back in — everything except the bin. */
.session-mini-open {
  flex: 1;
  min-width: 0;
  display: flex;
  align-items: center;
  gap: 11px;
  min-height: 56px;
  /* 12px on the right rather than the usual --pad: the elapsed time is pushed
     to that edge, and against the bin's hairline it read as touching it. */
  padding: 8px 12px 8px var(--pad);
  color: inherit;
  text-decoration: none;
}
/* The bottom-most element owes the home-indicator inset, and on a tabbed route
   that is the navbar underneath — which pays it already. This only applies where
   there is no nav below the bar. */
.screen.no-nav .session-mini { padding-bottom: calc(8px + var(--safe-b)); }
/* ...and then the screen's own bottom must stop paying it, because with a bar
   under it the screen is no longer against the edge of the display. Same shape
   as `.demo-bar + .topbar` at the other end. */
.screen.no-nav.has-mini .pane-bottom { padding-bottom: 10px; }

.session-mini-open:active { background: var(--rule-soft); }

/* Tim's bin, 2026-09-07. ⚠️ 52px wide against the 44px minimum, and separated
   from the rest of the bar by its own hairline: this is the one control on the
   phone that destroys a workout, and it lives permanently a few pixels from the
   Home tab. The margin is what stops a thumb travelling to the nav from
   clipping it, and the rule is what says it is a different thing from the pill
   it sits in rather than the right-hand end of it. */
.mini-del {
  flex: none;
  width: 52px;
  display: grid;
  place-items: center;
  background: none;
  border: 0;
  border-left: 1px solid var(--rule);
  color: var(--ink-faint);
  cursor: pointer;
}
.mini-del:active { color: var(--danger); background: var(--rule-soft); }

/* The arrow points UP, at the workout the ▾ in the runner put down here. */
.mini-arrow {
  flex: none;
  width: 34px; height: 34px;
  display: grid; place-items: center;
  border-radius: 50%;
  background: var(--accent);
  color: var(--accent-ink);
}
.mini-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.mini-head { display: flex; align-items: center; gap: 7px; min-width: 0; }
.mini-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--accent); }
.mini-name {
  min-width: 0;
  font-size: var(--fs-lg); font-weight: var(--fw-bold);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
/* Pushed to the far edge so the elapsed time sits in the same place whatever the
   workout is called — a clock that moves with the length of a name reads as a
   different number each time you glance at it. */
.mini-clock { flex: none; margin-left: auto; font-size: var(--fs-md); font-weight: var(--fw-med); color: var(--ink-soft); }
.mini-now {
  font-size: var(--fs-md); color: var(--ink-soft);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* A segmented control that is subordinate to another one above it — the
   Months/Years switch under the Data tabs. Same control, quieter: it is a
   choice WITHIN the screen the row above already chose. */
.segmented.sub { padding: 2px; }
.segmented.sub .seg { min-height: 34px; font-size: var(--fs-md); }

/* The sidebar brand row exists only on desktop. On mobile the navbar is a
   bottom tab bar, where an app title and an account button would both be
   wrong — the topbar carries the profile button there instead. */
.nav-brand { display: none; }

/* ==========================================================================
   Flat structure — hairlines instead of cards
   ========================================================================== */

/* Kept as a semantic grouping only; it draws nothing. */
.card { display: flex; flex-direction: column; gap: var(--gap); }

.section-label {
  font-size: var(--fs-sm);
  font-weight: var(--fw-bold);
  color: var(--ink-faint);
  padding-top: 2px;
}

/* A label UNDER another label — a programme's name inside "Start a workout" on
   the Record tab. Two identical all-caps rows in a stack read as two sections
   at the same level, which is the opposite of what the nesting means. Sentence
   case and no tracking make the second one subordinate without another size. */
.section-label.sub {
  text-transform: none;
  letter-spacing: 0;
  font-size: var(--fs-sm);
  font-weight: var(--fw-bold);
  color: var(--ink-soft);
}

/* ⚠️ THE PROGRAMME'S NAME ON THE RECORD SCREEN, and it is a heading rather than
   a caption on purpose. Tim, 2026-08-25: *"make the title of the workout system
   more clear because that's the first thing that the user will try to find."*
   It used to be `.section-label sub` — 11.5px grey — which made the one thing
   being searched for quieter than the 15.5px workout names underneath it, and
   with a single system it was not rendered at all. Full ink, because a heading
   in --ink-faint is what caused that inversion. */
/* ⚠️ It must hug the list BELOW it, not float between two. The pane lays its
   children out on one even `gap`, which put a second programme's name exactly
   halfway between the last workout of the previous one and the first of its
   own — so with two systems on screen it was ambiguous which group it headed.
   Extra space above, none below; a heading belongs to what follows it. */
.sys-head {
  font-size: var(--fs-lg);
  font-weight: var(--fw-bold);
  color: var(--ink);
  margin-top: 8px;
  margin-bottom: -4px;
}
.pane-scroll > .section-label + .sys-head { margin-top: 0; }
/* ~~`.sys-group` rules~~ — DELETED 2026-09-19 with the folding systems. The
   heading is a <button> again rather than a <summary>, so the two rules that
   reached it one level deeper inside a <details> have nothing left to reach. */

/* ⚠️ THE HEADING IS A CONTROL AGAIN, AND IT SWITCHES RATHER THAN UNFOLDS
   (2026-09-19). As a `.row` it takes the app's own 46px target and press-back,
   so `margin-bottom: -4px` would pull the list up into its padding — the row
   has its own space below it and does not need the pane's gap closed. */
button.sys-head { margin-bottom: 0; }

/* The word next to the chevron. Everywhere else a chevron alone means "go and
   look at that"; this one opens a sheet over the screen you are on, which is a
   different promise and has to be named — the same finding that put "Start" on
   the Record rows rather than a play triangle. Quiet, because the programme's
   NAME is what the eye is looking for here (2026-08-25) and the control that
   changes it must not outweigh it. */
.row-switch {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  flex: none;
  font-size: var(--fs-md);
  font-weight: var(--fw-med);
  color: var(--ink-soft);
}
.row-switch svg { opacity: 0.75; }

/* The current programme in the switcher sheet. The `.tag` beside the name is
   what says so in words; this is the same statement in the margin, for a list
   being scanned rather than read. Colour is not carrying it alone. */
.row.is-current { box-shadow: inset 2px 0 0 var(--accent); }

/* ⚠️ "Start", not a chevron. Everywhere else in this app a chevron means go and
   look at that; on the Record screen the same row BEGINS A SESSION, and Tim
   read it as a detail link after a gym session. A word rather than a bare
   triangle: this is the tap whose cost of being misread is highest.

   Not a filled button — it is not a second control, it is what the row does.
   Accent ink and the app's pill shape (Rule 2: no border box) say "this acts"
   without adding a target inside a target. */
.row-start {
  flex: none;
  display: inline-flex; align-items: center; gap: 3px;
  padding: 4px 9px 4px 10px;
  border-radius: 999px;
  background: var(--raised);
  color: var(--accent);
  font-size: var(--fs-sm); font-weight: var(--fw-bold);
  letter-spacing: 0.01em;
}
/* ⚠️ A DARKER GOLD IN THE LIGHT THEME, and the audit is what found it. The
   light accent (#96660F) measures **4.08:1** on --raised at 12px — below AA —
   and it shipped that way for the few hours between this pill being built and
   the audit next running. It is the same weakness the palette notes already
   record: --accent is the token with the least headroom in the light theme,
   because it has to stay recognisably gold on a pale ground.

   Same hue, darkened until it clears: 4.64:1 on --raised, 5.13 on --ground.
   ⚠️ Scoped to this pill rather than changed on the token, because --accent is
   also a FILL under --accent-ink in a dozen places, and darkening it globally
   would move every one of those pairs to fix one of them. */
:root[data-theme="light"] .row-start { color: #8B5E0D; }
/* The same scoped fix, once per palette: the pill is small bold text on
   --raised, the worst surface either accent sits on, so each palette gets an
   accent darkened a step for exactly this spot. Checked in tests/a11y.test.mjs
   against that palette's own light --raised. */
:root[data-theme="light"][data-palette="teal"] .row-start { color: #0C6357; }
:root[data-theme="light"][data-palette="indigo"] .row-start { color: #3A46B4; }
:root[data-theme="light"][data-palette="ember"] .row-start { color: #7E550B; }
.row-start svg { display: block; }

/* Rows are separated by rules, not wrapped in borders. Negative margins let
   the hairline run the full width while text stays aligned to the gutter. */
.list {
  display: flex;
  flex-direction: column;
  margin-inline: calc(var(--pad) * -1);
}
.list > * { padding-inline: var(--pad); }
.list > * + * { border-top: 1px solid var(--rule-soft); }

.row {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 46px;
  padding-block: 9px;
  text-align: left;
  width: 100%;
  color: inherit;
  background: none;
}
.row:active { background: var(--raised); }
.row-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.row-title { font-weight: var(--fw-med); font-size: var(--fs-lg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.row-sub { font-size: var(--fs-md); color: var(--ink-faint); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* The default row subtitle is one clipped line, which is right for a name but
   wrong for a sentence. `.wrap` is the sentence version. */
.row-sub.wrap { white-space: normal; overflow: visible; line-height: 1.4; }
/* Same for a title, and it is the programme lists that need it: the rating
   badge takes real width off the row, and a clipped "Volume Landmarks
   Hypertr..." trades the one thing that identifies the programme for the
   ornament beside it. Rule 3 — the name is the content. */
.row-title.wrap { white-space: normal; overflow: visible; line-height: 1.25; }
.row-meta { flex: none; font-size: var(--fs-md); color: var(--ink-faint); font-variant-numeric: tabular-nums; }
/* A row that is information rather than a destination. */
.row.static { cursor: default; align-items: flex-start; }
.row.static:active { background: none; }
.row.static .row-title { white-space: normal; }
.row.static .row-sub { white-space: normal; overflow: visible; line-height: 1.4; }

/* Small inline label beside a title — "Added", and whatever comes next.
   ⚠️ nowrap is load-bearing once a tag holds more than one word. A pill is a
   single object, and letting it break mid-phrase paints TWO pills — the
   background and both rounded ends are drawn per line box, so "grows with the
   goal" rendered as a chip reading "GROWS" above a chip reading "WITH THE
   GOAL". It reads as two unrelated labels. Whole pill, dropped to the next
   line. Confirmed from getClientRects(), which is the only thing that tells
   you a chip occupies two line boxes. */
.tag {
  margin-left: 7px;
  padding: 1px 7px;
  border-radius: 999px;
  background: var(--raised);
  color: var(--ink-faint);
  font-size: var(--fs-xs);
  font-weight: var(--fw-bold);
  letter-spacing: .04em;
  text-transform: uppercase;
  vertical-align: middle;
  white-space: nowrap;
}

/* How a ready-made system explains itself. Paragraphs, because these are
   instructions someone has to read, not a caption. */
.preset-notes { display: flex; flex-direction: column; gap: 8px; }
.preset-notes p { margin: 0; font-size: var(--fs-md); line-height: 1.5; color: var(--ink-soft); }

/* ── A system's weekly plan, as boxes (2026-09-16) ────────────────────────
   Tim: *"If the workout system does have this daily planner then show it as
   boxes at the top of the workout system when you click on it."*

   🚨 "BOXES" AND RULE 2 ("no boxes") ARE NOT IN CONFLICT HERE, and it is worth
   saying which one won. Rule 2 forbids a bordered CARD used to group things
   that spacing could have grouped — a box drawn around content to say "these
   belong together". A day slot is not that: the box IS the day, the way
   `.cal-cell` is a date on the calendar. So this borrows the calendar's answer
   exactly — a `--rule-soft` fill, a 6px radius, and NO border anywhere.

   🚨 IT WRAPS; IT DOES NOT SCROLL SIDEWAYS. A row of 14 boxes is the case that
   breaks a phone, and the two ways out were a horizontal scroller (like
   `.chips-scroll`) or a wrapping grid. The scroller loses: a week you cannot
   see all of is not a plan, it is a filmstrip, and the whole point of the boxes
   is taking in the shape of the programme at a glance. `auto-fill` with a 92px
   floor gives 3 columns in the 332px a 360px screen leaves after `--pad` — so
   14 days is 5 rows and 7 days is 3, both entirely within the pane's own
   vertical scroll, and NOTHING is horizontally scrollable (Rule 1). A wide
   screen fills the same rule out to a full week across.

   ⚠️ min-width: 0 on the grid, for the reason `.research-scroll` needed it: it
   is a flex item in `.pane-scroll`'s column, and without it a long workout name
   sets the floor and pushes the grid past the viewport. The name wraps and, if
   a single word cannot, breaks — a plan that overflows its box sideways is the
   fault this whole layout exists to avoid. */
.plan { display: flex; flex-direction: column; gap: 6px; }
.plan-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(92px, 1fr));
  gap: 6px;
  min-width: 0;
}
.plan-day {
  min-width: 0;
  border-radius: 6px;
  padding: 5px 7px 7px;
  display: flex;
  flex-direction: column;
  gap: 2px;
  background: var(--rule-soft);
}
/* Same caption voice, and the same measured reason, as `.cal-day`: --rule-soft
   is the worst background this app puts small text on, so the day label takes
   the readable step rather than --ink-faint. */
.plan-dow {
  font-size: var(--fs-xs);
  font-weight: var(--fw-bold);
  color: var(--ink-soft);
  line-height: 1.2;
}
.plan-slot {
  font-size: var(--fs-md);
  font-weight: var(--fw-med);
  color: var(--ink);
  line-height: 1.25;
  overflow-wrap: anywhere;
}
/* Rest is a real answer, so it is legible; it is just not a workout, so it is
   not the loudest thing in the box. An UNSET day is quieter still and is an em
   dash rather than a word, because there is no word for it that would not be
   the app guessing what somebody meant. */
.plan-slot.is-rest { font-weight: var(--fw-med); color: var(--ink-soft); }
.plan-slot.is-none { font-weight: var(--fw-med); color: var(--ink-faint); }

/* ── and the form behind the pencil that builds one ──────────────────────── */
.plan-edit { display: flex; flex-direction: column; gap: var(--gap); }
.plan-len { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
/* One row per day: the label holds a fixed column so the selects line up, and
   `1 1 0` on the select is what lets it shrink instead of pushing the row wide
   at 360px. The label is the SAME short token the box will show — `Mon`,
   `Day 14` — so the form and the boxes name each day identically; the select
   carries the full name for speech. `Day 14` is the widest of them and 52px
   holds it at this size. */
.plan-row { display: flex; align-items: center; gap: 8px; }
.plan-row-day {
  flex: none;
  width: 52px;
  font-size: var(--fs-sm);
  font-weight: var(--fw-bold);
  color: var(--ink-soft);
}
.plan-row .input { flex: 1 1 0; min-width: 0; }

/* Whether a programme is really the named person's is the ONE thing on this
   screen that must not be skimmed past, and `.chart-caption.warn` — a 12px
   grey line — rendered it as quieter than the notes underneath it. This is a
   hairline rule and full-strength ink, not a card: no border box, no fill, no
   icon (Rule 2). It is the only place in the app that uses --danger for
   something that is not an error, which is the point — nothing else on the
   screen looks like it. */
.preset-warning {
  border-left: 2px solid var(--danger);
  padding-left: 11px;
  font-size: var(--fs-md); line-height: 1.5; color: var(--ink);
}

/* "The original of this programme changed" — 2026-09-20. Deliberately the same
   hairline-rule idiom as `.preset-warning` above it and deliberately NOT its
   colour: the accent, because the original moving on is news rather than a
   fault, and red here would tell everybody who ever copied a programme that
   they had broken it (Rule 6). No card, no fill, no icon (Rule 2). */
.preset-update {
  border-left: 2px solid var(--accent);
  padding: 2px 0 2px 11px;
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  text-align: left;
  min-height: 44px;
}
.preset-update-main { flex: 1 1 auto; min-width: 0; }
.preset-update-title { font-size: var(--fs-lg); font-weight: var(--fw-med); color: var(--ink); line-height: 1.35; }
.preset-update-sub { font-size: var(--fs-md); color: var(--ink-faint); line-height: 1.4; margin-top: 2px; }

.update-list { display: flex; flex-direction: column; gap: 14px; }
.update-note { margin: 0; font-size: var(--fs-md); line-height: 1.5; color: var(--ink-soft); }
.update-row { display: flex; flex-direction: column; gap: 3px; }
.update-what { font-size: var(--fs-lg); line-height: 1.4; color: var(--ink); }
/* The status is the sentence that makes the list trustworthy, so it is words
   rather than a dot: a colour alone cannot say "left alone because you changed
   it" (Rule 5's general form). */
.update-status { font-size: var(--fs-md); line-height: 1.35; }
.update-status.ready { color: var(--accent); }
.update-status.edited,
.update-status.manual { color: var(--ink-faint); }

.row-chev { color: var(--ink-faint); flex: none; display: flex; }
.row-chev svg { width: 17px; height: 17px; display: block; }

.empty {
  padding: 26px 8px;
  color: var(--ink-faint);
  display: flex;
  flex-direction: column;
  gap: 8px;
  align-items: center;
  text-align: center;
}
.empty-title { font-size: var(--fs-lg); font-weight: var(--fw-med); color: var(--ink-soft); }
.empty p { margin: 0; font-size: var(--fs-md); max-width: 34ch; line-height: 1.45; }

/* ==========================================================================
   Buttons
   ========================================================================== */

.btn {
  min-height: 46px;
  padding: 0 16px;
  border-radius: var(--r);
  font-weight: var(--fw-med);
  font-size: var(--fs-lg);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 7px;
  background: var(--raised);
  color: var(--ink);
  transition: transform .06s ease;
  /* ⚠️ Several buttons in this app are <a> elements, because they navigate and
     a link is the honest element for that. Without this the UA underlines them
     and one button in a row of identical ones looks different for no reason —
     visible on "Leave the demo", and the same fault the social review found in
     the friend rows. Fixed on the class so it covers the next one too. */
  text-decoration: none;
}
.btn:active { transform: scale(.985); }
.btn svg { width: 18px; height: 18px; }
.btn.primary { background: var(--accent); color: var(--accent-ink); }
.btn.good    { background: var(--good); color: #08130D; }
.btn.ghost   { background: transparent; box-shadow: inset 0 0 0 1px var(--rule); color: var(--ink-soft); }
.btn.danger  { background: transparent; box-shadow: inset 0 0 0 1px var(--danger); color: var(--danger); }
.btn.block   { width: 100%; }
.btn.lg      { min-height: 52px; font-size: var(--fs-lg); }
.btn:disabled { opacity: .38; pointer-events: none; }

.btn-row { display: flex; gap: 8px; }
.btn-row > * { flex: 1; }

/* ==========================================================================
   Forms
   ========================================================================== */

/* "or" divider between two sign-in routes. Hairline, per Rule 2 — no boxes. */
.or-rule { display: flex; align-items: center; gap: 10px; color: var(--ink-faint); font-size: var(--fs-sm); }
.or-rule::before, .or-rule::after { content: ''; flex: 1; height: 1px; background: var(--rule); }

.field { display: flex; flex-direction: column; gap: 5px; }
.field label { font-size: var(--fs-sm); font-weight: var(--fw-med); color: var(--ink-faint); }
/* ⚠️ 13.5px, RAISED FROM 12.5 ON 2026-08-25. This class is the app's prose —
   the caveats, the citations, the explanations — and 12.5px was the single most
   common size in this stylesheet, with 11.5 second. Tim, from the gym: "the
   grey text is really challenging to spot and read". It was not only the
   colour; the app's DEFAULT body text was two steps below its headings and
   there was almost nothing in between. */
.field-help { font-size: var(--fs-md); color: var(--ink-faint); line-height: 1.45; }

/* Inline text link, for when a sentence needs to point somewhere and a button
   would shout. Underlined on the words only — the accent alone is not enough
   to mark a link. */
.text-link { color: var(--accent); text-decoration: underline; text-underline-offset: 2px; }

.input, select.input, textarea.input {
  width: 100%;
  min-height: 44px;
  padding: 10px 12px;
  background: var(--raised);
  border: 0;
  border-radius: var(--r);
  color: var(--ink);
  appearance: none;
}
.input::placeholder { color: var(--ink-faint); }
.input:focus { outline: none; box-shadow: inset 0 0 0 1.5px var(--accent); }
textarea.input { min-height: 64px; resize: vertical; line-height: 1.45; }

/* A note somebody sent, on the developer's inbox screen (2026-09-04).
   ⚠️ `pre-wrap` and `overflow-wrap`, because this is the only free text in the
   app written by one person for another: it keeps the line breaks they typed,
   and a pasted URL with no spaces in it wraps instead of widening the card and
   pushing the page sideways — which Rule 1 forbids and which nothing else here
   could produce. */
.note-body {
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  font-size: var(--fs-lg); line-height: 1.45;
  margin: 4px 0 8px;
}

select.input {
  background-image: linear-gradient(45deg, transparent 50%, var(--ink-faint) 50%),
                    linear-gradient(135deg, var(--ink-faint) 50%, transparent 50%);
  background-position: calc(100% - 17px) 50%, calc(100% - 12px) 50%;
  background-size: 6px 6px, 6px 6px;
  background-repeat: no-repeat;
  padding-right: 34px;
}

.chips { display: flex; flex-wrap: wrap; gap: 6px; }

/* A filter row that scrolls sideways instead of wrapping. Rule 1 allows inner
   scrolling for a genuinely unbounded list, and sixteen muscle groups above a
   272-row list is exactly that: wrapped, the filter was taller than the thing
   it filters. `flex: none` because this sits in a column flex parent that would
   otherwise crush it, and the scrollbar is hidden because a swipe is the
   gesture here — the partial chip at the right edge is what says "more". */
.chips-scroll {
  flex: none;
  flex-wrap: nowrap;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  /* The gutter, so the first and last chip are not welded to the edge. */
  margin-inline: calc(var(--pad) * -1);
  padding-inline: var(--pad);
}
.chips-scroll::-webkit-scrollbar { display: none; }
.chips-scroll > .chip { flex: none; }
.chip {
  padding: 7px 12px;
  border-radius: 999px;
  background: var(--raised);
  font-size: var(--fs-md);
  font-weight: var(--fw-med);
  color: var(--ink-soft);
  white-space: nowrap;
}
.chip[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); }

/* The chosen profile photo, in the account button and on the Account screen.
   ⚠️ EVERY ONE OF THESE IS ABSOLUTELY POSITIONED AGAINST A FIXED-SIZE PARENT,
   and none of them may be sized by the picture. A profile photo is the one
   image in this app whose intrinsic size (256px) is far larger than its box, so
   it is the one place where "the image decides" is a visible catastrophe rather
   than a small reflow. `inset: 0` against a parent with real pixel dimensions
   cannot be resolved any other way by any engine. */
.avatar-img {
  position: absolute; inset: 0;
  width: 100%; height: 100%; max-width: 100%; max-height: 100%;
  object-fit: cover; border-radius: 50%; display: block;
}
.avatar-card { display: flex; align-items: flex-start; gap: 14px; }
.avatar-face {
  position: relative;
  flex: none; width: 64px; height: 64px; border-radius: 50%;
  background: var(--raised); color: var(--ink-soft);
  display: grid; place-items: center; overflow: hidden;
}
.avatar-face img {
  position: absolute; inset: 0;
  width: 100%; height: 100%; max-width: 100%; max-height: 100%;
  object-fit: cover;
}
.avatar-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 7px; }
.avatar-actions { display: flex; gap: 8px; flex-wrap: wrap; }

/* Positioning a photo inside the circle (js/image-crop.js).
   ⚠️ The stage IS the crop square and the circle is inscribed in it, so what
   the ring encloses is exactly what gets saved. The scrim and the ring are
   deliberately NOT themed: they sit on top of somebody's photograph rather
   than on a surface, so they answer to the picture and not to the room —
   the same argument the body poster's tokens make. */
.crop-body { display: flex; flex-direction: column; gap: 12px; }
.crop-stage {
  position: relative; overflow: hidden;
  width: 100%; max-width: 300px; aspect-ratio: 1; margin: 0 auto;
  border-radius: 10px; background: #000;
  touch-action: none;                 /* or iOS scrolls the sheet instead */
  /* Prefixed too — this control exists for an iPhone, and dragging a photo on
     an unprefixed-only rule selects the sheet's text on older iOS Safari. */
  cursor: grab; -webkit-user-select: none; user-select: none;
}
.crop-stage:active { cursor: grabbing; }
.crop-img { position: absolute; max-width: none; display: block; pointer-events: none; }
.crop-hole {
  position: absolute; inset: 0; border-radius: 50%; pointer-events: none;
  box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.55);
  border: 2px solid rgba(255, 255, 255, 0.9);
}
.crop-controls { display: flex; align-items: center; gap: 10px; color: var(--ink-faint); }
.crop-controls svg { flex: none; }
.crop-zoom { flex: 1; min-width: 0; accent-color: var(--accent); height: 44px; }
.crop-zoom:disabled { opacity: 0.4; }

/* Settings' colour picker: each chip carries its palette's accent as a dot,
   so the choice is visible before it is made. The dot keeps a hairline ring
   so the gold dot never vanishes into the pressed gold chip behind it. */
.palette-chip { display: inline-flex; align-items: center; gap: 6px; }
.palette-dot {
  width: 12px; height: 12px; border-radius: 50%; flex: none;
  box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35);
}

/* ==========================================================================
   Stepper — the core in-gym control.
   Two fields sit side by side so weight and reps fit on one screen together.
   ========================================================================== */

.steppers {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
  gap: 8px;
}

.stepper { display: flex; flex-direction: column; gap: 5px; min-width: 0; }

/* 🆕 2026-09-27 THE BIG NUMBERS SIT ON ONE LINE. Tim: "make sure the two
   numbers on reps and weight align with each other. Right now they're a
   little off of each other depending on the detials above them." A caption
   above one field (a % target that wraps to two lines, a reps guess, none)
   pushed only that field's number down. Each stepper now borrows four shared
   rows from the pair — name · caption · number · plates — so the tallest
   caption sets the caption row for BOTH and the number rows start level.
   The margins reproduce the old 5px flex gaps exactly (an empty caption slot
   still costs its 5px, as it did), so nothing else moves.
   Browsers without subgrid keep the flex column above. */
@supports (grid-template-rows: subgrid) {
  .steppers > .stepper {
    display: grid;
    grid-row: span 4;
    grid-template-rows: subgrid;
    row-gap: 0;
  }
  .steppers > .stepper > .stepper-label    { grid-row: 1; align-self: end; margin-bottom: 5px; }
  .steppers > .stepper > .step-est         { grid-row: 2; align-self: end; margin-bottom: 5px; }
  .steppers > .stepper > .stepper-controls { grid-row: 3; }
  .steppers > .stepper > .step-unit        { grid-row: 4; align-self: start; margin-top: 5px; }
}

.stepper-label {
  font-size: var(--fs-sm);
  font-weight: var(--fw-bold);
  color: var(--ink-faint);
  text-align: center;
}
.stepper-suffix { font-style: normal; color: var(--accent); }
.stepper-suffix::before { content: " · "; color: var(--ink-faint); }

.stepper-controls { display: flex; align-items: center; gap: 5px; }

.step-btn {
  width: 46px; height: 52px;
  flex: none;
  border-radius: var(--r);
  background: var(--raised);
  color: var(--ink);
  display: grid;
  place-items: center;
  transition: transform .06s ease, background .1s ease;
}
.step-btn:active { transform: scale(.93); background: var(--raised-hi); }
.step-btn svg { width: 20px; height: 20px; }

.step-value {
  flex: 1;
  min-width: 0;
  width: 100%;
  text-align: center;
  font-variant-numeric: tabular-nums;
  font-size: 30px;
  font-weight: var(--fw-med);
  letter-spacing: -0.03em;
  background: transparent;
  border: 0;
  padding: 0;
}
.step-value:focus { outline: none; color: var(--accent); }
.step-unit { font-size: var(--fs-sm); color: var(--ink-faint); text-align: center; font-weight: var(--fw-med); }

/* The plate breakdown, when the same slot is holding one (2026-09-18, js/plates.js).
   ⚠️ --ink-soft rather than --ink-faint, and this is the whole of the styling
   argument: "5 lb steps" is a footnote about the control, but "bar + 45, 45, 25
   each side" is an INSTRUCTION being read at arm's length with a bar in the
   other hand. One step up the ink scale, not a colour of its own.
   Tabular figures so a two-digit and a three-digit plate do not jitter the list
   as the number is stepped. It is allowed to wrap — a truncated plate list is a
   wrong plate list — and `balance` keeps a wrapped one from orphaning "side".
   🛑 NO MOTION, deliberately: Rule 7 bans it on the logging path, and this line
   changes on every tap of ±. */
.step-unit.is-plates {
  color: var(--ink-soft);
  font-variant-numeric: tabular-nums;
  text-wrap: balance;
}

/* ---- Plate drawing (2026-09-26) ----
   Tim: "a visual of one side of the bar that has 2 10 plates visually on it …
   Color them differently aswell." The slot now holds a picture of ONE sleeve
   (js/plates.js plateDrawing, drawn by ui.js plateSvg); the sentence above is
   its aria-label/title. `has-plates` is on every stepper that CAN show plates
   and holds the slot at the drawing's 22px, so stepping onto a weight no plates
   make (the steps hint) never moves the rows below.
   Colours are the calibrated-plate convention (25 kg red, 20 blue, 15 yellow,
   10 green, 5 white, 2.5 black, 1.25 chrome; pound plates take the colour of
   the nearest kilo plate) — fixed hues, not theme tokens, because a blue 20 is
   blue in every gym. A thin --ink-faint outline is what lets the black plate
   read on the dark ground and the white one on the light.
   🔄 2026-09-27, Tim: "extreamly unclear … make it so that it takes up some
   more space and each plate actually has the label on it (45, 35, 25, 10, 5,
   etc)? Also make the design more clear its the end of a plate." 48px tall
   (was 22), every plate carries its number, corners rounded so each plate is
   its own disc, and the bar END is drawn: clip, bare sleeve, cap.
   Numbers: white on red / blue / black, near-black on yellow / green / white /
   chrome — whichever contrasts more with that hue; a number written beside a
   thin plate (`is-ink`) takes the page's --ink. ⚠️ plates.js copies this
   font size (11.5px = --fs-sm) to decide where each number fits.
   🛑 NO MOTION: rebuilt on every tap of ± (Rule 7). */
.step-unit.has-plates {
  min-height: 48px;
  display: flex; align-items: center; justify-content: center;
}
/* The steps hint in a plate slot (a weight no plates make) sits at the top,
   level with the reps field's own "1 rep steps" beside it. */
.step-unit.has-plates:not(.is-plates) { align-items: flex-start; }
.plate-draw { display: block; max-width: 100%; height: auto; overflow: visible; }
.plate-draw .pd-shaft,
.plate-draw .pd-sleeve { fill: var(--ink-faint); }
.plate-draw .pd-collar,
.plate-draw .pd-clip,
.plate-draw .pd-cap,
.plate-draw .pd-frame  { fill: var(--ink-soft); }
.plate-draw .pd-plate  { stroke: var(--ink-faint); stroke-opacity: .55; stroke-width: .75; }
.plate-draw .pd-label  {
  font-size: var(--fs-sm); font-weight: var(--fw-bold);
  font-variant-numeric: tabular-nums;
}
.plate-draw .pd-label.is-light { fill: #FFFFFF; }
.plate-draw .pd-label.is-dark  { fill: #15171A; }
.plate-draw .pd-label.is-ink   { fill: var(--ink); }
.plate-draw .pd-red    { fill: #D9463B; }
.plate-draw .pd-blue   { fill: #3673D6; }
.plate-draw .pd-yellow { fill: #E9C23A; }
.plate-draw .pd-green  { fill: #2F9E57; }
.plate-draw .pd-white  { fill: #F4F4F0; }
.plate-draw .pd-black  { fill: #1E2022; }
.plate-draw .pd-chrome { fill: #B4BAC0; }

/* ==========================================================================
   Session runner
   ========================================================================== */

/* The day a session is recorded for. Reads as part of the subtitle, not as a
   form field — it is right nearly every time and should not shout. It only
   gains the accent when it has been moved off today, which is exactly when
   someone needs to notice it. */
.session-sub { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
.session-sub-dot { opacity: .5; }
.session-date {
  font: inherit; font-variant-numeric: tabular-nums; color: var(--ink-soft);
  background: none; border: 0; border-bottom: 1px dashed var(--rule);
  padding: 0 0 1px; cursor: pointer; min-width: 0;
}
.session-date::-webkit-calendar-picker-indicator { opacity: .55; cursor: pointer; }
.session-date.is-moved {
  color: var(--accent); border-bottom-color: var(--accent); font-weight: var(--fw-med);
}
.session-date-note {
  color: var(--accent); font-size: var(--fs-xs); font-weight: var(--fw-bold);
  text-transform: uppercase; letter-spacing: .06em;
}
/* The workout clock (2026-09-23): the date's quiet dashed voice, since it is
   also a session fact you can tap to change. Tabular digits so it does not
   jitter each second; accent while paused, like a moved date. */
.session-clock {
  font: inherit; font-variant-numeric: tabular-nums;
  color: var(--ink-soft); background: none; border: 0;
  border-bottom: 1px dashed var(--rule); padding: 0 0 1px; cursor: pointer;
}
.session-clock.is-paused { color: var(--accent); border-bottom-color: var(--accent); font-weight: var(--fw-med); }

/* ── Location (0m): a typed label in the session header ────────────────────
   Same quiet dashed-underline voice as the date beside it — both are session
   facts you can change. Capped so a long gym name cannot push the date off a
   360px header; .session-sub wraps if they still cannot share a line. */
.session-loc {
  display: inline-flex; align-items: center; gap: 4px;
  font-size: var(--fs-sm); color: var(--ink-soft);
  border-bottom: 1px dashed var(--rule); padding: 0 0 1px;
  min-width: 0; max-width: 15ch;
}
.session-loc-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.session-loc.is-empty { color: var(--ink-faint); }
.session-loc svg { flex: none; }
/* A description is a sentence, not a gym name, so it gets more of the sub-line
   than 15ch — enough to recognise what you wrote without pushing the date off a
   360px header. The full text is on the chip's own label either way. */
.session-note { max-width: 24ch; }

/* Technical detail behind a disclosure, so a raw error string is diagnosable
   without being the first thing a user reads. */
.tech-detail { margin-top: 4px; }
/* The browser's own disclosure triangle, rather than a CSS-escaped glyph — an
   escaped one rendered as a missing-character box on Windows. */
.tech-detail summary {
  cursor: pointer; font-size: var(--fs-sm); font-weight: var(--fw-med); color: var(--ink-faint);
  padding: 4px 0;
}
.tech-detail .mono { word-break: break-all; font-size: var(--fs-sm); }

/* A workout whose every exercise counts as a benchmark. The badge is what
   replaces listing those benchmarks a second time under the day. */
.bench-badge {
  margin-left: 7px; padding: 1px 6px; border-radius: 4px;
  background: var(--accent-dim); color: var(--accent);
  font-size: var(--fs-xs); font-weight: var(--fw-bold); letter-spacing: .07em;
  text-transform: uppercase; vertical-align: middle;
}

/* Editing a recorded workout. One row per set: its number, its steppers, and a
   way to remove it. */
.edit-ex { display: flex; flex-direction: column; gap: 8px; }
.edit-set {
  display: flex; flex-direction: column; gap: 6px;
  padding-top: 10px; border-top: 1px solid var(--rule-soft);
}
.edit-set:first-of-type { border-top: 0; padding-top: 0; }
.edit-set-head { display: flex; align-items: center; justify-content: space-between; }
.edit-set-num {
  font-size: var(--fs-sm); font-weight: var(--fw-bold); color: var(--ink-faint);
}

/* Rest timer. Counts up from the last set; a target is optional, and only with
   one does the bar get to say the rest is over. */
.rest-bar {
  flex: none; display: flex; align-items: center; gap: 10px;
  padding: 7px var(--pad); border-top: 1px solid var(--rule-soft);
}
.rest-reset {
  background: none; border: 0; padding: 0; cursor: pointer;
  color: var(--ink); font-size: var(--fs-xl); font-weight: var(--fw-med); line-height: 1;
}
.rest-clock { font-variant-numeric: tabular-nums; letter-spacing: -0.02em; }
.rest-label { flex: 1; min-width: 0; font-size: var(--fs-sm); color: var(--ink-faint); font-weight: var(--fw-med); }
.rest-target { flex: none; font-size: var(--fs-sm); }
.rest-bar.is-done .rest-reset { color: var(--good); }
.rest-bar.is-done .rest-label { color: var(--good); font-weight: var(--fw-bold); }

/* ── People — who the steppers record for (guests, Open work 0e) ──────────
   One chip per person, above the progress bar. The add control keeps its
   words while the session is solo — the feature has to be findable the day
   a friend turns up — and collapses to a quiet + once names exist. The
   chips reuse .chip, so the pill, the 44px invisible hit target and the
   aria-pressed accent state are the ones every other chip already has. */
.people-bar {
  flex: none; display: flex; gap: 6px; align-items: center;
  padding: 8px var(--pad) 0;
  overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none;
}
.people-bar::-webkit-scrollbar { display: none; }
.person-chip { display: inline-flex; align-items: center; gap: 5px; font-size: var(--fs-md); }
.person-add { background: none; color: var(--ink-soft); }

/* ⚠️ A FRIEND'S CHIP IS MARKED, and it is not decoration (2026-08-29). A saved
   person's sets stop on this phone; a friend's are offered to their account at
   Finish. Those are different promises, and somebody switching between two
   names is entitled to know which one they are typing into before they finish
   rather than on the summary screen. A glyph, not a colour — the chip's colour
   already carries which person is ACTIVE, and a second meaning on the same
   channel is how a scale stops being readable. */
.person-chip.is-account svg { color: var(--accent); opacity: .85; }
.person-chip[aria-pressed="true"].is-account svg { color: inherit; opacity: 1; }

/* ⚠️ REMOVE-A-PERSON (2026-08-30) — only ever on the chip you are already
   recording for, so a destructive control is never sitting next to the chip
   somebody is aiming at to SWITCH. The wrapper exists because the ✕ has to be
   a SIBLING of the chip rather than a child: a button inside a button is
   invalid HTML and would need a stopPropagation that holds until the next
   control is added. `.set-del` learned exactly this on the set row.

   Deliberately smaller than 44px — the same call `.set-del` makes, and for the
   same reason: this is the one control on the bar that should not be easy to
   hit by accident. It is reachable, labelled, and two taps from anywhere. */
.person-wrap { display: inline-flex; align-items: center; flex: none; }
.person-wrap .person-chip { border-top-right-radius: 0; border-bottom-right-radius: 0; }
.person-del {
  display: flex; align-items: center; padding: 0 7px;
  align-self: stretch;
  border: 1px solid var(--accent); border-left: none;
  border-radius: 0 999px 999px 0;
  background: var(--accent); color: var(--accent-ink);
}
.person-del svg { width: 13px; height: 13px; display: block; }

/* 🚨 "JUST FOR ____" — 2026-09-10, Tim's button. A joint workout is one
   workout: adding, removing, swapping, reordering and moving on all reach
   everybody, and this is how one person steps out of that.

   ⚠️ IT IS DELIBERATELY NOT A `.person-chip`, and the distinction is the whole
   readability of this bar. Those chips are a radio group — exactly one is
   pressed, and pressing one SELECTS a person. This is a toggle that changes
   what the next tap will DO. Same pill, so it belongs to the bar; a dashed
   edge and no fill when off, so it never reads as a fifth person waiting to be
   picked. `aria-pressed` carries the state for anything that cannot see either.

   Pushed to the far end (`margin-left: auto`) because it is not part of the
   roster and must not sit between two names somebody is tapping between —
   the same argument `.person-del` makes about destructive controls and
   mis-taps, applied to a mode switch. */
.just-for {
  flex: none; margin-left: auto; font-size: var(--fs-md);
  background: none; color: var(--ink-soft);
  border: 1px dashed var(--line);
}
.just-for.is-on {
  background: var(--accent); color: var(--accent-ink);
  border-style: solid; border-color: var(--accent);
}

/* ⚠️ "Sent" is GOOD-coloured and a failure is warn-coloured, but neither is
   colour ALONE: both spell out what happened and the failed one names the way
   to retry. Rule 5's general form — never let a cue be the only carrier. */
.sent-note { color: var(--good); }

/* One row of the "who is training with you?" picker: the person, plus a
   sibling remove button for a saved identity. NOT `.pick-row`, which is the
   visibility picker and paints every svg inside it accent at 20px. */
.person-pick { display: flex; align-items: center; gap: 6px; }
.person-pick > .row { flex: 1; min-width: 0; }

.session-progress { flex: none; display: flex; gap: 3px; padding: 7px var(--pad) 0; }
.session-progress span { flex: 1; height: 3px; border-radius: 2px; background: var(--rule); }
.session-progress span.done { background: var(--good); }
.session-progress span.current { background: var(--accent); }
/* Steps inside a superset read as one run rather than as separate exercises. */
.session-progress span.grouped { opacity: .55; }
.session-progress span.grouped.current,
.session-progress span.grouped.done { opacity: 1; }

/* ── The superset banner, first thing on the session screen ────────────────
   Above the exercise name on purpose: "do not rest after this one" changes
   what you do with the next thirty seconds and the exercise name does not.
   Hairline bracket, no box (Rule 2). */
.group-banner {
  border-left: 2px solid var(--accent);
  padding-left: 11px;
  display: flex; flex-direction: column; gap: 4px;
}
.group-banner-head { display: flex; align-items: baseline; gap: 9px; flex-wrap: wrap; }
.group-kind {
  font-size: var(--fs-sm); font-weight: var(--fw-bold);
  color: var(--accent);
}
.group-round { font-size: var(--fs-sm); color: var(--ink-soft); font-variant-numeric: tabular-nums; }
.group-members { display: flex; flex-wrap: wrap; gap: 4px 8px; }
.group-member {
  font-size: var(--fs-md); color: var(--ink-faint); text-align: left;
  padding: 1px 0; border-radius: 4px;
}
.group-member.is-current { color: var(--ink); font-weight: var(--fw-med); }
.group-hint { font-size: var(--fs-sm); color: var(--ink-faint); }
/* The forward button says "straight into X" mid-round, and looks like the
   continuation it is rather than like the end of something. */
.btn.primary.is-linked { background: var(--accent-dim); color: var(--accent); }

.session-head { display: flex; flex-direction: column; gap: 3px; }
.session-ex-name {
  font-size: 22px;
  font-weight: var(--fw-bold);
  letter-spacing: -0.028em;
  line-height: 1.14;
  margin: 0;
  text-wrap: balance;
}
.session-ex-meta { font-size: var(--fs-md); color: var(--ink-faint); font-weight: var(--fw-med); }

/* The exercise name, and the way out of it.

   ⚠️ align-items must be flex-start, not center: "Chest-Supported Dumbbell Row"
   wraps to two lines at 360px and centring floats the button into the middle of
   the name. The button sits on the first line, where a control belongs.

   ⚠️ And the name gets `min-width: 0`. It is a flex child with `text-wrap:
   balance` — without this it refuses to shrink below its longest word and
   pushes the button off the right edge, which is the same `minmax(auto, 1fr)`
   trap the year grid's month strip hit. */
.session-head-row { display: flex; align-items: flex-start; gap: 10px; }
/* Wave 4 (manager): the exercise name sticks to the top of the runner's pane,
   so a long warm-up list can scroll the open set into view without losing
   which exercise it is. `display: contents` lifts the row out of its 85px
   column so it sticks against the pane, not inside its own parent. */
/* Wave 4 (MISC-FIX, measured at 1366): the phone lists stretched to 940px left
   Start ~880px from its label, and feed weights ~880px from their names. */
@media (min-width: 860px) { .feed-exs { max-width: 460px; } }
@media (min-width: 1024px) {
  .list.pick-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); column-gap: 48px; }
  .list.pick-grid > :nth-child(2) { border-top: 0; }
}
.pane-scroll > .session-head { display: contents; }
.pane-scroll > .session-head > .session-head-row {
  position: sticky; top: 0; z-index: 3;
  background: var(--ground);
  margin-bottom: 3px;
}
.session-head-row .session-ex-name { flex: 1; min-width: 0; }
.swap-btn {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: 5px;
  /* 44px of touch target, from 30px of ink — the padding is doing the work, so
     the control stays quiet while staying thumbable. */
  min-height: 44px;
  padding: 7px 10px;
  margin: -7px -6px -7px 0;
  background: transparent;
  border-radius: 9px;
  font-size: var(--fs-md);
  font-weight: var(--fw-med);
  color: var(--ink-soft);
}
.swap-btn svg { flex: none; }
.swap-btn:active { background: var(--raised); }

/* ⚠️ SWAP AND REMOVE ARE LOUD NOW, WHICH REVERSES THE RULE DIRECTLY ABOVE.
   Tim, 2026-08-31: *"Make the swap and remove boxes in a workout stand out just
   like the +add set button."* The quiet treatment was argued from D4 — these are
   occasional, the steppers are what the screen is for — and his answer is that
   he could not find them in a gym. `.add-set`'s exact shape: raised pill, 999px,
   12.5px/650. They also left the name's line; three pills beside a heading left
   about 110px for "Chest-Supported Dumbbell Row" at 360px.

   ⚠️ THE HIT AREA STAYS 44px WHILE THE PAINT IS 32, by the ::before every icon
   button has used since the 2026-08-20 audit. Matching the add button is a
   request about how loud they look, not permission to ship a 32px target on the
   one screen that is always used one-handed. */
.session-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.pill-action {
  position: relative;
  min-height: 32px;
  padding: 0 12px 0 9px;
  margin: 0;
  border-radius: 999px;
  background: var(--raised);
  color: var(--ink-soft);
  font-size: var(--fs-md);
  font-weight: var(--fw-med);
}
.pill-action::before {
  content: '';
  position: absolute;
  top: 50%; left: 50%;
  width: 100%; height: 44px;
  min-width: 44px;
  transform: translate(-50%, -50%);
}
.pill-action:active { transform: scale(.96); background: var(--raised-hi); }
.session-load { display: flex; align-items: center; gap: 7px; font-size: var(--fs-md); color: var(--ink-faint); }

.note-card {
  border-left: 2.5px solid var(--accent);
  padding: 2px 0 2px 10px;
  font-size: var(--fs-md);
  line-height: 1.4;
  color: var(--ink-soft);
  display: flex;
  flex-direction: column;
  gap: 1px;
}
.note-card b {
  font-size: var(--fs-sm);
  font-weight: var(--fw-bold);
  color: var(--accent);
}

.prefill-note {
  font-size: var(--fs-md);
  color: var(--ink-faint);
  display: flex;
  align-items: center;
  gap: 6px;
}
.prefill-note b { color: var(--accent); font-weight: var(--fw-med); }
.prefill-note svg { flex: none; color: var(--good); }

/* What an assist machine's number really means, under the steppers that set it.
   The box says 70 and the lifter is moving 110, so the 110 is the accented one:
   it is the number that goes UP as they get stronger, and the only one on the
   screen that behaves the way a weight is supposed to.

   ⚠️ Not a box and not a warning colour. It is an ordinary reading of an
   ordinary set, in the same faint grey as every other explanation on this
   screen — dressing it up would suggest something had gone wrong. Rule 2. */
.assist-readout {
  font-size: var(--fs-md);
  color: var(--ink-faint);
  line-height: 1.4;
  margin-top: 2px;
}
.assist-readout b { color: var(--accent); font-weight: var(--fw-med); }
.assist-readout .is-warn { color: var(--danger); font-weight: var(--fw-med); }

/* ── What the app thinks you can do, above the field you are filling in ─────
   2026-09-02. Above the WEIGHT: what percentage of your estimated max this is.
   Above the REPS: roughly how many you would get with it.

   ⚠️ SAME VOICE AS `.assist-readout` DELIBERATELY, because it is the same kind
   of thing — a quiet reading beside a number you are typing, not a warning and
   not an instruction. Centred to sit under the field's own centred name, and
   it holds no height when there is nothing to say, so an untouched field shows
   a caption of empty space rather than a caption about zero.

   ⚠️ It is NOT `.prefill-note`, whose green check sits beside "Last time: 135
   lbs" — a MEASUREMENT. Every number in here is worked out, which under Rule 5
   has to be separable by a cue that is not colour: the words do it ("estimated",
   "maybe", "to failure"). */
.step-est {
  font-size: var(--fs-sm);
  color: var(--ink-soft);
  line-height: 1.3;
  text-align: center;
  min-height: 0;
}
.step-est b { color: var(--accent); font-weight: var(--fw-bold); }

/* ── The estimate above the whole benchmark form ─────────────────────────── */
.bench-body { display: flex; flex-direction: column; gap: 14px; }
.bench-est { display: flex; flex-direction: column; gap: 3px; }
.bench-est-head { display: flex; align-items: baseline; gap: 8px; }
.bench-est-num {
  font-size: 26px; font-weight: var(--fw-bold); letter-spacing: -.02em;
  font-variant-numeric: tabular-nums;
}
/* The line that names what the big number IS. The Muscles panel learned this
   the hard way — Tim, 2026-08-31: "I have no idea what that weight means." */
.bench-est-note { font-size: var(--fs-md); color: var(--ink-soft); font-weight: var(--fw-med); }

/* What to lift next, and why — js/progression.js.
   No box (Rule 2): the icon and the accent-weighted first line carry it, and
   the sentence underneath is the same faint grey every other explanation on
   this screen uses. align-items must be flex-start, not center — the reason
   runs to two or three lines on a phone and centring floats the icon into the
   middle of the paragraph. */
.suggest-note {
  display: flex;
  align-items: flex-start;
  gap: 7px;
  margin-top: 6px;
}
.suggest-note svg { flex: none; color: var(--accent); margin-top: 1px; }
.suggest-body { min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.suggest-head { font-size: var(--fs-md); font-weight: var(--fw-med); color: var(--accent); }
/* Kept tight on purpose. This sits between the exercise name and the steppers,
   and on a 360px phone an exercise that also carries a superset banner and a
   note has very little room left — measured, the whole block has to stay around
   three lines or the steppers get pushed to the bottom of the pane. */
.suggest-why { font-size: var(--fs-sm); line-height: 1.42; color: var(--ink-faint); }
/* The one-tap way back. Deliberately quiet — it is the escape hatch on a
   proposal, not a second thing to decide between. */
.suggest-toggle {
  align-self: flex-start;
  font-size: var(--fs-sm);
  font-weight: var(--fw-med);
  color: var(--ink-soft);
  text-decoration: underline;
  text-underline-offset: 2px;
  padding: 2px 0;
}
.suggest-toggle:active { color: var(--accent); }
/* Nothing is being proposed, so nothing is highlighted. A lay-off note is an
   explanation of why the numbers are last time's, not a call to act. */
.suggest-note.is-hold .suggest-head { color: var(--ink-soft); font-weight: var(--fw-med); }
.suggest-note.is-hold svg { color: var(--ink-faint); }

/* ⚠️ flex: none is load-bearing. .pane-scroll is a column flex container, so a
   child with min-height: 0 gets CRUSHED below its content height once the pane
   overflows — and because the rows keep their own size they spill out of the
   shrunken box and whatever comes next is drawn on top of them. That is exactly
   what happened: on a 360×640 phone with six sets, the button that used to sit
   under this list was painted over set 4, hiding 5 and 6 entirely. The list must
   keep its full height and let the pane scroll. */
.set-list { display: flex; flex-direction: column; flex: none; }
.set-list > * + * { border-top: 1px solid var(--rule-soft); }

/* "Sets" heading with the add button on the right of it. */
.sets-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.add-set {
  flex: none;
  display: inline-flex; align-items: center; gap: 4px;
  min-height: 32px; padding: 0 11px 0 8px;
  border-radius: 999px;
  background: var(--raised);
  color: var(--ink-soft);
  font-size: var(--fs-md); font-weight: var(--fw-med);
  cursor: pointer;
}
.add-set svg { display: block; }
.add-set:active { transform: scale(.96); }
/* "2.3× your estimated max — typo?" under the weight (Open work 1). */
.step-est .typo-warn { color: var(--danger); }

/* How to count this exercise ("Reps per leg"), inside the grey meta line. */
.session-ex-meta .ex-note { color: var(--ink); font-weight: var(--fw-med); }

/* A lever machine's one-time leverage pick (2026-09-27), on its own quiet line
   under the meta line: same grey type, a small select that still keeps a 44px
   touch height. Set once per machine, so it must not compete with the steppers. */
.lever-pick { display: flex; align-items: center; gap: 8px; margin-top: 4px; }
.lever-pick .lever-pick-select {
  width: auto;
  flex: none;
  min-height: 44px;
  padding: 7px 34px 7px 10px;
  font-size: var(--fs-md);
}

/* "+ Warm-up" sits to the LEFT of "+ Add set" (warm-ups come first), both on
   the right of the heading. */
.sets-head > .section-label { order: -2; margin-right: auto; }
.sets-head > .add-warm { order: -1; }

/* ── WARM-UP SETS (2026-09-23) ─────────────────────────────────────────────
   Tim: *"Just make sure It's clear warm up sets are different than actual
   sets."* Three signals, none of them colour alone: a "W" where the number
   goes (working sets are still 1, 2, 3), a dashed outline on that square, and
   quieter text. They sit above set 1 with no divider — the "W" is the divider. */
.set-warm .warm-num {
  background: transparent;
  border: 1.5px dashed var(--ink-faint);
  color: var(--ink-soft);
  font-weight: var(--fw-bold);
}
.set-warm .set-vals { color: var(--ink-soft); }
.set-item.set-warm.active .warm-num { border-color: var(--accent); color: var(--accent); background: transparent; }

/* ⚠️ THE SET LIST IS THE CONTENT OF THIS SCREEN, NOT AN INDEX OF IT (2026-08-28).
   Tim: *"the list of sets should be large and share the space in the middle, and
   then when you select one, it makes it larger."* Until then the same numbers
   were on screen twice — a detached block of big steppers headed "SET 1 OF 4",
   and set 1 again in a 14px list under it — so the list read as a summary of a
   control that lived somewhere else. It IS the control now, and the type is
   sized to be read at arm's length rather than scanned. */
.set-item { display: flex; align-items: center; gap: 9px; padding: 0 2px; font-size: var(--fs-lg); }
.set-item.active .set-vals { color: var(--ink); font-weight: var(--fw-med); }

/* ⚠️ THE ROW IS THE TARGET. Everything except delete lives inside this button,
   because until 2026-08-25 only the 21px numbered square selected a set and the
   weight-and-reps beside it — which is what a thumb aims at, since it is what
   you are reading — did nothing. Tim reported it from the gym.

   The vertical padding moved OFF `.set-item` and onto this, so the button fills
   the row's full height rather than sitting in a padded box that is dead to the
   touch. Comfortably past the 44px the rest of the app is held to — the list is
   the screen's content now, and these are the rows a thumb hunts for mid-set. */
.set-pick {
  flex: 1;
  min-width: 0;
  display: flex; align-items: center; gap: 9px;
  min-height: 50px;
  padding: 4px 0;
  text-align: left;
  color: inherit;
  font-size: inherit;
}
.set-pick:active { opacity: .65; }

/* A drop hangs under its set, indented, and is NOT numbered — one drop set is
   one hard set (docs/handbook.md §6), and numbering the drops 2, 3, 4 would teach
   the reader the opposite of the rule the data model is built on. */
.set-drop { padding-left: 22px; }
/* A drop is a continuation, not a set, so it does not claim a full 44px row of
   its own — it would make a three-drop set taller than the exercise above it.
   The whole row is still live, which is the part Tim asked for. */
.set-drop .set-pick { min-height: 36px; }
.set-drop .set-vals { font-size: var(--fs-md); }
.set-list > .set-drop { border-top: 0; }
.drop-num {
  background: transparent; color: var(--ink-faint);
  font-size: var(--fs-lg); width: 22px; min-width: 22px;
}
.set-item.set-drop.active .drop-num { background: transparent; color: var(--accent); }

.drop-row { display: flex; flex-direction: column; gap: 5px; }
.drop-add { justify-content: center; gap: 7px; }
.set-num {
  width: 25px; height: 25px;
  flex: none;
  display: grid; place-items: center;
  border-radius: 6px;
  background: var(--raised);
  font-size: var(--fs-sm);
  font-weight: var(--fw-bold);
  color: var(--ink-faint);
}
.set-item.active .set-num { background: var(--accent); color: var(--accent-ink); }
.set-vals { flex: 1; font-variant-numeric: tabular-nums; color: var(--ink-soft); }
.set-del { color: var(--ink-faint); padding: 3px; display: flex; }
.set-del svg { width: 15px; height: 15px; display: block; }

/* ── FINISHED / EDIT ON A SET (2026-09-23) ─────────────────────────────────
   Tim: *"click "finished" on the side of that set and then it turns it a
   different color. Once you click finished, the +/- buttons … dissapear. Then
   the finished button toggles into "edit"."* Replaced the 2026-09-12 padlock.

   ⚠️ THE OUTERMOST THING ON EVERY ROW, in a slot every row reserves — a set with
   nothing in it carries the button invisibly (`.is-idle`) and a drop row carries
   an empty `.set-done-gap` of the same width — so delete sits in the same column
   on every line. `visibility`, not `display`, for exactly that reason.
   Fixed width so "Finished" → "Edit" never moves anything (design rule 9). */
.set-done-btn {
  position: relative;
  flex: none;
  width: 72px; min-height: 32px;
  border-radius: 999px;
  background: var(--raised);
  color: var(--ink-soft);
  font-size: var(--fs-sm); font-weight: var(--fw-bold);
}
/* 44px of hit on a 32px pill, the icon-button convention. */
.set-done-btn::before {
  content: '';
  position: absolute; left: 0; right: 0; top: 50%;
  height: 44px; transform: translateY(-50%);
}
.set-done-btn:active { opacity: .7; }
.set-done-btn.is-done { background: transparent; color: var(--good); border: 1px solid var(--good); }
.set-done-btn.is-idle { visibility: hidden; pointer-events: none; }
.set-done-gap { flex: none; width: 72px; }

/* A finished row is the "done" colour and is text, not a control — no press
   feedback, no pointer. Its numbers stay legible: it is still a set you did. */
.set-item.is-done { background: var(--good-dim); }
.set-item.is-done .set-num { color: var(--good); }
.set-item.is-done .set-pick { cursor: default; }
.set-item.is-done .set-pick:active { opacity: 1; }

/* ⚠️ THE OPEN SET'S CONTROLS, INSIDE THE LIST. It is a sibling of its row rather
   than a child, because `.set-del` has to stay a sibling of `.set-pick` (nesting
   it would be invalid HTML and would need a stopPropagation that works until the
   next control is added) — and a row that is a plain flex line cannot also be a
   column holding a panel.

   ⚠️ NO BOX, NO TINT (Rule 2). What says "this one is open" is what already said
   it — the accent number square and full-ink values — plus the presence of the
   controls themselves. A card drawn round the pair would be the nested bordered
   box the whole stylesheet refuses.

   ⚠️ AND NO HAIRLINE ABOVE IT. `.set-list > * + *` rules between rows; a rule
   between a set and its own controls would cut the one thing that has to read as
   attached. The row BELOW keeps its rule, which is what closes the panel off. */
.set-open {
  display: flex; flex-direction: column; gap: 8px;
  padding: 2px 0 14px;
}
.set-list > .set-open { border-top: 0; }

/* ⚠️ THE OPEN ROW IS THE CONTROLS — IT DOES NOT SIT ABOVE THEM (2026-08-31).
   Tim: *"I would rather make the set itself change so that it morphs into the
   weight and reps adjustment box… this way it doesn't have 2 places for the
   same thing."* The panel was a SIBLING of its row until now, which meant the
   row went on printing "135 lbs × 10" three inches above a stepper reading 135
   and a stepper reading 10 — the same duplication the 2026-08-28 restructure
   removed between the detached stepper block and the list, one level down.

   The row becomes a column: number and delete on the head line, steppers under
   it, and `.set-vals` is simply not rendered while it is open. The head line is
   what closes it, so `.set-del` stays exactly where it was — a sibling of
   `.set-pick`, never its child, for the two reasons the set list has always
   given. */
.set-item.is-open { flex-direction: column; align-items: stretch; gap: 0; }
.set-open-head { display: flex; align-items: center; gap: 9px; min-height: 44px; }
.set-open-head .set-pick { min-height: 44px; }
.set-drop.is-open .set-open-head, .set-drop.is-open .set-pick { min-height: 38px; }
/* The one affordance that says a tap will close this, now that there is no
   prose anywhere on the screen to say it. */
.set-open-caret { display: flex; color: var(--ink-faint); }
.set-open-caret svg { display: block; }

/* ── Today's exercises: reorder, add, remove (2026-08-31) ──────────────────
   Rows separated by hairlines, never boxed (Rule 2) — the same treatment
   `.list` gives every other list in the app. ~~and `.move-btns` is the
   builder's own pair of arrows, unchanged~~ — no arrows here since 2026-09-12;
   see the struck block below. */
.workout-sheet { display: flex; flex-direction: column; gap: 11px; }
.reorder-list { display: flex; flex-direction: column; margin-inline: calc(var(--pad) * -1); }
.reorder-list > * { padding-inline: var(--pad); }
.reorder-list > * + * { border-top: 1px solid var(--rule-soft); }
.reorder-row { display: flex; align-items: center; gap: 8px; padding-block: 8px; background: var(--ground); }
.reorder-row .row-main { flex: 1; min-width: 0; }
.reorder-row .row-title { font-size: var(--fs-lg); }
/* Which one you are standing on. An accent hairline down the left, the same
   mark the builder uses for a block — not a tinted row, which would read as
   selected-for-an-action rather than as where you are. */
.reorder-row.is-current { box-shadow: inset 2px 0 0 var(--accent); }
/* ⚠️ 30px OF PAINT, 44px OF HIT — and the audit is what found it, not a comment.
   The first version was 30×40 and the browser measured it failing the 44px
   corner test on all 20 samples: a thumb aimed at the handle was landing on the
   row beside it. It is the leftmost control with nothing but a label to its
   right, so the pseudo-element can grow past the paint without stealing from
   anything. Same ::before the icon buttons have used since 2026-08-20. */
.grip {
  position: relative;
  flex: none;
  width: 30px; height: 40px;
  display: grid; place-items: center;
  color: var(--ink-faint);
  border-radius: 6px;
  cursor: grab;
  /* ⚠️ WITHOUT THIS THE SHEET SCROLLS INSTEAD OF THE ROW MOVING. A touch that
     the browser has claimed for scrolling never delivers another pointermove to
     the handle, so the drag dies on the first pixel — on a phone, which is the
     only place this control is used with a finger. */
  touch-action: none;
}
.grip::before {
  content: '';
  position: absolute;
  top: 50%; left: 50%;
  width: 44px; height: 44px;
  transform: translate(-50%, -50%);
}
.grip:active { background: var(--raised); cursor: grabbing; }
.grip svg { display: block; }

/* ~~⚠️ THE ARROWS ARE 40×24 HERE, WIDER AND TALLER THAN THE BUILDER'S 28×21, AND
   THEY STILL DO NOT REACH 44. That is a stated trade rather than an oversight:
   two stacked targets cannot both be 44px tall inside one 56px row, and the row
   also has to carry the handle, the name and the delete. 24px is the WCAG 2.2 AA
   minimum in both directions, which they now clear with room; the comfortable
   path on a phone is the drag, and the arrows are what a keyboard and a screen
   reader get — where a 44px thumb box is not the measure that matters.~~
   🔄 GONE 2026-09-12 — Tim: *"the up and down arrows … are useless now that
   this is a drag feature now. Remove them."* The builder's `.move-btns` further
   down is untouched; the keyboard path in THIS sheet is the grip's own arrow
   keys (views-session.js). */
/* ⚠️ THE ROW FOLLOWS THE FINGER (2026-09-12), and the CSS half of that is two
   rules. Every row transitions its transform, so the rows a drag has passed
   SLIDE out of its way (Rule 7: the row you drag pushes the rest) and the row
   you let go of travels into its slot rather than appearing there — and the
   row under the finger has NO transition, because a transition between where
   the finger was and where it is now is lag, which is the thing Tim reported.
   `--ease-both`: rows moving from one place to another while you watch.
   The reduced-motion blanket at the end of the MOTION section makes both
   instant; the snap still lands, because it is a transform that ends at 0. */
.reorder-row { transition: transform var(--t) var(--ease-both); }
/* Lifted off the list while it is in the air, so the row being moved is the one
   thing that is obviously not in its place yet — and it stays lifted while it
   settles, because it is still in the air until it lands. */
.reorder-row.is-dragging,
.reorder-row.is-settling {
  background: var(--raised);
  border-radius: 8px;
  box-shadow: 0 6px 18px rgba(0, 0, 0, .28);
  position: relative; z-index: 1;
}
.reorder-row.is-dragging { transition: none; }
.set-open-label {
  font-size: var(--fs-sm);
  font-weight: var(--fw-bold);
  color: var(--ink-faint);
}
/* ⚠️ A DROP'S CONTROLS ARE NOT INDENTED, and that was tried first. Indenting the
   panel to line up with the ↳ row pushed the steppers 22px off every other grid
   on the screen and wrapped the "counts as one hard set" line an extra time at
   360px, for a relationship the `SET 1 · DROP 1` label already states in words —
   which is the one job that label was kept for. The indent belongs on the ROW,
   which is where the nesting is being shown. */

.session-footer {
  flex: none;
  padding: 8px var(--pad) calc(8px + var(--safe-b));
  border-top: 1px solid var(--rule);
  display: flex;
  gap: 8px;
  align-items: center;
}
.nav-arrow {
  width: 48px; height: 46px;
  flex: none;
  border-radius: var(--r);
  background: var(--raised);
  display: grid; place-items: center;
  color: var(--ink);
}
.nav-arrow:disabled { opacity: .28; pointer-events: none; }
.nav-arrow svg { width: 22px; height: 22px; }
.session-footer .btn { flex: 1; }

/* ⚠️ A FAILED SAVE AT THE END OF A WORKOUT, said on the screen and left there.
   Until 2026-08-22 this failure was completely silent: `finish()` awaited the
   save unguarded, so a full localStorage meant the user tapped Finish and
   nothing happened at all. A toast would not do — 2.4 seconds on a phone is
   indistinguishable from nothing happening, which is exactly how the same
   class of bug was reported from an iPhone on the sign-in screen. It sits
   directly above the button that failed.

   ⚠️ No fill and no box — Rule 2. It borrows `.preset-warning`'s shape, which
   is this app's established way of saying something that must not be skimmed:
   a --danger hairline and full-strength ink. Full-strength because --ink-faint
   failed AA in the 2026-08-20 audit and this is the least skippable sentence
   in the product. */
.save-error {
  flex: none;
  margin: 0 var(--pad) 8px;
  padding-left: 11px;
  border-left: 2px solid var(--danger);
  color: var(--ink);
  font-size: var(--fs-md);
  line-height: 1.45;
}
.save-error[hidden] { display: none; }
.save-error strong { color: var(--danger); }
/* Since 2026-09-07 it sits inside the save screen's pinned footer, which pays
   its own horizontal padding — the margins above are for a bar that is a direct
   child of `.screen`, which is where this used to live. */
.pane-bottom .save-error { margin-inline: 0; margin-bottom: 9px; }

/* ==========================================================================
   The "?" and its mini box (2026-09-07)

   Tim: *"have a little question mark somewhere near the thing that it's
   explaining … when you touch it it opens a mini box that shares what it's
   trying to say."*

   ⚠️ 26px of paint inside a 44px target. The dot must read as a footnote rather
   than a control competing with the thing it sits beside — but a 26px tap
   target would fail WCAG 2.5.8, so the button carries transparent padding and
   a negative margin, which keeps the layout tight and the target legal.
   ========================================================================== */

.help-dot {
  flex: none;
  display: inline-grid;
  place-items: center;
  width: 26px; height: 26px;
  padding: 0;
  margin: -9px;              /* cancels the halo below, so rows do not grow */
  border: 1px solid var(--rule);
  border-radius: 50%;
  background: none;
  /* ⚠️ --ink-soft, not --ink-faint. Measured in the browser at 4.52:1 on the
     light gold palette — a pass, with nothing to spare, on a 13px glyph that is
     the entire label of a control. --ink-soft is the same quiet register with
     roughly half again the margin. */
  color: var(--ink-soft);
  font-size: var(--fs-md); font-weight: var(--fw-bold); line-height: 1;
  cursor: pointer;
  position: relative;
  vertical-align: middle;
}
/* The invisible half of the touch target — 44px in both directions. */
.help-dot::after {
  content: '';
  position: absolute;
  inset: -9px;
  border-radius: 50%;
}
.help-dot[aria-expanded="true"] { border-color: var(--accent); color: var(--accent); }

/* The mini box. Positioned in JS against the dot, because the dot can be in a
   header, at the end of a row or beside a legend chip, and a box that runs off
   a 360px screen is the whole failure mode of this pattern. */
.help-pop {
  position: fixed;
  z-index: 70;
  max-width: 300px;
  /* ⚠️ A box taller than the screen is a broken box. Measured at 525px on the
     Volume screen's six explanations against an 852px phone — fine there and
     one more paragraph from not being. It scrolls inside itself rather than
     running off the top, which is the only failure mode this pattern has that
     the reader cannot recover from. */
  max-height: 62dvh;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 11px 13px;
  border-radius: var(--r);
  background: var(--raised);
  border: 1px solid var(--rule);
  box-shadow: 0 8px 28px rgba(0, 0, 0, .45);
  font-size: var(--fs-md);
  line-height: 1.5;
  color: var(--ink-soft);
}
.help-pop:focus { outline: none; }
.help-pop-title { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--ink); margin-bottom: 4px; }
.help-pop-body { margin: 0; }
.help-pop p { margin: 0 0 7px; }
.help-pop p:last-child { margin-bottom: 0; }
.help-pop b, .help-pop strong { color: var(--ink); font-weight: var(--fw-med); }

/* A chart from a paper inside a ? — figureNote() in js/ui.js. Nothing here is a
   look; it is the four rules that stop a 1,000px journal figure breaking the box
   it is in. `.help-pop` is max-width: 300px with overflow-x: hidden, so without
   max-width: 100% the image is silently CLIPPED rather than fitted; max-height
   keeps a tall figure from filling the 62dvh box on its own, and object-fit
   stops that squashing it when it does. */
.help-pop .help-figure-wrap { margin: 7px 0 0; }
.help-pop .help-figure {
  display: block;
  max-width: 100%;
  height: auto;
  max-height: 34dvh;
  object-fit: contain;
}
.help-pop .help-figure-credit { margin-top: 5px; font-size: var(--fs-sm); line-height: 1.4; }

/* The arrow, so the box is visibly attached to the ? that opened it rather than
   appearing from nowhere — Rule 7's "a thing that moves says where it came
   from", applied to a thing that appears. */
.help-pop::before {
  content: '';
  position: absolute;
  left: var(--arrow-x, 50%);
  top: -5px;
  margin-left: -5px;
  width: 9px; height: 9px;
  background: var(--raised);
  border-left: 1px solid var(--rule);
  border-top: 1px solid var(--rule);
  transform: rotate(45deg);
}
.help-pop.is-above::before { top: auto; bottom: -5px; transform: rotate(225deg); }

/* It grows out of the dot. --t-fast, because this is a footnote and nobody
   should wait for one. */
.help-pop { animation: help-in var(--t-fast) var(--ease-out); }
@keyframes help-in { from { opacity: 0; transform: translateY(-4px) } }
.help-pop-x {
  position: fixed; z-index: 70; pointer-events: none;
  animation: help-out var(--t-fast) var(--ease-in) forwards;
}
@keyframes help-out { to { opacity: 0 } }

/* A line of help text with its ? at the end of it. `align-items: baseline` so
   the dot sits on the text's own line rather than centring against a wrapped
   paragraph, which reads as a bullet floating in the margin. */
.help-line {
  display: flex;
  align-items: baseline;
  gap: 10px;
}
/* A SENTENCE and its dot: the text takes the row and the ? follows it. */
.help-line > .field-help,
.help-line > .note { flex: 1; min-width: 0; margin: 0; }

/* 🚨 A LABEL AND ITS DOT SIT TOGETHER — Tim, 2026-09-08: *"If something has a
   label like 'Just looking around' above demo account button, the question mark
   should be right next to the just looking around text, not on the right side.
   this way it's easy to know where to get information."*
   ⚠️ `0 1 auto`, NOT `none`. The dot must not grow away from the words, but a
   long label ("Your data — only in this browser") on a 360px screen still has
   to be allowed to SHRINK — `flex: none` would size it to max-content and push
   it off the side, which is the one thing the browser audit exists to catch. */
.help-line > .section-label,
.help-line > label { flex: 0 1 auto; min-width: 0; margin: 0; }

/* Seven things in one sentence are seven things nobody counts. */
.vis-list { margin: 4px 0 0; padding-left: 18px; }
.vis-list li { margin: 2px 0; }

/* ==========================================================================
   The save screen — everything about the session, asked at the end (2026-09-07)

   Tim's ask: the description and the gym come off the runner's header and are
   asked once, after the workout, over a summary of what is about to be written.
   ========================================================================== */

.save-screen { display: flex; flex-direction: column; gap: 16px; }
.save-title { margin: 2px 0 0; font-size: 21px; font-weight: var(--fw-bold); letter-spacing: -0.02em; }

/* Three facts across the top, the shape Hevy uses. ⚠️ Not a bordered card
   (Rule 2) — a hairline under the row is what separates it from the fields. */
.save-stats {
  display: flex;
  gap: var(--gap);
  padding-bottom: 14px;
  border-bottom: 1px solid var(--rule-soft);
}
.save-stat { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.save-stat-label {
  font-size: var(--fs-sm); font-weight: var(--fw-bold);
  color: var(--ink-faint);
}
.save-stat-value { font-size: var(--fs-xl); font-weight: var(--fw-bold); font-variant-numeric: tabular-nums; }
/* Duration is editable (2026-09-23): the same big number, with the date's
   dashed underline saying it can be changed. 16px+ stops iOS zooming on focus. */
.save-dur-row { display: flex; align-items: baseline; gap: 4px; min-width: 0; }
.save-dur {
  width: 3.2em; min-width: 0; padding: 0 0 1px; font-family: inherit; color: var(--ink);
  background: none; border: 0; border-bottom: 1px dashed var(--rule); -moz-appearance: textfield;
}
.save-dur::-webkit-outer-spin-button, .save-dur::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.save-dur-unit { font-size: var(--fs-md); font-weight: var(--fw-med); color: var(--ink-soft); }

/* ⚠️ Below everything, and nowhere near Save. It is the only control on the
   normal recording path that destroys a workout, so it is deliberately not in
   the footer where the thumb rests, and it is separated by enough space that it
   cannot be the thing you meant to tap. */
.save-discard { margin-top: 6px; align-self: flex-start; }

/* The cloud running out of room. Third borrower of `.preset-warning`'s shape —
   a --danger hairline and full-strength ink, no fill and no box (Rule 2) — for
   the reason that shape exists: this must not be skimmed. It sits inside the
   "Your data" card directly above the Download backup button, so the margins
   `.save-error` needs for a pinned footer are wrong here.

   ⚠️ Nothing paints it at all below the threshold; see cloudFullWarning(). */
.storage-warning {
  padding-left: 11px;
  border-left: 2px solid var(--danger);
  color: var(--ink);
  font-size: var(--fs-md);
  line-height: 1.45;
}
.storage-warning b { color: var(--danger); }

/* ==========================================================================
   Calendar
   ========================================================================== */

/* Months are one continuous vertical scroll; each month's heading sticks to the
   top of the pane while that month is in view. */
.cal-month { display: flex; flex-direction: column; gap: 4px; }

.cal-month-head {
  position: sticky;
  top: 0;
  z-index: 3;
  background: var(--ground);
  padding: 4px 0 3px;
  display: flex;
  flex-direction: column;
  gap: 3px;
  border-bottom: 1px solid var(--rule-soft);
}
.cal-title { font-size: var(--fs-lg); font-weight: var(--fw-bold); letter-spacing: -0.015em; }
.cal-month.is-current .cal-title { color: var(--accent); }

.cal-dows { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; }

.cal-grid { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; }
.cal-dow {
  text-align: center;
  font-size: var(--fs-xs);
  font-weight: var(--fw-bold);
  color: var(--ink-faint);
}

/* ⚠️ 66px, RAISED FROM 52 ON 2026-08-25. The cell has to be tall enough for a
   workout name to be worth reading — Tim asked for the name "as large as
   possible so it's easy to read and click on", and at 52px the tag had 8px of
   type and about 20px of room. The calendar is a scrolling list of months, so
   the height is paid for in scroll rather than in anything being cut off. */
.cal-cell {
  min-height: 66px;
  border-radius: 6px;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 3px;
  overflow: hidden;
  text-align: left;
  color: var(--ink-faint);
  background: var(--rule-soft);
}
.cal-cell.blank { background: transparent; pointer-events: none; }
.cal-cell:active:not(.blank) { filter: brightness(1.3); }

/* ⚠️ --ink-soft, not the --ink-faint it inherits from .cal-cell. A cell paints
   on --rule-soft, which is the darkest surface in the light theme and the
   lightest in the dark one — so it is the worst background this app puts small
   text on, and a 10.5px date number was the last thing still failing AA after
   the 2026-08-20 palette fix (3.94:1 light, 4.41:1 dark). A date is content
   somebody reads, not decoration, so it takes the readable step: 6.27:1 light,
   6.12:1 dark. Re-measured, not reasoned — the arithmetic against --rule-soft
   said 4.37 and the browser said 3.94, because an ACTIVE day is filled and the
   fill is what the number actually sits on. */
.cal-day {
  color: var(--ink-soft);
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-xs);
  font-weight: var(--fw-med);
  line-height: 1;
  padding: 1px 2px;
  flex: none;
}
.cal-cell.today { box-shadow: inset 0 0 0 1.5px var(--accent); }
/* ⚠️ --ink, not --accent. The light accent (#96660F) on a filled cell measures
   3.94:1 at 10.5px and was the very last AA failure left after the 2026-08-20
   audit — and it was invisible to the arithmetic, because it only appears on
   ONE cell in the month and only in the light theme. Nothing is lost: the cell
   already carries an accent ring and this number is already weight 800, so
   "today" was never being said by colour alone. Now 15.8:1 light, 16.3:1 dark,
   and it still reads as the strongest number on the grid. */
.cal-cell.today .cal-day { color: var(--ink); font-weight: var(--fw-bold); }

/* ⚠️ THE TAGS FILL THE CELL — Tim, 2026-08-25: *"in the months display, make the
   items for that day fill up that day's box entirely, besides the number, and
   have the name of the workout be as large as possible so it's easy to read and
   click on."*

   `flex: 1` on the container and on each tag is what "entirely" means: the
   number takes its line, everything left over is split between whatever that
   day holds. Two workouts share it, one takes all of it. */
.cal-tags { display: flex; flex-direction: column; gap: 2px; flex: 1; min-height: 0; overflow: hidden; }

/* ⚠️ NAMED LABELS RATHER THAN DOTS, AND NOW READABLE ONES. This was 8px — the
   smallest text in the entire app, on the one surface the palette notes call
   the worst this app puts small text on. It is fluid now: as large as the
   column allows, floored at 9.5px so a narrow phone cannot drive it back to
   where it was, capped at 12.5px so a tablet does not make a calendar cell
   shout louder than the month heading above it.

   ⚠️ TWO LINES, NOT ONE. "Upper A" fits a 49px column at 11px; "Chest & Back"
   does not, and clipping the name is exactly what makes a cell unreadable at a
   glance. Wrapping costs nothing here because the cell is now tall enough. */
.cal-tag {
  flex: 1;
  min-height: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  font-size: clamp(var(--fs-xs), 3.1vw, var(--fs-md));
  font-weight: var(--fw-bold);
  line-height: 1.15;
  padding: 1px 2px;
  border-radius: 4px;
  overflow: hidden;
  /* Two lines at most, then ellipsis. `nowrap` was here and is exactly what
     made a long name unreadable rather than merely tight. */
  overflow-wrap: anywhere;
}
.cal-tag > span {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
}
.cal-tag.w { background: var(--accent); color: var(--accent-ink); }
.cal-tag.b { background: var(--good); color: #08130D; }
/* "+2" is a count, not a workout, so it does not claim an equal share of the
   cell — it sits on one line under whatever it is counting. */
.cal-tag.more {
  flex: none; background: transparent; color: var(--ink-faint);
  padding: 0; font-size: var(--fs-xs);
}
:root[data-theme="light"] .cal-tag.b { color: #FFFFFF; }

.cal-cell.has-workout { background: var(--accent-dim); }
.cal-cell.has-benchmark-only { background: var(--good-dim); }

/* 🆕 A MONTH WITH NOTHING RECORDED IS ONE LINE — 2026-09-16, Tim: *"if a month
   has no recordings in it, just say the month and say no recordings, don't show
   an entire month of empty boxes."*

   The section is still a `.cal-month` and still carries `data-current-month`
   when it is this month — `landOnCurrentMonth` finds its target by that
   attribute — so what changes is only that the head becomes the whole section
   and lays itself out as a row rather than as a stack over a grid.

   ⚠️ `.cal-month-head` is `position: sticky`. On a collapsed month that is a
   no-op rather than a bug: a sticky element cannot travel outside its own
   containing block, and here the head IS the section. */
.cal-month.is-empty .cal-month-head {
  flex-direction: row;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
}
.cal-none {
  flex: none;
  font-size: var(--fs-md);
  color: var(--ink-faint);
}

/* ==========================================================================
   THE MONTH BAR CHART — 2026-09-16, Tim: *"if the user has more than 5 months
   of recordings in it, show a bar chart that has the months as the x axis and
   the number of days workouts recorded in that month as the y axis."*

   Hand-rolled in HTML/CSS, like every other chart here (handbook §"Charts are
   hand-rolled"). The reasoning — Months only, the y-axis wording on a friend's
   page, why a zero month draws no stub, why 36 bars scroll rather than squeeze
   — is above `monthsChart()` in js/views-data.js and is not repeated here.

   ⚠️ THE SLOT IS 22px + 3px SO TWELVE MONTHS FIT AT 360px WITHOUT SCROLLING:
   12 × 22 + 11 × 3 = 297px against roughly 300px of pane once the y-axis
   (~18px) and its gap are taken off a 332px content box. Thirteen months and up
   scroll sideways inside `.mchart-scroll` — Rule 1 forbids the WINDOW scrolling,
   not a wide picture scrolling in its own box.

   🚨 `min-width: 0` ON THE SCROLLER IS THE LOAD-BEARING LINE, and it is the
   `.research-scroll` lesson written down again: `.pane-scroll` is
   `overflow-x: hidden`, so a flex child that refuses to shrink below its content
   does not scroll — it bleeds into a clipped region no gesture can reach.
   ========================================================================== */

.mchart {
  display: flex;
  flex-direction: column;
  gap: 6px;
  min-width: 0;
  /* The plot's height. Bounded at both ends: this sits above a whole list of
     months, so it may not eat the screen on a tall phone, and it may not
     collapse to a strip of colour on a short one. */
  --mchart-h: clamp(84px, 13dvh, 132px);
}

.mchart-body { display: flex; align-items: flex-start; gap: 6px; min-width: 0; }

/* ⚠️ THE SCALE SITS OUTSIDE THE SCROLLER. Inside it, the maximum would scroll
   away from the bars it measures the moment anybody looked at last year — a
   chart whose axis can leave the screen is a chart of unlabelled rectangles.
   The two ticks are centred ON the two hairlines rather than sitting between
   them, which is what the translate is for. */
.mchart-y {
  flex: none;
  height: var(--mchart-h);
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: flex-end;
}
.mchart-tick { font-size: var(--fs-xs); line-height: 1; color: var(--ink-faint); }
.mchart-tick:first-child { transform: translateY(-50%); }
.mchart-tick:last-child  { transform: translateY(50%); }

.mchart-scroll {
  flex: 1;
  min-width: 0;
  overflow-x: auto;
  overflow-y: hidden;
  -webkit-overflow-scrolling: touch;
  /* Same treatment `.segmented` gets: the bar is a phone gesture, and a visible
     scrollbar under the months would read as a second control. */
  scrollbar-width: none;
}
.mchart-scroll::-webkit-scrollbar { display: none; }

/* `width: max-content` rather than a percentage: the columns are a fixed size
   by design, so the plot is exactly as wide as its data and the scroller has
   something real to scroll. */
.mchart-plot { display: flex; flex-direction: column; gap: 3px; width: max-content; }

/* ⚠️ SOLID HAIRLINES, NEVER DASHED (dataviz anti-patterns): a dashed rule reads
   as a threshold or a projection when it is only a grid. Two lines and no more
   — the top of the scale and the floor. The floor runs the full width of the
   plot, which is what makes a zero month read as a column standing on nothing
   rather than as a month that was left out. */
.mchart-bars {
  height: var(--mchart-h);
  display: flex;
  align-items: stretch;
  gap: 3px;
  border-top: 1px solid var(--rule);
  border-bottom: 1px solid var(--rule);
}
.mchart-labels { display: flex; gap: 3px; }

.mchart-slot {
  flex: 0 0 22px;
  width: 22px;
  display: flex;
  flex-direction: column;
  align-items: center;
}
/* ⚠️ The slot must STRETCH to the row's height, not hug its bar: a percentage
   height resolves against a definite one, and a bar inside an auto-height slot
   silently renders at zero. Pushing the bar down is the slot's job, not the
   row's. */
.mchart-bars .mchart-slot { justify-content: flex-end; }

/* ⚠️ 14px IN A 22px SLOT — a thin mark with 8px of ground between neighbours,
   well past the 2px surface gap the guidance asks for, and no border around it:
   bars are separated by the gap, never by an outline. `--accent` is the colour a
   trained day already wears on `.yr-cell.on` and `.cal-tag.w`, so the chart
   introduces no new meaning; validated (7.74:1 dark / 4.52:1 light against
   --ground, against the 3:1 a non-text mark needs). */
.mchart-bar {
  width: 14px;
  min-height: 3px;
  border-radius: 3px 3px 0 0;
  background: var(--accent);
}
/* A zero month is an empty column over the baseline. NOT a 2px stub: `--rule`
   is 1.29:1 on `--ground` and cannot be seen, and anything visible enough to be
   seen reads as a small quantity rather than as none. */
.mchart-bar.is-zero { height: 0; min-height: 0; }

.mchart-x { font-size: var(--fs-xs); line-height: 13px; color: var(--ink-faint); }
/* The year is printed only where it changes, and one step less quiet than the
   month names so a year boundary is findable in a 36-column run. The height is
   fixed so the empty ones still hold their row and the month names stay level
   with the bars above them. */
.mchart-yr {
  height: 11px;
  font-size: var(--fs-xs);
  line-height: 11px;
  font-weight: var(--fw-bold);
  color: var(--ink-soft);
}

.legend { display: flex; gap: 14px; font-size: var(--fs-sm); color: var(--ink-faint); justify-content: center; }
.legend span { display: flex; align-items: center; gap: 5px; }
.legend i { width: 8px; height: 8px; border-radius: 2px; display: block; }
.legend i.w { background: var(--accent); }
.legend i.b { background: var(--good); }

/* ==========================================================================
   The year grid — a year of training as one row of squares
   ==========================================================================

   Tim, 2026-08-22: one tiny box per day, coloured if he trained, so that years
   of history read on a single screen.

   ⚠️ THE CELL SIZE IS NOT A STYLE CHOICE. 53 week-columns have to cross a
   393px phone, which leaves about 6px each — that IS the feature, and every
   decision below follows from it. Nothing in here may be given a fixed pixel
   width: the columns are `1fr` so a year always spans exactly the space it has,
   on any screen, at any year length (a leap year opening on a Sunday takes 54).

   ⚠️ ONE COLOUR, DELIBERATELY. The month view paints workouts in --accent and
   benchmark-only days in --good. Those two measure ΔE 6.5 apart under
   protanopia in the light theme, which is inside the band the data-viz guidance
   allows ONLY with a secondary encoding — and a 6px square has room for no
   label, no texture and no gap. So this view says "trained" and the Months view
   keeps the distinction. See js/year-grid.js for the full reasoning. */

.cal-modes { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.cal-modes .segmented { width: 100%; }

/* The readout holds its row even with nothing selected — Design Rule 3's
   corollary. Revealing it on tap would shift every grid below it downward at
   the exact moment somebody was pointing at one. */
.yr-readout {
  display: flex; align-items: center; gap: 8px;
  width: 100%; min-height: 34px; padding: 0 2px;
  background: none; border: 0; text-align: left;
  font-size: var(--fs-md); color: var(--ink-faint);
  border-top: 1px solid var(--rule-soft);
}
.yr-readout:disabled { pointer-events: none; }
/* ⚠️ `display: flex` BEATS the hidden attribute. The UA sheet gives [hidden]
   `display: none` at the lowest precedence, so any rule setting a display wins
   and the element stays on screen — which is exactly what happened: the
   Workout/Benchmark legend went on being drawn over a Years view that paints
   neither. Anything in this file toggled by `.hidden = true` needs this. */
.yr-readout[hidden], .legend[hidden] { display: none; }
.yr-r-date { flex: none; font-weight: var(--fw-bold); color: var(--ink); }
.yr-r-what {
  flex: 1; min-width: 0; color: var(--ink-soft);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.yr-readout .row-chev { flex: none; color: var(--ink-faint); display: flex; }

.yr { display: flex; flex-direction: column; gap: 3px; }
.yr-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; }
.yr-title { font-size: var(--fs-lg); font-weight: var(--fw-bold); letter-spacing: -0.015em; }
.yr-count { font-size: var(--fs-sm); color: var(--ink-soft); }

/* Two columns — the day-name gutter and the squares — and two rows, with the
   month strip sharing the squares' column track so its labels land on the
   weeks they name. */
.yr-body {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  grid-template-rows: auto auto;
  gap: 2px 4px;
}
.yr-months { grid-area: 1 / 2; display: grid; grid-template-rows: auto; }
/* ⚠️ EVERY LABEL IS PINNED TO ROW 1. Twelve labels each spanning several
   columns overlap, and an auto-placed grid resolves an overlap by opening a new
   row — so the strip came out as a diagonal staircase twelve rows deep,
   pushing the squares half a screen down. Explicit placement is allowed to
   overlap; auto-placement is not. The span stays under the ~4.3 columns a month
   occupies so the labels do not actually collide. */
.yr-month {
  grid-row: 1;
  font-size: 9px; line-height: 1.1; color: var(--ink-soft);
  overflow: hidden; white-space: nowrap; letter-spacing: -0.02em;
}
.yr-dows {
  grid-area: 2 / 1;
  display: grid; grid-template-rows: repeat(7, 1fr);
  font-size: 8.5px; color: var(--ink-soft);
}
.yr-dow { display: flex; align-items: center; line-height: 1; }

/* A 1px gap, not the 2px the month calendar uses. At a 6px pitch a 2px gap is a
   third of the picture, and the squares stop reading as a run of days. */
.yr-grid {
  grid-area: 2 / 2;
  display: grid; grid-template-rows: repeat(7, auto);
  gap: 1px;
  cursor: pointer;
}
/* aspect-ratio is what makes the square square: the column is 1fr, and the
   height follows the width rather than being guessed at. */
.yr-cell { aspect-ratio: 1; border-radius: 1.5px; background: var(--rule-soft); }
.yr-cell.on { background: var(--accent); }

/* Today and the selected day are RINGS, not fills — a fill would be a second
   thing the colour of "you trained" and would read as one. Drawn outside the
   box so a 6px square keeps all 6 pixels of its own colour. */
.yr-cell.today { box-shadow: 0 0 0 1px var(--ink-soft); }
.yr-cell.sel { box-shadow: 0 0 0 1.5px var(--ink); position: relative; z-index: 1; }

/* ==========================================================================
   Graph
   ========================================================================== */

/* Chart series colours — validated for both themes against the six data-viz
   checks (OKLCH band + chroma floor, CVD ΔE 19.6, normal ΔE 23.2, contrast 5.3:1).
   These are chart-only and deliberately distinct from the UI accent. */
:root {
  --series-start: #3D8FC0;
  --series-now:   #C08430;
}
:root[data-theme="light"] {
  --series-start: #2C7CB0;
  --series-now:   #96660F;
}

/* The chart claims the screen; controls take what's left. */
.graph-controls { display: flex; flex-direction: column; gap: 6px; }

.control-row { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
/* ⚠️ 44, not 36 — raised 2026-08-24 after the a11y audit reached this screen for
   the first time (until then `#/data` was not a route and it had been measuring
   Home). At 36px tall this was one of only two ordinary controls in the app
   under the 44px target; the rest of that audit's "failures" are the tripwire
   firing on things narrower than 44 in one dimension, which is not the same
   finding. The 8px comes off the chart, which had 501 and can spare it — the
   control being reliably hittable is worth more than eight pixels of line. */
.control-row .input.compact {
  flex: 1 1 150px;
  min-width: 0;
  min-height: 44px;
  padding: 7px 10px;
  font-size: var(--fs-lg);
}
.chips.tight { gap: 4px; flex: none; }
.chips.tight .chip { padding: 6px 10px; font-size: var(--fs-md); }

.graph-host { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 8px; }

/* Where every lift stands, shown when a chart has no line to draw. Hairline
   rules between rows, no cards — Rule 2. */
.bests { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 8px; }
.bests-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; }
.bests-list > * + * { border-top: 1px solid var(--rule-soft); }
.best-row { display: flex; align-items: center; gap: 10px; padding: 9px 2px; }
.best-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.best-name { font-size: var(--fs-lg); font-weight: var(--fw-med); }
.best-sub { font-size: var(--fs-sm); color: var(--ink-faint); }
.best-nums { flex: none; display: flex; flex-direction: column; align-items: flex-end; gap: 1px; }
.best-set { font-size: var(--fs-lg); font-variant-numeric: tabular-nums; }
.best-e1rm { font-size: var(--fs-sm); color: var(--ink-faint); font-variant-numeric: tabular-nums; }

.chart-foot { flex: none; display: flex; flex-direction: column; gap: 5px; }

/* ⚠️ IT SCROLLS SIDEWAYS WHEN IT HAS TO — 2026-09-08, when Calendar became the
   SIXTH Data segment on Tim's instruction.

   The measurement that governs this row has been in `render.test.mjs` since
   Volume shipped: at 360px it is 293px wide against labels of 63+60+51+39+68 =
   281px, so five fit with 12px spare and **a sixth does not fit at all**. That
   measurement is still true. What changed is that a sixth segment now has to
   exist anyway, and the alternatives were worse: shrinking the labels below
   their content clips words, and wrapping to a second row grows the fixed
   header on the one screen whose content is a full-height body map.

   ⚠️ `.seg` KEEPS `flex: 1 1 auto`, WHICH IS WHAT MAKES THIS COST NOTHING WHEN
   IT IS NOT NEEDED. With room to spare the segments still grow to fill the row
   exactly as they did with five; only when their content is wider than the box
   does anything scroll. Every screen with four or five segments is untouched.

   ⚠️ The scrollbar is hidden because this is a tab bar, not a document — the
   partly-visible last segment is the affordance, which is why nothing here
   clips a label to fit. `ui.js` scrolls the selected one into view on render,
   so opening Data on Calendar does not look like an empty row. */
.segmented {
  display: flex;
  gap: 2px;
  background: var(--raised);
  border-radius: var(--r);
  padding: 2px;
  overflow-x: auto;
  /* Firefox and Chrome 121+. Safari is covered by the ::-webkit-scrollbar rule
     below, and older Chrome by both. ⚠️ `-webkit-overflow-scrolling: touch` is
     deliberately NOT here — it has been a no-op since iOS 13 and every current
     engine ignores it. */
  scrollbar-width: none;
  /* A sideways flick on the tab row must not drag the page behind it. */
  overscroll-behavior-x: contain;
}
.segmented::-webkit-scrollbar { display: none; }
.seg {
  position: relative;
  flex: 1 1 auto;
  /* 44px, painted, rather than the invisible pseudo-element the 2026-08-20
     audit used elsewhere — see the note beside that rule. This one is the mode
     switch for the entire Data tab and it is worth the 8px it costs the header.
     Tim opened the phone work on 2026-08-21, so "do not move pixels on hardware
     nobody has checked" no longer applies the way it did the day before. */
  min-height: 44px;
  border-radius: 7px;
  font-size: var(--fs-md);
  font-weight: var(--fw-med);
  color: var(--ink-faint);
  /* ⚠️ THE 2026-08-17 FIX TURNED A WRAP INTO A CLIP, and the clip was worse.
     "Bar Chart" used to break onto two lines; `white-space: nowrap` with
     `min-width: 0` stopped that by letting the item be squeezed BELOW its own
     label instead — measured at phone size on 2026-08-21, "Bar Chart" needed
     65px and was given 62, and with no overflow rule the extra 3px was painted
     under the neighbouring segment. On the Muscles tab the selected pill sits
     on top of it and the control reads "Bar Char".

     `flex: 1 1 auto` with a content-width floor is the actual answer: the three
     share the leftover space but none is ever squeezed below its own text. The
     ellipsis is a backstop for a width nobody has yet, and it degrades to
     "Bar C…" rather than to two labels painted on top of each other. */
  white-space: nowrap;
  min-width: -webkit-fit-content;
  min-width: fit-content;
  overflow: hidden;
  text-overflow: ellipsis;
  padding: 0 6px;
}
/* ⚠️ AN <a> IS NOT A <button>, and `.seg` was written for buttons. An anchor
   is inline, so `min-height` does nothing to it and the UA underlines it — the
   You / Friends switch first rendered as two underlined links floating in an
   oversized box. This is the same fault the social review found in the friend
   rows, arriving from the other direction.

   `display: block` with a matched `line-height`, rather than flex or grid:
   both of those would centre the label and quietly kill the `text-overflow`
   backstop above, which exists because this control has already clipped a
   label once. A block box keeps it. */
a.seg {
  display: block;
  text-align: center;
  text-decoration: none;
  line-height: 44px;
}
.segmented.sub a.seg { line-height: 34px; }

.seg[aria-selected="true"] { background: var(--ground); color: var(--ink); }
.seg:disabled { opacity: .35; pointer-events: none; }

/* ⚠️ A HAIRLINE BETWEEN UNSELECTED SEGMENTS, because without one this control
   states the wrong number of options. Only the selected segment is painted, so
   the other two share one continuous background with nothing between them — at
   393px "Graph | Bar Chart Muscles" reads as TWO choices, and the second of
   them appears to be called "Bar Chart Muscles". Measured 2026-08-21.

   The rule is suppressed either side of the selection, which is where the
   painted pill already does the separating — the same reason iOS's own control
   drops it there. Rule 2 still holds: this is a hairline, not a border box.

   ⚠️ Drawn at left:0, INSIDE the box, not in the 2px gap at left:-2px: the
   ellipsis backstop above needs `overflow: hidden`, which would clip anything
   hanging outside. 2px off the true midpoint is invisible on a 1px rule; a
   divider that silently fails to render is not. */
.seg + .seg::after {
  content: '';
  position: absolute;
  left: 0;
  top: 22%;
  bottom: 22%;
  width: 1px;
  background: var(--rule);
}
.seg[aria-selected="true"]::after,
.seg[aria-selected="true"] + .seg::after { display: none; }

.bar-legend { display: flex; gap: 14px; font-size: var(--fs-sm); color: var(--ink-faint); }
.bar-legend span { display: flex; align-items: center; gap: 5px; }
.bar-legend i { width: 9px; height: 9px; border-radius: 2px; display: block; }
.bar-legend i.k-start { background: var(--series-start); }
.bar-legend i.k-now { background: var(--series-now); }

/* Bars fill the available height: rows share the space, and the bars themselves
   thicken with the viewport instead of leaving a band of dead space. */
.bars {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  display: flex;
  flex-direction: column;
}
.bar-row {
  /* Grows into spare height, never shrinks below its content: at 1440×900 a
     66px row was squeezed to 62px and the rows overlapped. */
  flex: 1 0 auto;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 4px;
  padding: 8px 0;
  min-height: 62px;
}
.bar-row + .bar-row { border-top: 1px solid var(--rule-soft); }

.bar-head { display: flex; align-items: baseline; gap: 8px; }
.bar-name {
  flex: 1;
  min-width: 0;
  font-size: var(--fs-lg);
  font-weight: var(--fw-med);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.bar-delta { font-size: var(--fs-md); font-weight: var(--fw-med); color: var(--ink-faint); flex: none; }
.bar-delta.up { color: var(--good); }
.bar-delta.down { color: var(--danger); }
/* The rep count this row's weights were normalised to. */
.bar-reps { font-size: var(--fs-sm); color: var(--ink-faint); flex: none; }

.bar-line { display: flex; align-items: center; gap: 7px; }
.bar-tag {
  width: 34px;
  flex: none;
  font-size: var(--fs-xs);
  font-weight: var(--fw-bold);
  color: var(--ink-faint);
}
.bar-track { flex: 1; min-width: 0; display: flex; align-items: center; }
/* 4px rounded data-end, anchored flat to the baseline at the start */
.bar { height: clamp(13px, 2.4dvh, 24px); border-radius: 0 4px 4px 0; min-width: 3px; }
.bar.start { background: var(--series-start); }
.bar.now { background: var(--series-now); }
/* An estimated endpoint is hatched, not just faded — texture survives
   greyscale and colour-blindness, and the series colours stay unaltered.
   Must follow .bar.start/.bar.now: those set background shorthand, which
   would otherwise clear this image. */
.bar.est {
  background-image: repeating-linear-gradient(135deg,
    transparent 0 4px, var(--ground) 4px 6px);
}
.bar-val {
  width: 54px;
  flex: none;
  text-align: right;
  font-size: var(--fs-md);
  font-weight: var(--fw-med);
  color: var(--ink-soft);
}
.bar-val.est { color: var(--ink-faint); font-style: italic; }

.chart-caption { display: flex; align-items: center; gap: 7px; font-size: var(--fs-sm); color: var(--ink-faint); }
.chart-caption.warn { color: var(--ink-soft); }

/* The rep count every point on the chart is compared at. Sits beside the
   exercise name because it is part of reading the y-axis, not a filter. */
.rep-target { display: flex; align-items: center; gap: 6px; flex: none; }
.rep-target-label {
  font-size: var(--fs-xs); font-weight: var(--fw-bold);
  color: var(--ink-faint);
}

/* Legend swatch drawn to match the chart's own marker: a ring means measured. */
.pt-key {
  width: 9px; height: 9px; border-radius: 50%; flex: none;
  background: var(--ground); border: 2.5px solid var(--accent); box-sizing: content-box;
}
/* The plot area takes every pixel left over; JS measures it and draws the SVG
   at that exact size, so the chart never letterboxes. */
.chart-wrap { flex: 1; min-height: 120px; width: 100%; position: relative; }
.chart { display: block; }
.chart .grid-line { stroke: var(--rule); stroke-width: 1; }
.chart .axis-text { fill: var(--ink-faint); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
.chart .series-line { fill: none; stroke: var(--accent); stroke-width: 2.5; stroke-linecap: round; stroke-linejoin: round; }
.chart .series-area { fill: var(--accent); opacity: .09; }
.chart .pt { fill: var(--ground); stroke: var(--accent); stroke-width: 2.5; }
.chart .pt.bench { stroke: var(--good); }
.chart .pt-last { fill: var(--accent); stroke: var(--ground); stroke-width: 3; }

/* Hover crosshair. The chart is the one place a precise number matters, and
   reading one off a line by eye is guesswork. */
.chart { cursor: crosshair; touch-action: pan-y; }
.chart .hover-line { stroke: var(--ink-faint); stroke-width: 1; stroke-dasharray: 3 4; }
.chart .hover-dot { fill: var(--accent); stroke: var(--ground); stroke-width: 2.5; }
/* An estimated point stays hollow and dashed, exactly as it is on the line. */
.chart .hover-dot.est {
  fill: var(--ground); stroke: var(--accent);
  stroke-width: 2; stroke-dasharray: 2.5 2.5;
}
/* paint-order puts the ground-coloured stroke behind the glyphs, giving the
   text a halo so it reads over gridlines and the area fill. A tooltip box would
   be the obvious solution and the wrong one — see Rule 2, no boxes. */
.chart .hover-val, .chart .hover-date {
  font-variant-numeric: tabular-nums;
  paint-order: stroke;
  stroke: var(--ground);
  stroke-width: 3.5px;
  stroke-linejoin: round;
}
.chart .hover-val { fill: var(--ink); font-size: var(--fs-md); font-weight: var(--fw-bold); }
/* --ink-soft, not --ink-faint. ⚠️ This line was the FIRST time this project
   caught --ink-faint failing AA — 3.05:1 against --ground in light theme, for a
   10.5px label somebody is actively reading a number off — and it was fixed here
   and only here, while the same token stayed in 75 other places. The audit on
   2026-08-20 found the rest. Soft is now 7.20:1 light / 7.15:1 dark; faint
   clears AA too, so this line is a hierarchy choice rather than a fix. */
.chart .hover-date { fill: var(--ink-soft); font-size: var(--fs-xs); font-weight: var(--fw-med); }

.summary-grid {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 1px;
  background: var(--rule-soft);
  border-radius: var(--r);
  overflow: hidden;
}
.stat { background: var(--ground); padding: 6px 8px; display: flex; flex-direction: column; gap: 0; min-width: 0; }
.stat-label { font-size: var(--fs-sm); font-weight: var(--fw-bold); color: var(--ink-faint); }
.stat-value {
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-lg);
  font-weight: var(--fw-med);
  letter-spacing: -0.03em;
  overflow: hidden;
  text-overflow: ellipsis;
}
.stat-value.up { color: var(--good); }
.stat-value.down { color: var(--danger); }
.stat.wide { grid-column: 1 / -1; }

/* ==========================================================================
   Day detail
   ========================================================================== */

.detail-ex { display: flex; flex-direction: column; gap: 4px; padding-top: 8px; }
.detail-ex + .detail-ex { border-top: 1px solid var(--rule-soft); }
.detail-ex-head { display: flex; align-items: center; gap: 7px; flex-wrap: wrap; }
.detail-ex-name { font-weight: var(--fw-med); font-size: var(--fs-lg); }
.detail-sets { display: flex; flex-wrap: wrap; gap: 5px; }
.detail-set {
  display: flex;
  gap: 6px;
  align-items: baseline;
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-md);
  color: var(--ink-soft);
  padding: 4px 8px;
  background: var(--raised);
  border-radius: 5px;
}
.detail-set b { color: var(--ink-faint); font-weight: var(--fw-med); font-size: var(--fs-xs); }

.day-head { display: flex; align-items: center; gap: 8px; }
.day-title { font-size: var(--fs-lg); font-weight: var(--fw-bold); letter-spacing: -0.015em; }

/* ==========================================================================
   Sheet / toast
   ========================================================================== */

/* The bottom inset is `--kb`, for the same reason #app has it: a fixed element
   is positioned against the LAYOUT viewport, which the keyboard does not
   change, so a bottom sheet with a search box in it puts its own results — and
   its Done button — underneath the keyboard it just raised. The exercise picker
   is the worst case in the app: 272 exercises, and 3 of them visible. */
.sheet-backdrop,
.sheet-backdrop-x {
  position: fixed; inset: 0 0 var(--kb, 0px) 0; z-index: 60;
  background: rgba(0,0,0,.55);
  display: flex;
  align-items: flex-end;
  justify-content: center;
}
.sheet,
.sheet-x {
  background: var(--ground);
  width: 100%;
  max-width: 620px;
  /* 90dvh normally — a sheet that reaches the top stops reading as a sheet —
     but never taller than the backdrop, which the keyboard has already shortened. */
  max-height: min(90dvh, 100%);
  border-radius: 16px 16px 0 0;
  display: flex;
  flex-direction: column;
  padding-bottom: var(--safe-b);
}
.sheet-head {
  padding: 12px var(--pad);
  border-bottom: 1px solid var(--rule);
  display: flex;
  align-items: center;
  gap: 10px;
  flex: none;
}
.sheet-head h2 { margin: 0; font-size: var(--fs-lg); font-weight: var(--fw-bold); flex: 1; letter-spacing: -0.015em; }
/* 🚨 `overflow-x: hidden` FOR THE SAME REASON `.pane-scroll` HAS IT — read that
   block first; this is the same fault in the app's other scrolling surface.
   MEASURED, not reasoned about: a real touch drag over the exercise-comparison
   sheet (Home → a friend's workout → an exercise name) moved this box 3px
   sideways at 360px and 3px at 390px. Three pixels of nothing, and it drags the
   whole sheet under the reader's thumb.
   The offender is `.help-dot::after` — the transparent halo that grows the ?
   footnote to a legal 44px target sits 9px outside a 26px button, and on this
   sheet that button is flush with the right-hand column edge. It is invisible
   by design, so there is nothing behind the drag to reach. Nothing in a sheet
   is ever wider than the sheet: the one wide thing in the app, Research's
   age-by-muscle table, carries its own `.research-scroll`. */
.sheet-body {
  padding: var(--pad);
  overflow-y: auto;
  overflow-x: hidden;
  display: flex; flex-direction: column; gap: 11px; min-height: 0;
}
.sheet-foot { padding: 10px var(--pad); border-top: 1px solid var(--rule); flex: none; }

.search-results { display: flex; flex-direction: column; flex: 1; min-height: 120px; overflow-y: auto; }
.search-results > * + * { border-top: 1px solid var(--rule-soft); }

/* ================================================================== *
 * Exercise pictures (2026-08-30)
 *
 * ⚠️ THE PICTURE ALWAYS SITS ON WHITE, in both themes, hard-coded and never a
 * token. These illustrations are drawn on a white ground; painted straight
 * onto a dark surface they read as a glaring rectangle with a ragged edge
 * where the artwork stops. Same decision the QR code made on 2026-08-29 — an
 * asset that carries its own background does not get to inherit the theme.
 *
 * ⚠️ AND NOTHING HERE RESERVES SPACE. There is no art in the repo yet, and
 * `exerciseLabel()` returns the bare name when there is none — so every rule
 * below applies to an element that simply is not rendered. A screen with no
 * pictures must look exactly as it did before this shipped.
 * ================================================================== */
.ex-label { display: inline-flex; align-items: center; gap: 8px; min-width: 0; }
.ex-label-btn {
  /* A button, because outside a row the NAME is what Tim asked to be tappable.
     Styled to nothing so it keeps reading as the heading or label it replaced. */
  background: none; border: 0; padding: 0; margin: 0; color: inherit;
  font: inherit; text-align: left; cursor: pointer;
}
.ex-thumb {
  flex: none; width: 34px; height: 34px; border-radius: 6px;
  /* ⚠️ CONTAIN, not cover. These illustrations are usually two frames side by
     side, and a square crop of one takes the GAP BETWEEN THEM and throws away
     the outer half of each figure. Contained it is small — which Tim already
     accepted ("obviously the full pictures would be too small to see"), and is
     why tapping enlarges it. A thumbnail's job here is to identify, and a
     cropped one identifies the wrong thing. */
  object-fit: contain; object-position: center;
  background: #fff; border: 1px solid var(--rule);
}
.session-ex-name .ex-thumb, .ex-label-btn > .ex-thumb { width: 38px; height: 38px; }

/* The sub-line lines up under the name rather than under the picture. Written
   with :has() so it is purely additive — where :has() is unsupported the row
   looks exactly as it did before pictures existed, which is the same failure
   mode the whole feature is built around. */
.row-main:has(.ex-thumb) .row-sub { padding-left: 42px; }

/* The viewer. Fixed, above everything, its own ✕ in the corner. */
.exview,
.exview-x {
  position: fixed; inset: 0; z-index: 60;
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: 12px; padding: calc(env(safe-area-inset-top) + 52px) 16px calc(env(safe-area-inset-bottom) + 20px);
  background: rgba(0, 0, 0, .82);
}
.exview-close {
  position: absolute; top: calc(env(safe-area-inset-top) + 8px); right: 10px;
  background: var(--raised); color: var(--ink);
}
/* ⚠️ IT TAKES UP THE SCREEN, which is what Tim asked for and is not what the
   first version did: sized to the artwork's own pixels, a 600px-wide
   illustration rendered 346px wide in the middle of a black screen — a
   thumbnail with more ceremony. The card claims the width and the picture
   scales UP into it; `contain` keeps it undistorted, so a tall illustration
   ends up shorter than the card rather than stretched. */
.exview-card {
  background: #fff; border-radius: 10px; padding: 8px;
  width: 100%; flex: 0 1 auto; min-height: 0;
  display: flex; align-items: center; justify-content: center; overflow: hidden;
}
.exview-img {
  width: 100%; height: 100%; max-height: 100%;
  object-fit: contain; display: block;
}
/* ⚠️ On the dim ground, not on a token surface — this line sits over the
   backdrop and its contrast is against that, in both themes. */
.exview-name { color: #fff; font-size: var(--fs-lg); font-weight: var(--fw-med); text-align: center; }

/* ⚠️ THE SWAP SHORTLIST (2026-08-30). Five rows and a way onward, so the sheet
   is short enough not to want its own scroller — `.search-results` is written
   for the 275-row picker and claims `flex: 1` with a 120px floor, which inside
   a five-row sheet reserves height nothing is using. Here it sizes to content
   and the SHEET scrolls if it ever has to. */
.swap-lead { margin-bottom: 2px; }
.sheet .swap-lead + .search-results,
.sheet-x .swap-lead + .search-results { flex: none; min-height: 0; overflow: visible; }
.swap-all { margin-top: 12px; }

.toast,
.toast-x {
  position: fixed;
  left: 50%;
  bottom: calc(var(--bar-h) + var(--safe-b) + 14px);
  transform: translateX(-50%);
  z-index: 90;
  background: var(--raised-hi);
  color: var(--ink);
  padding: 10px 16px;
  border-radius: 999px;
  font-size: var(--fs-lg);
  font-weight: var(--fw-med);
  box-shadow: 0 6px 22px rgba(0,0,0,.45);
  max-width: calc(100vw - 28px);
  text-align: center;
}

/* ==========================================================================
   Workout builder
   ========================================================================== */

.builder-item {
  display: grid;
  grid-template-columns: 1fr auto auto;
  grid-template-areas:
    "main  move  del"
    "ctl   ctl   ctl"
    "note  note  note";
  align-items: center;
  gap: 5px 8px;
  padding-block: 9px;
}
.builder-main { grid-area: main; min-width: 0; }
.builder-main .row-title { font-size: var(--fs-lg); }
.move-btns { grid-area: move; display: flex; flex-direction: column; gap: 1px; }
.move-btns button { width: 28px; height: 21px; display: grid; place-items: center; color: var(--ink-faint); border-radius: 4px; }
.move-btns button:active { background: var(--raised); }
.move-btns svg { width: 13px; height: 13px; }
.move-btns button:disabled { opacity: .22; pointer-events: none; }
.builder-item > .icon-btn { grid-area: del; }

.builder-controls { grid-area: ctl; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.set-type { font-size: var(--fs-sm); padding: 3px 9px; }
.set-type.is-on { background: var(--accent-dim); color: var(--accent); border-color: transparent; }

/* ── Supersets in the builder ──────────────────────────────────────────────
   A block is bracketed by a hairline down its left and a label, never by a
   bordered card (Rule 2). The rule is the thing that says "these are one
   unit"; a box would say the same and cost 2px of height on every row plus a
   second visual language on a screen that already has none. */
.builder-group {
  border-left: 2px solid var(--accent);
  padding-left: 11px;
  margin-block: 4px;
}
.builder-group-head { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; padding-top: 6px; }
.builder-group-label {
  font-size: var(--fs-sm); font-weight: var(--fw-bold);
  color: var(--accent);
}
.builder-group-hint { font-size: var(--fs-sm); color: var(--ink-faint); }

/* The join control lives in the GAP between two exercises, because that is
   what a superset is a statement about. Quiet until it is on. */
.link-gap { display: flex; padding: 2px 0 6px; }
.link-btn {
  display: inline-flex; align-items: center; gap: 6px;
  font-size: var(--fs-sm); color: var(--ink-faint);
  padding: 3px 8px 3px 0; border-radius: 5px;
}
.link-btn svg { width: 15px; height: 15px; display: block; }
.link-btn:active { background: var(--raised); }
.link-gap.is-linked .link-btn { color: var(--accent); }

.builder-control-label {
  font-size: var(--fs-sm);
  font-weight: var(--fw-bold);
  color: var(--ink-faint);
}

.builder-note {
  grid-area: note;
  min-height: 34px;
  height: 34px;
  padding: 7px 10px;
  font-size: var(--fs-md);
  background: var(--raised);
  border: 0;
  border-radius: 7px;
  color: var(--ink);
  resize: none;
  width: 100%;
  line-height: 1.35;
}
.builder-note::placeholder { color: var(--ink-faint); }
.builder-note:focus { outline: none; box-shadow: inset 0 0 0 1.5px var(--accent); }

/* ---- mini stepper ---- */

.mini-stepper {
  display: flex;
  align-items: center;
  gap: 1px;
  background: var(--raised);
  border-radius: 7px;
  padding: 2px;
  flex: none;
}
.mini-btn { width: 30px; height: 28px; display: grid; place-items: center; border-radius: 5px; color: var(--ink-soft); }
.mini-btn:active { background: var(--raised-hi); color: var(--ink); }
.mini-btn svg { width: 14px; height: 14px; }
.mini-value { min-width: 20px; text-align: center; font-size: var(--fs-lg); font-weight: var(--fw-bold); font-variant-numeric: tabular-nums; }

/* ---- load-type badge ---- */

.load-badge {
  display: inline-block;
  font-size: var(--fs-xs);
  font-weight: var(--fw-bold);
  letter-spacing: 0.08em;
  padding: 2px 5px;
  border-radius: 3px;
  background: var(--raised);
  color: var(--ink-faint);
  white-space: nowrap;
  flex: none;
}
.load-badge.per-side { background: var(--accent-dim); color: var(--accent); }
/* ⚠️ THE DEFAULT PALETTE'S LIGHT GOLD CANNOT CARRY THIS CHIP — browser audit
   2026-09-06, 128 routes, eight failures and all of them this one element. The
   badge is 9px/800, so WCAG AA wants 4.5:1, and --accent on --accent-dim
   measured 3.96:1 (#96660F on #F0E4CB). Nothing else painted here is wrong: the
   fill is left exactly as it is, and only the letters darken, so the chip still
   matches the --accent-dim pills (.set-type.is-on, the linked button) sitting
   beside it on the same sheet.

   Measured, --accent on --accent-dim, every palette in both themes:
     gold   dark 6.27  light 3.96 ← the only failure, fixed to 5.02 below
     teal   dark 6.22  light 4.64
     indigo dark 6.35  light 5.02
     ember  dark 6.01  light 4.56
   The chip background is an opaque token, so the surface underneath it —
   ground on the set list, surface on the swap and exercises sheets — cannot
   change the ratio, and one value settles every place the badge is painted.

   ⚠️ SCOPED WITH :not([data-palette]) BECAUSE THAT IS WHAT "DEFAULT" MEANS HERE
   — boot removes the attribute for gold and sets it only for a chosen palette
   (js/app.js, js/views-data.js). A plain :root[data-theme="light"] rule, which
   is the shape .row-start uses, would paint this gold over teal, indigo and
   ember light too, and those three already clear AA on their own accents.
   #82570B is the same hue as #96660F, darkened: 5.02:1 on --accent-dim, with
   margin rather than the 4.50 that .row-start's #8B5E0D sits exactly on. */
/* 🚨 AND THE OTHER FOUR ELEMENTS PAINTING THIS PAIR — 2026-09-10. This rule
   fixed ONE of them on 2026-09-06 for a reason that looked sound and was not:
   at phone width `.load-badge.per-side` was the only element the audit could
   see painting `--accent` on `--accent-dim`. The desktop sweep added on
   2026-09-10 immediately found a second (the active sidebar nav label, below),
   and reading the stylesheet for the pair rather than waiting for an instrument
   to trip over it found three more.

   ⚠️ THE LESSON IS THE SCOPE OF THE ORIGINAL FIX, NOT THE COLOUR. "Every
   element that paints this today" means "every element the tool I happened to
   run can reach". The pair is a property of the two TOKENS; anything putting
   one on the other fails, at any width, on any screen, whether or not a route
   list contains it. Fixed together so the next element to paint them inherits
   the fix instead of the bug.

   ⚠️ `.bench-badge` is 9.5px/800 and `.set-type.is-on` and `.btn.primary.is-
   linked` are small controls — none of them is large-text under WCAG, so 4.5:1
   is the bar for all of them and 3.96 fails it. */
/* ⚠️ KEPT AS ITS OWN RULE RATHER THAN FOLDED INTO THE GROUP BELOW, and that is
   a note about the TEST rather than about the CSS. `tests/a11y.test.mjs` reads
   this pair by matching the selector followed directly by its `color:`, which
   is how it pins the hex to the element rather than to the file. Grouping the
   four selectors made that regex miss, `perSide` came back undefined, and the
   suite CRASHED — which through a `grep ^FAIL` looks exactly like a pass. */
:root[data-theme="light"]:not([data-palette]) .load-badge.per-side { color: #82570B; }

:root[data-theme="light"]:not([data-palette]) .bench-badge,
:root[data-theme="light"]:not([data-palette]) .btn.primary.is-linked,
:root[data-theme="light"]:not([data-palette]) .set-type.is-on { color: #82570B; }

/* ==========================================================================
   Finish screen
   ========================================================================== */

.finish-hero {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  text-align: center;
}
.finish-check {
  width: 62px; height: 62px;
  border-radius: 50%;
  background: var(--good-dim);
  color: var(--good);
  display: grid; place-items: center;
}
.finish-check svg { width: 32px; height: 32px; }
.finish-hero h2 { margin: 0; font-size: 23px; font-weight: var(--fw-bold); letter-spacing: -0.025em; }
.finish-hero p { margin: 0; color: var(--ink-faint); font-size: var(--fs-lg); }

/* ── Personal bests on the finish screen (UX review item 1) ────────────────
   Accent hairline, no box (Rule 2) — the same do-not-skim shape the preset
   warning uses, pointed at good news for once. */
.finish-prs {
  display: flex; flex-direction: column; gap: 4px;
  border-left: 2px solid var(--good);
  padding: 2px 0 2px 12px;
  text-align: left;
  max-width: 100%;
}
.finish-prs-head {
  display: inline-flex; align-items: center; gap: 5px;
  color: var(--good); font-size: var(--fs-sm); font-weight: var(--fw-bold);
}
.finish-pr { font-size: var(--fs-lg); font-weight: var(--fw-med); overflow-wrap: anywhere; }

/* ==========================================================================
   Desktop
   ========================================================================== */

@media (min-width: 860px) {
  :root { --pad: 22px; --gap: 12px; }

  #app { flex-direction: row; }

  .navbar {
    height: auto;
    width: 200px;
    flex: none;
    flex-direction: column;
    justify-content: flex-start;
    gap: 2px;
    padding: 20px 10px;
    border-top: 0;
    border-right: 1px solid var(--rule);
    overflow-y: auto;
    /* And the sideways axis off, for the reason the list beside .pane-scroll
       gives. ⚠️ IT HAS TO BE WRITTEN HERE RATHER THAN IN THAT LIST: the line
       above is the only `overflow-y` the navbar ever gets, so at phone width
       the element is `visible` on both axes. Clipping X up there would compute
       Y to `auto` and turn the bottom tab bar into a vertical scroller. */
    overflow-x: hidden;
  }
  .navbar a {
    flex: none;
    flex-direction: row;
    justify-content: flex-start;
    gap: 11px;
    padding: 10px 12px;
    border-radius: 7px;
    font-size: var(--fs-lg);
  }
  .navbar a[aria-current="page"] { background: var(--accent-dim); }
  /* 🚨 --accent ON --accent-dim, THE SECOND ELEMENT IN THIS SHEET TO PAINT THAT
     PAIR — desktop a11y sweep, 2026-09-02, 880px and 1280px, 14 routes each.
     The active label measured **3.96:1** in gold light and fails AA.

     ⚠️ IT IS A DESKTOP-ONLY FAULT AND THAT IS WHY IT SURVIVED. On a phone the
     active tab is --accent on the plain navbar --surface (4.99:1, passes); the
     line above is what puts --accent-dim underneath it, and until 2026-09-02
     this audit had only ever run at phone widths. Dark is 6.27 and passes;
     inactive labels are 9.41 / 7.94 everywhere.

     Same pair, same hex, same scoping argument as `.load-badge.per-side` above
     — read the block at its rule for the full measurement table. In short:
     #82570B is #96660F darkened to the same hue, 5.02:1 on --accent-dim, and
     `:not([data-palette])` is what "the default palette" means here, because
     teal (4.64), indigo (5.02) and ember (4.56) already clear AA on their own
     accents and an unscoped rule would repaint three that pass to fix one.

     Only the letters change; the --accent-dim pill behind them is untouched, so
     the selected tab still reads as the same shape it always did. */
  :root[data-theme="light"]:not([data-palette]) .navbar a[aria-current="page"] { color: #82570B; }

  /* The app name and the account button, top of the sidebar. This replaced a
     ::before pseudo-element that held the title — a pseudo-element cannot
     contain a real, focusable button. */
  .nav-brand {
    display: flex;
    align-items: center;
    gap: 9px;
    padding: 2px 8px 14px;
  }
  .nav-brand .avatar-btn { width: 30px; height: 30px; }
  .nav-brand .avatar-btn svg { width: 17px; height: 17px; }
  .nav-brand .avatar-glyph { font-size: var(--fs-md); }
  .nav-brand-name {
    font-size: var(--fs-lg);
    font-weight: var(--fw-bold);
    letter-spacing: -0.02em;
    color: var(--ink);
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* Only one account button on screen at a time. On desktop the sidebar owns
     it, so the one in the content header stands down. */
  .topbar .avatar-btn { display: none; }

  .screen { min-width: 0; }
  .topbar { padding-top: 18px; padding-bottom: 14px; }
  .topbar h1 { font-size: 24px; }

  /* Comes after the base .list rule, so its `margin-inline: auto` cancels the
     mobile full-bleed negative margin as well as centring the column. */
  .pane-top > *, .pane-scroll > *, .pane-bottom > * { max-width: 820px; width: 100%; margin-inline: auto; }

  /* The workout bar keeps its full-width surface — it is the bottom edge of the
     content area, like the navbar is the left edge — but its CONTENTS line up
     with the 820px column above them rather than sitting out at the margin. */
  .session-mini-open { padding-left: max(var(--pad), calc((100% - 820px) / 2)); }
  .mini-del { margin-right: max(0px, calc((100% - 820px) / 2 - 52px)); }
  .list > * { padding-inline: 0; }

  .steppers { grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 12px; }
  .step-btn { width: 54px; height: 58px; }
  .step-value { font-size: 34px; }

  .cal-cell { min-height: 78px; padding: 4px; }
  .cal-day { font-size: var(--fs-md); }
  .cal-tag { font-size: var(--fs-xs); padding: 2.5px 4px; }

  .sheet-backdrop, .sheet-backdrop-x { align-items: center; }
  .sheet { border-radius: 14px; max-height: min(82dvh, 100%); }

  .session-footer { justify-content: center; padding-bottom: 12px; }
  .session-footer > * { max-width: 820px; }
  .session-footer .nav-arrow { max-width: 48px; }

  .toast { bottom: 24px; }
  .stat-value { font-size: var(--fs-xl); }

  /* Muscle Groups: the detail sits BESIDE the figures, in the empty space next
     to them, rather than underneath.

     Stacked, the panel pushed the body up and shrank it the moment you tapped a
     muscle — the figure is the content, and content does not get smaller because
     you asked it a question (Rule 3). As a side column the panel's height
     changes freely and the figures never move at all.

     This shares the 860px breakpoint rather than getting its own. Measured at
     960px the side layout gives a LARGER figure than stacking does (395x433 vs
     338x370), because a column costs width once whereas a stacked panel costs
     nearly half the height. The two are about even at 860, and stability wins
     the tie. The fluid width is what keeps that true at the narrow end. */
  .graph-host.is-muscles { flex-direction: row; align-items: stretch; gap: 18px; }
  /* ⚠️ `height: auto` undoes the 57% the phone layout uses. Here the panel is a
     COLUMN beside the figure rather than a block under it, so the figure's
     height is the container's and a percentage of it would shrink the one thing
     this whole rule exists to keep still. */
  .graph-host.is-muscles .body-wrap { flex: 1; min-width: 0; height: auto; }
  .graph-host.is-muscles .body-foot {
    width: clamp(260px, 32%, 340px);
    flex: none;
    max-height: 100%;
    padding-left: 18px;
    /* A hairline, not a card — the panel is a column of this screen, not a box
       floating on it (Rule 2). */
    border-left: 1px solid var(--rule-soft);
  }
  /* Vertical room is no longer scarce here, so the legend can breathe. */
  .graph-host.is-muscles .lv-key { gap: 6px 12px; }
}

/* ==========================================================================
   THE DETAIL PANEL GETS ITS FIVE COLUMNS — 2026-09-24, Tim's ask
   ==========================================================================
   *"On the laptop/computer, the muslce groups section allows for a little more
   space. Could you make the details on the right side a little wider so that
   you don't need to click 'more details' to see the other things? Keep the
   version the same on the phone to conserve space."*

   🚨 THIS BREAKPOINT IS 1024px AND THE SPLIT'S IS 860px, AND THE GAP BETWEEN
   THEM IS THE WHOLE DESIGN. The first attempt widened the panel at 860 and it
   was measured rather than eyeballed, which is the only reason it did not ship:
   at an 880px window the figure fell to **298px wide against the panel's 320**,
   and the exercise column sat on its 4.5em floor, so "Barbell Bench Press" was
   wrapping inside 59px. **Buying five columns by crushing the body is exactly
   what Rule 3's corollary forbids** — the figure is the content on this screen.

   So the extra width and the extra columns arrive TOGETHER, at the width where
   there is genuinely room for both. Between 860 and 1023 nothing changes at all:
   the panel is still clamp(260px, 32%, 340px), the table is still three columns,
   and "More details" is still there for anyone who wants the working.

   ⚠️ `js/views-muscles.js` HOLDS THE SAME NUMBER, because CSS cannot tell JS what
   it matched and the table's cells are built before there is a layout to measure.
   **`tests/a11y.test.mjs` reads this file and fails if the two stop agreeing** —
   that test is what makes the duplication safe rather than a latent drift.

   Measured after the change, demo year, Back selected:
     1024px — figure 372x702, panel 349, exercise column 88px
     1280px — figure 527x702, panel 395, exercise column 134px
   Both still clear the 338x370 the stacked layout gives, which is the comparison
   the 860px breakpoint was originally chosen on.
   ========================================================================== */
@media (min-width: 1024px) {
  .graph-host.is-muscles .body-foot,
  /* ⚠️ The friend's map gets it too, and that is FALLOUT rather than a second
     ask: the columns' default is one piece of module state shared by every panel
     alive, so a friend's panel would otherwise show five columns in a box sized
     for three. Fixing the consequence of a change he asked for is part of it. */
  .map-split > .body-foot { width: clamp(340px, 40%, 460px); }

  /* 🚨 AND THE TYPE GOES UP WITH THE COLUMNS — 2026-09-25, Tim's report:
     *"much of the text just got a lot smaller and much harder to read. This made
     it worse rather than better."*

     He was right and the numbers are ugly: body text on this app is 15.5px, the
     source rows are 12.5px, and the header row is **9.5px** — so the moment the
     five-column state became the DEFAULT on a laptop, the thing he had asked to
     see more of was rendered in the smallest type in the product, and a header
     he had never had to read before became the smallest of all.

     ⚠️ THE 9.5px WAS DEFENSIBLE WHEN IT WAS OPT-IN AND IS NOT NOW. It was sized
     for a phone, where the header is a legend somebody deliberately opened and
     glances at once. A default is read every time.

     🛑 SCOPED TO THE WIDE STATE ON PURPOSE — the phone keeps 12.5/9.5 exactly,
     which is the half he asked to leave alone, and it has the width for neither. */
  .graph-host.is-muscles .muscle-sources,
  .map-split .muscle-sources { font-size: var(--fs-md); }
  .graph-host.is-muscles .msrc-head > *,
  .map-split .msrc-head > * { font-size: var(--fs-sm); }
}

/* 🚨 THE MUSCLES PANE IS EXEMPT FROM THE 940px READING WIDTH — 2026-09-25.
   Tim: *"the side menu is moved a couple inches off the right side wall."*

   Measured at 1512: the pane was capped at 940px and the panel's right edge sat
   **186px** from the window edge — about the two inches he described. The cap
   below is a LINE-LENGTH rule and it is right for prose; this screen is a figure
   beside a data table, where the cap was spending the space he had just asked to
   use on empty margin.

   ⚠️ IT IS AN EXEMPTION, NOT A CHANGE TO THE RULE. Every other pane in the app
   still reads at 940px, because that is what stops a paragraph running the width
   of a monitor. `.graph-host.is-muscles` is the only selector lifted, and it is
   lifted to a CAP rather than to `none` — at 1920 an uncapped figure would be
   half a metre of chest. */
@media (min-width: 1200px) {
  .pane-scroll > .graph-host.is-muscles { max-width: 1280px; }
}

@media (min-width: 1200px) {
  .pane-top > *, .pane-scroll > *, .pane-bottom > * { max-width: 940px; }
}

/* ==========================================================================
   MOTION — 2026-09-01, Tim's ask
   ==========================================================================

   *"When you click on something, I want it to have some sort of visible motion
   between the movement rather than just an instant change or teleportation…
   if these movements have some sort of realistic acceleration in how they
   start and stop that would be cool as well. Make sure to keep it quick
   though… Only use it when it's appropriate as well."*

   Four rules came out of that, and they are what everything below obeys:

   1. MOTION SHOWS A RELATIONSHIP, NEVER DECORATION. A thing that moves is
      telling you where it came from or where it went — the sheet came from the
      bottom edge, the selected segment slid from the one you were on, the row
      you opened pushed the rest down. If a movement does not answer "what
      just happened", it does not go in.
   2. NOTHING ANIMATES ON THE LOGGING PATH except a press answering back. The
      set list, the steppers and the rest timer are used one-handed with a bar
      in the other hand, and 170ms of politeness between tapping + and seeing
      the number is 170ms of standing there. That is what "only when
      appropriate" means in a gym app, and it is the one place this file spends
      no time at all.
   3. IT ACCELERATES AND IT DECELERATES. Linear motion is what reads as
      computery. Things arriving decelerate (--ease-out), things leaving
      accelerate away (--ease-in), and things moving from one place to another
      while you watch do both (--ease-both) — that last one is the only one
      that really looks like an object with weight.
   4. ONLY transform AND opacity WHERE THERE IS A CHOICE. Those two are handed
      to the compositor and cost no layout. The exceptions are deliberate and
      small: the volume bars animate `width` once when a screen is built, and
      an expanding row animates `grid-template-rows`, which is the only honest
      way to make the content below it slide rather than jump.

   ⚠️ AND ALL OF IT IS OFF UNDER prefers-reduced-motion, which is not a
   courtesy — vestibular disorders make sliding panels genuinely unpleasant,
   and this is a fitness app, so somebody using it may well be moving already.
   The block at the end of this section is the switch, and it is a blanket:
   anything added below it is covered without being remembered. */

/* ---- a screen arrives ------------------------------------------------- */
/* ⚠️ 6px AND A FADE, not a slide across. A horizontal push implies a stack —
   "you went forward, back is that way" — and this router has no idea which
   direction you went: `#/home` can be reached by tapping Home, by a back
   gesture or by a redirect after saving. A movement that says something false
   about where you are is worse than no movement. A short rise says only "this
   is new", which is all that is known. */
.screen { animation: screen-in var(--t) var(--ease-out); }
@keyframes screen-in { from { opacity: 0; transform: translateY(6px) } }

/* ---- a surface leaves -------------------------------------------------- */
/* ⚠️ THE CLASS IS RENAMED ON THE WAY OUT — `.sheet` becomes `.sheet-x` — and
   that is a deliberate contract rather than a trick. A closing sheet must stop
   being a sheet the instant it is asked to close: it stops matching
   `document.querySelector('.sheet')`, so nothing in the app or in a test can
   find, focus or assert against a panel that is on its way off the screen. The
   alternative (an `.is-closing` class) leaves a real `.sheet` in the DOM for a
   quarter of a second, which is exactly the sort of ghost that produces a test
   that passes for the wrong reason. The layout rules carry both names; only
   the animation differs. */
.sheet-backdrop { animation: fade-in var(--t) var(--ease-out); }
.sheet-backdrop-x { animation: fade-out var(--t) var(--ease-in) forwards; pointer-events: none; }
.sheet { animation: sheet-in var(--t-slow) var(--ease-out); }
.sheet-x { animation: sheet-out var(--t) var(--ease-in) forwards; }
.exview { animation: fade-in var(--t) var(--ease-out); }
.exview-x { animation: fade-out var(--t) var(--ease-in) forwards; pointer-events: none; }
@keyframes fade-in  { from { opacity: 0 } }
@keyframes fade-out { to { opacity: 0 } }
/* 2026-09-24: the sheet comes up from the bottom edge by its whole height and
   goes back down the same way (was a 22px nudge). 240ms in, 170ms out; under
   reduced motion the blanket below makes both instant, so it just appears. */
@keyframes sheet-in { from { transform: translateY(100%) } }
@keyframes sheet-out { to { transform: translateY(100%) } }

/* ---- RECORD COMES UP FROM THE BOTTOM (2026-09-09) ---------------------- */
/* 🚨 Tim: *"To make the record section feel more like a button that actually
   activates something, I want the screen to pull up the record section from the
   bottom (which covers over the main section display). The only change is that
   we'll add a down arrow in the upper left which will push the record section
   back down."*

   ⚠️ THIS IS THE ONE PLACE IN THE APP WHERE A WHOLE SCREEN MOVES, and it earns
   it on Rule 7's own test — a movement has to say where a thing came from. The
   big middle + is the one control in the product that is an ACTION rather than a
   destination (D4), and a tab swap said nothing about that. It rises from the
   edge its own button sits on.

   ⚠️ IT TRAVELS OVER THE TAB BAR ON THE WAY, deliberately: the panel comes from
   below the bar, which is where the finger that summoned it is. `--t-slow` is
   the duration for a whole surface and the ceiling is 250ms (a11y.test.mjs
   fails past it).

   🚨 AND NOTHING ON THE LOGGING PATH MOVES. Rule 7's own exception is that the
   set list, the steppers and the timer stay instant; this is the screen BEFORE a
   workout starts, not one you use with a bar in your hand. */
/* 🚨 AND IT NEEDS ITS OWN GROUND, which is the thing that turns two screens
   painted at once from a slide into a mess. A `.screen` has never had a
   background — it inherits the app's, which is fine when it is the only one on
   screen and is the whole problem for the 240ms when it is not: the first
   version of this had the Record panel and the screen behind it legible THROUGH
   each other, and it looked like a rendering bug rather than a movement.
   ⚠️ Both the panel and the parked screen need it, for the two directions. */
/* 🚨 `z-index` ON A STATIC ELEMENT, AND IT IS NOT A MISTAKE — 2026-09-10, and
   it is the fix for the bug below. `z-index` normally needs `position`, and the
   documented exception is FLEX ITEMS: a flex item with a z-index other than
   `auto` paints as though it created a stacking context, in the positioned
   layer. `.screen` is a flex child of `#app`, so this lifts the rising panel
   over the parked ghost — and over the tab bar it is meant to travel across —
   WITHOUT making it a containing block. `position: relative` would do the same
   job and would also re-anchor every absolutely-positioned descendant inside
   the screen for the 240ms the class is on, which is a layout change nobody
   asked for in the middle of an animation. */
.screen.rises {
  animation: screen-up var(--t-slow) var(--ease-out) both;
  background: var(--ground);
  z-index: 45;
}
@keyframes screen-up { from { transform: translateY(100%) } }

/* The screen it rose over, parked outside the router for the length of the
   animation — see parkScreen() in ui.js. Positioned from its measured box, so
   the only thing this rule owes is the layer and the fact that it is inert. */
.screen-ghost {
  position: fixed;
  /* 🚨 THE TWO DIRECTIONS WANT OPPOSITE STACKING, AND SHIPPING ONE NUMBER FOR
     BOTH IS THE BUG TIM REPORTED ON 2026-09-10: *"I can tell there's some sort
     of animation when you hit record and that section pulls up, but it
     immediately gets shut down and the screen does not cover the main section
     display at all. When I click the down arrow on the record section however,
     the downwards animation is good."*

     Exactly right, and the asymmetry is the diagnosis. `#app` creates no
     stacking context (no transform, no opacity, no z-index), so a
     `position: fixed; z-index: 50` ghost painted over EVERYTHING in it —
     including the panel rising underneath. The rise ran for its full 240ms
     behind a still picture of the old screen and only became visible in
     whatever was left of it after the ghost was removed, which is precisely
     "an animation that immediately gets shut down". Nothing was covered,
     because the thing doing the covering was the wrong one.

     ⚠️ FALLING GENUINELY WANTS THE TOP. The down arrow slides the Record
     screen off the bottom to reveal Home underneath, so there the ghost IS the
     moving thing and has to be above the router's output — which is why that
     direction looked right and was never suspected. Two behaviours, two
     layers, and the class that already distinguished them now carries it. */
  z-index: 40;          /* under .screen.rises (45), sheets (60), toasts (90) */
  pointer-events: none;
  overflow: hidden;
  display: flex;
  background: var(--ground);
}
/* 🚨 `animation: none` IS LOAD-BEARING, not tidiness. Moving a node in the DOM
   RESTARTS its CSS animations in Chrome — so the parked screen would replay
   `screen-in`, fading up from transparent, at the exact moment it is meant to be
   a still picture of where you just were. A picture of the previous screen that
   fades in as the new one rises over it reads as a flash, and it would be
   invisible in every test, because jsdom never builds a ghost at all. */
.screen-ghost > .screen { flex: 1; min-width: 0; animation: none; }
/* 🔄 AND THE TAB BAR RIDES IN THE GHOST SINCE 2026-09-12 — `parkScreen()` parks
   everything in `#app`, because Record is a no-nav screen now (Tim: it has to
   *"cover over the main sections display"*) and a bar that vanished a frame
   before the panel reached it was the opposite of being covered. The ghost's
   flex-direction is copied from `#app` inline, so the bar sits where it sat. */
.screen-ghost > .navbar { flex: none; animation: none; }
/* ⚠️ FAR ENOUGH TO CLEAR THE TAB BAR, not just its own height. At exactly 100%
   the panel's top edge lands on the bar's top edge and the last frame before it
   is removed has the bar covered — a flicker, at the one moment the eye is on
   the bar to see Home light up. */
.screen-ghost.is-falling {
  animation: screen-down var(--t-slow) var(--ease-in) both;
}
@keyframes screen-down {
  to { transform: translateY(calc(100% + var(--bar-h) + var(--safe-b))) }
}

/* ⚠️ THE TOAST HAD ITS OWN TRANSFORM AND THE ENTRY ANIMATION WAS EATING IT.
   `.toast` is centred with `translateX(-50%)`, and it borrowed the shared
   `rise` keyframe — whose `from` sets `transform: translateY(18px)` and so
   drops the centring for the length of the animation. It has been popping in
   half its own width to the right and sliding back ever since it was written.
   Its keyframes carry both parts now. */
.toast { animation: toast-in var(--t) var(--ease-out); }
.toast-x { animation: toast-out var(--t) var(--ease-in) forwards; pointer-events: none; }
@keyframes toast-in  { from { transform: translateX(-50%) translateY(14px); opacity: 0 } }
@keyframes toast-out { to   { transform: translateX(-50%) translateY(8px);  opacity: 0 } }

/* ---- the segmented control's pill slides ------------------------------ */
/* 🚨 THIS IS THE ONE TIM DESCRIBED. Tapping "Bars" used to repaint the pill
   under your finger and unpaint the old one — two instant changes with nothing
   joining them. Now one pill travels, and because it is the same object moving
   it also says which way you went along the row.
   ⚠️ It is drawn by JS (`wireSegmented`), and the painted-pill fallback below
   is what the control looks like if that never runs — no script, an old engine,
   a jsdom test. `.has-ind` is the class the JS adds, so the fallback is the
   default and the enhancement is opt-in. */
.segmented { position: relative; }
.seg { z-index: 1; transition: color var(--t) var(--ease-out); }
.seg-ind {
  position: absolute;
  left: 0; top: 0; width: 0; height: 0;
  z-index: 0;
  border-radius: 7px;
  background: var(--ground);
  transition: transform var(--t) var(--ease-both), width var(--t) var(--ease-both);
  pointer-events: none;
}
/* The first placement must not slide in from the left edge on every render. */
.seg-ind.no-anim { transition: none; }
.segmented.has-ind .seg[aria-selected="true"] { background: none; }

/* The hairline between unselected segments fades with the pill rather than
   blinking — `display: none` cannot transition, and this rule is only ever a
   separator, so opacity is the honest property for it anyway. */
.seg + .seg::after { opacity: 1; transition: opacity var(--t) var(--ease-out); }
.seg[aria-selected="true"]::after,
.seg[aria-selected="true"] + .seg::after { display: block; opacity: 0; }

/* ---- a press answers back --------------------------------------------- */
/* The only motion allowed on the logging path (rule 2 above), and it is 100ms.
   The background fades instead of blinking; the box gives a little. */
.row, .chip, .seg, .vol-row, .navbar a {
  transition: background-color var(--t-fast) linear, color var(--t-fast) linear,
              transform var(--t-fast) var(--ease-out);
}
.row:active, .chip:active, .vol-row:active { transform: scale(.99); }
.chip:active { transform: scale(.96); }

/* ---- a row opens ------------------------------------------------------- */
/* ⚠️ grid-template-rows: 0fr -> 1fr IS THE POINT, and a plain fade would have
   missed it. Fading the detail in still teleports every row BELOW it down the
   screen, which is the exact thing Tim described. Animating the track height
   makes the list slide open and shut around it.
   Where `fr` interpolation is unsupported it snaps — which is precisely the
   behaviour this replaced, so the floor is today's app. */
/* ⚠️ minmax(0, 0fr) AND NOT A BARE 0fr, which is the trap in this technique and
   is invisible unless you measure it. A bare `0fr` track is `minmax(auto, 0fr)`,
   so its automatic MINIMUM is the item's min-content height — and the closed row
   measured 14px in Chrome, exactly the detail's own top and bottom padding,
   which would have left twelve slivers of dead space down the list. `overflow:
   hidden` and `min-height: 0` on the item do not help; padding still counts. */
.vol-detail-wrap {
  display: grid;
  grid-template-rows: minmax(0, 0fr);
  transition: grid-template-rows var(--t) var(--ease-both), opacity var(--t) var(--ease-out);
  opacity: 0;
}
.vol-detail-wrap > * { overflow: hidden; min-height: 0; }
.vol-detail-wrap.is-open { grid-template-rows: minmax(0, 1fr); opacity: 1; }

/* ~~---- a system opens and closes (2026-09-16) ----~~ DELETED 2026-09-19.
   The four rules here dressed a `<details class="sys-group">` — list-style off,
   the webkit marker off, and the chevron turning on `--t-fast`. Both screens
   show ONE programme now (see `js/views-workouts.js`, the current-system block),
   so there is no disclosure left to dress and a rule matching nothing is a rule
   the next person has to work out is dead. `.me-other` and `.rt-topic` are still
   real disclosures and keep their own. */

/* ---- a bar grows to its number ---------------------------------------- */
/* Only when the screen is built or its window changes — the number really did
   just change, and the growth is what says by how much. It does NOT replay
   when a row is opened, because nothing about the data moved. */
.vol-fill { animation: vol-grow var(--t-slow) var(--ease-out); }
@keyframes vol-grow { from { width: 0 } }

/* ~~---- a set locks behind you (2026-09-12) ----~~ DELETED 2026-09-23: the
   padlock became a Finished / Edit button (see `.set-done-btn`). */

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
}

/* ==========================================================================
   Muscle Groups — the body map
   ==========================================================================
   ⚠️ REPLACED 2026-08-25 ON TIM'S INSTRUCTION, and the trade-off is real and
   deliberate. He sent a screenshot of another app's key and asked for *"the
   exact same colors that this image uses in order, but add one more"*. Sampled
   off that image, they are the MATERIAL DESIGN palette:

     Beginner  red    #F44336     Advanced  blue   #2196F3
     Novice    orange #FF9800     Expert    purple #9C27B0
     Interm.   green  #4CAF50     Elite     pink   #FF4081
     Proficient       #4DD0E1  ← the added one

   ⚠️ WHERE THE SEVENTH GOES WAS NOT A FREE CHOICE. Five of his six level names
   are ours (Beginner, Novice, Intermediate, Advanced, Elite), so keeping each
   on its reference colour leaves exactly one hole — Proficient, between green
   and blue — and the hue sweep has exactly one gap, in the same place. Cyan.

   ⚠️ WHICH CYAN WAS MEASURED, NOT PICKED. Six candidates through the dataviz
   validator; the number that decides it is the normal-vision adjacency floor:

     Cyan 500  #00BCD4   blue↔cyan ΔE 12.6  FAIL — the family-perfect tone, and
                                             the one a full-colour reader cannot
                                             tell from Advanced
     Cyan 400  #26C6DA   blue↔cyan ΔE 14.9  FAIL
     Teal 500  #009688   green↔teal ΔE 12.5 FAIL
     Cyan 300  #4DD0E1   blue↔cyan ΔE 17.6  PASS  ← chosen
     Cyan A400 #00E5FF               PASS, but electric enough to break the family

   ⚠️ WHAT THIS PALETTE COSTS, STATED PLAINLY. The ramp it replaces was built in
   OKLCH with STRICTLY MONOTONE LIGHTNESS, which is what made it read as a scale
   rather than a rainbow. Material's tones do not do that, so **the ordinal
   lightness cue is gone** — Novice (L .77) is now lighter than Elite (L .67).
   Measured on the seven, dark surface:

     lightness band .......... FAIL  orange, green, cyan and pink outside it
     chroma floor ............ PASS
     CVD adjacency ........... FAIL  green↔orange ΔE 3.6 protan
     normal-vision floor ..... PASS  worst adjacent 17.8 (orange↔red)
     contrast vs surface ..... WARN  purple 2.76:1

   ⚠️ THREE OF THOSE FOUR ARE INHERITED FROM HIS SIX, NOT CAUSED BY THE SEVENTH.
   The added cyan makes nothing worse; every rejected candidate above did.

   ⚠️ AND THE MITIGATION IS THE THING HE ASKED FOR IN THE SAME BREATH. The
   validator's own note says a CVD failure is survivable "ONLY with secondary
   encoding: direct labels, gaps, or texture". The new key is exactly that —
   every colour carries its LEVEL NAME inside its own chip, and tapping a muscle
   states the level in words. Level is never colour-alone, so green↔orange being
   hard to separate costs a reader nothing they cannot read directly.

   ⚠️ ONE THING THE REFERENCE IMAGE GETS WRONG AND THIS DOES NOT: it puts white
   text on every chip, which is 2.16:1 on orange and would be 1.84:1 on cyan.
   The ink here is chosen per chip from the chip's own luminance — see .lv-chip
   — so the worst of the seven is 4.95:1 and most are far above.

   ⚠️ AND THE TWO THEMES NOW AGREE. The old ramp ran its hues in OPPOSITE order
   in light mode, so Beginner was blue in the dark theme and green in the light
   one — the same level, two colours, and a screenshot shared between two users
   could disagree with itself. One set of seven, both themes. */

/* One palette, both themes. See the note above for why that is a change. */
:root {
  --lv-beginner:     #F44336;   /* Material Red 500    */
  --lv-novice:       #FF9800;   /* Material Orange 500 */
  --lv-intermediate: #4CAF50;   /* Material Green 500  */
  --lv-proficient:   #4DD0E1;   /* Material Cyan 300   — the added step */
  --lv-advanced:     #2196F3;   /* Material Blue 500   */
  --lv-expert:       #9C27B0;   /* Material Purple 500 */
  --lv-elite:        #FF4081;   /* Material Pink A200  */
  /* Below the 5th percentile. A DARKER red rather than an eighth hue: it is not
     a level of its own, it is "under the bottom one", and giving it a hue of its
     own would make it look like one. */
  --lv-below:        #8E1D14;
  /* No data sits OUTSIDE the ramp entirely: neutral, zero chroma, so it can
     never be mistaken for a low level. */
  --lv-none:         #282F33;
  /* TRAINED, CAN'T BE RANKED — Core and Neck (2026-09-04).
     ⚠️ IT IS A HATCH OF THESE TWO, NOT A NINTH COLOUR. The pattern is built in
     body-map.js and reads these two tokens, so the stylesheet still owns every
     colour on the figure. The ground is "no data" itself and the stripe is a
     neutral lift off it: the muscle stays plainly outside the ramp — which is
     true, nothing about it is ranked — while no longer being the same fill as a
     muscle nobody has ever trained. Zero chroma on both, so it cannot be read
     as a level however briefly it is glanced at. */
  --unranked-bg:     #282F33;
  --unranked-ink:    #59646B;
}
:root[data-theme="light"] {
  /* Only the non-level tokens change. The seven hues are mid-tone Material
     colours that carry on both surfaces, and a level meaning one colour
     everywhere is worth more than a per-theme optimisation. */
  --lv-none:         #DBDED7;
  --unranked-bg:     #DBDED7;
  --unranked-ink:    #A9AFA4;
}

/* min-width:0 guards the flexbox trap where an item's automatic minimum size is
   its INTRINSIC width: this SVG's viewBox is over 1000 wide, so without it a
   narrow phone could be pushed wider than its own viewport. */
/* ⚠️ THE FIGURE NEVER MOVES AND NEVER RESIZES. Tim, 2026-08-21: "I don't want
   to ever move the body (in direction or size)."

   It used to be `flex: 1`, which is the opposite instruction — a flex item that
   gives up whatever the panel below it needs. So tapping a muscle grew the
   panel, the figure shrank to fit what was left and slid upward, and the arms
   visibly detached from the body at the exact moment somebody was trying to
   look at them. Rule 3's corollary already said content must not shrink because
   you asked it a question; the desktop side-column obeyed it and the phone
   never did.

   The share is FIXED, so the figure is identical whether a muscle is selected
   or not, and the panel takes what is left and scrolls inside it. The real
   answer to "the panel needs more room" is fewer words in the panel, which is
   the other half of this change. */
.body-wrap {
  flex: none;
  height: 57%;
  min-width: 0;
  display: flex; align-items: center; justify-content: center;
}
.body-map { width: 100%; height: 100%; max-width: 100%; max-height: 100%; display: block; }

/* The paper the figure is printed on, and the ink printed on it. Both stay
   dark-on-light in EITHER theme: the drawing is lit as a poster, its shading
   assumes shadows are darker than the paper, and inverting the ink would turn
   every striation into a highlight. Dark mode gets a dimmer paper, not a dark
   one, so the sheet doesn't glare. */
.body-paper { fill: var(--body-paper); }
.body-ink {
  fill: var(--body-ink);
  /* The ink is the whole drawing — keylines, striations, shading. It must never
     swallow taps meant for the muscle underneath it. */
  pointer-events: none;
}
.body-caption {
  fill: var(--ink-faint); font-size: 26px;
  letter-spacing: 0.08em; text-transform: uppercase;
}

.body-region { cursor: pointer; transition: opacity .12s ease; }
/* The invisible hit halo behind each muscle (0i). Nothing is painted — the
   stroke exists only as hit-test geometry. 60 viewBox units ≈ ±10px on a
   360px screen. `pointer-events: all` makes interior + stroke band count
   despite fill/stroke being none/transparent. */
.body-halo {
  fill: none;
  stroke: transparent;
  stroke-width: 60;
  pointer-events: all;
  cursor: pointer;
}
/* ⚠️ GUARDED, because on a touch screen :hover STICKS after a tap — and on this
   particular element a leftover fade is not a cosmetic artefact, it is a false
   statement. D19 encodes CONFIDENCE as desaturation of the muscle's own colour:
   faded means "less sure about this rating". A muscle left at .82 because a
   thumb touched it would be read as the app hedging. Found 2026-08-21 on the
   first pass at phone size; `@media (hover: hover)` keeps it for a mouse and
   removes it entirely from every device that does not have one. */
@media (hover: hover) {
  .body-region:hover { opacity: .82; }
}
/* Selection dims the others rather than recolouring the one — the fill is
   carrying the level and must not be overwritten by the interaction state. */
.body-map[data-selected] .body-region:not(.is-selected) { opacity: .38; }
/* The ring is a separate path drawn on top of the ink. Under it, it would land
   exactly beneath the muscle's own black keyline and never be seen. */
.body-pick {
  fill: none; stroke: var(--accent); stroke-width: 6;
  stroke-linejoin: round; pointer-events: none;
}
/* Chrome paints outline:auto as a rectangle around an SVG element's BOUNDING
   BOX, so selecting a muscle drew a white box around it. It paints that on
   plain :focus — a :focus-visible rule alone does not remove it, because a
   mouse click focuses the path without matching :focus-visible.
   Keyboard focus is not dropped, it moves to .body-focus below, which follows
   the muscle's own shape instead of a box. */
.body-region:focus,
.body-region:focus-visible { outline: none; }
.body-focus {
  fill: none; stroke: var(--ink); stroke-width: 5; stroke-dasharray: 11 8;
  stroke-linejoin: round; pointer-events: none;
}

.lv-beginner, .body-region.lv-beginner         { fill: var(--lv-beginner); }
.lv-novice, .body-region.lv-novice             { fill: var(--lv-novice); }
.lv-intermediate, .body-region.lv-intermediate { fill: var(--lv-intermediate); }
.lv-proficient, .body-region.lv-proficient     { fill: var(--lv-proficient); }
.lv-advanced, .body-region.lv-advanced         { fill: var(--lv-advanced); }
.lv-expert, .body-region.lv-expert             { fill: var(--lv-expert); }
.lv-elite, .body-region.lv-elite               { fill: var(--lv-elite); }
.body-region.lv-below                          { fill: var(--lv-below); }
.body-region.lv-none                           { fill: var(--body-none); }
/* TRAINED, CAN'T BE RANKED. --hatch is set on the <svg> by body-map.js and
   points at that figure's OWN pattern def, which is why this is a variable and
   not a literal url(): the compare screen draws two figures in one document. */
.body-region.lv-unranked                       { fill: var(--hatch); }
/* The stripes themselves. They are filled from here rather than from a `fill`
   attribute because var() cannot be relied on inside a presentation attribute —
   see the note in body-map.js. */
.hatch-bg  { fill: var(--unranked-bg); }
.hatch-ink { fill: var(--unranked-ink); }

/* CONFIDENCE = how vivid the colour is. The rating owns the hue; how sure we
   are of it owns the saturation, mixed toward the same neutral that means "no
   data". Brightness could not carry confidence, because brightness already
   carries the RATING — the seven levels are a strictly monotone lightness ramp,
   so dimming an Elite would make it read as a lower level.

   --tint is set per muscle by body-map.js and floored at 38 %, so an unsure
   rating still reads as its own colour rather than collapsing into grey.
   "We are not sure" and "you have never trained this" must stay distinguishable.

   These declarations come AFTER the solid ones on purpose, weakest support
   first, so each browser takes the best one it understands and anything older
   keeps the solid fill above — exactly the pre-2026-08-17 behaviour. Nothing
   breaks, it just stops showing confidence.

   body-map.js sets the tint TWICE, as --tint (a percentage, which is what
   color-mix takes) and --tint-n (unitless, which is what calc() inside a colour
   function takes). One value in two syntaxes, because neither function accepts
   the other's. */
.body-region[style*="--tint"] {
  fill: color-mix(in oklab, var(--lv-mix, currentColor) var(--tint, 100%), var(--body-none));
}
/* THE ONE THAT IS ACTUALLY RIGHT. color-mix moves lightness as well as chroma,
   and in the light theme — where the ramp runs strongest = DARKEST — fading a
   muscle lightens it, which is the exact confusion this design set out to
   avoid. Relative colour syntax scales CHROMA alone and leaves l and h
   untouched, so an unsure Elite is a washed-out Elite and never reads as a
   lower level. */
@supports (fill: oklch(from red l c h)) {
  .body-region[style*="--tint"] {
    fill: oklch(from var(--lv-mix, currentColor) l calc(c * var(--tint-n, 1)) h);
  }
}
/* One place each level's colour is named as a variable, so the map's fill, the
   legend swatch and the confidence bar cannot drift apart. */
.lv-beginner     { --lv-mix: var(--lv-beginner); }
.lv-novice       { --lv-mix: var(--lv-novice); }
.lv-intermediate { --lv-mix: var(--lv-intermediate); }
.lv-proficient   { --lv-mix: var(--lv-proficient); }
.lv-advanced     { --lv-mix: var(--lv-advanced); }
.lv-expert       { --lv-mix: var(--lv-expert); }
.lv-elite        { --lv-mix: var(--lv-elite); }
.lv-below        { --lv-mix: var(--lv-below); }

/* ⚠️ THE KEY IS CHIPS NOW — Tim, 2026-08-25: *"the key is too small and not
   clear enough. I think the key should be mini round boxes right below the
   picture of the human with the name of the ranking inside and the box shaded
   in the color that it is. Make these decent sized so it's clear to the user."*

   What it replaced was a 10px swatch, an 11px grey name and a 10px mono
   percentage, wrapped three to a line — the smallest text on the screen doing
   the job of explaining every colour on it.

   ⚠️ THE CHIP IS ALSO WHY THE NEW PALETTE IS ALLOWED TO FAIL ITS CVD CHECK.
   Naming each colour in its own chip is the "direct labels" the validator
   demands as the price of that failure. It is not decoration; it is the
   secondary encoding. See the palette note above.

   ⚠️ INK PER CHIP, FROM THE CHIP'S OWN LUMINANCE. The reference image puts
   white on all six, which is 2.16:1 on its orange. Six of these seven are light
   enough to take near-black; purple is not and takes white. The rule is the
   chip's luminance, not a list, so it stays true if a colour is ever changed. */
.lv-key { display: flex; flex-wrap: wrap; gap: 6px; }
.lv-chip {
  display: inline-flex; align-items: center;
  min-height: 26px;
  padding: 0 10px;
  border-radius: 999px;
  font-size: var(--fs-md);
  font-weight: var(--fw-bold);
  letter-spacing: 0.01em;
  white-space: nowrap;
  color: #10161A;
}
/* Purple is the one chip too dark for near-black ink: 2.89:1 against it, 6.30:1
   against white. Every other chip clears 4.95:1 with the dark ink. */
.lv-chip.lv-expert { color: #FFFFFF; }
.lv-chip.lv-beginner     { background: var(--lv-beginner); }
.lv-chip.lv-novice       { background: var(--lv-novice); }
.lv-chip.lv-intermediate { background: var(--lv-intermediate); }
.lv-chip.lv-proficient   { background: var(--lv-proficient); }
.lv-chip.lv-advanced     { background: var(--lv-advanced); }
.lv-chip.lv-expert       { background: var(--lv-expert); }
.lv-chip.lv-elite        { background: var(--lv-elite); }
/* "No data" and the fade note are NOT levels, so they are not chips. They are
   quiet notes under the row — making them look like levels would add two
   rankings nobody can reach. */
.lv-notes { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 2px; }

.lv-key-item { display: flex; align-items: center; gap: 5px; font-size: var(--fs-sm); color: var(--ink-soft); }
.lv-sw { width: 10px; height: 10px; border-radius: 2px; flex: none; display: inline-block; }
.lv-sw.lv-beginner { background: var(--lv-beginner); }
.lv-sw.lv-novice { background: var(--lv-novice); }
.lv-sw.lv-intermediate { background: var(--lv-intermediate); }
.lv-sw.lv-proficient { background: var(--lv-proficient); }
.lv-sw.lv-advanced { background: var(--lv-advanced); }
.lv-sw.lv-expert { background: var(--lv-expert); }
.lv-sw.lv-elite { background: var(--lv-elite); }
/* `.lv-none` and `.lv-faded` are painted in the Data review block at the end
   of this file (the figure's own grey and its own fade). */
/* The key's own hatch. A repeating-linear-gradient rather than the SVG pattern,
   because this swatch is an ordinary HTML element — but the angle, the pitch and
   both colours are the same numbers body-map.js uses, so the key and the figure
   cannot show different marks for one state. */
.lv-sw.lv-unranked {
  background: repeating-linear-gradient(
    45deg,
    var(--unranked-ink) 0 2.2px,
    var(--unranked-bg) 2.2px 6px);
}
.lv-key-note { color: var(--ink-faint); }
.lv-pct { color: var(--ink-faint); font-variant-numeric: tabular-nums; font-size: var(--fs-xs); }

/* Confidence is one line of .muscle-meta now — the row, the percentage and the
   bar went with the paragraph on 2026-08-21. The muscle's own fade already IS
   the confidence (D19), so a second bar drawing the same quantity taught
   nothing and competed with the to-next bar beside it. */

/* Takes what the figure does not, and scrolls inside it. `max-height: 46%` here
   was the other side of the old bug: capping the panel is not the same as
   fixing the figure, because anything under the cap still stole height from it. */
.body-foot { flex: 1; min-height: 0; display: flex; flex-direction: column; gap: 8px; overflow-y: auto; }

/* ── The detail panel, rebuilt short (Tim, 2026-08-21) ──────────────────────
   "Make way less words on the bottom. We want it to be easy to understand, not
   a paragraph." It was a paragraph: a source sentence, a confidence block with
   a bar and a corroboration line, up to three multi-line caveats, a restatement
   of the comparison group the fixed header already carries, and a seven-row
   table of per-level weight targets. The lead is now the two things somebody
   taps a muscle to find out — what it is and what it lifts — and everything
   else is one short line or gone. */
.muscle-detail { gap: 7px; }

/* The answer, big enough to be the answer. */
.muscle-stat { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap; }
.muscle-est {
  font-size: 21px; font-weight: var(--fw-bold); letter-spacing: -0.02em;
  font-variant-numeric: tabular-nums;
}
.muscle-pct { font-size: var(--fs-md); color: var(--ink-soft); font-weight: var(--fw-med); }

/* What the big number IS (2026-08-31). Pulled up against it rather than sitting
   in the card's normal rhythm, because it is a CAPTION on that number and not
   another fact about the muscle — at the card's 7px gap it read as a third
   line of equal standing. */
/* ── The sets the estimate was built from, as COLUMNS (2026-09-21) ──────────
   🚨 Tim: *"make the From: details under the muscle group and estimated 1RM
   display better by putting the details in columns: exercise, weightxreps, and
   date. Also I want to try having a 'more details' button which adds more
   columns… Also, make each column have a different color so it's easy to see
   which numbers correspond to where."*

   It used to be three sentences stacked — "from Lat Pulldown 85 lbs×12, 14 Sep"
   / "and …" — so the weight sat in a different place on every line and nothing
   could be read DOWN the block. That is the whole request: the same fact in the
   same place on every row.

   ⚠️ `display: contents` ON THE ROW IS THE LOAD-BEARING LINE. The tracks belong
   to `.muscle-sources`; a row that stayed a box would size its own columns and
   the three rows would not line up with each other — which is the one thing this
   was asked to fix. Rows are 1–3 in practice (the rating uses at most three
   contributions), so there is no case where a flat grid is the wrong shape.

   🛑 NO MOTION when the two extra columns arrive, and that is a decision rather
   than an omission. `grid-template-columns` does not interpolate between a
   three-track and a five-track list, so the only animation available is a fade
   on the new cells — and Rule 7 wants motion that SHOWS A RELATIONSHIP. The
   relationship here ("same three rows, two more facts about each") is already
   shown by the three original columns not moving. A fade would decorate it. */

/* 🚨 FIVE COLUMN COLOURS, AND EVERY ONE OF THEM IS A NEW TOKEN. Neither `--lv-*`
   nor `--series-*` could be borrowed: those carry MEANING — a strength level and
   a point in time — and a date column wearing "Advanced" blue would be saying
   something false. Chosen the way the chart series were: OKLCH, one lightness
   band per theme (light L .473–.511, dark L .674–.695) so the five read as one
   weight rather than as a ranking, hues chosen by exhaustive search over the
   wheel to maximise the OKLab separation of ADJACENT columns.

   ⚠️ HUES 33° AND 159° ARE EXCLUDED BY HAND: they are `--danger` and `--good`,
   which are reserved meanings in this app. A red column would read as an error
   and a green one as a pass.

   ⚠️ THE MEASURED NUMBERS, because this file's own history says a hex nudged by
   eye invalidates all of it. Every colour was checked against `--ground`,
   `--surface`, `--raised` and `--rule-soft` in BOTH themes and ALL FOUR palettes
   — 80 pairs per theme. Worst cell 5.01:1 light, 5.02:1 dark, against the 4.5:1
   WCAG AA needs for text under 18.66px; on `--ground`, which is what is actually
   painted behind this panel, the range is 5.69–6.71. Adjacent-pair separation
   under simulated protanopia/deuteranopia (Machado 2009, severity 1.0) is
   ΔE ≥ 14.0, against a target of 8; unsimulated it is ΔE ≥ 15.8, against a floor
   of 15. On the harder ALL-pairs list the unsimulated worst is 15.3 and the CVD
   worst is 5.1 — five categorical colours cannot clear the all-pairs CVD floor
   in any design system, which is why colour is never the only cue here (below).

   ⚠️ TWO OF THE LIGHT VALUES SIT UNDER THE 0.10 CHROMA FLOOR (--msrc-1 at .084,
   --msrc-2 at .098) and that is the sRGB gamut, not a slack choice: there is no
   more chroma at those hues at this lightness. Measured against a neutral grey
   of their own lightness they are ΔE 8.4 and 9.8 apart — coloured, not grey. */
:root {
  --msrc-1: #00A9C6;   /* exercise      cyan        OKLCH .676 .120 216° */
  --msrc-2: #B39700;   /* weight × reps olive       OKLCH .681 .140  96° */
  --msrc-3: #7C89FE;   /* date          periwinkle  OKLCH .674 .172 276° */
  --msrc-4: #FF5588;   /* est. 1RM      rose        OKLCH .692 .208   6° */
  --msrc-5: #E159EF;   /* share         orchid      OKLCH .695 .240 324° */
}
:root[data-theme="light"] {
  /* Same five hues, re-stepped for a light ground — not flipped. A light theme
     that reuses the dark steps is the bug the strength ramp's note describes. */
  --msrc-1: #00677A;
  --msrc-2: #6D5C00;
  --msrc-3: #473AE2;
  --msrc-4: #BB0154;
  --msrc-5: #A300B1;
}

/* ⚠️ NOT OVERRIDDEN PER PALETTE, AND THAT IS DELIBERATE — it is what keeps this
   block clear of the `.load-badge.per-side` trap (2026-09-06). The four palettes
   move the surfaces by at most 0.3:1 on these colours, so one set clears AA on
   all four; a palette-scoped fix would need `:not([data-palette])` for gold and
   is exactly the rule somebody writes unscoped and breaks three to fix one. */

.muscle-sources {
  display: grid;
  /* 🚨 THE FIRST COLUMN IS THE ONLY ONE ALLOWED TO GIVE WAY. An exercise name
     wraps; "85 lbs×12" and "14 Sep" must not, because a number split over two
     lines stops being one number. The 4.5em floor is what stops `1fr`
     collapsing to nothing when five columns will not fit — past that the block
     scrolls inside itself rather than crushing the name to one letter a line. */
  grid-template-columns: minmax(4.5em, 1fr) max-content max-content;
  align-items: baseline;
  column-gap: 9px;
  row-gap: 2px;
  /* Rule 1: wide content scrolls inside its own box, never the page. `min-width:
     0` is the line that makes that do anything at all inside a column flex
     parent — the `.research-scroll` lesson, which was inert for eight days
     without it. The Y axis is stated because leaving it `visible` computes it to
     `auto` and invents a vertical scroller (2026-09-02). */
  min-width: 0;
  overflow-x: auto;
  overflow-y: hidden;
  overscroll-behavior-x: contain;
  font-size: var(--fs-md);
  line-height: 1.4;
  font-variant-numeric: tabular-nums;
}
.muscle-sources.is-more {
  grid-template-columns: minmax(4.5em, 1fr) repeat(4, max-content);
  /* 🆕 2026-09-24 — THE SAFETY NET, AND IT IS FOR THE PANELS NOBODY WIDENED. Since the derived
     columns default to ON above 860px and that default is one piece of module state shared by
     every panel alive, this table can now appear in a box that was never sized for five
     columns — the two-body compare screen puts two of them side by side. Where it does not
     fit it scrolls inside its OWN box rather than pushing the layout sideways, which is Rule
     1's standing answer for wide content and the same shape `.research-scroll` uses.
     ⚠️ `min-width: 0` is the load-bearing half: without it a grid item refuses to shrink below
     its content and the overflow lands on the pane instead, which is the fault this is here to
     prevent. */
  overflow-x: auto;
  /* ⚠️ AND THE Y AXIS IS STATED, which `tests/a11y.test.mjs` insists on and is right to: a box
     that sets one axis and leaves the other `visible` gets the free one computed to `auto`, so
     an x-scroller quietly becomes a y-scroller too. `hidden` clips nothing here — the grid's
     height is its content's, so there is never anything below the fold to reach. */
  overflow-y: hidden;
  min-width: 0;
}

.msrc-head, .msrc-row { display: contents; }

/* 🔒 The two extra columns are hidden rather than assumed absent, so the toggle
   still works if the view leaves every cell in the DOM and only flips the class.
   Same for the header, which is a legend for colours that are not on screen yet
   in the three-column state. */
.muscle-sources:not(.is-more) .msrc-head,
.muscle-sources:not(.is-more) .msrc-est,
.muscle-sources:not(.is-more) .msrc-share { display: none; }

/* 🛑 COLOUR IS NEVER THE ONLY THING SAYING WHICH COLUMN A NUMBER IS IN — Rule 5's
   general form, and the reason the all-pairs CVD number above is survivable. A
   column is identified by its fixed position first, by this header second, and
   by its colour third. The header's hairline stops at each column gap on
   purpose: five ticks that mark the tracks, not one rule that draws a table
   (Rule 2). */
.msrc-head > * {
  font-size: var(--fs-xs);
  font-weight: var(--fw-bold);
  white-space: nowrap;
  padding-bottom: 3px;
  margin-bottom: 2px;
  /* --rule, not --rule-soft: measured in the browser at 360px in both themes,
     the soft one was invisible at 1px under 9.5px type and the header stopped
     being a second cue at all. */
  border-bottom: 1px solid var(--rule);
}

.msrc-ex { font-weight: var(--fw-med); min-width: 0; overflow-wrap: anywhere; }
.msrc-set, .msrc-date, .msrc-est, .msrc-share { white-space: nowrap; }
/* The two derived numbers are right-aligned so their magnitudes line up; the
   record's own columns stay left, where a unit suffix reads as part of a phrase
   rather than as a figure in a ledger. */
.msrc-est, .msrc-share,
.msrc-head > :nth-child(4), .msrc-head > :nth-child(5) { text-align: right; }

/* One colour per column. Written twice — once on the class the row uses, once by
   position — so the header legend is painted whether or not the view repeats the
   cell classes in it. */
.msrc-ex,    .msrc-head > :nth-child(1) { color: var(--msrc-1); }
.msrc-set,   .msrc-head > :nth-child(2) { color: var(--msrc-2); }
.msrc-date,  .msrc-head > :nth-child(3) { color: var(--msrc-3); }
.msrc-est,   .msrc-head > :nth-child(4) { color: var(--msrc-4); }
.msrc-share, .msrc-head > :nth-child(5) { color: var(--msrc-5); }

/* The "More details" switch. It wears `.add-set`'s shape — a 32px raised pill,
   fully rounded — because that is what a small inline control looks like in this
   app already, and it hits at 44px through the same pseudo-element the icon
   buttons and the session pills use. A request for another view of the same rows
   is not permission to ship a 32px touch target.
   ⚠️ The open state is `--raised-hi` and `--ink`, NOT `--accent` on
   `--accent-dim`: that pair measures 3.96:1 in gold light and has now had to be
   fixed on six separate classes. `.mini-btn:active` already paints this one. */
.msrc-toggle {
  position: relative;
  align-self: flex-start;
  min-height: 32px;
  padding: 0 12px;
  border-radius: 999px;
  background: var(--raised);
  color: var(--ink-soft);
  font-size: var(--fs-sm);
  font-weight: var(--fw-med);
  white-space: nowrap;
}
.msrc-toggle::before {
  content: '';
  position: absolute;
  top: 50%; left: 50%;
  width: 100%; height: 44px;
  min-width: 44px;
  transform: translate(-50%, -50%);
}
.msrc-toggle:active { transform: scale(.96); background: var(--raised-hi); }
.msrc-toggle[aria-expanded="true"] { background: var(--raised-hi); color: var(--ink); }

.muscle-est-note {
  margin-top: -5px;
  font-size: var(--fs-md);
  color: var(--ink-faint);
  line-height: 1.35;
}

/* One quiet line. Used for where the number came from and how well corroborated
   it is — both of which are Rule 5 obligations and neither of which is worth a
   sentence. */
.muscle-meta { font-size: var(--fs-md); color: var(--ink-faint); line-height: 1.4; }

/* A caveat, compressed to a line. Still full-strength ink and still a hairline
   bracket — these are load-bearing (the whole app rests on them) and shortening
   one is allowed where softening it is not. */
.muscle-warn {
  border-left: 2px solid var(--accent);
  padding-left: 9px;
  font-size: var(--fs-sm); line-height: 1.35; color: var(--ink-soft);
}

/* What a muscle with no standards HAS got. Same bracket shape as the caveat
   above it, in the neutral the hatch is made of rather than in the accent: this
   is not a warning and should not borrow a warning's colour, but it is the same
   KIND of thing — a short statement qualifying what the panel above it said. */
.muscle-logged {
  border-left: 2px solid var(--unranked-ink);
  padding-left: 9px;
  display: flex; flex-direction: column; gap: 4px;
  font-size: var(--fs-sm); line-height: 1.35; color: var(--ink-soft);
}

.basis { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.basis-main { font-size: var(--fs-lg); font-weight: var(--fw-med); display: flex; align-items: center; gap: 3px; }
.basis-sub { font-size: var(--fs-sm); color: var(--ink-faint); }
/* The caption IS the control: the line that states the comparison group is the
   button that changes it, so nothing extra has to be added to the screen. */
/* ⚠️ min-height 44, raised 2026-08-24 from an effective 38. It is the ONLY
   control on the muscle screen that is not the illustration, so a thumb that
   misses it has nowhere else to go. Centred rather than padded: the two lines
   set their own height and padding alone would put the text off-centre in the
   taller box. The `justify-content` works because `.basis` already makes this a
   column flex container — the button IS the stack, not a wrapper round it. */
.basis-btn {
  text-align: left; cursor: pointer; background: none; color: inherit;
  padding: 2px 0;
  min-height: 44px;
  justify-content: center;
}
.basis-btn .basis-main svg { color: var(--ink-faint); }
.basis-btn:active { opacity: .7; }

.compare-sheet { display: flex; flex-direction: column; gap: 15px; }
.compare-axis { display: flex; flex-direction: column; gap: 6px; }
.compare-opts { display: flex; flex-wrap: wrap; gap: 6px; }
/* The two presets. Bigger than the chips below them because they are what most
   people want; the four axes are the escape hatch, not the main event.

   ⚠️ SHORTER SINCE 2026-09-09 BECAUSE THERE IS ONE LINE IN IT NOW — the hint
   under each name went (see PRESETS in views-muscles.js). 52px was two lines of
   type plus padding; a single 14.5px name wants 44, which is also the floor a
   touch target may not go below. ~~`.preset-hint`~~ went with it. */
.preset {
  flex: 1 1 140px; min-height: 44px;
  flex-direction: column; gap: 1px;
  align-items: flex-start; justify-content: center;
  padding: 8px 13px;
}
.preset-name { font-size: var(--fs-lg); font-weight: var(--fw-bold); }
.btn.preset[aria-pressed="true"] { background: var(--accent); color: var(--accent-ink); }

.muscle-head { display: flex; align-items: baseline; gap: 8px; }
.muscle-name { font-size: var(--fs-lg); font-weight: var(--fw-bold); flex: 1; }
.muscle-level { font-size: var(--fs-md); font-weight: var(--fw-bold); }
.field-help.general { font-style: italic; }

/* Progress toward the next level — this is what supplies a near goal when the
   colour has not moved for months. */
.to-next { display: flex; flex-direction: column; gap: 4px; }
.to-next-bar { height: 6px; background: var(--raised); border-radius: 3px; overflow: hidden; }
.to-next-fill { height: 100%; background: var(--accent); border-radius: 3px; }
.to-next-label { font-size: var(--fs-md); font-weight: var(--fw-med); color: var(--ink-soft); }

/* The seven-row per-level target table was removed on 2026-08-21: six of its
   seven rows are weights for levels somebody is nowhere near, and the seventh
   is what .to-next already says with a bar. */

.lv-name { white-space: nowrap; }

/* ⚠️ THE LEVEL WORD IS A CHIP NOW, NOT COLOURED TEXT — 2026-08-25.
   It used to be painted in the ramp colour, with the dim steps falling back to
   ink. That worked because the old ramp was BUILT for it: monotone lightness,
   one ramp per theme, tuned so the bright end cleared the dark ground and the
   dark end cleared the light one.

   ⚠️ THE MATERIAL PALETTE IS NOT A TEXT PALETTE, and the audit caught it rather
   than a person: "Advanced" rendered at **2.83:1** on Goals in the light theme,
   the only sub-4.5 text in 4,970 measured nodes. On a light ground the blue is
   2.83, the cyan 1.6, the pink 3.0; on a dark ground the purple is 2.89. There
   is no assignment of these seven to text that clears AA in both themes,
   because they were chosen to be FILLS.

   So the word wears the colour as a background and picks its own ink, exactly
   as the key's chips do — which also means the level looks the same wherever it
   appears, and the key teaches that vocabulary on the way in. */
.lv-text-below, .lv-text-beginner, .lv-text-novice,
.lv-text-intermediate, .lv-text-proficient,
.lv-text-advanced, .lv-text-expert, .lv-text-elite {
  display: inline-flex; align-items: center;
  padding: 1px 8px;
  border-radius: 999px;
  font-weight: var(--fw-bold);
  color: #10161A;
  white-space: nowrap;
}
.lv-text-expert { color: #FFFFFF; }
/* "Below Beginner" is not a level, so it does not get a level's chip — it stays
   plain ink. Giving it one would invent an eighth ranking. */
.lv-text-below { background: none; padding: 0; color: var(--ink-soft); }
.lv-text-beginner     { background: var(--lv-beginner); }
.lv-text-novice       { background: var(--lv-novice); }
.lv-text-intermediate { background: var(--lv-intermediate); }
.lv-text-proficient   { background: var(--lv-proficient); }
.lv-text-advanced     { background: var(--lv-advanced); }
.lv-text-expert       { background: var(--lv-expert); }
.lv-text-elite        { background: var(--lv-elite); }

/* A drop in a recorded day, and in the edit form. Indented and labelled
   "drop", never numbered as a set — one drop set is one hard set. */
/* A set and its drops must stay together when the row wraps. `.detail-sets` is
   a wrapping flex, so a bare drop chip could end up on the line above the set
   it belongs to — the numbers would still be in reading order and would still
   look, at a glance, like they belonged to the wrong set. The run wraps as one
   thing instead. */
.detail-set-run { display: flex; flex-wrap: wrap; gap: 5px; align-items: baseline; }
.detail-set.is-drop { opacity: .8; }
.detail-set.is-drop b { color: var(--ink-faint); font-weight: var(--fw-med); }
.detail-ex.in-group { border-left: 2px solid var(--accent); padding-left: 10px; }
.detail-group-label {
  font-size: var(--fs-sm); font-weight: var(--fw-bold);
  color: var(--accent); margin-bottom: 2px;
}
.edit-set.is-drop { padding-left: 14px; border-left: 1px solid var(--rule); }

/* ================= Social =================
   docs/social-plan.md. Rule 2 still applies — no boxes. Structure comes from
   hairlines, spacing and weight, exactly as everywhere else, so a friend's page
   reads as the same app rather than a bolted-on section. */

.section-head {
  font-size: var(--fs-sm); font-weight: var(--fw-bold);
  color: var(--ink-faint);
  margin: 18px 0 2px;
}
.section-head:first-child { margin-top: 4px; }

.note { margin: 4px 0 10px; color: var(--ink-soft); font-size: var(--fs-md); line-height: 1.5; }

.form { display: flex; flex-direction: column; gap: 10px; }
.input.mono { font-family: var(--mono); font-size: var(--fs-md); }

.row-icon { display: flex; color: var(--ink-faint); }
.row-icon svg { width: 20px; height: 20px; }

/* ── A friend's own face (2026-08-31) ──────────────────────────────────────
   Tim: friends were only ever seeing "the default blank humanoid". The photo
   is published with the name now, and `personFace()` paints it wherever a
   person is named. One rule, so the circle is the same object on a feed card,
   a friend row and their page.

   ⚠️ `object-fit: cover` on a SQUARE box, because the stored photo is already
   a square crop the owner positioned themselves (image-crop.js) — `contain`
   would letterbox somebody's face inside their own circle. */
.face-img {
  width: 100%; height: 100%;
  border-radius: 50%;
  object-fit: cover;
  display: block;
  /* These photographs carry their own light. On a dark surface an unfilled
     transparent corner would read as a notch out of the circle. */
  background: #fff;
}
.row-icon .face-img { width: 26px; height: 26px; }
.friend-face { display: flex; align-items: center; gap: 11px; padding-block: 4px; }
.friend-face .face-img { width: 54px; height: 54px; flex: none; }
.friend-face .row-title { font-size: var(--fs-xl); }

/* ==========================================================================
   THE PROFILE TAB (2026-09-08) — your own account as a place to look at

   Tim: *"this section will only show the user's profile picture and their
   username, as well as the number of workouts, followers, and following at the
   top."* Distinct from `#/account`, which is where things are CHANGED.
   🔄 **THE TWO SOCIAL FIGURES BECAME ONE, CALLED FRIENDS, ON 2026-09-09** —
   *"just combine the 2 and call them 'friends' instead."*

   ⚠️ THE FIGURES ARE A GRID OF EQUAL COLUMNS, not a flex row, and that is the
   whole of the layout decision. Equal columns keep the numbers on a shared
   baseline whatever their width, so "6" and "142" do not sit at different
   heights — and a 3-year account's four-digit workout count cannot squeeze the
   others into their own labels.
   ========================================================================== */

/* No padding of its own: the pane already gives the --pad gutter, and a second
   one put Profile's text 14px right of every other tab's (2026-09-24). */
.me-body { display: flex; flex-direction: column; gap: 16px; }
@media (min-width: 860px) {
  /* Rows drop their inner gutter at this width (`.list > *`), and only a list
     that is a direct child of the pane gets its full-bleed margin cancelled —
     these are nested, so without this their rows sat 22px left of the labels. */
  .me-body .list { margin-inline: 0; }
}

.me-head { display: flex; align-items: center; gap: 14px; }
.me-face {
  flex: none; width: 72px; height: 72px; border-radius: 50%;
  background: var(--raised); color: var(--ink-soft);
  display: grid; place-items: center; overflow: hidden;
}
/* 🆕 A FRIEND'S PROFILE — 2026-09-16. Tim asked for their picture *"big at the
   top"*, and this is the same circle at a bigger radius rather than a second
   head. ⚠️ 96px is the largest that keeps the head under a third of a 360×640
   viewport's content area, so their name, both figures and the first section
   are all still above the fold on the smallest phone this app supports —
   measured, because a face big enough to push the stats off the screen would
   answer his ask by breaking the thing the ask was about. */
.me-face.is-lg { width: 96px; height: 96px; }

/* "View data" — the panel door, in the top-right corner of their profile.
   ⚠️ `flex: none` so a long display name in the heading beside it cannot
   squeeze the button into its own label; the heading is what shrinks, and it
   already carries `min-width: 0` for exactly that.
   ⚠️ NO SIZING HERE, deliberately: it is a `.btn.small`, which already carries
   the invisible 44px hit target from the block near the top of this file — the
   painted control is 34px of ink and the finger gets the full box. A height
   rule here would be a second opinion about a target size that has already been
   measured once. Driven at 390×844: 82 × 34 painted at x=294, clear of the
   heading and inside the safe area. */
.topbar .topbar-btn { flex: none; }

/* A section that fills in after the screen has painted. `display: contents` so
   an empty one costs nothing — `.me-body` is a flex column with a gap, and a
   real wrapper would leave a hole either side of a section that never arrived
   (the friend profile's best lifts and calendar both arrive late). */
.me-slot { display: contents; }
.me-who { min-width: 0; display: flex; flex-direction: column; gap: 3px; }
/* Their name, at the size a name deserves on the screen that is about them.
   `overflow-wrap` because a display name is 60 characters of free text and one
   unbroken run of them must wrap rather than push the page sideways. */
.me-name { font-size: var(--fs-xl); font-weight: var(--fw-bold); letter-spacing: -0.01em; overflow-wrap: anywhere; }

/* ⚠️ `auto-fit` RATHER THAN A NUMBER, SINCE 2026-09-09. It was `repeat(3, 1fr)`
   and there are two tiles now (Followers and Following became one, called
   Friends) — and Tim left the door open to going back to three. A hard-coded
   column count is a second place to remember, and the equal-thirds argument
   below is really an equal-COLUMNS argument: whatever the count, the numbers
   share a baseline and a wide one cannot squeeze a narrow one into its label. */
/* No gap: the hairline between the tiles (review block at the end) sits centred. */
.me-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(0, 1fr)); gap: 0; }
.me-stat {
  display: flex; flex-direction: column; align-items: center; gap: 2px;
  padding: 12px 4px;
  /* No border or radius since 2026-09-24 (Rule 2) — the divider between two
     tiles is in the Profile/Goals/Account review block at the end. */
  color: inherit; text-decoration: none;
  /* 44px minimum in both directions without saying so in pixels: the padding
     plus two lines of type clears it, and the grid gives it the width. */
}
.me-stat-n { font-size: 21px; font-weight: var(--fw-bold); letter-spacing: -0.02em; }
.me-stat-l { font-size: var(--fs-sm); font-weight: var(--fw-med);
             color: var(--ink-faint); }
/* ⚠️ A count the app cannot know is a DASH and is not a link — see the comment
   above `countsHelp()` in views-me.js. Off the cloud there is no graph to count,
   and printing 0 would be a claim rather than an absence. */
.me-stat.is-off { opacity: 0.55; }

/* ================================================================== *
 * PROFILE'S SECTIONS — 2026-09-10 and -11, the Data/Profile split.
 *
 * 🚨 NO BOXES (Rule 2). Each is a `.section-label` over content, exactly like
 * every other grouped block in the app. Nothing on this screen is bordered:
 * `.me-stat` above lost its border on 2026-09-24 and keeps only a hairline
 * between the two tiles. A bordered card round a calendar would be a box.
 * ================================================================== */

/* Your body and your goal — 2026-09-11, steps 2 and 4. One label over one
   `.list`, and they share this rule rather than each getting their own, because
   the only thing either needs is the gap under its label. ⚠️ `min-width: 0` for
   the same reason every other section here has it: a long lift name in a flex
   column must be allowed to shrink, or it widens the pane and the whole screen
   gains a sideways drag (2026-09-10). */
.me-section { display: flex; flex-direction: column; gap: 8px; min-width: 0; }

/* The calendar. ⚠️ NOT its own scroller — the pane scrolls, and nesting a
   second one inside it is how a phone ends up with two scrollbars fighting
   over one drag. `ownCalendar` is passed `land: false` from here for the same
   reason: see its header. */
.me-cal { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.me-cal-host { display: flex; flex-direction: column; gap: 10px; min-width: 0; }

/* Best lifts. The measured number is the loud one and the estimate sits under
   it in the soft ink — Rule 5 carried by SIZE and WORD, never by colour alone,
   because "estimated max" is printed in full beside it. */
.me-bests { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
.me-best-nums {
  flex: none; display: flex; flex-direction: column; align-items: flex-end;
  gap: 1px; text-align: right;
}
.me-best-top { font-weight: var(--fw-med); font-variant-numeric: tabular-nums; }
.me-best-est { font-size: var(--fs-sm); color: var(--ink-faint); font-variant-numeric: tabular-nums; }

/* ── Best lifts, ranked (2026-09-12) ─────────────────────────────────────────
   The number wears the level's chip (`lv-text-<key>`, already validated for
   contrast in both themes) and the words under it carry the band and the
   level, so the colour is never the only carrier. A row with no number puts
   the reason in the number's slot rather than leaving a hole. */
.me-best-none { font-size: var(--fs-sm); color: var(--ink-faint); text-align: right; max-width: 15ch; }
.me-best.is-none .row-title { color: var(--ink-soft); }

/* "Other lifts" — a real <details>, styled like a row so it reads as part of
   the list. Same recipe as .rt-summary: padding rather than min-height for
   the 44px target, the native marker hidden, our own chevron turned on open.
   `--t-fast` is the MOTION section's own token; the turn is a press answering
   back, and reduced motion drops it with everything else. */
.me-other { border-top: 1px solid var(--rule-soft); }
.me-other-sum {
  display: flex; align-items: center; gap: 8px;
  padding: 13px 2px; cursor: pointer; list-style: none;
  font-weight: var(--fw-med); color: var(--ink);
}
.me-other-sum::-webkit-details-marker { display: none; }
.me-other-title { flex: 1 1 auto; min-width: 0; }
.me-other-n { font-size: var(--fs-sm); font-weight: var(--fw-med); color: var(--ink-soft); font-variant-numeric: tabular-nums; }
.me-other-chev { display: flex; color: var(--ink-faint); transition: transform var(--t-fast) var(--ease-out); }
.me-other[open] .me-other-chev { transform: rotate(90deg); }

/* A face beside a name in a plain list row. Sized to match `.row-icon`'s. */
.row-face { flex: none; width: 26px; height: 26px; display: grid; place-items: center;
            border-radius: 50%; overflow: hidden; background: var(--raised);
            color: var(--ink-soft); }

/* Most rows in this app are buttons; a friend row is a real link, so you can
   open somebody in a new tab. That means undoing the underline the browser
   gives an anchor — the row is the affordance, not the text inside it. */
a.row { text-decoration: none; }

/* A row that is a button must not look like a button — it is a row. */
.as-button { border: 0; font: inherit; cursor: pointer; }
/* Guarded for the same reason .body-region:hover is — a tapped row would keep
   an accent title indefinitely, which this app uses to mean "current". */
@media (hover: hover) {
  .as-button:hover .row-title { color: var(--accent); }
}

.btn.small { min-height: 34px; padding-inline: 11px; font-size: var(--fs-md); }

.danger-zone { margin-top: 26px; padding-top: 14px; border-top: 1px solid var(--rule); }

/* The visibility picker. Each option carries its own explanation, because
   "mid visibility" means nothing to somebody who has not read the plan (D8). */
.pick-list { display: flex; flex-direction: column; }
.pick-row {
  display: flex; align-items: center; gap: 10px; width: 100%;
  padding: 11px 0; text-align: left; background: none; border: 0;
  border-bottom: 1px solid var(--rule-soft); color: inherit; cursor: pointer;
}
.pick-row:last-of-type { border-bottom: 0; }
.pick-row svg { color: var(--accent); width: 20px; height: 20px; flex: none; }
.pick-title { font-size: var(--fs-lg); font-weight: var(--fw-med); }
.pick-sub { font-size: var(--fs-md); color: var(--ink-soft); line-height: 1.45; margin-top: 1px; }
.pick-row.is-on .pick-title { color: var(--accent); }

/* A friend's workout, closed by default and opening in place. */
.act { border-bottom: 1px solid var(--rule-soft); }
.act:last-child { border-bottom: 0; }
.act-detail { padding: 2px 0 12px; }
.act-line {
  display: flex; align-items: baseline; gap: 10px;
  padding: 5px 0 5px 30px; font-size: var(--fs-md);
}
.act-name { flex: 1; min-width: 0; }
.act-sets { color: var(--ink-soft); font-variant-numeric: tabular-nums; font-size: var(--fs-sm); text-align: right; }

/* The way in, at the bottom of an opened row — the same session, on its own
   screen. Quiet, because the row above it has already answered the question
   most people opened it with. */
.act-open {
  display: inline-block; margin: 6px 0 0 30px;
  font-size: var(--fs-md); font-weight: var(--fw-med); color: var(--accent); text-decoration: none;
}

/* ⚠️ CAPPED SINCE 2026-09-03, AND THE REASON IS THE TAP RATHER THAN THE SIZE.
   The figure was `width: 100%; height: auto`, which drew 640px of body on a
   360×780 phone — fine while it was a picture, and wrong the moment it became a
   control: tapping a muscle put the panel below the fold, so the screen answered
   a tap by appearing to do nothing at all. Same cap, same reasoning and the same
   aspect-ratio box as the Volume map (2026-09-01), which had this exact problem
   with its key. */
.friend-body {
  margin: 6px 0 4px;
  width: 100%;
  aspect-ratio: var(--body-ar, 1);
  max-height: 52dvh;
  max-width: calc(52dvh * var(--body-ar, 1));
  margin-inline: auto;
  display: flex; align-items: center; justify-content: center;
}
.friend-body .body-map { width: 100%; height: 100%; }

/* ==========================================================================
   A FRIEND'S MAP ON A LAPTOP — 2026-09-09

   Tim: *"right now viewing another person's profile (specifically on a laptop)
   is a mess. Everything is formated for an iphone instead of a laptop. The
   formating and design on an iphone works great."*

   ⚠️ NOTHING HERE APPLIES BELOW 860px, and that is deliberate rather than
   incidental: the phone layout is the one he called great, so `.map-block` and
   `.map-split` are a plain block and a plain block until the media query. The
   children stack exactly as they did when they were loose in the pane.

   The desktop half gives the figure and its panel the same two-column shape your
   OWN map has had since 2026-08-21 — same breakpoint, same clamp, same hairline
   — and leaves everything else on the page (their workouts, their body weight,
   the disconnect) full width underneath, which is where a stack of sections
   belongs. Rule 3's corollary is why the split is here rather than on the host:
   the figure must not shrink because you tapped a muscle, and a panel BESIDE it
   costs width once where a panel UNDER it costs half the height.
   ========================================================================== */
@media (min-width: 860px) {
  .friend-body { max-height: 64dvh; max-width: calc(64dvh * var(--body-ar, 1)); }

  .map-split { display: flex; align-items: stretch; gap: 18px; }
  /* ⚠️ `align-self: flex-start` — the aspect box governs its own height, and
     stretching it would drive the height from the panel beside it and hand the
     figure whatever shape was left over. */
  .map-split > .friend-body { flex: 1; min-width: 0; align-self: flex-start; }
  .map-split > .body-foot {
    width: clamp(260px, 32%, 340px);
    flex: none;
    padding-left: 18px;
    /* A hairline, not a card — the panel is a column of this screen, not a box
       floating on it (Rule 2). The same rule your own map uses. */
    border-left: 1px solid var(--rule-soft);
  }
  /* Vertical room is not scarce beside a figure, so the legend can breathe —
     the same relaxation `.graph-host.is-muscles` makes. */
  .map-split .lv-key { gap: 6px 12px; }
}

/* ==========================================================================
   Two bodies, side by side  (#/compare/<uid>)
   ==========================================================================
   🚨 Tim, 2026-09-03: "make a compare button somewhere that allows that user to
   display another person's body side by side to the current displayed body."

   ⚠️ SIDE BY SIDE AT EVERY WIDTH, INCLUDING 360px, AND THAT IS THE WHOLE POINT
   OF THE SCREEN. The obvious responsive move — stack the two columns on a phone
   — would put one body above the other, which is not a comparison: you cannot
   see two things at once by scrolling between them. Each figure is already two
   half-width drawings (front and back) inside one viewBox, so a column at 176px
   draws each at 88px. That is small and it is legible, because the figure is a
   silhouette with coloured regions rather than something you read detail off.
   The PANEL under it carries every number in words, at full size.
   ========================================================================== */
.cmp-grid {
  display: grid; grid-template-columns: 1fr 1fr; gap: 10px; align-items: start;
  margin: 4px 0 8px;
}
.cmp-col { min-width: 0; }
.cmp-name {
  font-size: var(--fs-md); font-weight: var(--fw-bold); letter-spacing: .02em;
  color: var(--ink-soft); text-align: center; margin-bottom: 2px;
  /* A name that wraps to two lines on one side and one on the other would put
     the two figures on different baselines, which reads as a rendering fault. */
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
/* ⚠️ THE HEIGHT IS CAPPED AND THE KEY IS WHY — found by MEASURING, not by
   looking. Left to fill its column the figure drew 405×599 at 1180×820 and put
   the level key at 852 against a fold at 820: 32px below it, on a screen whose
   whole job is that two coloured bodies can be read against each other.

   The strength ramp is a multi-hue scale that lands in the CVD floor band, and
   this project's own rule (docs/handbook.md §5, "Colour — validate, never eyeball")
   is that such a ramp is legal ONLY with secondary encoding — the legend and the
   named level on tap. A key below the fold is not secondary encoding. Same
   argument, and the same resolution, as the Volume map's 71dvh on 2026-09-01:
   the key wins.

   ⚠️ `aspect-ratio` RATHER THAN A BARE max-height, for the reason that map
   recorded: an SVG with a viewBox never crops — given a box of the wrong shape
   it shrinks and pads the rest, which is a small body floating in a lot of
   nothing. The box carries the drawing's own ratio, so it is exactly the
   picture's shape at every width. */
/* ⚠️ THE RATIO IS THE WHOLE DRAWING'S, NOT HALF OF IT, and getting that wrong
   cost a measurement: `--body-ar / 2` reads as "each column is half the width"
   and is a confusion between the COLUMN and the PICTURE. One figure here is
   already both bodies — front and back inside one viewBox — so its ratio is
   `--body-ar` whatever width the column happens to be. Halved, the box came out
   wide and short, the SVG letterboxed inside it, and the laptop drew a 34×16
   chest where the phone drew 43×20: a bigger screen showing a smaller body. */
.cmp-body {
  aspect-ratio: var(--body-ar, 1);
  max-height: 46dvh;
  max-width: calc(46dvh * var(--body-ar, 1));
  margin-inline: auto;
  display: flex; align-items: center; justify-content: center;
}
.cmp-body .body-map { width: 100%; height: 100%; }
/* The panels below the figures are cards already; this only stops a long
   exercise name from widening its column and shunting the other one. */
.cmp-grid .card { min-width: 0; }
.cmp-grid .muscle-sources { overflow-wrap: anywhere; }

/* ==========================================================================
   A friend's workout, on its own screen  (#/friend/<uid>/<sessionId>)
   ==========================================================================
   Hevy's workout detail (docs/social-plan.md §12.15) in this app's chrome: the
   same reading order — who, when, what it was called, what they said, the
   numbers, the split, the sets — with hairlines and space where theirs has
   boxes and zebra stripes (Rule 2). Their set rows alternate two near-identical
   greys; a rule between rows does the same job and is the pattern every other
   set list in this app already uses. */
.ws { display: flex; flex-direction: column; gap: 12px; }
.ws-who { display: flex; align-items: center; gap: 9px; color: inherit; text-decoration: none; }
/* The workout's name is the loudest thing on the screen, as on the card. */
.ws-title { margin: 0; font-size: 23px; font-weight: var(--fw-bold); line-height: 1.15; }
.ws-note {
  margin: -4px 0 0; font-size: var(--fs-lg); color: var(--ink-soft); line-height: 1.5;
  overflow-wrap: anywhere; white-space: pre-line;
}
.ws-where { margin: -4px 0 0; font-size: var(--fs-md); color: var(--ink-faint); }
/* Wider gaps than the card's: this screen has the room, and the numbers are
   what somebody stopped to look at. */
.ws-stats { gap: 4px 28px; }

/* ── The muscle split ─────────────────────────────────────────────────────
   ⚠️ ONE COLOUR, and the number is printed beside every bar. This is a share
   of one session, not a rank, so a ramp would be inventing an ordering that
   says good and bad where the app has no opinion (Rule 6). */
.ws-split { display: flex; flex-direction: column; gap: 7px; }
.split-row { display: flex; flex-direction: column; gap: 3px; }
.split-head { display: flex; justify-content: space-between; gap: 10px; font-size: var(--fs-md); }
.split-name { color: var(--ink); font-weight: var(--fw-med); }
.split-pct { color: var(--ink-soft); font-variant-numeric: tabular-nums; }
.split-track { height: 7px; border-radius: 999px; background: var(--rule); overflow: hidden; }
.split-fill { height: 100%; border-radius: 999px; background: var(--accent); }
/* ⚠️ Fine print stays --ink-soft rather than dropping to --ink-faint. The
   accessibility audit measured --ink-faint below 4.5:1 on every surface at
   text sizes under 18.66px, and this is 11.5px. Small is allowed; unreadable
   is not. */
.ws-fine { margin: 2px 0 0; font-size: var(--fs-sm); line-height: 1.45; color: var(--ink-soft); }

/* ── The set tables ───────────────────────────────────────────────────── */
.ws-ex { padding: 10px 0; border-bottom: 1px solid var(--rule-soft); }
.ws-ex:last-child { border-bottom: 0; }
.ws-ex.in-group { border-left: 2px solid var(--rule); padding-left: 10px; }
.ws-ex-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 7px; }
.ws-ex-name { font-size: var(--fs-lg); font-weight: var(--fw-med); }
.ws-sets { display: flex; flex-direction: column; margin-top: 5px; }
.ws-set { display: flex; gap: 14px; padding: 4px 0; font-size: var(--fs-md); }
.ws-set-n {
  flex: none; width: 34px;
  font-variant-numeric: tabular-nums; color: var(--ink-faint);
}
.ws-set-v { font-variant-numeric: tabular-nums; color: var(--ink); }
/* The column heads, in the same caption voice as a stat label. */
.ws-set.is-head { border-bottom: 1px solid var(--rule-soft); padding-bottom: 3px; }
.ws-set.is-head .ws-set-n,
.ws-set.is-head .ws-set-v {
  font-family: var(--sans);
  font-size: var(--fs-xs); font-weight: var(--fw-bold);
  color: var(--ink-faint);
}
/* A drop or a mini-set hangs UNDER the set it came off, indented and quieter —
   it is part of that set, not another one (D23). */
.ws-set.is-mini { padding-top: 0; }
.ws-set.is-mini .ws-set-n { padding-left: 12px; }
.ws-set.is-mini .ws-set-v { color: var(--ink-soft); font-size: var(--fs-md); }

/* An exercise name that opens a comparison. Hevy paints these blue; this app
   has no blue and does not need one — the accent it already uses for "this is
   the interactive thing" says the same, and a dotted underline says it again
   without colour, which is the rule everywhere else here. 44px of height comes
   from the padding, not from a bigger type size. */
.ws-ex-name.as-link {
  color: var(--accent);
  border-bottom: 1px dashed currentColor;
  padding: 6px 0;
  text-align: left;
}
.ws-ex-name.as-link:active { opacity: .7; }

/* Bests set in this workout. The same left keyline the finish screen's own
   personal bests use, so the two read as the same kind of thing. */
.ws-prs { display: flex; flex-direction: column; gap: 8px; }
.ws-pr {
  display: flex; flex-direction: column; gap: 2px;
  border-left: 2px solid var(--good); padding: 2px 0 2px 12px;
}
.ws-pr-head { display: flex; align-items: baseline; flex-wrap: wrap; gap: 6px; }
.ws-pr-name { font-size: var(--fs-lg); font-weight: var(--fw-med); }
.ws-pr-line { font-size: var(--fs-md); color: var(--ink-soft); }

/* What you can do with somebody else's workout, after you have read it. */
.ws-do { display: flex; flex-direction: column; gap: 8px; margin-top: 6px; }
.ws-copy-list { display: flex; flex-direction: column; gap: 3px; }

/* ── You and them, on one exercise ────────────────────────────────────────
   ⚠️ NO WINNER MARK ANYWHERE IN HERE. Two numbers side by side, the same
   weight, in the same ink. Rule 6: the app does not get an opinion about who
   trains better off one exercise, so nothing is coloured good or bad. */
.cmp { display: flex; flex-direction: column; gap: 12px; }
.cmp-row { display: flex; flex-direction: column; gap: 3px; }
.cmp-label {
  display: flex; align-items: baseline; gap: 7px;
  font-size: var(--fs-sm); font-weight: var(--fw-bold);
  color: var(--ink-faint);
}
.cmp-pair { display: flex; gap: 18px; flex-wrap: wrap; }
.cmp-num {
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-lg); font-weight: var(--fw-med);
}
.cmp-num b { font-family: var(--sans); font-size: var(--fs-sm); font-weight: var(--fw-med); color: var(--ink-faint); }
/* ⚠️ A CONVERTED FIGURE IS MARKED IN WORDS AND IN WEIGHT, NEVER IN COLOUR
   ALONE — Rule 5's general form. The number itself softens to --ink-soft so it
   does not read as loudly as the measured one beside it, and the word says
   what happened for anyone who cannot see the difference. */
.cmp-num.is-converted { color: var(--ink-soft); }
/* ⚠️ A PILL ON THE NUMBER, NOT A LINE UNDER IT. The first version was a block
   of small uppercase text below the figure — the same treatment `.cmp-label`
   uses for a ROW HEADING — so "CONVERTED" read as the title of the next row
   rather than as a note about the number above it. Borrowing `.tag`'s shape
   attaches it to the thing it describes, and matches the "estimated" pill on
   the row, which is the other half of the same claim. */
.cmp-mark {
  margin-left: 6px;
  padding: 1px 6px;
  border-radius: 999px;
  background: var(--raised);
  color: var(--ink-faint);
  font-family: var(--sans); font-size: var(--fs-xs); font-weight: var(--fw-bold);
  letter-spacing: .04em; text-transform: uppercase;
  white-space: nowrap;
}

/* The "% optimal" rating beside a ready-made system.
   Two numbers, never one — a programme good for growth is often not the one
   good for strength, and a blend would hide the trade. Right-aligned in the
   leftover space so the system's name stays the content (Rule 3). */
/* A 2x2 GRID rather than one row, since the badge gained days and minutes
   (Tim, 2026-08-19). Four cells side by side measure about 180px, which on a
   390px phone would leave the system's NAME with half the row — and Rule 3 says
   the name is the content and the badge is what goes in the leftover. Two rows
   of two costs height the row already has and no width at all. */
.rating {
  display: grid;
  grid-template-columns: repeat(2, minmax(46px, auto));
  gap: 3px 12px;
  flex: none;
  text-align: right;
}
.rating-num { font-size: var(--fs-lg); font-weight: var(--fw-bold); font-variant-numeric: tabular-nums; }
.rating-cap {
  font-size: var(--fs-xs); font-weight: var(--fw-bold);
  color: var(--ink-faint); margin-top: 1px;
  /* One cap per line, always. "days/wk" breaking after the slash would put a
     stray "wk" under a number and read as a fifth measurement. */
  white-space: nowrap;
}
/* Below ~380px the summary and the rating fight for the row. The rating is the
   newer thing but the name is the content, so the rating gives up size rather
   than squeezing the title into an ellipsis. */
@media (max-width: 380px) {
  .rating { gap: 3px 9px; grid-template-columns: repeat(2, minmax(40px, auto)); }
  .rating-num { font-size: var(--fs-md); }
  .rating-cap { font-size: var(--fs-xs); }
}

/* ⚠️ ON A PHONE THE BADGE STOPS SHARING THE ROW AND TAKES ITS OWN LINE.
   The 2×2 grid was the right answer when the alternative was four cells in a
   line — it stopped the badge eating half the row. It still ate about 150px of
   393, and on Explore that is the difference between a readable sentence and a
   28-character column: every summary wrapped to five or six stubs ("The whole
   of Jeff Nippard's 2023 / YouTube series — six workouts, two of / each day").
   Measured 2026-08-21. Rule 3 again — the sentence is the content — and the
   honest reading is that there is no arrangement of four numbers that leaves a
   sentence enough width on a 393px screen while sitting beside it.

   So the text gets the whole width and the numbers get their own line, where
   four across finally fits comfortably. `order` keeps the chevron up on line
   one with the title, because a chevron stranded beside the badge reads as
   pointing at the badge. */
@media (max-width: 700px) {
  .row-rated { flex-wrap: wrap; }
  .row-rated .row-chev { order: 1; }
  .row-rated .rating {
    order: 2;
    width: 100%;
    grid-template-columns: repeat(4, 1fr);
    gap: 0 10px;
    text-align: left;
    margin-top: 4px;
  }
  /* Freed of the badge, the two lines of text no longer need to be stubs. */
  .row-rated .row-sub.wrap { line-height: 1.35; }
}

/* The rating on a system the user built. Sits above the workout list because
   it describes the whole programme, and the badge aligns right so the heading
   stays the content (Rule 3). */
/* ⚠️ A GAP, because these are five separate claims and they were painting as
   one. `.field-help` carries no margin and this box carried no gap, so the
   caption, the score's explanation, the coverage line and the two caveats ran
   together into ~350 unbroken grey words — measured on a phone 2026-08-21,
   where it filled the screen. They are no longer a wall, and the workout list
   now sits above them so nobody has to scroll past them to reach their own
   programme.

   🔄 ⚠️ THIS PARAGRAPH USED TO END *"Nothing here is hidden or shortened: on a
   phone the caveats are load-bearing and a disclosure is how a caveat stops
   being read."* THAT WAS OVERTURNED BY TIM ON 2026-09-07, and the distinction
   he drew is finer than the one this comment made. A caveat behind a `<details>`
   nobody opens does stop being read — that part was right. What was wrong is
   treating every sentence in the block as the same kind of claim. Design Rule 9
   splits them: what the number IS stays in the open (*"3 sets of 20 and 3 sets
   of 5 get the same strength percentage"*, *"Indirect work counts half a set"*,
   *"Nothing real reaches 100 %"*), and only the reasoning behind each moved
   behind a "?" — which, unlike a disclosure, is a named control sitting against
   the words it answers for. Kept rather than deleted because the wrong version
   is why this block was built the way it was. */
.own-rating { margin-bottom: 6px; display: flex; flex-direction: column; gap: 7px; }
/* Top-aligned since the badge became two rows: centring left the heading
   floating against the middle of a taller block, reading as neither its label
   nor a caption for the row above. */
.own-rating-head { display: flex; align-items: flex-start; gap: 12px; }
.own-rating-head .section-label { flex: 1; min-width: 0; margin: 0; }

/* "A new version is ready." Sits above the bottom nav, out of the way of the
   content, and never covers the primary action of a screen. It OFFERS a
   refresh — reloading unasked would be right almost always and catastrophic
   once, mid-set with numbers typed and unsaved. */
.update-bar {
  position: fixed;
  left: 50%;
  transform: translateX(-50%);
  bottom: calc(var(--bar-h) + 12px + env(safe-area-inset-bottom, 0px));
  z-index: 60;
  display: flex;
  align-items: center;
  gap: 10px;
  max-width: calc(100% - 24px);
  padding: 9px 10px 9px 14px;
  border-radius: var(--r);
  background: var(--raised-hi);
  border: 1px solid var(--rule);
  box-shadow: 0 6px 24px rgba(0, 0, 0, .45);
  font-size: var(--fs-md);
}
.update-bar .icon-btn { margin-right: -4px; }
/* On desktop the nav is a left sidebar, so there is no bottom bar to clear. */
@media (min-width: 880px) {
  .update-bar { bottom: 16px; left: auto; right: 16px; transform: none; }
}

/* ================= Goals =================
   docs/goals-plan.md. Rule 2 still applies — no boxes. The one place this
   screen leans harder than the rest of the app is the hero, because Rule 3 says
   start with the most important thing as big as you can, and on a goal screen
   that is the target itself rather than any of the numbers under it. */

.goal-intro { display: flex; flex-direction: column; gap: 8px; margin: 4px 0 14px; }
.goal-intro-title { font-size: var(--fs-xl); font-weight: var(--fw-bold); letter-spacing: -0.01em; }
.goal-intro-body { margin: 0; color: var(--ink-soft); font-size: var(--fs-md); line-height: 1.5; }

.goal-screen { display: flex; flex-direction: column; gap: 16px; }

.goal-hero { display: flex; flex-direction: column; gap: 2px; }
.goal-hero-level {
  font-size: var(--fs-sm); font-weight: var(--fw-bold);
  /* A chip, not a band: the column would otherwise stretch its fill full width. */
  align-self: flex-start;
}
.goal-hero-lift { font-size: 25px; font-weight: var(--fw-bold); letter-spacing: -0.02em; line-height: 1.15; }
.goal-hero-meta {
  display: flex; align-items: baseline; gap: 7px; flex-wrap: wrap;
  font-size: var(--fs-lg); font-weight: var(--fw-med); color: var(--ink-soft); margin-top: 2px;
}
.goal-hero-meta .mono { font-variant-numeric: tabular-nums; color: var(--ink); }
.goal-hero-dot { color: var(--ink-faint); }

/* Three numbers side by side rather than in a bordered grid: the start, the
   now and the target are one sentence read left to right, and the bar under
   them is the same sentence drawn. */
.goal-nums { display: flex; gap: 18px; }
.goal-nums .stat { padding: 0; background: none; }
.goal-progress { gap: 7px; }

.goal-verdict { display: flex; flex-direction: column; gap: 6px; }
.goal-verdict-body { margin: 0; color: var(--ink-soft); font-size: var(--fs-md); line-height: 1.5; }

.goal-ambition { display: flex; align-items: baseline; gap: 9px; }
.goal-ambition-name { font-size: var(--fs-lg); font-weight: var(--fw-bold); }
.goal-ambition-gain {
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-lg); font-weight: var(--fw-med); color: var(--accent);
}

/* A requirement is a statement, not a destination, so it wraps rather than
   clipping and its value is right-aligned where a chevron would be. */
.req-row, .stall-row { align-items: flex-start; cursor: default; }
.req-row:active, .stall-row:active { background: none; }
.req-row .row-title, .stall-row .row-title { white-space: normal; }
.req-value {
  flex: none; align-self: center;
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-lg); font-weight: var(--fw-med); text-align: right; min-width: 62px;
}
.req-source {
  font-size: var(--fs-xs); font-weight: var(--fw-med); letter-spacing: 0.04em;
  color: var(--ink-faint); margin-top: 3px;
}
/* A requirement whose answer is words rather than a number. It sits under the
   label instead of in the tabular column — that column is sized for "7-10", and
   a sentence dropped into it takes 300px and crushes the label beside it into a
   five-word-tall stripe. */
.req-phrase { font-size: var(--fs-md); font-weight: var(--fw-med); color: var(--ink); margin-top: 1px; }

/* Status is never colour alone — every row already states its number and its
   sentence, and the tint is a second, redundant cue on top of both. */
.stall-row.is-short .req-value { color: var(--danger); }
.stall-row.is-ok .req-value { color: var(--good); }
.stall-row.is-unknown .req-value, .stall-row.is-invisible .req-value { color: var(--ink-faint); }
.stall-row.is-invisible .row-title { color: var(--ink-soft); }

.goal-option .row-title { font-weight: var(--fw-bold); }
.goal-option-gain {
  flex: none; font-variant-numeric: tabular-nums;
  font-size: var(--fs-lg); font-weight: var(--fw-med); color: var(--ink-soft);
}

/* Six tabs rather than five. Each is still an equal share of the bar, but the
   label can no longer push the share wider than it is: without min-width:0 a
   flex item refuses to shrink below its content, so the longest label would
   silently widen its own tab and squeeze the rest. */
.navbar a { min-width: 0; }
.navbar a span {
  max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* ================= Demo account =================
   A strip above the header on every screen. Not a toast and not a dialog shown
   once: both facts it carries have to be true at every moment of the session —
   this is not your data, and nothing you change is kept — and neither survives
   being said only at the start. It also holds the way out, because a sandbox
   with no visible exit is its own kind of trap. */
.demo-bar {
  flex: none;
  display: flex;
  align-items: center;
  gap: 9px;
  /* ⚠️ THE TOP INSET IS OWED HERE, not only by .topbar. The bar is prepended
     ABOVE the header (app.js does it for every route, including the fullscreen
     ones), so it — not .topbar — is what sits under the notch. `.topbar` was
     the only rule in this file padding safe-area-inset-top, so in the demo, and
     under the black-translucent status bar the installed PWA asks for, this
     text ran beneath the Dynamic Island. Found 2026-08-21.

     ⚠️ And .topbar must then NOT pad it a second time — see the rule below. */
  padding: calc(env(safe-area-inset-top, 0px) + 7px) var(--pad) 7px;
  background: var(--accent-dim);
  border-bottom: 1px solid var(--accent);
  font-size: var(--fs-md);
  line-height: 1.35;
  color: var(--ink);
}
.demo-bar-dot {
  flex: none;
  width: 8px; height: 8px;
  border-radius: 50%;
  background: var(--accent);
}
.demo-bar-text { flex: 1; min-width: 0; }
.demo-bar .btn { flex: none; }
/* Whichever element is FIRST owes the inset, and only that one. With the demo
   bar present the header is no longer against the top of the screen, so leaving
   its inset in place would push the title down by the height of the notch for
   no reason. */
.demo-bar + .topbar { padding-top: 9px; }

/* Below ~380px the sentence is what matters and the dot is decoration. */
@media (max-width: 380px) {
  .demo-bar { gap: 7px; font-size: var(--fs-sm); }
  .demo-bar-dot { display: none; }
}

/* ==========================================================================
   Home — the feed
   ==========================================================================
   ⚠️ STRAVA'S ANATOMY, THIS APP'S CHROME. Tim asked for a feed "extremely
   similar" to Strava's and, in the same breath, for no panels on any page.
   Strava's feed is elevated cards with drop shadows on a grey page; this one
   keeps the order and content of a Strava card exactly — avatar and name, then
   date/time/location, then the workout's title as the loudest thing in it, then
   what they did, then kudos/comment/share — and separates entries with space
   and a hairline instead of a box (Rule 2). If the boxes are wanted, it is a
   background and a radius on .feed-card and nothing else moves. */
.feed { display: flex; flex-direction: column; gap: 0; }
.feed-card {
  display: flex; flex-direction: column; gap: 7px;
  padding: 15px 0 13px;
}
.feed-card + .feed-card { border-top: 1px solid var(--rule); }

.feed-head { display: flex; align-items: center; gap: 9px; color: inherit; text-decoration: none; }
.feed-avatar {
  flex: none;
  width: 36px; height: 36px;
  border-radius: 999px;
  background: var(--raised);
  color: var(--ink-faint);
  display: grid; place-items: center;
}
.feed-who { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.feed-name { font-size: var(--fs-md); font-weight: var(--fw-bold); }
/* The meta line is the one place a middot separates two different KINDS of
   fact, so it stays on one line and truncates rather than wrapping into
   something that reads like two lines of equal weight. */
.feed-meta {
  font-size: var(--fs-sm); color: var(--ink-faint);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* ⚠️ LOUDER THAN THE NAME ABOVE IT, which is Strava's hierarchy and is right:
   you scan a feed for what happened, and whose it is qualifies it. On Strava's
   own web feed the title is the card's <h1> and the athlete's name is a span. */
.feed-title { margin: 0; font-size: var(--fs-xl); font-weight: var(--fw-bold); line-height: 1.2; }

/* The tappable half of a card: title, description, numbers, exercises. It is a
   link because it goes somewhere (the workout), and it inherits everything —
   the underline and the link colour would turn four different kinds of text
   into one. The head above it is a separate link to the person. */
.feed-open {
  display: flex; flex-direction: column; gap: 7px;
  color: inherit; text-decoration: none;
  border-radius: var(--r);
}
/* ⚠️ THE PRESS IS THE ONLY THING THAT MOVES HERE. A feed is scrolled with a
   thumb and a card that animates on arrival fights the scroll (Rule 7). */
.feed-open:active { background: var(--raised); }
.feed-open.is-flat:active { background: none; }

/* Their own words about the session, under the title and above the numbers —
   what the person said, then what the app counted. */
.feed-note {
  margin: 0;
  font-size: var(--fs-md); color: var(--ink-soft); line-height: 1.45;
  overflow-wrap: anywhere; white-space: pre-line;
}

/* ── The stat row ──────────────────────────────────────────────────────────
   Small uppercase label above, bold value below, in columns. Hevy's shape,
   this app's type — and NO dividers between the columns, which is where their
   version and Rule 2 part company. `gap` alone reads as three things because
   the labels are already a different weight and colour from the values.

   Columns are content-width rather than equal thirds: with two or three cells
   an even split leaves "Sets 24" floating in the middle of a third of the
   card, and the eye has to travel to find the next number. */
.feed-stats { display: flex; flex-wrap: wrap; gap: 3px 22px; }
.feed-stat { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.feed-stat-label {
  font-size: var(--fs-sm); font-weight: var(--fw-bold);
  color: var(--ink-faint);
}
.feed-stat-value {
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-lg); font-weight: var(--fw-med); letter-spacing: -.03em;
}

/* ── One row per exercise, set count first ────────────────────────────────
   The count leads because it is the part that varies and the part being
   compared; the name is what it applies to. Mono on the count keeps the names
   on a straight left edge without a table. */
.feed-exs { display: flex; flex-direction: column; gap: 2px; }
.feed-ex { display: flex; gap: 8px; font-size: var(--fs-md); line-height: 1.45; min-width: 0; }
.feed-ex-sets {
  flex: none; min-width: 52px;
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-md); color: var(--ink-faint);
  align-self: center;
}
.feed-ex-name { color: var(--ink-soft); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.feed-ex.is-quiet { color: var(--ink-faint); font-size: var(--fs-md); }

.feed-did { font-size: var(--fs-md); color: var(--ink-soft); line-height: 1.45; }
/* The lowest sharing tier publishes that somebody trained and nothing else. The
   card must read as complete rather than broken, so this is a stated fact in
   the app's caption voice, not an empty slot. */
.feed-did.is-quiet { color: var(--ink-faint); font-style: italic; }

/* Three equal thirds: thumbs-up left, comment middle, share right — the order
   Tim specified. Equal width rather than content width, so the middle one is
   actually in the middle. */
.feed-actions { display: flex; gap: 4px; margin-top: 3px; }
.feed-act {
  flex: 1;
  min-height: 40px;
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  border-radius: 7px;
  color: var(--ink-soft);
  font-size: var(--fs-md); font-weight: var(--fw-med);
}
.feed-act:active { background: var(--raised); }
.feed-act-glyph { font-size: var(--fs-lg); line-height: 1; }
/* Your own kudos reads as a state, not a hover: accent text, no fill (Rule 2). */
.feed-act.is-mine { color: var(--accent); }

/* ── "On your workouts" — who reacted to YOUR training ─────────────────────
   A quiet readout above the feed, never a notification system. Hairline
   below, no box.

   🆕 2026-09-27 — EACH ROW IS A LINK NOW, on Tim's report that the line names
   a reaction and not a workout you can get to: *"it's quite hard to know which
   workout the user is referring to."* It is still a readout; what changed is
   that the thing it reads out is reachable. `a.feed-mine-row` carries no
   underline and no colour of its own — the pressed state is the affordance,
   the same way a feed card's body is. */
.feed-mine { display: flex; flex-direction: column; gap: 6px; padding: 12px 0 13px; border-bottom: 1px solid var(--rule); }
.feed-mine-row { display: flex; flex-direction: column; gap: 2px; }
a.feed-mine-row {
  color: inherit; text-decoration: none;
  border-radius: var(--r);
  padding: 4px 0;
  margin: -4px 0;
}
a.feed-mine-row:active { background: var(--raised); }
.feed-mine-what { font-size: var(--fs-sm); font-weight: var(--fw-med); color: var(--ink-soft); }
.feed-mine-who { font-size: var(--fs-md); color: var(--ink); line-height: 1.45; overflow-wrap: anywhere; }

/* "Added to your systems" — the receipt on the Explore detail screen.
   Not a button and not a toast: a toast is gone in 2.4s and the old version
   fired one at the same moment it navigated away, which is why Tim could not
   tell whether the add had worked. This stays on the screen for as long as the
   copy exists. Accent tick, ordinary ink, no box (Rule 2). */
.added-note {
  display: flex; align-items: center; gap: 7px;
  font-size: var(--fs-md); font-weight: var(--fw-med);
  padding: 2px 0 2px;
}
.added-note svg { color: var(--accent); flex: none; }

/* ── Your own workouts, as cards ───────────────────────────────────────────
   `#/me/workouts` draws the same card the feed does (js/workout-card.js).

   Two things differ and both are below. The foot of your own card is a
   READOUT of the reactions that landed on it rather than the buttons a
   friend's card carries — you cannot react to your own workout, and
   firestore.rules says so with isFriendOf. And when a notification links to
   one card, that card says so for a moment.

   🚨 THE MARK IS STATE, NOT A PERFORMANCE, and the first version got that
   wrong: it faded over 1600ms, which is six times Rule 7's ceiling and passed
   the a11y suite only because that check measures the --t tokens rather than
   every keyframe. What arrives is a quiet raised ground, faded in over
   --t-slow, and it STAYS while you are on the screen — leaving rebuilds the
   list and clears it. A fade that times itself out is motion answering a
   question the reader may not have finished reading. */
.feed-rx { display: flex; flex-wrap: wrap; gap: 2px 14px; margin-top: 3px; }
.feed-rx-line { font-size: var(--fs-md); color: var(--ink-soft); line-height: 1.45; overflow-wrap: anywhere; }
.feed-card.is-named {
  background: var(--raised);
  border-radius: var(--r);
  animation: named-card var(--t-slow) var(--ease-out) both;
}
@keyframes named-card {
  from { background: transparent; }
  to   { background: var(--raised); }
}

/* ── The comment thread on a feed card ───────────────────────────────────── */
.comment-sheet { display: flex; flex-direction: column; gap: 12px; }
.comment-list { display: flex; flex-direction: column; max-height: 40vh; overflow-y: auto; }
.comment-list > * + * { border-top: 1px solid var(--rule-soft); }
.comment-row { display: flex; align-items: flex-start; gap: 8px; padding: 8px 0; }
.comment-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.comment-who { font-size: var(--fs-sm); font-weight: var(--fw-bold); }
.comment-text { font-size: var(--fs-md); color: var(--ink-soft); line-height: 1.45; overflow-wrap: anywhere; white-space: pre-line; }

/* The little kind mark on a feed card's title — a dumbbell for a lifting
   session, a route for an activity. It sits INSIDE the h2 so it shares the
   title's baseline and inherits its colour; sized in ems so it tracks the
   title if that ever changes size. Decorative: the title beside it already
   says what this is, which is why it carries no label of its own. */
.feed-kind { display: inline-flex; vertical-align: -0.12em; margin-right: 7px; color: var(--ink-faint); }
.feed-kind svg { width: 1em; height: 1em; }

/* ================================================================== *
 * Volume tab — weekly sets per muscle (2026-08-31)
 *
 * ⚠️ ONE COLOUR FOR EVERY BAR, and that is a decision rather than a shortcut.
 * The level ramp means RANK and the research hues mean IDENTITY; a volume bar
 * means MAGNITUDE, and painting a big one green or a small one red would be
 * the app forming an opinion the evidence does not support (Rule 6) — the
 * tiers describe what another set buys, not whether you are doing enough.
 * The only mark that carries a judgement is the 4-set tick, which is the one
 * threshold the source states outright.
 * ================================================================== */
/* ⚠️ THE RED-TO-GREEN RAMP, AND IT IS ON PROBATION BY CONSTRUCTION.
   GENERATED by tools/volume-ramp.mjs — regenerate and re-validate, never
   hand-edit a hex. Tim asked for red-to-green by name (2026-09-01); it is the
   worst-known pairing for colour vision deficiency, and it is legal here only
   because of three measured properties, all asserted by that tool:

     1. STRICTLY MONOTONE LIGHTNESS — OKLCH L 0.36 → 0.63 in five steps, and
        the order still runs darkest-to-lightest under protanopia, deuteranopia
        AND tritanopia. That is what makes it a scale rather than a rainbow, and
        it is the construction viridis uses.
     2. It reads as paint on both papers — worst step 1.89:1 on the dark theme's
        #C2C6C0 and 3.28:1 on white, against the SHIPPED strength ramp's 1.06
        and 1.84. Better than what is already on this figure.
     3. Adjacent ΔE under deuteranopia is 4.7, which is inside the floor band
        and is survivable ONLY with secondary encoding. That encoding is not
        optional decoration: the legend names every band in words, the panel
        states the number when a muscle is tapped, and the bar list under the
        figure carries all twelve numbers. Remove any of those and this ramp
        stops being defensible. */
:root {
  --vol-none: #760711;
  --vol-low:  #863709;
  --vol-mid:  #8c5f0e;
  --vol-good: #68871b;
  --vol-high: #29a259;
}
.body-region.lv-vol-none { fill: var(--vol-none); }
.body-region.lv-vol-low  { fill: var(--vol-low); }
.body-region.lv-vol-mid  { fill: var(--vol-mid); }
.body-region.lv-vol-good { fill: var(--vol-good); }
.body-region.lv-vol-high { fill: var(--vol-high); }

.vol-pane { display: flex; flex-direction: column; gap: 12px; padding-bottom: 12px; }

/* ⚠️ THE BOX IS THE SHAPE OF THE PICTURE, and that is the whole fix. An SVG with
   a viewBox never crops — it shrinks to fit whatever it is given — so a
   full-width, short container drew a small figure floating in a lot of empty
   space on a laptop, which is exactly how Tim reported it ("the display is
   really small") on the same day the same figure measured 10 % LARGER than the
   Muscles tab's on a phone. `aspect-ratio` plus a max-width tied to the same cap
   means the container is always exactly the drawing's shape: width-limited on a
   phone, height-limited on a desktop, never padded on either.

   ⚠️ 58dvh IS THE CAP AND IT IS THERE FOR THE KEY. Uncapped, the figure filled a
   360×720 phone exactly and the screen arrived as a red-and-green body with no
   legend in sight — on a ramp this project is only allowed to ship BECAUSE the
   legend is there. --body-ar is set inline from js/body-map.js's bodyAspect(),
   which takes a SEX — the two figures are not the same shape, so a female map
   gets a wider box rather than a smaller figure in a male-shaped one. */
.vol-figure {
  width: 100%;
  aspect-ratio: var(--body-ar, 1);
  max-height: 58dvh;
  max-width: calc(58dvh * var(--body-ar, 1));
  margin-inline: auto;
  display: flex; align-items: center; justify-content: center;
}
.vol-figure .body-map { width: 100%; height: 100%; }
/* ⚠️ AND IT GROWS ON A LAPTOP, because that is where it was reported small and
   because the Muscles tab gets bigger there too. Measured at 1280×860: that tab
   lays its figure out as a side column and draws the chest at 120×56, against
   90×43 here under the phone cap — which is the whole of Tim's report, since on
   a PHONE this figure already drew 44 % larger than that one.

   ⚠️ 71dvh RATHER THAN THE 76 THAT WOULD MATCH EXACTLY, and the five pixels are
   bought deliberately: at 76dvh the chest matches at 122×57 and the legend lands
   12px BELOW the fold. This ramp is only defensible while its key is on screen,
   so the key wins the argument. 71dvh draws the chest at 110×52 — 92 % of the
   Muscles tab — with the legend at 829 against a fold at 860. Same 860px
   breakpoint the side-column layout uses. */
@media (min-width: 860px) {
  .vol-figure {
    max-height: 71dvh;
    max-width: calc(71dvh * var(--body-ar, 1));
  }
}
/* ⚠️ PICKING ONE MUSCLE MUST NOT ERASE THE OTHER ELEVEN. The strength map dims
   everything but the selection to 38 %, which is right there — you are reading
   one muscle's rating. Here the whole point is the comparison BETWEEN muscles,
   and at 38 % the ramp washes out to a set of pale greys with a coloured hole in
   it. The selection outline is what marks the pick; this only steps the rest
   back. */
.vol-figure .body-map[data-selected] .body-region:not(.is-selected) { opacity: .72; }

.vol-legend { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; }
/* ⚠️ The key states what it is counting. Without it the chips read "10–19"
   beside a 4 / 8 / 12 weeks control, which is a number with no unit next to a
   control that looks like one — and it was misread that way the day it shipped. */
.vol-legend-unit {
  font-size: var(--fs-sm); font-weight: var(--fw-bold);
  color: var(--ink-faint); margin-right: 2px;
}
/* Named bands, not swatches — this is the secondary encoding the ramp's own
   CVD numbers oblige, so it is load-bearing and not a key that could be cut. */
.vol-chip {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 5px 10px; border-radius: 999px;
  font-size: var(--fs-sm); font-weight: var(--fw-med);
  background: var(--raised); color: var(--ink-soft);
}
.vol-chip i { width: 11px; height: 11px; border-radius: 3px; display: block; flex: none; }
.vol-chip.is-on { background: var(--raised-hi); color: var(--ink); }

/* What is selected on the figure, above the list. */
.vol-picked { display: flex; flex-direction: column; gap: 6px; }
.vol-picked-head { display: flex; align-items: baseline; gap: 8px; }
.vol-picked-name { flex: 1; min-width: 0; font-size: var(--fs-lg); font-weight: var(--fw-bold); }
.vol-picked-num { font-size: var(--fs-lg); font-weight: var(--fw-bold); font-variant-numeric: tabular-nums; }
.vol-intro-main { font-size: var(--fs-lg); font-weight: var(--fw-med); margin-bottom: 3px; }

.vol-list { display: flex; flex-direction: column; }
.vol-item + .vol-item { border-top: 1px solid var(--rule-soft); }

/* The whole row is the control — 56px tall, so the touch target is the row
   rather than the muscle's name. */
.vol-row {
  display: flex;
  flex-direction: column;
  gap: 5px;
  width: 100%;
  min-height: 56px;
  padding: 9px 0;
  background: none;
  border: 0;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
}
.vol-row:active { background: var(--rule-soft); }

.vol-head { display: flex; align-items: baseline; gap: 8px; }
.vol-name {
  flex: 1; min-width: 0;
  font-size: var(--fs-lg); font-weight: var(--fw-med);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.vol-num { flex: none; font-size: var(--fs-lg); font-weight: var(--fw-bold); font-variant-numeric: tabular-nums; }
.vol-unit { font-size: var(--fs-sm); font-weight: var(--fw-med); color: var(--ink-faint); }

.vol-track {
  position: relative;
  display: block;
  height: 9px;
  border-radius: 5px;
  background: var(--rule);
  overflow: hidden;
}
.vol-fill {
  position: absolute;
  inset: 0 auto 0 0;
  border-radius: 5px;
  background: var(--accent);
  min-width: 0;
}
/* The minimum effective dose. Inside the track's overflow, so it is clipped
   rather than floating past the end when the scale is small. */
.vol-med {
  position: absolute;
  top: 0; bottom: 0;
  width: 2px;
  margin-left: -1px;
  background: var(--ground);
  opacity: .85;
}
.vol-sub { font-size: var(--fs-sm); color: var(--ink-faint); }

.vol-row.is-none .vol-name { color: var(--ink-soft); }

.vol-detail { padding: 2px 0 12px; display: flex; flex-direction: column; gap: 8px; }
.vol-tier { display: flex; flex-direction: column; gap: 4px; }
.vol-tier-line { font-size: var(--fs-md); line-height: 1.45; color: var(--ink-soft); }
.vol-contrib-head {
  font-size: var(--fs-sm); font-weight: var(--fw-bold);
  color: var(--ink-faint);
}
.vol-contrib { display: flex; flex-direction: column; gap: 4px; }
.vol-contrib-row { display: flex; align-items: baseline; gap: 8px; font-size: var(--fs-md); }
.vol-contrib-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* "half" is the word, not a symbol — the fractional rule is unusual enough
   that a glyph would need explaining every time. */
.vol-contrib-kind { flex: none; font-size: var(--fs-xs); letter-spacing: .04em; color: var(--ink-faint); }
/* Tabular figures so the column lines up, but NOT the mono face — at 12.5px it
   renders "6.2" with enough letter-spacing to read as three separate glyphs. */
.vol-contrib-sets {
  flex: none; width: 34px; text-align: right;
  color: var(--ink-soft); font-size: var(--fs-md); font-variant-numeric: tabular-nums;
}

.vol-notes { display: flex; flex-direction: column; gap: 8px; padding-top: 4px; }

/* ================================================================== *
 * Research tab (2026-08-28)
 *
 * ⚠️ THE EIGHT SERIES COLOURS ARE NOT THE LEVEL PALETTE, on purpose. The
 * level colours mean RANK everywhere else in the app; borrowing them for
 * line IDENTITY would teach that Quads-coloured means Elite-coloured.
 * These are the dataviz reference categorical hues, machine-validated
 * 2026-08-28 against this app's dark surfaces (all four palettes, ≥3:1,
 * adjacent CVD ΔE ≥ 8.4) and white (three hues under 3:1 there — which
 * is what the labelled legend chips and the table view are for). The
 * dark set is the same hues re-stepped for dark, not a second palette.
 * ================================================================== */
:root {
  --rs-1: #3987e5; --rs-2: #d95926; --rs-3: #199e70; --rs-4: #c98500;
  --rs-5: #d55181; --rs-6: #008300; --rs-7: #9085e9; --rs-8: #e66767;
}
:root[data-theme="light"] {
  --rs-1: #2a78d6; --rs-2: #eb6834; --rs-3: #1baf7a; --rs-4: #eda100;
  --rs-5: #e87ba4; --rs-6: #008300; --rs-7: #4a3aa7; --rs-8: #e34948;
}

.research-pane { display: flex; flex-direction: column; gap: 10px; padding-bottom: 12px; }
.research-title { font-size: var(--fs-xl); margin: 2px 0 0; }
.research-sub { margin-top: -4px; }

.research-chart-host { width: 100%; }
.research-chart { display: block; touch-action: manipulation; }
.research-chart .chart-grid { stroke: var(--rule); stroke-width: 1; }
.research-chart .chart-tick { fill: var(--ink-faint); font-size: var(--fs-xs); font-variant-numeric: tabular-nums; }
.research-chart .research-ref { stroke: var(--ink-faint); opacity: .8; }
.research-chart .research-guide { stroke: var(--ink-faint); stroke-width: 1; stroke-dasharray: 2 3; }
.research-chart .research-line-label { font-size: var(--fs-sm); font-weight: var(--fw-med); }

.research-readout { min-height: 20px; font-size: var(--fs-md); line-height: 1.45; }
.research-readout-hint { color: var(--ink-faint); }

.research-legend { display: flex; flex-wrap: wrap; gap: 6px; }
.research-key { display: inline-flex; align-items: center; gap: 6px; }
/* Identity is never colour-alone: every chip carries the muscle's name, and
   the pressed state is a border change, not a repaint of the series hue. */
.research-key[aria-pressed="true"] { border-color: var(--ink); }
.research-key-ref { pointer-events: none; opacity: .8; }
.rs-dot { width: 10px; height: 10px; border-radius: 50%; flex: none; }
.rs-dot-ref {
  background: none; border-radius: 0; height: 0;
  border-top: 2px dashed var(--ink-faint); width: 14px;
}

.research-table summary { cursor: pointer; font-size: var(--fs-md); color: var(--ink-soft); padding: 4px 0; }
/* 🚨 `min-width: 0` IS WHAT MAKES THE `overflow-x` BESIDE IT DO ANYTHING —
   2026-09-10. This is a flex child of a column flex container, whose default
   `min-width: auto` refuses to shrink below its own content. So the box grew to
   the table's full 407px, its scroller had nothing to scroll, and the extra
   47px pushed the whole PANE sideways instead — which is the drag Tim reported.
   The scroller has been here since the table shipped; it has never once been
   the thing that scrolled. ⚠️ Same trap, opposite direction, as the `.seg`
   note: `min-width: auto` on a flex item is the default that surprises people
   both ways. */
.research-scroll { overflow-x: auto; min-width: 0; }
.research-table table { border-collapse: collapse; font-size: var(--fs-sm); font-variant-numeric: tabular-nums; }
.research-table th, .research-table td { padding: 4px 8px; text-align: right; white-space: nowrap; }
.research-table thead th { color: var(--ink-faint); font-weight: var(--fw-reg); border-bottom: 1px solid var(--rule); }
.research-table tbody th { text-align: left; font-family: var(--sans); font-weight: var(--fw-med); }

.research-notes { display: flex; flex-direction: column; gap: 8px; font-size: var(--fs-md); line-height: 1.55; color: var(--ink-soft); }
.research-notes p { margin: 0; }
.research-notes b { color: var(--ink); }
.research-notes a { color: var(--accent); }

/* ================================================================== *
 * The basics — the education topics (2026-08-30, Tim's ask)
 *
 * Rule 2 applies: no boxes. Topics are separated by a hairline and by
 * space, not by nested cards — eleven bordered panels would be exactly
 * the wall this content is trying not to be.
 *
 * ⚠️ THE CONFIDENCE PILL IS COLOURLESS ON PURPOSE. It carries the word
 * ("Strong evidence"), and `limited` is drawn with a DASHED border, so
 * the strongest and weakest claims on the page separate in greyscale,
 * under any colour blindness, and in a screenshot. Painting them in
 * --good / --danger is the fault the 2026-08-25 audit found when level
 * names wore the level's own colour and measured 2.83:1 on white.
 * ================================================================== */
.rt-section { display: flex; flex-direction: column; gap: 10px; }
.rt-list { display: flex; flex-direction: column; }
.rt-rule { height: 1px; background: var(--rule); margin: 6px 0 2px; }

.rt-topic { border-top: 1px solid var(--rule-soft); }
.rt-topic:last-child { border-bottom: 1px solid var(--rule-soft); }

.rt-summary {
  display: flex; align-items: center; gap: 8px; flex-wrap: wrap;
  /* 44px of target on a control somebody taps eleven times looking for the
     one they want — the 2026-08-20 audit's floor, met by padding rather
     than by a min-height so a wrapped question grows instead of clipping. */
  padding: 13px 2px; cursor: pointer; list-style: none;
}
.rt-summary::-webkit-details-marker { display: none; }
.rt-q { flex: 1 1 auto; min-width: 0; font-size: var(--fs-lg); font-weight: var(--fw-med); color: var(--ink); }
.rt-topic[open] .rt-q { color: var(--accent); }

.rt-conf {
  /* margin-left:auto so that when a long question pushes the pill onto its own
     line at 360px it right-aligns under the question rather than sitting under
     the first word looking like part of the sentence. */
  flex: none; margin-left: auto; font-size: var(--fs-xs); font-weight: var(--fw-med); letter-spacing: .02em;
  padding: 3px 7px; border-radius: 999px; white-space: nowrap;
  border: 1px solid var(--rule); color: var(--ink-soft);
}
.rt-conf-strong { border-color: var(--ink-soft); color: var(--ink); }
.rt-conf-limited { border-style: dashed; }

.rt-body {
  display: flex; flex-direction: column; gap: 9px;
  padding: 0 2px 15px; font-size: var(--fs-md); line-height: 1.55; color: var(--ink-soft);
}
.rt-lead { font-size: var(--fs-md); color: var(--ink-faint); margin-top: -4px; }
.rt-answer { margin: 0; font-size: var(--fs-md); color: var(--ink); }
.rt-points { margin: 0; padding-left: 18px; display: flex; flex-direction: column; gap: 7px; }
.rt-points li { margin: 0; }
.rt-myth { color: var(--ink); }
.rt-caveat { margin: 0; font-size: var(--fs-md); }
.rt-caveat b { color: var(--ink); }
/* ── Facets, hooks and groups — 2026-09-07, docs/research-plan.md ───────────
   The collapsed row now carries a hook under the question, so a closed list is
   worth reading rather than a column of headings. `.rt-head` is the column that
   holds both; `.rt-flags` holds the badge and the pill so they stay together
   when a long question wraps at 360px. */
.rt-head { flex: 1 1 auto; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.rt-hook { font-size: var(--fs-sm); line-height: 1.45; color: var(--ink-faint); }
.rt-topic[open] .rt-hook { display: none; }
.rt-flags { flex: none; margin-left: auto; display: flex; align-items: center; gap: 6px; }
.rt-flags .rt-conf { margin-left: 0; }

/* The disagreement badge. Solid rather than dashed, because unlike the
   confidence pill it is not a statement about strength of evidence — it says
   two sources contradict each other, which is either true or it is not. */
.rt-contested {
  flex: none; font-size: var(--fs-xs); font-weight: var(--fw-med); letter-spacing: .02em;
  padding: 3px 7px; border-radius: 999px; white-space: nowrap;
  border: 1px solid var(--accent); color: var(--accent);
}
.rt-verdict { font-size: var(--fs-md); }
.rt-verdict b { color: var(--ink); }
.rt-verdict-call { color: var(--ink); }

.rt-filters { display: flex; flex-wrap: wrap; gap: 6px; padding: 2px 0 4px; }
.rt-chip { cursor: pointer; }
/* Pressed state carries a border and a weight change, not colour alone —
   Design Rule 5, the same reason the confidence pill says its level in words. */
.chip-on { border-color: var(--ink-soft); color: var(--ink); font-weight: var(--fw-med); }

.rt-group { display: flex; flex-direction: column; }
.rt-group-head {
  margin: 14px 0 0; font-size: var(--fs-sm); font-weight: var(--fw-med);
  color: var(--ink-faint);
}
.rt-group:first-child .rt-group-head { margin-top: 2px; }
.rt-empty { padding: 10px 2px; }

.rt-src { font-size: var(--fs-sm); line-height: 1.5; color: var(--ink-faint); }
.rt-src b { color: var(--ink-soft); }
.rt-src a { color: var(--accent); }
.rt-src-n { color: var(--ink-faint); }

/* Who this screen is currently recording for, and where their suggestion came
   from. Quiet — it is context for the numbers, not a control. */
.for-note {
  font-size: var(--fs-md);
  line-height: 1.45;
  color: var(--ink-soft);
  margin-top: 2px;
}
.for-note b { color: var(--ink); font-weight: var(--fw-med); }

/* ⚠️ A QR CODE PAINTS ITS OWN WHITE GROUND, in both themes (2026-08-29). A
   light-on-dark code is legal and recent iPhones decode one, but plenty of
   Android scanners and third-party apps do not — and a code that fails on
   somebody else's phone fails at the one moment it exists for. So this is the
   one bright rectangle in dark mode, on purpose.

   ~240px rendered: at version 5 that is 45 modules across including the quiet
   zone, so ~5.3 CSS px a module — comfortably above the resolving floor for a
   camera 15-30cm away, which is where a phone held up to another phone sits. */
.qr-card {
  background: #fff;
  border-radius: 12px;
  padding: 14px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 8px;
  width: fit-content;
  margin: 0 auto 12px;
}
.qr-card svg { display: block; width: 240px; height: 240px; max-width: 100%; }
/* Named on the card so somebody holding it up can see it is the right person
   before the other phone is pointed at it. Black on the card's own white,
   never a theme token — the card is not part of the theme. */
.qr-name { color: #111; font-size: var(--fs-lg); font-weight: var(--fw-med); }

/* "Friends" beside somebody already connected, in a search result. Not a
   button: there is nothing to do, and a disabled button reads as broken. */
.row-flag {
  flex: none;
  font-size: var(--fs-md);
  font-weight: var(--fw-med);
  color: var(--ink-faint);
  padding: 0 4px;
}

/* What was just recorded, on the finish screen (2026-08-29). It replaced a
   "View this workout" button that led to a screen describing what the screen
   you were on had already summarised in one line — and left two primary-looking
   actions where there is only one thing to do next. Quiet: this is a receipt,
   not a control. */
.finish-record {
  margin-top: 16px;
  text-align: left;
  width: 100%;
  border-top: 1px solid var(--rule-soft);
}
.finish-ex { padding: 9px 0; border-bottom: 1px solid var(--rule-soft); }
.finish-ex:last-child { border-bottom: 0; }
.finish-ex-name { font-size: var(--fs-lg); font-weight: var(--fw-med); color: var(--ink); }
.finish-ex-sets {
  margin-top: 2px;
  font-variant-numeric: tabular-nums;
  font-size: var(--fs-md);
  color: var(--ink-soft);
  display: flex;
  flex-wrap: wrap;
  gap: 0 6px;
}

/* === Review 2026-09-24 · Record/runner === */
/* The open-workout screen's two in-body answers, stacked and centred. */
.conflict-actions { display: flex; flex-direction: column; align-items: center; gap: 8px; margin-top: 4px; }
/* Edit record: the minutes box, sized for "600", with its unit beside it. */
.edit-dur-row { display: flex; align-items: center; gap: 8px; }
.edit-dur { width: 6em; -moz-appearance: textfield; }
.edit-dur::-webkit-outer-spin-button, .edit-dur::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
/* An activity's time ("30:00", "1:05:00") in the stepper box: smaller type so
   the whole value fits between the ± buttons on a phone. */
.step-value.is-duration { font-size: 24px; }
.step-value.is-duration.is-long { font-size: var(--fs-xl); }
.edit-dur-unit { font-size: var(--fs-lg); font-weight: var(--fw-med); color: var(--ink-soft); }

/* === end Record/runner === */

/* === Review 2026-09-24 · Workouts === */
/* A programme's workout row: the row opens the workout, the pill beside it
   starts it. Two sibling buttons sharing one hairline row. */
.row-split { display: flex; align-items: stretch; }
.list > .row-split { padding-inline: 0; }
.row-split > .row { flex: 1; min-width: 0; padding-left: var(--pad); }
.row-go {
  flex: none; display: flex; align-items: center;
  padding: 0 var(--pad) 0 8px; min-height: 46px; background: none; color: inherit;
}
.row-go:active .row-start { transform: scale(.96); }
/* The workout checker's findings — plain lines, nothing when it finds nothing. */
.lint-notes { display: flex; flex-direction: column; gap: 4px; }
.lint-notes[hidden] { display: none; }
.lint-line.is-warn { color: var(--ink-soft); }
/* What the Added panel no longer pins, at the foot of the Explore page. */
.added-more { display: flex; flex-direction: column; gap: 8px; margin-top: 18px; }
.added-more .danger-zone { margin-top: 10px; }
/* The reps field in the builder's reps sheet: a few characters, not a line. */
.rep-row { padding-block: 6px; }
.rep-row .builder-control-label { min-width: 5.5em; }
.rep-input { width: 7.5em; flex: none; text-align: center; }
.rep-input[aria-invalid="true"] { box-shadow: inset 0 0 0 1px var(--danger); }

/* === end Workouts === */

/* === Review 2026-09-24 · Home/Friends === */

/* "2 people asked to connect" — one line at the top of Home, only when
   something waits on the Friends screen. A link, hairline below, no box. */
.feed-waiting {
  display: flex; align-items: center; gap: 9px;
  padding: 12px 0; border-bottom: 1px solid var(--rule);
  color: var(--ink); text-decoration: none;
  font-size: var(--fs-md); font-weight: var(--fw-med);
}
.feed-waiting:active { background: var(--raised); }
.feed-waiting > span:nth-child(2) { flex: 1; min-width: 0; }
.feed-waiting-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex: none; }

/* Drawn icons in the Kudos / Comment / Share row. */
.feed-act-glyph { display: inline-flex; }
.feed-act-glyph svg { display: block; }
/* The kudos pop: the thumb swells and settles as the count changes, before
   the save lands. 170ms, and off under reduced motion like everything else. */
.feed-act-glyph.is-popping { animation: kudos-pop var(--t) var(--ease-out); }
@keyframes kudos-pop {
  0%   { transform: scale(1); }
  45%  { transform: scale(1.35); }
  100% { transform: scale(1); }
}

/* One reaction inline — icon, words, and for a comment its age. Used by the
   Home strip and your own card's foot. */
.feed-mine-who { display: flex; flex-wrap: wrap; gap: 2px 14px; }
.rx-bit { display: inline-flex; align-items: baseline; gap: 5px; min-width: 0; }
.rx-bit > svg { flex: none; align-self: center; color: var(--ink-faint); }
.rx-age { font-size: var(--fs-sm); color: var(--ink-faint); white-space: nowrap; }
.comment-age { font-size: var(--fs-sm); color: var(--ink-faint); }

/* Compare: with a muscle selected, the two people's panels stack on a phone
   (dates clipped and names wrapped to three lines side by side at 393px).
   Side by side again from the 860px laptop split. */
@media (max-width: 859px) {
  .cmp-panels { grid-template-columns: 1fr; gap: 14px; }
}

/* === end Home/Friends === */

/* === Review 2026-09-24 · Data === */

/* 1. A picked muscle's details come first on a phone. Below the 860px split the
   panel under the figure is ~169px at 393x659, and the 76px level key took the top
   of it. `has-pick` is set by views-muscles.js while a muscle is open; the key moves
   under the details and comes back on top when nothing is picked. The laptop column
   (>= 860px) has room for both and is untouched. */
@media (max-width: 859px) {
  .body-foot.has-pick > .lv-key-wrap { order: 1; }
}

/* 4. The key's two notes are painted the way the FIGURE paints. "No data" is the
   figure's own grey, not the page's near-black. "Faded" is Advanced faded by the
   rule `.body-region[style*="--tint"]` uses — color-mix first, chroma-only where
   supported — at the map's floor, MIN_TINT = 0.38 (js/muscle-evidence.js; a test
   pins the two numbers together). No `opacity`: a see-through chip is not what a
   faded muscle looks like. (The older .lv-sw.lv-none / .lv-faded rules these
   replaced were deleted 2026-09-24; every swatch sits in a .lv-key-item.) */
.lv-key-item .lv-sw.lv-none { background: var(--body-none); }
.lv-key-item .lv-sw.lv-faded {
  --tint: 38%;
  --tint-n: 0.38;
  background: color-mix(in oklab, var(--lv-mix) var(--tint), var(--body-none));
}
@supports (color: oklch(from red l c h)) {
  .lv-key-item .lv-sw.lv-faded { background: oklch(from var(--lv-mix) l calc(c * var(--tint-n)) h); }
}

/* 9. "+12 lbs in 8 weeks", on the big number's own line so it moves nothing when it
   arrives. Gain green, loss red, no change neutral — the app's up/down colours. */
.muscle-trend { font-size: var(--fs-md); font-weight: var(--fw-med); color: var(--ink-soft); }
.muscle-trend.up { color: var(--good); }
.muscle-trend.down { color: var(--danger); }

/* 5. The graph's y-axis title ("lbs for 5 reps"): the tick labels' own style. */
.axis-title { letter-spacing: 0.02em; }

/* 8. An on/off switch for a yes/no setting. Label left, switch right, one row.
   Off is a quiet track; on is the accent — the chips' own "chosen" colour. */
.switch-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 36px; }
.switch {
  position: relative; flex: none;
  width: 46px; height: 28px; padding: 0;
  border: none; border-radius: 999px;
  background: var(--raised-hi);
  cursor: pointer;
  transition: background-color var(--t) var(--ease-out);
}
/* The knob crosses the track while you watch, so it eases both ends (Rule 7).
   Reduced motion is the blanket in the Motion section; no local override. */
.switch-knob {
  position: absolute; top: 3px; left: 3px;
  width: 22px; height: 22px; border-radius: 50%;
  background: var(--ink-soft);
  transition: transform var(--t) var(--ease-both), background-color var(--t) var(--ease-out);
}
.switch[aria-checked="true"] { background: var(--accent); }
.switch[aria-checked="true"] .switch-knob { transform: translateX(18px); background: var(--accent-ink); }
.switch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* === end Data === */

/* === Review 2026-09-24 · Profile/Goals/Account === */

/* The Profile stat tiles: no boxes (Rule 2). One hairline between the two
   figures does the separating (.me-stats has no gap, so it sits centred). */
.me-stat + .me-stat { border-left: 1px solid var(--rule-soft); }

/* Best-lift rows. A long name wraps instead of ending in "…", and each piece
   of the sub-line (the set, its date, days trained) stays whole — the line
   breaks between pieces, never inside "Aug 14". */
.me-best .row-title { white-space: normal; overflow: visible; line-height: 1.25; }
.me-best-bit { white-space: nowrap; }

/* === end Profile/Goals/Account === */

/* === Look pass 2026-09-24 · type, reading width, feed actions === */

/* Running text stops at a comfortable line length on a laptop. The 820–940px
   column is right for lists and charts and too long for a paragraph. Phone
   widths never reach 68ch, so this only acts from the laptop split. A
   `.field-help` centred inline (the account screens) is left alone: a capped
   centred line would sit off to the left. */
@media (min-width: 860px) {
  .preset-notes p, .note, .field-help:not([style*="text-align"]), .feed-note, .ws-note,
  .goal-intro-body, .goal-verdict-body, .update-note, .preset-warning,
  .vol-tier-line, .research-notes, .rt-body { max-width: 68ch; }
  /* ⚠️ A paragraph that is itself a direct child of a pane would be centred by
     the column's `margin-inline: auto` once it is narrower than the column. It
     keeps the column's LEFT edge instead: the left margin is half the space the
     820px column leaves, the right margin takes the rest. */
  :is(.pane-top, .pane-scroll, .pane-bottom) > :is(.note, .field-help, .update-note,
    .preset-warning, .goal-intro-body, .goal-verdict-body, .research-notes, .rt-body) {
    margin-inline: max(0px, (100% - 820px) / 2) auto;
  }

  /* Kudos / Comment / Share stay together under the card on a laptop, instead
     of three thirds spread across 940px. A fixed width each, so a count
     appearing on Kudos moves nothing beside it. */
  .feed-actions { justify-content: flex-start; }
  .feed-act { flex: 0 0 120px; }
}
@media (min-width: 1200px) {
  :is(.pane-top, .pane-scroll, .pane-bottom) > :is(.note, .field-help, .update-note,
    .preset-warning, .goal-intro-body, .goal-verdict-body, .research-notes, .rt-body) {
    margin-inline: max(0px, (100% - 940px) / 2) auto;
  }
}

/* === end Look pass === */

/* === Tour === */

/* The first-run tour (js/tour.js), 2026-09-25. One fixed layer over the whole
   app, so opening it moves and resizes nothing on the page. The dim IS the
   hole's shadow: one element, so the hole can glide without the full dim ever
   flashing between stops. The whole layer fades in and out (--t); the hole
   glides at --t-slow with --ease-both (an object crossing the screen, Rule 7);
   the bubble fades and slides 8px toward its target. Reduced motion is the
   blanket in the Motion section. */
.tour {
  position: fixed; inset: 0; z-index: 95;
  opacity: 0;
  transition: opacity var(--t) var(--ease-out);
}
.tour.is-on { opacity: 1; }
.tour.is-leaving { opacity: 0; transition-timing-function: var(--ease-in); pointer-events: none; }
.tour-block { position: absolute; inset: 0; }
.tour-spot {
  position: absolute; left: 0; top: 0;
  border-radius: var(--r);
  box-shadow: 0 0 0 150vmax rgba(0, 0, 0, .55);
  pointer-events: none;
  transition: transform var(--t-slow) var(--ease-both),
              width var(--t-slow) var(--ease-both),
              height var(--t-slow) var(--ease-both);
}
.tour-bubble {
  --slide: 8px;
  position: absolute; left: 0; top: 0;
  padding: 12px 13px 10px;
  border-radius: var(--r);
  background: var(--raised);
  border: 1px solid var(--rule);
  box-shadow: 0 8px 28px rgba(0, 0, 0, .45);
  color: var(--ink);
  opacity: 0;
  transform: translateY(var(--slide));
  pointer-events: none;
  transition: opacity var(--t) var(--ease-out), transform var(--t) var(--ease-out);
}
.tour-bubble[data-side="above"] { --slide: -8px; }
.tour-bubble.is-shown { opacity: 1; transform: none; pointer-events: auto; }
.tour-instant .tour-spot, .tour-instant .tour-bubble { transition: none; }
.tour-arrow {
  position: absolute; width: 12px; height: 12px; margin-left: -6px;
  background: var(--raised);
  border: 1px solid var(--rule);
  transform: rotate(45deg);
}
.tour-bubble[data-side="below"] .tour-arrow { top: -7px; border-right: 0; border-bottom: 0; }
.tour-bubble[data-side="above"] .tour-arrow { bottom: -7px; border-left: 0; border-top: 0; }
.tour-bubble[data-side="over"] .tour-arrow,
.tour-bubble[data-side="center"] .tour-arrow { display: none; }
.tour-title { font-size: var(--fs-xl); font-weight: var(--fw-bold); margin-bottom: 4px; }
.tour-text { margin: 0 0 10px; font-size: var(--fs-lg); line-height: 1.4; }
.tour-foot { display: flex; align-items: center; gap: 8px; }
.tour-count { flex: 1; font-size: var(--fs-sm); color: var(--ink-faint); font-variant-numeric: tabular-nums; }
.tour-skip {
  min-height: 34px; padding: 0 6px;
  background: none; border: 0;
  color: var(--ink-soft); font-size: var(--fs-md);
  cursor: pointer;
}
.tour-foot .btn { min-width: 64px; }
.tour-bubble[data-side="center"] .tour-foot .btn.primary { margin-left: auto; }
.tour button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.tour [hidden] { display: none; }   /* `.btn` is inline-flex, which beats the UA's [hidden] */

/* === end Tour === */

/* === Onboarding === */
/* The first-run questions (js/onboarding.js, 2026-09-25). Phone: the whole
   screen. Laptop: a centred panel over the dimmed app. One question per screen;
   screens slide sideways because this is a numbered sequence and the direction
   is known (Rule 7 forbids it for routes, where it is not). The two screens
   cross at once, so --ease-both. The reduced-motion blanket makes it instant.
   Above sheets (60), under toasts (90) and the tour (95). */
.ob-overlay {
  position: fixed; inset: 0 0 var(--kb, 0px) 0; z-index: 80;
  background: var(--ground);
  display: flex; justify-content: center; align-items: stretch;
  animation: fade-in var(--t) var(--ease-out);
}
.ob-overlay.is-leaving { animation: fade-out var(--t) var(--ease-in) forwards; pointer-events: none; }
.ob-panel {
  width: 100%; min-height: 0;
  display: flex; flex-direction: column;
  padding: env(safe-area-inset-top, 0px) 0 var(--safe-b);
  background: var(--ground);
}
.ob-head { flex: none; display: flex; align-items: center; gap: 12px; padding: 8px 10px 8px 6px; }
.ob-progress { flex: 1; display: flex; gap: 4px; }
.ob-seg { flex: 1; height: 4px; border-radius: 2px; background: var(--raised-hi); transition: background-color var(--t) var(--ease-out); }
.ob-seg.is-done { background: var(--accent); }
.ob-skip { flex: none; }
/* Screens are stacked absolutely in a clipped stage, so the one arriving and
   the one leaving share the space and nothing below them moves. */
.ob-stage { position: relative; flex: 1; min-height: 0; overflow: hidden; }
.ob-screen {
  position: absolute; inset: 0;
  display: flex; flex-direction: column; gap: 10px;
  padding: 12px var(--pad) var(--pad);
  overflow-x: hidden; overflow-y: auto;
  background: var(--ground);
}
.ob-q { margin: 8px 0 6px; font-size: 24px; font-weight: var(--fw-bold); letter-spacing: -0.02em; line-height: 1.2; outline: none; }
.ob-sub { margin: -6px 0 4px; font-size: var(--fs-md); color: var(--ink-faint); }
.ob-choices { display: flex; flex-direction: column; gap: 10px; }
.ob-grid { display: grid; grid-template-columns: 1fr 1fr; }
.ob-choice[aria-pressed="true"] { background: var(--accent-dim); color: var(--accent); box-shadow: inset 0 0 0 1.5px var(--accent); }
.ob-foot { margin-top: auto; padding-top: 12px; display: flex; flex-direction: column; gap: 12px; }
.ob-building { align-items: center; justify-content: center; }
.ob-building-text { margin: 0; font-size: var(--fs-xl); font-weight: var(--fw-med); color: var(--ink-soft); animation: fade-in var(--t) var(--ease-out); }
.ob-days { list-style: none; margin: 0; padding: 0; }
.ob-day { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; padding: 12px 0; border-top: 1px solid var(--rule); font-size: var(--fs-lg); }
.ob-day:last-child { border-bottom: 1px solid var(--rule); }
.ob-day-name { font-weight: var(--fw-med); }
.ob-day-count { font-size: var(--fs-md); color: var(--ink-faint); font-variant-numeric: tabular-nums; }
.ob-alt { margin: 0; text-align: center; font-size: var(--fs-md); color: var(--ink-soft); }
.ob-alt a { color: var(--accent); }
.ob-in-fwd  { animation: ob-in-fwd  var(--t-slow) var(--ease-both); }
.ob-in-back { animation: ob-in-back var(--t-slow) var(--ease-both); }
.ob-out-fwd  { animation: ob-out-fwd  var(--t-slow) var(--ease-both) forwards; pointer-events: none; }
.ob-out-back { animation: ob-out-back var(--t-slow) var(--ease-both) forwards; pointer-events: none; }
@keyframes ob-in-fwd   { from { transform: translateX(100%) } }
@keyframes ob-in-back  { from { transform: translateX(-100%) } }
@keyframes ob-out-fwd  { to { transform: translateX(-100%) } }
@keyframes ob-out-back { to { transform: translateX(100%) } }
@media (min-width: 860px) {
  .ob-overlay { background: rgba(0,0,0,.55); align-items: center; padding: 24px; }
  .ob-panel { max-width: 520px; height: min(680px, 100%); border-radius: 16px; overflow: hidden; padding: 6px 8px 10px; }
}
/* === end Onboarding === */

/* === Photos === */
/* The workout photo on a card or detail screen. The box has the photo's own
   shape (set inline from its {w,h}, clamped 4:5..1.91:1) before the picture
   arrives, so nothing below it moves when it lands. */
.card-photo {
  width: 100%;
  max-height: min(72dvh, 620px);
  overflow: hidden;
  border-radius: var(--r);
  background: var(--raised);
}
.card-photo[hidden] { display: none; }
.card-photo-img {
  display: block; width: 100%; height: 100%;
  object-fit: cover;
  opacity: 0;
  transition: opacity var(--t-slow) var(--ease-out);
}
.card-photo.is-loaded .card-photo-img { opacity: 1; }
.card .card-photo { margin-top: 10px; }

/* The Photo field on the save and edit screens. */
.photo-field-body { display: flex; align-items: center; gap: 12px; }
.photo-thumb {
  width: 96px; height: 96px; flex: none;
  border-radius: var(--r); overflow: hidden; background: var(--raised);
}
.photo-thumb img { display: block; width: 100%; height: 100%; object-fit: cover; }
.photo-field-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.photo-field-busy { margin: 0; }
@media (prefers-reduced-motion: reduce) {
  .card-photo-img { transition: none; }
}
/* === end Photos === */

/* === Polish · depth and colour === */
/* Pass V of docs/polish-plan.md, 2026-09-25. Tim: *"Some cites have really good
   shading, reflection graphics, shining … good coloring."* Layout, wording and
   the level colours are untouched; only light and depth are added.

   THE RULES THIS SECTION KEEPS
   - No bordered cards (Rule 2). Depth comes from shadow plus a 1px lighter top
     edge — the "reflection" — from the --elev-* tokens at the top of the file:
       3  sheets, popovers, toasts, the laptop questions panel
       2  primary buttons and the Record button
       1  ordinary raised buttons, the workout-in-progress strip
     Rows in a list stay flat: a shadow per row is a phone cost for nothing.
   - A tint only ever moves a surface AWAY from the text on it, so every
     contrast pair tests/a11y.test.mjs pins can only go up. Dark themes darken
     toward the bottom; light themes lighten toward the top. Same for the accent:
     --accent-top / --accent-bot never read below the flat accent.
   - No backdrop-filter anywhere. The tab bar and the sidebar are siblings of
     the screen, not layers over it — nothing scrolls under them — so a blur
     would blur the plain ground and cost a composited layer for nothing.
   - Nothing here animates; motion belongs to the Motion section.
   - `:where()` on the broad rules, so specificity is zero and every existing
     variant (.ghost, .danger, .is-linked, a focus ring…) still wins over them. */

/* ── Raised surfaces: lit from above, a hairline of light on the top edge ── */
:where(.btn) { box-shadow: var(--elev-1); }
:where(.chip) { box-shadow: inset 0 1px 0 var(--hl); }
/* 🚨 NOT `:where()` FOR THE IMAGE (fixed 2026-09-25, motion pass 2). `.btn` and
   `.chip` set their colour with the `background:` SHORTHAND, which resets
   background-image to none — and at zero specificity the gradient lost, so it
   never rendered anywhere (computed background-image: none, measured). At one
   class, later in the file, it wins over the shorthand; every variant that
   paints its own background (.ghost, .danger, .primary, .is-linked, a pressed
   chip) is two classes and still wins over this. */
.btn { background-image: var(--lit); }
.chip { background-image: var(--lit); }
:where(.input) { box-shadow: var(--recess); }
.session-mini { background-image: var(--lit); box-shadow: inset 0 1px 0 var(--hl); }

/* ── Primary buttons: the accent as two stops, a gloss line, pressed = flat ── */
.btn.primary,
.btn.preset[aria-pressed="true"],
.chip[aria-pressed="true"],
.mini-arrow {
  background-color: var(--accent);
  background-image: linear-gradient(180deg, var(--accent-top), var(--accent-bot));
}
.btn.primary {
  box-shadow: inset 0 1px 0 var(--gloss),
              0 1px 2px rgb(var(--shadow-rgb) / .30),
              0 6px 16px -8px color-mix(in srgb, var(--accent) 60%, transparent);
}
.btn.preset[aria-pressed="true"],
.chip[aria-pressed="true"],
.mini-arrow { box-shadow: inset 0 1px 0 var(--gloss); }
/* Pressed: the gloss goes and the top edge shades in — darker, never smaller. */
.btn.primary:active,
.btn.preset[aria-pressed="true"]:active,
.chip[aria-pressed="true"]:active {
  background-image: none;
  background-color: var(--accent-press);
  box-shadow: inset 0 2px 4px rgb(var(--shadow-rgb) / .35);
}
/* The green Finish button is the other filled call to action. Its ink is dark
   in both themes, so its second stop is lighter in both. */
.btn.good {
  background-image: linear-gradient(180deg, color-mix(in srgb, var(--good) 88%, #fff), var(--good));
  box-shadow: inset 0 1px 0 var(--gloss),
              0 1px 2px rgb(var(--shadow-rgb) / .30),
              0 6px 16px -8px color-mix(in srgb, var(--good) 60%, transparent);
}
.btn.good:active { background-image: none; box-shadow: inset 0 2px 4px rgb(var(--shadow-rgb) / .35); }
/* ⚠️ LIGHT: the dark ink measured 3.39:1 on this green (AA wants 4.5). The
   green stays; the ink goes white (5.6:1 on --good) and, like the light accent,
   the second stop goes DARKER so no part of the button reads below that. */
:root[data-theme="light"] .btn.good {
  color: #FFFFFF;
  background-image: linear-gradient(180deg, var(--good), color-mix(in srgb, var(--good) 86%, #000));
}
:root[data-theme="light"] .btn.good:active {
  background-image: none;
  background-color: color-mix(in srgb, var(--good) 84%, #000);
}
/* Linked is a quiet, not a filled, state — no gloss, no lift. */
.btn.primary.is-linked { box-shadow: none; }

/* ── The Record button: a crisp ring and a sheen. Static — no loop. ── */
/* Pass 2: the 3px 14% accent wash plus an accent-tinted blur read as a beige
   smudge in light and a muddy ring in dark (measured on screenshots). Now a
   hairline gap in the bar's own colour, one crisp 1px accent ring, and a small
   neutral shadow underneath — a halo with an edge. */
.navbar a.nav-primary svg,
.navbar a.nav-primary[aria-current="page"] svg {
  background-color: var(--accent);
  background-image:
    radial-gradient(90% 70% at 32% 18%, rgb(255 255 255 / .22), rgb(255 255 255 / 0) 60%),
    linear-gradient(180deg, var(--accent-top), var(--accent-bot));
  box-shadow: inset 0 1px 0 var(--gloss),
              0 0 0 2px var(--surface),
              0 0 0 3px color-mix(in srgb, var(--accent) 55%, transparent),
              0 2px 5px 1px rgb(var(--shadow-rgb) / .30);
}
:root[data-theme="light"] .navbar a.nav-primary svg {
  box-shadow: inset 0 1px 0 var(--gloss),
              0 0 0 2px var(--surface),
              0 0 0 3px color-mix(in srgb, var(--accent) 45%, transparent),
              0 2px 4px 1px rgb(var(--shadow-rgb) / .14);
}

/* ── Tab bar (phone) / sidebar (laptop) ── */
.navbar { background-image: var(--lit); box-shadow: inset 0 1px 0 var(--hl); }
@media (min-width: 860px) {
  /* The sidebar keeps the plain --surface it had before pass 1: lit, it went
     cream in light for no gain. The selected item is its --accent-dim fill and
     a hairline of light — no outline, no glow. */
  .navbar { background-image: none; box-shadow: none; }
  .navbar a[aria-current="page"] { box-shadow: inset 0 1px 0 var(--hl); }
}

/* ── Selected states: a raised pill in a recessed track, with a faint glow ── */
.segmented { box-shadow: var(--recess); }
.seg-ind,
.segmented:not(.has-ind) .seg[aria-selected="true"] {
  box-shadow: inset 0 1px 0 var(--hl),
              inset 0 0 10px -5px color-mix(in srgb, var(--accent) var(--glow-a), transparent),
              0 1px 3px rgb(var(--shadow-rgb) / .35);
}
/* ⚠️ DARK: the pill is painted --ground, which in the dark themes is DARKER
   than the --raised track — with a drop shadow that read as a hole, not a
   raised pill. Dark gets the step above the track and a real top highlight;
   light is untouched (its --ground is already lighter than its track). */
:root:not([data-theme="light"]) .seg-ind,
:root:not([data-theme="light"]) .segmented:not(.has-ind) .seg[aria-selected="true"] {
  background-color: var(--raised-hi);
  box-shadow: inset 0 1px 0 rgb(255 255 255 / .09),
              0 1px 2px rgb(var(--shadow-rgb) / .45),
              0 2px 6px -2px rgb(var(--shadow-rgb) / .40);
}
.ob-choice[aria-pressed="true"] {
  box-shadow: inset 0 0 0 1.5px var(--accent),
              inset 0 0 18px -6px color-mix(in srgb, var(--accent) 55%, transparent);
}

/* ── Floating surfaces: the highest lift ── */
.sheet, .sheet-x {
  background-image: var(--lit);
  box-shadow: inset 0 1px 0 var(--hl), 0 -12px 36px -12px rgb(var(--shadow-rgb) / .55);
}
.help-pop, .tour-bubble, .toast, .toast-x { box-shadow: var(--elev-3); }
@media (min-width: 860px) {
  .ob-panel { box-shadow: var(--elev-3); }
}

/* ── The line chart: the area under the line fades out downward ── */
/* The markup draws one flat-filled path; a mask turns it into a gradient
   without touching the chart code. ⚠️ MEASURED 2026-09-25: WebKit (so every
   iPhone) computes the mask and does not paint it on an SVG shape, so there
   the fill stays exactly the flat .09 it always was — which is why the opacity
   is NOT raised here. A real gradient on the phone needs a <linearGradient> in
   the chart's own markup (views-data.js). */
.chart .series-area {
  -webkit-mask-image: linear-gradient(180deg, #000, transparent);
  mask-image: linear-gradient(180deg, #000, transparent);
}

/* The Compare sheet's people are links (a pick navigates), and the UA was
   underlining every name and summary line. They are rows, not prose links. */
a.pick-row { text-decoration: none; }
/* === end Polish · depth and colour === */

/* === Motion === */
/* 2026-09-25, docs/polish-plan.md part M. Tim: "…shining, smooth and creative
   annimation…". js/motion.js decides WHEN (first paint of a visit only, never
   the logging path); this says HOW. Rule 7 holds: everyday motion is on the
   three tokens; the one longer duration is the celebration tier below, pinned
   by name in tests/a11y.test.mjs.
   Pass 2 (docs/motion2-plan.md, Tim: "Put professional level annimation and
   physics into this cite"): rows now arrive on springs (js/spring.js), which
   write inline transform/opacity — so nothing here may TRANSITION a row's
   transform, or the browser would smear every frame the spring writes. */
:root {
  /* A real win only — a new personal best, a workout saved, a goal reached. */
  --t-celebrate: 600ms;
}

/* ---- rows arrive on a spring after the screen (js/motion.js staggerIn) ---- */
/* While a spring owns the element, no CSS transition may chase its writes. */
.m-arriving { transition: none !important; }

/* ---- a bar fills to its number on first paint (Rule 7's width exception) ---- */
.m-fill { animation: m-grow var(--t-slow) var(--ease-out) both; }
@keyframes m-grow { from { width: 0; } }

/* Onboarding's progress: the next segment fills left to right rather than
   fading. A cover in the empty colour shrinks toward the right edge and
   reveals the segment's own done colour, so the colour stays the section
   that owns it. */
.ob-seg { position: relative; overflow: hidden; transition: none; }
.ob-seg::after {
  content: ""; position: absolute; inset: 0;
  background: var(--raised-hi);
  transform-origin: right center;
  transition: transform var(--t) var(--ease-out);
}
.ob-seg.is-done::after { transform: scaleX(0); }

/* ---- counted numbers keep one width while they count ---- */
.me-stat-n, .feed-stat-value, .summary-grid .stat-value { font-variant-numeric: tabular-nums; }

/* ---- a press answers back ---- */
/* A BUTTON gives a little (.97) and goes DARKER, never brighter: filled ones
   to their pressed stop (Polish · depth), raised ones to --raised-hi in both
   themes. A ROW only changes its background — its text, chevron and divider
   never move under a finger (Tim's rule: content never moves on tap).
   ⚠️ iOS Safari only fires :active at all when the page has a touchstart
   listener; app.js adds a passive no-op one at boot for exactly this. */
.btn {
  transition: transform var(--t-fast) var(--ease-out), background-color var(--t-fast) linear,
              box-shadow var(--t-fast) linear;
}
.btn:active { transform: scale(.97); }
.btn:not(.primary):not(.good):not(.ghost):not(.danger):not([aria-pressed="true"]):active {
  background-image: none;
  background-color: var(--raised-hi);
  box-shadow: var(--recess);
}
.row, .vol-row { transition: background-color var(--t-fast) linear, color var(--t-fast) linear; }
.row:active, .vol-row:active, .row.static:active { transform: none; }

/* ---- the tab you just chose gives a small pop ---- */
.navbar a.m-tabbed svg { animation: m-tab-pop var(--t) var(--ease-out); }
@keyframes m-tab-pop {
  0%   { transform: scale(1); }
  40%  { transform: scale(1.12); }
  100% { transform: scale(1); }
}

/* ---- the celebration tier: a specular shine crosses it, it pops ---- */
/* Pass 2 rebuild. The pop is a real spring now (js/motion.js, `bounce`, peak
   1.06, one wobble), so there is no pop keyframe here. The shine is a band of
   white light on a brief wash of the win colour (--good), which is what makes
   it visible on a white light-theme row as well as a dark one; the old accent
   band over a green check read as a khaki smear.
   ⚠️ NO `overflow: hidden` ON THE HOST, which clipped popovers and shadows for
   as long as it played. The band is moved INSIDE the pseudo-element by
   background-position, so the pseudo-element's own box (inset 0, the host's
   radius) is the clip and the host is left alone. A static host is given
   `position: relative` for the length of it (.m-celebrate-pos), nothing else. */
.m-celebrate-pos { position: relative; }
.m-celebrate { transition: none !important; }
.m-celebrate::after {
  content: ""; position: absolute; inset: 0; pointer-events: none; z-index: 1;
  border-radius: inherit;
  background-color: color-mix(in srgb, var(--good) 10%, transparent);
  background-image: linear-gradient(105deg, transparent 42%, rgb(255 255 255 / .10) 46%,
    rgb(255 255 255 / .30) 50%, rgb(255 255 255 / .10) 54%, transparent 58%);
  background-size: 300% 100%;
  background-repeat: no-repeat;
  animation: m-shine var(--t-celebrate) var(--ease-both) both;
}
:root[data-theme="light"] .m-celebrate::after {
  background-color: color-mix(in srgb, var(--good) 13%, transparent);
  background-image: linear-gradient(105deg, transparent 42%, rgb(255 255 255 / .35) 46%,
    rgb(255 255 255 / .85) 50%, rgb(255 255 255 / .35) 54%, transparent 58%);
}
@keyframes m-shine {
  0%   { background-position: 100% 0; opacity: 0; }
  15%  { opacity: 1; }
  75%  { opacity: 1; }
  100% { background-position: 0% 0; opacity: 0; }
}

/* The blanket at the end of the Motion rules cuts durations; a DELAY it
   leaves alone. So under reduced motion these are off outright, not just
   fast. (The springs in js/spring.js land at once under reduced motion.) */
@media (prefers-reduced-motion: reduce) {
  .m-fill, .m-celebrate::after, .navbar a.m-tabbed svg { animation: none !important; }
  .m-celebrate::after { display: none; }
}
/* === end Motion === */

/* === Motion 2 · Layout === */
/* docs/motion2-plan.md, "F · Layout". Measured at 393×659 (WebKit, iPhone) and
   1440×900, demo account. Layout only: no wording, no colours, no features.
   Everything here is either a phone rule, a laptop rule (≥860px) or both, and
   says which. */

/* ── ? dots sit next to their words (both widths) ─────────────────────────
   Tim, 2026-09-08: the ? "should be right next to the … text, not on the right
   side". Labels already did this; SENTENCES still took `flex: 1`, so on a
   laptop (text capped at 68ch) the dot floated 200px right of a short line —
   "Indirect work counts half a set.  ………  ?". `0 1 auto` sizes the sentence to
   its words, so a one-line sentence has its dot beside it and a wrapped one has
   it at the end of its own measure, never out at the column edge.
   The dot's -9px side margins (they cancel the 44px halo) put its PAINT 9px
   past the end of the line — measured 7px from the phone's edge in Settings —
   and 1px from a label's last letter. Inside a help line the dot keeps the
   vertical cancel and takes the line's own gap instead. */
.help-line > .field-help,
.help-line > .note,
.help-line > .goal-verdict-body { flex: 0 1 auto; }
.help-line > .help-dot { margin-inline: -4px 0; }

/* ── Workouts: the rating belongs to the notes column (laptop) ────────────
   Measured at 1440: notes capped at 68ch on the left, the four rating cells at
   x≈1200 on the far right, 700px from the heading they belong to. The block now
   keeps the notes' measure, and on a laptop the cells sit in one row of four
   beside the heading — the 2×2 grid exists for a 390px phone (see ratingBadge),
   and at this width it only left a 70px hole under the heading. */
@media (min-width: 860px) {
  .own-rating { max-width: 510px; }
  .own-rating .rating { grid-template-columns: repeat(4, minmax(46px, auto)); }
}
/* A direct child of the pane would be centred by the column's auto margins once
   narrower than it; it keeps the column's LEFT edge instead, like the notes. */
@media (min-width: 860px) {
  .pane-scroll > .own-rating { margin-inline: max(0px, (100% - 820px) / 2) auto; }
}
@media (min-width: 1200px) {
  .pane-scroll > .own-rating { margin-inline: max(0px, (100% - 940px) / 2) auto; }
}

/* ── Graph (phone): markers that stop colliding ───────────────────────────
   52 measured points across a 365px chart are 5px apart (median, measured), and
   8px rings overlapped into a chain. Rule 5 says a marker means measured, so
   they are NOT hidden — they get smaller when the chart is dense. The last point
   keeps its size: it is "now". Laptop widths have room and are untouched. */
/* 🔄 2026-09-25: superseded — thinMarkers() now hides a marker within 12px of a
   drawn one, so the markers left are all full size and never touch (Data section). */

/* ── Nested lists line up with their headings (laptop) ────────────────────
   A `.list` bleeds out by the gutter on a phone so its hairlines reach the
   edge; on a laptop only a list that is a DIRECT child of the pane gets that
   undone. Goals' lists are nested, so "What this asks of you" rows started at
   x=228 under a heading at x=250 and ran 44px wider than the column. */
@media (min-width: 860px) {
  .goal-screen .list, .goal-measured .list { margin-inline: 0; }
}

/* ── Goals: the number beside the label, the sentence under both ──────────
   A requirement row was [label + sentence + source | value], so every sentence
   wrapped inside 290px of a 365px phone while the value column held 70px of
   nothing under "7–10". The value now sits on the label's line and the
   sentence takes the full width. Same words, same order, fewer lines. */
.req-row, .stall-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  gap: 1px 10px; /* .row's 10px gap would open between every line */
  align-items: baseline;
}
.req-row > .row-main, .stall-row > .row-main { display: contents; }
.req-row > .row-main > *, .stall-row > .row-main > * { grid-column: 1 / -1; }
.req-row > .row-main > .row-title, .stall-row > .row-main > .row-title { grid-column: 1; grid-row: 1; }
.req-row > .req-value, .stall-row > .req-value { grid-column: 2; grid-row: 1; align-self: baseline; min-width: 0; }
.req-row .row-sub, .stall-row .row-sub { margin-top: 1px; }

/* ── Workouts (laptop): the split rows keep the column's edges ───────────
   On a laptop every list row drops its inner gutter (`.list > *`), but a
   programme's workout row is two buttons inside one `.row-split`, and each kept
   the phone's --pad — so "Push" sat 22px right of "3 workouts" above it and the
   Start pills 22px left of "Switch". */
@media (min-width: 860px) {
  .row-split > .row { padding-left: 0; }
  .row-go { padding-right: 0; }
}

/* ── Profile (phone): the head block, tighter ─────────────────────────────
   Face, name, two stat tiles and the friends note took ~195px before the first
   section. The tiles now read "209 Workouts" on one line — a 44px row rather
   than a 71px one — which is the same figure and word, side by side. */
@media (max-width: 859px) {
  .me-stat { flex-direction: row; align-items: baseline; justify-content: center; gap: 6px; padding: 10px 4px; }
  .me-body { gap: 14px; }
}

/* ── Profile (laptop): two columns ────────────────────────────────────────
   One 940px column ran 1500px down: the best lifts, the goal and the calendar
   stacked under each other with 500px of empty width beside every one.
   🔄 Layout pass 2, 2026-09-25: from 1024 (was 1200 — at 1024 it was one 780px
   column scrolling 661/1474), and the two columns are two STACKS rather than
   shared rows: face over best lifts on the left; the figures, the note about
   them, body details, the goal and the calendar on the right. Shared rows left
   a friend's profile with a 130px empty band (their stats row sat beside their
   face and the notes under both), and put the Close-Grip row 49px under the
   fold at 1440. The best lifts span down to a last `1fr` row — the Goals trick
   (see "Goals and a friend's workout") — so the right column's rows are only as
   tall as their own contents. Spacing is margins, as there. */
@media (min-width: 1024px) {
  .pane-scroll > .me-body { max-width: 1180px; }
  .me-body {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    grid-template-rows: repeat(12, auto) 1fr;
    column-gap: 48px; row-gap: 0;
    align-items: start;
  }
  .me-body > *, .me-body > .me-slot > * { grid-column: 2; margin-top: 14px; }
  .me-body > .me-slot { margin-top: 0; }
  .me-body > .me-head { grid-column: 1; grid-row: 1; align-self: center; margin-top: 0; }
  .me-body > .me-stats { grid-column: 2; grid-row: 1; align-self: center; margin-top: 0; }
  .me-body > .me-bests, .me-body > .me-slot > .me-bests { grid-column: 1; grid-row: 2 / -1; }
  /* 🆕 2026-09-25: the calendar column scrolls on its own. Months lands on the
     current month (Tim, 09-12); with the whole pane as the scroller that
     landing took the left column 6,000px out of view and left ~600px empty
     beside one month. Now only this column moves (views-data.js scrollerOf). */
  .me-body > .me-cal .me-cal-host, .me-body > .me-slot > .me-cal .me-cal-host {
    max-height: calc(100dvh - 140px);
    overflow-y: auto;
    overflow-x: hidden;
  }
}
@media (min-width: 1024px) and (max-width: 1199px) {
  .me-body { column-gap: 32px; }
}
/* A best lift that opens nothing (an estimate never recorded) has no chevron,
   so its badge sat 27px right of every other one in the list (x796 vs x769 at
   1440). It keeps the chevron's width, empty — only in a list that has
   chevrons, so a friend's list (none open) stays flush. Both widths. */
.list:has(> .me-best > .row-chev) > .me-best:not(:has(> .row-chev))::after {
  content: ''; flex: none; width: 17px;
}

/* ── Calendar (laptop): use the width ─────────────────────────────────────
   The Years grid is width-bound (53 square columns), so a wider column is the
   only CSS lever on how much of the pane it fills: 940 → 1180 makes every
   square 17px → 21px. Months and Years widen together, so switching between
   them moves nothing. Month names and day letters were 9px on a laptop. */
@media (min-width: 1200px) {
  .pane-top:has(> .cal-modes) > *, .pane-top:has(> .cal-modes) ~ .pane-scroll > * { max-width: 1180px; }
  .yr-month, .yr-dows { font-size: var(--fs-xs); }
  .yr-grid { gap: 2px; }
}

/* ── Settings (laptop): the switch stays with its words ───────────────────
   Labels and sentences stop at 68ch; the switches sat at the column's right
   edge, 700px from "Rest timer". The settings fields keep the text's measure. */
@media (min-width: 860px) {
  .pane-scroll:has(.switch-row) > .field { max-width: 510px; margin-inline: max(0px, (100% - 820px) / 2) auto; }
}
@media (min-width: 1200px) {
  .pane-scroll:has(.switch-row) > .field { margin-inline: max(0px, (100% - 940px) / 2) auto; }
}

/* ── Runner (laptop only — the phone runner is untouched) ─────────────────
   Two steppers split 940px, so "180" sat 180px from its − and +. They keep a
   thumb-sized width and sit together in the middle. The person chips and the
   progress dashes ran window-wide at x=22 while everything else starts at the
   content column; they now share its edges. WebKit's number spinners were
   showing inside the big value on a laptop — the ± buttons are the control. */
@media (min-width: 860px) {
  .set-open .steppers { grid-template-columns: repeat(auto-fit, minmax(200px, 340px)); justify-content: center; column-gap: 40px; }
  .step-value::-webkit-inner-spin-button, .step-value::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
  .people-bar, .session-progress { padding-inline: max(var(--pad), (100% - 820px) / 2); }
}
@media (min-width: 1200px) {
  .people-bar, .session-progress { padding-inline: max(var(--pad), (100% - 940px) / 2); }
}
/* ── Goals: the section heads were spaced like chapter breaks ────────────
   `.section-head` carries 18px above it, and inside the goal screen that sat on
   top of the column's own 16px gap: 34px of nothing over "What this asks of
   you" on a screen that already scrolls three phone-heights. */
.goal-screen .section-head { margin-top: 6px; }

/* ── Best lifts: a piece of the sub-line never crosses into the numbers ────
   Each piece is kept whole (`.me-best-bit`, nowrap), but a piece wider than the
   whole column — "Estimated from Triceps Pushdown, Overhead Press", "Hammer Curl
   35 lbs/side × 12" — then ran up to 150px past it, under the figures (measured
   at 393px). As an inline-block capped at the column, a piece still moves to
   the next line whole when it fits there, and breaks inside itself only when
   no line could hold it. */
.me-best-bit { display: inline-block; max-width: 100%; white-space: normal; }
.me-best > .row-main { min-width: 0; }

/* ── Superset line inside a workout (laptop) ──────────────────────────────
   `.builder-group > .list` bleeds out by the phone gutter so its rows' padding
   clears the group's line. On a laptop `.list > *` drops that padding, so the
   2px line at x=250 ran through names starting at x=241 (measured, 1440). The
   bleed goes with the padding it existed for. */
@media (min-width: 860px) {
  .builder-group > .list { margin-inline: 0; }
}

/* ══ Layout pass 2 (2026-09-25, laptop review) ══════════════════════════════
   Measured at 1440/1280/1024 in WebKit and Chrome, demo account. The rule for
   widths from here: a single-column reading screen is 940 centred in the
   content area; a two-pane screen is 1180 centred (Muscles and Volume keep
   their 1280 cap — Tim asked for that panel to reach the wall); and everything
   on one screen shares one right edge. */

/* ── Volume (laptop): the list beside the figure, like Muscles ────────────
   The per-muscle list started 1,081px down at 1440 — under the fold — with
   250px of nothing either side of the bodies. `.vol-side` (views-data.js) is
   the same column the Muscles panel is: same widths at the same breakpoints,
   the same hairline, and it scrolls on its own so the page does not. The
   figure takes the rest of the height, so it is the size it was. On a phone
   the column does not exist (`display: contents`) and nothing moves. */
.vol-side { display: contents; }
@media (min-width: 860px) {
  .graph-host.is-volume > .vol-pane {
    flex: 1; min-height: 0;
    display: grid;
    grid-template-columns: minmax(0, 1fr) clamp(260px, 32%, 340px);
    grid-template-rows: auto minmax(0, 1fr) auto;
    column-gap: 18px; row-gap: 12px;
    padding-bottom: 0;
  }
  .is-volume > .vol-pane > .vol-intro { grid-column: 1; grid-row: 1; }
  .is-volume > .vol-pane > .vol-figure {
    grid-column: 1; grid-row: 2;
    width: 100%; height: 100%; min-height: 0;
    max-width: none; max-height: none; aspect-ratio: auto;
  }
  .is-volume > .vol-pane > .vol-figure + div { grid-column: 1; grid-row: 3; }
  .is-volume > .vol-pane > .vol-side {
    grid-column: 2; grid-row: 1 / -1;
    display: flex; flex-direction: column; gap: 12px;
    min-height: 0;
    overflow-y: auto; overflow-x: hidden;
    overscroll-behavior: contain;
    padding: 0 0 12px 18px;
    border-left: 1px solid var(--rule-soft);
  }
  .vol-side > .vol-hint { margin-top: 2px; }
}
@media (min-width: 1024px) {
  .graph-host.is-volume > .vol-pane { grid-template-columns: minmax(0, 1fr) clamp(340px, 40%, 460px); }
}
@media (min-width: 1200px) {
  .pane-scroll > .graph-host.is-volume { max-width: 1280px; }
  /* Muscles and Volume: the controls above share the panes' edges. The
     picker sat at x350 and Compare ended at x1290 over a panel reaching x1418. */
  .pane-top:has(+ .pane-scroll > .graph-host:is(.is-muscles, .is-volume)) > * { max-width: 1280px; }
}

/* ── Goals and a friend's workout (≥1200): two columns ────────────────────
   Goals scrolled 1,904px in one 940px column with sentences ~150 characters
   long; a friend's workout 1,366px. Now the summary is on the left and the
   long part on the right: "What this asks of you" on Goals, the sets (and the
   two buttons that follow them) on a workout.
   ⚠️ HOW THE RIGHT COLUMN STAYS ONE PIECE WITHOUT STRETCHING THE LEFT: it spans
   every row down to a last `1fr` track. A grid item that spans a flexible track
   is left out of the auto tracks' sizing, so the left column's rows are only as
   tall as their own content and the fr row absorbs whatever the right column
   has left over. The left items carry their spacing as margins (row-gap 0),
   because the unused auto tracks would each add a gap. */
/* The wrapper is the same flex column the screen is (12px gaps), so a phone
   lays out exactly as before; its heading is the wrapper's first child but not
   the screen's, so it keeps the 18px a mid-screen heading has. */
.ws-sets-col { display: flex; flex-direction: column; gap: 12px; min-width: 0; }
.ws-sets-col > .section-head:first-child { margin-top: 18px; }
@media (min-width: 1200px) {
  .pane-scroll > .goal-screen:has(> .goal-reqs),
  .pane-scroll > .ws:has(> .ws-sets-col) { max-width: 1180px; }
  .goal-screen:has(> .goal-reqs), .ws:has(> .ws-sets-col) {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    grid-template-rows: repeat(12, auto) 1fr;
    column-gap: 48px; row-gap: 0;
    align-items: start;
  }
  .goal-screen:has(> .goal-reqs) > * { grid-column: 1; margin-top: 16px; }
  .ws:has(> .ws-sets-col) > * { grid-column: 1; margin-top: 12px; }
  .goal-screen:has(> .goal-reqs) > :first-child,
  .ws:has(> .ws-sets-col) > :first-child { margin-top: 0; }
  .goal-screen > .goal-reqs, .ws > .ws-sets-col { grid-column: 2; grid-row: 1 / -1; margin-top: 0; }
  .goal-screen > .goal-reqs > .goal-ambition { margin-top: 0; }
  .ws-sets-col > .section-head:first-child { margin-top: 0; }
  /* The bottom button keeps the screen's one right edge. */
  .pane-scroll:has(> .goal-screen > .goal-reqs) ~ .pane-bottom > * { max-width: 1180px; }
}
/* ── Workouts (≥1024): the workouts | the programme's words ─────────────
   `.sys-side` (views-workouts.js) holds the warning, the notes and "How this
   program rates". At 1440 the rating block ended at x860 while the list above
   it ran to x1290; at 1024 the screen scrolled 22px and cut "Indirect work
   counts half a set" off at the fold. As a right-hand column the rating's
   numbers sit on the list's right edge and everything fits. Same fr-row trick
   as Goals. On a phone the column is a plain flex column with the pane's gap. */
.sys-side { display: flex; flex-direction: column; gap: var(--gap); min-width: 0; }
@media (min-width: 1024px) {
  .pane-scroll:has(> .sys-side) {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    grid-template-rows: repeat(12, auto) 1fr;
    column-gap: 32px; row-gap: 0;
    align-items: start;
    padding-inline: max(var(--pad), (100% - 1180px) / 2);
  }
  .pane-scroll:has(> .sys-side) > * { grid-column: 1; max-width: none; margin-inline: 0; margin-top: var(--gap); }
  .pane-scroll:has(> .sys-side) > :first-child { margin-top: 0; }
  .pane-scroll > .sys-side { grid-column: 2; grid-row: 1 / -1; margin-top: 0; }
  .sys-side > .own-rating { max-width: none; margin-inline: 0; }
  .pane-top:has(+ .pane-scroll > .sys-side) > * { max-width: 1180px; }
  /* The program's note keeps the reading cap and the column's left edge (review
     4: at 1440 it started at x350 and ran 1180 wide past the window). The Look
     pass margin assumed an 820/940 column; this one is 1180, and the pane's
     22px padding is already in 100%, so half the leftover lands on x230. */
  .pane-top:has(+ .pane-scroll > .sys-side) > .field-help {
    max-width: 68ch;
    margin-inline: max(0px, (100% - 1180px) / 2) auto;
  }
}
@media (min-width: 1200px) {
  .pane-scroll:has(> .sys-side) { column-gap: 48px; }
}

/* Reading measure: a requirement's sentence stops at ~72 characters at every
   laptop width (it ran ~150 in one 940px column). */
@media (min-width: 860px) {
  .goal-screen .row-sub { max-width: 72ch; }
}

/* Profile's Months/Years switch stays in view (phone review, 2026-09-25): a
   tap on Months lands the pane on the current month, thousands of px below the
   switch just tapped. In Months the switch sticks to the top of the pane for as
   long as the calendar is on screen, and each month's heading sticks just under
   it instead of sliding beneath it. --cal-modes-h is measured by ownCalendar.
   Below 1024 only: from there the calendar is a column that scrolls on its own
   and the switch sits above it anyway. Years is short and is left alone. */
@media (max-width: 1023px) {
  .me-cal:has(.cal-month) > .cal-modes {
    position: sticky; top: 0; z-index: 4;
    background: var(--ground);
    padding-block: 4px;
    /* Covers the pane's top padding too, where the month scrolling past
       otherwise shows through above the switch. The pane clips the rest. */
    box-shadow: 0 -24px 0 var(--ground);
  }
  .me-cal:has(> .cal-modes) .cal-month-head { top: var(--cal-modes-h, 0px); }
}

/* === end Motion 2 · Layout === */

/* === Motion 2 · Data === */
/* docs/motion2-plan.md package D, 2026-09-25. Tim: "Put professional level
   annimation and physics into this cite." js/views-data.js and
   js/views-muscles.js decide WHEN (a pane's first show per session; travel —
   a sub-tab, a month swipe — every time) and drive the springs (js/spring.js).
   This holds the rest states, and switches CSS transitions off on anything a
   spring is writing, or the browser would smear every frame it writes. */

/* ---- a spring owns it: no transition, no keyframe may chase it ---- */
.m2-sliding, .m2-growing, .m2-dragging, .m2-panel-in { transition: none !important; }

/* ---- bars measure out from their baseline (scaleX, no layout per frame) ---- */
.m2-growing { transform-origin: left center; animation: none !important; }

/* ---- the line chart ---- */
/* A real <linearGradient> (in the markup, so WebKit paints it): the accent
   under the line fading to nothing at the baseline. The Polish section's mask
   and flat .09 are replaced on the charts that carry the gradient. */
.chart .series-area.m2-grad { opacity: 1; -webkit-mask-image: none; mask-image: none; }
.chart .m2-stop-top { stop-color: var(--accent); stop-opacity: .30; }
.chart .m2-stop-bot { stop-color: var(--accent); stop-opacity: 0; }
:root[data-theme="light"] .chart .m2-stop-top { stop-opacity: .22; }
/* Dense: the thinned markers are drawn small, never removed (Rule 5: a marker
   means measured). The ones kept full size — the last point, every new best,
   any with 12px of room — keep the full ring even where the Layout section
   shrinks a dense chart's markers. Same small size as that rule. */
/* 🔄 2026-09-25: small rings between full ones still overlapped in clusters, so
   a crowded marker is now hidden (it stays in the markup; the crosshair still
   reaches it). One size for every marker left. */
.chart .pt.pt-hid { display: none; }

/* ---- the calendar ---- */
/* Vertical drags stay the browser's (the months scroll); a sideways one is the
   month swipe. */
.cal-grid { touch-action: pan-y; }
/* While a month's grid is being dragged or is arriving, it is clipped to the
   month's own column: it poked out into the gutter left of the month header
   (phone review, 2026-09-25). `clip`, not `hidden` — no scroll container, so
   the sticky month header still sticks to the pane. Only while moving, so a
   resting grid's today ring is never trimmed. */
.cal-month.m2-cal-moving { overflow-x: clip; overflow-y: visible; }
.m2-dragging { -webkit-user-select: none; user-select: none; }
/* Years stretched to fill its pane: the rows are sized in JS (≤1.5× the square). */
.m2-yr-fit .yr-cell { aspect-ratio: auto; }
/* A day's label never splits inside a word (review round 4): "Benchmark" broke
   into "Bench / mark" and its second line was clipped in a 393px cell. Lines
   still break between words (two at most); a word wider than the cell ends in
   an ellipsis on its own line. */
.cal-tag { overflow-wrap: normal; word-break: normal; min-width: 0; }
.cal-tag > span { min-width: 0; max-width: 100%; text-overflow: ellipsis; }
/* Two labels sharing a day have one line each (~23px tall): a second line
   was cut through the middle ("Lower B" drawn as half of two lines). */
.cal-tags:has(> .cal-tag:not(.more) + .cal-tag:not(.more)) > .cal-tag > span {
  -webkit-line-clamp: 1; line-clamp: 1;
}

/* ---- Settings ---- */
/* The knob's position is a spring while it moves; its colour still eases. */
.switch-knob.m2-knob { transition: background-color var(--t) var(--ease-out) !important; }
/* The theme reveal: the new theme grows in a circle from the tapped chip.
   Scoped to its own transition by class, so no other view transition in the
   app inherits "no crossfade". */
:root.m2-theme-vt::view-transition-old(root),
:root.m2-theme-vt::view-transition-new(root) { animation: none; mix-blend-mode: normal; }
:root.m2-theme-vt::view-transition-new(root) { z-index: 2; }

/* ---- Muscles ---- */
/* The laptop's default pick is outlined without dimming the rest of the map. */
.body-map.m2-preview[data-selected] .body-region:not(.is-selected) { opacity: 1; }

/* Reduced motion: the JS starts none of this (motionAllowed()); this is the
   backstop for a setting changed while something was moving. */
@media (prefers-reduced-motion: reduce) {
  .m2-sliding, .m2-growing, .m2-panel-in { transform: none !important; opacity: 1 !important; }
}
/* Graph's rep target: "reps" was 10px faint text floating outside its
   stepper. The stepper's own pill now holds the word too, at the size of the
   chips beside it, so "− 10 + reps" reads as one control. */
.rep-target {
  gap: 0; padding-right: 10px;
  background: var(--raised); border-radius: 7px;
}
.rep-target > .mini-stepper { background: transparent; }
.rep-target > .rep-target-label {
  font-size: var(--fs-md); font-weight: var(--fw-med);
  color: var(--ink-soft);
}

/* === end Motion 2 · Data === */

/* === Motion 2 · Moments === */
/* docs/motion2-plan.md package E, 2026-09-25. Tim: *"Put professional level
   annimation and physics into this cite … Impress me."* The movement itself is
   springs in JS (js/spring.js); what is here is only what those springs need —
   an origin, a stacking order, a window to roll a digit through — plus the two
   loading indicators. Nothing here is on the logging path.
   ⚠️ THE TWO LOOPS (the skeleton's shimmer, the refresh spinner) are loading
   indicators, not transitions: a shimmer at Rule 7's 250ms would strobe. Their
   period is a variable (--m-loop), and they stop the moment the data lands. */

/* ── Finish screen: the counted numbers never change width ── */
.finish-n { font-variant-numeric: tabular-nums; }
/* 🔄 2026-09-25 (review): the finish screen is there from its first frame. The
   shell's `screen-in` fade replaced the runner with a black frame and then
   0.51 at 143ms (measured, WebKit 393px); what moves is its contents — the
   check pops, the numbers count, the rows follow (playFinish). */
.screen:has(.finish-hero) { animation: none; }

/* ── The runner going into its bar, and coming back out of it ──
   `transform-origin: 0 0` is what js/photo.js `rectFlight()` computes for. The
   flying ghost sits above the screen the router draws under it (the fall's
   own order, `.is-falling`); the rounded corners are what make a whole screen
   read as a card while it is small. The restoring screen keeps `.rises`
   (its stacking and ground) with the keyframe off — a CSS animation would
   override the spring's inline transform for its whole length. */
.screen-ghost.m-flying {
  z-index: 50;
  transform-origin: 0 0;
  border-radius: 18px;
  box-shadow: var(--elev-3);
  will-change: transform, opacity;
}
.screen.m-restoring {
  animation: none !important;
  transform-origin: 0 0;
  border-radius: 18px;
  will-change: transform, opacity;
}

/* ── Kudos: the count rolls through a one-line window ── */
.kudos-n { font-variant-numeric: tabular-nums; }
.odo-d {
  display: inline-block;
  height: 1.2em; line-height: 1.2em;
  overflow: hidden;
  vertical-align: bottom;
}
.odo-col { display: flex; flex-direction: column; will-change: transform; }
.odo-cell { display: block; height: 1.2em; text-align: right; }
/* The spring is the pop now; the keyframe stays only where springs do not run. */
.feed-act-glyph.is-popping.m-sprung { animation: none; }

/* ── Skeletons: the shape of the rows while a screen reads ──
   Faded in only after 150ms, so a quick read never flashes grey. Sizes match
   the real rows (measured at 393px on the demo): a feed card, Profile's head,
   counts and rows. */
.m-skel {
  --m-loop: 1.4s;
  --m-hl: rgb(255 255 255 / .06);
  animation: m-skel-in var(--t) var(--ease-out) 150ms both;
  pointer-events: none;
}
:root[data-theme="light"] .m-skel { --m-hl: rgb(255 255 255 / .6); }
@keyframes m-skel-in { from { opacity: 0; } }
.m-skel-card { display: flex; flex-direction: column; gap: 7px; padding: 15px 0 13px; }
.m-skel-card + .m-skel-card { border-top: 1px solid var(--rule); }
.m-skel-row { display: flex; align-items: center; gap: 9px; }
.m-skel-row > .m-skel-col { flex: 1; }
.m-skel-card > .m-skel-row:nth-child(3) { gap: 24px; }
.m-skel-card > .m-skel-row:nth-child(3) > .m-skel-col { flex: none; }
.m-skel-row.is-head { gap: 16px; min-height: 72px; }
.m-skel-col { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
.m-skel-col.is-lines { gap: 8px; padding-top: 4px; }
.m-skel-foot { height: 40px; }
.m-skel-line { display: flex; align-items: center; }
.m-skel-line > .m-skel-col { flex: 1; }
.m-skel-line + .m-skel-line { border-top: 1px solid var(--rule-soft); }
.m-skel-bar {
  display: block; flex: none;
  max-width: 100%;
  border-radius: 5px;
  background: var(--raised);
  position: relative; overflow: hidden;
}
.m-skel-bar.is-round { border-radius: 50%; }
.m-skel-bar.is-soft { opacity: .55; border-radius: var(--r); }
.m-skel-bar::after {
  content: ""; position: absolute; inset: 0;
  background: linear-gradient(100deg, transparent 30%, var(--m-hl) 50%, transparent 70%) no-repeat;
  background-size: 250% 100%;
  animation: m-shimmer var(--m-loop) ease-in-out infinite;
}
@keyframes m-shimmer {
  from { background-position: 130% 0; }
  to   { background-position: -30% 0; }
}

/* ── Pull to refresh (Home, phones) ──
   The pane's own bounce is turned off (iOS 16+ honours it) so the drawn
   rubber band is the only one — see pullToRefresh() in js/workout-card.js.
   Its children move only while a pull is on screen. */
.m-ptr-host { overscroll-behavior-y: none; position: relative; }
.m-ptr-host.is-pulling > * { transform: translateY(var(--ptr-y, 0px)); }
.m-ptr {
  --m-loop: .8s;
  position: absolute; top: -46px; left: 50%;
  width: 36px; height: 36px; margin-left: -18px;
  display: grid; place-items: center;
  border-radius: 50%;
  background: var(--raised);
  box-shadow: var(--elev-2);
  color: var(--accent);
  opacity: 0;
  pointer-events: none;
  z-index: 2;
}
.m-ptr svg { width: 22px; height: 22px; display: block; }
.m-ptr-arc { fill: none; stroke: currentColor; stroke-width: 2.4; stroke-linecap: round; }
.m-ptr:not(.is-armed) .m-ptr-arc { opacity: .7; }
.m-ptr.is-busy svg { animation: m-ptr-spin var(--m-loop) linear infinite; }
.m-ptr.is-busy .m-ptr-arc { stroke-dasharray: 34 60 !important; }
@keyframes m-ptr-spin { to { transform: rotate(360deg); } }

/* ── Tour: the hole moves on springs now, not on a transition ── */
.tour-spot { transition: none; }

/* ── Onboarding: a screen pushed out cannot be tapped on its way ── */
.ob-leaving { pointer-events: none; }

/* Reduced motion: the JS starts none of the springs (motionAllowed()). The
   loops are stopped outright — the blanket shortens a duration, and an
   infinite loop at .01ms is a flicker, not a stop. */
@media (prefers-reduced-motion: reduce) {
  .m-skel, .m-skel-bar::after, .m-ptr svg { animation: none !important; }
  .m-skel-bar::after { display: none; }
}
/* === end Motion 2 · Moments === */

/* === Motion 2 · Navigation === */
/* Screens that move like objects (js/gestures.js, 2026-09-25). Every position
   here is written per frame by a spring from JS; this section only owes the
   layers, the grounds and the shadows. ⚠️ No `transition` on a transform in
   here: a CSS transition under a spring's per-frame writes would lag the finger. */
:root { --nav-dim: .32; --nav-card-dim: .38; }
:root[data-theme="light"] { --nav-dim: .14; --nav-card-dim: .22; }

/* The screen in motion, the parked one and a kept picture each own their
   ground, so two screens never show through each other. */
.screen.nav-moving{animation:none}
/* …and stays without it once landed: removing `nav-moving` would otherwise
   start `.screen`'s arrival keyframe after the spring had already brought it. */
.screen.landed { animation: none; }
.screen.nav-moving { background: var(--ground); will-change: transform, opacity; }
.screen.nav-still { animation: none; }
.screen.nav-high { z-index: 45; }
.nav-ghost.nav-under { z-index: 40; }
.nav-ghost.nav-over { z-index: 50; }
.screen-ghost.nav-fall{animation:none}
.nav-ghost { will-change: transform; transform-origin: 50% 50%; }
.nav-ghost > .screen, .nav-snap > .screen { flex: 1; min-width: 0; min-height: 0; animation: none; }
.nav-ghost > .navbar, .nav-snap > .navbar { flex: none; animation: none; }
.nav-snap {
  position: fixed; z-index: 40; display: flex; overflow: hidden;
  background: var(--ground); pointer-events: none; transform-origin: 50% 50%;
}

/* The edge a sliding screen leads with: a soft shadow on the page that is on
   top, so it reads as a sheet of paper over another rather than a wipe. */
.screen.nav-k-push, .screen.nav-k-edge, .nav-ghost.nav-g-back {
  box-shadow: -10px 0 28px rgb(0 0 0 / .22);
}
.screen.nav-k-rise, .screen.nav-k-card, .nav-ghost.nav-g-fall { box-shadow: 0 -8px 30px rgb(0 0 0 / .28); }
.nav-dim {
  position: absolute; inset: 0; z-index: 5; background: #000; opacity: 0;
  pointer-events: none;
}
.nav-dim.nav-dim-fixed { position: fixed; inset: auto; z-index: 46; }
.nav-dim.nav-dim-swipe { z-index: 41; }
/* The tab bar is not part of the stack: it stays put, above what moves. */
.navbar.nav-bar-top { z-index: 46; }

/* 🔄 Review, 2026-09-25. A tab switch (and a laptop push/back) arrives IN
   PLACE: the new screen's ground covers the old one from its first frame and
   only its content fades, so two screens are never legible together. */
.screen.nav-in-place { box-shadow: none; }
/* 🔄 Phone review 3: …and that covering made a blank frame between the old
   content going and the new arriving. The ground is see-through while it
   moves, so the old content shows under it until it has gone (js/gestures.js
   OLD_OUT_MS / IN_GATE: a handoff, never two readable screens — review 4).
   Both screens stand on the same ground, so nothing shows but content. */
.screen.nav-moving.nav-xfade { background: transparent; }

/* One demo strip while screens move: a copy pinned at the strip's resting
   place, the real ones hidden (visibility — nothing below them shifts). The
   copy's ground is the page's, as the real strip's is. */
.nav-banner-pin {
  position: fixed; z-index: 60; margin: 0; box-sizing: border-box;
  pointer-events: none;
  background: linear-gradient(var(--accent-dim), var(--accent-dim)), var(--ground);
}
body.nav-pinned .demo-bar:not(.nav-banner-pin) { visibility: hidden; }
/* 🔄 Only for a sideways slide on a phone. A CARD (Record rising or falling,
   or dragged down) pins nothing since review 4: the card is one sheet and its
   strip travels with it, and the screen behind keeps its own, stepping back
   with it. The pinned copy sat above the card, so the card's strip slid up
   under it and two strips stood stacked for ~100ms. */

/* A card is up: behind the screen that stepped back is black, like iOS. */
body.nav-card { background: #000; }
#app.nav-card-app { background: var(--ground); overflow: hidden; transform-origin: 50% 50%; }

/* ── The tab bar's selection, one element that slides ─────────────────── */
.navbar.has-ind { position: relative; }
.navbar.has-ind > a { position: relative; z-index: 1; }
.nav-ind {
  position: absolute; left: 0; top: -1px; z-index: 2;
  width: 28px; height: 3px; border-radius: 0 0 3px 3px;
  background: var(--accent);
  transform-origin: 50% 50%;
  pointer-events: none;
}
@media (min-width: 860px) {
  /* The sidebar's selected fill IS the indicator, so the link's own goes. */
  .nav-ind {
    top: 0; z-index: 0; border-radius: 7px;
    background: var(--accent-dim); box-shadow: inset 0 1px 0 var(--hl);
  }
  .navbar.has-ind > a[aria-current="page"] { background: transparent; box-shadow: none; }
  /* The gold label waits for the fill (js/gestures.js `litFollows`): while it
     slides, only the link it is on is gold, and the colour crossfades across
     as it arrives. `:root` matches the light-gold rule's weight (above). */
  .navbar.has-ind > a { transition: color var(--t-fast) var(--ease-out); }
  :root .navbar.ind-moving > a[aria-current="page"]:not(.ind-lit) { color: var(--ink-faint); }
  .navbar.ind-moving > a.ind-lit { color: var(--accent); }
  :root[data-theme="light"]:not([data-palette]) .navbar.ind-moving > a.ind-lit { color: #82570B; }
}

/* Reduced motion: nothing moves by itself. A screen under a finger (the edge
   swipe, Record's drag-down) still follows it — phone review 3. */
@media (prefers-reduced-motion: reduce) {
  .screen.nav-moving:not(.nav-k-edge):not(.nav-k-card), .nav-ghost, .nav-snap { transform: none !important; }
  /* ⚠️ NOT .nav-ind: its transform IS its place (gestures.js drawInd jumps it
     there without animating). Pinning it put the selection on Home / the
     sidebar's title on every screen — sign-off review 2026-09-25. */
}
/* === end Motion 2 · Navigation === */

/* === Motion 2 · Surfaces === */
/* docs/motion2-plan.md package C, 2026-09-25. Tim: "Put professional level
   annimation and physics into this cite … Impress me." The segmented pill,
   sheets, the photo viewer, toasts, the ± press and the ? box ride springs in
   js/ui.js (SURFACE_SPRINGS); this section only owes them layers, origins and
   grounds, and switches off the keyframes they replace. `.s2-live` = a spring
   owns this element. ⚠️ No transition on a transform here: it would smear
   every frame the spring writes. */
.s2-live { animation: none !important; }

/* ── The segmented pill: a spring moves it, a finger can drag it ── */
.seg-ind { transition: none; }
/* The selected segment is the thumb: a sideways drag on it is ours, a
   vertical one still scrolls the page. */
.segmented.has-ind > .seg[aria-selected="true"] { touch-action: pan-y; }
.segmented.seg-dragging { -webkit-user-select: none; user-select: none; }
/* Months / Years: two equal halves (measured 187 / 172px at 393 — the pill
   changed width as it moved). Same on Profile and the Calendar screen. */
.cal-modes .segmented > .seg { flex: 1 1 0; }

/* ── Sheets: the dark behind thins as the sheet is dragged away ── */
.sheet-backdrop.s2-live, .sheet-backdrop-x.s2-live { background: rgb(0 0 0 / calc(.55 * var(--s2-k, 1))); }
.sheet.s2-phone, .sheet-x.s2-phone-x { position: relative; }
/* Pulled up past its rest (rubber band) the sheet must not lift off the
   bottom edge: its ground carries on below it. */
.sheet.s2-phone::after, .sheet-x.s2-phone-x::after {
  content: ""; position: absolute; left: 0; right: 0; top: calc(100% - 1px); height: 64px;
  background: var(--ground); pointer-events: none;
}
.sheet-grab {
  position: absolute; top: 5px; left: 50%; z-index: 1;
  width: 36px; height: 5px; margin-left: -18px; border-radius: 3px;
  background: var(--ink-faint); opacity: .55; pointer-events: none;
}
.s2-dragging { -webkit-user-select: none; user-select: none; }

/* ── Toasts: centred without a transform, so the spring owns it whole ── */
.toast, .toast-x {
  left: 14px; right: 14px; margin-inline: auto; width: max-content;
  transform: none; animation: none; touch-action: none;
  -webkit-user-select: none; user-select: none;
}
/* Light theme: `--raised-hi` on the light ground was ~1.2:1 — a grey pill that
   barely stood off the page (phone review 3). The toast is inverted there, ink
   for its ground and the page's ground for its words, as a note laid over the
   page rather than part of it. Dark keeps its raised pill. */
:root[data-theme="light"] :is(.toast, .toast-x) { background: var(--ink); color: var(--ground); }

/* Light theme, a switch that is OFF: its knob was `--ink-soft`, a dark disc
   that read as "on" (phone review 3). Off, the knob is light, lifted off the
   grey track by a shadow; the TRACK says which state it is in — grey off,
   accent on (on is unchanged). */
:root[data-theme="light"] .switch:not([aria-checked="true"]) .switch-knob {
  background: var(--surface); box-shadow: 0 1px 3px rgb(0 0 0 / .28);
}

/* ── The runner's ±: the spring is the press (.94); no CSS scale under it ── */
.step-btn { transition: background .1s ease; }
@media (prefers-reduced-motion: no-preference) {
  .step-btn:active { transform: none; }
}

/* ── The ? box grows out of its dot ── */
.help-pop { transform-origin: var(--arrow-x, 50%) 0; }
.help-pop.is-above { transform-origin: var(--arrow-x, 50%) 100%; }

/* ── The workout photo, full screen ── */
.pview { position: fixed; inset: 0; z-index: 60; overflow: hidden; touch-action: none; outline: none; }
.pview.is-closing { pointer-events: none; }
.pview-back { position: absolute; inset: 0; background: #000; }
.pview-img {
  position: absolute; display: block; max-width: none; transform-origin: 0 0;
  -webkit-user-select: none; user-select: none; -webkit-user-drag: none; -webkit-touch-callout: none;
}
.pview-close {
  position: absolute; z-index: 1; top: calc(env(safe-area-inset-top) + 8px); right: 10px;
  background: var(--raised); color: var(--ink);
}
.card-photo.is-openable { cursor: zoom-in; }
/* While the photo is out of its thumbnail, the thumbnail is empty. */
.card-photo.s2-src-hidden .card-photo-img { visibility: hidden; }

/* Reduced motion: the JS starts none of these springs; this is the backstop
   for a setting changed while something was moving. 🔄 Phone review 3: the
   grab handle STAYS — a sheet is still dragged down under reduced motion (a
   drag is the finger moving it, js/ui.js canDrag); only what follows the
   release is instant. A sheet opened then is not `.s2-live`, so its drag's
   transform is not caught by the rule below. */
@media (prefers-reduced-motion: reduce) {
  .sheet.s2-live, .toast.s2-live, .help-pop.s2-live { transform: none !important; }
}
/* Focus is moved onto the dialog itself when it opens (openSheet), as the ?
   box does; the dialog is not a control, so it draws no ring. */
.sheet:focus { outline: none; }
/* === end Motion 2 · Surfaces === */

/* === Motion 2 · Laptop === */
/* Laptop review, 2026-09-25 (motion pass 2). Tim: "Put professional level
   annimation and physics into this cite … Impress me." + his taste: a laptop
   uses its width, content never moves on tap, hover is feedback and never
   extra detail. Measured at 1440/1280/1024 (WebKit + Chrome) and 393. */

/* ── Hover: the mouse gets an answer (it had 2 rules) ─────────────────────
   Only where a mouse is the pointer — on a touch screen :hover sticks after a
   tap. Colour and tint only: nothing moves or resizes. --t-fast. */
:root { --hover-tint: rgb(255 255 255 / .05); }
:root[data-theme="light"] { --hover-tint: rgb(0 0 0 / .045); }
@media (hover: hover) and (pointer: fine) {
  .navbar a, button.row, a.row, .row.as-button, .seg, .chip, .btn, .feed-act,
  .feed-open, .feed-head, button.cal-cell, .yr-grid .yr-cell, .set-pick, .nav-arrow,
  .icon-btn, .help-dot { cursor: pointer; }
  .row.static, .set-item.is-done .set-pick { cursor: default; }
  .btn {
    transition: transform var(--t-fast) var(--ease-out), background-color var(--t-fast) linear,
                box-shadow var(--t-fast) linear, filter var(--t-fast) linear;
  }
  .feed-title, .feed-name { transition: color var(--t-fast) linear; }

  /* Rows you can open: a faint tint. */
  :is(button.row, a.row, .row.as-button):not(.static):not(:disabled):hover:not(:active) {
    background-color: var(--hover-tint);
  }
  /* Raised buttons and chips lift a step; filled ones darken a little. */
  .btn:not(.primary):not(.good):not(.ghost):not(.danger):not([aria-pressed="true"]):not(:disabled):hover:not(:active),
  .chip:not([aria-pressed="true"]):hover:not(:active),
  .nav-arrow:not(:disabled):hover:not(:active) { background-color: var(--raised-hi); }
  /* 🔄 Review 4: a 7% darken on the dark theme's gold read as no hover at all
     (both engines). Filled buttons now go the way the calendar's days do: up a
     step on dark (the raised buttons' --raised-hi lifts too), down on light —
     each also keeps its ink's contrast. */
  :is(.btn.primary, .btn.good, .btn.preset[aria-pressed="true"], .chip[aria-pressed="true"], .mini-arrow):not(:disabled):hover:not(:active) {
    filter: brightness(1.1);
  }
  :root[data-theme="light"] :is(.btn.primary, .btn.good, .btn.preset[aria-pressed="true"], .chip[aria-pressed="true"], .mini-arrow):not(:disabled):hover:not(:active) {
    filter: brightness(.9);
  }
  :is(.btn.ghost, .btn.danger):not(:disabled):hover:not(:active),
  .icon-btn:hover:not(:active) { background-color: var(--hover-tint); }
  /* A segment you are not on: its word comes up to full ink. */
  .seg:not([aria-selected="true"]):not(:disabled):hover { color: var(--ink); }
  /* The feed: the action tints, the card's title and the name take the accent
     (the same answer `.as-button:hover .row-title` already gives). */
  .feed-act:hover:not(:active) { background-color: var(--hover-tint); color: var(--ink); }
  .feed-act.is-mine:hover { color: var(--accent); }
  .feed-open:hover .feed-title, .feed-head:hover .feed-name { color: var(--accent); }
  /* Calendar: a day you can open brightens; a square gets a ring. */
  button.cal-cell:not(.blank):hover:not(:active) { filter: brightness(1.14); }
  :root[data-theme="light"] button.cal-cell:not(.blank):hover:not(:active) { filter: brightness(.95); }
  .yr-grid .yr-cell:not(.sel):hover { box-shadow: 0 0 0 1px var(--ink-faint); position: relative; z-index: 1; }
}
@media (hover: hover) and (pointer: fine) and (min-width: 860px) {
  /* Sidebar: an item you are not on tints and comes up to full ink. */
  .navbar > a:not([aria-current="page"]):not(.ind-lit):hover { background-color: var(--hover-tint); color: var(--ink); }
  /* A list row on a laptop has no side padding (its text IS the column edge),
     so its tint reaches 10px past each side instead of stopping at the words. */
  .pane-scroll .list > :is(button.row, a.row, .row.as-button):not(.static):not(.is-current):hover:not(:active) {
    box-shadow: -10px 0 0 var(--hover-tint), 10px 0 0 var(--hover-tint);
  }
}

/* ── Dialogs (laptop): rows keep the dialog's side padding; centred over the
   content area, right of the 200px sidebar, not over the window ─────────── */
@media (min-width: 860px) {
  .sheet-body .list > * { padding-inline: var(--pad); }
  body:has(> #app > .navbar) :is(.sheet-backdrop, .sheet-backdrop-x) { padding-left: 200px; }
}

/* ── Feed actions: the icons start on the card's text edge ────────────────
   They were centred in their boxes, so the Kudos thumb sat 30px in from the
   words above it (x380 vs x350 at 1440; x44 vs x14 at 393). Phone keeps its
   three equal thirds — Kudos left, Comment middle, Share right, as Tim set
   them — with Kudos on the left edge and Share on the right one; the boxes
   (the tap targets) do not change. Laptop's three 120px boxes start left. */
/* The row reaches 10px into the gutter on each side and the outer boxes pad
   those 10px back, so a press or hover fill has a margin round the icon while
   the icon itself sits on the text edge. */
.feed-actions { margin-inline: -10px; }
.feed-actions > .feed-act:first-child { justify-content: flex-start; padding-left: 10px; }
.feed-actions > .feed-act:last-child { justify-content: flex-end; padding-right: 10px; }
@media (min-width: 860px) {
  .feed-actions { margin-inline: -10px 0; }
  .feed-actions > .feed-act, .feed-actions > .feed-act:last-child {
    justify-content: flex-start; padding-inline: 10px 0; flex-basis: 130px;
  }
}

/* ── Words: no orphans ────────────────────────────────────────────────────
   "%." alone on a line. Longhands only: the `text-wrap` shorthand also resets
   text-wrap-mode and would undo every `white-space: nowrap` title. */
p, .note, .field-help, .row-sub, .feed-note, .ws-note, .help-pop-body, .goal-verdict-body,
.goal-intro-body, .update-note, .preset-warning, .research-notes, .rt-body, figcaption,
.empty p, .toast { text-wrap-style: pretty; }
h1, h2, h3, .empty-title, .section-head, .help-pop-title, .feed-title { text-wrap-style: balance; }

/* ── Runner ───────────────────────────────────────────────────────────────
   Finished → Edit moved the row's contents 3px down (both widths): the open
   set's head was 44px and a closed row 50px, each centring its button and
   numbers. The open head now matches the closed row. No motion added. */
.set-item:not(.set-drop) > .set-open-head, .set-item:not(.set-drop) > .set-open-head .set-pick { min-height: 50px; }
/* Laptop: the bottom bar's ← and Next share the content column's edges. */
@media (min-width: 860px) {
  .session-footer { justify-content: flex-start; padding-inline: max(var(--pad), (100% - 820px) / 2); }
  .session-footer > * { max-width: none; }
}
@media (min-width: 1200px) {
  .session-footer { padding-inline: max(var(--pad), (100% - 940px) / 2); }
}

/* ── Calendar (laptop): the small labels at the small reading size ────────
   Weekday letters, the workout names in a day, the year grid's months and
   days were 10px (--fs-xs) on a 1440 screen; --fs-sm is the smallest size the
   app reads text at. */
@media (min-width: 860px) {
  .cal-dow, .cal-tag, .yr-month, .yr-dow, .yr-dows { font-size: var(--fs-sm); }
}

/* ── Settings (laptop): one right edge ────────────────────────────────────
   The switches stop at the 510px text measure (Motion 2 · Layout); the
   Account row ran on to the column's edge, 430px further right. Everything in
   Settings now keeps that measure, so the chevron and the switches line up. */
@media (min-width: 860px) {
  .pane-scroll:has(.switch-row) > * { max-width: 510px; margin-inline: max(0px, (100% - 820px) / 2) auto; }
}
@media (min-width: 1200px) {
  .pane-scroll:has(.switch-row) > * { margin-inline: max(0px, (100% - 940px) / 2) auto; }
}
/* === end Motion 2 · Laptop === */

/* === Motion 2 · Additions === */
/* ── Profile: the goal row carries the Goals screen's bar, thin ───────────
   Same .to-next-bar/.to-next-fill (colour, radius, spring); 4px instead of 6
   and a 3px gap, so the row grows by 7px and stays one tap target. */
.me-goal-bar { height: 4px; border-radius: 2px; margin-top: 3px; }
.me-goal-bar > .to-next-fill { border-radius: 2px; }

/* ── Data › Muscles: the summary's names pick the muscle ──────────────────
   Text in the sentence's own size and line, accent + a soft underline. The
   ::after widens the hit area to 44px tall without touching the line box. */
.m-name-btn {
  position: relative; display: inline; padding: 0; margin: 0;
  font: inherit; line-height: inherit; color: var(--accent);
  text-decoration: underline; text-underline-offset: 2px;
  text-decoration-color: color-mix(in srgb, var(--accent) 45%, transparent);
  -webkit-tap-highlight-color: transparent;
}
.m-name-btn::after { content: ''; position: absolute; inset: -13px -4px; }
.m-name-btn:active { opacity: .6; }
.m-name-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 3px; }
@media (hover: hover) and (pointer: fine) {
  .m-name-btn:hover { text-decoration-color: var(--accent); }
}
/* === end Motion 2 · Additions === */

/* === Warm-ups in history (2026-09-27) ====================================
   Tim: *"Also add the warm-up sets in the workout history and day view."*
   Same signals as the runner's W rows (.set-warm above): a dashed "W" where a
   set number goes, and quieter text. No fill, so a warm-up chip never reads
   as one of the working-set chips beside it. Shown, never counted. */
.detail-set.is-warm { background: transparent; color: var(--ink-faint); }
.detail-set .warm-mark {
  display: inline-grid; place-items: center;
  min-width: 1.5em; padding: 0 3px;
  border: 1px dashed var(--ink-faint); border-radius: 4px;
  color: var(--ink-soft); font-weight: var(--fw-bold);
  line-height: 1.35;
}
.feed-ex-warm {
  flex: none; color: var(--ink-faint); font-size: var(--fs-sm);
  white-space: nowrap; align-self: center;
}
/* === end Warm-ups in history === */

/* === Auto-guide (2026-09-27) ==============================================
   Tim: *"an "auto-guide" button you could press which would just show you the
   single set you should be on … and then just a single button at the bottom."*
   js/guide-mode.js. One step, centred in the space between the top bar and
   the button: who and which set, the exercise, the same two steppers the list
   uses (bigger — they are the only thing on the screen), one button.
   ⚠️ No motion between steps: this is the logging path (Rule 7).
   ⚠️ `hidden` needs saying: `.session-footer` and friends set `display`, which
   beats the UA's [hidden]. */
.screen > .guide[hidden],
.screen > .people-bar[hidden],
.screen > .session-progress[hidden],
.screen > .pane-scroll[hidden],
.screen > .session-footer[hidden] { display: none; }

.guide { flex: 1; min-height: 0; display: flex; flex-direction: column; }
.guide-scroll { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; }
/* The body shrinks to the space there is, and only the lines about the
   exercise give way (they scroll on their own): the steppers and Next never
   leave the screen (2026-09-27 review). */
.guide-body {
  margin: auto; width: 100%; max-width: 460px;
  flex: 0 1 auto; min-height: 0;
  padding: 16px var(--pad);
  display: flex; flex-direction: column; align-items: stretch; gap: 4px;
  text-align: center;
}
.guide-body > * { flex: none; }
.guide-where {
  display: flex; justify-content: center; align-items: baseline; gap: 6px;
  font-size: var(--fs-lg); font-weight: var(--fw-bold); color: var(--ink-soft);
}
.guide-who { color: var(--accent); }
.guide-dot { color: var(--ink-faint); }
.guide-set.is-warm { color: var(--ink-faint); }
.guide-ex {
  margin: 0; font-size: 26px; font-weight: var(--fw-bold);
  letter-spacing: -0.028em; line-height: 1.15; text-wrap: balance;
  overflow-wrap: anywhere;
}
.guide-name { display: flex; justify-content: center; min-width: 0; }
.guide-name .ex-label-btn { text-align: center; }
/* The pane's own lines (coach's note, plan, last time …), in their own
   classes; this slot only stacks them and scrolls when there is no room. */
.guide-body > .guide-note {
  flex: 0 1 auto; min-height: 20px; overflow-y: auto; overflow-x: hidden;
  display: flex; flex-direction: column; gap: 4px; margin-top: 2px;
}
.guide-note[hidden] { display: none; }
.guide-note > * { flex: none; }
.guide-how { font-size: var(--fs-md); font-weight: var(--fw-bold); color: var(--ink-soft); }
.guide-note .note-card { text-align: left; }
.guide-note .prefill-note { justify-content: center; }
.guide-steppers { margin-top: 18px; text-align: left; }
.guide-assist { margin-top: 6px; }
.guide-assist[hidden] { display: none; }
.guide .step-value { font-size: 38px; }
/* A long number steps the font down so it is never clipped (2026-09-27:
   "1000" showed as "100"). Chosen by character count in guide-mode.js
   `boxFit()`; sizes measured in WebKit at 393px, and stepped down further
   there by measurement on a narrower phone. */
.guide .step-value.fit-md { font-size: 28px; }
.guide .step-value.fit-sm { font-size: 24px; }
.guide .step-value.fit-xs { font-size: 20px; }
.guide .step-btn { height: 60px; }
.guide-footer { justify-content: center; }
.guide-footer .btn { flex: 1; max-width: 460px; }
/* Back (2026-09-27, Tim: "there's no back button for the auto-guide like
   there should be"): the runner's own secondary grey, left of Next, as
   narrow as its words. Always there — disabled on the first step — so Next
   never changes width under a thumb. */
.guide-footer .guide-back { flex: none; padding: 0 14px 0 10px; }

/* The big bar (2026-09-27, js/bar-view.js). Tim: "a much larger and more
   detailed display of the bar with weights on it on both sides rather than
   the tiny display on the main screen. also annimate the plates moving on and
   off the bar." One fixed 360×150 box scaled to the column's width, so the
   screen never moves when plates do. Metal takes the theme's inks (as the
   small drawing does); the plate hues are the small drawing's calibrated
   colours, repeated here because those rules are scoped to `.plate-draw`.
   Plates move on the `snap` spring in bar-view.js; reduced motion swaps them. */
.guide-bar { width: 100%; margin: 12px auto 0; }
.guide-bar[hidden] { display: none; }
/* On a step with many lines the drawing gives way first — it scales down
   (the viewBox is `meet`, so it stays whole and centred) to a floor — so the
   lines above it keep room to be read (2026-09-27 review). */
.guide-body > .guide-bar { flex: 0 6 auto; min-height: 72px; aspect-ratio: 360 / 150; }
.guide-body > .guide-bar > .bar-draw { height: 100%; aspect-ratio: auto; }
.bar-draw { display: block; width: 100%; height: auto; aspect-ratio: 360 / 150; overflow: visible; }
.guide-bar.is-empty .bar-draw { opacity: .35; }
.bar-draw .bv-shaft,
.bar-draw .bv-sleeve { fill: var(--ink-faint); }
.bar-draw .bv-collar,
.bar-draw .bv-cap,
.bar-draw .bv-frame,
.bar-draw .bv-clip-body { fill: var(--ink-soft); }
.bar-draw .bv-knurl-line { fill: #000; fill-opacity: .38; }
.bar-draw .bv-plate-body { stroke: var(--ink-faint); stroke-opacity: .55; stroke-width: .75; }
.bar-draw .bv-label {
  font-size: var(--fs-md); font-weight: var(--fw-bold);
  font-variant-numeric: tabular-nums;
}
.bar-draw .bv-label.is-light { fill: #FFFFFF; }
.bar-draw .bv-label.is-dark  { fill: #15171A; }
.bar-draw .bv-label.is-ink   { fill: var(--ink); }
.bar-draw .pd-red    { fill: #D9463B; }
.bar-draw .pd-blue   { fill: #3673D6; }
.bar-draw .pd-yellow { fill: #E9C23A; }
.bar-draw .pd-green  { fill: #2F9E57; }
.bar-draw .pd-white  { fill: #F4F4F0; }
.bar-draw .pd-black  { fill: #1E2022; }
.bar-draw .pd-chrome { fill: #B4BAC0; }

/* The strip across the top (2026-09-27). Tim: "show the weights and reps of
   the past and future sets in this same exercise in the upper left cornour
   small" — and "Add Swap and Skip", "+ Set". The sets on the left, the three
   small buttons on the right (the runner's own `.pill-action`). In the flow,
   so it never covers the step; at most six rows, then it scrolls inside
   itself (guide-mode.js keeps the current row in view). */
.guide-top {
  flex: none; display: flex; align-items: flex-start; gap: 8px;
  padding: 8px var(--pad) 0;
}
.guide-sets {
  position: relative; flex: 1 1 auto; min-width: 0;
  max-height: calc(6 * 16px); overflow-y: auto; overflow-x: hidden;
  font-size: var(--fs-sm); line-height: 16px; color: var(--ink-faint);
  font-variant-numeric: tabular-nums;
}
.guide-sets[hidden] { display: none; }
.gs-row { display: flex; align-items: baseline; gap: 6px; white-space: nowrap; min-width: 0; }
.gs-num { flex: none; width: 1.1em; text-align: right; }
.gs-vals { min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.gs-tick { flex: none; color: var(--good); }
.gs-row.is-done { color: var(--ink-soft); }
.gs-row.is-now { color: var(--ink); font-weight: var(--fw-bold); }
.gs-row.is-now .gs-num { color: var(--accent); }
.gs-row.is-skip .gs-vals { text-decoration: line-through; }
.gs-row.is-drop .gs-num { font-size: var(--fs-xs); }
.guide-acts { flex: none; display: flex; gap: 6px; }
.guide-act.guide-skip { padding-left: 12px; }   /* no icon: even padding */
/* Not offered here: the slot stays, so nothing beside it moves. */
.guide-act.is-idle { visibility: hidden; }
/* A long exercise name on Next: one line, ellipsis, the arrow kept. */
.guide-next { min-width: 0; }
.guide-next-label { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* === end Auto-guide === */

/* === Look 3 · Glass, type and texture === */
/* Overhaul 2026-09-27, design.md package A. Tim: "where nicer design features
   (glassy textures, different fonts or displays, colors, and textures can be
   implemented in across the cite". The Polish section's old line "no
   backdrop-filter anywhere" is superseded HERE, with its reason kept:
   a blur is only worth its layer where something passes UNDER it.

   THE RULES THIS SECTION KEEPS
   - Glass only on floating chrome and overlays: the phone tab bar, the footers
     (runner, Auto-guide, save), the workout-in-progress strip, sheets, toasts,
     ? boxes and the tour bubble. NEVER on scrolling content (rows, cards,
     charts, the calendar's sticky month heads, the scrim). At most 3 blur
     layers on screen: nav + one footer/strip + one sheet.
   - Every blur is written twice, `-webkit-backdrop-filter` (iOS before 18)
     and `backdrop-filter`, with literal values, not a var().
   - `data-glass="off"`, `prefers-reduced-transparency` and an engine with no
     backdrop-filter all get solid bars and sheets and no grain. Only PAINT is
     switched; the floating layout is the same either way, so the Settings
     switch moves nothing.
   - The glass tint never drops under 76 % (design V-4: 80 % dark / 78 % light
     measured ≥ 5.98:1 for the tab labels over any content). tests/a11y pins it.
   - Tokens are rgb()/color-mix(), never `--x: #RRGGBB;` (the a11y parser reads
     those as text/surface pairs). Level colours are not touched.
   - Nothing here animates or transitions (Rule 7). */

/* ── Type: Barlow Semi Condensed, self-hosted (css/fonts/, OFL) ────────────── */
@font-face {
  font-family: 'Barlow Semi Condensed';
  src: url("fonts/barlow-semi-condensed-600.woff2") format("woff2");
  font-weight: 600; font-style: normal; font-display: swap;
}
@font-face {
  font-family: 'Barlow Semi Condensed';
  src: url("fonts/barlow-semi-condensed-700.woff2") format("woff2");
  font-weight: 700; font-style: normal; font-display: swap;
}

:root {
  --display: 'Barlow Semi Condensed', var(--sans);

  /* Glass. --glass-a is the one number the contrast measurement rests on. */
  --glass-a: 80%;
  --glass-bar:   color-mix(in srgb, var(--surface) var(--glass-a), transparent);
  --glass-float: color-mix(in srgb, var(--raised) 95%, transparent);   /* wave 5: was 84 %, rows read through */
  --glass-sheet: color-mix(in srgb, var(--ground) 92%, transparent);
  --glass-rule:  color-mix(in srgb, var(--rule) 70%, transparent);
  --rim: rgb(255 255 255 / .07);

  /* Texture: fractal grain, its strength baked into the SVG's alpha row (no
     opacity, no blend mode, no full-screen overlay layer). White grain on the
     dark themes, black on the light ones. */
  --grain-lt: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 1  0 0 0 0 1  0 0 0 0 1  0 0 0 .07 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
  --grain-dk: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.9' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0  0 0 0 0 0  0 0 0 0 0  0 0 0 .1 0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E");
  --grain: var(--grain-lt);
  /* The Black Ember logo's light, from the top of the ground. Follows every
     palette because it is the accent. */
  --ember-glow: radial-gradient(120% 60% at 50% -10%, color-mix(in srgb, var(--accent) 9%, transparent), transparent 60%);
  --ground-paint: var(--ember-glow), var(--grain), var(--ground);
}
:root[data-theme="light"] {
  --glass-a: 78%;
  --glass-float: color-mix(in srgb, var(--surface) 95%, transparent);  /* wave 5: was 80 % */
  --glass-sheet: color-mix(in srgb, var(--surface) 92%, transparent);
  --rim: rgb(255 255 255 / .75);
  --grain: var(--grain-dk);
  --ember-glow: radial-gradient(120% 60% at 50% -10%, color-mix(in srgb, var(--accent) 7%, transparent), transparent 60%);
}

/* Big numbers and titles only (21px and up). Body text, labels, buttons,
   pills and chart axes stay the system sans. Condensed reads ~12 % smaller at
   one px, so each steps up; tabular figures are measured working. */
.topbar h1, .session-ex-name, .guide-ex, .step-value, .me-stat-n, .save-title,
.finish-hero h2, .goal-hero-lift, .ws-title, .bench-est-num, .muscle-est, .ob-q {
  font-family: var(--display);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0;
}
.topbar h1 { font-size: 22px; line-height: 1.15; }
.session-ex-name { font-size: 25px; }
.guide-ex { font-size: 29px; }
.step-value { font-size: 34px; }
.step-value.is-duration { font-size: 27px; }
.me-stat-n { font-size: 24px; }
.save-title { font-size: 23px; }
.finish-hero h2 { font-size: 26px; }
.goal-hero-lift { font-size: 28px; }
.ws-title { font-size: 26px; }
.bench-est-num { font-size: 29px; }
.muscle-est { font-size: 24px; }
.ob-q { font-size: 27px; }
/* Condensed digits are ~0.8× as wide, so the Auto-guide's big number steps up
   and its long-number steps (guide-mode.js boxFit) keep the same fit. */
.guide .step-value { font-size: 44px; }
.guide .step-value.fit-md { font-size: 32px; }
.guide .step-value.fit-sm { font-size: 28px; }
.guide .step-value.fit-xs { font-size: 24px; }
@media (min-width: 860px) {
  .topbar h1 { font-size: 26px; }
  .step-value { font-size: 38px; }
}

/* V-12: WebKit drew number spin arrows inside the phone steppers too (the old
   hide rule lived in the laptop query). The ± buttons are the control. */
.step-value { -moz-appearance: textfield; appearance: textfield; }
.step-value::-webkit-inner-spin-button,
.step-value::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }

/* ── Texture: the ground ───────────────────────────────────────────────────── */
body { background: var(--ground-paint); }
/* The header sits on the glow (nothing scrolls under it). */
.topbar, .pane-top { background: transparent; }
/* A surface that moves as a whole carries the same ground, so the glow does
   not switch on at the end of a slide. */
.screen.rises, .screen-ghost, .screen.nav-moving, .nav-snap, #app.nav-card-app {
  background: var(--ground-paint);
}
/* V-14: the header's hairline only once the screen has scrolled (app.js puts
   `is-scrolled` on <html>), or where a fixed region sits under it. A colour
   change only: no size changes, nothing moves. */
.topbar { border-bottom-color: transparent; }
.is-scrolled .topbar,
.screen:has(> .pane-top) > .topbar { border-bottom-color: var(--rule-soft); }

/* ── Glass: the chrome floats over the scroller ────────────────────────────
   Phone: the screen runs under the tab bar, and its LAST child pays the bar
   in padding. The padding equals the extra height, so what a pane can scroll
   (scrollHeight − clientHeight) is unchanged. */
@media (max-width: 859px) {
  #app > .navbar { position: relative; z-index: 20; }
  .screen:not(.no-nav) { margin-bottom: calc(-1 * (var(--bar-h) + var(--safe-b))); }
  .screen:not(.no-nav) > .pane-scroll:last-child { padding-bottom: calc(11px + var(--bar-h) + var(--safe-b)); }
  .screen:not(.no-nav) > .pane-bottom:last-child { padding-bottom: calc(10px + var(--bar-h) + var(--safe-b)); }
  .screen:not(.no-nav) > .session-mini:last-child { padding-bottom: calc(var(--bar-h) + var(--safe-b)); }
}
/* Both widths: the footers float too. --chrome-b is their live height (app.js,
   ResizeObserver); 0 until measured, and padding = negative margin, so a late
   value changes only how far the list can scroll. */
.pane-scroll, .guide-scroll { isolation: isolate; }
.screen > .pane-scroll:has(~ :is(.pane-bottom, .session-footer, .session-mini)),
.guide-scroll {
  margin-bottom: calc(-1 * var(--chrome-b, 0px));
  padding-bottom: calc(11px + var(--chrome-b, 0px));
  scroll-padding-bottom: var(--chrome-b, 0px);
}
.pane-bottom, .session-footer, .session-mini { position: relative; z-index: 10; }

/* The paint. */
.pane-bottom, .session-footer, .session-mini {
  background-color: var(--glass-bar);
  background-image: var(--grain);
  -webkit-backdrop-filter: saturate(170%) blur(20px);
  backdrop-filter: saturate(170%) blur(20px);
  border-top-color: var(--glass-rule);
  box-shadow: inset 0 1px 0 var(--rim);
}
@media (max-width: 859px) {
  #app > .navbar {
    background-color: var(--glass-bar);
    background-image: var(--grain);
    -webkit-backdrop-filter: saturate(170%) blur(20px);
    backdrop-filter: saturate(170%) blur(20px);
    border-top-color: var(--glass-rule);
    box-shadow: inset 0 1px 0 var(--rim);
  }
}

/* Overlays. The scrim stays a plain dim (a full-screen blur that fades is the
   most expensive layer there is). */
.sheet, .sheet-x {
  background-color: var(--glass-sheet);
  -webkit-backdrop-filter: saturate(170%) blur(20px);
  backdrop-filter: saturate(170%) blur(20px);
}
.sheet.s2-phone::after, .sheet-x.s2-phone-x::after { background: var(--glass-sheet); }
.help-pop, .tour-bubble {
  background-color: var(--glass-float);
  -webkit-backdrop-filter: saturate(170%) blur(20px);
  backdrop-filter: saturate(170%) blur(20px);
}
.help-pop::before, .tour-arrow { background: var(--glass-float); }
/* Light keeps its inverted ink toast (a note laid over the page). */
:root:not([data-theme="light"]) :is(.toast, .toast-x) {
  background-color: var(--glass-float);
  -webkit-backdrop-filter: saturate(170%) blur(20px);
  backdrop-filter: saturate(170%) blur(20px);
}

/* ── Glass off: solid bars and sheets, no grain ────────────────────────────
   Three ways in, one answer: the Settings switch, the OS setting, and an
   engine that cannot blur. */
:root[data-glass="off"] {
  --glass-bar: var(--surface); --glass-float: var(--raised); --glass-sheet: var(--ground);
  --glass-rule: var(--rule); --grain: none;
}
:root[data-glass="off"][data-theme="light"] { --glass-float: var(--surface); }
[data-glass="off"] :is(#app > .navbar, .pane-bottom, .session-footer, .session-mini, .sheet, .sheet-x,
  .toast, .toast-x, .help-pop, .tour-bubble) {
  -webkit-backdrop-filter: none; backdrop-filter: none;
}
@media (prefers-reduced-transparency: reduce) {
  :root, :root[data-theme="light"] {
    --glass-bar: var(--surface); --glass-float: var(--raised); --glass-sheet: var(--ground);
    --glass-rule: var(--rule); --grain: none;
  }
  :root[data-theme="light"] { --glass-float: var(--surface); }
  :is(#app > .navbar, .pane-bottom, .session-footer, .session-mini, .sheet, .sheet-x,
    .toast, .toast-x, .help-pop, .tour-bubble) {
    -webkit-backdrop-filter: none; backdrop-filter: none;
  }
}
@supports not ((backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px))) {
  :root, :root[data-theme="light"] {
    --glass-bar: var(--surface); --glass-float: var(--raised); --glass-sheet: var(--ground);
    --glass-rule: var(--rule);
  }
  :root[data-theme="light"] { --glass-float: var(--surface); }
}

/* ── Metal ─────────────────────────────────────────────────────────────────
   The runner's plate drawing takes bar-view.js's steel and plate-face
   gradients as SVG defs in its own markup (plates.js); nothing to add here. */

/* ── The laptop sidebar's mark (V-15): the Black Ember icon before the name ── */
.nav-brand-mark { width: 22px; height: 22px; flex: none; display: block; border-radius: 5px; }
/* The mark took the name's room: "Fitness Trac…" at 1366. Tighter gap, and the
   condensed display face keeps the whole name on one line. */
.nav-brand { gap: 7px; }
.nav-brand-name { font-family: 'Barlow Semi Condensed', var(--font, system-ui), sans-serif; letter-spacing: 0; }
/* === end Look 3 === */

/* === Overhaul wave 2 · layout requests (2026-09-27) === */
/* The CSS the wave-1 builders asked for (they could not edit this file), plus
   the screens that had to fit. Each block names who asked. */

/* ── R-11: landscape on a notched phone. iOS ignores the manifest's
   orientation for a home-screen app; the notch side gets its inset. ── */
#app {
  padding-left: env(safe-area-inset-left, 0px);
  padding-right: env(safe-area-inset-right, 0px);
}

/* ── SHELL: a toast with an action, and an error toast ── */
.toast-action {
  flex: none;
  min-height: 44px;
  margin: -12px -8px -12px 10px;
  padding: 0 8px;
  background: none; border: 0;
  color: var(--accent);
  font: inherit; font-weight: var(--fw-bold);
  cursor: pointer;
}
/* Light inverts the toast (ink ground); the light accent is too dark on it,
   so the action takes the accent's pale tint instead. */
:root[data-theme="light"] .toast-action { color: var(--accent-dim); }
:root:not([data-theme="light"]) :is(.toast, .toast-x).toast-error {
  background-color: color-mix(in srgb, var(--danger) 26%, var(--raised));
}
:root[data-theme="light"] :is(.toast, .toast-x).toast-error {
  background-color: color-mix(in srgb, var(--danger) 30%, var(--ink));
}

/* ── EDIT ── */
.field-help .help-dot { margin-left: 4px; }

/* ── INTRO ── */
.ob-overlay ~ .help-pop, .ob-overlay ~ .help-pop-x { z-index: 85; }
.ob-icon { flex: none; width: 48px; height: 48px; border-radius: 12px; margin-top: 8px; }
.ob-signin { margin: 4px 0 0; }
.ob-title { align-items: center; }
.ob-weight-row { display: flex; gap: 10px; align-items: center; }
.ob-weight-row .input { flex: 1; min-width: 0; }
.ob-day-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.ob-day-ex { font-size: var(--fs-md); color: var(--ink-faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ob-plan { margin: 2px 0 0; font-size: var(--fs-md); color: var(--ink-soft); }
.first-save-actions { display: flex; flex-direction: column; gap: 10px; }
.first-save-ios { display: flex; gap: 6px; align-items: center; margin: 12px 0 0; }

/* ── WORKOUTS ── */
.feed-ex-top {
  margin-left: auto; flex: none;
  color: var(--ink-faint); font-size: var(--fs-sm);
  white-space: nowrap; font-variant-numeric: tabular-nums;
}
.feed-self-meta { margin-bottom: 6px; }
.feed-sentinel { height: 1px; }
.feed-offline { display: flex; align-items: center; justify-content: space-between; gap: 8px; color: var(--ink-soft); }
.sys-more-body { display: grid; gap: 12px; }
.sys-more-body .own-rating-head { flex-wrap: wrap; }

/* ── GOALS: laptop fits 1366×820 ── */
/* Measured at 1366×820 with the demo bar: the requirements column ran 185px
   past the pane (the left column fits). Same words, tighter leading: the
   column's gaps 12 → 6, rows lose their 44px floor (they are not buttons),
   the verdict lines sit 3px apart instead of 6. */
@media (min-width: 1200px) {
  .goal-reqs .req-row { min-height: 34px; padding-block: 2px; }
  .goal-screen > .goal-reqs { gap: 6px; }
  .goal-screen > .goal-reqs .goal-verdict { gap: 2px; }
  .goal-screen > .goal-reqs :is(.req-row .row-sub, .help-line, .field-help, .goal-verdict-body) { line-height: 1.3; }
}
.goal-end-link { white-space: nowrap; }

/* ── ME: the compact Profile head and the week dots ── */
.me-face.is-sm { width: 48px; height: 48px; }
.me-head.is-compact { gap: 12px; }
.me-head.is-compact .me-who { flex: 1; gap: 2px; }
.me-head.is-compact .me-name { font-size: var(--fs-lg); }
.me-head.is-compact .me-stats { display: flex; }
.me-head.is-compact .me-stat { flex-direction: row; align-items: baseline; gap: 5px; padding: 2px 12px 2px 0; }
.me-head.is-compact .me-stat + .me-stat { padding-left: 12px; }
/* 17px is under the display tier: the compact figures stay the system sans. */
.me-head.is-compact .me-stat-n { font-size: 17px; font-family: var(--sans); letter-spacing: -0.02em; }
.me-week-dots { display: inline-flex; gap: 5px; margin-right: 12px; flex: none; }
.me-week-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--rule); }
.me-week-dot.is-on { background: var(--ink); }
.me-week-dot.is-today { box-shadow: 0 0 0 1.5px var(--ink-faint); }
.me-week-usual { flex: none; margin-left: 8px; font-size: var(--fs-sm); color: var(--ink-faint); }
.feed-more { height: 1px; }
.home-hint { margin-top: 2px; }

/* ── ME: a best lift is three clean lines, not a squeezed column ──────────
   At 393px the right-hand words ("now 228 · High confidence · Intermediate",
   ~210px) squeezed the name into two lines and the set into three. Now:
     name ………………………… number  ›
     the set · the date · trained N days
     now 228 · High confidence · Intermediate
   The name and each line stay one line (ellipsis), the number keeps its
   level chip, and a row with no chevron keeps the chevron's column. */
.me-best {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto;
  grid-template-areas: "title top chev" "sub sub chev" "ever ever chev" "est est chev";
  column-gap: 10px; row-gap: 1px;
  align-items: baseline;
}
.me-best > .row-main, .me-best > .me-best-nums { display: contents; }
.me-best .row-title {
  grid-area: title; min-width: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis; line-height: 1.3;
}
.me-best .me-best-top, .me-best .me-best-none { grid-area: top; justify-self: end; text-align: right; max-width: none; }
.me-best .row-sub.wrap {
  grid-area: sub; min-width: 0; margin-top: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.me-best .row-sub.wrap .me-best-bit { display: inline; white-space: nowrap; max-width: none; }
.me-best .me-best-ever { grid-area: ever; min-width: 0; margin-top: 0; }
.me-best .me-best-est {
  grid-area: est; text-align: left; min-width: 0;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.me-best > .row-chev { grid-area: chev; align-self: center; }
.list:has(> .me-best > .row-chev) > .me-best:not(:has(> .row-chev))::after { grid-area: chev; }

/* ── SOCIAL SC-3: one legend shape for Muscles and Compare ── */
.lv-key-wrap.lv-compact { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 5px; }
.lv-compact .lv-key, .lv-compact .lv-notes { display: contents; }
.lv-compact .lv-chip { min-height: 18px; padding: 0 6px; font-size: 11px; line-height: 18px; }
.lv-compact .lv-key-item { font-size: 11px; gap: 3px; }
.lv-compact .lv-sw { width: 8px; height: 8px; }
@media (max-width: 859px) {
  .graph-host.is-muscles .body-wrap { flex: none; height: 300px; }
}
/* ── SOCIAL SC-9: the compare list ── */
.cmp-layout { display: flex; flex-direction: column; gap: 12px; }
.cmp-bodies { min-width: 0; display: flex; flex-direction: column; gap: 8px; }
.cmp-list { display: flex; flex-direction: column; }
.cmp-lrow {
  display: grid; grid-template-columns: 1fr 1fr 1fr; align-items: center; gap: 6px;
  min-height: 34px; padding: 2px 4px;
  background: none; border: 0; border-bottom: 1px solid var(--rule-soft); border-radius: 0;
  color: var(--ink); font: inherit; text-align: left;
}
.cmp-lrow-head { min-height: 22px; font-size: var(--fs-sm); color: var(--ink-soft); }
.cmp-lrow[aria-pressed="true"] { background: var(--surface); }
.cmp-lrow .cmp-m { font-weight: var(--fw-med); }
.cmp-lrow .lv-chip { min-height: 20px; padding: 0 8px; font-size: var(--fs-sm); }
.cmp-none { color: var(--ink-faint); }
.cmp-who { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (min-width: 860px) {
  .cmp-layout { flex-direction: row; align-items: flex-start; }
  .cmp-bodies { flex: 2 1 0; }
  .cmp-list { flex: 1 1 0; max-width: 33.33%; }
}

/* ── PROG: a cycle of up to 28 days fits a phone ──────────────────────────
   Three 92px columns made 28 days ten rows (~450px). Past 14 boxes the phone
   lays them out a week to a row, seven across — a 28-day cycle is four rows
   and reads as four weeks. Names wrap inside their box, never sideways. */
@media (max-width: 859px) {
  .plan-grid:has(> .plan-day:nth-child(15)) { grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 4px; }
  .plan-grid:has(> .plan-day:nth-child(15)) > .plan-day { padding: 4px 4px 5px; }
  .plan-grid:has(> .plan-day:nth-child(15)) .plan-slot { font-size: var(--fs-sm); line-height: 1.2; overflow-wrap: break-word; hyphens: auto; }
}

/* ── DATA: Volume rows are one line — name · bar · number ── */
.vol-row { flex-direction: row; align-items: center; gap: 10px; min-height: 40px; padding: 6px 0; }
.vol-head { display: contents; }
.vol-name { flex: 0 0 7.5em; }
.vol-track { flex: 1; order: 1; }
.vol-num { order: 2; min-width: 4.5em; text-align: right; }
.vol-tick-key { font-size: var(--fs-xs); opacity: .85; }
.vol-tick-mark { font-size: var(--fs-xs); color: var(--ink-faint); margin-right: 1px; }
@media (max-width: 859px) {
  .vol-figure { max-height: 260px; max-width: calc(260px * var(--body-ar, 1)); }
  /* The key on one line: smaller chips, and it may scroll sideways inside
     itself rather than wrap to a second row. */
  .vol-legend { flex-wrap: nowrap; gap: 4px; overflow-x: auto; overflow-y: hidden; scrollbar-width: none; }
  .vol-legend::-webkit-scrollbar { display: none; }
  .vol-legend > * { flex: none; }
  .vol-legend .vol-chip { padding: 3px 7px; gap: 4px; font-size: var(--fs-xs); }
  .vol-legend .vol-chip i { width: 9px; height: 9px; }
  .vol-legend-unit { font-size: var(--fs-xs); }
}
@media (min-width: 1024px) {
  .bars { display: grid; grid-template-columns: 1fr 1fr; column-gap: 24px; align-content: stretch; }
}

/* ── DATA: Settings fits without a page scroll ────────────────────────────
   Measured before: 200px over at 393×659, 54px over at 1366×820 (demo bar
   on). Every control stays one line; rows are 32px (each control keeps a 44px
   hit area that reaches into the row gap). On a phone the Workout group's four
   switches sit two to a line; on a laptop the groups sit in two columns. */
.set-group { gap: 0; }
.set-group > .section-label { padding-top: 0; line-height: 16px; }
.set-group .switch-row { min-height: 32px; }
.pane-scroll:has(> .set-group) { gap: 6px; }
.plates-sheet { display: flex; flex-direction: column; gap: 12px; }
.set-note { margin-left: 6px; }
.set-door-chip { max-width: 60%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
@media (max-width: 859px) {
  .set-group:has(> .switch-row + .switch-row + .switch-row):not(:has(> .set-chips, > .set-door, > .help-line, > .set-rows)) {
    display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); column-gap: 18px;
  }
  .set-group:has(> .switch-row + .switch-row + .switch-row):not(:has(> .set-chips, > .set-door, > .help-line, > .set-rows)) > .section-label {
    grid-column: 1 / -1;
  }
  .set-group .switch-row > .help-line { min-width: 0; }
}
@media (min-width: 860px) {
  .pane-scroll:has(> .set-group) {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 440px));
    justify-content: center; align-content: start;
    column-gap: 56px; row-gap: 14px;
  }
  .pane-scroll.pane-scroll:has(> .set-group) > :is(.field, *) { max-width: none; width: auto; margin-inline: 0; }
}
/* ── RUNNER-2: the save form, the kit chips, sitting out ── */
/* "Leave out" sat as 3px-padded faint text: a 44px-tall target now, and the
   line never wraps it away from its ?. */
.save-unopened { flex-wrap: nowrap; align-items: center; }
.save-unopened > .field-help { min-width: 0; }
.save-unopened-btn {
  flex: none; white-space: nowrap;
  min-height: 44px; margin-block: -12px;
  padding: 0 4px 0 8px;
  color: var(--accent); font-weight: var(--fw-med);
}
/* Equipment chips sit 4px above their hint line (the sheet's gap was 12). */
.equip-today { min-height: 0; margin-bottom: -8px; }
/* "You · out": the owner is sitting out, so their chip reads as off.
   Selected by the ✕ beside it (its label flips to "Train again…"), because
   the chip itself carries no class for it; `.is-out` works too if added. */
.person-wrap:has(> .person-del[aria-label^="Train again"]) > .person-chip,
.person-chip.is-out { opacity: .55; }
/* The save form stops at the 510px measure on a laptop; Save now stops there
   too, at the same left edge. */
@media (min-width: 860px) {
  .pane-scroll:has(> .save-screen) ~ .pane-bottom > * {
    max-width: 510px; margin-inline: max(0px, (100% - 820px) / 2) auto;
  }
}
@media (min-width: 1200px) {
  .pane-scroll:has(> .save-screen) ~ .pane-bottom > * { margin-inline: max(0px, (100% - 940px) / 2) auto; }
}
/* === end Overhaul wave 2 · layout requests === */

/* === Overhaul wave 2 · hit areas (R-5 / I-7) === */
/* Invisible hit-slop only: the painted size of every control is unchanged, so
   nothing moves (design-taste 9). Same pattern as `.icon-btn::before` at the
   top of the file — ::before, because several of these already use ::after.
   A halo that reaches a neighbour loses to it where the neighbour is later in
   the page, so two controls never fight over one point for long.
   `.set-del` / `.person-del` stay deliberately small (overhaul plan). */
.add-set, .switch, .mini-btn, .link-btn, .feed-act, .session-clock,
.move-btns button, .text-link { position: relative; }
.add-set::before, .switch::before, .mini-btn::before, .link-btn::before,
.feed-act::before, .move-btns button::before {
  content: '';
  position: absolute;
  top: 50%; left: 50%;
  width: 100%; height: 44px;
  min-width: 44px;
  transform: translate(-50%, -50%);
}
/* The workout clock is a few px of digits between the back button and the
   date: its halo grows 44px tall, and sideways only over the dot, so it takes
   nothing from either neighbour. The sub-line clipped it (`.topbar-sub`
   overflow: hidden, for an ellipsis that a wrapping flex row never shows). */
.topbar-sub.session-sub { overflow: visible; }
.session-clock::before {
  content: '';
  position: absolute;
  top: 50%; left: -2px; right: -9px;
  height: 44px;
  transform: translateY(-50%);
  z-index: 1;
}
/* A text link inside a line grows downward and upward only. */
.text-link::before {
  content: '';
  position: absolute;
  left: 0; right: 0; top: 50%;
  height: 44px;
  transform: translateY(-50%);
}
/* The avatar clips its photo (`overflow: hidden`), which also clipped its
   ::before halo to 34px. A transparent 5px border is part of the box a finger
   hits but not of the circle an eye sees (the photo and fill stay in the
   padding box); the negative margin keeps its footprint 34px. */
.topbar .avatar-btn {
  width: 44px; height: 44px;
  margin: -5px;
  border: 5px solid transparent;
  background-clip: padding-box;
}
/* === end Overhaul wave 2 · hit areas === */

/* === Overhaul wave 5 · visual QA (2026-09-27) === */
/* Fixes from the wave-4 visual QA, each re-measured in WebKit at 393×659 and
   1366×820 (demo). Level colours untouched; new tokens are rgb()/color-mix();
   nothing here animates or transitions (Rule 7). */

/* ── 1. Light: the runner's and Auto-guide's buttons had no visible fill ────
   Measured: `--raised` rgb(231,233,228) on a painted ground of rgb(231,232,229)
   (the ground + dark grain). Light controls now wear the light theme's own
   raised look — a white face, a warm hairline and the small drop the `.btn`
   family has — so they read as buttons on any ground. Dark is untouched. */
:root[data-theme="light"] :is(.step-btn, .pill-action, .set-done-btn:not(.is-done), .add-set) {
  background-color: var(--surface);
  box-shadow: inset 0 0 0 1px rgb(var(--shadow-rgb) / .28), 0 1px 2px rgb(var(--shadow-rgb) / .12);
}
:root[data-theme="light"] :is(.step-btn, .pill-action, .set-done-btn:not(.is-done), .add-set):active {
  background-color: var(--raised-hi);
  box-shadow: inset 0 0 0 1px rgb(var(--shadow-rgb) / .28);
}

/* ── 2. A toast sits above whatever floats at the bottom ───────────────────
   It sat at a fixed 24px on a laptop — on the runner, over Back / Next.
   `--chrome-b` (app.js) is the live height of the footers, the mini strip and
   the phone tab bar, so the toast clears all of them at every width. */
.toast, .toast-x { bottom: calc(max(var(--chrome-b, 0px), var(--bar-h) + var(--safe-b)) + 14px); }
@media (min-width: 860px) {
  .toast, .toast-x { bottom: calc(var(--chrome-b, 0px) + 24px); }
}

/* ── 3. Data → Muscles (phone): the summary line was cut off ───────────────
   The footer under the figures is 104px; its content was 128 ("Furthest
   behind: Calves (Novice)" hidden). Tighter gaps and leading, same words,
   and the figures give up 8px. */
@media (max-width: 859px) {
  .graph-host.is-muscles .body-wrap { height: 292px; }
  .graph-host.is-muscles .body-foot { gap: 4px; }
  .graph-host.is-muscles .body-foot > .card { gap: 2px; }
  .graph-host.is-muscles .body-foot > .card > .field-help { line-height: 1.3; }
}

/* ── 4. ? boxes stay readable over rows ────────────────────────────────────
   At 84 % (dark) / 80 % (light) the rows under a ? box read through its text.
   Pop-ups are now 95 %, like the sheets; the bars keep their 80 / 78 %. The
   value is set in Look 3's own :root blocks (--glass-float), so glass off and
   Reduce Transparency still make it solid. */

/* ── 5. An inline ? in running text no longer overlaps the last word ──────
   Its -9px margins cancel the 44px halo; to the left that pulled it over
   "…away." The halo stays; the left side takes a real gap instead. */
.empty :is(.empty-title, p) > .help-dot,
p > .help-dot { margin: -9px -9px -9px 4px; }

/* ── 6. A wrapped sentence keeps its ? right after its last word ──────────
   As a flex row the wrapped sentence's box was the full width, so the ? sat
   at x=353 while the words ended at x=122 (Explore, 393px). A sentence and its
   ? are now one run of text; the ? follows the last word on whatever line
   that is. Labels (Settings) and three-part lines keep the flex row. */
.help-line:has(> :is(.field-help, .note):first-child):has(> .help-dot:nth-child(2):last-child) { display: block; }
:where(.help-line:has(> .field-help:first-child):has(> .help-dot:nth-child(2):last-child)) { font-size: var(--fs-md); line-height: 1.45; }
:where(.help-line:has(> .note:first-child):has(> .help-dot:nth-child(2):last-child)) { font-size: var(--fs-md); line-height: 1.5; }
.help-line:has(> .help-dot:nth-child(2):last-child) > :is(.field-help, .note):first-child { display: inline; }
.help-line:has(> :is(.field-help, .note):first-child) > .help-dot:nth-child(2):last-child { margin: -9px -9px -9px 6px; }

/* ── 7. Laptop alignment ─────────────────────────────────────────────────── */
/* The rule: a section head starts where its rows start. A reading screen is
   the 820 / 940px centred column; a two-pane screen is the 1180 grid. The
   heads were outside the column (`.section-head`'s `margin: 18px 0 2px` beat
   the column's `margin-inline: auto`), 91px left of their rows on Goals. */
@media (min-width: 860px) {
  .pane-scroll > .section-head { margin-inline: auto; }
  /* The runner's exercise name, its meta line and Swap / Remove (lifted out
     of their column by `display: contents`) sit back in the column. */
  .pane-scroll > .session-head > * { width: 100%; max-width: 820px; margin-inline: auto; }
  /* Steppers: − and + were 285px apart at 1366. The − value + row stops at
     240px, like the Auto-guide's; the captions above keep their width. */
  .set-open .stepper-controls { max-width: 240px; margin-inline: auto; }
  /* Auto-guide: one centred column. The set list and Swap / Skip, the
     progress dashes and Back / Next share the steppers' 416px column. */
  .guide-top { width: 100%; max-width: 460px; margin-inline: auto; }
  .screen:has(> .guide:not([hidden])) > .session-progress { padding-inline: max(var(--pad), (100% - 416px) / 2); }
  .guide > .guide-footer { padding-inline: max(var(--pad), (100% - 416px) / 2); }
  .guide > .guide-footer .btn { max-width: none; }
  /* Save: "Discard workout" peeked under the Save footer (54px over at
     1366×820). Tighter gaps and a shorter note box; the form fits. */
  .save-screen { gap: 10px; }
  .save-screen textarea.input { height: 64px; }
}
@media (min-width: 1200px) {
  .pane-scroll > .session-head > * { max-width: 940px; }
}

/* ── 8 + 9. Settings ─────────────────────────────────────────────────────
   One text size for the row labels, the chips and the Account row (13px);
   one chip height (the door chips were 31, the rest 29). With a workout
   running, the mini strip took 65px and hid Account: the rows tighten so the
   screen still fits without a scroll. */
.set-group label, .set-group .chip { font-size: var(--fs-md); }
.set-group label { white-space: nowrap; }
.set-group .chip { min-height: 32px; }
/* The Workout group's two-to-a-line switches keep their labels on one line at
   13px: the gaps inside each pair tighten, and under 390px the label steps
   back to 11.5px rather than wrap. */
@media (max-width: 859px) {
  .set-group:has(> .switch-row + .switch-row + .switch-row):not(:has(> .set-chips, > .set-door, > .help-line, > .set-rows)) { column-gap: 10px; }
  .set-group:has(> .switch-row + .switch-row + .switch-row):not(:has(> .set-chips, > .set-door, > .help-line, > .set-rows)) > .switch-row { gap: 6px; }
  .set-group:has(> .switch-row + .switch-row + .switch-row):not(:has(> .set-chips, > .set-door, > .help-line, > .set-rows)) .help-line { gap: 6px; }
}
@media (max-width: 389px) {
  .set-group:has(> .switch-row + .switch-row + .switch-row):not(:has(> .set-chips, > .set-door, > .help-line, > .set-rows)) label { font-size: var(--fs-sm); }
}
.pane-scroll:has(> .set-group) > .row .row-title { font-size: var(--fs-md); font-weight: var(--fw-med); }
@media (max-width: 859px) {
  .screen:has(> .session-mini) > .pane-scroll:has(> .set-group) { gap: 2px; }
  .screen:has(> .session-mini) .set-group .switch-row,
  .screen:has(> .session-mini) .set-group .chip { min-height: 28px; }
  .screen:has(> .session-mini) .set-group > .section-label { line-height: 14px; }
}

/* ── 10. The Auto-guide's set list reads at arm's length ──────────────────
   It was 11.5px regular. 13px at medium weight; still six rows before it
   scrolls inside itself, and the guide still fits 393×659. */
.guide-sets { font-size: var(--fs-md); font-weight: var(--fw-med); line-height: 18px; max-height: calc(6 * 18px); }

/* ── 11. Light calendar: empty days are visible ───────────────────────────
   `--rule-soft` on the grained light ground measured ~1:1. Empty days take the
   white face; trained days keep their colours. */
:root[data-theme="light"] .yr-cell:not(.on),
:root[data-theme="light"] .cal-cell:not(.blank):not(.has-workout):not(.has-benchmark-only) {
  background-color: var(--surface);
}

/* ── 12. Hit areas without moving anything ────────────────────────────────
   `.set-del` stays 21×21 on purpose (overhaul plan "must not change": delete
   should not be easy to hit by accident). `.m-name-btn` gets a 44px floor for
   short names. `.help-dot` already hits 44×44 (its ::after). */
.m-name-btn::after { inset: -13px auto; left: 50%; width: max(100% + 8px, 44px); transform: translateX(-50%); }

/* ── 13. Profile (laptop): a best lift's source line wraps to two lines ────
   "Estimated from Overhead Press, Barbell Bench Press, Incline Dumbbell Bench
   Press · ne…" was cut at one line. The phone keeps its one-line ellipsis. */
@media (min-width: 860px) {
  .me-best .row-sub.wrap {
    white-space: normal;
    display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  }
  .me-best .row-sub.wrap .me-best-bit { white-space: normal; }
}

/* ── 14. The deploy notice is one line ────────────────────────────────────
   Centred by `left: 50%`, it could only use half the screen (197px), so "A new
   version is ready." wrapped to three lines. It sizes to its content now. */
.update-bar { width: max-content; white-space: nowrap; }

/* ── 15. The runner's sticky exercise name blends into the ground ─────────
   A solid `--ground` band showed against the glow and grain. It is a thin
   glass strip now, like the other floating chrome (one layer; the runner has
   no tab bar), and solid again with glass off.
   The glass is a layer behind the row that also covers the pane's 11px top
   padding: a sticky box stops at the padding edge, and the rows scrolling
   through that strip showed above the name. */
.pane-scroll > .session-head > .session-head-row { background: none; }
.pane-scroll > .session-head > .session-head-row::before {
  content: '';
  position: absolute;
  inset: -11px 0 0;
  z-index: -1;
  pointer-events: none;
  background-color: color-mix(in srgb, var(--ground) 80%, transparent);
  background-image: var(--grain);
  -webkit-backdrop-filter: saturate(170%) blur(20px);
  backdrop-filter: saturate(170%) blur(20px);
}
[data-glass="off"] .pane-scroll > .session-head > .session-head-row::before {
  background-color: var(--ground);
  -webkit-backdrop-filter: none; backdrop-filter: none;
}
@media (prefers-reduced-transparency: reduce) {
  .pane-scroll > .session-head > .session-head-row::before {
    background-color: var(--ground);
    -webkit-backdrop-filter: none; backdrop-filter: none;
  }
}
/* === end Overhaul wave 5 · visual QA === */
