/* ==========================================================
   Theme — "Neartial"
   ----------------------------------------------------------
   Imported via the DesignSync MCP tool from the "Neartias Design
   System" claude.ai/design project (id 14194842-…, design-system id
   f56aeed1-…), then adopted as the site's one permanent visual
   identity — there used to be a three-way switch between this design,
   a plainer "classic" amber/steel look, and an "Ive mode" (both now
   removed) but the site settled on this one, so the token names below
   hold its values directly rather than sitting behind a conditional
   [data-design] attribute. Ink-graphite surfaces, warm-ivory text,
   ember (red-orange) as the default accent — six curated accents are
   still admin-selectable (see the `[data-accent="…"]` blocks below),
   and light/dark is still admin-selectable (`[data-appearance="light"]`
   below) — Space Grotesk for display type, Manrope for body copy,
   JetBrains Mono for anything numeric. No frosted-glass/blur anywhere:
   the source design system's readme explicitly rules it out. Every
   screen is built on these shared tokens. The Library screen's own
   component styles further down additionally use --lib-* tokens for a
   few surfaces (translucent glass-card panels/lines rather than solid
   ones) — a deliberately different texture kept for that screen's
   denser, spec-sheet feel. */
:root {
  /* Raw values are the single source of truth; every name below this
     block (the --bg/--panel/--accent/--lib-* short names, kept so none
     of the ~2000 lines of component CSS below has to change) is an
     alias pointing back at one of these, never a second copy of the
     hex — that's what keeps re-theming centralized instead of drifting
     the two naming layers apart. */
  --color-bg: #0a0b0d;
  --color-surface: #171a1e;
  --color-surface-2: #1c1f24;
  --color-border: #262a31;
  --color-text: #f4f1ea;
  --color-text-muted: #9a9ea6;
  --color-amber: #d1461f;
  --color-amber-light: #e9683f;
  --color-coral: #c9a227;
  --color-blue: var(--color-amber);
  --color-danger: #c23b3b;
  --color-success: #5b9e6f;
  --color-warning: #c9a227;
  --color-white-04: #ffffff0d;
  --color-white-08: #ffffff14;
  --color-white-10: #ffffff1a;
  --color-white-20: #ffffff33;

  /* Semantic aliases — what component CSS should reference going
     forward. One warm accent (ember, aliased through --color-amber for
     historical reasons) drives nearly everything, including links —
     unlike a fixed-blue-for-links scheme, --color-blue here always
     tracks whichever accent is selected below, so switching the accent
     recolors links too, not just buttons. Brass (--color-coral) is the
     secondary/achievement accent. */
  --surface-app: var(--color-bg);
  --surface-card: var(--color-surface);
  --surface-card-2: var(--color-surface-2);
  --surface-glass: var(--color-white-04);
  --surface-glass-2: var(--color-white-08);
  --border-default: var(--color-border);
  --border-glass: var(--color-white-10);
  --border-glass-strong: var(--color-white-20);
  --text-primary: var(--color-text);
  --text-muted: var(--color-text-muted);
  --accent-primary: var(--color-amber);
  --accent-primary-ink: var(--color-amber-light);
  --accent-primary-soft: rgba(209, 70, 31, 0.16);
  /* Text/icons on a filled accent surface. applyTheme() overwrites this
     with whichever of near-black/near-ivory actually contrasts with the
     chosen accent; this literal is only the pre-JS fallback. */
  --accent-ink: #f4f1ea;
  /* Text sitting on the --accent-primary-soft wash (the lib-* "accent"
     tags/badges/pills) - applyTheme() overwrites this with an accent
     variant guaranteed to clear 4.5:1 against that actual composited
     wash, not just against the plain background; --accent-2's fixed
     lightness-shift measured only ~2.4:1-4:1 there depending on
     mode/accent, and this literal is only the pre-JS fallback. */
  --accent-on-soft: var(--color-amber-light);
  /* Accent used as plain text directly on the page/topbar background
     (nav active state) - no wash underneath it, so this needs its own
     contrast pass against --color-bg specifically. applyTheme() sets
     this to the same value as --color-blue (both are ensureContrastOn()
     against --color-bg), just under a name that doesn't carry --link's
     "blue is for informational links" semantic. Pre-JS fallback only. */
  --accent-on-page: var(--color-amber-light);
  --accent-secondary: var(--color-coral);
  --accent-secondary-soft: rgba(201, 162, 39, 0.16);
  --link: var(--color-blue);
  --link-soft: var(--accent-primary-soft);
  --link-hover: var(--color-amber-light);
  --danger: var(--color-danger);
  --success: var(--color-success);
  --warning: var(--color-warning);
  --warning-soft: rgba(245, 158, 11, 0.12);
  --border-hover: #343941;
  --ring-soft: rgba(255, 255, 255, 0.06);
  --scrim: rgba(10, 11, 13, 0.72);

  /* Effects */
  --shadow-card: 0 6px 20px rgba(0, 0, 0, 0.4);
  --shadow-glass: 0 1px 2px rgba(0, 0, 0, 0.4);
  --ease-standard: ease;
  /* Must match SETTLE in app.js (cubic-bezier(.16,1,.3,1)) — the one
     "front-loaded settle" curve springIn()/celebrate()/the body-3D heat
     tween/etc. all already use via WAAPI. CSS-only animations (no JS
     driving them) use this token instead of duplicating the raw value at
     every call site. */
  --ease-settle: cubic-bezier(.16, 1, .3, 1);
  --duration-fast: 0.05s;
  --duration-base: 0.15s;
  --duration-slow: 0.25s;

  /* Spacing, radius, minimum tap targets — not a strict 8px grid, values
     run pragmatically and are matched exactly rather than rounded. */
  --space-1: 4px;
  --space-2: 8px;
  --space-3: 10px;
  --space-4: 12px;
  --space-5: 14px;
  --space-6: 16px;
  --space-7: 20px;
  --space-8: 24px;
  --space-9: 32px;
  --space-10: 40px;
  /* Unified to one panel radius, site-wide: half of 10px, the smallest
     panel-scale radius that was actually in use (--radius-control) before
     this pass — every panel/card/modal on every screen used to carry its
     own value anywhere from 8px to 24px; now they all share this one. */
  --radius-sm: 5px;
  --radius-md: 5px;
  --radius-lg: 5px;
  --radius-xl: 5px;
  --radius-2xl: 5px;
  --radius-3xl: 5px;
  --radius-pill: 999px;

  /* Old short names — every existing rule below keeps working unchanged. */
  --bg: var(--surface-app);
  --panel: var(--surface-card);
  --panel-2: var(--surface-card-2);
  --border: var(--border-default);
  --text: var(--text-primary);
  --muted: var(--text-muted);
  --accent: var(--accent-primary);
  --accent-2: var(--accent-primary-ink);
  --shadow: var(--shadow-card);
  --font-mono: "JetBrains Mono", ui-monospace, "SF Mono", "Cascadia Code", "Consolas", "Menlo", monospace;
  --font-display: "Space Grotesk", ui-sans-serif, system-ui, sans-serif;
  --font-body: "Manrope", ui-sans-serif, system-ui, -apple-system, sans-serif;
  --radius-control: 5px;
  --radius-card: 5px;

  --lib-bg: var(--surface-app);
  --lib-panel: var(--surface-glass);
  --lib-panel-2: var(--surface-glass-2);
  --lib-line: var(--border-glass);
  --lib-line-strong: var(--border-glass-strong);
  --lib-text: var(--text-primary);
  --lib-muted: var(--text-muted);
  --lib-accent: var(--accent-primary);
  --lib-accent-ink: var(--accent-on-soft);
  --lib-accent-soft: var(--accent-primary-soft);
  --lib-accent-2: var(--accent-secondary);
  --lib-accent-2-soft: var(--accent-secondary-soft);
  --lib-shadow: var(--shadow-glass);

  /* --scale used to be bumped by a text-size toggle (html[data-size="large"]);
     the toggle is gone, so this is now a fixed 1 — left in place since every
     --fs-* / --btn-min* variable below (and the whole app) still references it. */
  --scale: 1;
  --fs-body:    calc(20px * var(--scale));
  --fs-h1:      calc(36px * var(--scale));
  --fs-h2:      calc(30px * var(--scale));
  --fs-input:   calc(22px * var(--scale));
  --fs-label:   calc(14px * var(--scale));
  --fs-button:  calc(20px * var(--scale));
  --fs-big:     calc(24px * var(--scale));
  --fs-huge:    calc(36px * var(--scale));
  --btn-min:    calc(56px * var(--scale));
  --btn-min-lg: calc(72px * var(--scale));
  --btn-min-xl: calc(100px * var(--scale));
  /* Brand-guideline names for the same three tap targets (56/72/100px) —
     both names resolve identically, kept in lockstep since --tap-min
     doesn't scale with --scale (a fixed constant per the guidelines)
     while --btn-min intentionally still can if --scale is ever revived. */
  --tap-min: var(--btn-min);
  --tap-min-lg: var(--btn-min-lg);
  --tap-min-xl: var(--btn-min-xl);
}

/* ==========================================================
   Accent + appearance
   ----------------------------------------------------------
   ACCENT is not defined here. It comes from the active profile's own
   colour (profiles.colour — any colour, free picker) and is written as
   inline custom properties on <html> by applyTheme() in public/app.js,
   which also derives the readable variants: --accent-ink for text on a
   filled accent button, and a contrast-corrected --color-blue for
   accent-as-text on the background. Deriving rather than hard-coding is
   what makes an arbitrary user colour safe; see deriveAccent().

   APPEARANCE is below, and is a per-device preference (localStorage,
   defaulting to the OS), set on <html> by the inline script in
   index.html's <head> before first paint.
   ========================================================== */

/* Light appearance — the source design system is dark-only by design
   ("ink graphite... premium dark mode"), so this is an original
   extension rather than an imported spec: warm-ivory becomes the
   surface, ink becomes the text, same accent/shadow shape otherwise. */
:root[data-appearance="light"] {
  --color-bg: #f4f1ea;
  --color-surface: #ffffff;
  --color-surface-2: #fbfaf7;
  --color-border: rgba(10, 11, 13, 0.1);
  --color-text: #0a0b0d;
  --color-text-muted: #4b515b;
  --border-hover: rgba(10, 11, 13, 0.2);
  /* These two were missed when the rest of the palette got its light
     override: they stayed --color-white-10/-20, i.e. a white hairline,
     which is invisible against light mode's warm-ivory background. They
     feed --lib-line/--lib-line-strong, so every --lib-* surface (the
     Train/Progress/Body/Library header plates, the filter bar, card
     dividers, table rules) silently lost its borders in light mode.
     Same inversion --color-border already uses. */
  --border-glass: rgba(10, 11, 13, 0.1);
  --border-glass-strong: rgba(10, 11, 13, 0.2);
  /* Same omission, same family, found by the follow-up light-mode pass:
     these feed --lib-panel/--lib-panel-2 across 35 rules. The glass wash
     is a *lift* off the page — 4% white over near-black reads as a
     raised card (measured 1.10 contrast against the page), but 4% white
     over warm ivory moves it almost nowhere (measured 1.006, i.e.
     indistinguishable; the cards only read as cards at all because of
     the border fix above). Light mode needs a much stronger lift toward
     white to travel the same visual distance from its own background.
     Values chosen to land on ~1.09 / ~1.11 against the page, matching
     what dark mode actually achieves rather than reusing its alphas.
     Kept translucent so the "wash layered on a card" intent still
     holds where these stack. */
  --surface-glass: rgba(255, 255, 255, 0.75);
  --surface-glass-2: rgba(255, 255, 255, 0.9);
  --ring-soft: rgba(10, 11, 13, 0.06);
  --scrim: rgba(10, 11, 13, 0.4);
  --shadow-card: 0 6px 20px rgba(10, 11, 13, 0.1);
  --shadow-glass: 0 1px 2px rgba(10, 11, 13, 0.08);
  /* --color-warning/--color-success/--color-coral were never given a
     light override at all (only --color-bg/-surface/-text/etc. were) —
     they're tuned to read against the dark ink-graphite background
     (measured there: a comfortable ~7-8:1), and the same raw hex against
     light mode's warm-ivory background measures only ~2.1:1 (warning and
     coral share the exact same hex, #c9a227) / ~2.8:1 (success), well
     under WCAG AA even for large text. Concretely: the runner's countdown
     timer sets color:var(--warning)/var(--success) directly on its
     (large) digit text for the "running low"/"done" states,
     .delta.up/.delta.down (the runner's per-set beat/down indicators) use
     --success/--danger on normal-sized text, and --color-coral feeds
     --accent-secondary/--lib-accent-2 — the Library screen's "danger"
     control variant (.lib-ctrl-btn.danger, .lib-add-btn.remove,
     .admin-badge-danger) sets its text color straight from it — all
     unreadable in light mode before this override. Darkened only enough
     to clear 4.5:1 against both --color-bg and --color-surface (white);
     --color-danger already measured 4.7:1+ unmodified, so it's left as
     the one shared value. Coral reuses warning's own corrected value
     since they were the identical original hex. */
  --color-warning: #7a5f17;
  --color-success: #3a6e4f;
  --color-coral: #7a5f17;
}

* {
  box-sizing: border-box;
  -webkit-tap-highlight-color: transparent;
}
[hidden] { display: none !important; }

html, body {
  margin: 0;
  padding: 0;
  height: 100%;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-body);
  font-size: var(--fs-body);
  overscroll-behavior-y: contain;
}

body {
  display: flex;
  flex-direction: column;
  min-height: 100dvh;
  /* Transparent, NOT --bg — the rule above already gives <html> that
     colour, and an <html> background propagates to the viewport canvas,
     so the page is painted either way and this is visually identical.
     What it buys is the ambient background canvas (#ambient-bg below):
     a z-index:-1 child paints after the root's background but BEFORE
     in-flow block backgrounds, so an opaque background here would bury
     it completely. Doing it this way keeps every content element's
     positioning untouched — the alternative (floating the canvas at
     z-index:0 and lifting #app above it) would mean giving #app
     `position: relative`, which silently re-parents the containing block
     of every absolutely-positioned descendant in every screen. */
  background: transparent;
}

/* Ambient orbital background — the landing hero's motif carried through
   the signed-in app. See public/ambient.js (the scene) and initAmbient()
   in app.js (when it mounts). Fixed and behind everything; never
   interactive, never in the a11y tree, and mounted at all only when
   supportsAmbient() passes (so a reduced-motion visitor never gets it). */
#ambient-bg {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: -1;
  pointer-events: none;
  /* Fades up rather than popping in — it mounts a beat after first paint
     (a dynamic import), and appearing instantly reads as a glitch. */
  animation: ambient-in 1200ms var(--ease-settle, ease) both;
}
@keyframes ambient-in { from { opacity: 0; } to { opacity: 1; } }

/* F-16 — every place a figure the user reads as *data* stacks into a
   column. --font-mono was already applied to all of these, but
   font-variant-numeric was not, so proportional digits made the columns
   ragged and, worse, made live-updating values (a set summary being
   edited, a countdown, a weight delta) visibly jitter as digit widths
   changed under them. Collected into one rule rather than added to ten
   scattered declarations so the convention is visible in one place and
   the next numeric column has somewhere obvious to join.
   Deliberately excludes mono text that ISN'T tabular data — eyebrows,
   section labels, nav items, the filter-bar toggle, the anim caption —
   where the mono face is doing typographic work, not alignment. */
.workout-card-stat.mono,
.summary-set-chip,
.set-row-summary,
.set-row-summary.pb-note,
.overview-pr-value.mono,
.balance-row-pct,
.balance-row-side,
.weight-day-row .day-delta,
.admin-plan-price-cell,
.lib-badge-time,
.metric-chip,
.ln-plan-price {
  font-variant-numeric: tabular-nums;
}

button {
  font-family: inherit;
  color: inherit;
  font-size: var(--fs-button);
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-control);
  padding: 14px 18px;
  cursor: pointer;
  min-height: var(--btn-min);
  transition: transform 0.05s ease, background 0.15s ease, border-color 0.15s ease;
  /* Single-word labels (Reset, Manage...) have no space to wrap at, so a
     button squeezed narrower than its text needs a fallback: break the
     word itself rather than spilling past its own box. */
  overflow-wrap: break-word;
}
/* The 1px hover-lift is a Neartial detail (its own Button component
   lifts on hover in addition to the border/background change every
   button already gets here) — safe as a permanent rule since `disabled`
   buttons are excluded and `:active`'s press-scale below always wins
   over this on click regardless of source order (same specificity,
   later in the file). */
button:hover:not(:disabled) { border-color: var(--border-hover); transform: translateY(-1px); }
/* Same specificity as the hover rule above (two pseudo-classes each) and
   later in source order, so a press (:hover and :active both true at
   once) correctly shows the press-scale, not the hover-lift. */
button:active:not(:disabled) { transform: scale(0.98); }
button:disabled { opacity: 0.5; cursor: not-allowed; }

button.primary   { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
button.primary:hover { background: var(--accent-2); border-color: var(--accent-2); }
button.secondary { background: var(--panel-2); }
button.danger    { background: var(--danger); border-color: var(--danger); color: white; }
button.big       { min-height: var(--btn-min-lg); font-size: var(--fs-big); padding: 18px 24px; }
button.huge      { min-height: var(--btn-min-xl); font-size: var(--fs-huge); padding: 20px 30px; font-weight: 600; }

/* A bare glyph (▶ ↺ ✕ 💾 🗑) needs more visual weight than the equivalent
   word to read clearly at a glance — scale each size class's own base
   font-size up rather than shrinking to match the text it replaced. */
button.icon-symbol       { line-height: 1; font-weight: 400; font-size: calc(var(--fs-button) * 1.4); }
button.icon-symbol.big   { font-size: calc(var(--fs-big) * 1.4); }
button.icon-symbol.huge  { font-size: calc(var(--fs-huge) * 1.3); }
/* Reset is a visually smaller/secondary button than Start/Pause, but its
   icon should read as equally prominent — match Start's icon size exactly
   rather than inheriting the smaller .big scale. */
#btn-timer-reset.icon-symbol { font-size: calc(var(--fs-huge) * 1.3); }

/* Brand guidelines, Iconography page: several glyphs in the icon set
   (📅 🛠 🏗 📋 🤖 🔧 🕐 🗑 ⚖ 🔗 ⚡ 📈 …) default to full-color emoji
   presentation on most platforms — color that plain CSS `color` cannot
   touch, since a color-emoji glyph paints from its own embedded palette,
   not currentColor (this bit the History icon once already: 📜 rendered
   orange and couldn't be recoloured; it's a plain ☰ now). Rather than
   hunting down a monochrome replacement character for every one, this
   applies the exact same fix the guidelines doc uses on its own icon-tile
   previews — desaturate via CSS filter — to a dedicated wrapper around
   just the glyph, never the button/badge it sits in, so a filter strong
   enough to flatten a color emoji never touches a `.primary`/`.danger`
   button's own amber/red background. A plain text-presentation symbol
   (✓ ✕ ▶ ↺ already currentColor) is visually unchanged by this — grayscale
   of a single-tone glyph is a no-op — so this is safe to apply universally
   rather than auditing which glyphs actually need it.
   Deliberately NOT applied to the 🏋️ favicon/brand-mark convention or to
   celebratory success-state emoji (💪 on a toast) — the guidelines treat
   both as intentionally colorful, a different category from functional
   nav/action iconography. */
.icon-glyph {
  filter: grayscale(1) contrast(1.15) brightness(1.2);
}
/* The filter above is tuned for ink: it desaturates, then LIGHTENS, so a
   flattened colour emoji reads against a dark page. On cream that's
   backwards — it pushes an already-light glyph up to nearly the page's
   own luminance. Measured, the 🔔 bell rendered at 4.58:1 contrast in
   dark mode but 1.05:1 in light, i.e. all but invisible; the filter was
   actively making it worse, since the raw unfiltered emoji manages 1.24.
   Light mode therefore darkens instead of lightening. Note the order:
   brightness before contrast [darken, then expand] measures far better
   than the reverse — same values applied contrast-first only reach
   2.72:1. */
:root[data-appearance="light"] .icon-glyph {
  filter: grayscale(1) brightness(0.3) contrast(1.3);
}
/* ...except inside a .danger control, which is white-on-red in BOTH
   appearances (the red background needs light ink either way), so a
   glyph there still wants the lightening treatment even on a cream
   page. This is the only surface in light mode that keeps light ink —
   .primary flips to black ink on its amber, so it needs no exception. */
:root[data-appearance="light"] .danger .icon-glyph {
  filter: grayscale(1) contrast(1.15) brightness(1.2);
}

input, select, textarea {
  font-family: inherit;
  color: inherit;
  font-size: var(--fs-input);
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 5px;
  padding: 12px 14px;
  min-height: var(--btn-min);
  width: 100%;
}
input[type="color"] { padding: 4px; width: 64px; min-height: var(--btn-min); }
input[type="number"] { width: 100%; }
/* Standardized checkbox/radio size site-wide — without this override
   every checkbox inherits the block above's width:100%/min-height:var(--btn-min)
   (meant for text/number inputs), rendering it as an oversized square
   instead of a normal tick box. .lib-muscle-check had its own one-off
   fix for this same problem; this is the single generic rule so no
   future checkbox needs to remember to add it per-class. */
input[type="checkbox"], input[type="radio"] {
  width: auto;
  min-height: 0;
  margin: 0;
  cursor: pointer;
}

/* The closed <select> picks up the dark theme fine via the rule above,
   but the native open-dropdown list is rendered by the OS/browser, not
   this stylesheet — without an explicit rule it defaults to a light
   background while still inheriting the theme's light text colour,
   making every option unreadable except under the hover highlight. */
select option {
  background-color: var(--bg);
  color: var(--text);
}

/* -------- top bar -------- */
#topbar {
  display: flex;
  align-items: center;
  /* Six nav icons plus Back and the profile chip don't all fit on one row
     on a narrow phone even icon-only (checked at 375px) — wrap instead of
     silently overflowing off the right edge. */
  flex-wrap: wrap;
  gap: 10px;
  row-gap: 6px;
  padding: 12px 16px;
  /* env() fallbacks to 0px on every non-notched browser/device, so this
     is a no-op everywhere except a real notch/status-bar/home-indicator
     edge (the Capacitor wrapper's WebView renders edge-to-edge under the
     iOS status bar and Android's cutout, unlike a normal mobile browser
     tab which already insets its own chrome). */
  padding-top: calc(12px + env(safe-area-inset-top, 0px));
  padding-left: calc(16px + env(safe-area-inset-left, 0px));
  padding-right: calc(16px + env(safe-area-inset-right, 0px));
  /* Solid --lib-bg, not translucent --lib-panel — this is sticky, and a
     translucent background would let scrolled content bleed through
     underneath it. Same reasoning as .lib-filterbar's own sticky bar. */
  background: var(--lib-bg);
  border-bottom: 1px solid var(--lib-line);
  position: sticky;
  top: 0;
  z-index: 10;
}
#topbar .spacer { flex: 1; }

/* "Hidden by a plan downgrade" notice — see #plan-lock-banner in
   index.html and wirePlanLockBanner() in app.js. Deliberately a small,
   plain bar (not a dismiss-and-forget toast) since it needs to keep
   showing across every visit to an affected screen for its whole
   notice window, not just once. */
.plan-lock-banner {
  /* Amber, not --danger red — this is a "heads up, something changed"
     notice, not an error state; red read as something having gone
     wrong. Dark text since --warning is a mid-tone gold in both
     light and dark mode (see --color-warning) — white washes out on it. */
  background: var(--warning);
  color: #1a1300;
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 13px;
  font-weight: 600;
  padding: 8px 44px; /* right padding clears the absolutely-positioned dismiss button */
}
.plan-lock-banner-dismiss {
  position: absolute;
  right: 12px;
  top: 50%;
  transform: translateY(-50%);
  background: none;
  border: none;
  color: inherit;
  font-size: 15px;
  font-weight: 700;
  line-height: 1;
  padding: 6px 8px;
  cursor: pointer;
  opacity: 0.7;
}
.plan-lock-banner-dismiss:hover { opacity: 1; }

/* #100 follow-up — the only way out of a trainer's read-only client-
   viewing mode (see enterTrainerProfileView() in app.js); sticky so it
   stays visible while scrolling a long Progress/Body screen. Accent, not
   --warning like the plan-lock banner above — this isn't a problem
   notice, just a permanent "you are here" marker for as long as the
   mode is active. space-between (not centered) puts the exit button on
   the right, away from the "Viewing X's data" text; flex-wrap keeps a
   long profile name from forcing the button off-screen on a narrow
   phone instead of just dropping to its own line. */
.trainer-viewing-banner {
  position: sticky;
  top: 0;
  z-index: 20;
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 8px 12px;
  background: var(--accent);
  color: #fff;
  font-size: 13px;
  font-weight: 600;
  padding: 10px 16px;
}
/* #112 — a trainer with share_edit is changing someone else's data, not
   just looking at it. Kept as the same banner in the same place rather
   than a second, louder one: what changes is the wording plus a darker
   ground, so the difference reads without the two states competing. */
.trainer-viewing-banner.can-edit { background: var(--accent-2); }

/* The trainer's per-exercise adjust dialog (#112). One row per exercise:
   the name/equipment/last-logged block takes the space left over, and
   the three number fields stay a fixed, aligned width so the columns
   line up down the list rather than jittering with name length. */
.trainer-adjust-row {
  display: flex;
  align-items: flex-end;
  flex-wrap: wrap;
  gap: 10px 12px;
  padding: 12px 0;
  border-bottom: 1px solid var(--border);
}
.trainer-adjust-name { flex: 1 1 200px; min-width: 0; }
.trainer-adjust-where,
.trainer-adjust-last {
  font-size: 12.5px;
  color: var(--muted);
  margin-top: 2px;
  overflow-wrap: anywhere;
}
.trainer-adjust-last { font-variant-numeric: tabular-nums; }
.trainer-adjust-row .field-label { flex: 0 0 76px; min-width: 0; margin-bottom: 0; }
.trainer-adjust-row input { width: 100%; min-width: 0; }
/* #116 — one row per active API token. */
.api-token-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px 12px;
  padding: 10px 0;
  border-bottom: 1px solid var(--border);
}
.api-token-main { flex: 1 1 220px; min-width: 0; }
.api-token-meta {
  font-size: 12.5px;
  color: var(--muted);
  margin-top: 2px;
  overflow-wrap: anywhere;
  font-variant-numeric: tabular-nums;
}
/* The token is long and unbreakable, and shown exactly once — it must
   wrap rather than overflow, and be selectable for a manual copy when
   the clipboard API is blocked. */
.api-token-value {
  display: block;
  font-family: var(--font-mono);
  font-size: 13px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 5px;
  padding: 12px 14px;
  margin: 12px 0;
  overflow-wrap: anywhere;
  user-select: all;
}
.trainer-adjust-insight {
  border: 1px solid var(--border);
  border-radius: 5px;
  padding: 12px 14px;
  margin-top: 12px;
  line-height: 1.55;
}
@media (max-width: 640px) {
  /* Three 76px fields plus a name column don't fit a phone row — the
     fields share their own line under the name instead of shrinking to
     nothing. */
  .trainer-adjust-name { flex: 1 1 100%; }
  .trainer-adjust-row .field-label { flex: 1 1 0; }
}
/* Same box-model as "Log cardio" (.lib-ctrl-btn) — matched dimension-
   for-dimension (padding/font-size/max-height/radius) rather than
   sharing the class itself, since .lib-ctrl-btn's own colours are the
   --lib-* texture reserved for Library/History/Measure, not this solid
   accent-coloured banner (same reasoning as .train-calendar-sync-btn on
   the Train screen itself). */
.trainer-viewing-back-btn {
  display: inline-block;
  border: 1px solid rgba(255, 255, 255, 0.6);
  background: transparent;
  color: #fff;
  border-radius: 999px;
  padding: 5px 14px;
  font-size: 12.5px;
  font-weight: 600;
  min-height: 0;
  max-height: 29px;
  white-space: nowrap;
  cursor: pointer;
}
.trainer-viewing-back-btn:hover { background: rgba(255, 255, 255, 0.15); }

/* Persistent brand mark, top-left of the topbar on every screen —
   including login/signup, since #topbar itself is never hidden there
   (only the profile-chip/nav/admin controls that assume a session are).
   A plain button (not a link) since navigation here goes through the
   SPA's own navigate(), not a real page load. */
.topbar-logo {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  background: none;
  border: none;
  padding: 0;
  margin-right: 4px;
  cursor: pointer;
  flex: none;
  /* This was only ever a <button> until about.html's own copy of the
     topbar (an <a>, since it's a real page navigation) — same fix as
     a.topbar-login/a.icon-btn just above for the same reason: an
     anchor doesn't inherit text color/no-underline the way a button
     does by default. */
  color: inherit;
  text-decoration: none;
}

/* The brand mark — three concentric arcs winding into a dumbbell-like pair
   of dots, drawn once as inline SVG (no image asset) so its colours track
   the active accent and the light/dark tokens directly. No background
   rect is drawn on purpose: the mark sits directly on whatever surface
   it's placed on, so in light mode it just shows the page's own (already
   light) background through it rather than carrying a separate dark
   chip that would need to be "un-done" per mode. */
.brand-mark { display: block; height: 28px; width: 28px; flex: none; }
.brand-mark-lg { height: 56px; width: 56px; margin: 0 auto 12px; }
/* This trio used to be its own @media (max-width: 900px), a few pixels
   off from #mainnav's old 900px switch-over and from the app's later
   JS-computed mobile-mode flag (#41, MOBILE_BREAKPOINT = 1060px in
   app.js). All three exist for the same reason — making room for the
   compact/bottom-bar mobile nav layout below — so they now key off the
   same .mobile-mode class rather than a second, separately-tuned literal.
   The unrelated .measure-record-grid/640px breakpoints elsewhere are
   deliberately untouched — see the file-level breakpoint note in
   CLAUDE.md — those tune their own component's layout, not the nav. */
.mobile-mode .brand-mark { height: 22px; width: 22px; }

.brand-word {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: calc(20px * var(--scale));
  letter-spacing: -0.02em;
  color: var(--text);
  white-space: nowrap;
}
.brand-word-accent { color: var(--accent); }
.mobile-mode .brand-word { font-size: calc(17px * var(--scale)); }
@media (max-width: 640px) {
  /* the crumb repeats the on-page <h1>; drop it first so the topbar
     controls stay on one line on narrow phones */
  #crumb { display: none; }
}
.mobile-mode #topbar {
  gap: 4px;
  padding-top: calc(10px + env(safe-area-inset-top, 0px));
  padding-bottom: 10px;
  padding-left: calc(10px + env(safe-area-inset-left, 0px));
  padding-right: calc(10px + env(safe-area-inset-right, 0px));
}
.mobile-mode .icon-btn { padding: 8px 8px; }
.mobile-mode .icon-btn .label { display: none; }
.mobile-mode .profile-chip { padding: 8px 10px 8px 8px; gap: 6px; }

/* -------- topbar burger --------
   Originally landing/login-only — the landing page's "Log in" text
   button (unlike every other .icon-btn here, its label isn't wrapped in
   a .label span, so the rule above can't shrink it — see the
   topbar-menu-trigger comment in index.html) plus the appearance toggle
   were wrapping the topbar onto a second row on a narrow phone.
   display:contents keeps #topbar-menu-panel invisible to layout
   everywhere else, so its children act as plain direct topbar children
   exactly as before — this only changes anything under the one selector
   below. has-topbar-menu now also covers the signed-in app's own mobile
   screens (see render()'s own comment on this class in app.js) — that's
   what gives #btn-trainer (a .nav-item, not .icon-btn — see the second
   rule below) somewhere to live instead of crowding the topbar as a
   sixth control. */
#btn-topbar-menu { display: none; }
#topbar-menu-panel { display: contents; }
.mobile-mode body.has-topbar-menu #btn-topbar-menu { display: inline-flex; }
.mobile-mode body.has-topbar-menu #topbar-menu-panel {
  display: none;
  position: absolute;
  top: 100%;
  right: calc(10px + env(safe-area-inset-right, 0px));
  margin-top: 8px;
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
  background: var(--lib-bg);
  border: 1px solid var(--lib-line);
  border-radius: var(--radius-card, 10px);
  padding: 10px;
  box-shadow: var(--shadow);
  z-index: 15;
  min-width: 160px;
}
.mobile-mode body.has-topbar-menu #topbar-menu-panel.open { display: flex; }
.mobile-mode body.has-topbar-menu #topbar-menu-panel .icon-btn,
.mobile-mode body.has-topbar-menu #topbar-menu-panel .nav-item {
  width: 100%;
  justify-content: flex-end;
  text-align: right;
}

/* Same burger pattern, for the standalone static pages (about/help/
   contact/terms/privacy) — these have no app.js/mobile-mode machinery
   at all, so this uses a plain media query at the same 1060px
   breakpoint app.js's MOBILE_BREAKPOINT uses, gated on a dedicated
   has-topbar-menu body class rather than reusing .mobile-mode (which
   only that JS-computed flag should ever drive) or is-landing (which
   these pages already carry for the hero/topbar-transparency treatment,
   independent of this). */
@media (max-width: 1060px) {
  body.has-topbar-menu #btn-topbar-menu { display: inline-flex; }
  body.has-topbar-menu #topbar-menu-panel {
    display: none;
    position: absolute;
    top: 100%;
    right: calc(10px + env(safe-area-inset-right, 0px));
    margin-top: 8px;
    flex-direction: column;
    align-items: stretch;
    gap: 6px;
    background: var(--lib-bg);
    border: 1px solid var(--lib-line);
    border-radius: var(--radius-card, 10px);
    padding: 10px;
    box-shadow: var(--shadow);
    z-index: 15;
    min-width: 160px;
  }
  body.has-topbar-menu #topbar-menu-panel.open { display: flex; }
  body.has-topbar-menu #topbar-menu-panel .icon-btn {
    width: 100%;
    justify-content: flex-end;
    text-align: right;
  }
}

/* ==========================================================
   Primary navigation
   ----------------------------------------------------------
   One #mainnav element (built by renderNav() in app.js), positioned two
   different ways rather than duplicated: a pill row inside the sticky
   topbar on desktop, and a fixed bottom tab bar in mobile mode (below
   1060px — see .mobile-mode / MOBILE_BREAKPOINT in app.js, #41), where
   the bottom of the screen is the only comfortable place for a thumb —
   this app is used one-handed, mid-set, in a gym.
   ========================================================== */
#mainnav { display: flex; align-items: center; gap: 4px; }
#mainnav[hidden] { display: none; }

/* Fixed size for every nav icon regardless of which glyph it is —
   without this, individual glyphs' own natural ink coverage still made
   some look a size smaller than their neighbours (see NAV_ITEMS in
   app.js for why these are text-presentation, not color-emoji). */
.nav-item .icon-glyph { font-size: 0.6em; line-height: 1; }

.nav-item {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  background: transparent;
  /* `none`, not a transparent-coloured border — a same-specificity
     border-color-only rule elsewhere (the base button:hover rule) can't
     make a border reappear if there's no border to colour in the first
     place. A `border: Npx solid transparent` here previously let that
     base rule's higher specificity repaint it visible on hover. */
  border: none;
  border-radius: var(--radius-pill);
  padding: 8px 16px;
  min-height: var(--btn-min);
  /* Matches #train-eyebrow (.lib-eyebrow) — same face as the topbar's
     profile name, per the header's own consistent-styling brief. */
  font-family: var(--font-mono);
  font-size: var(--fs-button);
  font-weight: 600;
  color: var(--muted);
  white-space: nowrap;
}
/* No background/pill in any state, including hover — the only feedback
   a hover gets is the text brightening toward the page's own text
   colour; there is deliberately no "box" ever drawn around a nav link. */
.nav-item:hover { background: transparent; border: none; transform: none; color: var(--text); }
/* Selection is denoted by text colour alone — no background fill, no
   weight change — so every nav link (selected or not) looks like the
   same control, just a different colour. Text uses --accent-on-page,
   not the raw --accent: --accent is whatever hex the profile picked,
   with no contrast guarantee on its own (a pale accent can be nearly
   invisible against the topbar background). --accent-on-page is
   applyTheme()'s ensureContrastOn() pass against the *actual* topbar
   background colour, in both light and dark mode — see applyTheme()
   in app.js. The icon glyph now follows the same colour (see the
   NAV_ITEMS comment in app.js on why these are text-presentation
   glyphs) rather than staying a fixed neutral grey. */
.nav-item.is-active {
  background: transparent;
  color: var(--accent-on-page);
}

/* Desktop shows the label beside the icon; the mobile bar (below) stacks
   them and shrinks the font instead. */
.nav-label { font-size: var(--fs-button); }

/* Was its own @media (max-width: 900px) query, independent of the app's
   JS-computed mobile-mode flag (#41) — two breakpoints deciding "is this
   a phone" a few pixels apart from each other is exactly the kind of
   drift #41 exists to prevent, so this now keys off the same
   `mobile-mode` class on <html> (state.isMobile, MOBILE_BREAKPOINT =
   1060px in app.js) rather than a second, separately-tuned 900px literal.
   Selector-gated rather than a media query for that reason — there's now
   exactly one place in the codebase that decides the mobile/desktop
   split. */
.mobile-mode #mainnav {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 20;
  gap: 0;
  background: var(--lib-bg);
  border-top: 1px solid var(--lib-line);
  /* env() keeps the bar clear of the iOS home indicator and, in
     landscape on a notched device, the side notch/rounded-corner area;
     the fallback 0px applies everywhere else. */
  padding: 6px calc(4px + env(safe-area-inset-right, 0px))
    calc(6px + env(safe-area-inset-bottom, 0px))
    calc(4px + env(safe-area-inset-left, 0px));
}
/* Scoped to #mainnav — this flex:1/column layout is for the fixed
   bottom tab bar specifically. #btn-admin carries the same .nav-item
   class for shared colour/font/hover styling (see the ask this was
   built for), but it lives in the topbar, not the bottom bar, so it
   must not inherit this layout or it'd stretch to fill the row. */
.mobile-mode #mainnav .nav-item {
  flex: 1;
  min-width: 0;
  flex-direction: column;
  gap: 3px;
  padding: 6px 2px;
  min-height: 0;
  border-radius: var(--radius-md);
}
.mobile-mode #mainnav .nav-label { font-size: 10px; font-weight: 600; letter-spacing: 0.01em; }
/* Admin stays icon-only in the topbar on mobile, same as every other
   header icon button there (see .icon-btn .label above) — the topbar
   is already tight on a narrow phone. */
.mobile-mode #btn-admin { padding: 8px 8px; }
.mobile-mode #btn-admin .nav-label { display: none; }
/* Room for the fixed bar so it never covers the last row of content.
   Class rather than a blanket rule because the bar is hidden on the
   runner and the signed-out screens, which shouldn't carry the gap. */
/* +env() here matches the same inset already added to #mainnav's own
   padding above — otherwise the bar grows taller on a notched device
   than this fixed 84px reservation accounts for, and the last row of
   page content ends up hidden behind it. */
.mobile-mode body.has-bottom-nav #app { padding-bottom: calc(84px + env(safe-area-inset-bottom, 0px)); }

.icon-btn {
  background: transparent;
  border: none;
  min-height: var(--btn-min);
  padding: 8px 14px;
  border-radius: 5px;
  font-size: var(--fs-button);
}
.icon-btn:hover { background: var(--panel-2); border: none; }
/* The header's own icon buttons (Admin, appearance toggle) never show a
   pill/box — scoped to #topbar specifically so this doesn't touch every
   other iconBtn() usage throughout the app's modals, which keep their
   existing hover background. */
#topbar .icon-btn:hover { background: transparent; border: none; color: var(--text); }
#crumb {
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: 0.85em;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted);
}

/* ---- profile chip in the topbar ---- */
.profile-chip {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  background: transparent;
  border: none;
  border-radius: 999px;
  padding: 8px 14px 8px 10px;
  min-height: var(--btn-min);
  /* Matches #train-eyebrow (.lib-eyebrow) — see .nav-item above. */
  font-family: var(--font-mono);
  font-size: var(--fs-button);
  cursor: pointer;
}
.profile-chip:hover { background: transparent; border: none; color: var(--text); }
.profile-chip .name { font-weight: 600; }
.profile-chip .chev { color: var(--muted); font-size: 0.9em; }

#app {
  flex: 1;
  padding: 24px 20px 40px;
  max-width: 1400px;
  margin: 0 auto;
  width: 100%;
}

.screen { display: block; }
h1 { font-family: var(--font-display); font-size: var(--fs-h1); font-weight: 700; margin: 0 0 20px; letter-spacing: -0.02em; }
h2 { font-family: var(--font-display); font-size: var(--fs-h2); font-weight: 700; margin: 0 0 12px; letter-spacing: -0.02em; }
.hint { color: var(--muted); margin: 0 0 20px; font-size: var(--fs-body); }


/* Order-exercises modal (#66) — up/down buttons rather than drag, so each
   row's own layout stays simple and static instead of needing drag-state
   styling. */
.reorder-list { display: flex; flex-direction: column; gap: 8px; margin-bottom: 20px; max-height: 50vh; overflow-y: auto; }
.reorder-row {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 10px 12px;
  border: 1px solid var(--border);
  border-radius: 8px;
  background: var(--panel);
}
.reorder-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.reorder-move-btns { display: flex; gap: 4px; flex: none; }

/* Generic small loading spinner — a rotating ring drawn with borders
   (no image/font glyph, so it never depends on emoji rendering or a
   vendored icon set). Used both standalone (.generate-status) and
   swapped in for a button's icon-glyph content by setBtnBusy(). Sized
   in em so it scales with whatever context drops it into (a big modal
   button vs. a line of body text). */
.spinner {
  display: inline-block;
  width: 0.9em;
  height: 0.9em;
  border: 0.15em solid currentColor;
  border-right-color: transparent;
  border-radius: 50%;
  animation: spin 0.7s linear infinite;
  vertical-align: -0.15em;
}
@keyframes spin { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) {
  .spinner { animation: none; opacity: 0.6; }
}

.generate-status {
  display: flex;
  align-items: center;
  gap: 8px;
  margin-top: -8px;
}
/* #111 — "this is your last generation this period". Deliberately the
   warning colour rather than --danger: nothing has gone wrong, the user
   is simply about to spend the last of something. */
.generate-last-warning { color: var(--warning); }
/* The "further context" block on the generator's review step. Separated
   from the program above it by a rule rather than a card, since it acts
   on that program rather than sitting beside it. */
.program-refine-section {
  border-top: 1px solid var(--border);
  margin-top: 16px;
  padding-top: 14px;
}
.program-refine-section textarea {
  width: 100%;
  resize: vertical;
  font-family: inherit;
  line-height: 1.5;
  margin-bottom: 10px;
}

.ai-notes-textarea {
  width: 100%;
  resize: vertical;
  font-family: inherit;
  line-height: 1.5;
}
.ai-notes-textarea:disabled {
  opacity: 0.75;
  resize: none;
}
/* The runner's read-only AI notes view (#65) — deliberately much larger
   than the Manage screen's edit modal, since this one exists to be read
   comfortably mid-workout rather than edited. */
.modal-big { max-width: 640px; }
.modal-big .ai-notes-textarea {
  font-size: 17px;
  line-height: 1.6;
  min-height: 50vh;
}

/* #87 — the 👍/👎 program-feedback row inside the AI notes modal. */
.ai-notes-feedback {
  margin-top: 4px;
  padding-top: 12px;
  border-top: 1px solid var(--border-default);
}
.ai-notes-feedback-row {
  display: flex;
  gap: 8px;
  margin-bottom: 8px;
}
.ai-notes-feedback-row .icon-symbol.active {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--accent-ink);
}

/* -------- profile picker -------- */
.profile-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 16px;
  margin-bottom: 28px;
}
/* Glass — see .settings-card. */
.profile-card {
  background: var(--lib-panel);
  border: 2px solid var(--lib-line);
  border-radius: var(--radius-card);
  padding: 32px 20px;
  text-align: center;
  cursor: pointer;
  min-height: 160px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 12px;
  transition: transform 0.1s ease, border-color 0.15s ease;
}
.profile-card:hover { border-color: var(--accent); }
.profile-card:active { transform: scale(0.98); }
.profile-card .dot {
  width: 56px;
  height: 56px;
  border-radius: 50%;
  box-shadow: 0 0 0 3px var(--ring-soft);
}
.profile-card .name { font-size: calc(24px * var(--scale)); font-weight: 600; }
.profile-card .actions {
  display: flex;
  gap: 8px;
  margin-top: 6px;
}
/* Edit/Delete now use .lib-ctrl-btn (the app's shared pill-button style,
   already used cross-screen — e.g. the edit-workout modal) instead of a
   one-off rectangular style scoped to just this screen. */

/* "+ Add" card — last tile in the profile grid, opens the add-profile
   modal (see openAddProfileModal) rather than the old always-visible
   inline form. Dashed border distinguishes it from a real profile. */
.profile-card-add {
  border-style: dashed;
  color: var(--muted);
}
.profile-card-add:hover { color: var(--accent); }
/* #82 — the card itself isn't clickable (only its own Restore button is),
   unlike a live profile card's whole-card select — cursor: default and a
   touch of dimming say so at a glance. */
.profile-card-deleted { cursor: default; opacity: 0.85; }
.profile-card-deleted:hover { border-color: var(--border); }
#deleted-profiles-toggle { margin-bottom: 12px; }
.profile-card-add .add-dot {
  width: 56px;
  height: 56px;
  border-radius: 50%;
  border: 2px dashed currentColor;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 28px;
  line-height: 1;
  box-shadow: none;
}

.screen-auth {
  max-width: 380px;
  margin: 10vh auto 0;
  padding: 0 16px;
}
.auth-form {
  display: flex;
  flex-direction: column;
  gap: 14px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 5px;
  padding: 20px;
  margin-top: 16px;
}
.auth-form label {
  display: flex;
  flex-direction: column;
  gap: 6px;
  font-size: calc(15px * var(--scale));
  color: var(--muted);
}
.auth-form input {
  font-size: calc(18px * var(--scale));
  padding: 10px 12px;
}
.auth-error {
  color: var(--danger);
  font-size: calc(15px * var(--scale));
}
.auth-success {
  color: var(--accent);
  font-size: calc(15px * var(--scale));
}
/* The site-wide "secondary action" pill — same bordered/transparent
   treatment as .topbar-login and .ln-btn-ghost (border var(--border),
   fills in var(--accent) on hover, never a solid background), just
   sized for in-app screens rather than the landing page's bigger scale.
   `width: fit-content` is the actual fix for a longstanding bug: this
   class used to be `display: block` with no width constraint, which
   centers correctly for a <button> (buttons default to fit-content
   sizing) but NOT for an <a> tag (anchors stretch to their block
   parent's full width) — #auth-forgot's text sat left-aligned inside an
   invisible full-width box, reading as "not centered" even though the
   box itself technically was. Forcing fit-content width makes the
   margin:auto centering trick work identically for both tag types. */
.link-btn {
  display: block;
  width: fit-content;
  margin: 16px auto 0;
  padding: 10px 22px;
  border: 1px solid var(--border);
  border-radius: 999px;
  background: transparent;
  color: var(--text);
  font-family: var(--font-display);
  font-weight: 600;
  font-size: calc(15px * var(--scale));
  text-align: center;
  text-decoration: none;
  cursor: pointer;
  transition: border-color 200ms ease, color 200ms ease;
}
.link-btn:hover { border-color: var(--accent); color: var(--accent); }
/* Destructive variant (e.g. the trainer roster's "Remove") — keeps
   .link-btn's outlined-pill shape/sizing so it matches its non-danger
   sibling exactly, just recoloured so the destructive one reads
   differently at a glance. Stays red on hover too, unlike the base
   variant's hover-to-accent — this one's colour IS the warning. */
/* background: transparent is explicit, not inherited from .link-btn's own
   rule — button.danger (0,1,1) outranks plain .link-btn (0,1,0) on
   specificity and would otherwise win the cascade for background,
   leaving red text on the solid red button.danger fill. */
.link-btn.danger { background: transparent; border-color: var(--danger); color: var(--danger); }
.link-btn.danger:hover { border-color: var(--danger); color: var(--danger); background: color-mix(in srgb, var(--danger) 12%, transparent); }
.auth-oauth-row {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-top: 16px;
}
.auth-oauth-divider {
  display: flex;
  align-items: center;
  gap: 10px;
  color: var(--muted);
  font-size: calc(13px * var(--scale));
}
.auth-oauth-divider::before, .auth-oauth-divider::after {
  content: '';
  flex: 1;
  height: 1px;
  background: var(--border);
}
.auth-oauth-btn { width: 100%; }

/* First-sign-in setup wizard (.screen-onboarding, renderOnboarding() in
   app.js) — a wider centered card than .screen-auth (380px) since the
   Plan step embeds the same three-card pricing grid the landing pages
   use; every other step is narrow content sitting inside the same
   card, so the width is set once here rather than varying per step. */
.screen-onboarding {
  max-width: 760px;
  margin: 8vh auto 40px;
  padding: 0 16px;
}
/* Glass — see .settings-card. The wizard runs on a real logged-in
   account, so the ambient field is behind it like every other screen;
   this card is the first surface a new user ever sees and was the last
   solid slab left in the app. */
.onboarding-card {
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: 5px;
  padding: 32px;
}
.onboarding-progress-row {
  display: flex;
  align-items: center;
  gap: 14px;
  margin-bottom: 24px;
}
.onboarding-progress {
  display: flex;
  gap: 6px;
  flex: 1;
}
.onboarding-back {
  flex: 0 0 auto;
  white-space: nowrap;
  font-size: calc(13px * var(--scale));
}
.onboarding-dot {
  flex: 1;
  height: 4px;
  border-radius: 999px;
  background: var(--border);
}
.onboarding-dot.is-done { background: var(--accent); }
.onboarding-eyebrow {
  font-family: var(--font-mono, monospace);
  font-size: 11px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--accent);
  margin: 0 0 10px;
}
.screen-onboarding h1 {
  font-size: calc(26px * var(--scale));
  margin: 0 0 8px;
  text-wrap: balance;
}
.screen-onboarding .hint { margin-bottom: 22px; }

.onboarding-tiles {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: 14px;
}
.onboarding-tile {
  text-align: left;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 8px;
  padding: 18px;
  cursor: pointer;
  transition: border-color 200ms ease, background 200ms ease;
}
.onboarding-tile:hover { border-color: var(--accent); }
.onboarding-tile.is-selected { border-color: var(--accent); background: var(--accent-soft, rgba(209, 70, 31, 0.1)); }
.onboarding-tile h3 { margin: 0 0 6px; font-size: calc(17px * var(--scale)); }
.onboarding-tile p { margin: 0; color: var(--muted); font-size: calc(14px * var(--scale)); }

/* Reuses the landing pages' own .ln-plan grid/card styling — this just
   constrains it to the wizard's narrower card and adds the one thing
   the landing pages never needed: a "you already picked this one"
   highlight for a pre-selected tier (see renderOnboardingPlan()). */
.onboarding-plan-grid { margin-top: 4px; }
.ln-plan.is-preselected { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }

.onboarding-primary-btn { display: block; width: 100%; margin-bottom: 10px; }
.onboarding-action-list {
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.onboarding-action-list .secondary { width: 100%; }
.onboarding-skip { margin-top: 20px; }

/* Trainer's own shareable code — shown on both the wizard's Invite step
   and the real Trainer console (see wireTrainerConnectCodeReveal() in
   app.js), same markup/classes either way. */
.connect-code-reveal { margin: 14px 0; }
.connect-code-value {
  font-family: var(--font-mono, monospace);
  font-size: 20px;
  font-weight: 700;
  letter-spacing: 0.12em;
  background: var(--bg);
  border: 1px solid var(--border);
  border-radius: 6px;
  padding: 10px 14px;
  display: inline-block;
}
.connect-code-row {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
  margin-top: 6px;
}
.connect-code-row .link-btn { margin: 0; }

/* Account settings (.screen-account-settings, tpl-account-settings) —
   was a single narrow .screen-auth column (built for the login form)
   with each section just a bare <h2> + margin-top, no visual separation
   of its own. With every optional section switched on at once (OAuth
   linked, Calendar sync configured, billing enabled) that read as one
   long undifferentiated strip of text. Now a wider column of distinct
   .settings-card panels, one per section, so each reads as its own
   block regardless of how many are visible at once. */
.screen-account-settings {
  max-width: 960px;
  margin: 6vh auto 60px;
  padding: 0 16px;
}
.settings-head { margin-bottom: 8px; }
.settings-head h1 { margin-bottom: 4px; }
/* Two columns once there's room for them — Password/Connected accounts/
   Google Calendar/Billing sit side by side instead of stacking into a
   tall single strip; Backup & migration (settings-card-wide) always
   spans both, since its content is a paragraph plus a button row, not a
   short form. Collapses to one column below 700px, same breakpoint
   .measure-record-grid already uses for its own two-column layout. */
/* minmax(0, 1fr), not a bare 1fr, in both templates. A grid track's
   default floor is its widest item's min-content width, so ONE oversized
   child (the Google Calendar per-slot time/duration form was the last
   one — five max-content columns totalling ~440px) pushed the track wider
   than the viewport and every card in the column overflowed with it, not
   just the card containing it. Capping the track at 0 contains the damage
   to the offending child, which then gets its own fix below. Same
   min-width footgun as .connected-account-status right underneath. */
.settings-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 20px;
  margin-top: 20px;
}
.settings-card-wide { grid-column: 1 / -1; }
@media (max-width: 700px) {
  .settings-grid { grid-template-columns: minmax(0, 1fr); }
}
/* Glass, not solid --panel: this card sits directly on the page, and the
   ambient field renders behind every logged-in screen, so a solid fill
   cuts a rectangle out of it. Same treatment and same reasoning as the
   Library screen's panels. See the note above .timer-panel for the one
   surface that deliberately stays solid. */
.settings-card {
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: 5px;
  padding: 20px;
  min-width: 0;
}
.settings-card h2 { font-size: calc(16px * var(--scale)); margin: 0 0 10px; color: var(--text); }
/* .auth-form already draws its own card (background/border/padding) for
   the login screen — nesting it inside .settings-card too would double
   it up, so this strips just the box styling back out while keeping
   .auth-form's flex/gap/label layout intact. */
.settings-card-form { background: none; border: none; padding: 0; margin-top: 0; }
.account-backup-actions { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 10px; }
/* #btn-account-export is an <a>, not a <button> — the base button rules
   are element-scoped (button.secondary etc.) so an anchor styled the
   same way needs its own copy of the relevant properties. */
.account-backup-actions a.secondary {
  display: inline-flex;
  align-items: center;
  font-family: inherit;
  color: inherit;
  text-decoration: none;
  font-size: var(--fs-button);
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-control);
  padding: 14px 18px;
  min-height: var(--btn-min);
  cursor: pointer;
}
.account-backup-actions a.secondary:hover { border-color: var(--border-hover); }

/* Trainer requests (Account Settings) and the Trainer console's own
   roster — same row shape, reused in both. */
.trainer-request-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 10px;
  padding: 12px 0;
  border-bottom: 1px solid var(--border);
}
.trainer-request-row:last-child { border-bottom: none; }
.trainer-request-info { display: flex; flex-direction: column; gap: 2px; }
.trainer-request-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.trainer-share-perms { display: flex; flex-direction: column; gap: 6px; margin: 8px 0; }
.trainer-roster-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 10px;
  margin-bottom: 10px;
}
.trainer-roster-head h2 { margin: 0; }
.trainer-roster-controls {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 12px;
}
.trainer-roster-controls input[type="text"] { flex: 1; min-width: 160px; }
.trainer-roster-controls select { flex: none; }
/* The client report's sections are glass panels, matching the trainer
   console they're reached from and every other screen in the app. This
   screen previously had no panel treatment at all — every section sat
   bare on the page background, which read as unfinished next to the
   console that links to it.
   Scoped to direct children of #trainer-client-body deliberately:
   .trainer-ai-section is ALSO used by the AI Trainer block on the
   client's own Progress Overview, where it already sits inside that
   screen's panel and must not gain a second one. The shorthand `border`
   here also overrides the top-border divider .trainer-ai-section carries
   for that other use, which would otherwise draw a stray line across
   the panel.
   The sections moved inside .trainer-report-col when this screen went
   two-column, so these are matched by that wrapper rather than as direct
   children — the wrapper only exists here, so the other screen's own
   .trainer-ai-section is still untouched, which is the whole point of
   the scoping. */
#trainer-client-body > .trainer-report-balance,
#trainer-client-body .trainer-report-col > .trainer-report-ai,
#trainer-client-body .trainer-report-col > .trainer-ai-section {
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: var(--radius-card);
  box-shadow: var(--lib-shadow);
  padding: 16px 18px;
  margin: 0 0 12px;
}
/* Train / Progress / Body on the left, the two AI tools on the right.
   minmax(0, 1fr) rather than a bare 1fr on both tracks: an AI review is
   generated prose of unpredictable length, and a bare 1fr floors a track
   at its widest child, which would let one long line push the other
   column off-screen. */
.trainer-report-cols {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 12px;
  align-items: start;
}
.trainer-report-col { min-width: 0; }
/* Stacked below this, in DOM order: Train, Progress, Body, then the two
   AI blocks. Still grouped rather than interleaved, which was the point
   of the change. */
@media (max-width: 900px) {
  .trainer-report-cols { grid-template-columns: minmax(0, 1fr); gap: 0; }
}
.trainer-report-balance h3 { font-size: calc(14px * var(--scale)); margin: 0 0 6px; color: var(--text); }
/* Train/Progress/Body section stat rows — sit directly under the (now
   clickable) section title, see trainerSectionHeading() in app.js. */
.trainer-report-ai .lib-stat-row { margin: 8px 0 4px; }
/* Only a clickable section title gets this class (trainerSectionHeading()
   in app.js) — the accent-on-hover is the same "this is a link" cue
   .link-btn already uses elsewhere, applied to a heading instead of a
   button since the whole title is the click target here. */
.trainer-section-heading-link { transition: color 200ms ease; }
.trainer-section-heading-link:hover { color: var(--accent); }
/* The chevron itself is a separate element (not a style on the heading
   text) so "no change to the heading text's own styling" still holds —
   this is purely an added tap-to-open affordance, most needed on touch
   where the hover-to-accent rule above never fires. */
.trainer-section-heading-chevron { color: var(--muted); font-weight: 400; }
/* Added right when the heading above is tapped, removed once
   enterTrainerProfileView()'s fetch settles (see trainerSectionHeading())
   — real loading feedback for the one genuinely async gap between tap
   and the screen actually changing, not just decoration. */
.lib-stat-row.trainer-stat-row-active .lib-stat b { color: var(--accent); transition: color 150ms ease; }
.lib-stat-row.trainer-stat-row-active { animation: trainer-stat-row-sweep 900ms var(--ease-standard) infinite; }
@keyframes trainer-stat-row-sweep {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.6; }
}
@media (prefers-reduced-motion: reduce) {
  .lib-stat-row.trainer-stat-row-active { animation: none; }
}
.trainer-ai-section { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border); }
.trainer-workout-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  padding: 10px 0;
  border-bottom: 1px solid var(--border);
}
.trainer-workout-row:last-child { border-bottom: none; }
.trainer-report-list { list-style: none; padding: 0; margin: 6px 0 0; color: var(--muted); font-size: calc(14px * var(--scale)); }
.trainer-ai-section h3 { font-size: calc(14px * var(--scale)); margin: 12px 0 6px; color: var(--text); }
.trainer-ai-summary-out { margin-top: 8px; }
.trainer-ai-suggest-form { display: flex; flex-direction: column; gap: 8px; margin-top: 8px; }
.trainer-ai-suggest-form label { display: flex; flex-direction: column; gap: 4px; font-size: 13px; color: var(--muted); }
.trainer-landing-pref {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 13px;
  color: var(--muted);
  flex-basis: 100%;
}

/* #86 — per-slot Google Calendar time/duration form, appended into
   .account-backup-actions (a flex row) alongside the sync checkbox/
   re-link button above it — flex-basis: 100% pushes it onto its own
   line rather than fighting those for horizontal space. */
.gcal-schedule-form { flex-basis: 100%; margin-top: 6px; }
/* One shared column template for all three slot rows (Morning/Afternoon/
   Evening), rather than three independent flex rows — a per-row flex
   layout only lines up the time/duration inputs if every row's label
   happens to be the same width, which "Afternoon" isn't next to
   "Morning"/"Evening". Grid auto-wraps every 5 cells onto a new row, so
   all three rows genuinely share the same column positions. */
.gcal-schedule-grid {
  display: grid;
  grid-template-columns: max-content max-content max-content max-content max-content;
  align-items: center;
  column-gap: 8px;
  row-gap: 8px;
}
.gcal-schedule-label { justify-self: start; }
.gcal-schedule-min-label { color: var(--muted); }
.gcal-schedule-grid input[type="time"] { width: 165px; }
.gcal-schedule-grid input[type="number"] { width: 105px; }
/* Five max-content columns come to roughly 440px, well past a phone's
   ~290px card, and because they're max-content nothing about them can
   shrink. Below the same 700px the settings grid collapses at, the slot
   label takes a line of its own (grid-column: 1 / -1, which the flat
   cell list auto-flows around) and the four remaining cells share the
   next one, with both inputs free to shrink. */
@media (max-width: 700px) {
  .gcal-schedule-grid {
    grid-template-columns: minmax(0, 1fr) max-content minmax(0, 5rem) max-content;
    row-gap: 4px;
  }
  .gcal-schedule-label { grid-column: 1 / -1; margin-top: 8px; }
  .gcal-schedule-grid input[type="time"],
  .gcal-schedule-grid input[type="number"] { width: 100%; min-width: 0; }
}
.gcal-schedule-save-row { display: flex; align-items: center; gap: 10px; margin-top: 12px; }
.connected-account-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 0;
  border-top: 1px solid var(--border);
}
.connected-account-name { font-weight: 600; min-width: 80px; }
/* min-width/overflow-wrap, not just flex:1 — a flex child still defaults
   to min-width:auto and refuses to shrink below its content, and a linked
   account's email is one unbreakable token. Without these the row's
   min-content width became the floor for the whole .settings-grid track,
   so on a phone EVERY settings card inherited that width and overflowed
   together, not just this one (measured on a 360px screen: 324px cards in
   a 288px column, all six clipped off the right edge). Same failure mode,
   and same fix, as the Manage screen's workout-name input — see the
   grid/flex min-width note in CLAUDE.md. */
.connected-account-status {
  color: var(--muted);
  font-size: calc(14px * var(--scale));
  flex: 1;
  min-width: 0;
  overflow-wrap: anywhere;
}

.profile-footer-links {
  display: flex;
  justify-content: center;
  gap: 20px;
}
.profile-footer-links .link-btn {
  margin: 16px 0 0;
}

.auth-pitch {
  text-align: center;
  margin-bottom: 22px;
}
.auth-pitch-title {
  font-family: var(--font-display);
  font-size: calc(26px * var(--scale));
  font-weight: 700;
  letter-spacing: -0.02em;
  color: var(--text);
}
.auth-pitch-sub {
  color: var(--muted);
  font-size: calc(15px * var(--scale));
  margin-top: 8px;
  line-height: 1.4;
}
/* ==========================================================================
   Landing page — the public "shop front" (tpl-landing in index.html,
   renderLanding() in app.js, hero scene in public/landing3d.js).

   Design thesis: INERTIA. The product name is a play on "inertial", so
   the page is built on Newton's first law both as copy and as motion
   rule — nothing on this page snaps. Every reveal, hover and parallax
   has mass: long durations, an overshoot-and-settle curve, and stagger
   that reads as one object dragging the next into motion. That single
   rule is why the timings below look slow next to the in-app screens'
   (the app is a tool and should feel instant; this is a pitch and
   should feel heavy).

   Prefixed .ln- rather than .landing- so this self-contained system
   can't collide with the in-app screens' class vocabulary, and so it's
   obvious at a glance which rules belong to the marketing page.
   ========================================================================== */

:root {
  /* Settle curve: overshoots slightly past 1 then eases back — a mass
     arriving at rest, not a value being interpolated. Used by every
     reveal on this page. */
  --ln-settle: cubic-bezier(0.16, 1, 0.24, 1);
  --ln-gutter: clamp(20px, 6vw, 96px);
  --ln-max: 1240px;
}

/* The landing page is full-bleed; #app's own max-width/padding would
   letterbox the hero. Scoped to a body class set only by showLanding()
   so no other screen is affected. */
body.is-landing #app { padding: 0; max-width: none; }
body.is-landing { overflow-x: hidden; }

/* Topbar floats transparently over the hero, then gains a surface once
   the hero has scrolled away — a solid bar over a full-bleed 3D scene
   would cut the composition in half at the exact moment it should read
   as edge-to-edge. */
body.is-landing #topbar {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 40;
  background: transparent;
  border-bottom-color: transparent;
  transition: background 700ms var(--ln-settle), border-color 700ms var(--ln-settle);
}
body.is-landing.ln-scrolled #topbar {
  background: var(--lib-bg);
  border-bottom-color: var(--lib-line);
}

.ln-root {
  font-family: var(--font-display);
  color: var(--text);
  /* Cancels the app-wide .screen block flow assumptions — every section
     below manages its own rhythm. */
  display: block;
}

/* ---------- shared reveal system ----------
   [data-reveal] is inert until app.js's IntersectionObserver adds
   .is-in. Transition (not animation) so an element that scrolls back
   out and in again doesn't restart mid-flight.

   CRITICAL: the hidden-start state is gated behind .ln-reveal-ready,
   which app.js only adds once it has an IntersectionObserver in hand
   and is about to start observing. Without that gate this system fails
   CLOSED — if the observer never runs (a JS error earlier in the
   screen, a browser without IntersectionObserver, a stale cached
   app.js) every element on the page sits at opacity 0 forever and the
   landing page renders blank. Gated this way it fails OPEN instead:
   no JS means no animation, but all the content is simply visible,
   which is the right failure mode for a public marketing page. */
.ln-reveal-ready [data-reveal] {
  opacity: 0;
  transform: translate3d(0, 34px, 0);
}
[data-reveal] {
  transition:
    opacity 1000ms var(--ln-settle),
    transform 1200ms var(--ln-settle);
  will-change: opacity, transform;
}
.ln-reveal-ready [data-reveal].is-in,
[data-reveal].is-in {
  opacity: 1;
  transform: translate3d(0, 0, 0);
}

/* ---------- buttons ---------- */
.ln-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 10px;
  font-family: var(--font-display);
  font-size: clamp(15px, 1.1vw, 17px);
  font-weight: 600;
  letter-spacing: 0.01em;
  padding: 16px 26px;
  border-radius: 999px;
  border: 1px solid transparent;
  cursor: pointer;
  overflow: hidden;
  transition:
    transform 600ms var(--ln-settle),
    background 400ms ease,
    border-color 400ms ease,
    color 400ms ease;
}
.ln-btn:hover { transform: translateY(-3px); }
.ln-btn:active { transform: translateY(-1px); }
/* .ln-btn was only ever a <button> until contact.html/404.html used it
   on a real <a> (a mailto: link, a "back home" link) — third time this
   exact anchor-vs-button gap has come up (see a.topbar-login/a.icon-btn
   and .topbar-logo above), so covering the whole .ln-btn family here too. */
a.ln-btn { color: inherit; text-decoration: none; }
a.ln-btn.ln-btn-primary { color: #fff; }

/* Topbar "Log in", landing page only (shown by render(), see
   #btn-landing-login). Pill-shaped to match .ln-btn rather than the
   in-app .icon-btn square, since on this screen it's a marketing
   control sitting next to a marketing hero. */
.topbar-login {
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(14px, 1vw, 16px);
  min-height: 0;
  padding: 11px 22px;
  border-radius: 999px;
  border-color: var(--border);
  transition: border-color 400ms ease, color 400ms ease, background 400ms ease;
}
.topbar-login:hover { border-color: var(--accent); color: var(--accent); background: transparent; }
/* .topbar-login/.icon-btn were only ever <button>s until the About link
   (an <a>, since it's a real page navigation to a standalone static
   page) — buttons inherit text color/no-underline by default, anchors
   don't, so this is needed specifically for the anchor case. */
a.topbar-login, a.icon-btn { color: inherit; text-decoration: none; }

.ln-btn-primary {
  background: var(--accent);
  color: #fff;
  box-shadow: 0 10px 34px -12px var(--accent);
}
/* A lower-emphasis .ln-btn variant — the trainer teaser's cross-link CTA
   isn't the primary conversion action on the main landing page, so it
   gets an outline rather than a filled accent button. */
.ln-btn-secondary {
  background: transparent;
  border-color: var(--border);
  color: var(--text);
}
.ln-btn-secondary:hover { border-color: var(--accent); color: var(--accent); }
.ln-btn-primary:hover { background: var(--accent-2); box-shadow: 0 18px 44px -14px var(--accent); }
.ln-btn-ghost {
  background: transparent;
  color: var(--text);
  border-color: var(--border);
}
.ln-btn-ghost:hover { border-color: var(--accent); color: var(--accent); }
.ln-btn-lg { padding: 20px 34px; font-size: clamp(16px, 1.3vw, 19px); }

/* ============ HERO ============ */
.ln-hero {
  position: relative;
  min-height: 100vh;
  min-height: 100svh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: center;
  text-align: center;
  padding: 0 var(--ln-gutter);
  overflow: hidden;
  isolation: isolate;
}
.ln-hero-canvas {
  position: absolute;
  inset: 0;
  z-index: -2;
}
.ln-hero-canvas canvas { display: block; width: 100%; height: 100%; }

/* Fakes the bloom the vendored three.js build can't do (no
   EffectComposer, only OrbitControls), plus a vignette and a fade into
   the page background so the canvas doesn't end on a hard edge. */
.ln-hero-veil {
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background:
    radial-gradient(58% 46% at 50% 46%, var(--accent-primary-soft) 0%, transparent 70%),
    radial-gradient(120% 90% at 50% 50%, transparent 42%, var(--bg, #0a0b0d) 100%);
  opacity: 0.9;
}
:root[data-appearance="light"] .ln-hero-veil {
  background:
    radial-gradient(58% 46% at 50% 46%, var(--accent-primary-soft) 0%, transparent 70%),
    radial-gradient(120% 90% at 50% 50%, transparent 46%, var(--panel) 100%);
  opacity: 0.75;
}

.ln-hero-inner {
  position: relative;
  max-width: 1100px;
  width: 100%;
}

.ln-eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 14px;
  margin: 0 0 clamp(18px, 3vw, 34px);
  font-family: var(--font-display);
  font-size: clamp(10px, 1vw, 12px);
  letter-spacing: 0.34em;
  text-transform: uppercase;
  color: var(--accent);
}
.ln-eyebrow-rule {
  display: block;
  width: clamp(28px, 5vw, 64px);
  height: 1px;
  background: currentColor;
  opacity: 0.6;
}

/* The wordmark is the page's single loudest element on purpose — at
   this scale the letterforms stop being a logo and become the artwork. */
.ln-wordmark {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(3.6rem, 15.5vw, 14rem);
  line-height: 0.86;
  letter-spacing: -0.055em;
  white-space: nowrap;
}
.ln-ltr {
  display: inline-block;
  /* Each letter falls in under its own delay — the stagger is what
     makes the word read as assembling under momentum rather than
     fading in as one flat block. */
  animation: ln-letter-in 1400ms var(--ln-settle) both;
  animation-delay: calc(var(--i) * 68ms + 120ms);
}
.ln-ltr-accent { color: var(--accent); }
@keyframes ln-letter-in {
  from { opacity: 0; transform: translate3d(0, 0.42em, 0) rotate(6deg); filter: blur(14px); }
  60%  { filter: blur(0); }
  to   { opacity: 1; transform: translate3d(0, 0, 0) rotate(0); filter: blur(0); }
}

/* #48 — a one-line gloss on the NEART portion of the wordmark (Scottish
   Gaelic for strength/power/might), sitting between the wordmark and the
   lede so a first-time visitor reads the name's meaning before the pitch
   copy, without it competing with either for size. */
.ln-name-note {
  margin: clamp(10px, 1.5vw, 16px) 0 0;
  font-family: var(--font-display);
  font-size: clamp(12px, 1.1vw, 14px);
  letter-spacing: 0.04em;
  color: var(--muted);
}
.ln-name-note-accent {
  color: var(--accent);
  font-weight: 700;
  letter-spacing: 0.14em;
}
/* Plain-language purpose statement — see the comment on this element in
   index.html for why it exists as its own, deliberately unpoetic line. */
.ln-hero-purpose {
  margin: clamp(14px, 2vw, 22px) auto 0;
  max-width: 46ch;
  font-size: clamp(14px, 1.3vw, 17px);
  line-height: 1.5;
  color: var(--text);
}
.ln-hero-lede {
  margin: clamp(22px, 3.5vw, 38px) auto 0;
  max-width: 40ch;
  font-size: clamp(17px, 1.7vw, 23px);
  line-height: 1.45;
  color: var(--muted);
}
.ln-hero-lede em {
  font-style: normal;
  color: var(--text);
  font-weight: 600;
}

.ln-hero-actions {
  display: flex;
  justify-content: center;
  flex-wrap: wrap;
  gap: 14px;
  margin-top: clamp(28px, 4vw, 46px);
}

.ln-scrollcue {
  position: absolute;
  bottom: clamp(20px, 4vh, 44px);
  left: auto;
  right: clamp(20px, 6vw, 96px);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 12px;
}
.ln-scrollcue-label {
  font-family: var(--font-display);
  font-size: 10px;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--muted);
}
.ln-scrollcue-track {
  position: relative;
  width: 2px;
  height: 54px;
  background: var(--border);
  overflow: visible;
}
.ln-scrollcue-dot {
  position: absolute;
  top: 0; left: -0.5px;
  width: 3px; height: 12px;
  border-radius: 2px;
  background: var(--accent);
  animation: ln-cue 2.4s linear infinite;
}
@keyframes ln-cue {
  0%   { transform: translateY(-14px); opacity: 0; }
  35%  { opacity: 1; }
  100% { transform: translateY(42px); opacity: 0; }
}

/* ============ shared section furniture ============ */
.ln-sec-head {
  display: flex;
  align-items: baseline;
  gap: clamp(14px, 2vw, 26px);
  max-width: var(--ln-max);
  margin: 0 auto clamp(38px, 6vw, 76px);
  padding: 0 var(--ln-gutter);
}
.ln-sec-index {
  font-family: var(--font-display);
  font-size: clamp(11px, 1vw, 13px);
  letter-spacing: 0.2em;
  color: var(--accent);
  padding-top: 0.4em;
}
.ln-sec-title {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(1.9rem, 5.4vw, 3.9rem);
  line-height: 1.02;
  letter-spacing: -0.035em;
}
/* #92 — the pricing section's currency picker, pushed to the far end of
   the same flex header the title sits in. */
.ln-currency-select {
  margin-left: auto;
  align-self: center;
  background: var(--surface-card);
  color: var(--text-primary);
  border: 1px solid var(--border-default);
  border-radius: var(--radius-control, 10px);
  font-family: var(--font-mono);
  font-size: 0.9rem;
  padding: 0.4em 0.7em;
  /* ~25% of its old width — the shared `select { width: 100% }` rule
     otherwise stretches it to fill the rest of this flex header. */
  width: 25%;
  max-width: 110px;
}

/* ============ STATEMENT ============ */
.ln-statement {
  padding: clamp(90px, 17vh, 200px) var(--ln-gutter);
  max-width: var(--ln-max);
  margin: 0 auto;
}
.ln-statement-text {
  margin: 0;
  font-family: var(--font-display);
  font-weight: 700;
  font-size: clamp(2.1rem, 7.6vw, 6rem);
  line-height: 1.02;
  letter-spacing: -0.04em;
}
.ln-word {
  display: inline-block;
  margin-right: 0.22em;
  /* Longer, heavier travel than the generic reveal — these are the
     biggest objects on the page, so they take the longest to settle. */
  transition:
    opacity 1100ms var(--ln-settle),
    transform 1400ms var(--ln-settle);
}
/* Gated on .ln-reveal-ready for the same fail-open reason as
   [data-reveal] above — these words are the section's entire content. */
.ln-reveal-ready .ln-word:not(.is-in) { transform: translate3d(0, 0.5em, 0) rotate(3deg); }
.ln-word-accent { color: var(--accent); }
.ln-statement-sub {
  margin: clamp(28px, 4vw, 48px) 0 0;
  max-width: 54ch;
  font-size: clamp(16px, 1.5vw, 20px);
  line-height: 1.6;
  color: var(--muted);
}
/* The Trainer page (renderForTrainers()) keeps its own .ln-statement
   text layout (left-aligned .ln-statement-sub, no .ln-hero flex column)
   rather than switching to the full .ln-hero structure — but it does
   now carry the same spinning globe/orbit hero scene every other public
   page's .ln-hero gets (see the .ln-hero-canvas/.ln-hero-veil markup in
   tpl-for-trainers), so it needs the same containment .ln-hero itself
   provides for those absolutely-positioned children: relative
   positioning, clipped overflow, and an isolated stacking context so the
   canvas's negative z-index never bleeds into content outside this
   section. It still needs its own top clearance under the fixed topbar,
   and its .ln-hero-actions/.ln-eyebrow (both normally hero-only) need
   centering since they're outside .ln-hero's own flex column here. */
.ln-trainers-hero {
  position: relative;
  overflow: hidden;
  isolation: isolate;
  padding-top: clamp(140px, 22vh, 240px);
  text-align: center;
}
.ln-trainers-hero .ln-eyebrow,
.ln-trainers-hero .ln-hero-actions {
  justify-content: center;
}
.ln-trainers-hero .ln-statement-sub {
  margin-left: auto;
  margin-right: auto;
}
/* contact.html is a single short message, not a scrolling landing page —
   .ln-statement's shared clamp(90px, 17vh, 200px) padding (tuned for
   About/the main landing page's much longer scroll) left this page
   taller than the viewport with nothing to show for the extra space. */
.ct-statement { padding-top: clamp(32px, 5vh, 64px); padding-bottom: clamp(32px, 5vh, 64px); }
/* contact.html's own lede — centered (its parent .ln-statement isn't,
   and .ln-statement-sub normally sits flush-left within it) since this
   page is a single centered message, not a left-reading paragraph
   sitting under a left-aligned heading like About's equivalent. */
.ct-lede { margin-left: auto; margin-right: auto; text-align: center; }
/* The site's link colour is reserved for informational links (see the
   brand guidelines note elsewhere in this file); this one email link is
   the page's primary call to action repeated inline, so it gets the
   brand accent instead of blue to match the button above it. */
.ct-email-link { color: var(--accent); }
.ct-email-link:hover { color: var(--accent-2); }

/* ============ THE THREE LAWS ============ */
.ln-laws { padding: clamp(60px, 10vh, 130px) 0; }
.ln-law-grid {
  max-width: var(--ln-max);
  margin: 0 auto;
  padding: 0 var(--ln-gutter);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(270px, 1fr));
  gap: clamp(18px, 2.4vw, 32px);
}
.ln-law {
  position: relative;
  padding: clamp(24px, 2.6vw, 36px);
  border: 1px solid var(--border);
  border-radius: 5px;
  background: var(--panel);
  overflow: hidden;
  transition: border-color 700ms var(--ln-settle), transform 900ms var(--ln-settle);
}
.ln-law:hover { border-color: var(--accent); transform: translateY(-6px); }
.ln-mech-note {
  margin: clamp(28px, 4vw, 44px) auto 0;
  max-width: var(--ln-max);
  padding: 0 var(--ln-gutter);
  text-align: center;
  font-family: var(--font-display);
  font-size: clamp(1.2rem, 2vw, 1.6rem);
  letter-spacing: 0.08em;
  color: var(--muted);
}
.ln-mech-accent {
  color: var(--accent);
  font-weight: 700;
}
.ln-law-fig {
  margin: -8px 0 22px;
  aspect-ratio: 200 / 120;
}
.ln-fig { width: 100%; height: 100%; overflow: visible; }
.ln-fig line, .ln-fig path, .ln-fig rect {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.6;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}
/* Each of these needs the .ln-fig ancestor to out-specify the
   `.ln-fig line, .ln-fig path, .ln-fig rect` reset above (0,2,0) — a
   bare .ln-fig-mass (0,1,0) loses to it and silently renders every
   figure in flat currentColor with no fill. */
.ln-fig .ln-fig-ground { stroke: var(--border); }
.ln-fig .ln-fig-mass { stroke: var(--accent); fill: var(--accent-primary-soft); }
.ln-fig .ln-fig-still line { stroke: var(--muted); opacity: 0.45; }
.ln-fig .ln-fig-arrow line, .ln-fig .ln-fig-arrow path { stroke: var(--accent); }

/* Each figure animates only once its card is in view — an animation
   looping off-screen is wasted battery and, worse, is already halfway
   through by the time anyone sees it. */
.ln-law.is-in .ln-fig-force .ln-fig-arrow {
  stroke: var(--accent);
  animation: ln-shove 2.8s var(--ln-settle) infinite;
}
.ln-law.is-in .ln-fig-force .ln-fig-mass { animation: ln-nudge 2.8s var(--ln-settle) infinite; }
@keyframes ln-shove {
  0%, 12%  { transform: translateX(-16px); opacity: 0; }
  30%      { opacity: 1; }
  62%      { transform: translateX(6px); opacity: 1; }
  100%     { transform: translateX(6px); opacity: 0; }
}
@keyframes ln-nudge {
  0%, 34%  { transform: translateX(0); }
  70%      { transform: translateX(14px); }
  100%     { transform: translateX(0); }
}
.ln-fig .ln-fig-echo { stroke: var(--accent); opacity: 0; fill: none; }
.ln-law.is-in .ln-fig-momentum .ln-fig-echo-1 { animation: ln-echo 2.6s ease-out infinite; animation-delay: 0.10s; }
.ln-law.is-in .ln-fig-momentum .ln-fig-echo-2 { animation: ln-echo 2.6s ease-out infinite; animation-delay: 0.22s; }
.ln-law.is-in .ln-fig-momentum .ln-fig-echo-3 { animation: ln-echo 2.6s ease-out infinite; animation-delay: 0.34s; }
.ln-law.is-in .ln-fig-momentum .ln-fig-mass { animation: ln-drift 2.6s ease-out infinite; }
@keyframes ln-echo {
  0%   { opacity: 0.55; }
  70%  { opacity: 0; }
  100% { opacity: 0; }
}
@keyframes ln-drift {
  0%   { transform: translateX(-30px); }
  70%  { transform: translateX(6px); }
  100% { transform: translateX(6px); }
}
.ln-law.is-in .ln-fig-inertia .ln-fig-still { animation: ln-breathe 3.4s ease-in-out infinite; }
@keyframes ln-breathe { 0%, 100% { opacity: 0.25; } 50% { opacity: 0.7; } }

.ln-law-num {
  font-family: var(--font-display);
  font-size: 11px;
  letter-spacing: 0.24em;
  color: var(--accent);
}
.ln-law h3 {
  margin: 10px 0 12px;
  font-family: var(--font-display);
  font-size: clamp(1.2rem, 2vw, 1.6rem);
  letter-spacing: -0.02em;
}
.ln-law p { margin: 0; color: var(--muted); line-height: 1.6; font-size: clamp(14px, 1.15vw, 16px); }

/* ============ CAPABILITIES ============ */
.ln-caps { padding: clamp(60px, 10vh, 130px) 0; }
.ln-caps-layout {
  max-width: var(--ln-max);
  margin: 0 auto;
  padding: 0 var(--ln-gutter);
  display: grid;
  grid-template-columns: minmax(180px, 300px) 1fr;
  gap: clamp(30px, 6vw, 90px);
  align-items: start;
}
.ln-caps-sticky {
  position: sticky;
  top: 40vh;
}
.ln-caps-counter {
  display: flex;
  align-items: baseline;
  gap: 10px;
  font-family: var(--font-display);
  line-height: 1;
}
.ln-caps-cur {
  font-size: clamp(3.4rem, 8vw, 6.4rem);
  font-weight: 700;
  letter-spacing: -0.05em;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  transition: opacity 500ms var(--ln-settle);
}
.ln-caps-total {
  font-family: var(--font-display);
  font-size: clamp(12px, 1.1vw, 15px);
  color: var(--muted);
}
.ln-caps-bar {
  margin-top: 18px;
  height: 2px;
  background: var(--border);
  border-radius: 2px;
  overflow: hidden;
}
.ln-caps-bar-fill {
  display: block;
  height: 100%;
  width: 0;
  background: var(--accent);
  /* Slow, heavy fill — matches the page's momentum rule rather than
     tracking the scrollbar 1:1. */
  transition: width 900ms var(--ln-settle);
}

.ln-caps-list { list-style: none; margin: 0; padding: 0; }
.ln-cap {
  display: flex;
  gap: clamp(16px, 2.4vw, 34px);
  padding: clamp(28px, 4.5vw, 56px) 0;
  border-top: 1px solid var(--border);
  transition: opacity 800ms var(--ln-settle);
}
.ln-cap:last-child { border-bottom: 1px solid var(--border); }
/* Non-active rows recede rather than disappear — keeps the list
   readable while still pointing at where you are. */
.ln-cap.is-in:not(.is-active) { opacity: 0.44; }
.ln-cap-num {
  flex: none;
  font-family: var(--font-display);
  font-size: clamp(11px, 1vw, 13px);
  letter-spacing: 0.2em;
  color: var(--accent);
  padding-top: 0.55em;
}
/* IMG-03 still life (see docs/image-specs.md) — sits above the heading
   rather than beside it. A side-by-side layout has to fight the body
   copy's own 52ch max-width for the same row, and that math breaks at
   exactly the viewport widths this card already has to support; stacking
   avoids it entirely and reads better as one still image per capability
   anyway, closer to an editorial layout than a UI icon. Capped narrower
   than the card's own text column so it never dominates the row — it's
   mood, not the point. Not every card has one yet (IMG-03 is a
   still-in-progress set); a card with no .ln-cap-media simply doesn't
   reserve the space, so this never leaves a gap.
   Same dual-image swap pattern as .body-silhouette-dark/-light above —
   pure CSS on [data-appearance], not a JS choice at render time. */
.ln-cap-media {
  position: relative;
  width: 100%;
  max-width: 480px;
  aspect-ratio: 16 / 9;
  border-radius: var(--radius-card, 16px);
  overflow: hidden;
  margin-bottom: 22px;
  background: var(--panel);
}
.ln-cap-media img { display: block; width: 100%; height: 100%; object-fit: cover; }
.ln-cap-media .ln-cap-media-light { display: none; }
:root[data-appearance="light"] .ln-cap-media .ln-cap-media-dark { display: none; }
:root[data-appearance="light"] .ln-cap-media .ln-cap-media-light { display: block; }
/* Dark scrim behind the heading, independent of site theme — the photo
   itself is always a dark near-black void regardless of [data-appearance]
   (see IMG-03's own art direction), so the heading needs guaranteed local
   contrast rather than whatever the page's text colour happens to be in
   either theme. Tall enough to sit behind a two-line wrapped heading, not
   just a single short one. */
.ln-cap-media::after {
  content: '';
  position: absolute;
  inset: auto 0 0 0;
  height: 68%;
  background: linear-gradient(to top, rgba(10, 11, 13, 0.92), rgba(10, 11, 13, 0));
  pointer-events: none;
}
/* The heading now lives inside .ln-cap-media itself (see the markup) —
   absolutely positioned over the image, bottom-anchored with real padding
   on every side rather than pulled up by a negative margin from outside.
   The `bottom` offset is what was asked for over the first cut of this:
   the text needs clear air below it before the image's own edge, not to
   run right up against it. */
.ln-cap-media h3 {
  position: absolute;
  z-index: 1;
  left: 24px;
  right: 24px;
  bottom: 22px;
  margin: 0;
  color: #f4f1ea;
  text-shadow: 0 1px 12px rgba(0, 0, 0, 0.5);
  font-family: var(--font-display);
  font-size: clamp(1.25rem, 3vw, 2.1rem);
  line-height: 1.12;
  letter-spacing: -0.03em;
}
.ln-cap-body p {
  margin: 0;
  max-width: 52ch;
  color: var(--muted);
  line-height: 1.62;
  font-size: clamp(14px, 1.2vw, 17px);
}

/* ============ FEATURED ============ */
.ln-featured { padding: clamp(60px, 10vh, 130px) 0; }
.ln-card-rail {
  max-width: var(--ln-max);
  margin: 0 auto;
  padding: 0 var(--ln-gutter);
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
  gap: clamp(16px, 2vw, 28px);
}
.ln-card {
  position: relative;
  display: block;
  border-radius: 5px;
  overflow: hidden;
  border: 1px solid var(--border);
  background: var(--panel);
  aspect-ratio: 4 / 5;
  transition: transform 900ms var(--ln-settle), border-color 600ms ease;
}
.ln-card:hover { transform: translateY(-8px); border-color: var(--accent); }
.ln-card-media {
  position: absolute;
  inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  /* Slow zoom on hover — mass again: it starts moving and keeps going
     rather than snapping to a new scale. */
  transition: transform 1600ms var(--ln-settle);
}
.ln-card:hover .ln-card-media { transform: scale(1.07); }
.ln-card-media-empty {
  position: absolute;
  inset: 0;
  background:
    radial-gradient(90% 70% at 30% 20%, var(--accent-primary-soft), transparent 70%),
    var(--panel-2);
}
.ln-card-scrim {
  position: absolute;
  inset: 0;
  background: linear-gradient(to top, rgba(6, 7, 9, 0.92) 0%, rgba(6, 7, 9, 0.45) 40%, transparent 72%);
}
.ln-card-text {
  position: absolute;
  left: 0; right: 0; bottom: 0;
  padding: clamp(18px, 2vw, 26px);
  /* Always light — it sits on the dark scrim in both themes, so it must
     not follow --text (which flips to near-black in light mode and
     would vanish against the gradient). */
  color: #f4f1ec;
}
.ln-card-text h3 {
  margin: 0 0 6px;
  font-family: var(--font-display);
  font-size: clamp(1.05rem, 1.7vw, 1.4rem);
  letter-spacing: -0.02em;
  line-height: 1.15;
}
.ln-card-text p {
  margin: 0;
  font-size: clamp(13px, 1.05vw, 15px);
  line-height: 1.5;
  color: rgba(244, 241, 236, 0.78);
}

/* ============ PLANS ============ */
/* Trainer cross-link teaser — a quieter card bridging into the
   dedicated Trainer page, sitting between the capability sections and
   the personal pricing grid. */
.ln-trainer-teaser {
  max-width: var(--ln-max);
  margin: 0 auto;
  padding: 0 var(--ln-gutter) clamp(40px, 6vh, 70px);
}
.ln-trainer-teaser-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 20px;
  padding: clamp(24px, 3vw, 36px);
  border: 1px solid var(--border);
  border-radius: var(--radius-card, 16px);
  background: var(--panel-2, var(--panel));
}
.ln-trainer-teaser-title {
  margin: 0 0 6px;
  font-family: var(--font-display);
  font-size: clamp(18px, 1.6vw, 22px);
}
.ln-trainer-teaser-sub {
  margin: 0;
  max-width: 52ch;
  color: var(--muted);
  font-size: 15px;
  line-height: 1.5;
}
.ln-plans { padding: clamp(60px, 10vh, 130px) 0; }
.ln-plan-grid {
  max-width: var(--ln-max);
  margin: 0 auto;
  padding: 0 var(--ln-gutter);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
  gap: clamp(16px, 2vw, 26px);
  align-items: stretch;
}
.ln-plan {
  display: flex;
  flex-direction: column;
  padding: clamp(26px, 3vw, 40px);
  border: 1px solid var(--border);
  border-radius: 5px;
  background: var(--panel);
  transition: transform 900ms var(--ln-settle), border-color 600ms ease;
}
/* The pricing grid is shared between the public landing page and the
   onboarding wizard's plan step, and the two sit on completely different
   grounds: the landing page has its own WebGL hero and no ambient
   canvas, while onboarding is a logged-in screen with the field behind
   it. So the glass treatment is scoped here rather than applied to
   .ln-plan itself — solid is correct on the landing page, and the .ln-*
   family is deliberately its own visual system (see the landing note in
   CLAUDE.md) that shouldn't be dragged into the app's. */
.screen-onboarding .ln-plan {
  background: var(--lib-panel);
  border-color: var(--lib-line);
}
.ln-plan:hover { transform: translateY(-6px); }
.ln-plan-featured {
  border-color: var(--accent);
  box-shadow: 0 22px 60px -30px var(--accent);
}
.ln-plan-tag {
  font-family: var(--font-display);
  font-size: 10px;
  letter-spacing: 0.24em;
  text-transform: uppercase;
  color: var(--accent);
  min-height: 1.2em;
}
.ln-plan-name {
  margin: 12px 0 0;
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 3vw, 2.2rem);
  letter-spacing: -0.03em;
}
/* #92 — real, live-from-Stripe pricing; replaces the old "mass bars"
   placeholder (that stood in for a price back when no billing
   integration existed yet — see #89/#92). */
.ln-plan-price {
  margin: 18px 0 22px;
  font-family: var(--font-mono);
  font-size: clamp(1.3rem, 2.6vw, 1.8rem);
  font-weight: 600;
  color: var(--text);
}

.ln-plan-limits { list-style: none; margin: 0 0 26px; padding: 0; display: flex; flex-direction: column; gap: 11px; flex: 1; }
.ln-plan-limits li {
  display: flex;
  justify-content: space-between;
  gap: 14px;
  font-size: clamp(13px, 1.1vw, 15px);
  color: var(--muted);
  padding-bottom: 11px;
  border-bottom: 1px solid var(--border);
}
.ln-plan-limits li:last-child { border-bottom: 0; padding-bottom: 0; }
.ln-plan-limits b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
.ln-plan-cta {
  display: block;
  width: 100%;
  text-align: center;
  padding: 14px 18px;
  border-radius: 999px;
  font-family: var(--font-display);
  font-size: 15px;
  font-weight: 600;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--muted);
}
.ln-plan-cta-live {
  background: var(--accent);
  border-color: var(--accent);
  color: #fff;
  cursor: pointer;
  transition: transform 600ms var(--ln-settle), background 400ms ease;
}
.ln-plan-cta-live:hover { transform: translateY(-3px); background: var(--accent-2); }
.ln-plans-error {
  grid-column: 1 / -1;
  margin: 0;
  color: var(--muted);
  text-align: center;
}
.ln-plans-note {
  max-width: var(--ln-max);
  margin: clamp(24px, 3vw, 40px) auto 0;
  padding: 0 var(--ln-gutter);
  color: var(--muted);
  font-size: clamp(13px, 1.1vw, 15px);
}
/* help.html's plan-capability comparison — a plain table rather than
   .ln-plan cards, since this page is about capability, not price (the
   landing page's own pricing cards already cover price). Sits inside
   the same .ln-plan-grid container as a single grid item. */
.help-plan-table {
  grid-column: 1 / -1;
  width: 100%;
  border-collapse: collapse;
  font-size: clamp(14px, 1.1vw, 16px);
}
.help-plan-table th, .help-plan-table td {
  padding: 12px 16px;
  text-align: center;
  border-bottom: 1px solid var(--border);
}
.help-plan-table th:first-child, .help-plan-table td:first-child { text-align: left; }
.help-plan-table thead th {
  font-family: var(--font-display);
  font-weight: 600;
  color: var(--text);
}
.help-plan-table tbody td:first-child { color: var(--muted); }

/* help.html's search bar — a plain text filter over every card on the
   page (see the <script> at the bottom), not a real search backend. */
.help-search-section {
  max-width: var(--ln-max);
  margin: 0 auto clamp(40px, 6vw, 76px);
  padding: 0 var(--ln-gutter);
}
.help-search-wrap {
  display: flex;
  align-items: center;
  gap: 12px;
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 14px 22px;
  background: var(--panel);
  transition: border-color 300ms ease;
}
.help-search-wrap:focus-within { border-color: var(--accent); }
.help-search-input {
  flex: 1;
  border: none;
  background: transparent;
  min-height: 0;
  padding: 0;
  font-family: var(--font-display);
  font-size: clamp(15px, 1.3vw, 18px);
  color: var(--text);
}
.help-search-input:focus { outline: none; }
.help-search-input::placeholder { color: var(--muted); }
.help-search-empty { text-align: center; margin-top: 12px; }

/* help.html's FAQ — native <details>/<summary> accordions, same card
   shell (.ln-law's border/background/radius) but each one collapsed by
   default rather than a permanently-open grid of them. */
.help-faq-list {
  max-width: var(--ln-max);
  margin: 0 auto;
  padding: 0 var(--ln-gutter);
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.help-faq-item {
  border: 1px solid var(--border);
  border-radius: 5px;
  background: var(--panel);
  padding: 6px 24px;
  transition: border-color 400ms ease;
}
.help-faq-item[open] { border-color: var(--accent); }
.help-faq-item summary {
  cursor: pointer;
  padding: 18px 0;
  font-family: var(--font-display);
  font-weight: 600;
  font-size: clamp(15px, 1.3vw, 18px);
  list-style: none;
}
.help-faq-item summary::-webkit-details-marker { display: none; }
.help-faq-item summary::after {
  content: '+';
  float: right;
  color: var(--accent);
  font-size: 1.3em;
  line-height: 1;
  transition: transform 300ms ease;
}
.help-faq-item[open] summary::after { transform: rotate(45deg); }
.help-faq-item p { margin: 0 0 20px; color: var(--muted); line-height: 1.6; }
.help-faq-item p a { color: var(--accent); }
.help-faq-item p a:hover { color: var(--accent-2); }

/* help.html's screen-guide illustrations — one small schematic wireframe
   per section (not a real screenshot; see the session that added these
   for why), sitting between the section heading and its card grid. */
.help-section-fig { max-width: var(--ln-max); margin: -12px auto 28px; padding: 0 var(--ln-gutter); }
.help-section-fig svg { display: block; width: 100%; max-width: 340px; height: auto; margin: 0 auto; color: var(--muted); }

/* ============ FOOTER ============ */
.ln-footer {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  align-items: center;
  justify-content: space-between;
  max-width: var(--ln-max);
  margin: 0 auto;
  padding: 28px var(--ln-gutter) 44px;
  border-top: 1px solid var(--border);
}
.ln-footer-brand {
  font-family: var(--font-display);
  font-weight: 700;
  letter-spacing: -0.02em;
}
.ln-footer-links { display: flex; gap: 22px; }
.ln-footer-links a {
  color: var(--muted);
  text-decoration: none;
  font-size: 14px;
  transition: color 400ms ease;
}
.ln-footer-links a:hover { color: var(--accent); }

/* ============ responsive ============ */
/* On a short viewport (laptop with browser chrome, landscape phone) the
   hero's own content already fills the height — the scroll cue would
   collide with the CTA row rather than sitting under it. */
@media (max-height: 760px) {
  .ln-scrollcue { display: none; }
}
@media (max-width: 860px) {
  .ln-caps-layout { grid-template-columns: 1fr; }
  /* The sticky counter becomes a compact top-pinned progress bar on
     narrow screens — a 40vh-offset sticky column has nowhere to live
     once the layout is single-column. It must be fully opaque and
     raised: as the first grid child it pins while the list scrolls
     underneath it, so a transparent strip renders the counter directly
     on top of whatever row is passing behind. */
  .ln-caps-sticky {
    top: 62px;
    z-index: 5;
    display: flex;
    align-items: center;
    gap: 16px;
    padding: 12px 0 14px;
    background: var(--bg);
    border-bottom: 1px solid var(--border);
  }
  .ln-caps-cur { font-size: 2.4rem; }
  .ln-caps-bar { flex: 1; margin-top: 0; }
  .ln-card-rail { grid-template-columns: repeat(auto-fill, minmax(230px, 1fr)); }
}

/* Honour a reduced-motion preference: the page still reveals (content
   must never be stuck invisible), it just arrives without travel,
   and every looping decorative animation stops. supportsLanding3D() in
   app.js separately declines to mount the WebGL hero at all. */
@media (prefers-reduced-motion: reduce) {
  [data-reveal], .ln-word { transition-duration: 1ms !important; transform: none !important; }
  .ln-ltr { animation: none; opacity: 1; }
  .ln-scrollcue-dot,
  .ln-law.is-in .ln-fig-force .ln-fig-arrow,
  .ln-law.is-in .ln-fig-force .ln-fig-mass,
  .ln-law.is-in .ln-fig-momentum .ln-fig-echo,
  .ln-law.is-in .ln-fig-momentum .ln-fig-mass,
  .ln-law.is-in .ln-fig-inertia .ln-fig-still { animation: none; }
  .ln-btn:hover, .ln-card:hover, .ln-plan:hover, .ln-law:hover { transform: none; }
  .ln-card:hover .ln-card-media { transform: none; }
}

/* -------- admin Plans table (renderAdmin's plan-settings form) -------- */
/* The limits here are the live enforced values, not a display copy —
   see PLAN_LIMITS/loadPlanSettings in server.js. */
.admin-plans-scroll {
  overflow-x: auto;
  margin-bottom: 6px;
}
.admin-plans-table {
  width: 100%;
  border-collapse: collapse;
  min-width: 640px;
}
.admin-plans-table th,
.admin-plans-table td {
  text-align: left;
  padding: 10px 12px 10px 0;
  border-bottom: 1px solid var(--border);
  vertical-align: middle;
}
.admin-plans-table th {
  font-size: calc(13px * var(--scale));
  font-weight: 600;
  color: var(--muted);
  white-space: nowrap;
}
.admin-plan-input {
  width: 92px;
  padding: 8px 10px;
  font-size: calc(15px * var(--scale));
}
/* The ∞ placeholder is the affordance for "leave blank for unlimited" —
   without it an empty box just reads as unset. */
.admin-plan-input::placeholder { color: var(--muted); opacity: 0.7; }
.admin-plan-input:disabled { opacity: 0.5; cursor: not-allowed; }
/* Trainer plans table — same shape as .admin-plan-input above, kept as a
   distinct class (not shared) so the two tables' autosave handlers can
   each scope their own $$('.admin-*-plan-input') query without picking
   up the other table's fields. */
.admin-trainer-plan-input {
  width: 92px;
  padding: 8px 10px;
  font-size: calc(15px * var(--scale));
}
.admin-trainer-plan-input::placeholder { color: var(--muted); opacity: 0.7; }
.admin-trainer-plan-input:disabled { opacity: 0.5; cursor: not-allowed; }
/* Prices are read-only display now (locked to Stripe) rather than an
   input — styled muted/monospace so it reads as "reported", not "unset". */
.admin-plan-price-cell { font-family: var(--font-mono); color: var(--muted); font-size: calc(14px * var(--scale)); }
.admin-plans-note { margin-top: 4px; }
.admin-plans-save-status {
  font-size: calc(13px * var(--scale));
  color: var(--muted);
}
.admin-plans-save-status-error { color: var(--danger); }
/* Was `display: block` (overriding .trainer-request-row's flex) so a long
   message and its stack trace wrapped freely instead of fighting for
   width. The Dismiss button needs a home without giving that up, so:
   grid, message column capped at minmax(0, 1fr) — a bare 1fr is
   minmax(auto, 1fr) and an unbroken stack-trace line would set the whole
   row's floor — with the button pinned top-right. */
.admin-log-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: start;
  gap: 10px;
}
/* Without this the flex column refuses to shrink past its widest child,
   and .admin-log-stack's own overflow-x never gets the chance to fire. */
.admin-log-row .trainer-request-info { min-width: 0; }
.admin-log-dismiss { white-space: nowrap; }
/* Dismissed rows only appear at all under "Show dismissed", where the
   point is to tell them apart from live ones at a glance. */
.admin-log-row-dismissed { opacity: 0.55; }
@media (max-width: 640px) {
  .admin-log-row { grid-template-columns: minmax(0, 1fr); }
  .admin-log-dismiss { justify-self: start; }
}
.admin-log-stack {
  margin: 6px 0 0;
  padding: 8px 10px;
  background: var(--panel);
  border-radius: var(--radius-control);
  font-family: var(--font-mono);
  font-size: calc(12px * var(--scale));
  color: var(--muted);
  overflow-x: auto;
  white-space: pre;
}
.admin-plans-error {
  margin: 0 0 12px;
  padding: 12px 14px;
  border: 1px solid var(--danger);
  border-radius: var(--radius-control);
  color: var(--danger);
  font-size: calc(14px * var(--scale));
}

/* -------- admin featured-content modal (openFeatureModal in app.js) -------- */
.admin-feature-preview {
  display: block;
  max-width: 100%;
  max-height: 220px;
  object-fit: cover;
  border-radius: var(--radius-control);
  margin-bottom: 4px;
}

.legal-page {
  max-width: 640px;
  margin: 8vh auto 0;
  padding: 0 20px 40px;
  line-height: 1.6;
}
.legal-page h1 {
  margin-bottom: 12px;
}
.legal-placeholder-notice {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 5px;
  padding: 14px 16px;
  color: var(--danger);
  font-size: calc(14px * var(--scale));
}

/* -------- workout list -------- */
.workout-list {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
  gap: 16px;
  margin-bottom: 24px;
}
/* Tabs + the "+ New" workout dropdown share one row, action on the
   right — the dropdown collapses the four creation methods (Build/Ad
   hoc/Browse templates/Generate) that used to be four separate buttons
   wrapping onto their own row below the workout list. .tabs carries its
   own bottom border/margin (shared with History/Measure, which use it
   without this wrapper) — suppressed here so only the row's own border
   shows, spanning the tabs AND the button as one seam instead of
   stopping short under just the tab strip. */
.tabs-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 12px;
  border-bottom: 1px solid var(--lib-line);
  margin-bottom: 18px;
}
.tabs-row .tabs { border-bottom: none; margin-bottom: 0; }
/* Groups "Log cardio" and "+ New" together as one flex item so
   .tabs-row's own space-between only splits it from the tab strip, not
   from each other — otherwise a 3-child space-between row spreads all
   three out evenly instead of keeping the two action buttons adjacent. */
.tabs-row-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
@media (max-width: 480px) {
  .tabs-row { row-gap: 10px; }
  /* The Body screen's date/time picker (3 tabs plus a labelled date
     input) is genuinely too wide to sit beside the tab strip on a narrow
     phone, so it drops to its own full-width row. Narrower right-side
     controls (Train's + New dropdown, Admin's + Add user button)
     deliberately don't get this treatment — .tabs-row's own
     flex-wrap: wrap already sends them to their own row once they
     actually stop fitting; forcing it here unconditionally made Train's
     + New button wrap even when there was plenty of room left beside
     Today/Plan (issue #61). */
  #measure-tabs-row > :not(.tabs) { flex: 1 1 100%; }
}
.dropdown { position: relative; display: inline-block; flex: none; }
.dropdown-caret { font-size: 0.7em; }
/* The trigger reuses .lib-ctrl-btn's compact pill shape (button.primary
   still wins the accent fill by specificity — see that combo's existing
   use on admin's "+ Add exercise") so it reads as part of the same
   dense --lib-* tab-bar language instead of a full-size CTA button
   dropped in beside a slim tab strip. */
.dropdown-menu {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 50;
  display: flex;
  flex-direction: column;
  gap: 4px;
  /* max-content, not a fixed min-width — a fixed guess (220px) was
     narrower than the longest item ("🤖 Generate workout"), which wrapped
     it onto two lines. Sizing to the widest item keeps every item on one
     line regardless of which optional items (Generate workout) happen to
     be present. Capped so it can't overflow a narrow viewport. */
  width: max-content;
  max-width: min(320px, calc(100vw - 32px));
  min-width: 220px;
  /* Solid, not --lib-panel: that token is a ~4%-opacity glass wash meant
     to sit on top of an already-opaque card (like .lib-plate), not to be
     the only thing behind a menu floating over arbitrary page content -
     it read as barely-there/see-through. Same fix #topbar and
     .lib-filterbar already apply for the identical reason (both are
     sticky/floating over scrolling content, see the styles.css header
     comment on those). */
  background: var(--lib-bg);
  border: 1px solid var(--lib-line-strong);
  border-radius: var(--radius-control, 5px);
  padding: 6px;
  box-shadow: var(--shadow);
}
.dropdown-menu button {
  text-align: left;
  background: transparent;
  border: none;
  color: var(--lib-text);
  white-space: nowrap;
}
.dropdown-menu button:hover { background: var(--lib-panel-2); }
/* A plan-gated/quota-exhausted feature stays visible (see the "Generate
   workout" button) rather than hidden, so a free-plan account sees the
   upsell instead of a vanished feature — dimmed just enough to read as
   "not available right now" without looking broken. */
.dropdown-menu button.locked { opacity: 0.6; }

/* Workout builder modal (openWorkoutBuilderModal) — launched from the
   Workouts screen, so shared tokens/scale, not --lib-*, per the rule
   documented above about not mixing token systems within a screen. */
.builder-slots { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
/* #103 — a slot is a card now, not a label beside a <select>. The select
   truncated long exercise names mid-word at modal width and had nowhere
   to put the sets/reps/rest that were being applied invisibly at create
   time; the card gives the name a full line of its own and the three
   fields a row under it. */
.builder-slot-card {
  border: 1px solid var(--border);
  border-radius: var(--radius-control);
  background: var(--panel-2);
  padding: 11px 13px;
  display: flex;
  flex-direction: column;
  gap: 7px;
  min-width: 0;
}
.builder-slot-card.empty { border-style: dashed; }
.builder-slot-top { display: flex; align-items: center; justify-content: space-between; gap: 10px; }
.builder-slot-tools { flex: none; display: flex; align-items: center; gap: 5px; }
.builder-slot-name { font-size: calc(15px * var(--scale)); font-weight: 600; overflow-wrap: anywhere; }
.builder-slot-equipment { font-size: calc(12px * var(--scale)); color: var(--muted); }
.builder-slot-fields { display: flex; gap: 8px; flex-wrap: wrap; }
/* --- Build to a target (openTargetBuilderModal, #103 option C) ---------
   The inverse of the builder above: the goal's movements as empty meters
   you fill yourself. Shares .lib-tray-* for the picked list, plus the
   builder's own type buttons, name field and add row for its chrome,
   rather than cloning them — these two modals are deliberately siblings,
   and a visual difference between them here would read as meaning
   something it doesn't. Shared tokens throughout, same as the builder.

   NOTE: do not write a class glob immediately followed by a slash in
   these comments. ".builder-type-*" plus "/" spells a comment-closer,
   which silently ends the comment early and turns the rest of this
   sentence into garbage CSS that swallows the rules after it — exactly
   how the --scale block was broken once before. */
.target-summary {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 10px;
  font-size: calc(13px * var(--scale));
  color: var(--muted);
}
.target-summary strong { color: var(--text-primary); font-size: calc(15px * var(--scale)); }
/* --success, not --accent, for the "you're done" states below. Accent on
   panel measures 3.83:1 in dark mode — fine for a bar, under AA for text
   — and this is also genuinely a success signal rather than a brand one,
   which is the distinction --success exists to carry. */
.target-summary.complete strong { color: var(--success); }
.target-meters { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.target-meter { display: grid; grid-template-columns: minmax(0, 130px) 1fr auto; gap: 10px; align-items: center; }
.target-meter-label {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  font-size: calc(12px * var(--scale));
  color: var(--muted);
  overflow-wrap: anywhere;
}
.target-meter.met .target-meter-label { color: var(--text-primary); }
.target-track {
  min-width: 0;
  height: 7px;
  border-radius: 4px;
  background: var(--panel-2);
  overflow: hidden;
}
.target-track i { display: block; height: 100%; border-radius: 4px; background: var(--accent); }
/* A zero-width fill would be invisible, so an untouched movement still
   paints a small stub — "nothing here yet" has to be legible as a state,
   not as an empty row you might read straight past. --muted, not
   --border: border-on-panel-2 measures 1.15:1 in dark mode, which is the
   stub being invisible in exactly the theme most people use it in, and
   an invisible "empty" marker is worse than none. */
.target-track i.zero { min-width: 6px; background: var(--muted); }
.target-track i.part { background: var(--accent-2, var(--accent)); opacity: 0.75; }
.target-meter-count {
  flex: none;
  display: flex;
  align-items: center;
  gap: 5px;
  font-family: var(--font-mono);
  font-size: calc(11.5px * var(--scale));
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.target-meter.met .target-meter-count { color: var(--success); }
.target-over {
  padding: 0 5px;
  border-radius: 999px;
  background: var(--panel-2);
  color: var(--muted);
  font-size: 10px;
}
.target-short, .target-done, .target-short-empty {
  margin: 0 0 8px;
  font-size: calc(13px * var(--scale));
  color: var(--muted);
}
.target-done { color: var(--success); }
.target-suggest { display: flex; flex-direction: column; gap: 4px; margin-bottom: 16px; }
.target-suggest-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 7px 10px;
  border: 1px solid var(--border);
  border-radius: var(--radius-control);
  background: var(--panel-2);
}
.target-suggest-main { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.target-suggest-name { font-size: calc(13.5px * var(--scale)); overflow-wrap: anywhere; }
.target-suggest-meta { font-size: calc(11px * var(--scale)); color: var(--muted); }
.target-picks { border-top: 0; padding-top: 0; }
.target-pick-meta { font-size: calc(11px * var(--scale)); color: var(--muted); }
.target-pick-meta.offplan { color: var(--accent-2, var(--accent)); }

@media (max-width: 560px) {
  .target-meter { grid-template-columns: 1fr auto; }
  /* The track drops to its own full-width line under the label rather
     than being squeezed to a few unreadable pixels beside it. */
  .target-track { grid-column: 1 / -1; order: 3; }
}

.builder-name-field { display: flex; flex-direction: column; gap: 5px; margin-bottom: 14px; }
.builder-name-field span {
  font-size: calc(11px * var(--scale));
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--muted);
}
.builder-name-field input { width: 100%; min-width: 0; }
.builder-add-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  flex-wrap: wrap;
  margin-bottom: 18px;
}
.builder-count { font-size: calc(12px * var(--scale)); color: var(--muted); }
/* Step 1's type buttons carry their exercise count, so picking a type is
   no longer a completely blind choice between six bare names. */
.builder-type-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 8px;
  margin-bottom: 18px;
}
.builder-type-btn {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  min-width: 0;
  min-height: 0;
  padding: 11px 13px;
  border: 1px solid var(--border);
  border-radius: var(--radius-control);
  background: var(--panel-2);
  text-align: left;
  cursor: pointer;
}
.builder-type-btn:hover { border-color: var(--accent); }
.builder-type-name { font-size: calc(14px * var(--scale)); font-weight: 600; }
.builder-type-meta { font-size: calc(11.5px * var(--scale)); color: var(--muted); }
.builder-slot-label {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 120px;
  font-weight: 600;
  color: var(--muted);
  font-size: 13px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
/* Movement pattern icon (IMG-02) — sized to sit on the label's own cap
   height rather than the row's full height, and never shrinks when the
   label text wraps onto two lines (min-width:120px above is easy to
   exceed with a long admin-authored slot label). */
.pattern-icon { width: 16px; height: 16px; flex: none; color: currentColor; }
.builder-slot-empty { color: var(--muted); font-size: calc(13px * var(--scale)); margin: 0; }
.workout-card {
  /* --lib-panel/--lib-line, not the shared --panel/--border — this card
     sits directly under the Train screen's --lib-plate header and the
     Today block's .train-scheduled cards (both already --lib-*), and
     --panel is a visibly different, slightly bluer solid shade next to
     the translucent --lib-panel wash the rest of this screen uses. */
  background: var(--lib-panel);
  border: 2px solid var(--lib-line);
  border-radius: var(--radius-card);
  padding: 24px;
  cursor: pointer;
  min-height: 160px;
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.workout-card:hover { border-color: var(--accent); }
/* #100 follow-up — the card's own click no longer starts a session in
   trainer-viewing mode (see renderWorkouts()); the ✎/expand icon buttons
   inside it stay clickable regardless, they're not part of this rule. */
.workout-card-readonly { cursor: default; }
.workout-card-readonly:hover { border-color: var(--lib-line); }

/* Train screen — Today block and the signal row on each workout card. */
.train-section-heading {
  margin: 28px 0 12px;
  font-family: var(--font-display);
  font-size: 15px;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}
/* Weekly plan's own heading needs a right-aligned control alongside it
   (the Google Calendar sync status button) — every other
   .train-section-heading stands alone, so this wrapper is scoped to
   just this one instead of changing the shared heading's own layout. */
.train-section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; }
.train-section-head .train-section-heading { margin: 28px 0 12px; flex: none; }
/* #btn-plan-calendar-sync (an id, not just the class below) — needed to
   outrank .mobile-mode .icon-btn's own padding override below 1060px;
   a single-class selector here would lose to that compound one on
   source order alone and the button would grow past "Log cardio"'s
   height on exactly the viewport widths this was meant to fix. */
#btn-plan-calendar-sync.train-calendar-sync-btn {
  margin: 28px 0 12px;
  /* Same box-model as "Log cardio" (.lib-ctrl-btn) on this same screen,
     so the two sit at the same height — matched dimension-for-dimension
     (padding/font-size/border) rather than sharing the class itself,
     since .lib-ctrl-btn's own colours are the --lib-* texture reserved
     for Library/History/Measure, not this screen's shared tokens. */
  font-size: 12.5px;
  font-weight: 600;
  min-height: 0;
  max-height: 29px;
  color: var(--accent);
  border: 1px solid var(--border);
  border-radius: 999px;
  padding: 5px 14px;
  white-space: nowrap;
}
.train-calendar-sync-btn:hover { border-color: var(--accent); background: transparent; }
.train-today-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: 12px;
}
.train-resume, .train-scheduled {
  display: flex;
  flex-direction: column;
  gap: 4px;
  align-items: flex-start;
  text-align: left;
  padding: 18px 20px;
  border-radius: var(--radius-card);
  cursor: pointer;
  min-height: 0;
}
/* The resume card is the only accent-filled surface on this screen — an
   in-progress session is the one thing here that's time-sensitive. */
.train-resume {
  background: var(--accent);
  border: 2px solid var(--accent);
  color: var(--accent-ink);
}
.train-resume-label, .train-scheduled-label {
  font-size: 12px;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  opacity: 0.75;
}
.train-resume-name, .train-scheduled-name {
  font-family: var(--font-display);
  font-size: 20px;
  font-weight: 600;
}
.train-resume-meta { font-size: 14px; opacity: 0.85; }
.train-scheduled {
  background: var(--lib-panel);
  border: 2px solid var(--lib-line);
  color: var(--text-primary);
}
.train-scheduled:hover { border-color: var(--accent); }
.train-scheduled.skipped { opacity: 0.5; cursor: default; text-decoration: line-through; }
/* #91 — a completed slot shrinks to one row ("Complete – {name}") and
   shades with the app's existing --success green, matching the
   "already done" convention History's own session dots use. */
.train-scheduled.completed {
  flex-direction: row;
  align-items: center;
  background: color-mix(in srgb, var(--success) 18%, var(--lib-panel));
  border-color: var(--success);
  color: var(--success);
  cursor: default;
  padding: 10px 20px;
}
.train-scheduled.completed .train-scheduled-name { font-size: 16px; }
/* --success is already darkened for light mode (see the :root[data-
   appearance="light"] override up top) for WCAG contrast against a
   plain background, but layered on this card's own tinted green
   background that still read too washed out — a further, dedicated
   darkening for this one spot rather than changing --success globally
   and risking every other --success use case elsewhere. */
:root[data-appearance="light"] .train-scheduled.completed { color: #24462f; }

.workout-card-stats {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  min-height: 22px;
  color: var(--muted);
  font-size: 13px;
}
.workout-card-stat.mono { font-family: var(--font-mono); color: var(--text-primary); }
.workout-card-head { display: flex; align-items: flex-start; gap: 8px; }
.workout-card-head .title { flex: 1; min-width: 0; }
/* Quiet until the card is hovered or the button itself is focused:
   starting a workout is what this card is for, editing it is the rare
   secondary path and shouldn't compete for attention. Always reachable
   by keyboard and always a full tap target, just low-contrast. */
.workout-card-edit { opacity: 0.45; flex: none; }
.workout-card:hover .workout-card-edit,
.workout-card-edit:focus-visible { opacity: 1; }

/* #40 — the toggle arrow stacks directly under the edit button, per the
   issue's own ask ("this should be under the edit button"). Hidden
   entirely outside mobile mode — desktop always shows the exercise list
   inline, there's nothing to toggle. */
.workout-card-head-actions { display: flex; flex-direction: column; align-items: center; gap: 4px; flex: none; }
.workout-card-toggle-exercises { display: none; }
.mobile-mode .workout-card-toggle-exercises {
  display: inline-flex;
  opacity: 0.45;
}
.workout-card:hover .workout-card-toggle-exercises,
.workout-card-toggle-exercises:focus-visible { opacity: 1; }
.workout-card-toggle-exercises .icon-glyph { transition: transform 150ms ease; }
.workout-card.exercises-open .workout-card-toggle-exercises .icon-glyph { transform: rotate(180deg); }
/* Collapsed by default on mobile; .exercises-open (toggled by the arrow
   above) reveals it again as a preview, without leaving Train. */
.mobile-mode .workout-card .exercise-list { display: none; }
.mobile-mode .workout-card.exercises-open .exercise-list { display: flex; }

/* The base .sparkline is width:100% — correct inside a stat card, but in
   this flex row it would grow to its 140px cap and drag the row's height
   up with it. Here it's an inline detail, so pin it to what was asked
   for. */
.workout-card-stats .sparkline { width: 72px; margin-left: auto; }

/* Post-workout summary — the payoff that used to be a confirm() and a
   redirect. Full-focus like the runner (see NAV_HIDDEN_VIEWS): the moment
   deserves the whole screen, not nav chrome competing for attention. */
.screen-session-summary { max-width: 640px; margin: 0 auto; }
.summary-head { text-align: center; margin-bottom: 20px; }
.summary-stats { justify-content: center; margin-bottom: 20px; }
.summary-prs-banner {
  background: var(--accent);
  color: var(--accent-ink);
  border-radius: var(--radius-control, 5px);
  padding: 12px 16px;
  margin-bottom: 20px;
  font-weight: 600;
  text-align: center;
  /* Complements the celebrate() confetti burst that already fires on the
     workout name heading above this (see renderSessionSummary()) — a
     matching one-shot glow on the banner itself so the "you hit a PB"
     payoff isn't just the heading's own small burst, the whole line that
     names it lights up too. Pure CSS (plays automatically on mount, same
     reasoning as the modal-open animation above) rather than WAAPI, since
     there's no dynamic value here that needs JS to compute. */
  animation: summary-pr-glow 1100ms var(--ease-settle) both;
}
@keyframes summary-pr-glow {
  0% { box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 60%, transparent); transform: scale(0.98); }
  40% { transform: scale(1); }
  100% { box-shadow: 0 0 0 16px color-mix(in srgb, var(--accent) 0%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
  .summary-prs-banner { animation: none; }
}
.summary-exercise {
  border: 1px solid var(--border);
  border-radius: var(--radius-card);
  padding: 14px 16px;
  margin-bottom: 10px;
}
.summary-exercise.pb { border-color: var(--success); }
.summary-exercise-name { font-weight: 600; display: flex; align-items: center; gap: 8px; }
.summary-pb-tag {
  font-size: 11px; font-weight: 700; letter-spacing: 0.05em;
  color: var(--success); border: 1px solid var(--success); border-radius: 999px;
  padding: 1px 8px;
}
.summary-exercise-sets { display: flex; flex-wrap: wrap; gap: 6px; margin: 8px 0; }
.summary-set-chip {
  font-family: var(--font-mono); font-size: 13px;
  background: var(--panel-2); border-radius: 999px; padding: 3px 10px;
}
.summary-exercise-volume {
  display: flex; align-items: center; gap: 8px;
  color: var(--muted); font-size: 13px;
}
#btn-summary-done { width: 100%; margin-top: 8px; }
.workout-card .title { font-size: calc(26px * var(--scale)); font-weight: 600; }
.workout-card .subtitle { color: var(--muted); font-size: calc(15px * var(--scale)); }
.workout-card .exercise-list {
  list-style: none;
  padding: 12px 0 0;
  margin: 0;
  color: var(--muted);
  font-size: calc(16px * var(--scale));
  display: flex;
  flex-direction: column;
  gap: 4px;
  border-top: 1px solid var(--border);
  margin-top: auto;
}

.row-actions {
  display: flex;
  gap: 12px;
  justify-content: flex-end;
}

.workout-update-badge {
  align-self: flex-start;
  margin-top: 4px;
  padding: 4px 10px;
  border-radius: 999px;
  border: 1px solid var(--accent);
  background: transparent;
  color: var(--accent);
  font-size: calc(13px * var(--scale));
  max-height: 29px;
  cursor: pointer;
}
.workout-update-badge:hover { background: var(--accent); color: var(--accent-ink); }

/* -------- runner -------- */
.runner-top {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 12px;
}
.runner-title { flex: 1; min-width: 0; }
.workout-name { color: var(--muted); font-size: calc(15px * var(--scale)); letter-spacing: 0.02em; text-transform: uppercase; }
.exercise-counter { color: var(--muted); font-size: calc(15px * var(--scale)); margin-top: 2px; }

/* The toggle and the dots/pills it controls share one flex-wrap row so
   they read as a single line (#67) — .runner-progress itself becomes
   display: contents inside it, merging its own children directly into
   this row's wrap flow rather than wrapping as a separate nested block. */
.runner-progress-row { display: flex; align-items: center; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; }
.runner-progress { display: contents; }
/* min-height: 0 on both is load-bearing, not decorative — the shared
   global `button` rule sets min-height: var(--btn-min) (a full tap
   target), which otherwise wins over these buttons' own explicit
   height/padding and inflates them well past the dots they're meant to
   sit flush alongside on one line. */
/* #79 — a real filled badge (not just coloured text): --link is this
   design system's genuine fixed blue (see the "blue only for links and
   informational states" rule) rather than --accent, which is a per-
   profile colour a user can set to anything — this toggle should read
   the same way for everyone. The "+"/"−" glyph itself is swapped in JS
   (renderRunner) based on runnerProgressNamesMode, so the icon always
   shows what tapping it does next. */
.runner-progress-toggle {
  flex: none;
  width: 22px; height: 22px;
  min-height: 0;
  border-radius: 50%;
  border: none;
  background: var(--link);
  /* Was a hardcoded #000 — --link is contrast-corrected per profile
     accent/appearance (see applyTheme()'s --color-blue), but the fixed
     black text on top wasn't, so it could read fine in one mode and go
     low-contrast in the other. --accent-ink is the same "readable text
     on an accent-derived background" token every other accent-coloured
     control (buttons, badges, the per-side toggle) already uses. */
  color: var(--accent-ink);
  font-family: var(--font-display);
  font-weight: 700;
  font-size: 15px;
  line-height: 1;
  cursor: pointer;
  padding: 0;
}
.runner-progress-toggle:hover { background: var(--link-hover); }
.runner-dot {
  width: 10px; height: 10px; border-radius: 50%;
  background: var(--border);
  border: 2px solid transparent;
}
.runner-dot.filled { background: var(--accent); }
/* F-13 — a partially-worked exercise fills from the bottom in
   proportion to sets logged (--dot-fill, set per-dot in
   renderWorkoutProgress). Previously every in-progress exercise looked
   identical to an untouched one, so the row answered "which am I on"
   but not "how far through". A hard-stop gradient rather than a nested
   element keeps this to one node per dot. */
.runner-dot.partial {
  background: linear-gradient(
    to top,
    var(--accent) var(--dot-fill, 0%),
    var(--border) var(--dot-fill, 0%)
  );
}
.runner-dot.current { border-color: var(--accent); }
/* #79 — inverted from the original #67 styling: every pill defaults to
   solid accent fill now, and it's the CURRENT exercise's own pill that's
   transparent/outlined instead, so the active one is the one that stands
   apart from the row rather than blending into it. */
.runner-pill {
  display: inline-flex; align-items: center; gap: 4px;
  min-height: 0;
  padding: 2px 10px;
  border-radius: 999px;
  border: 1px solid var(--accent);
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 12px;
  line-height: 1.6;
  cursor: pointer;
  max-width: 100%;
}
.runner-pill:hover { background: var(--accent-2); border-color: var(--accent-2); }
.runner-pill.done { background: none; color: var(--muted); border-color: var(--border); }
/* The currently-selected exercise's own pill — transparent/outlined
   rather than every other pill's solid accent fill, so which one is
   active reads at a glance. Wins over .done: an already-logged exercise
   you're currently on should still read as "current" first. */
.runner-pill.current { background: none; border-color: var(--accent); color: var(--accent); }
.runner-pill.current:hover { background: var(--lib-accent-soft, rgba(255,255,255,0.08)); }

/* Glass — see .settings-card. The runner's own sticky timer panel is the
   one exception and stays solid; the note on .timer-panel says why. */
.exercise-card {
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: 5px;
  padding: 24px;
  margin-bottom: 20px;
}
.exercise-card h2 { font-size: calc(32px * var(--scale)); margin-bottom: 6px; word-wrap: break-word; }
.exercise-card h2.pb {
  display: inline-block;
  color: var(--success);
  border-bottom: 3px solid var(--success);
  padding-bottom: 2px;
}
.exercise-meta {
  display: flex;
  gap: 16px;
  align-items: center;
  color: var(--muted);
  margin-bottom: 20px;
  font-size: calc(17px * var(--scale));
  flex-wrap: wrap;
}
.unit-toggle {
  margin-left: auto;
  min-height: calc(var(--btn-min) * 0.75);
  padding: 4px 12px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--text);
  font-family: var(--font-mono);
  font-size: calc(14px * var(--scale));
  text-transform: uppercase;
  cursor: pointer;
}
.unit-toggle:hover { border-color: var(--accent); }

.setup-note {
  display: flex;
  align-items: flex-start;
  gap: 12px;
  background: var(--warning-soft);
  border: 1px solid rgba(245, 158, 11, 0.4);
  border-radius: var(--radius-lg);
  padding: 14px 16px;
  margin-bottom: 20px;
}
.setup-note-icon { font-size: calc(22px * var(--scale)); line-height: 1; }
.setup-note-text {
  font-size: calc(18px * var(--scale));
  color: var(--text);
  white-space: pre-wrap;
  line-height: 1.4;
}

.anim-toggle {
  display: inline-flex;
  align-items: center;
  background: none;
  border: none;
  padding: 4px 0;
  margin-bottom: 14px;
  color: var(--accent);
  font-size: calc(16px * var(--scale));
  font-weight: 600;
  cursor: pointer;
}
.anim-toggle:disabled {
  color: var(--muted);
  font-weight: 400;
  cursor: default;
}
.anim-panel {
  display: flex;
  /* F-07 — column so the rig can carry a caption under it saying what
     it's actually showing; it had none, and a schematic figure with no
     label is easy to read as a rendering glitch rather than a diagram. */
  flex-direction: column;
  justify-content: center;
  align-items: center;
  gap: 10px;
  min-height: 190px;
  padding: 14px 12px;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 5px;
  margin-bottom: 20px;
}
/* F-07 — a mono caption, deliberately understated: it names the
   apparatus being drawn (the rig really does draw per-equipment props)
   and says "schematic" out loud, so nobody reads a jointed stick figure
   as a claim about precise form. */
.anim-caption {
  font-family: var(--font-mono);
  font-size: calc(11px * var(--scale));
  letter-spacing: 0.04em;
  color: var(--muted);
  text-align: center;
  text-transform: uppercase;
}
.anim-panel[hidden] { display: none; }

/* The runner's 3D demo mount — same idea as .body-3d-mount but sized to
   drop into the existing .anim-panel slot rather than a full-width
   section. See app.js's ANIMATED_3D_MOVES for which movements use this
   instead of .exercise-anim-rig. */
/* F-07 — the mount was a fixed 172x190 box sitting in a panel that is
   ~690px wide on the desktop runner. Widened so it uses some of that
   space, but bounded: mountBody3D() reads this element's rendered size
   once at mount time to size its renderer, so the clamp resolves before
   the canvas is created and the WebGL buffer matches. Height is fixed
   rather than aspect-linked — the rig is a standing figure, so vertical
   room is what it actually needs, and a fixed height keeps the panel
   from jumping as the runner moves between exercises. */
.run-anim-3d-mount {
  position: relative;
  width: clamp(172px, 38%, 260px);
  height: 210px;
  border-radius: 5px;
  overflow: hidden;
  background: radial-gradient(circle at 50% 30%, var(--lib-panel), var(--lib-bg));
}
.run-anim-3d-mount canvas { display: block; width: 100%; height: 100%; }

/* RepDB exercise-animation overlay (see repdbMapping in app.js) — a real
   production clip, not the small stick-figure rig, so it gets the full
   width of the panel rather than a fixed small box. */
.run-anim-repdb-img {
  display: block;
  width: 100%;
  max-width: 320px;
  height: auto;
  margin: 0 auto;
  border-radius: 5px;
}

/* Exercise demo rig — one shared jointed figure, posed and animated into
   every movement in the library; see resolveDemo() in app.js. Limbs are
   thick round-capped lines so the figure reads as a solid character
   rather than a wireframe. Rest pose is a side view facing right, hips
   at (72,96), ground at y=150.

   Two-layer joints: `-pose` groups take a static angle from the posture
   class, the inner groups take the movement's @keyframes. Because they're
   separate elements the transforms compose, which is what lets the same
   rig be a standing squat and a figure lying on a bench. It also means a
   movement's keyframes are written relative to its posture, so most are a
   single `to {}` — the implicit `from` is the posture's own rest angle. */
/* F-07 — was a fixed 172px square, which on the desktop runner sat
   marooned in a ~690px panel with dead space either side of it. Scales
   with the panel now, capped so it stays a diagram rather than becoming
   the loudest thing on the screen. The floor is the old fixed size, so
   nothing gets smaller than it used to be on a phone. */
.exercise-anim-rig { width: clamp(172px, 42%, 280px); height: auto; aspect-ratio: 1 / 1; }
.exercise-anim-rig line { stroke-width: 10; stroke-linecap: round; fill: none; }
.exercise-anim-rig .ea-torso { stroke-width: 12; }
.exercise-anim-rig .ea-foot { stroke-width: 7; }
.exercise-anim-rig .ea-near, .exercise-anim-rig .ea-torso, .exercise-anim-rig .ea-head { stroke: var(--accent); }
.exercise-anim-rig .ea-head { fill: var(--accent); }
.exercise-anim-rig .ea-far { stroke: var(--lib-accent-2); opacity: 0.8; }

/* Equipment sits behind/around the figure and is deliberately drawn in a
   muted grey so it reads as apparatus, never as part of the body. */
.exercise-anim-rig .ea-prop-bar,
.exercise-anim-rig .ea-prop-line,
.exercise-anim-rig .ea-prop-rail,
.exercise-anim-rig .ea-prop-bench,
.exercise-anim-rig .ea-prop-cable,
.exercise-anim-rig .ea-prop-band { stroke: var(--lib-muted); fill: none; }
.exercise-anim-rig .ea-prop-bar { stroke-width: 7; stroke-linecap: round; }
.exercise-anim-rig .ea-prop-bench { stroke-width: 8; stroke-linecap: round; }
.exercise-anim-rig .ea-prop-rail { stroke-width: 4; opacity: 0.6; }
.exercise-anim-rig .ea-prop-line { stroke-width: 4; opacity: 0.55; }
.exercise-anim-rig .ea-prop-cable { stroke-width: 2.5; opacity: 0.75; }
.exercise-anim-rig .ea-prop-band { stroke-width: 3.5; opacity: 0.75; stroke-dasharray: 7 5; }
.exercise-anim-rig .ea-prop-plate { fill: var(--lib-muted); }
.exercise-anim-rig .ea-prop-fill { fill: var(--lib-muted); opacity: 0.85; }
.exercise-anim-rig .ea-prop-box { fill: none; stroke: var(--lib-muted); stroke-width: 4; opacity: 0.6; }
.exercise-anim-rig .ea-prop-wheel { fill: none; stroke: var(--lib-muted); stroke-width: 4; opacity: 0.6; }

/* Pivots. Each joint's `-pose` wrapper and its animated inner group share
   one origin — the joint's own coordinate, which stays valid inside a
   parent's transform because a child group's coordinates are expressed in
   the parent's local space. transform-box is set explicitly: the SVG
   default resolves transform-origin against the element bbox, not the
   viewBox, which would put every pivot in the wrong place. */
.exercise-anim-rig g { transform-box: view-box; }
.exercise-anim-rig .ea-figure                                     { transform-origin: 72px 100px; }
.exercise-anim-rig .ea-torso-pose, .exercise-anim-rig .ea-torso-root { transform-origin: 72px 96px; }
.exercise-anim-rig .ea-hip-near-pose, .exercise-anim-rig .ea-hip-near { transform-origin: 74px 96px; }
.exercise-anim-rig .ea-knee-near-pose, .exercise-anim-rig .ea-knee-near { transform-origin: 74px 122px; }
.exercise-anim-rig .ea-hip-far-pose, .exercise-anim-rig .ea-hip-far   { transform-origin: 70px 96px; }
.exercise-anim-rig .ea-knee-far-pose, .exercise-anim-rig .ea-knee-far { transform-origin: 70px 122px; }
.exercise-anim-rig .ea-shoulder-near-pose, .exercise-anim-rig .ea-shoulder-near { transform-origin: 78px 54px; }
.exercise-anim-rig .ea-elbow-near-pose, .exercise-anim-rig .ea-elbow-near       { transform-origin: 78px 78px; }
.exercise-anim-rig .ea-shoulder-far-pose, .exercise-anim-rig .ea-shoulder-far   { transform-origin: 74px 54px; }
.exercise-anim-rig .ea-elbow-far-pose, .exercise-anim-rig .ea-elbow-far         { transform-origin: 74px 78px; }

/* ---- Postures (static) ---------------------------------------------- */
/* pose-stand is the rest pose and needs no rules. */

.pose-hinged .ea-torso-pose { transform: rotate(62deg); }
.pose-hinged .ea-knee-near-pose, .pose-hinged .ea-knee-far-pose { transform: rotate(14deg); }
.pose-hinged .ea-hip-near-pose, .pose-hinged .ea-hip-far-pose { transform: rotate(-10deg); }

.pose-supine .ea-figure { transform: translate(-4px, 14px) rotate(90deg); }
.pose-supine .ea-shoulder-near-pose, .pose-supine .ea-shoulder-far-pose { transform: rotate(90deg); }
.pose-supine .ea-hip-near-pose, .pose-supine .ea-hip-far-pose { transform: rotate(-62deg); }
.pose-supine .ea-knee-near-pose, .pose-supine .ea-knee-far-pose { transform: rotate(84deg); }

.pose-incline .ea-figure { transform: translate(-2px, 8px) rotate(56deg); }
.pose-incline .ea-shoulder-near-pose, .pose-incline .ea-shoulder-far-pose { transform: rotate(56deg); }
.pose-incline .ea-hip-near-pose, .pose-incline .ea-hip-far-pose { transform: rotate(-40deg); }
.pose-incline .ea-knee-near-pose, .pose-incline .ea-knee-far-pose { transform: rotate(58deg); }

.pose-decline .ea-figure { transform: translate(-4px, 12px) rotate(112deg); }
.pose-decline .ea-shoulder-near-pose, .pose-decline .ea-shoulder-far-pose { transform: rotate(112deg); }
.pose-decline .ea-hip-near-pose, .pose-decline .ea-hip-far-pose { transform: rotate(-56deg); }
.pose-decline .ea-knee-near-pose, .pose-decline .ea-knee-far-pose { transform: rotate(70deg); }

.pose-prone .ea-figure { transform: translate(-6px, 2px) rotate(90deg); }
.pose-prone .ea-shoulder-near-pose, .pose-prone .ea-shoulder-far-pose { transform: rotate(-90deg); }

.pose-quadruped .ea-figure { transform: translate(-6px, 4px) rotate(90deg); }
.pose-quadruped .ea-shoulder-near-pose, .pose-quadruped .ea-shoulder-far-pose { transform: rotate(-90deg); }
.pose-quadruped .ea-hip-near-pose, .pose-quadruped .ea-hip-far-pose { transform: rotate(-90deg); }

.pose-bridge .ea-figure { transform: translate(-4px, 10px) rotate(70deg); }
.pose-bridge .ea-shoulder-near-pose, .pose-bridge .ea-shoulder-far-pose { transform: rotate(66deg); }
.pose-bridge .ea-hip-near-pose, .pose-bridge .ea-hip-far-pose { transform: rotate(-46deg); }
.pose-bridge .ea-knee-near-pose, .pose-bridge .ea-knee-far-pose { transform: rotate(88deg); }

.pose-hang .ea-figure { transform: translateY(18px); }
.pose-hang .ea-shoulder-near-pose, .pose-hang .ea-shoulder-far-pose { transform: rotate(178deg); }
.pose-hang .ea-knee-near-pose, .pose-hang .ea-knee-far-pose { transform: rotate(46deg); }

.pose-inverted .ea-figure { transform: translate(2px, -6px) rotate(58deg); }
.pose-inverted .ea-shoulder-near-pose, .pose-inverted .ea-shoulder-far-pose { transform: rotate(122deg); }

.pose-seatedfloor .ea-figure { transform: translate(-6px, 14px); }
.pose-seatedfloor .ea-hip-near-pose, .pose-seatedfloor .ea-hip-far-pose { transform: rotate(-82deg); }
.pose-seatedfloor .ea-knee-near-pose, .pose-seatedfloor .ea-knee-far-pose { transform: rotate(12deg); }

.pose-seatedbench .ea-figure { transform: translateY(6px); }
.pose-seatedbench .ea-hip-near-pose, .pose-seatedbench .ea-hip-far-pose { transform: rotate(-84deg); }
.pose-seatedbench .ea-knee-near-pose, .pose-seatedbench .ea-knee-far-pose { transform: rotate(84deg); }

.pose-seatedbike .ea-figure { transform: translate(-16px, 2px); }
.pose-seatedbike .ea-torso-pose { transform: rotate(24deg); }
.pose-seatedbike .ea-hip-near-pose, .pose-seatedbike .ea-hip-far-pose { transform: rotate(-74deg); }
.pose-seatedbike .ea-knee-near-pose, .pose-seatedbike .ea-knee-far-pose { transform: rotate(70deg); }
.pose-seatedbike .ea-shoulder-near-pose, .pose-seatedbike .ea-shoulder-far-pose { transform: rotate(-62deg); }

.pose-kneel .ea-figure { transform: translateY(24px); }
.pose-kneel .ea-knee-near-pose, .pose-kneel .ea-knee-far-pose { transform: rotate(92deg); }

.pose-pike .ea-figure { transform: translate(-8px, 10px); }
.pose-pike .ea-torso-pose { transform: rotate(76deg); }
.pose-pike .ea-shoulder-near-pose, .pose-pike .ea-shoulder-far-pose { transform: rotate(-76deg); }

.pose-legpress .ea-figure { transform: translate(6px, 10px) rotate(62deg); }
.pose-legpress .ea-shoulder-near-pose, .pose-legpress .ea-shoulder-far-pose { transform: rotate(58deg); }
.pose-legpress .ea-hip-near-pose, .pose-legpress .ea-hip-far-pose { transform: rotate(-74deg); }
.pose-legpress .ea-knee-near-pose, .pose-legpress .ea-knee-far-pose { transform: rotate(84deg); }

/* ---- Movements ------------------------------------------------------ */
/* Every movement is a rep cycle, so they all run `alternate` — one
   keyframe block describes the far end of the rep and the browser plays
   it back out again, which is both half the CSS and a natural lifting
   tempo. Frozen entirely (leaving a correctly posed still, equipment and
   all) under prefers-reduced-motion: reduce. */
@media (prefers-reduced-motion: no-preference) {
  .exercise-anim-rig .ea-figure,
  .exercise-anim-rig .ea-torso-root,
  .exercise-anim-rig .ea-hip,
  .exercise-anim-rig .ea-knee,
  .exercise-anim-rig .ea-shoulder,
  .exercise-anim-rig .ea-elbow {
    animation-duration: 1.15s;
    animation-timing-function: ease-in-out;
    animation-iteration-count: infinite;
    animation-direction: alternate;
  }

  /* -- squat family -- */
  .mv-squat .ea-figure { animation-name: k-squat-fig; }
  .mv-squat .ea-hip    { animation-name: k-squat-hip; }
  .mv-squat .ea-knee   { animation-name: k-squat-knee; }
  .mv-squat .ea-torso-root { animation-name: k-lean-mid; }
  @keyframes k-squat-fig  { to { transform: translateY(13px); } }
  @keyframes k-squat-hip  { to { transform: rotate(-34deg); } }
  @keyframes k-squat-knee { to { transform: rotate(66deg); } }
  @keyframes k-lean-mid   { to { transform: rotate(15deg); } }

  .mv-jump .ea-figure, .mv-jump .ea-hip, .mv-jump .ea-knee, .mv-jump .ea-shoulder { animation-duration: 0.62s; }
  .mv-jump .ea-figure  { animation-name: k-jump-fig; }
  .mv-jump .ea-hip     { animation-name: k-jump-hip; }
  .mv-jump .ea-knee    { animation-name: k-jump-knee; }
  .mv-jump .ea-shoulder { animation-name: k-jump-arm; }
  @keyframes k-jump-fig  { from { transform: translateY(16px); } to { transform: translateY(-14px); } }
  @keyframes k-jump-hip  { from { transform: rotate(-40deg); } to { transform: rotate(6deg); } }
  @keyframes k-jump-knee { from { transform: rotate(74deg); } to { transform: rotate(-4deg); } }
  @keyframes k-jump-arm  { from { transform: rotate(40deg); } to { transform: rotate(-40deg); } }

  .mv-legpress .ea-knee { animation-name: k-legpress-knee; }
  .mv-legpress .ea-hip  { animation-name: k-legpress-hip; }
  @keyframes k-legpress-knee { to { transform: rotate(-72deg); } }
  @keyframes k-legpress-hip  { to { transform: rotate(34deg); } }

  .mv-thruster .ea-figure { animation-name: k-thruster-fig; }
  .mv-thruster .ea-hip    { animation-name: k-squat-hip; }
  .mv-thruster .ea-knee   { animation-name: k-squat-knee; }
  .mv-thruster .ea-shoulder { animation-name: k-thruster-arm; }
  @keyframes k-thruster-fig { to { transform: translateY(11px); } }
  @keyframes k-thruster-arm { from { transform: rotate(-150deg); } to { transform: rotate(-95deg); } }

  .mv-splitsquat .ea-figure   { animation-name: k-splitsquat-fig; }
  .mv-splitsquat .ea-hip-near { animation-name: k-splitsquat-hipn; }
  .mv-splitsquat .ea-knee-near { animation-name: k-splitsquat-kneen; }
  .mv-splitsquat .ea-hip-far  { animation-name: k-splitsquat-hipf; }
  .mv-splitsquat .ea-knee-far { animation-name: k-splitsquat-kneef; }
  @keyframes k-splitsquat-fig   { to { transform: translateY(11px); } }
  @keyframes k-splitsquat-hipn  { from { transform: rotate(-26deg); } to { transform: rotate(-44deg); } }
  @keyframes k-splitsquat-kneen { from { transform: rotate(26deg); } to { transform: rotate(72deg); } }
  @keyframes k-splitsquat-hipf  { from { transform: rotate(22deg); } to { transform: rotate(28deg); } }
  @keyframes k-splitsquat-kneef { from { transform: rotate(30deg); } to { transform: rotate(74deg); } }

  .mv-lunge .ea-figure    { animation-name: k-lunge-fig; }
  .mv-lunge .ea-hip-near  { animation-name: k-lunge-hipn; }
  .mv-lunge .ea-knee-near { animation-name: k-lunge-kneen; }
  .mv-lunge .ea-hip-far   { animation-name: k-lunge-hipf; }
  .mv-lunge .ea-knee-far  { animation-name: k-lunge-kneef; }
  @keyframes k-lunge-fig   { to { transform: translateY(10px); } }
  @keyframes k-lunge-hipn  { to { transform: rotate(-42deg); } }
  @keyframes k-lunge-kneen { to { transform: rotate(68deg); } }
  @keyframes k-lunge-hipf  { to { transform: rotate(26deg); } }
  @keyframes k-lunge-kneef { to { transform: rotate(52deg); } }

  .mv-stepup .ea-figure    { animation-name: k-stepup-fig; }
  .mv-stepup .ea-hip-near  { animation-name: k-stepup-hipn; }
  .mv-stepup .ea-knee-near { animation-name: k-stepup-kneen; }
  @keyframes k-stepup-fig   { to { transform: translate(22px, -20px); } }
  @keyframes k-stepup-hipn  { from { transform: rotate(-62deg); } to { transform: rotate(-6deg); } }
  @keyframes k-stepup-kneen { from { transform: rotate(74deg); } to { transform: rotate(6deg); } }

  /* -- hinge family -- */
  .mv-deadlift .ea-torso-root { animation-name: k-deadlift-torso; }
  .mv-deadlift .ea-hip   { animation-name: k-deadlift-hip; }
  .mv-deadlift .ea-knee  { animation-name: k-deadlift-knee; }
  .mv-deadlift .ea-figure { animation-name: k-deadlift-fig; }
  @keyframes k-deadlift-torso { to { transform: rotate(58deg); } }
  @keyframes k-deadlift-hip   { to { transform: rotate(-14deg); } }
  @keyframes k-deadlift-knee  { to { transform: rotate(22deg); } }
  @keyframes k-deadlift-fig   { to { transform: translateY(5px); } }

  .mv-hipthrust .ea-hip { animation-name: k-hipthrust-hip; }
  .mv-hipthrust .ea-torso-root { animation-name: k-hipthrust-torso; }
  @keyframes k-hipthrust-hip   { from { transform: rotate(30deg); } to { transform: rotate(-6deg); } }
  @keyframes k-hipthrust-torso { from { transform: rotate(-16deg); } to { transform: rotate(2deg); } }

  .mv-ballistic .ea-figure, .mv-ballistic .ea-torso-root, .mv-ballistic .ea-hip,
  .mv-ballistic .ea-knee, .mv-ballistic .ea-shoulder { animation-duration: 0.72s; }
  .mv-ballistic .ea-torso-root { animation-name: k-ballistic-torso; }
  .mv-ballistic .ea-hip   { animation-name: k-ballistic-hip; }
  .mv-ballistic .ea-knee  { animation-name: k-ballistic-knee; }
  .mv-ballistic .ea-shoulder { animation-name: k-ballistic-arm; }
  @keyframes k-ballistic-torso { from { transform: rotate(48deg); } to { transform: rotate(-4deg); } }
  @keyframes k-ballistic-hip   { from { transform: rotate(-16deg); } to { transform: rotate(2deg); } }
  @keyframes k-ballistic-knee  { from { transform: rotate(26deg); } to { transform: rotate(0deg); } }
  @keyframes k-ballistic-arm   { from { transform: rotate(34deg); } to { transform: rotate(-72deg); } }

  .mv-nordic .ea-torso-root { animation-name: k-nordic; animation-duration: 1.9s; }
  @keyframes k-nordic { to { transform: rotate(62deg); } }

  /* -- horizontal push -- */
  .mv-benchpress .ea-elbow    { animation-name: k-press-elbow; }
  .mv-benchpress .ea-shoulder { animation-name: k-press-shoulder; }
  @keyframes k-press-elbow    { to { transform: rotate(78deg); } }
  @keyframes k-press-shoulder { to { transform: rotate(-14deg); } }

  .mv-pushup .ea-elbow  { animation-name: k-pushup-elbow; }
  .mv-pushup .ea-figure { animation-name: k-pushup-fig; }
  @keyframes k-pushup-elbow { to { transform: rotate(72deg); } }
  @keyframes k-pushup-fig   { to { transform: translate(-6px, 8px) rotate(90deg); } }

  .mv-fly .ea-shoulder { animation-name: k-fly; }
  @keyframes k-fly { to { transform: rotate(62deg); } }

  .mv-dip .ea-elbow { animation-name: k-dip-elbow; }
  .mv-dip .ea-figure { animation-name: k-dip-fig; }
  .mv-dip .ea-torso-root { animation-name: k-lean-small; }
  @keyframes k-dip-elbow { from { transform: rotate(-8deg); } to { transform: rotate(76deg); } }
  @keyframes k-dip-fig   { to { transform: translateY(14px); } }
  @keyframes k-lean-small { to { transform: rotate(12deg); } }

  .mv-pressstanding .ea-shoulder { animation-name: k-pressstanding-sh; }
  .mv-pressstanding .ea-elbow    { animation-name: k-pressstanding-el; }
  @keyframes k-pressstanding-sh { from { transform: rotate(-62deg); } to { transform: rotate(-88deg); } }
  @keyframes k-pressstanding-el { from { transform: rotate(-72deg); } to { transform: rotate(-4deg); } }

  /* -- horizontal pull -- */
  .mv-rowbent .ea-elbow    { animation-name: k-row-elbow; }
  .mv-rowbent .ea-shoulder { animation-name: k-row-shoulder; }
  @keyframes k-row-elbow    { to { transform: rotate(82deg); } }
  @keyframes k-row-shoulder { to { transform: rotate(28deg); } }

  .mv-rowseated .ea-elbow    { animation-name: k-rowseated-el; }
  .mv-rowseated .ea-shoulder { animation-name: k-rowseated-sh; }
  .mv-rowseated .ea-torso-root { animation-name: k-rowseated-torso; }
  @keyframes k-rowseated-el    { from { transform: rotate(-58deg); } to { transform: rotate(16deg); } }
  @keyframes k-rowseated-sh    { from { transform: rotate(-74deg); } to { transform: rotate(-34deg); } }
  @keyframes k-rowseated-torso { from { transform: rotate(14deg); } to { transform: rotate(-12deg); } }

  .mv-rowinverted .ea-elbow  { animation-name: k-rowinv-elbow; }
  .mv-rowinverted .ea-figure { animation-name: k-rowinv-fig; }
  @keyframes k-rowinv-elbow { to { transform: rotate(74deg); } }
  @keyframes k-rowinv-fig   { from { transform: translate(2px, -6px) rotate(58deg); } to { transform: translate(2px, -14px) rotate(48deg); } }

  .mv-facepull .ea-shoulder { animation-name: k-facepull-sh; }
  .mv-facepull .ea-elbow    { animation-name: k-facepull-el; }
  @keyframes k-facepull-sh { from { transform: rotate(-86deg); } to { transform: rotate(-96deg); } }
  @keyframes k-facepull-el { from { transform: rotate(-58deg); } to { transform: rotate(52deg); } }

  .mv-renegade .ea-elbow-near { animation-name: k-renegade; }
  @keyframes k-renegade { to { transform: rotate(84deg); } }

  /* -- vertical push -- */
  .mv-ohp .ea-shoulder { animation-name: k-ohp-sh; }
  .mv-ohp .ea-elbow    { animation-name: k-ohp-el; }
  @keyframes k-ohp-sh { from { transform: rotate(-96deg); } to { transform: rotate(-172deg); } }
  @keyframes k-ohp-el { from { transform: rotate(-76deg); } to { transform: rotate(-2deg); } }

  .mv-pushjerk .ea-shoulder { animation-name: k-ohp-sh; }
  .mv-pushjerk .ea-elbow    { animation-name: k-ohp-el; }
  .mv-pushjerk .ea-knee     { animation-name: k-pushjerk-knee; }
  .mv-pushjerk .ea-figure   { animation-name: k-pushjerk-fig; }
  @keyframes k-pushjerk-knee { from { transform: rotate(26deg); } to { transform: rotate(0deg); } }
  @keyframes k-pushjerk-fig  { from { transform: translateY(7px); } to { transform: translateY(0); } }

  .mv-pike .ea-elbow { animation-name: k-pike-elbow; }
  @keyframes k-pike-elbow { to { transform: rotate(70deg); } }

  .mv-dipupright .ea-elbow  { animation-name: k-dip-elbow; }
  .mv-dipupright .ea-figure { animation-name: k-dip-fig; }

  /* -- vertical pull -- */
  .mv-pulldown .ea-shoulder { animation-name: k-pulldown-sh; }
  .mv-pulldown .ea-elbow    { animation-name: k-pulldown-el; }
  @keyframes k-pulldown-sh { from { transform: rotate(-174deg); } to { transform: rotate(-116deg); } }
  @keyframes k-pulldown-el { from { transform: rotate(2deg); } to { transform: rotate(58deg); } }

  .mv-pullup .ea-figure { animation-name: k-pullup-fig; }
  .mv-pullup .ea-elbow  { animation-name: k-pullup-elbow; }
  @keyframes k-pullup-fig   { from { transform: translateY(18px); } to { transform: translateY(-2px); } }
  @keyframes k-pullup-elbow { to { transform: rotate(-68deg); } }

  .mv-uprightrow .ea-shoulder { animation-name: k-uprightrow-sh; }
  .mv-uprightrow .ea-elbow    { animation-name: k-uprightrow-el; }
  @keyframes k-uprightrow-sh { to { transform: rotate(-34deg); } }
  @keyframes k-uprightrow-el { to { transform: rotate(96deg); } }

  /* -- rotation -- */
  .mv-woodchop .ea-torso-root { animation-name: k-woodchop-torso; }
  .mv-woodchop .ea-shoulder   { animation-name: k-woodchop-arm; }
  @keyframes k-woodchop-torso { from { transform: rotate(-24deg); } to { transform: rotate(26deg); } }
  @keyframes k-woodchop-arm   { from { transform: rotate(-142deg); } to { transform: rotate(-38deg); } }

  .mv-seatedtwist .ea-torso-root { animation-name: k-twist; }
  .mv-seatedtwist .ea-shoulder   { animation-name: k-twist-arm; }
  @keyframes k-twist     { from { transform: rotate(-28deg); } to { transform: rotate(28deg); } }
  @keyframes k-twist-arm { from { transform: rotate(-56deg); } to { transform: rotate(-72deg); } }

  .mv-punch .ea-torso-root, .mv-punch .ea-shoulder, .mv-punch .ea-elbow { animation-duration: 0.5s; }
  .mv-punch .ea-torso-root  { animation-name: k-punch-torso; }
  .mv-punch .ea-shoulder-near { animation-name: k-punch-arm; }
  .mv-punch .ea-shoulder-far  { animation-name: k-punch-arm; animation-delay: -0.5s; }
  @keyframes k-punch-torso { from { transform: rotate(-12deg); } to { transform: rotate(12deg); } }
  @keyframes k-punch-arm   { from { transform: rotate(-58deg); } to { transform: rotate(-104deg); } }

  .mv-windshield .ea-hip { animation-name: k-windshield; }
  @keyframes k-windshield { from { transform: rotate(-56deg); } to { transform: rotate(-116deg); } }

  /* -- anti-rotation -- */
  .mv-pallof .ea-elbow    { animation-name: k-pallof-el; }
  .mv-pallof .ea-shoulder { animation-name: k-pallof-sh; }
  @keyframes k-pallof-el { from { transform: rotate(-64deg); } to { transform: rotate(-2deg); } }
  @keyframes k-pallof-sh { to { transform: rotate(-84deg); } }

  .mv-plank .ea-figure, .mv-plankhold .ea-figure { animation-name: k-hold-breathe; animation-duration: 2.6s; }
  @keyframes k-hold-breathe { from { transform: translate(-6px, 2px) rotate(90deg); } to { transform: translate(-6px, 4px) rotate(90deg); } }

  .mv-birddog .ea-hip-far      { animation-name: k-birddog-leg; }
  .mv-birddog .ea-shoulder-near { animation-name: k-birddog-arm; }
  .mv-birddog .ea-hip-far, .mv-birddog .ea-shoulder-near { animation-duration: 1.6s; }
  @keyframes k-birddog-leg { to { transform: rotate(84deg); } }
  @keyframes k-birddog-arm { to { transform: rotate(-84deg); } }

  /* -- carry / hold -- */
  .mv-carry .ea-hip-near, .mv-carry .ea-hip-far, .mv-carry .ea-knee-near, .mv-carry .ea-knee-far { animation-duration: 0.7s; }
  .mv-carry .ea-hip-near  { animation-name: k-walk-hip; }
  .mv-carry .ea-hip-far   { animation-name: k-walk-hip; animation-delay: -0.7s; }
  .mv-carry .ea-knee-near { animation-name: k-walk-knee; }
  .mv-carry .ea-knee-far  { animation-name: k-walk-knee; animation-delay: -0.7s; }
  @keyframes k-walk-hip  { from { transform: rotate(-18deg); } to { transform: rotate(14deg); } }
  @keyframes k-walk-knee { from { transform: rotate(6deg); } to { transform: rotate(34deg); } }

  .mv-hang .ea-figure { animation-name: k-hang-sway; animation-duration: 2.8s; }
  @keyframes k-hang-sway { from { transform: translateY(18px); } to { transform: translateY(21px); } }

  /* -- cardio -- */
  .mv-run .ea-hip-near, .mv-run .ea-hip-far, .mv-run .ea-knee-near, .mv-run .ea-knee-far,
  .mv-run .ea-shoulder-near, .mv-run .ea-shoulder-far, .mv-run .ea-figure { animation-duration: 0.4s; }
  .mv-run .ea-figure        { animation-name: k-run-fig; }
  .mv-run .ea-hip-near      { animation-name: k-run-hip; }
  .mv-run .ea-hip-far       { animation-name: k-run-hip; animation-delay: -0.4s; }
  .mv-run .ea-knee-near     { animation-name: k-run-knee; }
  .mv-run .ea-knee-far      { animation-name: k-run-knee; animation-delay: -0.4s; }
  .mv-run .ea-shoulder-near { animation-name: k-run-arm; animation-delay: -0.4s; }
  .mv-run .ea-shoulder-far  { animation-name: k-run-arm; }
  @keyframes k-run-fig  { from { transform: translateY(0); } to { transform: translateY(-5px); } }
  @keyframes k-run-hip  { from { transform: rotate(-38deg); } to { transform: rotate(26deg); } }
  @keyframes k-run-knee { from { transform: rotate(16deg); } to { transform: rotate(62deg); } }
  @keyframes k-run-arm  { from { transform: rotate(-42deg); } to { transform: rotate(24deg); } }

  .mv-cycle .ea-hip-near, .mv-cycle .ea-hip-far, .mv-cycle .ea-knee-near, .mv-cycle .ea-knee-far { animation-duration: 0.55s; }
  .mv-cycle .ea-hip-near  { animation-name: k-cycle-hip; }
  .mv-cycle .ea-hip-far   { animation-name: k-cycle-hip; animation-delay: -0.55s; }
  .mv-cycle .ea-knee-near { animation-name: k-cycle-knee; }
  .mv-cycle .ea-knee-far  { animation-name: k-cycle-knee; animation-delay: -0.55s; }
  @keyframes k-cycle-hip  { from { transform: rotate(2deg); } to { transform: rotate(-22deg); } }
  @keyframes k-cycle-knee { from { transform: rotate(-16deg); } to { transform: rotate(30deg); } }

  .mv-burpee .ea-figure, .mv-burpee .ea-hip, .mv-burpee .ea-knee, .mv-burpee .ea-torso-root { animation-duration: 0.95s; }
  .mv-burpee .ea-figure    { animation-name: k-burpee-fig; }
  .mv-burpee .ea-hip       { animation-name: k-burpee-hip; }
  .mv-burpee .ea-knee      { animation-name: k-burpee-knee; }
  .mv-burpee .ea-torso-root { animation-name: k-burpee-torso; }
  @keyframes k-burpee-fig   { from { transform: translateY(-10px); } to { transform: translateY(18px); } }
  @keyframes k-burpee-hip   { from { transform: rotate(4deg); } to { transform: rotate(-52deg); } }
  @keyframes k-burpee-knee  { from { transform: rotate(0deg); } to { transform: rotate(84deg); } }
  @keyframes k-burpee-torso { from { transform: rotate(0deg); } to { transform: rotate(36deg); } }

  /* -- isolation -- */
  .mv-curl .ea-elbow { animation-name: k-curl; }
  @keyframes k-curl { to { transform: rotate(-112deg); } }

  .mv-tripush .ea-elbow { animation-name: k-tripush; }
  @keyframes k-tripush { from { transform: rotate(-62deg); } to { transform: rotate(-2deg); } }

  .mv-triover .ea-shoulder { animation-name: k-triover-sh; }
  .mv-triover .ea-elbow    { animation-name: k-triover-el; }
  @keyframes k-triover-sh { to { transform: rotate(-168deg); } }
  @keyframes k-triover-el { from { transform: rotate(92deg); } to { transform: rotate(8deg); } }

  .mv-raise .ea-shoulder { animation-name: k-raise; }
  @keyframes k-raise { to { transform: rotate(-88deg); } }

  .mv-calf .ea-figure { animation-name: k-calf; }
  @keyframes k-calf { to { transform: translateY(-9px); } }

  .mv-legcurl .ea-knee { animation-name: k-legcurl; }
  @keyframes k-legcurl { to { transform: rotate(98deg); } }

  .mv-legext .ea-knee { animation-name: k-legext; }
  @keyframes k-legext { from { transform: rotate(52deg); } to { transform: rotate(-8deg); } }

  .mv-crunch .ea-torso-root { animation-name: k-crunch; }
  @keyframes k-crunch { to { transform: rotate(38deg); } }

  .mv-glutekick .ea-hip-near { animation-name: k-glutekick; }
  @keyframes k-glutekick { to { transform: rotate(72deg); } }

  .mv-climber .ea-hip-near, .mv-climber .ea-hip-far { animation-duration: 0.45s; }
  .mv-climber .ea-hip-near { animation-name: k-climber; }
  .mv-climber .ea-hip-far  { animation-name: k-climber; animation-delay: -0.45s; }
  @keyframes k-climber { from { transform: rotate(0deg); } to { transform: rotate(-74deg); } }

  .mv-chairdip .ea-elbow  { animation-name: k-chairdip-el; }
  .mv-chairdip .ea-figure { animation-name: k-chairdip-fig; }
  @keyframes k-chairdip-el  { to { transform: rotate(80deg); } }
  @keyframes k-chairdip-fig { to { transform: translateY(12px); } }
}

/* Progressive-focus set list: one big active card, logged sets collapsed
   above it, upcoming sets dimmed below (see activeSetNumber() in app.js).
   Only one row is ever the grid-based "active" layout at a time; the
   other two states are single-line flex rows. */
.sets {
  display: flex;
  flex-direction: column;
  gap: 10px;
  margin-bottom: 20px;
}
.set-row.active {
  display: grid;
  /* One column always — a set can track anywhere from 1 to 6+ metrics
     (#36), so the fixed "weight left, reps right" 2-column layout this
     used to be can't hold. .set-row-fields (below) owns its own
     auto-fit grid instead, sized to however many fields this exercise
     actually has. */
  grid-template-columns: 1fr;
  /* No "done" row any more — Done moved up onto the eyebrow line. */
  grid-template-areas: "eyebrow" "fields" "prev";
  gap: 12px;
  align-items: start;
  /* Glass, matching the rest of the site — --lib-panel-2 is the
     second-tier token, right for a surface nested inside the already-
     glass .exercise-card. */
  background: var(--lib-panel-2);
  border: 2px solid var(--accent);
  border-radius: 5px;
  padding: 18px;
}
/* Label on the left, then Per side, then Done hard against the right.
   The label takes the slack (flex: 1) rather than the row using
   space-between, so the two controls stay together as a pair on the
   right instead of Per side drifting into the middle. */
.set-row-eyebrow {
  grid-area: eyebrow;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 10px;
  font-weight: 600;
  font-size: calc(15px * var(--scale));
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}
/* The Per side toggle now lives on the eyebrow line (a whole-exercise
   setting) rather than next to the weight input (a per-set one) — same
   .per-side-toggle look, just relocated. */
.set-row-eyebrow .per-side-toggle { text-transform: none; letter-spacing: normal; }
.set-row-eyebrow-label { flex: 1; min-width: 0; }
.set-row-eyebrow .per-side-toggle { flex: none; }
/* Roughly a third of the card's width, per the brief. flex-basis (not
   width) so it measures against the eyebrow row, which spans the card's
   full inner width — a percentage width would have resolved against
   whatever content-sized box it happened to sit in. min-width keeps it
   tappable at 320px, where 30% is only ~85px, and the row wraps rather
   than crushing the label if all three can't fit. */
.set-row-eyebrow .done-btn {
  flex: 0 0 30%;
  min-width: 96px;
}
.set-row-fields {
  grid-area: fields;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
  gap: 12px;
}
.set-row.active .prev { grid-area: prev; }
.set-row .field label {
  display: block;
  font-size: var(--fs-label);
  color: var(--muted);
  margin-bottom: 4px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.set-row .field input {
  font-size: calc(22px * var(--scale));
  min-height: var(--btn-min);
  width: 100%;
}
.weight-input-row { display: flex; align-items: center; gap: 8px; }
.weight-input-row input { flex: 1; min-width: 0; }
.weight-unit-label {
  font-size: calc(15px * var(--scale));
  color: var(--muted);
  text-transform: uppercase;
  flex: none;
}
.per-side-toggle {
  flex: none;
  min-height: var(--btn-min);
  padding: 6px 10px;
  font-size: calc(13px * var(--scale));
  border-radius: var(--radius-control, 5px);
  border: 1px solid var(--border);
  background: transparent;
  color: var(--muted);
  cursor: pointer;
}
.per-side-toggle.active { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
/* #45 — the time_s field's seconds/minutes-and-seconds entry-mode
   toggle, same visual language as .per-side-toggle but sized to sit
   inline with a field's own label instead of the taller eyebrow row. */
.field-label-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; margin-bottom: 4px; }
.field-label-row label { margin-bottom: 0; }
.time-entry-mode-toggle {
  flex: none;
  min-height: 0;
  padding: 2px 8px;
  font-size: calc(11px * var(--scale));
  border-radius: var(--radius-control, 5px);
  border: 1px solid var(--border);
  background: transparent;
  color: var(--muted);
  cursor: pointer;
  text-transform: none;
  letter-spacing: normal;
}
.time-entry-mode-toggle:hover { color: var(--text); border-color: var(--border-hover); }
.time-minsec-row { display: flex; align-items: center; gap: 6px; }
.time-minsec-row input { flex: 1; min-width: 0; }
.time-minsec-sep {
  flex: none;
  font-size: calc(15px * var(--scale));
  color: var(--muted);
  text-transform: uppercase;
}
/* The bodyweight_kg field (#36) — never editable, so a plain display
   value rather than an input; matches .set-row .field input's font size
   so it doesn't look visually demoted next to real fields, but muted
   since there's nothing to type here. */
.metric-chip {
  display: flex;
  align-items: center;
  min-height: var(--btn-min);
  font-size: calc(22px * var(--scale));
  font-family: var(--font-mono);
  color: var(--muted);
}
/* Live-computed avg speed (distance_m + time_s, bike/rower/treadmill
   exercises) — sits right under the fields grid, same muted treatment as
   .set-row .prev since it's supplementary info, not an input. */
.metric-derived-hint {
  font-size: calc(14px * var(--scale));
  color: var(--muted);
  font-family: var(--font-mono);
  margin-top: -4px;
}
.set-row .prev {
  font-size: calc(14px * var(--scale));
  color: var(--muted);
}
.set-row .prev strong { color: var(--text); font-weight: 600; }
.set-row .prev .pb-note { color: var(--success); font-weight: 600; }
.set-row-summary.pb-note { flex: none; color: var(--success); font-family: var(--font-mono); }
.set-row .done-btn {
  min-height: var(--btn-min);
  padding: 10px 18px;
  font-size: var(--fs-button);
}

/* Collapsed rows: a logged set (tappable to edit, carries a delta chip
   showing beat/matched/down vs last time) or an untouched upcoming one
   (dimmed, tappable to bring forward). */
.set-row.collapsed {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 14px;
  border-radius: 5px;
  /* Not --border, which measured 2.07:1 against the card behind and, on
     an upcoming row, 1.12:1 once the row's own dimming was applied on top
     — the row read as a smudge rather than as something tappable. Mixed
     from --text-primary so it inverts with the theme rather than needing
     a second token. */
  border: 1px solid color-mix(in srgb, var(--text-primary) 45%, transparent);
  /* Glass, same second-tier token as .set-row.active above. */
  background: var(--lib-panel-2);
  cursor: pointer;
  min-height: var(--btn-min);
}
.set-row.collapsed:hover { border-color: var(--accent); }
.set-row.collapsed.logged { border-color: var(--success); }
/* Dims the CONTENT, not the row. This used to be `opacity: .55` on the
   row itself, which also faded its border and its background — so the
   quieter-because-not-done-yet treatment was costing the row its
   boundary, and no border colour could fix that while the whole element
   was being faded. Every child here is a span (see renderRunner's
   upcoming branch), so there are no bare text nodes for this to miss. */
.set-row.collapsed.upcoming > * { opacity: 0.55; }
/* 68px (was 52px) — "Set 1" fit at 52px, but "Set 1 · R" (Left/Right split
   tracking) wraps onto two lines at that width. */
.set-row-num { font-weight: 600; font-size: calc(14px * var(--scale)); flex: none; width: 68px; white-space: nowrap; }
.set-row-summary { font-family: var(--font-mono); font-size: calc(15px * var(--scale)); flex: 1; min-width: 0; }
.set-row-summary.muted { font-family: inherit; color: var(--muted); }
.set-row-check { color: var(--success); font-weight: 700; flex: none; }

/* #80 — ad hoc extra set, appended after every real set row. Dashed
   border reads as "not a planned set" (unlike the solid collapsed rows
   above it), same tap-target sizing as everything else in this list. */
.add-set-btn {
  display: flex; align-items: center; justify-content: center;
  width: 100%; margin-top: 8px; padding: 10px 14px;
  border: 1px dashed var(--border); border-radius: 5px;
  background: none; color: var(--muted);
  font-size: calc(14px * var(--scale)); min-height: var(--btn-min);
  cursor: pointer;
}
.add-set-btn:hover { border-color: var(--accent); color: var(--accent); }

@media (max-width: 640px) {
  /* At this width the weight-unit label and per-side toggle were eating
     most of the row's width, leaving the number input too narrow to read
     while typing — stack the input on its own line instead. */
  .weight-input-row { flex-wrap: wrap; }
  .weight-input-row input {
    flex: 1 1 100%;
    font-size: calc(26px * var(--scale));
  }
}

/* Layout-transparent by default, so Prev/Next and the timer flow into
   the page exactly where they always did on a phone. It only becomes a
   real box at the desktop breakpoint — see the ≥1100px block. */
.runner-side { display: contents; }

.runner-nav {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
}
/* Grid items default to min-width:auto, which refuses to shrink below the
   button's own content width — at large text sizes that pushes the row
   wider than its container and the button clips off-screen. min-width:0
   lets the button shrink and wrap its label instead. */
.runner-nav > button { min-width: 0; }

/* -------- timer panel -------- */
/* Deliberately still solid --panel while every other page-level surface
   moved to glass. This one is position:sticky (bottom:12px), and a
   translucent sticky surface shows content scrolling underneath it — the
   same conflict that made .lib-filterbar give up its pin. It resolves
   the other way here: a rest timer you can still see while scrolling
   through sets is the entire point of pinning it, and this design system
   rules out the backdrop blur that would let it be both. */
.timer-panel {
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: 5px;
  padding: 24px;
  box-shadow: var(--shadow);
  position: sticky;
  bottom: 12px;
  /* Makes cqw below resolve against this panel's own rendered width
     rather than the viewport — needed so the clock always sizes to
     the space it actually has, whether that's near-full-width on
     mobile or the fixed ~440-600px sidebar column on desktop. */
  container-type: inline-size;
}
.timer-display {
  font-variant-numeric: tabular-nums;
  font-family: -apple-system, BlinkMacSystemFont, "SF Mono", "Segoe UI Mono", "Roboto Mono", monospace;
  text-align: center;
  /* cqw, not vw: on desktop the panel sits in a fixed-width sidebar
     column that's much narrower than the viewport, so sizing off the
     viewport (the old vw-based clamp) overflowed the column on any
     reasonably wide monitor. */
  font-size: clamp(72px, 40cqw, 220px);
  font-weight: 700;
  letter-spacing: -0.03em;
  line-height: 1;
  margin: 0 0 16px;
  padding: 8px 0;
  /* font-size joins the transition for F-12's idle/running size change —
     without it the clock would snap between sizes the instant a set is
     logged, which is the one moment the user is looking straight at it. */
  transition: color 0.2s ease, font-size 260ms cubic-bezier(.16,1,.3,1);
  max-width: 100%;
}
@media (prefers-reduced-motion: reduce) {
  .timer-display { transition: color 0.2s ease; }
  /* `pulse` on .timer-display.done was the one genuinely unguarded
     animation left in this stylesheet — and an infinite one, firing at
     exactly the moment the user is looking at the clock. The colour
     change to --success already carries the "rest is over" signal
     without the motion. */
  .timer-display.done { animation: none; }
}
/* F-12 — on the desktop runner the timer sits in a fixed 440–600px
   sidebar and the digit clock alone renders up to 220px tall, making an
   idle timer roughly the most prominent thing on a screen where it does
   nothing until a set is logged. `.idle` is already exactly the right
   state (paint() sets it for "not running, time remaining"), so the
   clock simply holds back until it has something to count, then grows
   to full "readable across the room" size the moment it starts.
   Desktop only: the narrow layout collapses the whole panel to a peek
   strip instead, which already solves this there. */
@media (min-width: 1100px) {
  .timer-display.idle { font-size: clamp(56px, 24cqw, 132px); }
}
.timer-display.warn  { color: var(--warning); }
.timer-display.done  { color: var(--success); animation: pulse 1s ease-in-out infinite; }
.timer-display.idle  { color: var(--text); }
@keyframes pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.6; }
}
/* Fires once when rest auto-starts (logging a set) — see paint() in
   bindTimerPanel(). One-shot (no infinite), plays over the panel's own
   existing border/shadow rather than replacing them, so it reads as "this
   surface just lit up" without permanently changing the panel's look. */
@keyframes timer-just-started-glow {
  0% { box-shadow: var(--shadow), 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); border-color: var(--accent); }
  100% { box-shadow: var(--shadow), 0 0 0 22px color-mix(in srgb, var(--accent) 0%, transparent); border-color: var(--border); }
}
.timer-panel.timer-just-started { animation: timer-just-started-glow 900ms var(--ease-settle) both; }

.timer-presets {
  display: grid;
  grid-template-columns: repeat(5, 1fr);
  gap: 10px;
  margin-bottom: 14px;
}
.timer-presets button {
  min-height: var(--btn-min-lg);
  font-size: var(--fs-big);
  font-weight: 600;
  min-width: 0;
}
.timer-presets button.active {
  border-color: var(--accent);
  background: var(--accent-primary-soft);
}
@media (max-width: 600px) {
  .timer-presets { grid-template-columns: repeat(3, 1fr); }
  .timer-presets button { min-height: var(--btn-min); font-size: var(--fs-button); }
}

.timer-controls {
  display: grid;
  grid-template-columns: 3fr 2fr;
  gap: 12px;
}
.timer-controls > button { min-width: 0; }

.timer-body {
  overflow: hidden;
  max-height: 900px;
  transition: max-height 0.25s ease, opacity 0.2s ease;
}

.timer-handle {
  display: none;
  width: 100%;
  background: transparent;
  border: none;
  padding: 6px 0 0;
  cursor: pointer;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  touch-action: none;
}
.timer-handle-grip {
  width: 48px;
  height: 5px;
  border-radius: 3px;
  background: var(--border);
}
.timer-handle-peek {
  display: none;
  font-variant-numeric: tabular-nums;
  font-family: -apple-system, BlinkMacSystemFont, "SF Mono", "Segoe UI Mono", "Roboto Mono", monospace;
  font-size: calc(24px * var(--scale));
  font-weight: 700;
  color: var(--text);
  padding-bottom: 4px;
}
.timer-handle-peek.warn { color: var(--warning); }
.timer-handle-peek.done { color: var(--success); }

/* Collapsible "flip down" timer is a mobile/narrow-layout affordance —
   on the desktop side-by-side runner layout the timer stays as-is,
   always fully visible, per the original "readable across the room"
   requirement. */
@media (max-width: 1099px) {
  .timer-handle { display: flex; }
  .timer-panel.collapsed .timer-body {
    max-height: 0;
    opacity: 0;
  }
  .timer-panel.collapsed .timer-handle-peek { display: block; }
  /* Half the desktop clamp(72px, 40cqw, 220px) — the big digit clock
     dominates the expanded mobile panel's height (measured ~130px tall
     at a 375px viewport before this), squeezing the rest-timer well
     below the fold on a phone. Buttons/presets keep their own sizing
     (still real tap targets); only the digits shrink. */
  .timer-display { font-size: clamp(36px, 20cqw, 110px); }

  /* F-02 — .timer-panel is sticky bottom:12px, so once the exercise card
     is taller than the viewport the panel lifts off its flow position and
     pins to the bottom edge, painting over whatever is still scrolling
     beneath it. Measured at 375x812: the collapsed peek occupied
     694–800px while the Done button sat at 791 — i.e. permanently
     covered, with no amount of scrolling able to clear it, because
     nothing reserved space for the pinned panel. Reserving it here is
     the same fix body.has-bottom-nav #app already applies for the mobile
     tab bar. --timer-peek-reserve is the collapsed panel's own measured
     height plus its 12px bottom offset and a little breathing room. */
  .screen-runner { --timer-peek-reserve: 132px; padding-bottom: var(--timer-peek-reserve); }
}

/* F-01 — phone-scale only (the 700–1099px tablet range is single-column
   too but has vertical room to spare). At 375x812 the runner spent 86px
   on a header that wrapped to three lines and 80px on a two-line 32px
   title before any of it, pushing the weight input to y=591 and Done to
   y=791 — the primary action of the app's most-used screen, flush
   against the bottom edge, requiring a scroll on every single set.
   Everything below buys vertical space back above the fold; none of it
   changes the desktop layout. */
@media (max-width: 700px) {
  .screen-runner .runner-top { gap: 8px; margin-bottom: 10px; }
  /* Name and counter share one line instead of stacking into two that
     then wrap into three — the counter is short and mono, so it reads
     fine as a suffix. */
  .screen-runner .runner-title { display: flex; align-items: baseline; gap: 8px; }
  /* flex: 1 1 auto + min-width: 0 is what makes the ellipsis work: as a
     default `flex: 0 1 auto` item next to the counter it shrank to
     min-content and truncated to a single letter rather than claiming
     the row's spare width. */
  .screen-runner .workout-name {
    flex: 1 1 auto; min-width: 0;
    font-size: calc(13px * var(--scale));
    white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
  }
  .screen-runner .exercise-counter {
    margin-top: 0; flex: 0 0 auto;
    font-size: calc(12px * var(--scale));
    font-variant-numeric: tabular-nums; white-space: nowrap;
  }
  .screen-runner .runner-progress-row { margin-bottom: 10px; }
  .screen-runner .exercise-card { padding: 16px; margin-bottom: 14px; }
  /* Still the clear focal point of the card, just not three lines of it.
     Two lines max, then ellipsis — the full name is already shown in the
     progress pills and the workout list. */
  .screen-runner .exercise-card h2 {
    font-size: calc(21px * var(--scale));
    line-height: 1.2;
    margin-bottom: 4px;
    display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;
    overflow: hidden;
  }
  .screen-runner .exercise-meta {
    margin-bottom: 12px; gap: 10px;
    font-size: calc(14px * var(--scale));
  }
  /* F-08 — Finish ends the session and sat immediately adjacent to a
     bare "+" (add exercise) at phone tap-target spacing, which is a
     genuinely easy mis-tap mid-set. Push it away from its neighbours so
     the destructive action has its own margin of error. */
  .screen-runner .runner-top #btn-finish { margin-left: 6px; }
  /* A disclosure control, not a primary action — it doesn't need a full
     56px tap target sitting between the exercise name and its sets. */
  .screen-runner .anim-toggle {
    min-height: 0; padding: 7px 10px;
    font-size: calc(13px * var(--scale));
    margin-bottom: 10px;
  }
}

/* -------- manage --------
   The exercise rows below (.manage-workout/.manage-ex-*) still use the
   same --lib-* tokens as the Library screen for their own dense card/row
   styling, but the outer modal shell itself no longer forces a smaller
   site-wide font-size (#35) — it's .modal-wide now, same shell every
   other add/edit form uses, so this doesn't read as a different UI
   bolted onto the rest of the app. */

/* History and Measure join the same fixed-px, --lib-* visual family (see
   the tabs, history-item, picker-label, progress and measure rules
   below).

   That sentence used to list those as class globs separated by slashes,
   which spelled a comment-closer mid-sentence: the comment ended early,
   the remainder parsed as a selector, and it absorbed the rule below
   into itself. So this reset never applied — Progress and Body inherited
   the 20px body size while their siblings .screen-library and
   .screen-manage sat at the intended 14px. Same failure that once ate
   the --scale block; don't write a class glob before a slash in here. */
.screen-history, .screen-measure { font-size: 14px; line-height: 1.5; }

.screen-workouts [data-panel="plan"] { font-size: 14px; line-height: 1.5; }
.schedule-weekly-grid {
  display: grid;
  grid-template-columns: 110px repeat(3, 1fr);
  gap: 8px;
  align-items: start;
  margin: 14px 0 26px;
}
.schedule-grid-corner { }
.schedule-grid-head {
  font-size: 12px;
  font-weight: 700;
  color: var(--lib-muted);
  text-align: center;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.schedule-grid-day-label { font-size: 13px; font-weight: 600; color: var(--lib-text); padding-top: 7px; }
.schedule-grid-cell { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.schedule-grid-select {
  width: 100%;
  padding: 7px 8px;
  border-radius: 5px;
  border: 1px solid var(--lib-line-strong);
  background: var(--lib-panel);
  color: var(--lib-text);
  font-size: 13px;
}
/* A slot with no weekly rule but an AI-generated (or other one-off)
   program already scheduled there — signified with the accent colour
   directly on the dropdown rather than a separate badge underneath it. */
.schedule-grid-select-ai {
  border-color: var(--lib-accent);
  background: var(--lib-accent-soft);
  color: var(--lib-accent-ink);
}
@media (max-width: 640px) {
  /* Three 1fr columns at 375px leave each select ~79px wide, about two
     characters of a workout name. Giving the columns a 104px floor and
     scrolling the grid sideways inside itself was the first answer, but a
     week you have to scroll to read is still a week you can't read — so
     the table transposes instead: one row per day, and each slot's own
     column heading moves onto its cell (data-slot-label, set in
     renderPlanPanel) as an inline label. The three shared column
     headings have nothing left to head, so they're hidden. */
  .schedule-weekly-grid {
    grid-template-columns: minmax(0, 1fr);
    gap: 6px;
  }
  .schedule-grid-corner, .schedule-grid-head { display: none; }
  .schedule-grid-day-label {
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--lib-muted);
    padding-top: 12px;
  }
  .schedule-weekly-grid > .schedule-grid-day-label:first-of-type { padding-top: 0; }
  .schedule-grid-cell { flex-direction: row; align-items: center; gap: 10px; }
  .schedule-grid-cell::before {
    content: attr(data-slot-label);
    flex: 0 0 68px;
    font-size: 11px;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--lib-muted);
  }
  .schedule-grid-select { font-size: 12px; padding: 6px 8px; min-width: 0; }
}

.schedule-day-card {
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: 5px;
  box-shadow: var(--lib-shadow);
  padding: 14px 16px;
  margin-bottom: 12px;
}
.schedule-day-heading { font-weight: 700; margin-bottom: 8px; color: var(--lib-text); }
.schedule-slot-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 6px 0;
  border-top: 1px solid var(--lib-line);
}
.schedule-slot-row:first-of-type { border-top: none; padding-top: 0; }
.schedule-slot-time { width: 80px; flex: none; color: var(--lib-muted); font-size: 12.5px; text-transform: uppercase; letter-spacing: 0.03em; }
.schedule-slot-workout { flex: 1; }
.schedule-slot-skipped .schedule-slot-workout { color: var(--lib-muted); text-decoration: line-through; }
.schedule-slot-actions { display: flex; gap: 6px; }

/* -------- history + progress -------- */
.history-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  margin-bottom: 16px;
}
.history-head-actions { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
/* The shared period selector sits in the same header row as the
   import/export actions -- .picker-label's default column layout and
   20px bottom margin are meant for a standalone block, not a header
   chip, so this overrides both. */
.analytics-period { flex-direction: row; align-items: center; gap: 8px; margin-bottom: 0; }

/* History and Measure use the same fixed-px, --lib-* glass-panel treatment
   as Library/Manage (see .screen-history/.screen-measure below) — .tabs,
   .history-*, .picker-label, .progress-chart/.progress-table are exclusive
   to these two screens, so converting them here doesn't touch anything
   shared with the rest of the (still --scale-driven) app. */
.tabs {
  display: flex;
  gap: 8px;
  margin-bottom: 18px;
  border-bottom: 1px solid var(--lib-line);
  /* Five tabs (Progress screen) don't fit 375px at the old fixed padding
     -- rather than wrapping into a ragged second row (which breaks the
     active tab's border-bottom illusion), the row scrolls horizontally
     within itself. The page itself must never scroll sideways; this is
     the container that absorbs it instead. */
  overflow-x: auto;
  scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
/* F-03 — the affordance that replaces the hidden scrollbar. A mask (not
   a positioned pseudo-element) because .tabs is itself the scroll
   container: a ::after inside it would scroll away with the content,
   whereas a mask applies to the element's own box and stays pinned to
   the visible edge. Classes are set by wireTabs() in app.js, so the fade
   only appears on the side that actually has more tabs. */
.tabs.overflow-end {
  -webkit-mask-image: linear-gradient(to right, #000 calc(100% - 34px), transparent);
  mask-image: linear-gradient(to right, #000 calc(100% - 34px), transparent);
}
.tabs.overflow-start {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 34px);
  mask-image: linear-gradient(to right, transparent, #000 34px);
}
.tabs.overflow-start.overflow-end {
  -webkit-mask-image: linear-gradient(to right, transparent, #000 34px, #000 calc(100% - 34px), transparent);
  mask-image: linear-gradient(to right, transparent, #000 34px, #000 calc(100% - 34px), transparent);
}
@media (prefers-reduced-motion: no-preference) {
  .tabs { scroll-behavior: smooth; }
}
.tabs .tab {
  flex: none;
  background: transparent;
  border: 1px solid transparent;
  border-bottom: none;
  border-radius: 5px 5px 0 0;
  padding: 10px 20px;
  color: var(--lib-muted);
  font-weight: 600;
  font-size: 14px;
  min-height: 40px;
}
.tabs .tab.active {
  color: var(--lib-text);
  background: var(--lib-panel);
  border-color: var(--lib-line-strong);
  position: relative;
  top: 1px;
}
.tab-panel { display: block; }
.tab-panel[hidden] { display: none; }
/* wireTabs() (app.js) is shared by every tabbed screen — Progress's five
   tabs, Body's three, Train's Today/Plan, and any other .tab-panel set —
   so this one rule gives every one of them a cross-fade on tab switch
   instead of the instant hidden/visible swap they all had before, with
   no changes needed at any individual call site. Restarts correctly each
   time a panel goes from [hidden] (display:none, animation not running)
   back to visible — that transition is what re-triggers a static
   animation rule, no JS toggle needed. */
.tab-panel:not([hidden]) { animation: tab-panel-in 260ms var(--ease-settle) both; }
@keyframes tab-panel-in {
  from { opacity: 0; transform: translateY(4px); }
}
@media (prefers-reduced-motion: reduce) {
  .tab-panel:not([hidden]) { animation: none; }
}
/* Progress's tabs (Overview/Lifts/Volume/Balance/Sessions) each build
   several charts (barChart/donut/heatmapGrid/statCard's countUp) that
   already animate their own bars/segments/cells in on a stagger as soon
   as the tab's data arrives — this panel-level transform/opacity
   animation was landing on top of that, at the same moment, and the
   combination read as jerky rather than smooth (Body's tabs have far
   fewer simultaneously-animating chart elements per tab, so the same
   rule stays fine there). Drop the panel-level animation here and let
   each chart's own existing entrance animation carry the transition. */
.screen-history .tab-panel:not([hidden]) { animation: none; }

.history-item {
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: 5px;
  padding: 16px;
  margin-bottom: 10px;
  display: flex;
  gap: 14px;
  align-items: center;
  font-size: 14px;
}
.history-item .when { color: var(--lib-muted); min-width: 200px; font-variant-numeric: tabular-nums; }
.history-item .kind {
  color: var(--lib-muted);
  font-size: 11px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  min-width: 90px;
}
.history-item .what { flex: 1; font-weight: 600; }
.history-item .who  { color: var(--lib-muted); font-size: 12.5px; }
.history-item.clickable { cursor: pointer; }
.history-item.clickable:hover { border-color: var(--lib-accent); }
.history-item .chev { color: var(--lib-muted); font-size: 1.2em; }
/* .when's 200px and .kind's 90px floors already total more than a 375px
   row's inner width before .what has said anything, so the summary
   ("Chest 102cm · Waist 78cm") and the chevron both ran off the right
   edge. Below 640px the row wraps instead: date and kind share the first
   line, the summary takes the second with the chevron beside it. */
@media (max-width: 640px) {
  .history-item { flex-wrap: wrap; gap: 6px 10px; padding: 12px 14px; }
  .history-item .when { min-width: 0; }
  .history-item .kind { min-width: 0; }
  .history-item .what { flex: 1 1 60%; min-width: 0; overflow-wrap: anywhere; }
  .history-item .chev { margin-left: auto; }
}

/* #84 — Simple-logged cardio row: no chevron (nothing to open), a plain
   delete icon instead. */
.history-item-cardio .history-cardio-delete { color: var(--lib-muted); }
.history-item-cardio .history-cardio-delete:hover { color: var(--danger); }

.history-dots { display: flex; flex-wrap: wrap; gap: 5px; margin-top: 6px; }
.history-dot { width: 10px; height: 10px; border-radius: 50%; flex: none; background: var(--lib-line-strong); }
.history-dot.incomplete { background: var(--danger); }
.history-dot.complete   { background: var(--link); }
.history-dot.pb         { background: var(--success); }

.picker-label {
  display: flex;
  flex-direction: column;
  gap: 8px;
  color: var(--lib-muted);
  font-size: 12px;
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin-bottom: 20px;
}
.picker-label select { max-width: 640px; }

.progress-chart {
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: 5px;
  padding: 20px;
  margin-bottom: 20px;
  overflow-x: auto;
}
.progress-chart svg { display: block; max-width: 100%; height: auto; }
.progress-chart .empty {
  padding: 40px 20px;
  text-align: center;
  color: var(--lib-muted);
  font-size: 14px;
}
.progress-chart .empty .empty-state-icon { display: block; margin: 0 auto 14px; }
.progress-chart .axis-line { stroke: var(--lib-line-strong); stroke-width: 1; }
.progress-chart .grid-line { stroke: var(--lib-line-strong); stroke-width: 1; stroke-dasharray: 3 4; opacity: 0.6; }
.progress-chart .axis-label {
  fill: var(--lib-muted);
  font-family: inherit;
  font-size: 12px;
}
.progress-chart .series-line { fill: none; stroke: var(--lib-accent); stroke-width: 2.5; }
.progress-chart .series-point { fill: var(--lib-accent); stroke: var(--lib-bg); stroke-width: 2; }
.progress-chart .series-point.peak { fill: var(--warning); }

/* The bar-chart/heatmap/donut chart-kit primitives (Phase 1) were built
   against the shared --accent/--panel-2/--muted tokens for the Train
   screen. This screen (and Measure, which reused barChart() in Phase 6
   for the weight/volume correlation) uses the separate --lib-* family
   throughout (see the note on .screen-history below), so their embedded
   output gets the same scoped-override treatment .progress-chart
   .series-* already uses above, rather than mixing token families within
   one screen. */
/* F-05 — mirrors the translucent-body/solid-cap treatment the base
   .bar-chart-fill rule uses, in the --lib-* token family. This scoped
   override is more specific than the base rule and covers exactly the
   two screens barChart() actually renders on, so without repeating the
   treatment here the softened fill never applied anywhere at all. */
.screen-history .bar-chart-fill, .screen-measure .bar-chart-fill {
  background: color-mix(in srgb, var(--lib-accent) 34%, transparent);
  border-top-color: var(--lib-accent);
}
.screen-history .bar-chart-label, .screen-measure .bar-chart-label { color: var(--lib-muted); }
.screen-history .bar-chart-gridline, .screen-measure .bar-chart-gridline { border-top-color: var(--lib-line-strong); }
.screen-history .bar-chart-axis, .screen-measure .bar-chart-axis,
.screen-history .bar-chart-value, .screen-measure .bar-chart-value { color: var(--lib-muted); }
.screen-history .heatmap-cell, .screen-measure .heatmap-cell { background: var(--lib-panel-2, var(--lib-line)); }
.screen-history .heatmap-cell.level-1, .screen-measure .heatmap-cell.level-1 { background: color-mix(in srgb, var(--lib-accent) 28%, var(--lib-line)); }
.screen-history .heatmap-cell.level-2, .screen-measure .heatmap-cell.level-2 { background: color-mix(in srgb, var(--lib-accent) 52%, var(--lib-line)); }
.screen-history .heatmap-cell.level-3, .screen-measure .heatmap-cell.level-3 { background: color-mix(in srgb, var(--lib-accent) 76%, var(--lib-line)); }
.screen-history .heatmap-cell.level-4, .screen-measure .heatmap-cell.level-4 { background: var(--lib-accent); }
.screen-history .donut-seg, .screen-measure .donut-seg { stroke: var(--lib-accent); }
.screen-history .donut-center, .screen-measure .donut-center { color: var(--lib-text); }
.screen-history .donut-center-label, .screen-measure .donut-center-label { color: var(--lib-muted); }
.screen-history .donut-legend-swatch, .screen-measure .donut-legend-swatch { background: var(--lib-accent); }
.screen-history .donut-legend-value, .screen-measure .donut-legend-value { color: var(--lib-muted); }
.screen-history .empty, .screen-measure .empty { color: var(--lib-muted); }

.overview-pr-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 0;
  border-bottom: 1px solid var(--lib-line);
}
.overview-pr-row:last-child { border-bottom: none; }
.overview-pr-name { flex: 1; font-weight: 600; }
.overview-pr-value.mono { font-family: var(--font-mono); }
.overview-pr-date { color: var(--lib-muted); font-size: 13px; flex: none; }

.balance-list.progress-chart { display: flex; flex-direction: column; gap: 10px; }
.balance-row { display: flex; align-items: center; gap: 12px; }
.balance-row-name { width: 160px; flex: none; font-size: 14px; display: flex; align-items: center; gap: 6px; }
.balance-row-name .pattern-icon { width: 15px; height: 15px; }
/* Larger than .pattern-icon — a whole body silhouette plus a small
   highlighted region needs more room to stay legible than the pattern
   glyphs' bold single arrow does; checked against the real row height,
   not just the icon in isolation. */
.balance-row-name .muscle-icon { width: 20px; height: 20px; flex: none; }
.balance-row-track {
  flex: 1;
  height: 10px;
  border-radius: 999px;
  background: var(--lib-line);
  overflow: hidden;
}
.balance-row-fill { height: 100%; background: var(--lib-accent); border-radius: inherit; }
.balance-row-pct { width: 90px; flex: none; text-align: right; font-family: var(--font-mono); font-size: 13px; color: var(--lib-muted); }
/* F-06 — "Not trained" is a neutral observation about a period, not an
   error, and --danger is carrying real failures elsewhere in the app.
   A single untrained pattern among eleven trained ones is the case this
   actually renders now (the all-zero case is intercepted upstream in
   drawBalanceList), and that deserves muted text, not an alarm. */
.balance-row.zero .balance-row-pct { color: var(--lib-muted); font-style: italic; }
.balance-row.zero .balance-row-name { color: var(--lib-muted); }
/* "No measurable load" (real sets logged, just nothing kg-based to show)
   is a different, better story than genuinely untrained — keep the name
   at full contrast so it doesn't read as a gap in training. */
.balance-row.no-load .balance-row-name { color: var(--lib-text); }
/* Left/Right split sub-label — only rendered when a muscle/pattern has
   any per-side-tracked history in the period (see the Balance analytics
   design decision); imbalance (>15pt skew from 50/50) gets the same
   warning treatment the timer/quota UI elsewhere in the app already uses. */
.balance-row-side { width: 110px; flex: none; text-align: right; font-family: var(--font-mono); font-size: 12px; color: var(--lib-muted); }
.balance-row-side.imbalance { color: var(--warning); font-weight: 600; }

.balance-suggestion-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 12px 16px;
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: 5px;
  margin-bottom: 8px;
  flex-wrap: wrap;
}
.balance-suggestion-pattern { font-weight: 600; font-size: 14px; }
.balance-suggestion-pattern .muted { font-weight: 400; color: var(--lib-muted); }
.balance-suggestion-exercise { font-size: 13px; color: var(--lib-muted); }

.progress-table {
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: 5px;
  padding: 6px;
  overflow-x: auto;
}
.progress-table table {
  width: 100%;
  border-collapse: collapse;
  font-size: 14px;
}
.progress-table th, .progress-table td {
  padding: 12px 14px;
  text-align: left;
  border-bottom: 1px solid var(--lib-line);
  font-variant-numeric: tabular-nums;
}
.progress-table th {
  font-size: 12px;
  color: var(--lib-muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.progress-table tr:last-child td { border-bottom: none; }

/* -------- session detail -------- */
.detail-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 16px;
  flex-wrap: wrap;
  margin-bottom: 20px;
}
.detail-head h1 { margin-bottom: 6px; }
.detail-actions {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
}

/* Glass — see .settings-card. */
.detail-exercise {
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: 5px;
  padding: 6px;
  margin-bottom: 18px;
  overflow-x: auto;
}
.detail-exercise h3 {
  font-size: calc(20px * var(--scale));
  margin: 14px 16px 10px;
}
.detail-exercise table {
  width: 100%;
  border-collapse: collapse;
  font-size: var(--fs-body);
  min-width: 560px;
}
.detail-exercise th, .detail-exercise td {
  padding: 12px 14px;
  text-align: left;
  border-bottom: 1px solid var(--border);
  font-variant-numeric: tabular-nums;
}
.detail-exercise th {
  font-size: var(--fs-label);
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.detail-exercise tr:last-child td { border-bottom: none; }
.detail-exercise .skipped { color: var(--muted); font-style: italic; }
/* Five columns with a 560px floor can only ever scroll sideways on a
   phone, so below 640px each set row becomes a stacked card instead: the
   thead is dropped and every cell re-states its own heading from the
   data-label renderSessionDetail() puts on it. Achieved is the one cell
   that stacks its label above the value rather than beside it — its
   contents are a row of number inputs plus Clear, not a short string. */
@media (max-width: 640px) {
  .detail-exercise { overflow-x: visible; padding: 6px 10px 10px; }
  .detail-exercise table { display: block; min-width: 0; }
  .detail-exercise thead { display: none; }
  .detail-exercise tbody { display: block; }
  .detail-exercise tr {
    display: block;
    border: 1px solid var(--border);
    border-radius: 5px;
    padding: 6px 12px;
    margin-bottom: 8px;
  }
  .detail-exercise td {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 12px;
    padding: 6px 0;
    border-bottom: none;
  }
  .detail-exercise td::before {
    content: attr(data-label);
    flex: none;
    font-size: var(--fs-label);
    color: var(--muted);
    text-transform: uppercase;
    letter-spacing: 0.05em;
  }
  .detail-exercise td[data-label="Achieved"] { flex-direction: column; align-items: stretch; gap: 6px; }
  .detail-exercise h3 { margin: 10px 4px; }
}

.achieved-edit {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
}
.achieved-edit input {
  min-height: calc(44px * var(--scale));
  font-size: calc(16px * var(--scale));
  padding: 8px 10px;
}
.achieved-edit input[data-role="weight"] { width: 90px; }
.achieved-edit input[data-role="reps"]   { width: 66px; }
.detail-field {
  display: flex;
  flex-direction: column;
  gap: 3px;
}
.detail-field label {
  font-size: var(--fs-label);
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.detail-field input { width: 90px; }
.achieved-edit button {
  min-height: calc(44px * var(--scale));
  padding: 8px 12px;
  font-size: calc(14px * var(--scale));
}
.achieved-edit button.clear-btn {
  background: transparent;
  color: var(--muted);
  border-color: var(--border);
}
.achieved-edit button.clear-btn:hover { color: var(--danger); border-color: var(--danger); }

.delta { font-weight: 700; }
.delta.up   { color: var(--success); }
.delta.down { color: var(--danger); }
.delta.flat { color: var(--muted); font-weight: 400; }

/* -------- modal -------- */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: var(--scrim);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 20px;
  z-index: 200;
  /* Every modal in the app builds this same overlay/.modal pair inline
     (there's no single shared factory function to hook a JS entrance
     animation into without touching dozens of call sites) — a pure-CSS
     animation on the class itself applies automatically everywhere, on
     open, with zero call-site changes. The close side stays instant
     (overlay.remove()) — animating that would mean coordinating a
     removal delay across every one of those call sites individually,
     a much larger and riskier change for the same "modal just closes"
     outcome the user already gets today. */
  animation: modal-overlay-in var(--duration-slow) var(--ease-standard) both;
}
@keyframes modal-overlay-in {
  from { opacity: 0; }
}
.modal {
  background: var(--panel);
  border: 1px solid var(--border);
  /* Bigger than --radius-card — the modal is Neartial's "one hero
     surface per screen" per the source design system's readme. */
  border-radius: 5px;
  padding: 28px;
  width: 100%;
  max-width: 440px;
  /* .modal-wide and .modal-xwide have carried these two for a while; the
     base modal did not, and nothing else caught it — the overlay is
     `align-items: center` with `overflow: visible`, so a modal taller
     than the viewport overflowed EQUALLY off the top and bottom with
     nothing able to scroll to either. Measured on Manage muscles (651px,
     one of ~30 plain .modal call sites) at 720x380, a phone in
     landscape: the title sat 135px above the viewport and the Close
     button 107px below it, unreachable. Only dismissing via the backdrop
     saved it, and a modal whose primary action is Save rather than Close
     would simply have been unusable. */
  max-height: 88vh;
  overflow-y: auto;
  box-shadow: var(--shadow);
  animation: modal-in 220ms var(--ease-settle) both;
}
@keyframes modal-in {
  from { opacity: 0; transform: translateY(10px) scale(0.98); }
}
@media (prefers-reduced-motion: reduce) {
  .modal-overlay, .modal { animation: none; }
}
.modal h2 { margin-bottom: 20px; }
.modal .field-label {
  display: block;
  color: var(--muted);
  font-size: var(--fs-label);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin-bottom: 8px;
}
.modal .field-row {
  display: flex;
  align-items: center;
  gap: 12px;
  margin-bottom: 20px;
}
.modal .field-row input[type="text"] { flex: 1; }
.modal .field-row select { flex: 1; }
.modal .field-row textarea { flex: 1; }
/* Sets/Reps/Rest row on the trainer's "Suggest a change" modal — see
   openManualSuggestionModal() in app.js. min-width: 0 so the grid items
   can actually shrink below their content width in this fairly narrow
   modal (same grid-child footgun documented for .set-row etc.). */
.trainer-suggestion-scheme {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 12px;
  margin-bottom: 20px;
}
.trainer-suggestion-scheme .field-label { min-width: 0; margin-bottom: 4px; }
.trainer-suggestion-scheme input { width: 100%; min-width: 0; }
.modal-actions {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 12px;
  margin-top: 8px;
}
.modal-actions.three {
  grid-template-columns: 1fr 1fr 1fr;
}
.modal-actions > button { min-width: 0; }

/* #38 — for content that can grow taller than the viewport (the import
   modal's starter-pack picker + equipment checklist, in particular): cap
   the modal itself and make it the scroll container, then pin the action
   row to the bottom of that scroll so Import/Cancel stay reachable no
   matter how far down the content grows. */
.modal.modal-scroll {
  max-height: 88vh;
  overflow-y: auto;
}
.modal.modal-scroll .modal-actions {
  position: sticky;
  bottom: 0;
  background: var(--panel);
  padding-top: 12px;
  padding-bottom: 2px;
}

.program-review-workout {
  border: 1px solid var(--border);
  border-radius: 5px;
  padding: 10px 14px;
  margin-bottom: 12px;
}
.program-review-workout h3 { margin: 0 0 4px; font-size: calc(16px * var(--scale)); }
.program-review-workout ul { margin: 6px 0 0; padding-left: 18px; font-size: calc(14px * var(--scale)); }
.program-review-notes { border-top: 1px solid var(--border); padding-top: 10px; margin-top: 4px; }

.modal.modal-wide {
  max-width: 640px;
  max-height: 88vh;
  overflow-y: auto;
}
/* The three workout-building modals (#103): the picker with its facet
   chips and pick tray, and both builders with their per-exercise
   sets/reps/rest rows. All three lay out a name, a meta line and three
   number fields on one row, which 640px squeezes hard — a long exercise
   name wraps to two lines while the fields below it sit in a third of
   the width. Deliberately a separate class rather than widening
   .modal-wide itself, which a dozen simpler forms across the app share
   and none of them need the room. */
.modal.modal-xwide {
  max-width: 860px;
  max-height: 88vh;
  overflow-y: auto;
}
/* Dedicated modifier rather than widening .modal-wide itself — that
   class is the shared shell every other add/edit modal in the app uses
   too, so this only widens the profile-edit modal specifically (it now
   groups fields into .modal-section cards, which needs more breathing
   room than a plain field grid). */
.modal.modal-profile-edit { max-width: 860px; }
.modal-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 16px;
  margin-bottom: 20px;
}
.modal-grid .field-row { margin-bottom: 0; }
/* A field that needs the full modal width regardless of how many
   columns the grid above settled on (e.g. the profile-edit modal's
   Name/Colour fields — the colour picker's swatch row doesn't fit a
   narrow column). */
.field-block-wide { grid-column: 1 / -1; }

/* Groups related fields into their own card, same visual language as
   Account Settings' .settings-card — a modal full of ungrouped fields
   reads as one long strip once there are enough of them. panel-2 (not
   panel, the modal's own background) so each section reads as a
   distinct surface sitting on top of the modal rather than blending
   into it. auto-fit (not the shared .modal-grid rule's auto-fill) is
   deliberate here: with exactly 2-3 short fields per section, auto-fill
   would leave empty phantom columns and left-align the real fields
   instead of letting them spread out to fill the section's full width. */
.modal-section {
  background: var(--panel-2);
  border: 1px solid var(--border);
  border-radius: 5px;
  padding: 16px 18px;
  margin-bottom: 20px;
}
.modal-section .modal-grid {
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  margin-bottom: 0;
}
.modal-section-title {
  font-size: calc(13px * var(--scale));
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin: 0 0 14px;
}

.lib-picker-results { max-height: 50vh; overflow-y: auto; margin-bottom: 16px; }
.lib-picker-group { margin-bottom: 18px; }
.lib-picker-group h3 { font-size: var(--fs-label); color: var(--muted); margin: 0 0 8px; text-transform: uppercase; letter-spacing: 0.05em; }
.lib-picker-row {
  display: flex; align-items: center; justify-content: space-between; gap: 12px;
  padding: 8px 0; border-bottom: 1px solid var(--border);
}
.lib-picker-row:last-child { border-bottom: 0; }
.lib-picker-row.selected { color: var(--accent); }
.lib-picker-name { display: flex; align-items: center; gap: 6px; }

/* --- Exercise picker: facets + pick tray (#103) -------------------------
   The picker used to be a search box over a flat ~100-row list. These are
   the muscle/movement facets and the ordered pick tray that replaced it.
   Shared tokens (--panel/--border/--accent), matching the .lib-picker-*
   rules directly above rather than the --lib-* set .lib-add-btn inside
   each row uses — this one modal already straddles both token systems, so
   new rules follow their nearest neighbours instead of widening the split.
   Don't take this as licence to mix the two on a whole screen; the rule
   documented near the top of this file still holds everywhere else. */
.lib-picker-facets { margin-bottom: 12px; }
.lib-facet-summary {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 0;
  cursor: pointer;
  list-style: none;
  font-size: calc(12px * var(--scale));
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--muted);
}
.lib-facet-summary::-webkit-details-marker { display: none; }
.lib-facet-summary::before {
  content: '▸';
  font-size: 0.9em;
  transition: transform var(--duration-fast, 120ms) ease;
}
.lib-picker-facets[open] > .lib-facet-summary::before { transform: rotate(90deg); }
.lib-facet-summary:hover { color: var(--text-primary); }
.lib-facet-active-count {
  padding: 1px 7px;
  border-radius: 999px;
  background: var(--accent);
  color: var(--accent-ink);
  font-family: var(--font-mono);
  font-size: 10px;
  letter-spacing: 0;
}
.lib-facet-group { display: flex; align-items: flex-start; gap: 10px; padding-top: 8px; }
.lib-facet-legend {
  flex: none;
  width: 66px;
  padding-top: 6px;
  font-size: calc(11px * var(--scale));
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--muted);
}
.lib-facet-chips { display: flex; flex-wrap: wrap; gap: 5px; min-width: 0; }
.lib-facet-chip {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  min-height: 0;
  min-width: 0;
  padding: 4px 9px;
  border-radius: 999px;
  border: 1px solid var(--border);
  background: transparent;
  color: var(--muted);
  font-size: calc(12px * var(--scale));
  line-height: 1.35;
  cursor: pointer;
}
.lib-facet-chip:hover { border-color: var(--accent); color: var(--text-primary); }
.lib-facet-chip.on { background: var(--accent); border-color: var(--accent); color: var(--accent-ink); }
.lib-facet-icon { width: 13px; height: 13px; }
.lib-facet-count { font-family: var(--font-mono); font-size: 10.5px; opacity: 0.65; }
.lib-facet-clear {
  min-height: 0; min-width: 0;
  padding: 2px 8px;
  border: 0;
  background: transparent;
  /* Not --accent: accent-on-panel measures 3.83:1 in dark mode, under
     AA for text this size. The underline already carries "actionable",
     so the colour doesn't have to. */
  color: var(--text-primary);
  font-size: calc(12px * var(--scale));
  text-decoration: underline;
  cursor: pointer;
}
.lib-facet-clear:hover { color: var(--accent); }
.lib-picker-count {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  margin-bottom: 8px;
  font-size: calc(12px * var(--scale));
  color: var(--muted);
}

/* With a tray open the modal is showing two scrollable lists at once, so
   the results list gives up height rather than pushing the confirm button
   below the fold. */
.lib-picker-modal.has-tray .lib-picker-results { max-height: 30vh; }
.lib-tray {
  max-height: 32vh;
  overflow-y: auto;
  margin-bottom: 16px;
  border-top: 1px dashed var(--border);
  padding-top: 10px;
}
.lib-tray:empty { display: none; }
.lib-tray-head {
  display: flex;
  align-items: baseline;
  gap: 8px;
  flex-wrap: wrap;
  margin-bottom: 8px;
  font-size: calc(12px * var(--scale));
  color: var(--muted);
}
.lib-tray-head strong { color: var(--text-primary); }
.lib-tray-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 0;
  border-bottom: 1px solid var(--border);
}
.lib-tray-row:last-child { border-bottom: 0; }
.lib-tray-order {
  flex: none;
  width: 20px;
  font-family: var(--font-mono);
  font-size: calc(12px * var(--scale));
  color: var(--muted);
  text-align: right;
}
/* min-width:0 so a long exercise name shrinks instead of shoving the
   fields and the up/down/remove buttons out of the row — a flex child
   still defaults to min-width:auto and won't go below its content. */
.lib-tray-main { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 5px; }
.lib-tray-name { font-size: calc(14px * var(--scale)); overflow-wrap: anywhere; }
.lib-tray-fields { display: flex; gap: 6px; flex-wrap: wrap; }
.lib-tray-field, .builder-slot-field {
  display: flex;
  align-items: center;
  gap: 5px;
  min-width: 0;
  font-size: calc(11px * var(--scale));
  color: var(--muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.lib-tray-field input, .builder-slot-field input {
  width: 62px;
  min-width: 0;
  min-height: 0;
  padding: 4px 7px;
  font-size: calc(13px * var(--scale));
  font-family: var(--font-mono);
  text-transform: none;
  letter-spacing: 0;
}
.lib-tray-field input[type="text"], .builder-slot-field input[type="text"] { width: 84px; }
.lib-tray-actions { flex: none; display: flex; gap: 3px; }
.lib-tray-btn {
  min-height: 0; min-width: 0;
  width: 30px; height: 30px;
  padding: 0;
  border-radius: 6px;
  background: var(--panel-2);
  border: 1px solid var(--border);
  /* --muted on --panel-2 measures 2.57:1 in dark mode — under even the
     3:1 floor for a UI control. The disabled state below is what dims
     these, not the resting colour. */
  color: var(--text-primary);
  font-size: calc(14px * var(--scale));
  line-height: 1;
}
.lib-tray-btn:hover:not(:disabled) { border-color: var(--accent); }
.lib-tray-btn:disabled { opacity: 0.35; cursor: default; }

@media (max-width: 560px) {
  .lib-facet-group { flex-direction: column; gap: 4px; }
  .lib-facet-legend { width: auto; padding-top: 0; }
  .lib-tray-row { flex-wrap: wrap; }
  .lib-tray-actions { margin-left: auto; }
}
.modal-grid .field-block .prev {
  margin-top: 6px;
  font-size: calc(13px * var(--scale));
  color: var(--muted);
}

/* -------- body measurements --------
   Same fixed-px, --lib-* glass-panel treatment as Library/Manage/History
   (see .screen-measure below) — every class in this block is exclusive to
   the Measure screen and its Weight modal, so there's nothing shared to
   accidentally affect elsewhere. .mf-* position rules (top/left/right %)
   are untouched — they're plain percentage layout against .body-diagram,
   independent of font-size/--scale either way. */
/* Relocated into #measure-tabs-row, right-aligned next to the tab strip
   (same positioning idiom as the Train screen's "+ New" dropdown in its
   own .tabs-row) — only ever visible on the Record tab, toggled by
   renderMeasure()'s wireTabs onShow. .picker-label's default block
   layout (label above control, 20px bottom margin) is meant for a
   standalone field, not a header-row chip, so this overrides both,
   mirroring the Progress screen's own .analytics-period treatment. */
#measure-tabs-row { align-items: center; }
.measure-date-label {
  flex-direction: row;
  align-items: center;
  gap: 8px;
  margin-bottom: 0;
  flex: none;
  white-space: nowrap;
}
.measure-date-label input { min-height: 34px; font-size: 13px; padding: 6px 10px; }

/* Record tab: measurements on the left, weekly weight on the right. */
/* F-15 — the body diagram is deliberately capped at 380px ("per
   request", see .body-diagram) and its callout fields span about 494px
   around it. In a 1fr left column that assembly sat centred in 996px at
   a 1440px viewport, marooned with ~250px of dead space either side —
   the same complaint as the header plates in F-04, just on this screen.
   Capping the grid pulls both columns in together so the diagram keeps
   its requested size and stops floating. Sized so the left column lands
   just wide enough for the callouts (900 - 340 right column - 24 gap
   = 536, against a 494px assembly). */
.measure-record-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(260px, 704px);
  gap: 24px;
  align-items: start;
  max-width: 1264px;
}
/* #88 — the right column holds the weekly weight and resting heart rate
   panels SIDE BY SIDE rather than stacked, so both daily-entry grids are
   in shot at once. 704px is two 340px panels plus the 24px gap, and
   1264px is the old 900px cap plus that second panel — which means the
   left (diagram) column still resolves to the same 536px it always did,
   so nothing about the body-diagram callout sizing above changes. */
.measure-col-weight {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: 24px;
  align-items: start;
}
/* Below the full width there isn't room for three columns without
   squeezing the diagram under the ~494px its callout assembly needs, so
   the two panels stack again and the grid returns to its original
   proportions. The container's own `gap` spaces them once stacked —
   deliberately no margin rule on the panels, which would double up
   against the gap in the side-by-side layout. */
@media (max-width: 1264px) {
  .measure-record-grid {
    grid-template-columns: minmax(0, 1fr) minmax(260px, 340px);
    max-width: 900px;
  }
  .measure-col-weight { grid-template-columns: minmax(0, 1fr); }
}
@media (max-width: 900px) {
  .measure-record-grid { grid-template-columns: 1fr; }
}

.weight-card-label {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: 12px;
  color: var(--lib-muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}

/* -------- weekly weight panel (Record tab, right column) -------- */
.weight-week-panel {
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: 5px;
  padding: 18px 20px;
}
/* Marks a field the user typed something out of range into. Deliberately
   leaves the typed value in place to be corrected rather than reverting
   it, so the border is the only signal there is. */
.input-invalid {
  border-color: var(--danger) !important;
}
.weight-week-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  margin-bottom: 16px;
}
/* The average badge that replaced the week-start-day picker in this
   corner — that picker moved to the profile-edit modal (see the info
   bubble below, which points there). */
#weight-week-average {
  font-family: var(--font-mono);
  font-size: 15px;
  font-weight: 700;
  color: var(--lib-text);
}

/* -------- info bubble (small "i" button + popover) --------
   Generic enough to reuse elsewhere: a .info-bubble wraps a
   .info-bubble-btn and a .info-bubble-panel, toggled by wireInfoBubble()
   in app.js using the same outside-click/Escape-to-close idiom as the
   Train screen's "+ New" dropdown. */
.info-bubble { position: relative; display: inline-flex; }
.info-bubble-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 16px;
  height: 16px;
  min-height: 0;
  padding: 0;
  border-radius: 50%;
  border: 1px solid var(--lib-line-strong);
  background: var(--lib-panel-2);
  color: var(--lib-muted);
  font-family: var(--font-mono);
  font-size: 10px;
  font-style: italic;
  line-height: 1;
  cursor: pointer;
}
.info-bubble-btn:hover { border-color: var(--lib-accent); color: var(--lib-text); }
.info-bubble-panel {
  position: absolute;
  top: calc(100% + 8px);
  /* Right-anchored by default — the original trigger sites (a stat-card
     label, a table header) sit inside a narrow right-hand column, and a
     left-anchored panel this wide pushed past the page's own right edge.
     Now also used right after left-margin headings (h1/h2/label text),
     where right-anchoring instead clips the panel off the LEFT edge of
     the viewport — wireInfoBubble()/infoTip()'s open() measures this at
     open time and adds .align-left to flip it, rather than needing every
     call site to know in advance which side it's near. */
  right: 0;
  z-index: 20;
  width: 220px;
  /* Solid, not --lib-panel(-2) — those are a ~4%-opacity glass wash
     (see the brand-guidelines note above), fine for a surface sitting
     flush against the page background but not for a popover that floats
     OVER other text/charts: the glass let whatever was underneath show
     through and made the tooltip hard to read. --lib-bg is the same
     "needs real opacity" swap the sticky topbar already makes for the
     identical reason. */
  background: var(--lib-bg);
  border: 1px solid var(--lib-line-strong);
  border-radius: 5px;
  padding: 10px 12px;
  font-size: 12px;
  font-weight: 400;
  text-transform: none;
  letter-spacing: normal;
  color: var(--lib-text);
  box-shadow: var(--lib-shadow);
}
.info-bubble-panel.align-left { right: auto; left: 0; }
/* The vertical counterpart — opens the panel upward when there isn't room
   below it. Set by open() in both wireInfoBubble() and infoTip(), which
   measure at open time; see the note there. */
.info-bubble-panel.align-above { top: auto; bottom: calc(100% + 8px); }

/* -------- weekly weight rows (shared by the panel above) -------- */
.weight-modal-nav {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 20px;
}
.weight-modal-nav .week-label {
  flex: 1;
  text-align: center;
  font-weight: 600;
  font-size: 15px;
}
.weight-modal-nav button { min-height: 38px; padding: 8px 14px; font-size: 14px; }

.weight-day-rows-head {
  display: flex;
  gap: 12px;
  padding: 0 14px;
  margin-bottom: 6px;
  font-size: 11px;
  color: var(--lib-muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
.weight-day-rows-head span:first-child { flex: 1; }
.weight-day-rows-head span:nth-child(2) { flex: none; width: 90px; text-align: right; }
.weight-day-rows-head span:nth-child(3) { flex: none; width: 64px; text-align: right; }

/* ~25% shorter than before (row was ~58px tall: 20px padding + 38px
   input) so all 7 days fit on screen without scrolling the panel. */
.weight-day-rows { display: flex; flex-direction: column; gap: 7px; margin-bottom: 20px; }
.weight-day-row {
  display: flex;
  align-items: center;
  gap: 12px;
  background: var(--lib-panel-2);
  border: 1px solid var(--lib-line);
  border-radius: 5px;
  padding: 6px 14px;
}
.weight-day-row.is-today { border-color: var(--lib-accent); }
.weight-day-row .day-label { flex: 1; font-size: 13px; }
.weight-day-row .day-label .day-name { font-weight: 600; display: block; }
.weight-day-row .day-label .day-date { color: var(--lib-muted); font-size: 11px; }
.weight-day-row input {
  width: 90px;
  min-height: 30px;
  font-size: 14px;
  text-align: right;
  flex: none;
}
/* Day-over-day change — plain signed number, deliberately not colour-
   coded good/bad like .delta-chip elsewhere: whether a rising or falling
   number is "good" depends on the user's own goal (bulking vs cutting),
   which this app has no notion of. */
.weight-day-row .day-delta {
  flex: none;
  width: 64px;
  text-align: right;
  font-family: var(--font-mono);
  font-size: 13px;
  color: var(--lib-muted);
}
.body-view-toggle { display: flex; gap: 8px; justify-content: center; margin-bottom: 12px; }
/* Period switcher for the heat map. Sits above the canvas rather than
   below it so it reads as a control over the figure, not a caption on it,
   and wraps on a narrow phone instead of squeezing four pills flat. */
.body-3d-periods {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
  justify-content: center;
  margin-bottom: 10px;
}
.body-3d-periods .lib-ctrl-btn { min-width: 56px; }
.body-3d-wrap {
  width: 100%;
  max-width: 520px;
  margin: 0 auto 12px;
}
.body-3d-mount {
  position: relative;
  width: 100%;
  aspect-ratio: 3 / 4;
  border-radius: var(--radius-card, 5px);
  overflow: hidden;
  background: radial-gradient(circle at 50% 30%, var(--lib-panel), var(--lib-bg));
  touch-action: none;
}
.body-3d-mount canvas { display: block; width: 100%; height: 100%; }
.body-3d-info {
  text-align: center;
  margin-top: 8px;
  min-height: 1.4em;
}
/* The proxy input a marker tap shows — the real <input> for that field
   lives inside the flat diagram, hidden while 3D is active, so this
   writes into it on every keystroke instead of trying to focus it. */
.body-3d-quick-field {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  flex-wrap: wrap;
}
.body-3d-quick-field label { font-weight: 600; }
.body-3d-quick-input {
  width: 90px;
  text-align: center;
  font-size: calc(18px * var(--scale));
  min-height: var(--btn-min);
}
.body-diagram {
  position: relative;
  width: 100%;
  /* Half the diagram's previous 760px rendered size, per request. */
  max-width: 380px;
  aspect-ratio: 400 / 640;
  margin: 0 auto 20px;
}
.body-svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
/* Dual-image swap (see silhouetteMarkup() in app.js) — the dark-mode
   PNG's pale stroke is nearly invisible against light mode's warm-ivory
   background, so light mode swaps in a genuinely different, darker-
   stroke asset rather than a filter. Pure CSS on [data-appearance], not
   a JS choice at render time, so this also reacts correctly if the
   user flips the appearance toggle while this panel is already open. */
.body-silhouette-light { display: none; }
:root[data-appearance="light"] .body-silhouette-dark { display: none; }
:root[data-appearance="light"] .body-silhouette-light { display: inline; }
#body-silhouette path,
#body-silhouette ellipse {
  fill: none;
  stroke: var(--lib-muted);
  stroke-width: 2.5;
  stroke-linejoin: round;
  stroke-linecap: round;
}
.callout-line {
  stroke: var(--lib-muted);
  stroke-width: 1.5;
  transition: stroke 0.15s ease, stroke-width 0.15s ease;
}
.callout-line.active {
  stroke: var(--lib-accent);
  stroke-width: 3;
}

.measure-field {
  position: absolute;
  /* Widened back up (roughly double the previous 18%). The figure's own
     silhouette bulges wider than the callout lines' 32%/68% anchor
     points at some heights (a hanging hand near the hip, in
     particular), so flush-against-the-diagram-edge boxes still clipped
     it — pushed out past the diagram's own box instead. There's ample
     slack there: the diagram is centred in a panel roughly 300px wider
     than itself on each side, so a negative offset here still lands
     nowhere near the panel's own edge. */
  width: 34%;
}
.measure-field.mf-left  { left: -15%; text-align: right; }
.measure-field.mf-right { right: -15%; text-align: left; }
.measure-field label {
  display: block;
  font-size: 10px;
  color: var(--lib-muted);
  text-transform: uppercase;
  letter-spacing: 0.04em;
  margin-bottom: 2px;
}
/* #50 — the unit (cm/in, per the profile's measurement_unit) sits right
   in the label so it's visible before the user ever types anything, not
   just once a "Prev:" hint with a unit suffix happens to exist below the
   field. Deliberately not uppercase/letter-spaced like the rest of the
   label — "(cm)" reads better than "(CM)" at this size. */
.measure-field-unit { text-transform: none; letter-spacing: normal; opacity: 0.8; }
.measure-field input {
  /* Scaled down from 38px/15px to match the halved diagram. */
  min-height: 30px;
  font-size: 12px;
  padding: 5px 8px;
  text-align: inherit;
}
/* Matches the callout line's own focus highlight (.callout-line.active,
   toggled alongside this by the same focus/blur pair in
   renderMeasureRecordPanel) so the box and its line light up together. */
.measure-field input:focus {
  border-color: var(--lib-accent);
}
/* Carried forward from the last recorded measurement, not yet confirmed
   for this entry — dashed and muted so it reads as a suggestion, not a
   value already typed for today. Clears the moment the user edits it
   (see renderMeasureRecordPanel()). */
.measure-field input.measure-prefilled { color: var(--lib-muted); border-style: dashed; }
.measure-field .prev {
  margin-top: 3px;
  font-size: 10px;
  color: var(--lib-muted);
  min-height: 1.3em;
}
.measure-field .prev .delta { font-size: inherit; }

.mf-neck            { top: 6.25%;  transform: translateY(-50%); }
.mf-shoulders        { top: 20.78%; transform: translateY(-50%); }
.mf-left-upper-arm   { top: 35.47%; transform: translateY(-50%); }
.mf-waist            { top: 50%;    transform: translateY(-50%); }
.mf-left-thigh       { top: 64.53%; transform: translateY(-50%); }
.mf-left-calf        { top: 79.22%; transform: translateY(-50%); }
.mf-left-ankle       { top: 93.75%; transform: translateY(-50%); }

.mf-chest            { top: 6.25%;  transform: translateY(-50%); }
.mf-right-upper-arm  { top: 23.75%; transform: translateY(-50%); }
.mf-hips             { top: 41.25%; transform: translateY(-50%); }
.mf-right-thigh      { top: 58.75%; transform: translateY(-50%); }
.mf-right-calf       { top: 76.25%; transform: translateY(-50%); }
.mf-right-ankle      { top: 93.75%; transform: translateY(-50%); }

.measure-save-btn {
  display: block;
  width: 100%;
  max-width: 420px;
  margin: 24px auto 0;
  min-height: 44px;
  font-size: 16px;
  padding: 12px 24px;
}

@media (max-width: 760px) {
  /* the pinpoint diagram gets cramped on phones — fall back to a plain
     stacked list of the same 13 fields, still grouped left/right visually */
  .body-diagram { aspect-ratio: auto; max-width: 480px; }
  .body-svg { display: none; }
  .measure-field {
    position: static !important;
    width: 100% !important;
    text-align: left !important;
    transform: none !important;
    margin-bottom: 14px;
  }
  .measure-field input { text-align: left !important; }
}

/* -------- toast -------- */
#toast {
  position: fixed;
  bottom: calc(30px + env(safe-area-inset-bottom, 0px));
  left: 50%;
  transform: translateX(-50%) translateY(100px);
  background: var(--panel-2);
  border: 1px solid var(--border);
  color: var(--text);
  padding: 12px 20px;
  border-radius: 999px;
  box-shadow: var(--shadow);
  opacity: 0;
  transition: transform 0.25s ease, opacity 0.25s ease;
  pointer-events: none;
  z-index: 100;
  font-size: 15px;
}
#toast.success {
  border-color: var(--success);
  color: var(--success);
}
#toast.show {
  opacity: 1;
  transform: translateX(-50%) translateY(0);
}

/* ==========================================================
   Screen: exercise library
   ----------------------------------------------------------
   Deliberately built on its own --lib-* tokens (defined in :root
   above) rather than the shared --bg/--panel/--accent tokens, so it
   keeps the same dense, spec-plate look regardless of the site-wide
   theme-mode toggle. Font sizes here are fixed px, not var(--scale)
   -driven, for the same reason — this screen is meant to stay
   compact and scannable rather than grow with anything.
   ========================================================== */
.screen-library { font-size: 14px; line-height: 1.5; }

.lib-plate {
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: var(--radius-card);
  box-shadow: var(--lib-shadow);
  padding: 20px 22px;
  margin-bottom: 16px;
}
/* F-04 — this used to be `justify-content: space-between` with no cap on
   the inner content, so at 1440px the title block and the stat row were
   shoved to opposite edges of a 1360px plate with a measured 656px of
   dead panel between them (46% of the viewport), on all four screens
   that use this header. Stacking instead, with the stats promoted to a
   full-width strip below the title, closes the gulf and gives the
   numbers real presence rather than leaving them orphaned right.
   Deliberately a `>` child selector: .lib-stat-row is also used in five
   places that are NOT a plate header (the session-summary screen, the
   Lifts picker row, the measurement-chart stats), where it must stay the
   compact inline row it has always been. */
.lib-plate-top { display: flex; flex-direction: column; align-items: stretch; gap: 16px; }
.lib-eyebrow {
  font-family: var(--font-mono);
  font-size: 11px;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--lib-accent-ink);
  font-weight: 600;
  margin: 0 0 6px;
}
.lib-h1 { font-size: 24px; line-height: 1.15; margin: 0; font-weight: 750; letter-spacing: -0.01em; color: var(--lib-text); text-wrap: balance; }
.lib-desc { color: var(--lib-muted); font-size: 13px; max-width: 62ch; margin: 10px 0 0; }
.lib-stat-row { display: flex; gap: 20px; flex-wrap: wrap; }
/* #60 — the Exercises tab's picker and headline stat share one row
   instead of the stat sitting on its own full-width line below it;
   picker-label's own margin-bottom (meant for a standalone field) would
   otherwise misalign it against the stat card next to it. */
.lifts-header-row {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 20px;
  flex-wrap: wrap;
  margin-bottom: 20px;
}
.lifts-header-row .picker-label { margin-bottom: 0; flex: 1 1 260px; min-width: 0; }
.lifts-header-row .picker-label select { max-width: 100%; }
/* F-04 — the plate-header variant only (see .lib-plate-top above): a
   full-width strip of equal columns divided off from the title by a
   rule. auto-fit + minmax collapses to two columns on a phone, which
   also fixes the 3-then-1 orphan wrap the old flex row produced there
   (F-11) without a separate mobile rule. */
.lib-plate-top > .lib-stat-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
  gap: 14px 0;
  padding-top: 15px;
  border-top: 1px solid var(--lib-line);
}
.lib-plate-top > .lib-stat-row .lib-stat { text-align: left; min-width: 0; }
/* Rules between cells are what make an evenly-distributed row read as a
   deliberate stat strip rather than three numbers stranded far apart —
   with only three stats on a 1314px plate each cell is ~425px wide, and
   without a divider the eye has nothing tying them together. auto-fit
   collapses the unused tracks to 0px, so :first-child is reliably the
   real first stat. */
.lib-plate-top > .lib-stat-row .lib-stat {
  padding: 0 18px;
  border-left: 1px solid var(--lib-line);
}
.lib-plate-top > .lib-stat-row .lib-stat:first-child { padding-left: 0; border-left: none; }
/* Once the grid wraps, a "first in row 2" cell would carry a stray
   leading rule with nothing to its left — drop the dividers entirely at
   the width where wrapping starts and fall back to plain gaps. */
@media (max-width: 760px) {
  .lib-plate-top > .lib-stat-row { gap: 14px 18px; }
  .lib-plate-top > .lib-stat-row .lib-stat { padding: 0; border-left: none; }
}
.lib-stat { text-align: right; min-width: 60px; }
.lib-stat b { display: block; font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 20px; font-weight: 700; color: var(--lib-text); }
.lib-stat span { font-size: 10px; letter-spacing: 0.06em; text-transform: uppercase; color: var(--lib-muted); }

/* The Library screen's three control sections are glass panels, same
   treatment as .lib-plate above them, so the ambient field behind the
   page carries through instead of stopping at a solid slab across the
   top third of the screen.
   That means the filter bar can no longer be sticky. The two are
   genuinely incompatible here: --lib-panel is a 4%-alpha white, so a
   pinned translucent bar shows the list scrolling through it, and the
   usual fix — backdrop blur — is ruled out by this design system.
   Losing the pin is the smaller cost. It was already `position: static`
   on mobile (#39, where a bar that tall "was eating most of the screen
   while locked at the top"), and it pinned 328px on desktop, 36% of a
   900px viewport, because Workouts and Library were inside it too.
   Those are separate panels now, so what would have been pinned is a
   third of what it was — but a translucent pin still bleeds, so it goes. */
.lib-filterbar, .lib-panel {
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: var(--radius-card);
  box-shadow: var(--lib-shadow);
  padding: 16px 18px;
  margin-bottom: 12px;
}
/* Flex column so the `order` rules further down actually apply. They
   were written against a block parent, where order is inert, so the
   Filters toggle rendered wherever prepend() put it — above the search
   row — rather than "directly under the search row" as their own
   comment intends. */
.lib-filterbar { display: flex; flex-direction: column; gap: 10px; }
/* #39 — on mobile the bar is no longer pinned (it was eating most of the
   screen while "locked" at the top of every scroll) and starts collapsed
   behind its own toggle row; .lib-filterbar-open (set by
   wireCollapsibleFilterBar() in app.js) reveals the real filter content
   again. Desktop is completely untouched — none of this applies without
   the .mobile-mode ancestor class. */
.mobile-mode .lib-filterbar { position: static; }
.lib-filterbar-toggle { display: none; }
.mobile-mode .lib-filterbar-toggle {
  display: flex;
  align-items: center;
  justify-content: space-between;
  width: 100%;
  background: none;
  border: none;
  color: var(--lib-text);
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: 4px 2px 8px;
  cursor: pointer;
}
.lib-filterbar-chevron { transition: transform 150ms ease; }
.lib-filterbar.lib-filterbar-open .lib-filterbar-chevron { transform: rotate(180deg); }
/* Workouts and Library are sibling .lib-panel sections now rather than
   display:contents children of the bar, so the collapse rule no longer
   needs to exempt them — being outside the bar is what keeps them
   visible on mobile. */
.mobile-mode .lib-filterbar > *:not(.lib-filterbar-toggle) { display: none; }
.mobile-mode .lib-filterbar.lib-filterbar-open > *:not(.lib-filterbar-toggle) { display: flex; }

/* F-09 — the desktop half of the same idea, but a lighter touch than
   mobile's all-or-nothing collapse. Mobile hides the entire search +
   chip section because vertical space is the scarce thing there; on
   desktop the screen opened on six rows of controls before any library,
   so only the SECONDARY filters fold away. Search, the result count,
   and Equipment (which is how the results are actually grouped, so it's
   navigation more than filtering) stay put at every width. */
.lib-filterbar-toggle { display: none; }
@media (min-width: 1060px) {
  .lib-filterbar-toggle {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    width: auto;
    align-self: flex-start;
    background: none;
    border: 1px solid var(--lib-line);
    border-radius: 999px;
    color: var(--lib-text);
    font-family: var(--font-mono);
    font-size: 11px;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    padding: 6px 14px;
    min-height: 0;
    cursor: pointer;
    order: 2;
  }
  .lib-filterbar-toggle:hover { border-color: var(--lib-accent); }
  /* Secondary chip groups and the three list-wide buttons fold away;
     .lib-chip-group-primary and everything outside .lib-chip-groups
     stay. Ordered so the toggle sits directly under the search row
     rather than wherever prepend() put it in the DOM. */
  .lib-filterbar .lib-search-row { order: 1; }
  .lib-filterbar .lib-chip-groups { order: 3; }
  /* Scoped through .lib-chip-groups deliberately. It used to matter
     because the Current Workout selector was a .lib-chip-group inside
     this bar's display:contents wrapper, so a looser descendant selector
     hid the workout picker whenever filters were collapsed. That picker
     is in its own .lib-panel now and out of reach either way, but the
     container stays in the path: it's what keeps this rule aimed at the
     filter chips specifically rather than at any chip group that ends up
     in the bar later. */
  .lib-filterbar:not(.lib-filterbar-open) .lib-chip-groups .lib-chip-group:not(.lib-chip-group-primary),
  .lib-filterbar:not(.lib-filterbar-open) .lib-chip-groups .lib-clear-btn { display: none; }
}
/* The count of hidden filters currently doing something — without it,
   collapsing an ACTIVE filter would silently narrow the results with no
   visible cause. Set by wireCollapsibleFilterBar() in app.js. */
.lib-filterbar-count {
  font-variant-numeric: tabular-nums;
  background: var(--lib-accent);
  color: var(--accent-ink, #fff);
  border-radius: 999px;
  padding: 0 6px;
  min-width: 17px;
  text-align: center;
}
.lib-filterbar-count[hidden] { display: none; }
/* The floating twin: only relevant once the inline toggle above has
   scrolled out of view (see the IntersectionObserver in
   wireCollapsibleFilterBar()), which is also when the bar being
   non-sticky actually matters — this is what stays reachable instead. */
.lib-filter-fab { display: none; }
.mobile-mode .lib-filter-fab:not([hidden]) {
  display: flex;
  align-items: center;
  justify-content: center;
  position: fixed;
  right: calc(16px + env(safe-area-inset-right, 0px));
  bottom: calc(20px + env(safe-area-inset-bottom, 0px));
  z-index: 50;
  width: 48px;
  height: 48px;
  min-height: 0;
  padding: 0;
  border-radius: 999px;
  background: var(--lib-accent);
  border-color: var(--lib-accent);
  color: var(--lib-accent-ink);
  font-size: 18px;
  box-shadow: var(--shadow);
}
/* Clear of the fixed bottom tab bar (see body.has-bottom-nav's own
   84px #app padding) when one is present. */
body.has-bottom-nav .lib-filter-fab:not([hidden]) { bottom: calc(84px + 12px + env(safe-area-inset-bottom, 0px)); }
.lib-search-row { display: flex; gap: 10px; margin-bottom: 10px; }
.lib-search-box {
  flex: 1;
  display: flex;
  align-items: center;
  gap: 8px;
  background: var(--lib-panel);
  border: 1px solid var(--lib-line-strong);
  border-radius: 5px;
  padding: 0 12px;
  min-height: 40px;
}
.lib-search-box svg { flex: none; opacity: 0.55; color: var(--lib-muted); }
.lib-search-box input {
  border: 0; outline: 0; background: none; color: var(--lib-text);
  font-size: 14px; width: 100%; height: 100%; min-height: 0; padding: 0;
}
.lib-search-box input::placeholder { color: var(--lib-muted); }
.lib-result-count {
  display: flex; align-items: center; padding: 0 4px;
  font-family: var(--font-mono); font-size: 12px; color: var(--lib-muted);
  white-space: nowrap; font-variant-numeric: tabular-nums;
}
.lib-chip-groups { display: flex; gap: 16px; flex-wrap: wrap; align-items: center; }
.lib-chip-group { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
/* Plain section labels within the filter bar (Workouts, Library) — same
   mono/uppercase treatment as the mobile-only "Filters" toggle text
   above, but always visible and non-interactive: these just mark where
   one row group ends and the next begins. */
.lib-section-heading {
  font-family: var(--font-mono);
  font-size: 12px;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--lib-text);
  padding: 4px 2px 8px;
}
.lib-section-heading-spaced { margin-top: 20px; }
.lib-workout-row {
  display: flex;
  align-items: center;
  gap: 16px;
  flex-wrap: wrap;
  margin-top: 10px;
  padding-top: 10px;
  border-top: 1px solid var(--lib-line);
}
.lib-workout-controls { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; margin-left: auto; }
/* 29px — "Log cardio"'s own rendered height, measured live rather than
   computed from padding/font-size (line-height isn't declared here, so
   it inherits the browser default). Explicit max-height, not just
   relying on every one of this pill-button family's rules happening to
   share the same padding/font-size, since that consistency was
   previously emergent rather than enforced — nothing stopped a future
   addition from quietly growing past it. Applied to every .lib-ctrl-btn-
   family pill button (this rule plus .lib-clear-btn/.lib-update-badge/
   .lib-inline-btn/.lib-pack-chip/.workout-update-badge/the calendar-sync
   button below) — explicitly NOT .link-btn, a different, taller pill
   class used for Skip/Cancel/Dismiss/Back throughout the rest of the
   app, per explicit product decision to leave that one alone. */
.lib-ctrl-btn {
  display: inline-block;
  border: 1px solid var(--lib-line-strong);
  background: var(--lib-panel);
  color: var(--lib-text);
  border-radius: 999px;
  padding: 5px 14px;
  font-size: 12.5px;
  min-height: 0;
  max-height: 29px;
  text-decoration: none;
  cursor: pointer;
}
.lib-ctrl-btn:hover { border-color: var(--lib-accent); }
/* Explicit background/border-color here, not just color — without them
   the generic button.danger rule's solid var(--danger) background wins
   (element+class beats this rule's two classes for those properties but
   not for color), leaving orange-red text on a near-identical red fill
   that's only legible once :hover swaps in the soft wash below. */
.lib-ctrl-btn.danger { background: var(--lib-panel); border-color: var(--lib-accent-2); color: var(--lib-accent-2); }
.lib-ctrl-btn.danger:hover { border-color: var(--lib-accent-2); background: var(--lib-accent-2-soft); }
/* Toggled-on state for multi-select pill buttons (e.g. the starter-pack
   picker) — filled instead of just outlined, so which ones are currently
   selected reads at a glance. */
.lib-ctrl-btn.active { background: var(--lib-accent-soft); border-color: var(--lib-accent); color: var(--lib-accent-ink); }
.lib-ctrl-hint { font-size: 12px; color: var(--lib-muted); }
.lib-glabel {
  font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.08em;
  text-transform: uppercase; color: var(--lib-muted); margin-right: 2px;
}
select.lib-chip-select {
  appearance: none; -webkit-appearance: none;
  border: 1px solid var(--lib-line-strong);
  background: var(--lib-panel);
  color: var(--lib-text);
  border-radius: 999px;
  padding: 5px 26px 5px 12px;
  font-size: 12.5px;
  font-family: inherit;
  cursor: pointer;
  min-height: 0;
  width: auto;
  background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='10' height='6'><path d='M1 1l4 4 4-4' stroke='%239aa3b0' stroke-width='1.4' fill='none' stroke-linecap='round' stroke-linejoin='round'/></svg>");
  background-repeat: no-repeat;
  background-position: right 10px center;
}
.lib-clear-btn {
  border: 1px solid var(--lib-line-strong);
  background: var(--lib-panel);
  color: var(--lib-muted);
  border-radius: 999px;
  padding: 5px 12px;
  font-size: 12.5px;
  min-height: 0;
  max-height: 29px;
}
.lib-clear-btn:hover { color: var(--lib-text); border-color: var(--lib-accent); }

.lib-equipment-group { margin-top: 34px; }
.lib-equipment-group:first-child { margin-top: 0; }
/* Wraps: this row carries the equipment name, its count, and up to five
   action controls — on a phone the "+ Station" button was losing the
   fight for space and breaking across two lines inside its own 29px-tall
   pill, so the second line was clipped. Letting the row (and the action
   group) wrap gives the buttons a whole line of their own instead. */
.lib-equipment-head {
  display: flex; align-items: baseline; flex-wrap: wrap; gap: 10px;
  margin-bottom: 10px; padding-bottom: 8px; border-bottom: 2px solid var(--lib-line-strong);
}
.lib-equipment-head h1 { font-size: 18px; margin: 0; font-weight: 800; color: var(--lib-text); letter-spacing: -0.01em; min-width: 0; overflow-wrap: anywhere; }
.lib-manage-actions { display: flex; align-items: center; flex-wrap: wrap; gap: 6px; margin-left: auto; }

.lib-station-group { margin-top: 22px; }
.lib-station-group.lib-no-station { margin-top: 10px; }
.lib-station-head {
  display: flex; align-items: baseline; flex-wrap: wrap; gap: 10px;
  margin-bottom: 8px; padding-bottom: 6px; border-bottom: 1px solid var(--lib-line);
}
.lib-station-head h2 { min-width: 0; overflow-wrap: anywhere; }
.lib-station-dot { width: 9px; height: 9px; border-radius: 50%; flex: none; }
.lib-station-head h2 { font-size: 14.5px; margin: 0; font-weight: 700; color: var(--lib-text); }
.lib-station-head .lib-inline-icon-btn { margin-left: auto; }
.lib-count { font-family: var(--font-mono); font-size: 11.5px; color: var(--lib-muted); font-variant-numeric: tabular-nums; }

/* Update-from-catalog badge on the per-account Library screen's equipment
   header — same pill shape/idiom as .workout-update-badge (#17's Train
   card badge), just re-themed with the --lib-* tokens this screen uses
   instead of the shared ones. .updated is a non-interactive <span>, not
   a second button state, once the click has actually gone through. */
.lib-update-badge {
  flex: none;
  padding: 3px 10px;
  border-radius: 999px;
  border: 1px solid var(--lib-accent);
  background: transparent;
  color: var(--lib-accent);
  font-size: 11.5px;
  max-height: 29px;
  cursor: pointer;
}
.lib-update-badge:hover { background: var(--lib-accent); color: var(--lib-bg); }
.lib-update-badge:disabled { opacity: 0.6; cursor: default; }
.lib-update-badge.updated {
  border-color: var(--success);
  color: var(--success);
  cursor: default;
}

.lib-collapse-toggle {
  min-height: 0; min-width: 0; width: 20px; height: 20px; padding: 0; flex: none;
  border: none; background: none; color: var(--lib-muted);
  font-size: 13px; line-height: 1; cursor: pointer;
}
.lib-collapse-toggle:hover { color: var(--lib-accent); }

.lib-inline-btn {
  border: 1px solid var(--lib-line-strong);
  background: var(--lib-panel);
  color: var(--lib-text);
  border-radius: 999px;
  padding: 3px 10px;
  font-size: 11.5px;
  min-height: 0;
  max-height: 29px;
  /* max-height above clips anything that wraps, so the label must never
     break — the row it sits in wraps instead (.lib-equipment-head). */
  white-space: nowrap;
}
.lib-inline-btn:hover { border-color: var(--lib-accent); }
/* #77 — every one of these (edit/remove/feedback/rename/reorder/link/
   star icons across equipment, station, and exercise rows) is built via
   iconBtn(), which always prepends its own "icon-symbol" class. That
   collided with this rule at equal specificity (one class each) but lost
   on source order, so button.icon-symbol's much larger
   calc(var(--fs-button) * 1.4) — 28px at the default scale — was silently
   winning over the 12px intended here, overflowing this button's own
   24×24 box. The "button." prefix below matches icon-symbol's own
   element+class specificity so this rule's font-size reliably wins
   regardless of source order. display:flex centering (rather than
   relying on default button content flow) is what keeps every glyph —
   different emoji have different natural ascent/descent — centered the
   same way regardless of which one it is, so icons of the same action
   type land visually aligned row to row. */
button.lib-inline-icon-btn {
  display: inline-flex; align-items: center; justify-content: center;
  min-height: 0; min-width: 0; width: 24px; height: 24px; padding: 0;
  border: 1px solid var(--lib-line-strong); background: var(--lib-panel); color: var(--lib-muted);
  border-radius: 6px; font-size: 12px; line-height: 1;
}
.lib-inline-icon-btn:hover { color: var(--lib-text); border-color: var(--lib-accent); }
.lib-edit-cell { text-align: center; }

/* flex-wrap is load-bearing, not tidiness. This row holds seven buttons
   on the admin Equipment catalog tab, and at 375px it measured 612px of
   content in a 335px box with nothing scrolling — neither the row
   (overflow-x: visible) nor the page body. Manage patterns, Manage
   metrics, Resync exercise metrics and Export library were simply
   unreachable on a phone. Same failure the topbar note in CLAUDE.md
   describes; re-check this row's wrap if another button is added. */
.lib-admin-row { display: flex; flex-wrap: wrap; gap: 10px; margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--lib-line); }

.lib-station-inputs { display: flex; flex-direction: column; gap: 8px; margin-bottom: 12px; }
.lib-station-input-row { display: flex; align-items: center; gap: 8px; }
.lib-station-input-row input { flex: 1; }
.lib-remove-row-btn {
  flex: none; min-height: 0; width: 30px; height: 30px; padding: 0;
  border: 1px solid var(--border); background: var(--panel-2); color: var(--muted); border-radius: 5px;
}
.lib-remove-row-btn:hover { color: var(--danger); border-color: var(--danger); }

/* Label row + inline Select all/none actions above a checkbox grid or
   toggle-button grid (the import modal's equipment checklist and pack
   picker) - .field-header-actions' own buttons override .link-btn's
   default block/centered layout, since here they sit inline next to a
   label rather than standalone under a form. Now that the base
   .link-btn is a full pill button, these get a smaller pill (matching
   .lib-inline-btn's scale) instead of losing their border/padding. */
.field-row-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
.field-header-actions { display: flex; gap: 12px; }
.field-header-actions .link-btn {
  display: inline-block; width: auto; margin: 0; padding: 3px 12px; font-size: 12px; min-height: 0;
}
.lib-muscle-check-grid {
  display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
  gap: 6px 12px; margin-bottom: 18px;
}
.lib-pack-grid { display: flex; flex-wrap: wrap; gap: 8px; margin-bottom: 18px; }
/* #85 — selected-equipment chips above the Import Library search results;
   same pill shape as .lib-ctrl-btn.active but with a visible remove (✕)
   affordance baked into the label rather than a separate button. */
.lib-pack-chips { display: flex; flex-wrap: wrap; gap: 8px; margin: 10px 0; }
.lib-pack-chips:empty { display: none; }
.lib-pack-chip {
  background: var(--accent); color: var(--accent-ink);
  border: none; border-radius: 999px; padding: 6px 12px;
  font-size: 13px; cursor: pointer;
  max-height: 29px;
}
.lib-pack-chip:hover { opacity: 0.85; }
.lib-muscle-check { display: flex; align-items: center; gap: 6px; font-size: 13px; color: var(--text); cursor: pointer; }
.lib-muscle-check input { min-height: 0; width: auto; margin: 0; cursor: pointer; }

.lib-muscle-manage-list { max-height: 320px; overflow-y: auto; display: flex; flex-direction: column; gap: 6px; margin-bottom: 4px; }
.lib-muscle-manage-row { display: flex; align-items: center; gap: 8px; }
.lib-muscle-manage-row input[type="text"] { flex: 1; }
/* Manage metrics (#36) reuses the same row/list shell but needs three
   fields instead of one — name gets most of the width, unit and the
   input-type select stay narrow and fixed so a long metric name doesn't
   crowd them out. */
.lib-metric-manage-row input.lib-metric-name { flex: 2; min-width: 0; }
.lib-metric-manage-row input.lib-metric-unit { flex: 0 0 70px; min-width: 0; }
.lib-metric-manage-row select { flex: 0 0 110px; min-width: 0; }

.lib-table-scroll { overflow-x: auto; }
table.lib-ex-table { width: 100%; border-collapse: collapse; font-size: 13px; }
.lib-ex-table th {
  text-align: left; font-family: var(--font-mono); font-size: 10px; letter-spacing: 0.06em;
  text-transform: uppercase; color: var(--lib-muted); font-weight: 600; padding: 6px 8px;
  border-bottom: 1px solid var(--lib-line); white-space: nowrap;
}
.lib-ex-table td { padding: 9px 8px; border-bottom: 1px solid var(--lib-line); vertical-align: top; color: var(--lib-text); }
.lib-ex-table tr:hover td { background: var(--lib-panel-2); }
.lib-ex-table tr.lib-in-workout td { background: var(--lib-accent-soft); }
.lib-ex-table tr.lib-in-workout:hover td { background: var(--lib-accent-soft); }
.lib-ex-name { font-weight: 600; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.lib-ex-note { color: var(--lib-muted); font-size: 12px; margin-top: 2px; }
.lib-unlocked-note { color: var(--lib-accent-ink); font-weight: 600; }
.lib-muscle-cell { display: flex; flex-direction: column; gap: 4px; }
.lib-tagset { display: flex; flex-wrap: wrap; gap: 4px; }
.lib-tag { font-size: 10.5px; padding: 2px 7px; border-radius: 999px; white-space: nowrap; }
.lib-tag.primary { background: var(--lib-accent-soft); color: var(--lib-accent-ink); font-weight: 600; }
.lib-tag.secondary { background: var(--lib-panel-2); color: var(--lib-muted); border: 1px solid var(--lib-line); }
.lib-defaults-cell { font-family: var(--font-mono); font-variant-numeric: tabular-nums; font-size: 12px; white-space: nowrap; }
.lib-badge-time {
  display: inline-block; font-family: var(--font-mono); font-size: 9.5px; letter-spacing: 0.04em;
  text-transform: uppercase; background: var(--lib-accent-2-soft); color: var(--lib-accent-2);
  border-radius: 4px; padding: 1px 5px;
}
.lib-weight-tag { color: var(--lib-muted); font-size: 12px; }
.lib-pattern-cell { color: var(--lib-muted); font-size: 12px; white-space: nowrap; }
.lib-add-cell { text-align: center; }
.lib-add-btn {
  min-height: 0; min-width: 0;
  width: 28px; height: 28px;
  padding: 0;
  border-radius: 5px;
  background: var(--lib-panel-2);
  border: 1px solid var(--lib-line-strong);
  color: var(--lib-accent-ink);
  font-size: 16px;
  font-weight: 700;
  line-height: 1;
}
.lib-add-btn:hover { background: var(--lib-accent-soft); border-color: var(--lib-accent); }
.lib-add-btn.remove { color: var(--lib-accent-2); }
.lib-add-btn.remove:hover { background: var(--lib-accent-2-soft); border-color: var(--lib-accent-2); }
/* One-shot confirmation on the exercise just added — see
   justToggledExerciseId in renderLibrary()'s addExercise(). The existing
   toast() already confirms off-row, near the bottom of the screen; this
   closes the loop right where attention already is, on the button
   itself, without needing to look away from the row just tapped. */
.lib-add-btn.just-added { animation: lib-add-btn-pulse 500ms var(--ease-settle) both; }
@keyframes lib-add-btn-pulse {
  0% { transform: scale(1); background: var(--lib-accent); border-color: var(--lib-accent); color: var(--lib-accent-ink); }
  100% { transform: scale(1); }
}
@media (prefers-reduced-motion: reduce) {
  .lib-add-btn.just-added { animation: none; }
}
.lib-readonly-mark {
  display: inline-flex; align-items: center; justify-content: center;
  width: 28px; height: 28px; color: var(--lib-accent-ink); font-weight: 700;
}

.lib-empty-state { text-align: center; padding: 50px 20px; color: var(--lib-muted); }
.lib-empty-big { font-size: 26px; margin-bottom: 6px; }
/* IMG-04 — replaces the plain "—" glyph most .lib-empty-state blocks used
   to show above their text. Sized to occupy roughly the "~40% of frame"
   the spec calls for against this block's own generous padding, not
   against the whole screen. */
.empty-state-icon { display: block; width: 64px; height: 48px; margin: 0 auto 14px; color: var(--lib-muted); }
/* Notifications' own empty state sits on a plain .settings-card (not a
   .lib-empty-state block), so it needs its own center/padding treatment
   to actually read as an illustrated empty state and not just an icon
   floating above left-aligned text. */
#notif-empty-state { text-align: center; padding: 40px 20px; }

@media (max-width: 720px) {
  .lib-plate { padding: 16px; }
  .lib-h1 { font-size: 19px; }
  .lib-workout-controls { margin-left: 0; }
  .lib-ex-table thead { display: none; }
  .lib-ex-table, .lib-ex-table tbody, .lib-ex-table tr, .lib-ex-table td { display: block; width: 100%; }
  .lib-ex-table tr {
    border: 1px solid var(--lib-line);
    border-radius: 5px;
    margin-bottom: 8px;
    padding: 8px 10px;
    background: var(--lib-panel);
  }
  .lib-ex-table td { border: 0; padding: 3px 0; }
  .lib-ex-table td::before {
    content: attr(data-label);
    display: block;
    font-family: var(--font-mono);
    font-size: 9px;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--lib-muted);
    margin-top: 6px;
  }
  .lib-ex-table td:first-child::before { display: none; }
  .lib-add-cell, .lib-edit-cell { text-align: left; }
}

/* keep timer visible on wide screens */
@media (min-width: 1100px) {
  .screen-runner {
    display: grid;
    grid-template-columns: 1fr minmax(440px, 600px);
    grid-template-rows: auto 1fr;
    gap: 20px;
    align-items: start;
  }
  /* .runner-progress-row (the "i" toggle + dots/pills, #67) sits between
     .runner-top and .exercise-card in the DOM — without an explicit
     column span it consumes a cell in the grid's auto-placement, which
     pushes .exercise-card into column 2 and bumps .timer-panel down into
     row 3, column 1 (left of and below the exercise instead of beside
     it). Spanning it full-width like .runner-top keeps .exercise-card/
     .timer-panel as the row's only two real cells. Targets
     .runner-progress-row, not .runner-progress itself — that inner div
     is display: contents now (its dot/pill children merge straight into
     the row's own flex-wrap), so it no longer participates in the grid
     as its own item. */
  .screen-runner .runner-top,
  .screen-runner .runner-progress-row { grid-column: 1 / -1; }
  .screen-runner .exercise-card { margin-bottom: 0; }
  /* The right column becomes a real box here and takes over the sticky
     that used to live on .timer-panel itself, so the timer AND Prev/Next
     pin together and both stay in shot while the exercise card scrolls
     beside them. Prev/Next used to sit at the bottom of that card, which
     on a long exercise meant scrolling away from the timer to reach it.
     column-reverse because the DOM order is nav-then-timer: that order
     is what keeps the sub-1100px flow (card, nav, timer) untouched, and
     reversing it here puts the timer on top with the nav beneath. */
  .runner-side {
    display: flex;
    flex-direction: column-reverse;
    gap: 20px;
    position: sticky;
    top: 80px;
  }
  /* The wrapper is the sticky element now; leaving this sticky too would
     make it stick within an already-stuck parent. */
  .screen-runner .timer-panel { position: static; }
  /* Safe to go glass on desktop specifically: the two columns are
     side by side, so nothing scrolls BEHIND the timer here — which is
     the whole reason it stays solid on mobile, where it is a sticky
     peek strip floating over the scrolling card. */
  .screen-runner .timer-panel { background: var(--lib-panel); border-color: var(--lib-line); }
}

/* ---------- Admin screen ---------- */
/* ---- profile accent picker (create + edit profile) ---- */
.accent-field { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.accent-field input[type="color"] { flex: none; }
.accent-presets { display: flex; gap: 6px; flex-wrap: wrap; }
/* Presets are shortcuts into the same free picker, not a restricted set
   -- they write into the colour input rather than being a separate
   mode, so a preset and a hand-picked colour are indistinguishable
   downstream. */
.accent-preset {
  flex: none;
  width: 26px;
  height: 26px;
  min-height: 0;
  padding: 0;
  border-radius: 50%;
  background: var(--swatch);
  border: 2px solid transparent;
  cursor: pointer;
}
.accent-preset:hover { border-color: var(--text); transform: none; }
/* The one saved/bookmarked colour, kept visually distinct from the six
   fixed brand presets — a persistent border (not transparent-until-
   hover like the others) plus a small star badge, so it reads as "your
   saved colour" rather than a seventh generic swatch. */
.accent-saved-slot:empty { display: none; }
.accent-preset-saved {
  border-color: var(--text);
  position: relative;
}
.accent-preset-saved::after {
  content: '★';
  position: absolute;
  inset: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 11px;
  color: #fff;
  text-shadow: 0 0 2px rgba(0, 0, 0, 0.6);
}
/* Overrides .link-btn's block/centered/margin defaults (built for a
   standalone link under a form) so this sits inline in the accent-field
   row instead. */
.accent-save-btn { display: inline-block; width: auto; margin: 0; padding: 4px 12px; font-size: calc(13px * var(--scale)); }

/* ---- appearance toggle in the header ---- */
.icon-btn.icon-only { padding: 8px 10px; }
.icon-btn.icon-only .icon-glyph { font-size: 1.05em; }

/* ---- notification bell (see docs/pt-sponsored-plan.md) ----
   Sits directly beside the appearance toggle in the topbar (same
   .icon-btn.icon-only sizing), so it needs `position: relative` purely
   to anchor the badge — nothing else about its own layout changes. */
.notif-bell { position: relative; }
.notif-badge {
  position: absolute;
  top: 2px;
  right: 2px;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  border-radius: 999px;
  background: var(--accent-secondary, #d1461f);
  color: #fff;
  font-family: var(--font-mono, monospace);
  font-size: 10px;
  line-height: 16px;
  text-align: center;
  font-weight: 700;
  pointer-events: none;
}

.admin-inline-form {
  display: flex;
  gap: 10px;
  flex-wrap: wrap;
  align-items: center;
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: 5px;
  padding: 14px;
  margin-bottom: 16px;
}
.admin-inline-form input:not([type="checkbox"]):not([type="radio"]),
.admin-inline-form select,
.admin-inline-form textarea {
  flex: 1 1 180px;
}

/* Feature toggles — a single rolled-up <details> holding every site-wide
   on/off switch, so N kill-switches don't each cost their own heading +
   block below the Users tab. Collapsed to just "Feature toggles" until
   opened; add any future one as another .feature-toggle-row inside the
   existing .feature-toggle-list rather than a new admin-subheading
   section of its own. */
.admin-feature-toggles { margin: 24px 0 10px; }
.admin-feature-toggles > summary {
  cursor: pointer;
  display: flex;
  align-items: center;
  gap: 6px;
  font-size: 15px;
  color: var(--lib-muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  list-style: none;
}
.admin-feature-toggles > summary::-webkit-details-marker { display: none; }
.admin-feature-toggles > summary::before {
  content: '›';
  display: inline-block;
  font-size: 16px;
  transition: transform var(--duration-fast, 150ms);
}
.admin-feature-toggles[open] > summary::before { transform: rotate(90deg); }
.feature-toggle-list { display: flex; flex-direction: column; gap: 10px; margin-top: 12px; }
.feature-toggle-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: nowrap;
  gap: 12px;
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: 5px;
  padding: 12px 14px;
}
/* Label + its info bubble stay one line at any reasonable width — this
   is the "checkbox and description on one line" fix: the old layout put
   a checkbox styled via .admin-inline-form input{flex:1 1 180px}, which
   forced the (invisible, ~13px) checkbox itself to claim 180px of flex
   basis and pushed the label text onto its own line beneath it. */
.feature-toggle-text {
  display: flex;
  align-items: center;
  gap: 4px;
  flex: 1 1 auto;
  min-width: 0;
  font-size: 13px;
  color: var(--lib-text);
}
.feature-toggle-status { flex: none; }

/* A real toggle switch (not a checkbox styled to look vaguely like one)
   — the checkbox itself stays in the DOM and fully functional (checked
   state, change events, keyboard/focus), just visually replaced by the
   track+thumb sibling it drives via :checked. */
.toggle-switch { position: relative; display: inline-flex; flex: none; width: 38px; height: 22px; }
.toggle-switch input {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  margin: 0;
  opacity: 0;
  cursor: pointer;
  z-index: 1;
}
.toggle-switch-track {
  position: absolute;
  inset: 0;
  background: var(--lib-line-strong);
  border-radius: 999px;
  transition: background var(--duration-fast, 150ms);
}
.toggle-switch-track::after {
  content: '';
  position: absolute;
  top: 2px;
  left: 2px;
  width: 18px;
  height: 18px;
  border-radius: 50%;
  background: var(--lib-bg);
  transition: transform var(--duration-fast, 150ms);
}
.toggle-switch input:checked + .toggle-switch-track { background: var(--lib-accent); }
.toggle-switch input:checked + .toggle-switch-track::after { transform: translateX(16px); }
.toggle-switch input:focus-visible + .toggle-switch-track { outline: 2px solid var(--lib-accent); outline-offset: 2px; }
.toggle-switch input:disabled { cursor: not-allowed; }
.toggle-switch input:disabled + .toggle-switch-track { opacity: 0.5; }
.admin-subheading {
  margin: 24px 0 10px;
  font-size: 15px;
  color: var(--lib-muted);
  text-transform: uppercase;
  letter-spacing: 0.05em;
}
/* #42 — pairs a subheading with the buttons that open its modals on the
   same line, instead of each getting its own full section below. */
.admin-subheading-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  margin: 24px 0 10px;
}
.admin-subheading-row .admin-subheading { margin: 0; }
/* A lighter-weight label between the two Plans tables (personal,
   trainer) — enough to separate them visually without the heavier
   uppercase/tracked treatment .admin-subheading uses for whole tab
   sections. */
.admin-subheading-minor {
  margin: 20px 0 8px;
  font-size: 13px;
  font-weight: 600;
  color: var(--muted);
}
.admin-subheading-actions { display: flex; gap: 8px; flex-wrap: wrap; }
.admin-users-toolbar { display: flex; justify-content: flex-end; margin-bottom: 12px; }
.admin-user-row {
  display: flex;
  align-items: center;
  gap: 14px;
  flex-wrap: wrap;
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: 5px;
  padding: 10px 14px;
  margin-bottom: 8px;
}
/* Wraps rather than overflowing: an admin account with both badges shows
   email + "admin" + "plan locked" on one line, which is already wider
   than a 375px row before the email itself is counted. min-width: 0 on
   the flex child is what actually lets the email shrink (an email is one
   unbreakable token — the same flex min-width:auto footgun documented in
   CLAUDE.md), and the badges stay whole rather than breaking mid-word. */
.admin-user-main { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; min-width: 0; }
.admin-user-main strong { min-width: 0; overflow-wrap: anywhere; }
.admin-user-meta { color: var(--lib-muted); font-size: 12.5px; }
.admin-user-actions { display: flex; align-items: center; gap: 8px; flex-wrap: wrap; margin-left: auto; }
/* The single edit-icon action on the far right of a user row (replaces
   the old inline plan select + admin/disable/reset buttons — those now
   live in openEditUserModal). */
.admin-user-edit { margin-left: auto; flex: none; }
/* Stacked full-width, not the inline-pill row .lib-ctrl-btn defaults to
   — three buttons wrapping mid-word inside a ~440px modal read worse
   than one per line. */
.admin-edit-user-actions { display: flex; flex-direction: column; gap: 8px; margin: 18px 0; }
.admin-edit-user-actions .lib-ctrl-btn { width: 100%; }
.admin-badge {
  display: inline-block;
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 2px 8px;
  border-radius: 999px;
  background: var(--lib-accent-soft);
  color: var(--lib-accent-ink);
  white-space: nowrap;
}
.admin-badge-danger { background: var(--lib-accent-2-soft); color: var(--lib-accent-2); }
/* #92 — flags a plan row whose price_gbp/price_usd/price_eur fields are
   currently being overridden by a live Stripe Price for real display. */
.admin-badge-live {
  margin-left: 8px;
  background: color-mix(in srgb, var(--success) 18%, var(--lib-panel));
  color: var(--success);
}
.admin-draft-card {
  background: var(--lib-panel-2);
  border: 1px solid var(--lib-line-strong);
  border-radius: 5px;
  padding: 16px;
  margin-bottom: 16px;
}
.admin-draft-exercise-list { margin: 10px 0; padding-left: 20px; color: var(--lib-text); font-size: 13.5px; }
.admin-draft-exercise-list li { margin-bottom: 4px; }

.admin-submission-card {
  background: var(--lib-panel-2);
  border: 1px solid var(--lib-line-strong);
  border-radius: 5px;
  padding: 14px 16px;
  margin-bottom: 14px;
}
.admin-submission-meta { color: var(--lib-muted); font-size: 13px; margin: 4px 0 10px; }
.admin-suggestion-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 6px 0;
  border-bottom: 1px solid var(--lib-line);
}
.admin-suggestion-row:last-child { border-bottom: none; }
/* The generator slot-editor form (buildSlotForm in app.js) used to reuse
   .admin-suggestion-row as its outer wrapper — a display:flex row meant
   for the simple label+controls pair every other user of that class has.
   Here it wrapped two full-width .modal-grid blocks instead: as flex
   items with no explicit basis, each grid shrank to its own
   content-minimum (one 220px column) rather than claiming the row's
   full width, collapsing the intended 3-column Pattern/Muscle/Label and
   Reps/Sets/Rest layout into six fields stacked in a cramped 220px lane.
   This is a plain block wrapper instead, so its .modal-grid children
   each get the full available width; the tighter minmax below (160px,
   vs. the shared .modal-grid's 220px) is also needed — this form's
   fields are all short selects/number inputs, not the wider text
   fields the default minmax was sized for, and 220px×3 doesn't fit
   .modal-wide's ~584px content width anyway. */
.admin-slot-form {
  padding: 10px 0;
  border-bottom: 1px solid var(--lib-line);
}
.admin-slot-form:last-child { border-bottom: none; }
.admin-slot-form .modal-grid { grid-template-columns: repeat(auto-fill, minmax(160px, 1fr)); }
.admin-insights-grid {
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 20px;
}
@media (max-width: 800px) {
  .admin-insights-grid { grid-template-columns: 1fr; }
}
.admin-insights-table {
  width: 100%;
  border-collapse: collapse;
  font-size: 13px;
}
.admin-insights-table th, .admin-insights-table td {
  text-align: left;
  padding: 6px 8px;
  border-bottom: 1px solid var(--lib-line);
}
.admin-insights-table th { color: var(--lib-muted); font-weight: 600; }
.admin-insights-zero td { color: var(--danger); font-weight: 600; }

/* ==========================================================
   Chart kit + shared UI vocabulary
   ----------------------------------------------------------
   Styles for the primitives in public/app.js's "Chart kit",
   "Motion" and "Shared UI vocabulary" sections. Deliberately NOT
   scoped to a screen — these are meant to be dropped anywhere,
   unlike the older .progress-chart block above which predates the
   kit and stays as-is to avoid regressing the two screens using it.
   ========================================================== */

/* ---- sparkline: axis-less trend, sized to sit inside a card ---- */
.sparkline { display: block; width: 100%; max-width: 140px; height: auto; overflow: visible; }
.sparkline-line { fill: none; stroke: var(--accent); stroke-width: 2; stroke-linecap: round; stroke-linejoin: round; }
.sparkline-head { fill: var(--accent); }
/* Placeholder so a card with too few points to plot keeps its layout
   height instead of collapsing and shifting everything around it. */
.sparkline-empty { display: block; height: 32px; }

/* ---- bar chart ---- */
.bar-chart {
  display: flex;
  align-items: flex-end;
  gap: 10px;
  height: 120px;
  /* F-05 — position context for the gridline layer, and left padding so
     the axis labels have somewhere to sit without overlapping bar one. */
  position: relative;
  padding-left: 34px;
}
/* F-05 — the scale the bars are read against. Pointer-events off so it
   never intercepts a bar's own hover tooltip. */
.bar-chart-grid {
  position: absolute;
  inset: 0 0 0 34px;
  pointer-events: none;
}
.bar-chart-gridline {
  position: absolute;
  left: 0; right: 0;
  border-top: 1px dashed var(--border);
  opacity: 0.5;
}
/* The baseline is a real axis, not another gridline — solid and full
   strength so the bars visibly stand on something. */
.bar-chart-gridline:last-child { border-top-style: solid; opacity: 0.9; }
.bar-chart-axis {
  position: absolute;
  right: calc(100% + 6px);
  top: -0.6em;
  font-family: var(--font-mono);
  font-size: 9px;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
  white-space: nowrap;
}
.bar-chart-col {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 6px;
  height: 100%;
}
.bar-chart-track { flex: 1; width: 100%; display: flex; align-items: flex-end; min-height: 0; }
/* F-05 — was a 100%-saturation accent block, which at five bars wide
   read as five slabs of colour rather than a chart. A translucent body
   with a solid cap keeps the accent doing the work at the one place that
   carries the value (the top edge) while letting the gridlines behind
   show through the mass of the bar. */
.bar-chart-fill {
  position: relative;
  width: 100%;
  background: color-mix(in srgb, var(--accent) 34%, transparent);
  border-top: 2px solid var(--accent);
  border-radius: 3px 3px 0 0;
  min-height: 2px;
}
/* #57 — the number+unit driving each bar, shown above it rather than
   only on hover. Anchored to the fill (not the track), so it tracks
   wherever the bar's own current top edge is. */
.bar-chart-value {
  position: absolute;
  bottom: 100%;
  left: 50%;
  transform: translateX(-50%);
  margin-bottom: 4px;
  font-family: var(--font-mono);
  font-size: 10px;
  font-variant-numeric: tabular-nums;
  /* F-05 — muted rather than full --text now that gridlines carry the
     scale: the per-bar figure is the precise read (and the only one
     available on touch, where the hover tooltip never fires), but it
     shouldn't compete with the bars themselves for attention. */
  color: var(--muted);
  white-space: nowrap;
}
.bar-chart-label {
  font-size: 10px;
  color: var(--muted);
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  max-width: 100%;
}

/* ---- heatmap (consistency / calendar grid) ---- */
/* gap kept small since the one caller (Overview's consistency grid) now
   runs 28 columns to fit three rows — a 4px gap across that many tracks
   left less room for the cells themselves than for the gaps between
   them. */
.heatmap-grid { display: grid; gap: 2px; }
.heatmap-cell { aspect-ratio: 1; border-radius: 2px; background: var(--panel-2); }
/* Level 0 is "nothing logged" and stays the plain surface — the ramp
   only begins once there's something to show, so an empty week reads as
   genuinely empty rather than as a faint amount of work. */
.heatmap-cell.level-0 { background: var(--panel-2); }
.heatmap-cell.level-1 { background: color-mix(in srgb, var(--accent) 28%, var(--panel-2)); }
.heatmap-cell.level-2 { background: color-mix(in srgb, var(--accent) 52%, var(--panel-2)); }
.heatmap-cell.level-3 { background: color-mix(in srgb, var(--accent) 76%, var(--panel-2)); }
.heatmap-cell.level-4 { background: var(--accent); }
/* A missed scheduled day, in 'scheduled' streak mode. An inset ring
   rather than a background, because it has to coexist with whatever
   intensity the cell already carries — a day where you trained something
   other than what was due is both a real session and a real miss, and
   overwriting the fill would lose one of those. --danger, not the accent:
   semantic state, deliberately a different channel from the amber
   intensity ramp so it can't be misread as "more training". */
/* An underline rather than a ring around the whole cell: the cell's fill
   is already carrying the day's training intensity, and a full border
   competed with it for the same shape — two enclosing marks on one
   square read as one heavier square rather than as two facts.
   An inset shadow, not border-bottom, for two reasons: the cell is sized
   by aspect-ratio, so a real border would change its box; and an inset
   shadow composites OVER whatever background the cell has. That second
   point is load-bearing — the obvious `background: color-mix(danger,
   <the cell's own token>)` approach silently fails on the Progress
   screen, which repoints heatmap backgrounds at the translucent --lib-*
   set, where mixing into one yields a 0.25-alpha fill measuring 1.27:1.
   Compositing sidesteps the token system, so this needs no per-screen
   variant and works the same at every intensity level. */
.heatmap-cell.missed { box-shadow: inset 0 -3px 0 0 var(--danger); }
/* A dark edge directly above the red, on INTENSITY-BEARING CELLS ONLY,
   and it is not decoration. --danger and --accent are both warm
   reds/oranges of nearly equal luminance — measured 1.16:1 against each
   other — so on a level-4 cell the underline alone is invisible, which
   is precisely the case this marker exists for (you trained, just not
   the thing that was scheduled). The dark band separates the two so the
   red reads against it rather than against the fill. Earlier shadows
   paint on top, so the 3px red sits over the 5px dark band and 2px of it
   shows through as the separator. 0.8 rather than something softer
   because both edges have to clear 3:1: over the amber fill the
   separator reads 3.96:1 against it and the red 3.42:1 against the
   separator, where 0.55 gave 2.74 and 2.36 — a band you could half-see.

   :not(.level-0) is the fix for #104. This was applied to every missed
   cell, on the reasoning that the separator "all but vanishes" on an
   empty one — which was measured in dark mode only, where an empty cell
   is near-black and a black band on it genuinely is invisible. In light
   mode an empty cell is near-white, so the exact same band is 2px of
   near-black sitting above the red: the "dark area above the red mark"
   as reported. There is nothing to separate on a cell with no fill, so
   it simply shouldn't paint there — the red already reads on its own
   (3.13:1 dark, higher in light). Measure both themes when a rule mixes
   a fixed rgba() with theme-aware tokens; that is the whole class of
   bug here. */
.heatmap-cell.missed:not(.level-0) {
  box-shadow:
    inset 0 -3px 0 0 var(--danger),
    inset 0 -5px 0 0 rgba(0, 0, 0, 0.8);
}
/* F-14 — range anchors at the ends, intensity key in the middle, all on
   one caption row. space-between keeps the anchors pinned to the grid's
   own edges so they read as labelling its start and end. */
.heatmap-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  margin-top: 8px;
  flex-wrap: wrap;
}
.heatmap-range {
  font-family: var(--font-mono);
  font-size: 10px;
  font-variant-numeric: tabular-nums;
  color: var(--muted);
}
.heatmap-legend { display: flex; align-items: center; gap: 4px; }
.heatmap-legend-label {
  font-family: var(--font-mono);
  font-size: 10px;
  color: var(--muted);
  margin: 0 2px;
}
/* Fixed size: these are a key, not data, so they must not inherit the
   grid's aspect-ratio-driven sizing. */
.heatmap-legend-cell { width: 10px; height: 10px; aspect-ratio: auto; flex: none; }

/* ---- donut ---- */
.donut-with-legend { display: flex; align-items: center; gap: 20px; flex-wrap: wrap; }
.donut-wrap { position: relative; display: inline-flex; align-items: center; justify-content: center; flex: none; }
.donut { display: block; max-width: 100%; height: auto; }
.donut-seg { stroke: var(--accent); }
.donut-legend { display: flex; flex-direction: column; gap: 8px; min-width: 0; }
/* #58 — column-count is set inline per render (donut() in app.js: 1
   column up to 6 segments, 2 up to 12, and so on) so a long legend
   (many cardio exercises, a big muscle-group donut) reads two or three
   across instead of one long list beside a comparatively compact ring.
   A flex container ignores column-count entirely, hence the block
   override here; break-inside: avoid (below) keeps one row from being
   visually split across a column boundary. */
.donut-legend-multicol { display: block; column-gap: 20px; }
.donut-legend-row { display: flex; align-items: center; gap: 8px; font-size: 13px; }
.donut-legend-multicol .donut-legend-row { break-inside: avoid; margin-bottom: 8px; }
.donut-legend-swatch { width: 11px; height: 11px; border-radius: 3px; background: var(--accent); flex: none; }
.donut-legend-label { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.donut-legend-value { margin-left: auto; padding-left: 12px; color: var(--muted); white-space: nowrap; }
.donut-center {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-weight: 700;
  font-size: 20px;
  color: var(--text);
  pointer-events: none;
}

/* ---- stat card ---- */
.stat-card {
  display: flex;
  flex-direction: column;
  gap: 4px;
  background: var(--lib-panel);
  border: 1px solid var(--lib-line);
  border-radius: var(--radius-card);
  padding: 16px 18px;
  min-width: 0;
}
.stat-card-label {
  font-size: 11px;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}
/* #59 — infoTip()'s bubble already resolves its --lib-* tokens from
   :root regardless of which screen it's dropped into; this is just
   spacing so it doesn't crowd the text it's next to. */
.stat-card-label .info-bubble,
th .info-bubble { margin-left: 4px; vertical-align: middle; }
.stat-card-figure { display: flex; align-items: baseline; gap: 4px; min-width: 0; }
.stat-card-value {
  font-family: var(--font-mono);
  /* tabular-nums matters more than it looks: without it, digits change
     width as countUp() runs and the whole card jitters while animating. */
  font-variant-numeric: tabular-nums;
  /* Fluid rather than a fixed 34px: a six-figure volume ("186,400") in a
     two-up grid on a 375px phone overflowed the card and collided with
     its neighbour. Scales down with the viewport and stops at 34px on
     desktop, so the headline stays big where there's room for it. */
  font-size: clamp(20px, 5.5vw, 34px);
  font-weight: 700;
  line-height: 1.05;
  color: var(--text);
  min-width: 0;
}
.stat-card-unit { font-size: 13px; color: var(--muted); flex: none; }

/* ---- delta chip ---- */
.delta-chip {
  display: inline-flex;
  align-items: center;
  align-self: flex-start;
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: 12px;
  font-weight: 600;
  padding: 2px 8px;
  border-radius: 999px;
}
/* Direction is carried by the +/− sign as well as the colour, so this
   still reads correctly in greyscale or to anyone who can't distinguish
   the two hues. */
.delta-chip.good { color: var(--success); background: color-mix(in srgb, var(--success) 14%, transparent); }
.delta-chip.bad { color: var(--danger); background: color-mix(in srgb, var(--danger) 14%, transparent); }
.delta-chip.flat { color: var(--muted); background: var(--panel-2); }

/* ---- confirm dialog ---- */
.confirm-modal { max-width: 400px; }
.confirm-modal h2 { margin-bottom: 8px; }
.confirm-modal .hint { margin-bottom: 20px; }
.confirm-actions { display: flex; gap: 10px; justify-content: flex-end; flex-wrap: wrap; }
.confirm-actions button { flex: 1 1 auto; min-width: 120px; }

/* ---- celebration burst ---- */
.celebrate-layer {
  position: fixed;
  width: 0;
  height: 0;
  /* Above the modal layer so a PR landing during a dialog still shows,
     and never interactive — it must not eat the tap for the next set. */
  z-index: 300;
  pointer-events: none;
}
.celebrate-bit {
  position: absolute;
  width: 7px;
  height: 7px;
  border-radius: 2px;
  background: var(--accent);
}
.celebrate-bit:nth-child(3n) { background: var(--accent-2); }
.celebrate-bit:nth-child(3n + 1) { background: var(--success); }
