/* =========================================================================
   MYONIC - stylesheet
   Sections: 1 variables (1b theme overrides) · 2 base · 3 header & timers
             4 notifications · 5 layout · 6 exercise cards · 7 start-page tools
             · 8 logs · 9 panels · 10 install/update · 11 animations
   ========================================================================= */


/* ============================= 1 · Variables ============================= */
:root {
    /* Surfaces - three flat, distinct elevation tiers (no gradients/washes):
       page canvas, in-flow card, and a slightly lighter raised tier for
       nested/secondary surfaces (e.g. toggle tracks). */
    --color-background: rgb(0, 0, 0);
    --color-background-header: rgb(0, 0, 0);
    --color-background-panel: rgb(0, 0, 0);
    --color-surface: rgb(12, 12, 12);
    --color-surface-raised: rgb(22, 22, 22);
    --color-background-log-entry: rgb(243, 244, 246);
    /* Fixed dark ink for text on top of --color-background-log-entry, which
       stays light in every theme - unlike --color-text-dark (below), this is
       never overridden per theme. */
    --color-ink: rgb(17, 17, 19);

    /* Borders - neutral hairlines, used for structure. Reserve the accent
       border for genuinely accent-colored controls (see below). */
    --color-border: rgba(255, 255, 255, 0.08);
    --color-border-strong: rgba(255, 255, 255, 0.16);

    /* Text */
    --color-text: rgb(237, 237, 239);
    --color-text-dark: rgb(10, 10, 10);
    --color-text-muted: rgb(138, 138, 144);
    /* Emphasis text (stat values, key numbers) - shares the single accent
       hue rather than a separate "info" color. */
    --color-highlight: rgb(198, 241, 53);

    /* Primary accent - one signal color (lime), reserved for the primary
       CTA, completed/active states, and emphasized numbers. Lime + signal:
       almost everything else in the app is grayscale. */
    --color-accent: rgb(198, 241, 53);
    --color-accent-border: rgba(198, 241, 53, 0.4);
    --color-accent-fill: rgba(198, 241, 53, 0.14);
    /* Ink for text/icons sitting on a solid fill of the accent - which
       --color-success and the set-state tokens all alias, so this covers them
       too. Defaults to --color-text-dark, and that is the whole answer on the
       six themes whose accent is bright; the four light themes darkened
       theirs to read as text on white and override this to white, because
       dark-on-dark vanishes there. Reach for it, not --color-text-dark,
       whenever the thing behind the ink is one of those fills: -text-dark is
       for ink on a light *surface*, and the two agree only by coincidence on
       the bright-accent themes. */
    --color-on-accent: var(--color-text-dark);

    /* State colors - completed/pending/success all alias the one accent;
       partial is a neutral bright fill rather than a second hue, so the
       three set states read as outline / neutral-fill / accent-fill instead
       of a rainbow. Danger stays real red - the one unavoidable second hue,
       reserved strictly for destructive actions. */
    --color-yes: rgb(198, 241, 53);
    --color-no: rgb(198, 241, 53);
    --color-pending: rgb(198, 241, 53);
    --color-completed: rgb(198, 241, 53);
    --color-partial: rgb(228, 228, 231);
    --color-danger: rgb(248, 113, 113);
    /* The confirm button's edge. Every current success fill clears 3:1
       against its panel, so the edge aliases the fill. Keeping the boundary
       token explicit lets a future palette separate the outline without
       changing the fill; the theme-matrix test measures this contract. */
    --color-success-edge: var(--color-success);
    /* Where a start card sits in the rotation - UP NEXT, AVAILABLE, PREVIOUS.
       The theme's own colour by default, which is what every theme wants
       except one: Candy Pop's highlight is white, because white is the only
       thing that survives the armed pulse on a mid-tone ground, and that put
       this label in the same colour as the card title right above it. */
    --color-rotation: var(--color-highlight);
    /* Ink for a label sitting *on* the danger fill. Dark by default, which is
       right for the dark themes: their red is a light rgb(248,113,113), and
       white on it measures 2.77:1. The light themes override it, because
       their reds were darkened to clear 4.5:1 as text and dark ink on those
       is worse, not better. */
    --color-on-danger: var(--color-text-dark);

    /* Panel action colours - shared across all panel confirm/cancel/secondary
       buttons: accent to save, red to cancel/destroy. */
    --color-success: rgb(198, 241, 53);
    --color-success-border: rgba(198, 241, 53, 0.4);

    /* The quiet wash behind a control that is being *used* - now only the two
       focused text fields (.profile-routine-name:focus-visible and
       .stacked-field > textarea:focus-visible). It was the app's
       press feedback until nothing was pressed-styled any more: a wash that
       arrives and leaves with the finger reads as a flash on any tap shorter
       than about a tenth of a second, which is most of them. Kept separate
       from --color-accent-fill, which stays reserved for accent-colored
       controls only. */
    --color-press: rgba(255, 255, 255, 0.06);
    /* Track background for the settings toggle/segmented pill controls. */
    --color-track: rgba(255, 255, 255, 0.12);

    /* A 2px accent rule that fades out at both ends rather than stopping dead,
       the same treatment the vertical hairlines get via mask-image (see
       the band picker and the .set-increment-btn divider).

       No picker draws it any more. It marked the chosen cell in the
       exercise-category list, the AMRAP strip and the warmup kind strip, and
       the load-mode block and band-tension grid had already been exempted -
       both are grids divided by hairlines on both axes, so an underline on the
       live cell sat on top of the divider below it and competed with the grid
       instead of marking a place in it. That reading turned out to be true of
       the strips as well, so the whole family now says "this one" the way those
       two grids always did: the coloured word, and only the word.

       The same lesson had already been learned once, from the other end -
       applied to the pill, the tabs, the language list and the theme swatch as
       well, it stopped reading as a picker's mark and started reading as a
       texture the app had acquired. Each of those still says "this one" in its
       own way, and now so does every picker.

       Nothing draws it any more at all. Its last user was .set-increment-btn's
       press flash, which went when momentary press feedback did - a mark that
       appears and disappears with the finger is a flash on any tap shorter
       than about a tenth of a second, which is most of them. The token stays
       because three comments below cite it as the record of what that mark
       was and why the pickers gave it up (see .exercise-category-btn,
       .load-mode-cell and .band-tension-title--toggle); the inner var()
       resolves against the element that uses it, so each theme's own
       --color-highlight would apply if anything did. */
    /* The boot curtain's fade, shared by #splash's own transition and the
       document background underneath it - they have to move together or the
       safe-area strips iOS paints from that background fall out of step. Kept
       in sync with SPLASH_FADE_MS in main.js, which decides when the element
       is finally removed. */
    --splash-fade: 400ms;
    /* The curtain's mark clears first, and the curtain only then begins to
       dissolve. Both marks are the same artwork, so revealing the page while
       this one was still up put two of them on screen at once; hiding the
       page's instead made it arrive after the curtain had gone, which read as
       a pop. Sequencing them means neither happens: by the time anything of
       the page is visible, all of it is. */
    --splash-mark-fade: 280ms;
    /* Entry is deliberately longer than exit, which is how a native launch
       reads: the mark settles into place and is taken away a little more
       briskly. One token per direction because the mark is the only thing in
       the app that animates differently coming and going. Well over twice the
       exit, so the arrival is unmistakably the slower half - and SPLASH_MIN_MS
       in main.js has to clear VIEWPORT_KICK_MS + this with room left over, or
       the mark reaches full opacity just as the curtain starts to lift. */
    --splash-mark-fade-in: 720ms;
    /* The decelerating curve iOS uses for presentation - almost all of the
       movement happens up front and the last few percent take their time,
       which is what stops a fade reading as a linear ramp. */
    --splash-ease-out: cubic-bezier(0.32, 0.72, 0, 1);
    --picker-hairline: linear-gradient(to right, transparent, var(--color-highlight) 50%, transparent);
    /* Its structural twin, for lines that divide rather than select: the two
       rules bounding a wheel's selected row. --color-border-strong, since
       those state "the value is here", not "you chose this". */
    --wheel-hairline: linear-gradient(to right, transparent, var(--color-border-strong) 50%, transparent);

    /* Radii */
    --radius-small: 8px;
    /* Shared corner radius for cards, drawers, panel sections, and other
       bordered containers. It matches the visible 18px cap radius of the
       set rows without applying their capsule-producing 9999px value. */
    --radius-medium: 18px;
    /* Every standalone rectangular button uses the same curve as chat's
       actions. Joined rows/cells stay square and true icon controls circular. */
    --radius-control: var(--radius-medium);
    --radius-pill: 9999px;

    /* Spacing */
    --spacing-s: 0.5rem;
    --spacing-sm: 0.75rem;
    --spacing-m: 1rem;
    --spacing-l: 1.5rem;

    /* Sizing */
    --button-min-height: 2.6rem;
    --exercise-row-height: 2.25rem;
    /* The set-state dot on each row. A shade larger than .edit-weight-btn's
       own font-size (1rem): matched exactly, the dot read as smaller than the
       figure beside it, since a circle's visual weight sits below its bounding
       box while text fills its own. */
    --set-dot-size: 1.15rem;
    /* The stroke an unfilled or part-filled set dot is drawn with. Heavy
       enough to read at 1.15rem without closing up the hole that distinguishes
       "not done" from the solid "done". */
    --set-dot-stroke: 0.16rem;
    /* The row's left-hand slot: its set number at rest, its + while the row's
       controls are open. A token because a superset row's second line indents
       to clear it (see .superset-tracking below), and the two must not drift. */
    --set-row-control-width: 2.9rem;
    /* One exercise in a start card's list: its name over its figure. Fixed so
       .session-card-list can be capped at exactly three rows and scroll. */
    --session-card-row: 2.3rem;
    --session-card-row-gap: 10px;
    /* The left-hand marker box every set row starts with. The set number sits
       centered in it while the row is at rest and the + ring fills it while
       the row's controls are open, so the two land on one vertical guide down
       the card instead of the ring drifting right of the numbers. */
    --set-marker-size: 1.3rem;
    --rir-btn-width: 2.4rem;
    /* What the set/RIR button widens to while its inline number field is open.
       Named because the set button's overlap offset is derived from it - see
       .set-btn.editing-reps. */
    --set-btn-editing-width: 4.5rem;
    /* Body-text weight, per theme rather than fixed.
     *
     * Light glyphs on a dark background optically bloom: the stroke spreads
     * into the surrounding dark and the same weight reads heavier and softer
     * than dark-on-light does. Every theme here renders this text at 400, so
     * nothing is literally bold - but on the dark themes it *looks* bold and
     * slightly fuzzy, while the light themes look correct. Dropping the dark
     * themes a step cancels the bloom instead of leaving the two halves of
     * the theme set visibly disagreeing about how heavy the app's text is.
     *
     * :root carries the dark default (see the dark palette below it); the
     * four light themes override it back to 400. */
    --font-weight-body: 350;
    --container-width: min(100%, 480px);
    /* Shared type size for the active exercise name in the header badge and
       the exercise-card heading below it, so the two read as the same label
       at two altitudes. There is no shared left guide any more - the card's
       name is centered (see .exercise-header-toggle), so only the size
       still carries over. */
    --exercise-name-size: 1rem;
    --header-inline-inset: calc(var(--spacing-s) + var(--spacing-sm));


    /* Type scale */
    --font-size-button-small: 1.2rem;
    --font-size-button-medium: 1.4rem;
    /* Shared DURATION/REST digit scale. Both timers occupy the same header
       slot and use identical typography; color and label identify the mode. */
    --font-size-timer: 1.8rem;
    /* Matches .session-card-start's own rendered height (see style.css
       section 7) so the idle screen's Start button becomes this exact box
       the instant a workout starts, instead of visibly resizing. */
    --timer-stack-height: 3.55rem;
    /* Every number in the app - the clock, a set's weight×reps, a logged
       set's pill, a stat value - reads in this instead of the body font.
       Ships on every platform already (no added font, no precache cost),
       and tabular-nums (set alongside it, not here) keeps digits from
       shifting width as they change. */
    --font-mono: ui-monospace, "SF Mono", "Roboto Mono", "IBM Plex Mono", Menlo, Consolas, monospace;

    /* Header geometry
       --safe-area-top has a floor of 14px, not just the raw env() value.
       In a regular (non-installed) mobile browser tab, env(safe-area-inset-top)
       is typically 0 even on notched phones - the browser's own chrome
       already avoids the notch, so nothing is reported to the page. That
       leaves fixed-position header content sitting flush against the very
       top of the layout viewport, which can render partly under/behind the
       browser's own address bar. The floor keeps a minimum clearance there
       in every context; installed/standalone mode still gets the real
       (larger) notch value via max(). */
    --safe-area-top: max(env(safe-area-inset-top, 0px), 14px);
    --safe-area-bottom: env(safe-area-inset-bottom, 0px);
    /* Sized so the header's bottom edge - where .workout-timer-badge-rule
       is pinned - falls on the same screen row as the idle screen's
       #session-underline-bar, keeping the line still when Start is tapped.
       That bar's bottom sits at --spacing-l (main's own top padding past
       the safe area) + .session-card-start's height (--timer-stack-height,
       which is sized to match it) + the 1px bar itself; --header-height
       adds the same --safe-area-top on top, so only that sum belongs here.
       Rounded up from 81.8px - a fraction of a pixel of slack above the
       badge is invisible, an under-measure would crop the rule. */
    --header-base-height: 82px;
    --header-height: calc(var(--header-base-height) + var(--safe-area-top));

    /* Layers
       Workout panels sit above main content but below the shared timer header.
       App-covering start-page panels sit above all app chrome, while
       notifications remain the topmost transient feedback. */
    --z-panel: 90;
    --z-header: 100;
    --z-app-covering-panel: 300;
    --z-notification: 500;



    /* Effects - elevated-card shadows replace the old gradient-wash cards.
       Two tiers: a resting card shadow, and a stronger one for elements
       actively lifted above their neighbors (e.g. a dragged reorder row). */
    --shadow-card: 0 8px 20px rgba(0, 0, 0, 0.4), 0 2px 6px rgba(0, 0, 0, 0.3);
    --shadow-raised: 0 12px 28px rgba(0, 0, 0, 0.5);
    /* Mild lift for small filled controls (the header badge, primary CTAs) -
       paired with the .glass-cap highlight below rather than used alone. */
    --shadow-bevel: 0 6px 14px rgba(0, 0, 0, 0.35);
    /* Every "armed" control - one that has been pressed once and will act on
       the second press - pulses at this one period. See armedFillPulse /
       armedTextPulse in section 11 for what the pulse is made of, and
       armButton() in main.js for what arms them. ~4 cycles fit the set dot's
       3s correction window (CONFIG.SET_BLINK_MS) and ~8 the 6s confirm window
       (CONFIRM_TIMEOUT_MS). */
    --pulse-armed: 0.7s;
    /* The brightness range both armed keyframes swing between. Not a free
       choice: an armed control paints --color-highlight, --color-danger or (on
       a filled dot) either one as a fill, and every one of those has to clear
       the 3:1 a UI component owes its background in all ten themes at BOTH
       ends. Measured headroom, worst theme: --color-highlight alone tolerates
       0.655-1.570, --color-danger only 0.760-1.510 (it is the binding one -
       at 0.72 it falls to 2.77:1 on Dusty Tiger). 0.80/1.20 is inside both
       with margin, bottoming out at 3.12:1 (Sage, danger). Widen either end
       and re-measure before assuming it still holds. */
    --pulse-armed-dim: 0.8;
    --pulse-armed-lit: 1.2;
}

/* ------------------------- 1b · Theme overrides --------------------------
   :root above is the "Lime" theme (default, no [data-theme] attribute
   needed). Each block below overrides only the color/shadow tokens for a
   selectable alternative - structure, spacing, and radii stay identical
   across every theme, only the token values change. Applied via
   document.documentElement.dataset.theme, set on load by
   scripts/theme-init.js (to avoid a flash of the wrong theme) and live by
   the theme picker in Settings (see openSettingsPopup() in ui.js). */



/* Cobalt - a dim navy-charcoal, the same "medium dark" depth as Patina above
   but cool instead of warm. Electric-blue primary, same reasoning as Patina:
   bright enough that neither --color-text-dark nor --color-highlight needs
   to diverge from the plain convention. */
[data-theme="cobalt"] {
    --color-background: rgb(20, 26, 40);
    --color-background-header: rgb(20, 26, 40);
    --color-background-panel: rgb(20, 26, 40);
    --color-surface: rgb(28, 36, 54);
    --color-surface-raised: rgb(36, 46, 66);

    --color-border: rgba(255, 255, 255, 0.08);
    --color-border-strong: rgba(255, 255, 255, 0.16);

    --color-text: rgb(230, 234, 245);
    --color-text-dark: rgb(20, 26, 40);
    --color-text-muted: rgb(142, 150, 170);
    --color-highlight: rgb(96, 165, 250);

    --color-accent: rgb(96, 165, 250);
    --color-accent-border: rgba(96, 165, 250, 0.4);
    --color-accent-fill: rgba(96, 165, 250, 0.14);

    --color-yes: rgb(96, 165, 250);
    --color-no: rgb(96, 165, 250);
    --color-pending: rgb(96, 165, 250);
    --color-completed: rgb(96, 165, 250);
    --color-partial: rgb(203, 213, 225);
    --color-danger: rgb(248, 113, 113);

    --color-success: rgb(96, 165, 250);
    --color-success-border: rgba(96, 165, 250, 0.4);

    --color-press: rgba(255, 255, 255, 0.06);
    --color-track: rgba(255, 255, 255, 0.12);
}


/* ---------------------------- The black five ----------------------------
   Lime in :root above and the four below all sit on a true rgb(0, 0, 0)
   canvas and share one structure: one hue carries accent/highlight and every
   set-state
   token (yes/no/pending/completed/success), a near-neutral carries
   --color-partial, and the blink on .pending rather than a colour change is
   what separates pending from completed.

   Only --color-background/-header/-panel are pure black. The two surfaces
   above them are not: flattening those to 0 as well would erase every card
   boundary in the app, since a card is drawn by its fill against the page
   and nothing else. All five step 0 -> 12 -> 22.

   Every accent here was picked against the armed-pulse contrast floor rather
   than by eye, because --color-highlight is scaled to 0.8 while a control is
   armed and pure black leaves no headroom underneath. The guard test in
   app.test.mjs does that arithmetic; what it means in practice is that a
   deeper red than the one below fails - rgb(206, 46, 46) lands at 2.89:1. */

/* Cerise - magenta-leaning pink-red, the most saturated of the three. Far
   enough from --color-danger's salmon in hue that a delete still reads as a
   different kind of thing. */
[data-theme="cerise"] {
    --color-background: rgb(0, 0, 0);
    --color-background-header: rgb(0, 0, 0);
    --color-background-panel: rgb(0, 0, 0);
    --color-surface: rgb(12, 12, 12);
    --color-surface-raised: rgb(22, 22, 22);

    --color-border: rgba(255, 255, 255, 0.08);
    --color-border-strong: rgba(255, 255, 255, 0.16);

    --color-text: rgb(237, 237, 239);
    --color-text-dark: rgb(10, 10, 10);
    --color-text-muted: rgb(138, 138, 144);
    --color-highlight: rgb(232, 62, 140);

    --color-accent: rgb(232, 62, 140);
    --color-accent-border: rgba(232, 62, 140, 0.4);
    --color-accent-fill: rgba(232, 62, 140, 0.14);

    --color-yes: rgb(232, 62, 140);
    --color-no: rgb(232, 62, 140);
    --color-pending: rgb(232, 62, 140);
    --color-completed: rgb(232, 62, 140);
    --color-partial: rgb(228, 228, 231);
    --color-danger: rgb(248, 113, 113);

    --color-success: rgb(232, 62, 140);
    --color-success-border: rgba(232, 62, 140, 0.4);

    --color-press: rgba(255, 255, 255, 0.06);
    --color-track: rgba(255, 255, 255, 0.12);
}

/* Default - the theme a fresh install opens in, and the brightest accent of
   the five: an electric cyan on black. It replaced Bronze, a muted warm tan
   that was the quietest accent here and the hardest to see a signal in; the
   structure is Bronze's unchanged, only the hue and the tint under it moved,
   cool where Bronze was warm. Measured, not picked by eye: 10.61:1 static on
   black, 6.83:1 at the dim end of the armed pulse against the 3:1 that pulse
   owes its ground, and 10.01:1 for near-black ink on an accent fill. Nothing
   here is near the floor, which is the point of it being the default. The
   surfaces below carry the same coolness, which is what keeps them from
   reading grey. */
[data-theme="default"] {
    --color-background: rgb(0, 0, 0);
    --color-background-header: rgb(0, 0, 0);
    --color-background-panel: rgb(0, 0, 0);
    --color-surface: rgb(10, 12, 13);
    --color-surface-raised: rgb(17, 21, 23);

    --color-border: rgba(255, 255, 255, 0.08);
    --color-border-strong: rgba(255, 255, 255, 0.16);

    --color-text: rgb(230, 236, 238);
    --color-text-dark: rgb(10, 10, 10);
    --color-text-muted: rgb(130, 141, 146);
    --color-highlight: rgb(0, 199, 255);

    --color-accent: rgb(0, 199, 255);
    --color-accent-border: rgba(0, 199, 255, 0.4);
    --color-accent-fill: rgba(0, 199, 255, 0.14);

    --color-yes: rgb(0, 199, 255);
    --color-no: rgb(0, 199, 255);
    --color-pending: rgb(0, 199, 255);
    --color-completed: rgb(0, 199, 255);
    --color-partial: rgb(228, 228, 231);
    --color-danger: rgb(248, 113, 113);

    --color-success: rgb(0, 199, 255);
    --color-success-border: rgba(0, 199, 255, 0.4);

    --color-press: rgba(255, 255, 255, 0.06);
    --color-track: rgba(255, 255, 255, 0.12);
}

/* Red - warmer and a shade off pure, which is as deep as the pulse floor
   allows on this canvas. The only theme in the app that has to move
   --color-danger: every other one can leave it at the shared salmon because
   its accent is nowhere near red, and here that would make the delete
   button and the "set completed" tick the same signal. It goes lighter and
   pinker instead of darker, so destructive still reads as the louder of the
   two rather than the dimmer. */
[data-theme="red"] {
    --color-background: rgb(0, 0, 0);
    --color-background-header: rgb(0, 0, 0);
    --color-background-panel: rgb(0, 0, 0);
    --color-surface: rgb(13, 11, 11);
    --color-surface-raised: rgb(24, 20, 20);

    --color-border: rgba(255, 255, 255, 0.08);
    --color-border-strong: rgba(255, 255, 255, 0.16);

    --color-text: rgb(237, 235, 235);
    --color-text-dark: rgb(10, 10, 10);
    --color-text-muted: rgb(144, 136, 136);
    --color-highlight: rgb(240, 62, 48);

    --color-accent: rgb(240, 62, 48);
    --color-accent-border: rgba(240, 62, 48, 0.4);
    --color-accent-fill: rgba(240, 62, 48, 0.14);

    --color-yes: rgb(240, 62, 48);
    --color-no: rgb(240, 62, 48);
    --color-pending: rgb(240, 62, 48);
    --color-completed: rgb(240, 62, 48);
    --color-partial: rgb(228, 228, 231);
    --color-danger: rgb(255, 150, 138);
    --color-on-danger: rgb(10, 10, 10);

    --color-success: rgb(240, 62, 48);
    --color-success-border: rgba(240, 62, 48, 0.4);

    --color-press: rgba(255, 255, 255, 0.06);
    --color-track: rgba(255, 255, 255, 0.12);
}

/* Green - the fifth of the black set, and the only one whose hue also carries
   a meaning of its own elsewhere in the app (--color-success is green in no
   other theme). That is not a conflict here for the same reason it is not in
   Lime: every set-state token is the accent, so "done" and "the app's colour"
   are deliberately the same thing, and it is the blink on .pending that
   separates the states rather than a hue. */
[data-theme="green"] {
    --color-background: rgb(0, 0, 0);
    --color-background-header: rgb(0, 0, 0);
    --color-background-panel: rgb(0, 0, 0);
    --color-surface: rgb(11, 13, 11);
    --color-surface-raised: rgb(20, 24, 20);

    --color-border: rgba(255, 255, 255, 0.08);
    --color-border-strong: rgba(255, 255, 255, 0.16);

    --color-text: rgb(236, 239, 236);
    --color-text-dark: rgb(10, 10, 10);
    --color-text-muted: rgb(136, 144, 136);
    --color-highlight: rgb(59, 209, 111);

    --color-accent: rgb(59, 209, 111);
    --color-accent-border: rgba(59, 209, 111, 0.4);
    --color-accent-fill: rgba(59, 209, 111, 0.14);

    --color-yes: rgb(59, 209, 111);
    --color-no: rgb(59, 209, 111);
    --color-pending: rgb(59, 209, 111);
    --color-completed: rgb(59, 209, 111);
    --color-partial: rgb(228, 228, 231);
    --color-danger: rgb(248, 113, 113);

    --color-success: rgb(59, 209, 111);
    --color-success-border: rgba(59, 209, 111, 0.4);

    --color-press: rgba(255, 255, 255, 0.06);
    --color-track: rgba(255, 255, 255, 0.12);
}


/* ---------------------------- The white four ----------------------------
   The black six inverted: one hue on a white ground, every set-state token
   sharing it, and --color-partial a neutral rather than a second colour.

   Two things reverse rather than mirror. The accent must be dark enough to
   read as *text* on white, not merely visible on it - so each is a saturated
   mid-tone rather than the bright hue its dark counterpart uses, and the
   armed pulse is checked at its *lit* end here, which brightens toward the
   background instead of away from it. And --color-on-accent goes white, since
   these accents are dark: the dark ink the black six use would vanish.

   --font-weight-body returns to 400 on all six. :root thins it to 350 for
   the bloom of light glyphs on a dark ground, which does not happen here. */

/* Scarlett - the warmest of the six and the one closest to --color-danger,
   so its danger is deliberately deepened well past the accent rather than
   left at the shared red. */
[data-theme="scarlett"] {
    --font-weight-body: 400;
    --color-background: rgb(255, 255, 255);
    --color-background-header: rgb(255, 255, 255);
    --color-background-panel: rgb(255, 255, 255);
    --color-surface: rgb(250, 250, 250);
    --color-surface-raised: rgb(242, 240, 240);

    --color-border: rgba(0, 0, 0, 0.10);
    --color-border-strong: rgba(0, 0, 0, 0.20);

    --color-text: rgb(26, 24, 24);
    --color-text-dark: rgb(26, 24, 24);
    --color-text-muted: rgb(108, 100, 100);
    --color-highlight: rgb(232, 33, 0);

    --color-accent: rgb(232, 33, 0);
    --color-on-accent: rgb(255, 255, 255);
    --color-accent-border: rgba(232, 33, 0, 0.4);
    --color-accent-fill: rgba(232, 33, 0, 0.12);

    --color-yes: rgb(232, 33, 0);
    --color-no: rgb(232, 33, 0);
    --color-pending: rgb(232, 33, 0);
    --color-completed: rgb(232, 33, 0);
    --color-partial: rgb(120, 116, 116);
    --color-danger: rgb(140, 20, 0);
    --color-on-danger: rgb(255, 255, 255);

    --color-success: rgb(232, 33, 0);
    --color-success-border: rgba(232, 33, 0, 0.4);

    --color-press: rgba(0, 0, 0, 0.05);
    --color-track: rgba(0, 0, 0, 0.10);
}

/* Navy - the darkest accent of the six (15:1 as text), so it is the one that
   reads as ordinary type rather than as a highlight. */
[data-theme="navy"] {
    --font-weight-body: 400;
    --color-background: rgb(255, 255, 255);
    --color-background-header: rgb(255, 255, 255);
    --color-background-panel: rgb(255, 255, 255);
    --color-surface: rgb(250, 250, 251);
    --color-surface-raised: rgb(238, 240, 245);

    --color-border: rgba(0, 0, 0, 0.10);
    --color-border-strong: rgba(0, 0, 0, 0.20);

    --color-text: rgb(24, 26, 32);
    --color-text-dark: rgb(24, 26, 32);
    --color-text-muted: rgb(100, 106, 120);
    --color-highlight: rgb(19, 42, 109);

    --color-accent: rgb(19, 42, 109);
    --color-on-accent: rgb(255, 255, 255);
    --color-accent-border: rgba(19, 42, 109, 0.4);
    --color-accent-fill: rgba(19, 42, 109, 0.12);

    --color-yes: rgb(19, 42, 109);
    --color-no: rgb(19, 42, 109);
    --color-pending: rgb(19, 42, 109);
    --color-completed: rgb(19, 42, 109);
    --color-partial: rgb(116, 122, 136);
    --color-danger: rgb(179, 38, 30);
    --color-on-danger: rgb(255, 255, 255);

    --color-success: rgb(19, 42, 109);
    --color-success-border: rgba(19, 42, 109, 0.4);

    --color-press: rgba(0, 0, 0, 0.05);
    --color-track: rgba(0, 0, 0, 0.10);
}

/* Fern - the light counterpart to Green, and the theme that replaced Sage. */
[data-theme="fern"] {
    --font-weight-body: 400;
    --color-background: rgb(255, 255, 255);
    --color-background-header: rgb(255, 255, 255);
    --color-background-panel: rgb(255, 255, 255);
    --color-surface: rgb(250, 251, 250);
    --color-surface-raised: rgb(238, 244, 238);

    --color-border: rgba(0, 0, 0, 0.10);
    --color-border-strong: rgba(0, 0, 0, 0.20);

    --color-text: rgb(24, 28, 24);
    --color-text-dark: rgb(24, 28, 24);
    --color-text-muted: rgb(100, 110, 100);
    --color-highlight: rgb(0, 128, 0);

    --color-accent: rgb(0, 128, 0);
    --color-on-accent: rgb(255, 255, 255);
    --color-accent-border: rgba(0, 128, 0, 0.4);
    --color-accent-fill: rgba(0, 128, 0, 0.12);

    --color-yes: rgb(0, 128, 0);
    --color-no: rgb(0, 128, 0);
    --color-pending: rgb(0, 128, 0);
    --color-completed: rgb(0, 128, 0);
    --color-partial: rgb(116, 126, 116);
    --color-danger: rgb(179, 38, 30);
    --color-on-danger: rgb(255, 255, 255);

    --color-success: rgb(0, 128, 0);
    --color-success-border: rgba(0, 128, 0, 0.4);

    --color-press: rgba(0, 0, 0, 0.05);
    --color-track: rgba(0, 0, 0, 0.10);
}

/* Magnolia - the light counterpart to Cerise, and the one theme of the five
   whose ground is not white: a pale lilac-white the cerise sits on more
   comfortably than plain white does. */
[data-theme="magnolia"] {
    --font-weight-body: 400;
    --color-background: rgb(248, 244, 255);
    --color-background-header: rgb(248, 244, 255);
    --color-background-panel: rgb(248, 244, 255);
    --color-surface: rgb(252, 250, 255);
    --color-surface-raised: rgb(240, 234, 250);

    --color-border: rgba(0, 0, 0, 0.10);
    --color-border-strong: rgba(0, 0, 0, 0.20);

    --color-text: rgb(30, 24, 32);
    --color-text-dark: rgb(30, 24, 32);
    --color-text-muted: rgb(110, 100, 116);
    --color-highlight: rgb(196, 42, 110);

    --color-accent: rgb(196, 42, 110);
    --color-on-accent: rgb(255, 255, 255);
    --color-accent-border: rgba(196, 42, 110, 0.4);
    --color-accent-fill: rgba(196, 42, 110, 0.12);

    --color-yes: rgb(196, 42, 110);
    --color-no: rgb(196, 42, 110);
    --color-pending: rgb(196, 42, 110);
    --color-completed: rgb(196, 42, 110);
    --color-partial: rgb(124, 116, 128);
    --color-danger: rgb(179, 38, 30);
    --color-on-danger: rgb(255, 255, 255);

    --color-success: rgb(196, 42, 110);
    --color-success-border: rgba(196, 42, 110, 0.4);

    --color-press: rgba(0, 0, 0, 0.05);
    --color-track: rgba(0, 0, 0, 0.10);
}


/* =============================== 2 · Base =============================== */
*,
*::before,
*::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

.visually-hidden {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip: rect(0, 0, 0, 0);
    white-space: nowrap;
    border: 0;
}

/* Scrollbar: hidden on the root viewport so the exercise list scrollbar
   never overlaps the set buttons. The page remains scrollable via touch
   or trackpad; only the visible thumb/track is removed. */
html {
    font-size: 16px;
    -webkit-text-size-adjust: 100%;
    text-size-adjust: 100%;
    scrollbar-width: none;
    -ms-overflow-style: none;
    /* Only <body> used to carry a ground, so anything painted outside its box
       - the over-scroll bounce, and the strip below it on a device with a home
       indicator - fell through to the canvas default. Invisible on a dark
       theme against a white default; the moment the light themes arrived it
       was a white band under a coloured app. */
    background-color: var(--color-background);
}

/* ...and while the boot curtain is still black, that ground has to be the
   curtain's, not the theme's. #splash covers the viewport but not the
   over-scroll area beyond it, which is exactly where a light theme showed a
   white footer strip under an otherwise black screen.

   Keyed on :not(.splash--themed) rather than on the element existing at all:
   held to removal, the page behind the curtain stayed black for the whole
   fade, so the fade revealed nothing and the theme arrived as a snap the
   instant the node went - which is what made the top of the screen flip from
   black to white a beat after everything else. Released at the same moment the
   curtain itself takes the theme's ground, so the viewport and the strips
   outside it are never two different colours.

   body as well as html: the two paint different regions - html the over-scroll
   area, body the safe-area strips at top and bottom.

   The animation is this rule's own escape hatch, and it exists because the
   selector cannot release itself. Every other way out of black is something a
   script does - revealSplashMark() adds the class, dismissSplash() removes the
   node - so a boot where main.js never loads at all (a failed module fetch on
   a cold, offline first open) leaves #splash in the document, un-themed,
   forever. The curtain has its own scriptless failsafe for exactly that case
   and fades itself out at 8s; without this, it faded out to reveal a light
   theme still framed in black on every edge the curtain never covered. Same
   delay and same duration as splash-failsafe, so the two move as one.

   Untouched by prefers-reduced-motion on purpose: it is a failsafe before it
   is an animation, and honouring the preference here would mean removing the
   only way out. */
html:has(#splash:not(.splash--themed)),
html:has(#splash:not(.splash--themed)) body {
    background-color: #000;
    animation: splash-ground-failsafe 400ms 8s forwards;
}

/* ...and they cross-fade to it rather than switching, in step with the
   curtain's own ground.

   This is what the bottom of an iOS screen needs. The home-indicator strip is
   painted by the compositor from the document's background, not from the fixed
   overlay lying over it, so when that background changed instantly the strip
   flipped to the theme colour while everything above it was still dissolving -
   the one region of the screen visibly out of step. Matching durations puts
   them back together.

   The mark's fade-in duration and curve, not the curtain's exit: this now runs
   with the mark's arrival rather than with the curtain's departure, and the
   whole point is that the two are one event.

   Scoped to while #splash is in the document, so switching theme in Settings
   still lands at once: a page whose background eased into place while every
   control on it changed instantly would look broken in a different way. */
html:has(#splash),
html:has(#splash) body {
    transition: background-color var(--splash-mark-fade-in) ease-in-out;
}

html::-webkit-scrollbar {
    display: none;
}

body {
    min-height: 100vh;
    display: flex;
    flex-direction: column;
    align-items: center;
    font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    font-weight: var(--font-weight-body);
    background-color: var(--color-background);
    color: var(--color-text);
    overflow-x: hidden;
    /* Kills the horizontal swipe that navigates back/forward. `overflow-x:
       hidden` alone does not: the gesture is overscroll, which fires whether
       or not there is anything to scroll to. In a browser tab it is merely
       surprising; installed as a PWA there is nowhere to navigate *to*, so a
       sideways drag mid-workout either does nothing or leaves the app. `none`
       rather than `contain` because there is no horizontal scrolling
       anywhere in this app worth preserving. */
    overscroll-behavior-x: none;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

/* The idle screen is a fixed-height app shell, not a normal scrolling page
   - see the <main> comment in index.html and #idle-scroll below. min-height
   (the base rule above) is a floor that lets the page grow taller than the
   viewport if content demands it, which is exactly what must NOT happen
   here: a document that can grow means the document can scroll, and a
   document that can scroll can carry .idle-start-card up
   underneath the status bar/notch/front camera with it. height (a hard
   cap) plus overflow: hidden makes that structurally impossible instead of
   just unlikely. dvh first would be ignored by engines that don't support
   it, so vh is listed first as the fallback those engines keep using. */
body:not([data-workout]) {
    /* min-height must be cleared, not just overridden by height: the base
       `body` rule sets min-height: 100vh, and a min-height larger than the
       height always wins. On any browser where 100vh exceeds 100dvh - iOS
       Safari and Android Chrome with the address bar showing, i.e. exactly
       the not-installed case - the idle shell would otherwise be laid out
       taller than the screen it has to fit inside, while overflow: hidden
       below hides the evidence. */
    min-height: 0;
    height: 100vh;
    height: 100dvh;
    /* Same measured height <main> uses, and not an afterthought: <body> is a
       flex column and <main> is a shrinkable item in it (flex-shrink defaults
       to 1), so main's height is capped by this one no matter what it asks
       for. Sizing only main from --app-height made that a one-way valve - a
       measurement smaller than 100dvh shrank the shell, a larger one was
       silently discarded - which is why the cold-launch fix appeared to do
       nothing on iOS: dvh resolved short here, and main was clamped to the
       short body however correctly it had measured the viewport. Both boxes
       have to read the same height or neither does. */
    height: var(--app-height, 100dvh);
    overflow: hidden;
}

/* Boot-only, and never seen by anyone: for a quarter of a second while the
   splash curtain is still opaque, the idle shell stops being a bounded
   non-scrolling box and becomes a document taller than the viewport. That is
   the one thing measured to make iOS release the full-screen viewport it
   withholds from a cold standalone launch - see kickViewport() in ui.js for
   the measurements. Higher specificity than the rule above so it wins without
   !important; removed 250ms later, and a no-op on every platform that had
   already given us the whole screen. */
body.viewport-kick:not([data-workout]) {
    height: auto;
    min-height: 200vh;
    overflow: visible;
}

/* Locks the page behind an open content panel. */
body.has-overlay {
    overflow: hidden;
}


/* `hidden` must win over the display values set on buttons and flex boxes. */
[hidden] {
    display: none !important;
}

button,
input {
    font-family: inherit;
    touch-action: manipulation;
    -webkit-tap-highlight-color: transparent;
    user-select: none;
    -webkit-user-select: none;
}

button {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-height: var(--button-min-height);
    border: none;
    /* Square by default, and every rounded button says so itself.
       This used to default to var(--radius-pill), which quietly broke any
       button given a border on one side only: the 9999px radius curled the
       ends of that single edge into stubs, so a plain row divider rendered
       as half a pill outline. It is not a bug you can see coming from the
       rule you are writing - it comes from a default three thousand lines
       away - and it had been hit more than once. */
    border-radius: 0;
    cursor: pointer;
    color: inherit;
    background: transparent;
    /* Every native default here is already reset except this one - without
       it, some browsers still render their own pressed/active-state chrome
       (a rounded, fading highlight) on top of our own styling, which is what
       made .session-card-start's underline look like a curved pill on tap
       instead of a straight hairline. */
    appearance: none;
    -webkit-appearance: none;
}

button:focus-visible,
input:focus-visible {
    outline: 2px solid var(--color-highlight);
    outline-offset: 2px;
}

input[type="number"] {
    -moz-appearance: textfield;
    appearance: textfield;
}

input[type="number"]::-webkit-outer-spin-button,
input[type="number"]::-webkit-inner-spin-button {
    -webkit-appearance: none;
    margin: 0;
}

/* Tapping a number field clears it to a blinking cursor, moving the
   previous value into the placeholder so it stays visible - greyed out -
   behind the caret (see clearToPlaceholder()/valueOrPlaceholder() in ui.js).
   These fields are focused programmatically the instant they appear, so the
   browser's own focus ring would just flash uninvited; the caret itself is
   feedback enough. */
input.reps-input,
.weight-input-container input[type="number"],
.weight-input-container input[type="text"] {
    outline: none !important;
}

input.reps-input::placeholder {
    color: inherit;
    opacity: 0.45;
}

.weight-input-container input[type="number"]::placeholder,
.weight-input-container input[type="text"]::placeholder {
    color: var(--color-text-muted);
    opacity: 0.7;
}



/* ========================= 3 · Header and timers ======================== */

#workout-header {
    position: fixed;
    inset: 0 0 auto 0;
    height: var(--header-height);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: var(--z-header);
    background-color: var(--color-background-header);
}

/* Start/Switch no longer live here (see .idle-start-card) - the idle
   header has no controls, so it shrinks down to just the safe-area inset
   instead of reserving the old controls' height.
   It can't go all the way to 0, though: #workout-header is the *only*
   thing pinned (position: fixed) above the scrollable idle content, so
   without it covering at least this strip, scrolling drags whatever's
   first in that flow (.idle-start-card, now at the top of the page - see
   index.html) straight up underneath the status bar / notch / front
   camera - there's nothing left to mask it. Every theme's
   --color-background-header equals --color-background, so this leaves no
   visible seam either way. .header-content's own padding is zeroed too -
   box-sizing: border-box cannot shrink a box below its own padding, so
   leaving that padding in place would let .header-content overflow past
   this now-short parent (#workout-header itself doesn't clip its overflow)
   and sit above the Tools list in the header's stacking context, silently
   eating the first row's taps. */
body:not([data-workout]) #workout-header {
    height: var(--safe-area-top);
}

body:not([data-workout]) .header-content {
    padding: 0;
}

/* align-items: flex-end and no bottom padding - .workout-timer-badge-rule
   is absolutely positioned against this box's bottom edge (see that rule),
   and it has to land on the exact screen row #session-underline-bar
   occupies on the idle screen so tapping Start never moves the line. Any
   bottom padding here, or centering the badge in leftover space, would
   push it off that row. */
.header-content {
    position: relative;
    display: flex;
    align-items: flex-end;
    justify-content: center;
    width: 100%;
    max-width: var(--container-width);
    height: 100%;
    padding: 0;
    padding-top: calc(var(--spacing-s) + var(--safe-area-top));
}

/* Idle-screen start card: the top flex child of the idle app shell (see
   the body:not(...)/main rules above and #idle-scroll below) - shrink-to-
   fit, never scrolls, always fully visible. See
   updateWorkoutSelect()/renderStartCards() in main.js/ui.js. position:
   relative so #session-underline-bar (a child of this, not of the
   scroller) can be positioned against it and stay put while the cards
   scroll past underneath it. */
.idle-start-card {
    position: relative;
    flex-shrink: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--spacing-s);
    width: 100%;
}

.profile-list,
.preset-profile-list,
.profile-routine-editors {
    display: flex;
    flex-direction: column;
    width: calc(100% + 2 * var(--spacing-m));
    margin-inline: calc(-1 * var(--spacing-m));
}

.profile-list,
.preset-profile-list {
    gap: 0;
}

.profile-routine-editors {
    gap: 0;
}

/* Inset Rhythm keeps the program overview in the panel's ordinary gutter,
   then lets each workout's accent rule run edge to edge below it. There is no
   raised surface here: the totals and fold action are chrome, not a card. */
.profile-builder-overview {
    display: flex;
    align-items: center;
    gap: var(--spacing-s);
    width: calc(100% + 2 * var(--spacing-m));
    margin: 0 calc(-1 * var(--spacing-m));
    padding: 0 var(--spacing-m);
    background: none;
    border: none;
}

/* Program totals, straight under the name. A program list has always said how
   many workouts; it has never said anything about the work in them. */
.profile-summary {
    flex: 1 1 auto;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-s);
    width: 100%;
    min-height: 44px;
    margin: 0;
    padding: 0;
    font-size: 0.78rem;
    color: var(--color-text-muted);
    background: none;
    border: none;
    text-align: left;
}
.profile-summary span {
    min-width: 0;
}
.profile-summary .tools-list-chevron {
    flex: 0 0 auto;
    width: 16px;
    height: 16px;
}

.profile-fold-all {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    gap: 5px;
    min-height: 44px;
    padding: 0 0 0 var(--spacing-s);
    color: var(--color-accent);
    background: none;
    border: none;
    border-radius: 0;
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    white-space: nowrap;
}

.profile-fold-all svg {
    width: 14px;
    height: 14px;
}

/* Which day this card is meant for, in the slot the exercise count used to
   hold. It keeps the count's exact typography so the heading's rhythm is
   unchanged, and the extra margin still keeps it clear of the icon button
   beside it - the heading's 6px gap alone read as touching.

   The count went because the rows below already answer it. A button rather
   than a label, because this is the only way into the day picker: unset it
   holds the slot at reduced strength rather than vanishing, since an empty
   slot gives the thumb nothing to aim at. 32px tall so it is a real target at
   0.68rem type. */
.profile-routine-day {
    flex-shrink: 0;
    display: inline-flex;
    align-items: center;
    min-height: 32px;
    margin: -2px 0 0;
    padding: 0;
    font-size: 0.68rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-text-muted);
    background: none;
    border: none;
    border-radius: 0;
}
.profile-routine-day.is-unset {
    opacity: 0.72;
}
/* The card's goal and note, on one row under its heading. A quiet row: it sits
   between the card's title and its exercises, and neither of those should have
   to compete with it. The goal takes the readable colour once set; unset, the
   row is the muted prompt that says what it is for. */
.profile-routine-about {
    display: flex;
    align-items: center;
    gap: var(--spacing-s);
    width: 100%;
    min-height: 34px;
    margin: 0;
    padding: 0 var(--spacing-m) var(--spacing-s);
    color: var(--color-text);
    background: none;
    border: none;
    border-radius: 0;
    text-align: left;
}
.profile-routine-about.is-unset {
    color: var(--color-text-muted);
}
.profile-routine-about-goal {
    flex: 0 1 auto;
    min-width: 0;
    overflow: hidden;
    font-size: 0.82rem;
    font-weight: 600;
    text-overflow: ellipsis;
    white-space: nowrap;
}
/* Always muted, whatever the goal beside it is doing - it is evidence there is
   more behind the row, not a second headline. */
.profile-routine-about-note {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    font-size: 0.78rem;
    color: var(--color-text-muted);
    text-overflow: ellipsis;
    white-space: nowrap;
}
.profile-routine-about .tools-list-chevron {
    flex: 0 0 auto;
    width: 16px;
    height: 16px;
    color: var(--color-text-muted);
}

/* The only card of a one-card program: a label, not a field, because that
   card is named by its program (see profileRoutineEditorHtml in ui.js).
   It carries the name field's exact geometry - same pull-back, same 15px
   guide, same size - so adding a second card swaps it for an input without
   the heading shifting by a pixel. */
.profile-routine-fixed {
    flex: 1;
    display: flex;
    align-items: center;
    min-width: 0;
    min-height: 34px;
    padding: 0;
    margin: 0;
    font-size: 1rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-text);
}

.profile-empty-workout {
    margin: 0;
    padding: var(--spacing-sm) var(--spacing-m);
    background: none;
    border: none;
    border-top: 1px solid var(--color-border);
    border-radius: 0;
    font-size: 0.85rem;
    color: var(--color-text-muted);
}

/* Add is a row in the same rhythm as everything above it. The dashed box it
   replaces was a pattern used nowhere else in the app. */
.profile-add-row {
    display: flex;
    align-items: center;
    gap: var(--spacing-s);
    width: 100%;
    min-height: 46px;
    padding: 0 var(--spacing-m);
    border: none;
    border-top: 1px solid var(--color-border);
    border-radius: 0;
    background: none;
    color: var(--color-accent);
    font-size: 0.95rem;
    font-weight: 600;
    text-align: left;
}

.profile-routine-editor > .profile-add-row {
    width: 100%;
    min-height: 48px;
    margin: 0;
    padding: 0 var(--spacing-m);
    color: var(--color-accent);
    background: none;
    border: none;
    border-top: 1px solid var(--color-border);
    border-radius: 0;
}
/* An add-row living directly in a panel body rather than under a row list:
   the body's own gutter provides the inset, and there is no list above it
   for a border to separate from. Same voice, different neighborhood. */
.panel-add-row {
    padding: 0;
    border-top: none;
}

/* The row's + is the app's own add glyph (ADD_ICON_SVG), sized to read as
   part of the label rather than an icon button of its own. */
.profile-add-row svg {
    flex-shrink: 0;
    width: 16px;
    height: 16px;
}

.profile-library-intro {
    margin: 0 0 var(--spacing-m);
    color: var(--color-text-muted);
    font-size: 0.9rem;
    line-height: 1.45;
}

.profile-library-section + .profile-library-section {
    margin-top: var(--spacing-l);
}

.profile-library-section > .panel-section-title {
    margin: 0 0 var(--spacing-s);
}

.profile-list-item {
    position: relative;
    padding: 0;
    background: none;
    background-image: linear-gradient(to right, var(--color-border), var(--color-border));
    background-repeat: no-repeat;
    background-position: right bottom;
    background-size: calc(100% - var(--spacing-m)) 1px;
    border: none;
    border-radius: 0;
    box-shadow: none;
}

.program-library-card.active {
    background-image: linear-gradient(to right, var(--color-accent-border), var(--color-accent-border));
    box-shadow: none;
}

.profile-list-static {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
    padding: var(--spacing-m);
}

.profile-archive {
    width: calc(100% + 2 * var(--spacing-m));
    margin: var(--spacing-l) calc(-1 * var(--spacing-m)) 0;
    border: none;
    border-top: 1px solid var(--color-border);
    border-radius: 0;
    overflow: visible;
}
/* Archived programs reuse the same card width as the main sections without
   inheriting another disclosure inset. */
.profile-archive .profile-list {
    width: 100%;
    margin-inline: 0;
}

.profile-archive > summary {
    min-height: 44px;
    padding: var(--spacing-sm) var(--spacing-m);
    color: var(--color-text-muted);
    font-size: 0.78rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    cursor: pointer;
}

.profile-archive[open] > summary {
    border-bottom: 1px solid var(--color-border);
}

.profile-add-routes {
    display: flex;
    flex-direction: column;
    width: calc(100% + 2 * var(--spacing-m));
    margin: 0 calc(-1 * var(--spacing-m)) var(--spacing-m);
}
/* The three routes into a new program read as one set of three: each is an
   icon, a label and a chevron on the same rhythm. Build used to be a "+" with
   no border and no chevron, because it was the primary add and the other two
   were afterthoughts hanging off it; with each route named by its own symbol
   there is no primary left to mark.

   Uppercase from CSS rather than from the catalogs, the way every other
   uppercase label in the app does it: programs.copyExisting also titles the
   panel the row opens, which must stay in sentence case, and a Norwegian
   catalog should not have to carry a shouting duplicate of a string it
   already has. */
.profile-add-routes .profile-add-row {
    justify-content: flex-start;
    min-height: 54px;
    padding: 0 var(--spacing-m);
    font-size: 0.82rem;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-text);
    background-color: transparent;
    background-image: linear-gradient(to right, var(--color-border), var(--color-border));
    background-repeat: no-repeat;
    background-position: right top;
    background-size: calc(100% - 3.9rem) 1px;
    border: none;
    border-radius: 0;
}

/* The hairlines divide these three from each other, so the first row takes
   none: a line above Build with nothing above it to divide from closed the
   group off at the top while the bottom stayed open. */
.profile-add-routes .profile-add-row:first-child {
    color: var(--color-accent);
    background-color: transparent;
    background-image: none;
}
.profile-add-routes .tools-list-chevron {
    /* Pushed to the right edge; the icon and label stay together on the left,
       which a space-between row could not do with three children in it. */
    margin-left: auto;
    width: 16px;
    height: 16px;
    color: var(--color-text-muted);
}

.profile-add-routes .profile-add-row:first-child .tools-list-chevron {
    color: var(--color-text-muted);
}

.profile-copy-source {
    border-bottom: 1px solid var(--color-border);
}
.profile-copy-source:last-child {
    border-bottom: none;
}

.profile-add-primary {
    justify-content: flex-start;
    width: calc(100% + 2 * var(--spacing-m));
    min-height: 50px;
    margin-top: var(--spacing-l);
    margin-inline: calc(-1 * var(--spacing-m));
    padding-inline: var(--spacing-m);
    color: var(--color-accent);
    background-color: transparent;
    background-image: linear-gradient(to right, var(--color-border), var(--color-border));
    background-repeat: no-repeat;
    background-position: right top;
    background-size: calc(100% - 3.9rem) 1px;
    border: none;
    border-radius: 0;
    box-shadow: none;
}

.profile-routine-editor {
    position: relative;
    overflow: visible;
    padding-bottom: 0;
    background: none;
    border: none;
    border-top: 1px solid var(--color-accent);
    border-radius: 0;
    box-shadow: none;
}
.profile-routine-editor:focus-visible {
    outline: 2px solid var(--color-highlight);
    outline-offset: -2px;
}
.profile-routine-editor.dragging {
    z-index: 5;
    background-color: var(--color-surface-raised);
    border-radius: var(--radius-small);
    box-shadow: var(--shadow-raised);
}

/* Active and archived cards use a static overview; inactive cards turn this
   same area into the explicit "Use program" action. */

.profile-list-select,
.preset-profile-list > button {
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
    padding: var(--spacing-m);
    color: var(--color-text);
    background: transparent;
    text-align: left;
}

.profile-list-select {
    position: relative;
    padding-right: 6.5rem;
}

.profile-name-row {
    display: flex;
    align-items: center;
    gap: 6px;
}

.profile-list-use {
    position: absolute;
    right: var(--spacing-m);
    top: 50%;
    display: inline-flex;
    align-items: center;
    gap: 4px;
    color: var(--color-accent);
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    transform: translateY(-50%);
}

.profile-list-use .tools-list-chevron {
    width: 14px;
    height: 14px;
}

/* Deliberately bigger than the 0.8rem description text below it, so the
   program name reads as a heading rather than as incidental text.

   The library rows are in here too, and they are the reason the size is
   stated rather than inherited: these names live inside a <button>, which
   does not inherit the document's font size, so the name fell back to the
   UA's own 13.33px - 0.85px larger than the 0.78rem uppercase HISTORY /
   EDIT / DELETE labels beneath it. The program a row is about was the
   smallest-looking thing in it. */
.preset-profile-list > button strong,
.profile-list-select strong,
.profile-list-static strong {
    font-size: 1.05rem;
    font-weight: 700;
}

/* Full-width (unlike the shared .profile-name-row default, which only
   shrink-wraps its content) so the difficulty badge below has the whole
   button's width to right-align itself against. */
.preset-profile-list .profile-name-row {
    width: 100%;
}

/* Armed when this preset is already in rotation and the next tap would add a
   second copy (see onCreatePreset() in main.js). This button is built from
   elements, so armButton() leaves its content alone - the name colour is the
   whole signal, alongside the notification that explains it.

   No rail down the edge and no red: nothing here is being destroyed, so the
   danger colour this first used overstated a duplicate warning, and a
   hairline hung off one side of a row in a hairline-divided list read as a
   rendering fault rather than a state. --color-highlight is the same "this
   one is live" accent the rest of the app uses. */
.preset-profile-list > button.confirm strong {
    color: var(--color-highlight);
}

/* Same plain-uppercase-text voice as .exercise-tier-badge (T1/T2/T3) rather
   than a filled pill, so novice/intermediate/advanced reads as a quiet
   classification, not a second competing badge. Pushed to the row's right
   edge (auto left margin, in the now-full-width row above) rather than
   sitting right beside the name, so it reads as a tag on the card rather
   than part of the title. */
.preset-profile-list .preset-difficulty-badge {
    flex-shrink: 0;
    margin-left: auto;
    font-size: 0.65rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    padding: 0;
    color: var(--color-text-muted);
    background: none;
    border-radius: 0;
}

/* width: 100%, not the old 90%-centered inset - .session-card below already
   supplies its own var(--spacing-m) padding, the same inset
   #session-underline-bar and .tools-list use, so the "Let's go!" welcome
   card lines up with both instead of sitting further indented. */
#welcome-screen {
    width: 100%;
}

/* Bigger and narrower than the shared .session-card-more treatment used by
   real workout cards' "No exercises yet"/"+N more" rows - this message is
   the welcome card's only content besides its button, so it reads more like
   deliberate body copy than a muted list aside. Scoped to #welcome-screen so
   real workout cards are unaffected. */
#welcome-screen .session-card-more {
    max-width: 80%;
    margin: 0 auto;
    font-size: 0.95rem;
    text-align: center;
}

.profile-list-select .profile-list-meta,
.profile-list-static .profile-list-meta,
.preset-profile-list span {
    color: var(--color-text-muted);
    font-size: 0.8rem;
}

.preset-profile-list .preset-profile-description {
    line-height: 1.45;
}

.preset-profile-list .preset-profile-cta {
    display: flex;
    align-items: center;
    align-self: flex-end;
    gap: 4px;
    margin-top: var(--spacing-s);
    color: var(--color-accent);
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
}

.preset-profile-list .preset-profile-cta .tools-list-chevron {
    width: 14px;
    height: 14px;
}

.profile-list-actions,
.profile-routine-heading,
.profile-exercise-row {
    display: flex;
    align-items: center;
    gap: 6px;
}

/* Flat text actions share the row's inset rhythm. There is no card boundary
   around them, so the single rule above is enough to state the relationship. */
.profile-list-actions button {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: var(--button-min-height);
    padding: 0;
    font-size: 0.78rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-text-muted);
    background: none;
    border: none;
    transition: color 0.15s ease;
}

/* Only real actions are rendered. auto-fit distributes each card's available
   actions evenly without keeping dead placeholder cells. */
.profile-list-actions {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(4.5rem, 1fr));
    gap: 0;
    align-items: stretch;
    border-top: 1px solid var(--color-border);
}

/* Which program is running. Accented because it answers the library's most
   important state question at a glance. */
.profile-list-active {
    display: inline-flex;
    align-items: center;
    min-height: 0;
    margin-left: auto;
    padding: 0;
    color: var(--color-highlight);
    background: none;
    border: none;
    border-radius: 0;
    font-size: 0.65rem;
    font-weight: 800;
    letter-spacing: 0.06em;
    text-transform: uppercase;
}

/* Hairlines make the compact action bar read as one control group. */
.profile-list-actions > * {
    position: relative;
}

.profile-list-actions > *:not(:first-child)::before {
    content: '';
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    width: 1px;
    background-color: var(--color-border);
    pointer-events: none;
    opacity: 0.8;
}

/* Armed by armButton() in main.js, which also swaps the label to "Confirm".
   Full danger rather than the muted grey it shares with Edit/Duplicate at
   rest, plus the pulse every other armed control in the app carries - colour
   alone left this reading as an ordinary relabel, which is the same thing
   that made Save/Discard easy to miss. --color-danger is the reason
   --pulse-armed-dim/-lit are as narrow as they are; see :root. */
.profile-list-actions button.confirm {
    color: var(--color-danger);
    font-weight: 800;
    animation: armedTextPulse var(--pulse-armed) ease-in-out infinite;
}

.preset-profile-list > button {
    padding: var(--spacing-sm) var(--spacing-m);
    background-color: transparent;
    background-image: linear-gradient(to right, var(--color-border), var(--color-border));
    background-repeat: no-repeat;
    background-position: right bottom;
    background-size: calc(100% - var(--spacing-m)) 1px;
    border: none;
    border-radius: 0;
}

.profile-routine-heading {
    align-items: flex-start;
    padding: var(--spacing-s) var(--spacing-m) 0;
    margin-bottom: var(--spacing-s);
    background: none;
    border-bottom: none;
}

.profile-routine-title {
    flex: 1 1 auto;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    min-width: 0;
}

.profile-routine-actions {
    flex: 0 0 auto;
    display: flex;
    gap: 6px;
    margin-right: var(--spacing-s);
}

/* The shared reorder handle uses negative block margins so it does not make a
   compact exercise row taller. A workout heading has a second line for its
   preferred day, so that shortcut pulled this handle above the name's own
   centreline. Keep the horizontal pull-back, but give this instance the same
   34px line box as the workout name. */
.reorder-handle.profile-card-handle {
    height: 34px;
    margin: 0 -0.35rem;
}

/* Only the workout-card name is still a field. The exercise rows stopped being
   a form: each one is a button opening its own sheet, because three inputs
   could never say what a band, a cardio interval or an AMRAP prescribes - and
   at 360px the name field truncated at ~100px trying. */
/* The app's own focused-field look for workout-card names. */
.profile-routine-name:focus-visible {
    outline: 1px solid var(--color-accent);
    outline-offset: 0;
    background-color: var(--color-press);
}

/* Sits at the row's right edge, after the figure - the app's own "this opens
   something", borrowed from .tools-list. At 0.45 opacity on top of an already
   muted colour it was the faintest mark on the row, which made the app's one
   signal that this row opens something the easiest thing on it to miss. It
   carries the full muted colour now; the row's own surface does the rest. */
.profile-exercise-open .tools-list-chevron {
    flex: 0 0 auto;
    width: 16px;
    height: 16px;
    color: var(--color-text-muted);
}

/* A line, not a field. Nothing else in the app puts a border around editable
   text, and four icon buttons beside it had squeezed this to about 90px.
   At the old 0.72rem the card's own title was the smallest text on the card,
   which read as exactly backwards.

   1rem is a floor, not a preference: iOS zooms the whole page in whenever a
   focused input computes below 16px, and it does not zoom back out - so
   renaming a workout card left the lifter pinching their way back. Every
   text-entry control in this app is at or above 16px for that reason; see
   the guard test in app.test.mjs. The uppercase tracking is what keeps it
   from shouting over the exercise names at the same size. */
.profile-routine-name {
    flex: 1;
    min-width: 0;
    min-height: 34px;
    /* Padded, then pulled back by the heading row's own 6px gap, so the focus
       ring has somewhere to sit without the name moving when it appears.
       .profile-routine-fixed above copies these values for the same reason.
       Pulled left only: the left neighbour is a
       drag handle whose glyph sits well inside its 32px box, so the ring has
       room to reach into that gap - the right neighbour is the day chip,
       which the ring would otherwise land on.

       The left padding is 15px rather than the gap's own 6px so this name's
       text lands on the same vertical guide as the exercise names it titles.
       Those sit inside .profile-exercise-open, whose 1px border and 8px
       padding put their text 9px further right than a bare 6px puts this one
       - so the card's title started 9px left of every row beneath it. */
    width: 100%;
    padding: 0;
    margin: 0;
    font-size: 1rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-text);
    background: none;
    border: none;
    border-radius: var(--radius-small);
}
.profile-routine-name::placeholder {
    color: var(--color-text-muted);
}

/* The whole row, minus the handle and the trash. It opens this exercise's own
   sheet - every setting the row has lives behind it - but flat and borderless
   it read as a label with a faint chevron after it, and the tap that opens the
   sheet was being discovered by accident.

   It is still a list row rather than a form field: what it gains is a resting
   surface and an edge, the same pair .exercise-surface uses to say "this is an
   object you act on". A raised row is not the boxed control the note above
   .profile-routine-name rules out - that one is about *editable text*, which
   this is not; the name here is a label on a button, typed somewhere else. */
.profile-exercise-open {
    flex: 1;
    display: flex;
    /* Centred, not baseline. Baseline tied the name, the link mark, the W×n
       chip and the prescription figure to one another, which reads well - but
       it also parked the whole group against the top of a box that min-height
       keeps 6.5px taller than the group is, so the name sat 8.5px from the top
       and 14px from the bottom of its own button.

       align-content cannot fix that: a single-line flex container's line is
       always the container's full inner cross size, so there is no free space
       left over for it to distribute. align-items is the only lever, and the
       items here run 15.5-17.5px tall, so centring them individually moves
       each off the shared baseline by well under a pixel. */
    align-items: center;
    gap: var(--spacing-s);
    min-width: 0;
    min-height: 48px;
    padding: 6px 0;
    /* The figure and chevron cannot shrink, so under a long name the row's
       content can exceed its box by a few pixels - clipped here so it can
       never widen the panel's scroll area. The name span already truncates. */
    overflow: hidden;
    background: none;
    border: none;
    border-radius: 0;
    text-align: left;
}
.profile-exercise-name {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--color-text);
    text-overflow: ellipsis;
    white-space: nowrap;
}

.profile-exercise-rows {
    display: flex;
    flex-direction: column;
    gap: 0;
    padding: 0;
}

.profile-superset-editor {
    display: flex;
    flex-direction: column;
    gap: 6px;
    margin: var(--spacing-s) var(--spacing-m);
    padding: var(--spacing-s) 0 var(--spacing-s) var(--spacing-s);
    background: none;
    border: none;
    border-left: 2px solid var(--color-accent-border);
    border-radius: 0;
}

.profile-superset-heading {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-s);
}

/* The group's rest, on its heading row beside the label. Reads as a value you
   can change rather than an icon: it is the one number a group carries, and
   the rounds below it are already dense with figures. */
.profile-superset-rest {
    flex: 0 0 auto;
    min-height: 32px;
    padding: 0 var(--spacing-s);
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    color: var(--color-text-muted);
    background: none;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-control);
    font-variant-numeric: tabular-nums;
}
.profile-superset-heading strong {
    flex: 1;
    color: var(--color-highlight);
    font-size: 0.72rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
}

/* The builder reorders with the same handle the Edit Exercises panel uses, but
   keeps its own row layout - see enableDragReorder()'s itemSelector in ui.js
   for why the draggable class is separate from .reorder-item. */
.profile-drag-item {
    position: relative;
}
.profile-drag-item.dragging {
    z-index: 5;
    background-color: var(--color-surface-raised);
    border-radius: var(--radius-small);
    box-shadow: var(--shadow-raised);
}
.profile-drag-item:focus-visible {
    outline: 2px solid var(--color-highlight);
    outline-offset: 2px;
}
/* The exercise rows share the heading's horizontal gutter, so the card's
   handles form one left column and its trailing circles one right column -
   the rows used to run full-bleed, which pushed the collapse chevron onto
   the physical screen edge and scattered every handle onto its own x. */
.profile-exercise-row {
    padding-inline: var(--spacing-m);
    background-image: linear-gradient(to right, var(--color-border), var(--color-border));
    background-repeat: no-repeat;
    background-position: right bottom;
    background-size: calc(100% - 3.9rem) 1px;
}
/* Inside a superset card the block already carries its own spacing-s
   padding; subtract it so the inner rows keep the same columns as the
   rows outside the card. */
.profile-superset-editor .profile-exercise-row {
    padding-inline: 0;
    background-size: calc(100% - 2.9rem) 1px;
}
.profile-superset-editor .profile-set-rows {
    padding-left: calc(1.9rem - var(--spacing-s));
    padding-right: calc(var(--spacing-m) - var(--spacing-s));
}

.group-round-editor {
    margin-bottom: var(--spacing-m);
    padding: var(--spacing-sm);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-small);
}

.group-round-editor > h3 {
    margin: 0 0 var(--spacing-sm);
    font-size: 0.78rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
}

.group-station-editor {
    display: grid;
    gap: var(--spacing-s);
    margin-top: var(--spacing-sm);
    padding-top: var(--spacing-sm);
    border-top: 1px solid var(--color-border);
}

.group-station-editor select {
    width: 100%;
    min-height: 44px;
    padding-inline: var(--spacing-sm);
    color: var(--color-text);
    background: var(--color-surface);
    border: 1px solid var(--color-border-strong);
    border-radius: var(--radius-small);
}

.compact-actions {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--spacing-s);
}

.compact-actions button {
    min-height: 44px;
    padding: var(--spacing-s);
    color: var(--color-text);
    background: transparent;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-control);
}

/* Header row + its set rows as one unit - the unit is what drags (see
   profileExerciseBlockHtml() in ui.js: swapPast() reparents the item element,
   so a drag class on the header alone would strand its set rows). */
.profile-exercise-block {
    display: flex;
    flex-direction: column;
}

/* One row per set under the header - the builder showing the program the way
   it will be performed. Indented past the handle gutter so the numbers land
   on the same left guide the live card's set rows keep; the right inset
   matches the header rows above, so the revealed trash sits in the same
   trailing column as the circled icons. */
.profile-set-rows {
    display: flex;
    flex-direction: column;
    padding-left: 1.9rem;
    padding-right: var(--spacing-m);
}

/* Explicit, because display: flex above beats the UA's [hidden] rule. */
.profile-set-rows[hidden] {
    display: none;
}

/* .set-column supplies the dotted rule and row height; only the tighter
   height is local, since a prescription row has no status button to clear. */
.profile-set-row {
    min-height: 2rem;
}

/* Row deletion lives behind the number toggle, exactly like the live card:
   a closed row shows only its prescription, an open one swaps the number
   for the + and reveals the trash beside the figure. The slot/toggle/
   actions classes are the live card's own (see rowControlHtml() in ui.js),
   so their swap CSS applies here unchanged - only the trash reveal is
   builder-local, because the live card's trash stands in for a completion
   dot this row does not have. */
.profile-set-row > .profile-set-remove {
    display: none;
}
.profile-set-row.row-controls-open > .profile-set-remove {
    display: flex;
    animation: rowControlPulse 1s ease-in-out infinite;
}

/* The RIR slot as the builder shows it: the live row's own "R" marker, but
   a statement rather than a control - there is no set to record against yet,
   so it states that this exercise *will* ask for reps in reserve. Same width
   as the live button so the two rows line up column for column. */
.rir-btn--static {
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 var(--rir-btn-width);
    width: var(--rir-btn-width);
    font-size: 0.9rem;
    color: var(--color-text-muted);
    opacity: 0.7;
}

/* The row's own rest, in the builder as on the card. Its own line-height
   would otherwise make the builder's tighter 2rem row taller than its
   siblings. */
.profile-set-row > .set-rest-chip {
    height: auto;
}

/* The row's one control: the figure, opening the set step. */
.profile-set-edit {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: flex-start;
    min-width: 0;
    min-height: 2rem;
    padding: 0 var(--spacing-s);
    background: none;
    border: none;
    text-align: left;
}
/* The set-rows disclosure chevron; flipped while the rows are showing. The
   drawn circle stays the row's 36px convention; the hit area is extended
   past it to the 44px a thumb needs, the same ::after trick
   .reorder-handle uses. */
.profile-sets-toggle {
    position: relative;
}
.profile-sets-toggle::after {
    content: '';
    position: absolute;
    /* Same right-edge rule as .profile-set-remove::after below. */
    inset: -4px 0 -4px -8px;
}
.profile-sets-toggle svg {
    width: 16px;
    height: 16px;
    transition: transform 0.15s ease;
}
.profile-sets-toggle[aria-expanded="true"] svg {
    transform: rotate(180deg);
}

/* One quiet trash per set row. Borderless and small, unlike the exercise
   row's circled trash - deleting a set is cheap and local, and 25 circled
   icons would out-shout the prescriptions they sit beside. The hit area is
   extended past the drawn glyph to the 44px a thumb needs. */
.profile-set-remove {
    position: relative;
    flex: 0 0 auto;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2rem;
    height: 2rem;
    min-height: 0;
    padding: 0;
    color: var(--color-text-muted);
    background: none;
    border: none;
}
.profile-set-remove::after {
    content: '';
    position: absolute;
    /* Never rightward: this button sits on the full-bleed row's right edge,
       and a pseudo poking past it is scrollable overflow - the panel gains a
       6px sideways drift with nothing visible to explain it. */
    inset: -6px 0 -6px -12px;
}
.profile-set-remove svg {
    width: 14px;
    height: 14px;
}
/* "W×3" beside the prescription figure - shown only for a count-only warmup
   prescription; explicitly prescribed rungs render as W rows instead (see
   profileExerciseBlockHtml() in ui.js). */
.profile-warmup-chip {
    flex: 0 0 auto;
    font-size: 0.68rem;
    font-weight: 700;
    letter-spacing: 0.04em;
    color: var(--color-text-muted);
}

.profile-superset-row-marker {
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 36px;
    width: 36px;
    height: 36px;
    color: var(--color-highlight);
    font-size: 0.78rem;
    font-weight: 800;
    border: 1px solid var(--color-accent-border);
    border-radius: 50%;
}


.profile-icon-btn {
    flex: 0 0 36px;
    min-height: 36px;
    padding: 4px;
    color: var(--color-text-muted);
    background: transparent;
    border: 1px solid var(--color-border);
    border-radius: 50%;
}

.profile-exercise-row > .profile-icon-btn {
    border-color: transparent;
    background-color: transparent;
}

.profile-routine-actions .profile-icon-btn {
    background-color: transparent;
}

.profile-icon-btn:disabled {
    opacity: 0.35;
}

/* Matches .remove-exercise-btn svg's sizing - a comfortable, clearly visible
   icon within the 36px circle, rather than the plain ↑/↓/⎘/× font glyphs
   this replaced (see MOVE_UP_ICON_SVG etc. in ui.js), which had no explicit
   size and rendered small/thin. */
.profile-icon-btn svg {
    width: 18px;
    height: 18px;
}

/* Armed delete on a non-empty workout card - the same danger pulse every
   other armed control carries, on the circle's own glyph and ring. */
.profile-icon-btn.confirm {
    color: var(--color-danger);
    border-color: var(--color-danger);
    animation: armedTextPulse var(--pulse-armed) ease-in-out infinite;
}

/* Program filter: a disclosure row, not a native select (see the comment on
   its markup in ui.js). Reuses the tools-list vocabulary - label, value,
   chevron, expand in place - so it reads as the same kind of control as the
   start page's own Data Tools drawer. */
.history-program-filter {
    margin-bottom: var(--spacing-m);
}

.history-program-toggle {
    display: flex;
    align-items: center;
    gap: var(--spacing-sm);
    width: 100%;
    min-height: 2.6rem;
    padding: 0;
    color: var(--color-text);
    border-bottom: 1px solid var(--color-border);
}

.history-program-label {
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.history-program-value {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    text-align: right;
    font-size: 0.92rem;
    font-weight: 600;
}

.history-program-chevron {
    transition: transform 0.2s ease;
}

.history-program-toggle[aria-expanded="true"] .history-program-chevron {
    transform: rotate(90deg);
}

.history-program-options {
    display: flex;
    flex-direction: column;
}

.history-program-option {
    justify-content: flex-start;
    width: 100%;
    min-height: 2.4rem;
    padding: 0 0 0 var(--spacing-m);
    font-size: 0.92rem;
    color: var(--color-text-muted);
    border-bottom: 1px solid var(--color-border);
    transition: color 0.15s ease, border-color 0.15s ease;
}

.history-program-option:last-child {
    border-bottom: none;
}

.history-program-option[aria-checked="true"] {
    color: var(--color-text);
    border-bottom-color: var(--color-accent);
}

/* One button per workout, plus All. A wrapping row rather than the settings
   segmented track: a program can have six routines (Push/Pull/Legs' A-F),
   which a fixed-width track would crush. Same flat/underline vocabulary as
   the rest of the app - the selected workout is marked by an accent rule
   under it, not a filled pill. */
.history-routine-filter {
    display: flex;
    flex-wrap: wrap;
    gap: var(--spacing-m);
    margin-bottom: var(--spacing-m);
}

.history-routine-btn {
    min-height: 2rem;
    padding: 0 0.15rem;
    font-size: 0.82rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-text-muted);
    background: none;
    border-radius: 0;
    border-bottom: 1px solid transparent;
    transition: color 0.15s ease, border-color 0.15s ease;
}

.history-routine-btn[aria-checked="true"] {
    color: var(--color-text);
    border-bottom-color: var(--color-accent);
}

.log-profile-name {
    min-width: 0;
    overflow: hidden;
    color: var(--color-text-muted);
    font-size: 0.72rem;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* One card per workout type; swiping between them *is* Switch now - there
   is no separate switch button. Cards are edge-to-edge (no side gutter, no
   peek of a neighbor) - the dots below are the only affordance that there's
   more than one, confirming which is currently centered (see
   renderStartCards() in ui.js, which keeps them in sync). */
.session-card-scroller {
    display: flex;
    width: 100%;
    max-width: var(--container-width);
    overflow-x: auto;
    /* Stops a swipe past the last card from chaining up to the page, where
       it would become the browser's back/forward gesture. */
    overscroll-behavior-x: contain;
    scroll-snap-type: x mandatory;
    scrollbar-width: none;
}

.session-card-scroller::-webkit-scrollbar {
    display: none;
}

/* No fill, no border, no radius - deliberately not a "card" any more (see
   #session-underline-bar below for the one piece of persistent chrome
   that replaces it). Top padding is 0 so the Start button sits flush
   against this card's own top edge, which is what lets ui.js compute the
   underline bar's position as just that button's own rendered height. */
/* The whole card starts its workout, not just the button at the top (see
   wireEvents() in main.js) - the exercise list below is describing what that
   tap begins, so it belongs to the same target instead of being a dead zone
   over most of the card's height. */
.session-card {
    flex: 0 0 100%;
    scroll-snap-align: center;
    display: flex;
    flex-direction: column;
    gap: 14px;
    padding: 0 var(--spacing-m) var(--spacing-l);
    text-align: left;
    cursor: pointer;
}

/* The workout letter already reads in the button's own label, so there's
   no separate heading above it any more - "Up Next" joins it as a small
   label above rather than a heading-level tag of its own. No fill, no
   pill shape any more either - this is a plain typographic control now,
   the accent line below (#session-underline-bar) is the "button". */
.session-card-start {
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    gap: 4px;
    padding: 6px 0;
    color: var(--color-text);
    background: transparent;
    border-radius: var(--radius-small);
    /* A <button> is text-align: center by default. That was invisible while
       the label shrink-wrapped its own text, but the label is full-width now
       (it needs to be, to ellipsis a long descriptor), so the default started
       centring it under a left-aligned "UP NEXT" eyebrow. */
    text-align: left;
}

.session-card-start-tag {
    font-size: 0.68rem;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

/* Where this card sits in the rotation - UP NEXT, AVAILABLE, PREVIOUS - in
   the theme's own colour, with the program name beside it left muted. It is
   the one word on the card that changes meaning per card, so it is the one
   worth colouring; the program name is the same on all of them.

   --color-highlight rather than --color-accent, which is what "the theme's
   colour" means for small text: the two are the same value in every theme
   named after a colour, and where they differ (Candy Pop, Cobalt) highlight is
   the one picked to stay legible as type. The raw accent drops to 3.2:1 on
   Candy Pop. */
.session-card-rotation {
    color: var(--color-rotation);
}

/* Scoped to the Start button rather than set on the class itself, which the
   Training Programs panel reuses for its short "ACTIVE" badge. Here the tag
   carries the program's name, up to 40 typed characters, and wrapping it to a
   second line would not merely look wrong: #session-underline-bar's position is
   computed from this button's rendered height (see syncDots() in ui.js), so a
   taller tag moves the accent rule off the row the header's own hairline has to
   meet. Same reasoning as the label below it. */
.session-card-start .session-card-start-tag {
    max-width: 100%;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* Capitalized via text-transform, not upper-cased in the markup itself, so
   assistive tech still announces "Start Workout A" rather than spelling it
   out letter by letter. Not text-transform: uppercase at this size, unlike
   the tag above - a big headline set in caps reads as shouty/harder to
   scan; the small eyebrow tag is where uppercase earns its keep. */
/* One line, always. #session-underline-bar's position is computed from this
   button's rendered height (see syncDots() in ui.js), so a label wrapping to
   a second line does not just look wrong - it moves the accent rule off the
   row the header's own hairline has to meet. The verb is dropped from the
   copy for the same reason: "Workout C - Upper Hypertrophy" fits where
   "Start Workout C - Upper Hypertrophy" did not, and the eyebrow above
   already says UP NEXT. Ellipsis is the backstop for a descriptor longer
   than any of ours. */
.session-card-start-label {
    width: 100%;
    font-size: 1.5rem;
    font-weight: 800;
    letter-spacing: -0.01em;
    line-height: 1.15;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* What this day is for, under its name. One line, ellipsed: the card is a tap
   target, and a goal that wrapped would push the exercise list it introduces
   off the bottom of a card whose height the carousel fixes. */
.session-card-goal {
    width: 100%;
    margin-top: 2px;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--color-text-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* The welcome card is the exception to the "no caps at this size" note
   above, and it can be: it carries a two-word call to action rather than a
   program name, so there is nothing to scan and nothing to ellipsis. It is
   also the only card with no eyebrow tag above it to align to, which is
   what lets it centre without leaving the tag stranded left. Real session
   cards keep their left-aligned, sentence-case label. */
#welcome-screen .session-card-start {
    align-items: center;
    text-align: center;
}

#welcome-screen .session-card-start-label {
    text-transform: uppercase;
    /* The negative tracking above is for a long name set tight; caps need
       the opposite or they run together. */
    letter-spacing: 0.02em;
}

/* Fixed chrome: a sibling of .session-card-scroller (see index.html), not
   part of any one card, so it never moves while cards slide past beneath
   it. Its "top" is set per-frame by renderStartCards()'s syncDots() in
   ui.js to match whichever card is currently centered, since that card's
   button height can vary (e.g. a descriptor wrapping to a second line).
   Horizontally inset to match the cards' own side padding so it reads as
   underlining the text above it, not spanning the raw screen edge. */
.session-underline-bar {
    position: absolute;
    left: var(--spacing-m);
    right: var(--spacing-m);
    top: 0;
    /* Same weight and color as .workout-timer-badge-rule (the hairline
       under the active exercise name in the header), which takes over this
       exact screen row the instant a workout starts - so the line neither
       thickens nor changes color across the transition. */
    height: 1px;
    background-color: var(--color-accent);
    pointer-events: none;
    transition: top 0.2s ease;
}

/* The welcome screen's fallback card (see index.html) isn't inside the
   scroller and its label never wraps, so it needs no JS height sync - just
   an ordinary in-flow bar instead of one positioned against a moving
   target. */
.session-underline-bar--static {
    position: static;
    width: 100%;
}

/* Two columns of up to SESSION_CARD_MAX_EXERCISES/2 - see renderStartCards()
   in ui.js for the cap and its "+N more" overflow row, styled the same as an
   empty-list placeholder below (.session-card-more). The accent dots and
   their hanging indent are gone: twelve marks in a half-width grid read as
   clutter, and each entry is now two lines of its own, which separates them
   without any marker at all.

   Row-major (the default flow, so no grid-auto-flow here), so the exercises
   read 1 2 / 3 4 / 5 6 in the order the workout actually performs them.
   Column-major would read down one side and back up the other with nothing
   on screen to say so. */
.session-card-list {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    /* Fixed rather than auto, so "three rows tall" is a height this can be
       clipped to. Two text lines - the name at 0.82rem and the figure at
       0.76rem - plus the 1px between them. */
    grid-auto-rows: var(--session-card-row);
    gap: var(--session-card-row-gap) var(--spacing-m);
    /* Every exercise is rendered; five rows of them are shown until the card
       is opened. Clipped rather than scrolled: this sits inside a
       horizontally snapping carousel, where a vertical drag had to win an
       argument with the carousel before it did anything, and a scroll region
       with no visible bar gives no hint it can be scrolled at all.
       SESSION_CARD_COLLAPSED in ui.js is this row count times two columns and
       has to stay in step with it - see that constant. */
    max-height: calc(5 * var(--session-card-row) + 4 * var(--session-card-row-gap));
    overflow: hidden;
    transition: max-height 0.25s ease;
    list-style: none;
    margin: 0;
    padding: 0;
}

/* Opened, to the height this list's own exercises need - measured per card by
   renderStartCards() in ui.js, which is the only reason a custom property is
   involved at all. `none` cannot be transitioned from a fixed value, and the
   flat 60rem that stood in for it made *closing* look broken: max-height had
   to travel from 960px down to the ~177px the content actually occupies
   before anything moved on screen, so a 250ms transition sat frozen for the
   first 180ms and then snapped shut in the last 66ms. Opening hid the same
   fault - it cleared the content height about 10ms in, which reads as
   instant, which is why only the close was ever reported.

   The 60rem fallback survives for a list that has not been measured yet: too
   tall is invisible while the card is standing still, and only costs the
   dead travel above if it is closed before the next render. */
.session-card-list.is-expanded {
    max-height: var(--session-card-open, 60rem);
}

/* A clipped list ends the way .duration-wheel does - dissolving into the page,
   not sliced. The cap above lands exactly on a row boundary, so a long workout
   used to stop on a clean horizontal cut and the only thing saying there was
   more was a "+N more" caption under it. The fade replaces that caption
   outright: it makes the same statement, in the thing the statement is about,
   and it does not have to pick a number to say it. (The count survives as the
   block's aria-label - see sessionCardExercisesHtml() in ui.js - because a
   fade says nothing to a screen reader.)

   Two rows at full strength, then a long ramp across the remaining three that
   reaches nothing at the box's bottom edge, a little above the carousel's
   dots. Gentle on purpose: a short fade reads as a row that has been dimmed,
   a long one reads as a list carrying on past the card.

   Scoped to [data-action="expand-exercises"] because that attribute is an
   exact "this list is clipped" flag - ui.js only emits it when the exercises
   outnumber SESSION_CARD_COLLAPSED, which is exactly what this box holds - so
   a list that fits is never dimmed for rows it does not have.

   Opened, the rule stops matching and the fade snaps off while the height
   animates. A mask cannot be transitioned without registering the gradient
   stop as an @property, and that would be a second thing to keep in step with
   the 250ms max-height transition for an artifact on the one card the lifter
   just tapped. */
.session-card-exercises[data-action="expand-exercises"] .session-card-list:not(.is-expanded) {
    -webkit-mask-image: linear-gradient(to bottom, #000 0,
        #000 calc(2 * var(--session-card-row) + var(--session-card-row-gap)), transparent 100%);
    mask-image: linear-gradient(to bottom, #000 0,
        #000 calc(2 * var(--session-card-row) + var(--session-card-row-gap)), transparent 100%);
}

/* The whole block is the toggle - everything between the title's rule and the
   bottom of the card. flex: 1 so it claims whatever height the card has left,
   which is what makes the dead space under a short list part of the target
   rather than a strip that starts the workout instead. */
.session-card-exercises {
    flex: 1 1 auto;
    min-height: 0;
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.session-card-list li {
    display: flex;
    flex-direction: column;
    gap: 1px;
    min-width: 0;
    font-size: 0.82rem;
    color: var(--color-text-muted);
}

/* The overflow/empty row is a statement about the list, not an entry in it,
   so it spans both columns and sits after them rather than taking a cell. */
.session-card-list li.session-card-more {
    grid-column: 1 / -1;
    font-size: 0.78rem;
    color: var(--color-text-muted);
    opacity: 0.75;
}

.session-card-exercise-name {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    letter-spacing: 0.02em;
    color: var(--color-text);
}

/* Muted and a step down: the name is what identifies the row, the weight is
   the detail under it. Reversing the old emphasis, which had the weight in
   full-strength text beside a muted name. */
.session-card-exercise-weight {
    font-family: var(--font-mono);
    font-size: 0.76rem;
    color: var(--color-text-muted);
    font-variant-numeric: tabular-nums;
}

.session-card-dots {
    display: flex;
    align-items: center;
    gap: 6px;
}

.session-card-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background-color: var(--color-border-strong);
    transition: background-color 0.2s ease, transform 0.2s ease;
}

.session-card-dot.active {
    background-color: var(--color-highlight);
    transform: scale(1.3);
}

/* The idle app shell's only scrolling region - the bottom flex child under
   .idle-start-card (see index.html for the order). It claims the full
   remaining height of <main> down to the screen's true bottom edge: flex: 1
   takes whatever space .idle-start-card doesn't need, and min-height: 0
   overrides the flex default of never shrinking below content height, which
   is what actually lets overflow-y: auto do anything. position/z-index date
   from when the footer logo was an overlay that scrolled content had to paint
   over; they now just give this region its own stacking context. Its own flex
   column + gap replicates the spacing <main> used to provide directly, back
   when #data-tools/#workout-controls were main's own direct children instead
   of living in here. */
.idle-scroll {
    position: relative;
    z-index: 1;
    flex: 1 1 auto;
    min-height: 0;
    width: 100%;
    display: flex;
    flex-direction: column;
    gap: var(--spacing-l);
    /* The gap under the last row, and the only one: it was the footer logo's
       padding until that mark was removed, and it stays a single flat value
       with no safe-area term in it. The shell above is already 100dvh, which
       on iOS standalone can itself exclude that inset - counted here as well
       it doubled on exactly one platform, which is how the logo used to sit
       about three centimetres high there. */
    padding-bottom: var(--spacing-sm);
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
}

/* Workout timer -------------------------------------------------------- */
#workout-timer {
    display: flex;
    align-items: center;
    justify-content: center;
    width: 100%;
}

/* Pill pinned left, digits pinned right - not centered as a pair. The pill
   claims all space left by the fixed-width digits, and the gap between the
   two equals the pill's left screen inset (--header-inline-inset). The
   exercise-card heading below no longer shares that left guide - it centers
   its own name - so only --exercise-name-size still carries across the two.
   The name is the flexible ellipsis target
   when space is tight; the workout letter, progress bars, and digits retain
   their full width. Both stay visible and in place while paused - the pill
   swaps its own content for End Workout while the elapsed time freezes
   (see #workout-timer-badge/#workout-timer-display below) - rather than the
   row disappearing for a separate controls row to replace it. An
   active rest countdown still takes priority over either: see the comment
   on syncView() in timers.js. */
.workout-timer-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) 5.4rem;
    align-items: center;
    width: 100%;
    padding-left: var(--header-inline-inset);
    padding-right: calc(var(--spacing-s) + var(--spacing-sm) * 2);
    column-gap: var(--header-inline-inset);

    /* One shared vertical rhythm for both of this row's columns - the badge
       (label row over the exercise name) and the timer (label over digits).
       Both bottom-align on the same line, so these are what let the two
       label rows land on the same line too despite the digits being much
       taller than the name. Kept here, on their common parent, because
       #workout-timer-display's gap is derived from all four. */
    --badge-row-gap: 9px;
    /* What the meters add below their SESSION/SETS labels, and so how much
       lower the badge's label row sits than a bare line of text would.
       Must stay equal to .workout-timer-badge-stat's gap plus
       .workout-timer-badge-meter's height. */
    --badge-meter-block: 8px;
    /* Both value rows, stated rather than left to `normal`, since the gap
       below is a difference between them and has to be exact. Each matches
       its element's own line-height. */
    --badge-name-height: calc(var(--exercise-name-size) * 1.2);
    --badge-digits-height: calc(var(--font-size-timer) * 1.05);
}

.workout-timer-slot {
    display: grid;
    width: 5.4rem;
}

.workout-timer-slot > #workout-timer-display,
.workout-timer-slot > #rest-timer,
.workout-timer-slot > #lift-cadence-timer {
    grid-area: 1 / 1;
    width: 100%;
}

/* Keep the inactive timer in the shared grid track's size calculation.
   `visibility` prevents it from being seen or tapped, while both modes
   reserve the same maximum intrinsic width and the scrollspy pill therefore
   stays perfectly still when DURATION swaps to REST. */
.workout-timer-slot > [hidden] {
    display: flex !important;
    visibility: hidden;
    pointer-events: none;
}

/* justify-content: flex-end, not center - #workout-timer-badge-status next
   to it bottom-anchors its content against .workout-timer-badge-rule (see
   that rule's own comment), so DURATION needs the same bottom anchor or its
   digits would float above the exercise name/rule's baseline instead of
   sharing it. */
/* Same bottom-anchored label-over-value stack as
   #workout-timer-badge-status beside it, down to the same gap and
   padding-bottom: both columns then end on the same line, putting the
   digits' baseline on the exercise name's and DURATION's on the WORKOUT
   A/SESSION/SETS row - rather than the timer floating as its own
   separately-centered block. */
#workout-timer-display {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-end;
    flex-shrink: 0;
    min-width: 0;
    min-height: var(--timer-stack-height);
    /* Not --badge-row-gap: the digits are far taller than the exercise name
       they bottom-align with, so reusing it would lift DURATION above the
       MENU row. Beside it the badge stacks the meter block, its
       row gap and the name; below this one there is only this gap and the
       digits. Making those two spans equal is what puts the labels on one
       line, and it leaves ~2px here. */
    gap: calc(var(--badge-meter-block) + var(--badge-row-gap)
              + var(--badge-name-height) - var(--badge-digits-height));
    padding: 0 0 6px;
    cursor: pointer;
    user-select: none;
    -webkit-user-select: none;
}

/* DURATION while running; PAUSED for as long as the elapsed digits are
   frozen. Only the compact label pulses, leaving the time continuously
   legible and keeping the timer's geometry completely still. */
#workout-timer-label {
    opacity: 1;
}

#workout-timer-label.workout-timer-label--paused {
    animation: pausedLabelPulse 2.4s ease-in-out infinite;
}

#workout-timer-digits {
    font-size: var(--font-size-timer);
    font-weight: 700;
    line-height: 1.05;
    font-variant-numeric: tabular-nums;
    -webkit-font-feature-settings: "tnum";
    font-feature-settings: "tnum";
    color: var(--color-text);
}

/* The outer shape only - position/radius/bevel/overflow-clip shared by
   whichever of its three children is currently showing (see
   #workout-timer-badge-status/-actions below, toggled in syncView() in
   timers.js). overflow: hidden is what keeps -actions' square corners
   inside this pill's rounded ones once it's split into two buttons. */
/* The outer shape only - fixed height, shared by whichever of the three
   states below is currently showing (see #workout-timer-badge-status/
   -actions/-rest-actions, toggled in syncView() in timers.js). Flat and
   gloss-free, the same move away from a filled/beveled pill that
   .session-card-start already made - this used to be a rounded, accent-
   filled chip; now every state is plain content sitting directly on the
   header. */
/* Deliberately not position: relative - .workout-timer-badge-rule below is
   absolutely positioned and has to resolve against .header-content (the
   full header width) rather than this column, and nothing here needs a
   positioning context of its own. MENU shares the badge's grid area
   instead of positioning absolutely, so the rule keeps its full-header
   containing block. */
#workout-timer-badge {
    display: grid;
    align-items: center;
    min-width: 0;
    width: 100%;
    height: var(--timer-stack-height);
}

/* The at-a-glance readout: MENU with two equally sized progress meters
   top-right (see .workout-timer-badge-meter below),
   the active exercise's name underneath, and a thin rule closing it off.
   The stats row, name, and rule all start [hidden] - see render() in
   exercise-scrollspy.js - so before an exercise is active the badge is
   just the MENU control and the session's accessible identity.
   justify-content: flex-end so the name stacks up from the bottom of the
   badge rather than centering in it, and padding-bottom clears the height
   of .workout-timer-badge-rule plus this same 5px gap - the rule no longer
   sits in this flow (it's positioned against .header-content instead, see
   that rule), so nothing else reserves room for it. */
#workout-timer-badge-status {
    grid-area: 1 / 1;
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
    gap: var(--badge-row-gap);
    min-width: 0;
    width: 100%;
    height: 100%;
    padding-bottom: 6px;
    color: var(--color-text);
}

.workout-timer-badge-eyebrow-row {
    display: flex;
    align-items: flex-start;
    justify-content: flex-end;
    gap: 10px;
    min-width: 0;
}

/* Every small uppercase label across the header - MENU, SESSION, SETS
   and the timer's own DURATION/PAUSED/REST beside them - is one treatment
   at one size. They previously ran at three (0.64/0.5/0.58rem), which read
   as three competing labels rather than the single row of labels they are;
   this settles on the largest of the three. Muted throughout, and uniformly
   so: the workout label used to set its letter in the accent colour, which
   made it the one coloured thing in a row of labels and read as a highlight
   nothing had asked for. SESSION and SETS pair with their accent meters and
   DURATION/REST with the digits; the workout label is a label all the way
   through.
   line-height: 1 trims each label's box to its own cap height, which is
   what lets them share a baseline once the badge and the timer beside it
   bottom-align (see #workout-timer-badge-status/#workout-timer-display). */
.workout-timer-badge-stat-label,
#workout-timer-label,
.rest-timer-label {
    font-size: 0.64rem;
    font-weight: 700;
    line-height: 1;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

/* The visual workout identity moved to MENU; the full card/program name
   remains in the badge's accessible label, assembled by exercise-scrollspy. */
.workout-timer-badge-stats {
    flex-shrink: 0;
    display: flex;
    align-items: flex-end;
    gap: 14px;
}

/* Accessible sibling of the aria-hidden status block. It occupies the old
   workout-label corner while the status meters remain visual-only, and the
   full 44px height makes the small eyebrow treatment a real touch target. */
#main-menu-btn {
    grid-area: 1 / 1;
    align-self: start;
    justify-self: start;
    z-index: 2;
    display: flex;
    align-items: flex-start;
    gap: 0.15rem;
    min-width: 0;
    height: 2.75rem;
    min-height: 2.75rem;
    padding: 0.25rem 0.25rem 0 0;
    font-size: 0.64rem;
    font-weight: 700;
    line-height: 1;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    white-space: nowrap;
    color: var(--color-text-muted);
    background: transparent;
}

#main-menu-btn svg {
    flex: 0 0 auto;
    width: 0.78rem;
    height: 0.78rem;
    margin-top: -0.08rem;
}

.workout-timer-badge-stat {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
}

/* Type comes from the shared label rule above; only the meter pairing is
   its own. */

/* Both meters share this exact box - same length, same thickness - so
   Session (one continuous fill) and Sets (equal flex segments, one per
   set - see #workout-timer-badge-sets below) read as two different kinds
   of the same instrument rather than two differently sized ones. */
.workout-timer-badge-meter {
    display: flex;
    align-items: center;
    width: 56px;
    height: 4px;
    border-radius: 2px;
    background-color: var(--color-border-strong);
    overflow: hidden;
}

/* A 320px viewport leaves the badge column about 162px after the timer and
   header insets. Even the compact MENU target and the ordinary 56px meters
   cannot occupy that eyebrow row without crossing SESSION. Keep the two
   instruments equal, but shorten both at legacy-phone widths; 44px still
   gives every set segment a clear state while leaving a measured gap between
   the menu target and the first stat (rather than relying on text alone). */
@media (max-width: 340px) {
    .workout-timer-badge-stats {
        gap: 8px;
    }

    .workout-timer-badge-meter {
        width: 40px;
    }

    .workout-timer-badge-stat-label {
        font-size: 0.58rem;
        letter-spacing: 0.08em;
    }
}

.workout-timer-badge-meter-fill {
    display: block;
    height: 100%;
    width: 0%;
    background-color: var(--color-accent);
    transition: width 0.25s ease-out;
}

/* Sets: the same 56x4 box as Session, just divided into one flex segment
   per set (generated in render(), exercise-scrollspy.js) instead of one
   continuous fill. */
.workout-timer-badge-meter--segmented {
    background-color: transparent;
    gap: 2px;
}

#workout-timer-badge-sets i {
    display: block;
    flex: 1;
    height: 100%;
    border-radius: 1px;
    background-color: var(--color-border-strong);
    transition: background-color 0.2s ease;
}

#workout-timer-badge-sets i.on {
    background-color: var(--color-accent);
}

/* flex-shrink: 1 (not the default 0 every other child here uses) plus
   min-width: 0 is what makes this the one part of the badge that actually
   compresses - text-overflow: ellipsis then keeps that compression
   graceful instead of just clipping mid-character. Same size and weight
   as .exercise-name on the exercise cards below - deliberately not a
   bigger headline treatment, so the two stay matched. */
#workout-timer-badge-name {
    min-width: 0;
    flex-shrink: 1;
    font-size: var(--exercise-name-size);
    font-weight: 700;
    line-height: 1.2;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Plain hairline closing off the status block - was a curved notch cut
   between the letter and the name; now a flat rule under the name, the
   same underline language the redesigned start button uses.

   Deliberately the exact same declarations #session-underline-bar uses on
   the idle screen (inset var(--spacing-m) from both edges of an equally
   wide, position: relative ancestor), because the two are the same line as
   far as the user is concerned: tapping Start swaps the idle screen for the
   workout screen, and this rule has to land on the pixel row that one just
   occupied so the line never appears to jump. Positioning it against
   .header-content rather than laying it out inside the badge is what makes
   that possible - in flow it could only ever span the badge's own grid
   track, so it needed a hand-tuned width that reached under the timer slot
   but still stopped short of the idle bar's gutters. It stays a child of
   -status so hiding that (paused, or before an exercise is active) still
   hides the rule with it. */
.workout-timer-badge-rule {
    position: absolute;
    left: var(--spacing-m);
    right: var(--spacing-m);
    bottom: 0;
    height: 1px;
    background-color: var(--color-accent);
}

/* Paused (End Workout) and the active rest timer (-15/Edit/+15) share
   this box with -status above - same fixed height, same position - only
   the content changes. Flat text matches -status and keeps the group balanced
   in the header however long the translated words are. */
#workout-timer-badge-actions,
#workout-timer-badge-rest-actions,
#workout-timer-badge-cadence-actions {
    grid-area: 1 / 1;
    display: grid;
    grid-template-columns: minmax(0, auto);
    justify-content: center;
    column-gap: var(--spacing-m);
    align-items: stretch;
    position: relative;
    width: 100%;
    height: 100%;
}

#workout-timer-badge-actions button,
#workout-timer-badge-rest-actions button,
#workout-timer-badge-cadence-actions button {
    display: flex;
    /* No `width: 100%` any more: the column is the label's own width, and a
       stretched button would re-introduce the gap it used to leave. The
       inline padding is what keeps short words comfortably separated. */
    padding-inline: var(--spacing-sm);
    white-space: nowrap;
    /* --header-action-scale is set by fitHeaderActionLabels() in ui.js: 1 when
       the labels fit, less when a language's words are longer than the badge.
       The base size is exposed as its own property so that measuring pass can
       read it without having to know which token it came from. */
    --header-action-base-size: var(--exercise-name-size);
    align-items: center;
    justify-content: center;
    min-width: 0;
    min-height: 0;
    height: 100%;
    color: var(--color-text);
    background: transparent;
    font-size: calc(var(--header-action-base-size) * var(--header-action-scale, 1));
    font-weight: 700;
    text-transform: uppercase;
    border-radius: var(--radius-control);
}

/* Ending changes the workout's lifecycle, but the summary behind it still owns
   the actual Save/Discard decision. Keep this first step as quiet danger text:
   the slow breath makes the borderless action discoverable while matching the
   PAUSED label beside it. Reduced-motion holds the first, fully opaque frame. */
#workout-timer-badge-actions #end-workout-btn {
    align-self: center;
    width: auto;
    height: 2.35rem;
    min-height: 2.35rem;
    padding-inline: var(--spacing-m);
    color: var(--color-danger);
    background-color: transparent;
    border: 0;
    border-radius: var(--radius-control);
    box-shadow: none;
    animation: pausedLabelPulse 2.4s ease-in-out infinite;
}

/* The active rest timer carries three plain word controls, with spacing doing
   the grouping instead of the retired accent-gradient dividers. */
#workout-timer-badge-rest-actions {
    grid-template-columns: repeat(3, minmax(0, auto));
}

#workout-timer-badge-cadence-actions {
    grid-template-columns: repeat(3, minmax(0, auto));
}

#lift-cadence-stop-btn {
    color: var(--color-highlight);
}

/* Rest timer ----------------------------------------------------------
   Replaces only the workout-clock digits, leaving the workout/exercise badge
   visible. DURATION and REST share their geometry and type scale; the theme
   highlight on REST makes the mode change immediately visible.
   justify-content: flex-end to match #workout-timer-display's own bottom
   anchor - it swaps into the exact same slot, so it needs the same baseline. */
#rest-timer,
#lift-cadence-timer {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: flex-end;
    flex-shrink: 0;
    min-width: 0;
    min-height: var(--timer-stack-height);
    /* Swaps into #workout-timer-display's slot, so it repeats that rule's
       gap/padding-bottom to keep REST and its digits on the same two lines
       DURATION and its digits occupy. */
    gap: calc(var(--badge-meter-block) + var(--badge-row-gap)
              + var(--badge-name-height) - var(--badge-digits-height));
    padding: 0 0 6px;
    color: var(--color-highlight);
    font-variant-numeric: tabular-nums;
    user-select: none;
    -webkit-user-select: none;
}

/* A first tap arms dismissal; the second performs it. The countdown is a
   structured control, so it keeps REST + its digits and uses the shared armed
   pulse rather than replacing its contents with the word Confirm. */
#rest-timer.confirm {
    font-weight: 800;
    animation: armedTextPulse var(--pulse-armed) ease-in-out infinite;
}

.rest-timer-number {
    font-size: var(--font-size-timer);
    font-weight: 700;
    line-height: 1.05;
}

/* The active lifting row is the same "work is under way" instrument as a
   cardio interval, but remains a completion dot rather than a transport
   glyph. Its header countdown owns Stop. */
.set-btn.hands-free-running {
    background-color: var(--color-highlight);
    box-shadow: none;
    animation: armedFillPulse var(--pulse-armed) ease-in-out infinite;
}

/* ========================== 4 · Notifications =========================== */
.notification {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    height: var(--header-height);
    padding-top: var(--safe-area-top);
    padding-inline: var(--spacing-m);
    display: flex;
    /* A column so a two-clause message stacks: the lines are flex items, and
       `display: block` on them means nothing in a row. One clause is a single
       item and is unaffected. */
    flex-direction: column;
    align-items: center;
    justify-content: center;
    text-align: center;
    /* Two lines at a readable size, breaking where the words allow. Wrapping
       was originally banned because the strip is a fixed height and a
       three-line message spilled over the content beneath it; the answer to
       that is a size the strip can actually hold two lines of, not one line
       shrunk until it is unreadable. The JS in showNotification() still
       shrinks - but only for a string that cannot break (a German compound),
       and only down to 0.8. The transform keeps interpolated names ("Norsk
       bokmål") from breaking the all-caps voice these strings speak in. */
    overflow: hidden;
    overflow-wrap: break-word;
    text-transform: uppercase;
    z-index: var(--z-notification);
    background-color: var(--color-background);
    color: var(--color-text);
    font-size: clamp(1rem, 4vw, 1.3rem);
    line-height: 1.25;
    font-weight: bold;
    transition: opacity 0.3s ease;
    /* Tapping the banner dismisses it immediately (see showNotification() in
       ui.js) - pointer-events: auto so the banner itself receives that tap,
       rather than it passing through to whatever sits beneath it. The
       banner already only covers the header row, and only for a couple of
       seconds, so this occasionally "eats" one tap meant for a header
       control in the rare case the two land in the same instant - accepted
       trade-off for letting a tap clear it out of the way immediately. */
    cursor: pointer;
    pointer-events: auto;
}

/* One clause per line - see showNotification() in ui.js. Each is centred in
   its own line rather than the pair being centred as one block. */
.notification-line {
    display: block;
}

.notification--hide {
    opacity: 0;
}

/* On the idle screen the real header shrinks to a thin safe-area strip
   (see body:not([data-workout]) #workout-header above) and the "Start
   Workout" card sits right beneath it - the banner's normal height stops
   just short of that label's underline, leaving a sliver of its letters
   peeking out below. Drop it further here so its bottom edge lands right
   at #session-underline-bar instead. */
body:not([data-workout]) .notification {
    height: calc(var(--header-height) + 1rem);
}

/* ============================== 5 · Layout ============================== */
main {
    width: 100%;
    max-width: var(--container-width);
    min-height: calc(100vh - var(--header-height));
    margin-inline: auto;
    /* Match the exercise cards' 8px side inset: each card is
       calc(100% - --spacing-m) and centered, leaving --spacing-s per side. */
    padding-top: calc(var(--header-height) + var(--spacing-s));
    padding-bottom: calc(4rem + var(--safe-area-bottom));
    display: flex;
    flex-direction: column;
    gap: var(--spacing-l);
}

/* The idle screen's app shell: a fixed-height column (see body:not(...)
   above for why height, not min-height) with two flex children -
   .idle-start-card, #idle-scroll (in that DOM order - see index.html) - of
   which only #idle-scroll is allowed to scroll. No header to clear at the top
   here either, just the safe-area inset that #workout-header's own now-short
   idle height (see below) still covers. padding-bottom is 0: the gap under
   the last row belongs to #idle-scroll alone, and nothing on this screen adds
   a safe-area inset - see that rule for what happened when two things did. */
body:not([data-workout]) main {
    position: relative;
    /* Cleared for exactly the reason body's own rule clears it, and missed
       here when that one was written: the base `main` rule sets
       min-height: calc(100vh - var(--header-height)), and a min-height larger
       than the height always wins. Wherever 100vh exceeds 100dvh by more than
       the header is tall - iOS Safari and Android Chrome with the address bar
       showing - this shell was laid out taller than the screen it has to fit
       inside, which put the foot of #idle-scroll below the fold, and moved it
       as the address bar came and went. */
    min-height: 0;
    height: 100vh;
    height: 100dvh;
    /* Last, so it wins where it exists. See trackAppHeight() in ui.js: dvh is
       right everywhere except a cold standalone launch on iOS, where it
       resolves against a viewport that has not settled and is never
       re-evaluated - laying this shell out short and stranding the footer
       logo partway up the screen. The two lines above remain the fallback for
       any browser this never runs in. */
    height: var(--app-height, 100dvh);
    padding-top: calc(var(--safe-area-top) + var(--spacing-l));
    padding-bottom: 0;
    overflow: hidden;
}

/* Fallback for mobile browsers that cannot honor the manifest/API portrait
   lock. The physical-orientation class is maintained by pwa.js. */
#portrait-required {
    display: none;
}

body.landscape-orientation {
    overflow: hidden;
}

body.landscape-orientation > :not(#portrait-required) {
    visibility: hidden;
    pointer-events: none;
}

body.landscape-orientation #portrait-required {
    position: fixed;
    inset: 0;
    z-index: calc(var(--z-notification) + 1);
    display: flex;
    align-items: center;
    justify-content: center;
    padding: var(--spacing-l);
    text-align: center;
    font-size: 1.2rem;
    font-weight: 700;
    color: var(--color-text);
    background-color: var(--color-background);
}

/* Pulled up onto the section above so the five menu rows read as one list.
   .idle-scroll's --spacing-l gap is right for its other children (the card
   row, the install nudge) and is cancelled here rather than removed there. */
#data-tools {
    margin-top: 0;
}

#exercises {
    width: 100%;
}

.exercise-list {
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: var(--spacing-sm);
    width: 100%;
}

/* ========================== 6 · Exercise cards ========================== */
/* No fill, no shadow, no border at all - the same move away from "elevated
   card" that the start button and the tools menu already made, taken to its
   end. The centered heading and the run of dotted row rules are enough to
   say where one exercise stops and the next begins; an outline on top of
   that was one boundary too many.

   Inset --spacing-m per side to sit directly under the header hairline
   (.workout-timer-badge-rule, itself inset the same from .header-content)
   so the card's width and the line above it agree. */
.exercise-item {
    position: relative;
    display: grid;
    gap: 0;
    width: calc(100% - var(--spacing-m) * 2);
    margin-inline: auto;
    padding: 0 0 var(--spacing-s);
    background: none;
    border: none;
}

/* A superset keeps both exercises inside one outer card. Their normal tool
   headers remain independent, while the shared list beneath them alternates
   A1/B1, A2/B2 so the pairing is explicit without duplicating cards. */
.superset-label {
    padding: var(--spacing-sm) var(--spacing-m) 0;
    color: var(--color-accent);
    font-size: 0.65rem;
    font-weight: 800;
    letter-spacing: 0.12em;
}

.superset-card .superset-exercise + .superset-exercise .exercise-tools {
    margin-top: var(--spacing-s);
}

/* Matches .exercise-main's own zero inset, so a superset's warmup rows sit
   on the same left edge - and carry the same full-width dotted rule - as
   every other row in the card. */
.superset-exercise > .superset-warmups {
    padding: 0;
}

.superset-exercise > .superset-warmups .warmup-section {
    margin-top: var(--spacing-s);
}

.superset-card > .superset-working-sets {
    grid-column: 1;
    grid-row: auto;
    padding-top: var(--spacing-s);
}

/* A superset's rows alternate between its two exercises, so every row names
   the one it belongs to. The rows used to be marked A1/B1/A2/B2, which
   numbered each exercise separately and never said what A and B were; the
   number now counts rounds and is printed once, against the round's first row
   (see rowControlHtml() in ui.js), with the partner's row left unnumbered so
   the two read as one round.

   The name sits on its own line above the weight/reps figure: the line beside
   that figure is already carrying the AMRAP flag, the RIR button, the set dot
   and the row's trash. Same quiet uppercase voice as .exercise-tier-badge and
   .preset-difficulty-badge - not a filled pill. */
.superset-tracking .set-column {
    flex-wrap: wrap;
    padding-top: var(--spacing-s);
}

.superset-tracking .set-row-exercise {
    /* Fills the rest of the marker's line, which is what pushes the figure and
       its controls onto the second line. */
    flex: 1 0 calc(100% - var(--set-row-control-width) - var(--spacing-s));
    overflow: hidden;
    font-size: 0.65rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--color-text-muted);
}

.superset-tracking .set-row-exercise small {
    margin-left: var(--spacing-s);
    font-size: 0.58rem;
    font-weight: 600;
    color: var(--color-highlight);
}

/* Line two lands under the name rather than under the round number. Whichever
   element opens that line carries the indent - the rest chip only exists in
   individual-rest mode. */
.superset-tracking .set-column > .set-rest-chip,
.superset-tracking .set-column:not(:has(.set-rest-chip)) > .edit-weight-btn {
    margin-left: calc(var(--set-row-control-width) + var(--spacing-s));
}

/* The exercise label and its collapsible tools are one block. It carries no
   fill or radius of its own now - the card's outline above is the only
   chrome, and a raised pill here would put a second surface back inside it. */
.exercise-tools {
    grid-column: 1;
    grid-row: 1;
    width: 100%;
    min-width: 0;
    margin: 0;
    overflow: hidden;
}

/* Taller than a set row, unlike before: the name is centered between the
   card's top outline and the dashboard now, so it needs its own room rather
   than borrowing a row's height. */
.exercise-tools:not(.expanded) {
    height: 2.75rem;
}

/* The wrapper preserves heading semantics while the nested button makes the
   complete label row interactive. */
.exercise-header {
    position: relative;
    width: 100%;
    height: 2.75rem;
    min-width: 0;
}

/* Centered, with symmetric padding wide enough to clear the chevron (and
   the delete button that replaces it on hold) on either side, so the name
   is centered against the card rather than against whatever is left over
   beside those controls. */
.exercise-header-toggle {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--spacing-s);
    width: 100%;
    min-width: 0;
    height: 2.75rem;
    min-height: 0;
    padding: 0 2.25rem;
    -webkit-touch-callout: none;
}

.exercise-header-toggle:focus-visible {
    outline-offset: -2px;
}

.exercise-name {
    min-width: 0;
    overflow: hidden;
    font-size: var(--exercise-name-size);
    font-weight: bold;
    line-height: 1.2;
    letter-spacing: 0.02em;
    text-align: center;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--color-text);
}

/* T1/T2/T3 label for a tiered-program exercise (Tiered Progression preset) -
   plain small uppercase text beside the name, same muted/bold voice as
   .exercise-rep-range rather than a filled pill, so it reads as a quiet
   classification rather than another competing badge. */
.exercise-tier-badge {
    flex-shrink: 0;
    margin-left: var(--spacing-s);
    font-size: 0.65rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

/* One standard hold on the label reveals reorder, add-below and trash. The
   destructive action remains outermost; the two structural controls step
   inward by one button width each. */
.remove-exercise-btn,
.add-after-btn,
.reorder-exercise-btn {
    position: absolute;
    z-index: 1;
    top: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2.75rem;
    height: 2.75rem;
    min-height: 0;
    color: var(--color-text-muted);
    background: transparent;
    transform: translateY(-50%);
}

.remove-exercise-btn {
    right: 0;
    color: var(--color-danger);
    background-color: var(--color-surface-raised);
    border-radius: 50%;
}

.add-after-btn {
    right: 2.75rem;
    color: var(--color-highlight);
}

.reorder-exercise-btn {
    right: 5.5rem;
}

.remove-exercise-btn svg,
.add-after-btn svg,
.reorder-exercise-btn svg {
    width: 18px;
    height: 18px;
}

/* The pencil into the exercise editor - the pill's left slot, mirroring the
   chevron's slot on the right, and always visible: the sheet is the only
   whole-prescription editor a live exercise has, and an affordance that had
   to be discovered by holding would not be an affordance. */
.edit-exercise-btn {
    position: absolute;
    z-index: 1;
    top: 50%;
    left: 0;
    display: flex;
    align-items: center;
    justify-content: center;
    /* The header's own height, so the tap target clears 44px. */
    width: 2.75rem;
    height: 2.75rem;
    min-height: 0;
    color: var(--color-text-muted);
    transform: translateY(-50%);
}

.edit-exercise-btn svg {
    width: 16px;
    height: 16px;
}

.exercise-header.exercise-actions-armed .exercise-header-toggle {
    padding-right: 8.5rem;
}

.exercise-header.exercise-actions-armed .exercise-tools-chevron {
    visibility: hidden;
}

/* One of the few things in the app that still reacts to a finger, and it can:
   a hold lasts long enough to be seen, so this is a state being entered
   rather than the flash a momentary press style would have been. It is driven
   by a class the hold handler sets, not by :active - which nothing uses. */
.exercise-header-toggle.holding-actions {
    opacity: 0.7;
}



/* The expanding label/drawer and set rows share one stacked column. */
.exercise-controls {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    grid-template-rows: auto auto;
    row-gap: 0;
    align-items: start;
    width: 100%;
    padding: 0;
}

/* Pulled out of the flow so it can't offset the centered name (see
   .exercise-header-toggle) - `margin-left: auto` would push the name off
   center by exactly the chevron's own width. */
.exercise-tools-chevron {
    position: absolute;
    right: var(--spacing-sm);
    top: 50%;
    flex-shrink: 0;
    width: 16px;
    height: 16px;
    transform: translateY(-50%);
    color: var(--color-text-muted);
    transition: transform 0.2s ease;
}

/* Keeps the centering translate from the base rule - a bare rotate() here
   would drop it and let the chevron jump down half its height on expand. */
.exercise-header-toggle[aria-expanded="true"] .exercise-tools-chevron {
    transform: translateY(-50%) rotate(90deg);
}

/* The collapsible rows live inside .exercise-tools, so revealing them grows
   the label pill itself. The header button's aria-expanded mirrors [inert].
   Collapse/reveal animates via the classic grid-template-rows 0fr->1fr
   trick (same 0.2s ease timing as .exercise-tools.expanded's border-radius
   transition above, so both finish together instead of visibly staggered) -
   toggling `hidden` here would force display:none, which gives this
   transition no starting size to animate from and is what used to make the
   drawer open in two visually disconnected steps. */
.exercise-tools-body {
    display: grid;
    grid-template-rows: 0fr;
    width: 100%;
    transition: grid-template-rows 0.2s ease;
}

.exercise-tools.expanded .exercise-tools-body {
    grid-template-rows: 1fr;
}

/* padding-bottom stays 0 at rest and only appears once expanded - border-box
   sizing can't shrink a box below its own padding (see body:not([data-workout])
   #workout-header above for the same fix elsewhere), so an unconditional
   padding here would stop the collapsed row from ever reaching true 0fr. */
.exercise-tools-body-inner {
    min-height: 0;
    overflow: hidden;
    padding-bottom: 0;
    transition: padding-bottom 0.2s ease;
}

.exercise-tools.expanded .exercise-tools-body-inner {
    padding-bottom: var(--spacing-s);
}

/* double_progression's "Reps 6-8 · Target 6" line - same muted/uppercase
   voice as .exercise-stat-label, sitting above the VOLUME/REPMAX/REST
   dashboard rather than inside it since it isn't a tappable shortcut. */
.exercise-rep-range {
    padding: var(--spacing-s) var(--spacing-sm) 0;
    font-size: 0.65rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-align: center;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

/* The lifter's own note, on the card while they lift. Sentence case and
   left-aligned, unlike the shouted uppercase line above it: that one is a
   derived label, this one is prose someone wrote. Clamped to two lines rather
   than truncated in JS, so the whole note stays selectable and available to
   assistive tech while the card keeps its height. */
.exercise-note {
    margin: 0;
    padding: var(--spacing-s) var(--spacing-sm) 0;
    font-size: 0.8rem;
    line-height: 1.4;
    color: var(--color-text-muted);
    display: -webkit-box;
    -webkit-box-orient: vertical;
    -webkit-line-clamp: 2;
    line-clamp: 2;
    overflow: hidden;
}

/* Minimal VOLUME/REPMAX/REST dashboard: plain native typography with no
   separate screen/chip surface. The accent value supplies enough hierarchy
   against the muted label without another background layer. */
.exercise-stat-grid {
    display: grid;
    /* The column count follows the tile list rather than being fixed at three
       - see statTileDescriptors() in ui.js. A weights card carries four
       (VOLUME, REPS, 1RM, REST); band and cardio carry three, because neither
       has both a tonnage and a 1RM to report. The fallback keeps the grid
       sane if the inline custom property is ever missing. */
    grid-template-columns: repeat(var(--stat-tile-count, 3), 1fr);
    gap: var(--spacing-s);
    padding: var(--spacing-s) var(--spacing-sm) 0;
}

/* Stated here rather than inherited from the base `button` rule, because not
   every tile is a button: INTERVAL is a readout (see statTileDescriptors() in
   ui.js) and rendered as a div. The grid stretches all of them to the same
   height either way, but the buttons were picking up justify-content: center
   and min-height from `button` while the div fell back to flex-start - so its
   label and clock sat 4px above the ones beside it. */
.exercise-stat-tile {
    --stat-screen-fg: var(--color-accent);
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    min-height: var(--button-min-height);
    gap: 0.25rem;
    font: inherit;
    text-align: center;
    background: transparent;
    border: 0;
}

button.exercise-stat-tile {
    cursor: pointer;
}

.exercise-stat-tile.custom .exercise-stat-label {
    color: var(--color-text);
}

.exercise-stat-label {
    font-size: 0.65rem;
    font-weight: 700;
    line-height: 1;
    letter-spacing: 0.08em;
    color: var(--color-text-muted);
    text-transform: uppercase;
}

.exercise-stat-screen {
    width: 100%;
    padding: 0.1rem 0 0;
    color: var(--stat-screen-fg);
    background: transparent;
    font-family: inherit;
    font-size: 0.95rem;
    font-weight: var(--font-weight-body);
    line-height: 1.15;
    font-variant-numeric: tabular-nums;
    border: 0;
    border-radius: 0;
    box-shadow: none;
    text-shadow: none;
}

/* No horizontal padding: each row's dotted rule is meant to run the card's
   full width (see .set-column). */
.exercise-main {
    grid-column: 1;
    grid-row: 2;
    display: flex;
    flex-direction: column;
    gap: var(--spacing-s);
    min-width: 0;
    padding: 0 0 var(--spacing-s);
}

/* Warmup section - visually distinct (dashed, muted buttons) from the
   working sets below it, so the two never read as the same kind of set.
   No divider rule of its own anymore - each row's "Warmup"/"Set" label
   already makes the boundary obvious without an extra dashed line. */
.warmup-section {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-s);
}

.warmup-tracking {
    opacity: 0.85;
}

/* One set/warmup per row, always - letting them fall into an auto-fit grid
   of multiple columns per row made rows of different widths wrap against
   each other unpredictably, especially once .exercise-main had the full
   card width to itself (see .exercise-controls above). A single column
   keeps every row the same width and reads top-to-bottom in set order. */
.sets-tracking {
    display: grid;
    grid-template-columns: 1fr;
    /* Consecutive rows' completion targets used to overlap. .set-btn::after
       stretches each one to 44px against a 36px row pitch, so every row
       overhung 4px into the rows above and below and adjacent targets
       overlapped by 8px - probing a vertical line down the dots found a
       button continuously across 80px, with no dead space anywhere between
       one set and the next. A 48px pitch leaves 4px between them.

       It costs page length, about 12px a row, which is the deliberate trade
       for targets that do not overlap. */
    gap: var(--spacing-sm);
}


/* No fill and no pill of its own - a row is just its contents over a dotted
   rule running the card's full width, which is what keeps eight
   near-identical rows from reading as eight stacked objects.

   Drawn as a repeating background rather than `border-bottom: 1px dotted`,
   because that keyword gives no control over the gap: browsers render it as
   1px on, 1px off, which is dense enough to read as a solid line. This is a
   1px dot every 6px, in --color-border rather than --color-border-strong, so
   it stays clearly quieter than the header's solid accent hairline. */
.set-column {
    display: flex;
    align-items: center;
    gap: var(--spacing-s);
    min-height: var(--exercise-row-height);
    padding: 0;
    /* Mixed from --color-text-muted rather than drawn in --color-border.
       Border tokens are tuned as hairlines *on a surface* and land at
       1.2-1.6:1 against the page in every theme - fine for a divider inside
       a filled card, invisible now that the card has no fill and this rule
       is the only thing separating one set from the next. At 55% it clears
       ~2:1 everywhere while staying clearly lighter than the state dot.
       The flat declaration is the fallback where color-mix is unsupported. */
    background-image: linear-gradient(to right, var(--color-border-strong) 1px, transparent 1px);
    background-image: linear-gradient(to right,
        color-mix(in srgb, var(--color-text-muted) 55%, var(--color-background)) 1px, transparent 1px);
    background-size: 6px 1px;
    background-position: bottom left;
    background-repeat: repeat-x;
}

/* Stable left-hand slot: the set's identity while closed, the add button
   while open. Both sit on the same left guide, so revealing the controls
   swaps what is drawn without moving where the eye already is. */
.set-row-control-slot {
    position: relative;
    flex: 0 0 var(--set-row-control-width);
    width: var(--set-row-control-width);
    height: var(--exercise-row-height);
}

.set-delete-btn,
.set-row-add-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    height: var(--exercise-row-height);
    min-height: 0;
    padding: 0;
    border: none;
    /* Only the colour: .set-delete-btn.confirm turns the bare glyph red. The
       fill, dim and shrink this used to ease went with the app's press
       styling, and nothing else moves them. */
    transition: color 0.2s ease;
}

/* The set's own number (or W) as plain small text at the row's left edge,
   not a filled circle - with the row itself no longer a surface, a tonal
   disc here would be the only object left in it. */
.set-row-control-toggle {
    display: flex;
    align-items: center;
    justify-content: flex-start;
    width: 100%;
    height: var(--exercise-row-height);
    min-height: 0;
    padding: 0 0 0 var(--spacing-s);
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    color: var(--color-text-muted);
    background: none;
    border: none;
    border-radius: 0;
}

/* Centered in the shared marker box rather than left-aligned as bare text, so
   a one-character "1" and a two-character superset "A1" share the guide the
   + ring uses - left-aligning them put each marker's center somewhere
   different depending on how many characters it had. */
.set-row-control-toggle > span {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: var(--set-marker-size);
}

.warmup-column .set-row-control-toggle {
    color: var(--color-text-muted);
    opacity: 0.7;
}

.set-row-actions {
    display: none;
    position: absolute;
    inset: 0;
    justify-content: flex-start;
}

.row-controls-open .set-row-control-toggle {
    display: none;
}

.row-controls-open .set-row-actions {
    display: flex;
}

/* Temporary row editing is deliberately time-limited in main.js. Pulse only
   its revealed add/delete affordances, leaving the set prescription stable. */
.row-controls-open .set-row-add-btn,
.row-controls-open > .set-delete-btn {
    animation: rowControlPulse 1s ease-in-out infinite;
}

/* Armed: the same pulse every other armed control gets. Chrome-free despite
   sitting among filled things - see .set-delete-btn.confirm below, which
   turns the bare glyph itself red - so it takes the text variant. */
.row-controls-open > .set-delete-btn.confirm {
    animation: armedTextPulse var(--pulse-armed) ease-in-out infinite;
}

/* Plain text, not a bordered button - sized to its own content instead of
   stretching to fill the row, so "60kg × 5" doesn't carry the same empty
   width as "180kg × 5" would. The row's shared surface (see .set-column
   above) is chrome enough; this only needs to look tappable via its own
   weight, not another outline.

   height: 2.25rem + min-height: 0 (overriding the global `button` rule's
   taller min-height, same override .set-btn/.set-delete-btn already use)
   deliberately match .set-btn exactly: this used to be the row's tallest
   child at a full 2.6rem, which was the actual reason .set-column rendered
   taller than the circular button sitting inside it. Now that both are
   pinned to the same 2.25rem, the row's height collapses to match .set-btn
   with no explicit height needed on .set-column itself - keep these two
   values in lockstep if either ever changes. */
/* This set's own rest, present only in individual mode. It sits between the
   set number and the prescription, pushing the weight/reps right - the rest
   is a property of the row, so it belongs on the row's own guide rather than
   trailing the numbers where it would read as part of them. Muted and
   smaller than the prescription: it is reference, not the row's subject. */
.set-rest-chip {
    display: flex;
    align-items: center;
    gap: 0.2rem;
    flex-shrink: 0;
    height: var(--exercise-row-height);
    padding-right: 0.55rem;
    font-family: var(--font-mono);
    font-size: 0.72rem;
    font-variant-numeric: tabular-nums;
    color: var(--color-text-muted);
}

.set-rest-chip svg {
    width: 12px;
    height: 12px;
    flex-shrink: 0;
}

/* The rest chip is row context, not a control: while the row's add/delete
   controls are open it steps aside so those keep the room they had. */
.row-controls-open > .set-rest-chip {
    display: none;
}

.edit-weight-btn {
    flex: 1 1 auto;
    min-width: 0;
    height: var(--exercise-row-height);
    min-height: 0;
    padding: 0;
    /* text-align alone doesn't position a flex item's content - the global
       `button` rule (see Base above) sets justify-content: center, which
       wins for a button whose only content is a bare text node. Overriding
       it here is what actually pins the text to the button's own left
       edge, right after the fixed-width label - so it stays put whether
       this button is wide (a short "60kg x 5") or narrowed by its neighbor
       widening (the reps-input pill above), instead of drifting as the
       box's center point moves. */
    justify-content: flex-start;
    gap: 0.4rem;
    text-align: left;
    /* Match the exercise-name scale so the prescription is comfortably
       readable without changing the row's shared compact height. */
    font-size: 1rem;
    font-weight: var(--font-weight-body);
    letter-spacing: 0.01em;
    color: var(--color-text);
    background-color: transparent;
    border: none;
    border-radius: var(--radius-small);
    /* Belt-and-suspenders: .edit-weight-figure below already truncates
       itself with an ellipsis before this would ever be needed, but the
       row's rounded shape (border-radius above) should be the hard visual
       boundary no combination of content can spill past. */
    overflow: hidden;
}

/* The weight×reps text itself uses the same face as the row label, with
   tabular numerals so changing weights still does not make digits jitter.
   min-width: 0 overrides a flex item's default min-width: auto, which
   would otherwise refuse to shrink below the text's natural width and
   push it past the button's edge instead of letting text-overflow do
   anything; nowrap + ellipsis then give it a graceful truncation instead
   of overflowing or wrapping onto a second line in that situation. */
.edit-weight-figure {
    display: flex;
    align-items: baseline;
    gap: 0.3rem;
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

/* Pushed to the figure's right edge so it lands beside the RIR button,
   reading as a second per-set marker rather than something prefixed to the
   numbers. margin-left: auto rather than ordering, so it sits hard right
   whether or not a previous-weight arrow is also present. */
.amrap-badge {
    flex: 0 0 auto;
    margin-left: auto;
    padding-left: var(--spacing-s);
    font-size: 0.95rem;
    line-height: 1;
}

.edit-weight-previous,
.edit-weight-arrow {
    flex: 0 0 auto;
    color: var(--color-text-muted);
}

.edit-weight-arrow {
    opacity: 0.7;
}

.edit-weight-current {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    transition: color 0.2s ease;
}

/* Deload attention lives directly on the working rows. Static partial color
   keeps several simultaneous entry points readable without a wall of pulse
   animations; an existing starting value and arrow remain quietly muted. */
.edit-weight-btn.needs-deload .edit-weight-current {
    color: var(--color-partial);
}

/* A small tonal circle rather than an outlined square - untouched/target
   state uses the same neutral --color-track fill the settings toggles use
   for a static (non-interactive-feeling) surface, so a still-empty set
   reads as quietly present rather than bordered-and-waiting. Only
   completed, pending, and partial fills reach for color. */
/* A plain dot, sized to the weight/reps text beside it so the two share a
   line rather than the state marker towering over the number it belongs to.
   font-size: 0 hides the ✓ glyph in the markup - state is carried entirely
   by fill now, which is legible at a glance across eight rows in a way a
   row of identical checkmarks was not. .reps-input sets its own font-size,
   so the inline editor below is unaffected. */
.set-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    position: relative;
    flex: 0 0 var(--set-dot-size);
    width: var(--set-dot-size);
    height: var(--set-dot-size);
    min-height: 0;
    /* Widened from --spacing-s: with the ::after target no longer reaching
       under the R glyph, this gap is what puts real dead space between the
       two live areas. The weight/reps box absorbs it, being the row's only
       flexible element. */
    margin-left: 0.75rem;
    margin-right: var(--spacing-s);
    font-size: 0;
    /* Not done: an outline with the page showing through, so how much ink a
       dot carries rises with how much of the set is done - empty, part-filled
       (.partial), solid (.completed). It used to be a *solid* muted disc,
       which put maximum ink on the one state where nothing had happened, and
       left "next set to press" separated from "already done" by hue alone.
       That failed badly in themes where --color-text-muted and
       --color-highlight sit at the same lightness: 1.08:1 between the two
       states on Patina, 1.40 Cobalt, 1.47 Candy Pop. A hole is a hole in
       every palette, and reads without colour vision.

       The stroke still states something - "this set is not done" - so it
       keeps the colour chosen to clear the 3:1 WCAG asks of a UI component,
       not the ~1.3:1 --color-track was giving it in every theme.
       --color-text-muted is the one per-theme value already tuned for
       legibility against the page, and at 90% of it the dot lands 3.3:1 or
       better everywhere. Flat declaration first as the fallback where
       color-mix is unsupported.

       An inset shadow rather than a border, the same way .partial draws its
       ring: the stroke grows inward, so every state keeps the exact same
       footprint and nothing in the row shifts as a set is completed. */
    background-color: transparent;
    box-shadow: inset 0 0 0 var(--set-dot-stroke) var(--color-text-muted);
    box-shadow: inset 0 0 0 var(--set-dot-stroke) color-mix(in srgb, var(--color-text-muted) 90%, var(--color-background));
    border: none;
    border-radius: 50%;
    /* The dot is deliberately small - it sits on the weight/reps line - but a
       ~18px tap target is not one. ::after grows the hit area to the row's
       full height and past it horizontally, without touching layout the way
       padding on a flex item would. */
    position: relative;
    /* margin-left rides along with width: the reps field's overlap offset is
       applied as a negative margin (see .set-btn.editing-reps below), and
       animating only the width would snap the left edge across the RIR button
       before growing back out to meet it. */
    transition: width 0.15s ease, height 0.15s ease, border-radius 0.15s ease, background-color 0.2s ease, box-shadow 0.2s ease, opacity 0.15s ease, margin-left 0.15s ease;
}

/* The tap target, larger than the drawn dot and deliberately off-centre.
 *
 * Centred on the dot it extended 12.8px to the left, which put live area
 * under the RIR button's own glyph: 13px of what looked like a mark for a
 * different control actually pressed the completion dot, and the two targets
 * abutted with no dead space at all, so a tap on the boundary was a coin
 * flip. A control's target may be larger than its glyph, but it must never
 * reach under a *different* control's glyph.
 *
 * So the leftward overhang stops at the midpoint of the dead gap between the
 * two, and the target keeps its full 2.75rem by extending right into the
 * card's own padding, where nothing else sits. Height stays centred - the row
 * gap above (.sets-tracking) is what separates it from the rows either side.
 *
 * -10px, not the -6px this held while the RIR mark was a centred letter. That
 * letter drifted with the value beside it, so the overhang had to clear the
 * furthest right it ever reached. The mark is now left-anchored in a button
 * whose contents are a fixed width (see .rir-value), which fixes where its
 * glyphs end and moves the true midpoint of the gap 4px left. The rule is
 * unchanged; only the boundary it is measured against stopped moving. */
.set-btn::after {
    content: '';
    position: absolute;
    top: 50%;
    left: -10px;
    width: 2.75rem;
    height: 2.75rem;
    transform: translateY(-50%);
}

/* Widens from a circle to a pill while the inline reps-input (see
   promptRepsDone() in main.js) is open - a 2-digit rep count doesn't fit
   comfortably in a 36px circle. Growing width alone is enough: the
   weight/reps text beside it (.edit-weight-btn) is the row's only other
   flexible element, so it yields the space automatically and the button
   visually "grows leftward" without any repositioning of its own. */
/* Grows from the dot back to a full-height pill while the inline reps-input
   is open (see promptRepsDone() in main.js) - a 2-digit rep count needs the
   room, and the dot has none to give. */
.set-btn.editing-reps,
.rir-btn.editing-reps {
    flex-basis: var(--set-btn-editing-width);
    width: var(--set-btn-editing-width);
    /* Short of the full row height, with the difference left below it. The
       row draws its dotted divider along its own bottom edge, so a
       full-height editor sat flush against that line with nothing between
       them. Squared off rather than pill-shaped because the app moved away
       from capsule fields; this is the last inline editor left. */
    height: calc(var(--exercise-row-height) - 8px);
    margin-bottom: 8px;
    border-radius: var(--radius-small);
}

/* The reps field opens *over* the RIR button, not beside it. Growing in flow
   pushed RIR leftward, so a control the user wasn't touching slid across the
   row and back again on every rep entry.
 *
 * The negative margin cancels the button's entire growth, so its contribution
 * to the row stays exactly one dot wide and *nothing* reflows - the pill
 * simply paints leftward over the RIR button, which is wholly inside it.
 * Cancelling only the RIR's own width was not enough: the remaining growth
 * still came out of the flexible .edit-weight-btn, and since that sits
 * *before* RIR in the row, shrinking it dragged RIR left anyway.
 *
 * The pill's field is opaque (.reps-input, inset: 0) and .set-btn is the
 * later sibling, so it covers RIR without needing a z-index.
 *
 * Scoped to rows that actually have a RIR button - it's optional (see
 * rirEnabled in main.js), and with none present there is nothing to overlap,
 * so the button keeps its original behaviour of taking the space from the
 * flexible weight/reps label beside it. */
.set-column:has(.rir-btn) .set-btn.editing-reps {
    margin-left: calc(var(--set-dot-size) - var(--set-btn-editing-width));
}

/* Compact effort-in-reserve entry, kept muted so it reads as secondary to
   the weight/reps figure - but at the same size as it, since the two sit on
   one line and a smaller glyph here just looked like a mistake. See
   promptRirEdit() in main.js. */
.rir-btn {
    display: flex;
    align-items: center;
    /* Left, not centred. Both children have fixed widths, so the mark lands at
       one x in every row whether that row has been filled in or not - centring
       re-flowed the pair the moment a value arrived and slid the battery
       leftward, which read as the column wobbling as the workout was logged. */
    justify-content: flex-start;
    /* The battery and the number read as one mark, so the gap is tight - and
       it has to be, since both share a fixed 2.4rem. */
    gap: 0.1rem;
    position: relative;
    /* Fixed width, wide enough for the mark alone and the mark plus a digit
       alike. Sized to its content it grew the moment a value was committed,
       dragging itself - and the figure beside it - leftward on every entry. */
    flex: 0 0 var(--rir-btn-width);
    width: var(--rir-btn-width);
    height: var(--exercise-row-height);
    min-height: 0;
    padding: 0;
    font-size: 1rem;
    font-weight: 400;
    font-variant-numeric: tabular-nums;
    color: var(--color-text-muted);
    background: none;
    border: none;
    border-radius: var(--radius-small);
    transition: color 0.2s ease;
}

/* Sized in em against the button's own 1rem rather than the root, so the mark
   and the digit beside it scale together. Drawn generously inside its viewBox
   so it survives being this small: at these sizes a battery drawn to the usual
   24-unit icon margins leaves a body barely 7px across, which is where a
   standing battery stops reading as one and becomes a pill.

   1em is the ceiling, not a preference. The two-digit slot beside it takes 2ch,
   which is 20.2px of the button's 38.4 at this font, so anything above ~1.04em
   overflows the button - and what it overflows into is the completion dot's
   side of the row. */
.rir-battery {
    flex: none;
    width: 1em;
    height: 1em;
}

/* The value's slot is always two digits wide, whether it holds two, one or
   none, and that fixed width is what lets the button left-align its contents
   to a stable x. Sized to the value instead, the pair re-flowed the moment a
   number was entered. clampRir() has no ceiling, so two digits is reachable
   and not merely defensive.

   ch is the digit width in a tabular face, which .rir-btn already sets, so the
   slot is exactly two figures rather than an approximation of them. */
.rir-value {
    flex: none;
    width: 2ch;
    text-align: left;
}

.rir-btn.has-value {
    color: var(--color-text);
}

/* Both filled states use --color-highlight rather than --color-accent /
   --color-pending. A dot is a small solid shape, closer to a glyph than to a
   large fill, so it needs text-level contrast - and the raw accent falls to
   3.2:1 (Candy Pop) and pending to 1.3:1 there against those themes' own
   backgrounds. --color-highlight is exactly the value each theme already
   defines as its accent darkened enough to read, and clears 6:1 in all
   ten. The cost is that pending and completed share a color in themes
   where they used to differ; the pulse below is what tells them apart, which
   is already how most themes worked. */
/* Pulses for CONFIG.SET_BLINK_MS, the window in which the set can still be
   corrected - the app's one filled armed control, so it gets the ring as well
   as the size/brightness swing (see armedFillPulse). The dot reads as a pupil:
   it contracts to a dark donut and dilates to a bright solid disc. That
   contracted end is deliberately the exact ring the reduced-motion fallback
   draws, so with motion off the state is one frame of this animation held
   still rather than a separate invention. */
.set-btn.pending {
    color: var(--color-text-dark);
    background-color: var(--color-highlight);
    animation: armedFillPulse var(--pulse-armed) ease-in-out infinite;
}

/* Done: the dot is filled in, and drops the unfilled state's stroke so the
   shape is a plain solid disc rather than a disc with a rim. */
.set-btn.completed {
    color: var(--color-text-dark);
    background-color: var(--color-highlight);
    box-shadow: none;
}

/* Completed, but with fewer reps than prescribed - filled to the fraction of
   the set actually done, as a wedge swept clockwise from twelve o'clock.
   --set-fill is written per button by setButtonStyle() in ui.js from
   repsDone/reps; the 50% here is only what an unstyled .partial would fall
   back to.

   Deliberately a difference in *form* rather than in hue: as a fill,
   --color-partial dropped to 2.53:1 (Tiger) and 2.85:1 (Vibrant) against
   those themes' backgrounds, and any single colour picked to fix that has to
   be re-checked against ten palettes and every future one. Drawing it in the
   same --color-highlight the completed state uses inherits that state's
   contrast everywhere.

   A continuous sweep rather than one segment per rep: at 1.15rem the dot's
   circumference is only ~58px, so a 14-rep set would draw 14 segments about
   4px apart and read as noise. A sweep is drawn the same way whether the set
   was 5 reps or 20.

   The stroke stays, and in --color-highlight rather than the unfilled
   state's muted: it is what separates "did one rep of fourteen" - a 7% wedge
   that would otherwise look like an untouched set - from "have not started
   this set". The ring says the set was worked; the wedge says how much. */
.set-btn.partial {
    background-color: transparent;
    background-image: conic-gradient(var(--color-highlight) var(--set-fill, 50%), transparent 0);
    box-shadow: inset 0 0 0 var(--set-dot-stroke) var(--color-highlight);
}

/* Warmup dots read as the same instrument one step quieter: an unfilled
   warmup is dimmer than an unfilled working set, and a completed one lands
   on muted rather than the accent a real set earns. */
/* No second opacity on the dot itself - .warmup-tracking above already dims
   the whole block, and compounding the two (0.85 x 0.7) put the warmup dot
   back under the contrast floor the fill above was chosen to clear.

   A completed warmup takes the same --color-highlight a completed working
   set does, and deliberately no longer a muted fill of its own. Muting it
   was a way of saying "this counts for less", but once the *empty* dot had
   to be raised to --color-text-muted at 90% for contrast, a completed
   warmup at 100% of the same colour was within a few RGB steps of it -
   togglable, but with almost nothing to show for it. The warmup block
   already says it is a warmup twice over, through the W marker and
   .warmup-tracking's own dimming, so the dot is free to just report
   done/not-done clearly. */
.warmup-btn.completed {
    background-color: var(--color-highlight);
}

/* A cardio row's control is a transport, not a completion dot.
 *
 * Everywhere else in the app this control means "I finished this set". On a
 * cardio row the same tap *starts* real work, so the shape has to say so:
 * play while idle, stop while the interval runs, and only then the check
 * every other row uses. Same grid slot, same footprint and the same
 * data-action - main.js's handler is untouched, and nothing in the row
 * shifts as the state changes.
 *
 * .set-btn sets font-size: 0, because its dot is a shape rather than a
 * glyph. The transport needs its glyph back, sized to sit inside the same
 * 1.15rem dot without touching the ring.
 */
.set-btn.cardio-transport {
    font-size: 0.62rem;
    line-height: 1;
    color: var(--color-text-muted);
    /* The play triangle is optically left-heavy inside a circle; a hair of
       padding re-centres it against the ring. */
    padding-left: 0.08em;
}

/* Running: filled and pulsing, borrowing the pending state's animation. Both
   mean "this is happening right now and a tap changes it", so they should read
   the same way rather than each inventing a signal.

   No glyph inside it - see setButtonGlyph() in ui.js. The fill and the pulse
   already say the interval is under way, and a pause bar small enough to fit
   this dot could not be centred convincingly. */
.set-btn.cardio-transport.running {
    background-color: var(--color-highlight);
    box-shadow: none;
    animation: armedFillPulse var(--pulse-armed) ease-in-out infinite;
}

/* Recorded: .completed/.partial already fill the dot, so the check just needs
   a colour that reads against that fill. */
.set-btn.cardio-transport.completed,
.set-btn.cardio-transport.partial {
    color: var(--color-text-dark);
    padding-left: 0;
}

/* The stop control, in the slot RIR occupies on a lifting row - a timed set
   has no reps to hold in reserve. Present only while there is something to
   end, and hidden rather than removed so the row never needs rebuilding.
   Sized to the same dot as the transport beside it, since the two are read
   as a pair. */
.set-btn.cardio-finish-btn {
    flex: 0 0 var(--set-dot-size);
    margin-left: 0;
    margin-right: var(--spacing-s);
    font-size: 0.5rem;
    padding-left: 0;
    color: var(--color-text-muted);
}

.set-btn.cardio-finish-btn[hidden] {
    display: none;
}

/* Holding the play control opens inline entry for a run the app did not time
   (promptCardioTimeLogged() in main.js). The press is acknowledged while the
   threshold is being met, the same way the row's add button does it - a hold
   with no feedback reads as an unresponsive tap. */
.set-btn.cardio-transport.holding {
    opacity: 0.6;
}

/* Hours, minutes and seconds as three scrolling wheels - see
   durationFieldsHtml() in ui.js.

   The scrolling, the momentum and the snapping are all the browser's, via
   scroll-snap; the script only reads back which item ended up in the middle.
   Wheels are narrow by construction, which is the other half of why they are
   here: three number boxes inherited .weight-input-container's 110px width
   and ran off the side of the panel. */
.duration-wheels {
    --duration-wheel-item: 2.25rem;
    --duration-wheel-visible: 5;
    display: flex;
    justify-content: center;
    gap: var(--spacing-s);
    width: 100%;
    /* The block carries its own air, on both sides, rather than living off
       whatever the container it landed in happens to give. It landed in
       containers sized for a 44px input: .weight-input-container's 8px row
       padding held a 198px wheel eight pixels off its own hairline, and in
       the exercise form the row below started at exactly the wheel's bottom
       edge - a thumb aiming at the last rung hit the weight field instead.

       Padding, not margin: at the top of #custom-mode-fields a margin would
       collapse into the picker's own bottom margin above it and add nothing,
       and only padding is guaranteed to survive every container this drops
       into. It is dead space by design - the scroller is inside it, so a
       drag that misses the wheel misses everything rather than landing on
       the neighbour. */
    padding-block: var(--spacing-l);
}

/* The picker above a wheel block stops adding its own gap: with both, the
   space above the wheels was the sum of the two and the space below was the
   padding alone, which read as the wheels sitting low in their own slot. Two
   selectors because the exercise form wraps its wheels in #custom-mode-fields
   while the warmup steps put them straight after the picker. */
.load-mode-picker:has(+ .duration-wheels),
.load-mode-picker:has(+ * > .duration-wheels:first-child) {
    margin-bottom: 0;
}

/* Three visible rows where a wheel sits among other fields (sets/reps/bar
   weight in the exercise form, reps in the set editor) rather than being
   the panel's whole subject - five rows there out-shouted every field
   around them. */
.duration-wheels--compact {
    --duration-wheel-visible: 3;
}

/* Columns share the row evenly, so SETS | REPS | STEP | BAR each get about
   85px of a 390px screen - enough for a four-character label and a
   two-digit rung. The labels are a size down from the five-row wheels and
   never wrap: a wrapped label pushes its own wheel out of line with the
   three beside it. */
.duration-wheels--compact .duration-wheel-column {
    flex: 1 1 0;
    min-width: 0;
}

.duration-wheels--compact .duration-wheel-label {
    font-size: 0.7rem;
    white-space: nowrap;
}

/* A column decided by something else on the panel - currently the bar
   weight while "uses a barbell" is off. Greyed in place rather than hidden:
   the column keeps its slot in the row, and a hidden wheel has no height to
   position itself against until it is revealed, which is what made the old
   bar-weight row need a remount every time it appeared. */
.wheel-column-inert {
    opacity: 0.4;
    pointer-events: none;
}

.duration-group-label {
    font-size: 0.85rem;
    color: var(--color-text-muted);
}

.duration-wheel-column {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 0.25rem;
    min-width: 0;
    flex: 1 1 0;
}

/* The accent, not the muted grey the app's other field labels take. A wheel
   names itself only here - the rungs are bare numbers, so SETS, BAR and SEC
   are the whole of what says which quantity is being turned - and at muted
   grey over a column of muted grey rungs that caption was the faintest thing
   in its own control. Each theme's own --color-highlight, so this follows the
   accent rather than pinning one colour. */
.duration-wheel-label {
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--color-highlight);
}

/* A greyed column's label greys with it (the bar weight while "uses a
   barbell" is off): .wheel-column-inert's 0.4 opacity alone left an accent
   caption over dead rungs, which read as the live one. */
.wheel-column-inert .duration-wheel-label {
    color: var(--color-text-muted);
}

.duration-wheel-viewport {
    position: relative;
    width: 100%;
}

.duration-wheel {
    height: calc(var(--duration-wheel-item) * var(--duration-wheel-visible));
    overflow-y: auto;
    scroll-snap-type: y mandatory;
    scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
    /* A flick that reaches the first or last rung stops there instead of
       handing the rest of its momentum to the panel behind it. Spinning a
       wheel to 0 or to 59 is exactly the gesture that used to scroll the
       whole panel out from under the control being aimed at. */
    overscroll-behavior-y: contain;
    /* Fades the ends so the column reads as a wheel turning past the window
       rather than a list that stops. */
    mask-image: linear-gradient(to bottom, transparent, #000 28%, #000 72%, transparent);
    -webkit-mask-image: linear-gradient(to bottom, transparent, #000 28%, #000 72%, transparent);
}

.duration-wheel::-webkit-scrollbar {
    display: none;
}

.duration-wheel:focus-visible {
    outline: 2px solid var(--color-highlight);
    outline-offset: 2px;
}

/* Half the visible height above and below, so the first and last values can
   still reach the middle. */
.duration-wheel-pad {
    height: calc(var(--duration-wheel-item) * ((var(--duration-wheel-visible) - 1) / 2));
}

.duration-wheel-item {
    height: var(--duration-wheel-item);
    line-height: var(--duration-wheel-item);
    text-align: center;
    scroll-snap-align: center;
    font-variant-numeric: tabular-nums;
    color: var(--color-text-muted);
    transition: color 0.15s ease;
}

.duration-wheel-item[aria-selected="true"] {
    color: var(--color-text);
    font-weight: 700;
}

/* The window the selected value sits in. Drawn over the wheel and inert, so
   it never intercepts the scroll it is describing.

   Its two rules are pseudo-elements rather than the element's own top/bottom
   borders: a border takes a colour, not a gradient, and these fade out at
   their ends like every other hairline in the app now does. The fade matters
   more here than elsewhere - the wheel itself already fades top and bottom
   (see the mask-image on .duration-wheel), and two rules ending hard against
   nothing were the only square edges left in the control. */
.duration-wheel-band {
    position: absolute;
    left: 0;
    right: 0;
    top: calc(var(--duration-wheel-item) * ((var(--duration-wheel-visible) - 1) / 2));
    height: var(--duration-wheel-item);
    pointer-events: none;
}

.duration-wheel-band::before,
.duration-wheel-band::after {
    content: '';
    position: absolute;
    left: 0;
    right: 0;
    height: 1px;
    background-image: var(--wheel-hairline);
}

.duration-wheel-band::before {
    top: 0;
}

.duration-wheel-band::after {
    bottom: 0;
}

/* A wheel spun by a thumb is animation the lifter asked for, but the
   keyboard's smooth scroll is not - and neither is the colour fade. */
@media (prefers-reduced-motion: reduce) {
    .duration-wheel-item {
        transition: none;
    }
}

/* The trash glyph alone, on nothing. It used to be a filled half-pill with
   its flat side against the card's right edge; the fill made the row's most
   destructive control also its heaviest object, and the half-pill was the
   last of that shape left in the app. The generous tap area stays - only the
   surface under it goes. */
.set-delete-btn {
    display: none;
    flex: 0 0 2.9rem;
    width: 2.9rem;
    margin-right: 0;
    /* The glyph ends where the completion dot ends - the button keeps its full
       width for the tap target, but its content is pushed to the same right
       margin .set-btn uses, so the trash lands exactly on the dot it replaces
       instead of 6px inboard of it. */
    justify-content: flex-end;
    padding-right: var(--spacing-s);
    color: var(--color-text-muted);
    background: none;
    border-radius: 0;
}

.row-controls-open > .set-btn {
    display: none;
}

.row-controls-open > .rir-btn {
    display: none;
}

.row-controls-open > .set-delete-btn {
    display: flex;
}

/* Sized to the completion dot it stands in for: at 16px it read as the
   smallest mark on the row, which is the wrong emphasis for the one
   destructive control there. */
.set-delete-btn svg {
    width: var(--set-dot-size);
    height: var(--set-dot-size);
    flex-shrink: 0;
}

/* Armed, the glyph itself carries the warning - there is no fill left to
   turn red, and a bare icon at full danger colour is unambiguous enough. */
.set-delete-btn.confirm {
    color: var(--color-danger);
}

/* The exact row inserted by an add action holds an accent wash briefly, then
   returns to the normal raised surface. The overlay avoids disturbing the
   row's own status colors and remains click-through throughout. */
.set-column--inserted {
    position: relative;
}

.set-column--inserted::after {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 2;
    /* Its own radius, not inherit: .set-column is a flex row with square
       corners, so inheriting gave the wash four sharp ones - the only sharp
       corners left in an app whose every other surface is rounded. It sits
       *over* a row rather than filling a container, which is exactly the case
       for softening it. The builder's card flash deliberately does not do
       this: that one fills a box, and a box's fill takes the box's shape. */
    border-radius: var(--radius-small);
    background-color: var(--color-accent);
    pointer-events: none;
    animation: row-landed-flash 2.4s ease-out forwards;
}

/* A small ringed plus, not the filled half-pill this used to be. The ring is
   a hairline so the control reads as available without competing with the
   row's own numbers, and it sits left-aligned on the same guide the set
   number occupies while the row is closed, so revealing the controls doesn't
   shift the eye. Deliberately smaller than the slot: the button keeps the
   full-height tap target, only the drawn ring is small. */
.set-row-add-btn {
    position: relative;
    width: 100%;
    justify-content: flex-start;
    padding-left: var(--spacing-s);
    background: none;
    color: var(--color-accent);
}

/* Exactly the marker box, border included - so the ring occupies the same
   square the set number's glyph is centered in, on the same vertical guide. */
.set-row-add-btn > svg {
    position: relative;
    z-index: 1;
    display: block;
    box-sizing: border-box;
    width: var(--set-marker-size);
    height: var(--set-marker-size);
    padding: 4px;
    flex-shrink: 0;
    border: 1px solid var(--color-accent);
    border-radius: 50%;
}

/* A radial fill makes the first-set, zero-warmup hold threshold visible.
   It completes at the same 500ms instant that the warmup is inserted.
   Sized and placed to the drawn ring rather than the button box - the button
   spans the whole slot for the tap target's sake, and filling all of that
   would read as the row itself activating. */
.set-row-add-btn.hold-warmup::after {
    content: '';
    position: absolute;
    left: var(--spacing-s);
    top: 50%;
    width: var(--set-marker-size);
    height: var(--set-marker-size);
    margin-top: calc(var(--set-marker-size) / -2);
    border-radius: 50%;
    background-color: var(--color-highlight);
    transform: scale(0);
    opacity: 0.45;
}

.set-row-add-btn.hold-warmup.holding::after {
    animation: rowHoldProgress 500ms linear forwards;
}

@keyframes rowHoldProgress {
    to { transform: scale(1); }
}

/* Inline reps entry, rendered inside the set button - pill-shaped to match
   .set-btn.editing-reps's own widened shape (see above), not the resting
   circle. */
.set-btn .reps-input,
.rir-btn .reps-input {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    text-align: center;
    font-size: var(--font-size-button-medium);
    border: none;
    /* Matches the button it fills - see .editing-reps above. */
    border-radius: var(--radius-small);
    background-color: var(--color-background-log-entry);
    color: var(--color-ink);
}


/* ========================= 7 · Start-page tools ========================= */
/* Chrome-free, matching .session-card: no border/fill of its own, just the
   same var(--spacing-m) inset .session-card uses so a row's icon/label lines
   up exactly under the start button's label and #session-underline-bar.
   Rows are separated by the dotted rule .tools-list button draws for itself
   (see below), which is the whole separation - there is no press state to
   add a second one. */
.tools-list {
    width: 100%;
    padding: 0 var(--spacing-m);
}

.tools-list button {
    width: 100%;
    min-height: 46px;
    padding: 0;
    gap: var(--spacing-sm);
    justify-content: flex-start;
    font-size: 0.92rem;
    font-weight: 600;
    color: var(--color-text);
    /* Override the base button rule's pill radius - left as the default
       (var(--radius-pill), 9999px) the border-bottom below follows that
       curvature at the corners instead of staying a straight line, same
       issue .session-card-start had. */
    border-radius: 0;
    border-bottom: 1px solid transparent;
    /* The same quiet dotted rule the set rows keep (see .set-column): a
       1px dot every 6px in the muted mix, so the list reads as rows of one
       system instead of free-floating labels. Drawn as a background rather
       than a border, which leaves the border edge free - it once carried an
       accent line on press, and nothing has needed it since. */
    background-image: linear-gradient(to right, var(--color-border-strong) 1px, transparent 1px);
    background-image: linear-gradient(to right,
        color-mix(in srgb, var(--color-text-muted) 55%, var(--color-background)) 1px, transparent 1px);
    background-size: 6px 1px;
    background-position: bottom left;
    background-repeat: repeat-x;
}

/* No trailing rule under the last row of a list - except the idle screen's
   Training Programs, whose single row is the *first* row of a menu that
   continues into #data-tools below it (the two are separate sections only so
   each keeps its own aria landmark). Scoped by the panel lists' own second
   class rather than by restating the gradient in an override. */
.tools-list.sheet-list button:last-child,
.tools-list.tools-panel-list button:last-child,
#data-tools .tools-list button:last-child {
    background-image: none;
}

/* The install row is in the DOM whether or not the browser has an install
   prompt to offer, and hidden the rest of the time - so :last-child above
   lands on a row nobody can see and leaves the last visible row still carrying
   a rule into empty space. This selects the last row that is actually showing.

   Its own rule rather than a fourth line on the list above: a selector the
   engine does not understand invalidates the entire rule it appears in, and
   this one has to degrade to the plain :last-child behaviour rather than take
   that with it. */
#data-tools .tools-list button:nth-last-child(1 of :not([hidden])) {
    background-image: none;
}

/* The idle screen's own menu speaks the app's label voice - the same
   uppercase micro-label every other piece of chrome uses. Scoped to the
   idle screen deliberately: .tools-list is also the exercise sheet's "More"
   list and the Data Tools panel, where each row carries a *value* beside it
   ("Rest between sets  2:00") and reads as a setting rather than a
   destination. Via text-transform, not upper-cased strings, so screen
   readers still announce words rather than spelling them out. */
#idle-scroll .tools-list button {
    letter-spacing: 0.04em;
    text-transform: uppercase;
}

/* Echoes #session-underline-bar: the accent rule only appears where you're
   actually touching, never as permanent decoration - no background fill. */
.tools-list button svg:first-child {
    width: 18px;
    height: 18px;
    flex-shrink: 0;
}

.tools-list button span {
    flex: 1;
    text-align: left;
}

/* A waiting update stays inside the familiar start-page menu. The dot is
   intentionally quiet: the row itself is hidden until there is an update. */
.tools-list button .update-available-dot {
    width: 0.5rem;
    height: 0.5rem;
    flex: 0 0 0.5rem;
    border-radius: 50%;
    background-color: var(--color-accent);
    box-shadow: 0 0 0 3px var(--color-accent-fill);
}

.tools-list-chevron {
    width: 15px;
    height: 15px;
    flex-shrink: 0;
    color: var(--color-text-muted);
}

/* Inset Rhythm: start-page destinations sit directly on the page, with each
   separator beginning after the icon gutter. The icon and type carry the
   hierarchy; there is no filled group, icon well, border, radius, or shadow
   for iOS Safari to clip against the workout header. */
.start-menu-list {
    width: calc(100% - 2 * var(--spacing-m));
    margin-inline: auto;
    padding: 0;
    overflow: visible;
    background: none;
    border: none;
    border-radius: 0;
    box-shadow: none;
}

/* Keep a small, explicit gap between the workout-card region and the first
   menu surface. The menu itself stays flat: an outer shadow was clipped by
   the fixed header in iOS Safari and added no useful hierarchy here. */
#program-menu {
    padding-top: var(--spacing-s);
}

.start-menu-list button {
    min-height: 3.45rem;
    padding-inline: 0;
    background-image: linear-gradient(to right, var(--color-border), var(--color-border));
    background-size: calc(100% - 2.9rem) 1px;
    background-position: right bottom;
    background-repeat: no-repeat;
    border: none;
    border-radius: 0;
}

#data-tools .start-menu-list button:nth-last-child(1 of :not([hidden])) {
    background-image: none;
}

.start-menu-list button > svg:first-child,
.tools-panel-list button > svg:first-child {
    box-sizing: border-box;
    width: 1.15rem;
    height: 1.15rem;
    padding: 0;
    color: var(--color-highlight);
    background: none;
    border: none;
    border-radius: 0;
}

.start-menu-list--primary button > svg:first-child {
    color: var(--color-highlight);
    background: none;
    border: none;
}

.start-menu-list--primary button {
    font-weight: 700;
}

.start-menu-list .tools-list-chevron {
    color: var(--color-text-muted);
}



/* ============================== 8 · Logs ================================ */
/* "Show N more / Show less" toggle below the log entries. A small muted
   pill that stays visually subordinate to the log cards above it. */
.logs-toggle-btn {
    align-self: center;
    min-height: 2rem;
    padding: 6px 18px;
    font-size: 0.8rem;
    font-weight: 600;
    letter-spacing: 0.03em;
    text-transform: uppercase;
    color: var(--color-text-muted);
    background: transparent;
    border: 1px solid var(--color-border-strong);
    border-radius: var(--radius-control);
    cursor: pointer;
}

/* A compact history card: badge/date/duration header, one line per exercise,
   then a volume total + delete action footer. */
/* The last elevated card family in the app, brought in line with the
   exercise card and the panel lists: no fill, no lift, no radius, one
   hairline between entries. .panel-session-list's own gap supplies the
   breathing room those borders used to. */
.log-entry {
    display: flex;
    flex-direction: column;
    width: 100%;
    padding: 0 0 var(--spacing-m);
    border-bottom: 1px solid var(--color-border);
    color: var(--color-text);
}

.log-entry:last-child {
    border-bottom: none;
}

/* The exercise card's own header language: the workout name centred on its
   own line at .exercise-name's scale, the program/date/duration meta as one
   quiet centred line beneath it. The old left-packed row read as a tag
   cloud next to the cards it summarizes. */
.log-header {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    justify-content: center;
    column-gap: var(--spacing-s);
    row-gap: 0.15rem;
    padding-bottom: var(--spacing-s);
    margin-bottom: var(--spacing-s);
    border-bottom: 1px solid var(--color-border);
}

/* The entry's heading, sized and coloured like .exercise-name - a full-row
   line, not an accent pill: one pill per entry made a list of ten workouts
   read as ten badges. */
.log-workout-badge {
    flex-basis: 100%;
    padding: 0;
    font-size: var(--exercise-name-size);
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    text-align: center;
    color: var(--color-text);
}

.log-date {
    font-size: 0.85rem;
    color: var(--color-text-muted);
}

.log-duration {
    font-size: 0.85rem;
    font-variant-numeric: tabular-nums;
    color: var(--color-text-muted);
}

.log-exercises {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-s);
}

.log-exercise {
    display: flex;
    flex-direction: column;
    gap: 0;
    border-bottom: 1px solid var(--color-border);
    font-size: 0.92rem;
}

.log-exercise:last-child {
    border-bottom: none;
}

.log-exercise-toggle {
    display: flex;
    align-items: center;
    gap: var(--spacing-m);
    width: 100%;
    min-height: 3.6rem;
    padding: var(--spacing-s) 0;
    color: var(--color-text);
    text-align: left;
    border-radius: 0;
}

.log-exercise-heading {
    display: flex;
    flex: 1;
    flex-direction: column;
    gap: 0.25rem;
    min-width: 0;
}

.log-exercise-summary {
    overflow: hidden;
    color: var(--color-text-muted);
    font-size: 0.8rem;
    font-variant-numeric: tabular-nums;
    line-height: 1.35;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.log-exercise-chevron {
    transition: transform 0.18s ease;
}

.log-exercise-toggle[aria-expanded="true"] .log-exercise-chevron {
    transform: rotate(90deg);
}

.log-exercise-details {
    padding: 0 0 var(--spacing-m);
}

.log-exercise-details[hidden] {
    display: none;
}

.log-exercise-row {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: var(--spacing-s);
    margin-bottom: var(--spacing-s);
}

.log-exercise-name {
    color: var(--color-text);
    font-weight: 600;
}

.log-exercise-sets {
    display: grid;
    grid-template-columns: 1.75rem minmax(0, 1fr);
    gap: var(--spacing-s) var(--spacing-sm);
    align-items: center;
}

/* Plain numerals, the same treatment .set-row-control-toggle gives a live
   set's marker - a tonal disc per set turned each logged exercise into a
   column of chips. */
.log-set-label {
    display: inline-flex;
    align-items: center;
    justify-content: flex-start;
    width: 1.75rem;
    height: 1.75rem;
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    color: var(--color-text-muted);
    line-height: 1;
}

.log-warmup-label,
.log-summary-label {
    color: var(--color-text-muted);
}

.log-set-pill {
    display: flex;
    align-items: center;
    align-content: center;
    flex-wrap: wrap;
    gap: 0 var(--spacing-s);
    min-width: 0;
    min-height: 1.75rem;
    /* Warmup pills add a 1px outline around --spacing-s of breathing room.
       Give plain working rows the same total inset so every load/reps value
       starts on one vertical axis. The warmup rule below replaces this with
       its padding alone because its border supplies the remaining pixel. */
    padding: 2px calc(var(--spacing-s) + 1px);
    font-family: var(--font-mono);
    font-size: 0.82rem;
    /* Regular, matching .edit-weight-btn on the live card - this is the same
       weight x reps figure, just recorded rather than prescribed. */
    font-weight: 400;
    font-variant-numeric: tabular-nums;
    color: var(--color-text);
}

/* Dashed and muted, echoing the live exercise card's warmup styling, so a
   warmup row never reads as just another working set. */
.log-warmup-pill {
    color: var(--color-text-muted);
    background-color: transparent;
    border: 1px dashed var(--color-border-strong);
    border-radius: var(--radius-small);
    padding-inline: var(--spacing-s);
}

.log-added-reps {
    /* --color-highlight, not --color-accent: bare text on the card's
       neutral surface, not a filled control. */
    color: var(--color-highlight);
    font-weight: 700;
}

/* Plain small caps, the same voice .exercise-tier-badge uses on the live
   card - the last filled pill left in a log entry, and the one thing in it
   that isn't a heading, a figure or a rule. */
.log-exercise-badge {
    padding: 0;
    font-size: 0.68rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.log-exercise-1rm {
    font-size: 0.82rem;
    color: var(--color-highlight);
}

/* Sits beside the 1RM at the same size but muted, not accented: rest is a
   prescription the session was run under, not an achievement it reached. */
.log-exercise-rest {
    font-size: 0.82rem;
    color: var(--color-text-muted);
}

.log-footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-s);
    margin-top: var(--spacing-m);
    padding-top: var(--spacing-m);
    border-top: 1px solid var(--color-border);
}

.log-volume {
    font-size: 0.92rem;
    color: var(--color-text-muted);
}

.log-volume strong {
    color: var(--color-highlight);
    font-weight: 700;
}

/* Icon-only trash can (see TRASH_ICON_SVG in ui.js), no fill/border - the
   same chrome-free language the header/session buttons already use, rather
   than the bordered rectangle this used to be. Armed (tap again to
   confirm) follows the same icon-only "pay attention" convention Discard/
   Save on the workout timer use: color shifts to --color-highlight plus
   the shared attention-pulse, since there's no background fill here to
   turn solid the way .set-delete-btn's own circle can. */
.delete-log-btn {
    padding: 9px;
    color: var(--color-text-muted);
    transition: color 0.2s ease;
}

.delete-log-btn svg {
    width: 18px;
    height: 18px;
    flex-shrink: 0;
}

.delete-log-btn.confirm {
    color: var(--color-highlight);
    font-weight: 800;
    animation: armedTextPulse var(--pulse-armed) ease-in-out infinite;
}

/* ============================= 9 · Panels =============================== */
/* Full-bleed workout panels sit under the fixed header. The three start-page
   app panels can opt into covering all app chrome via .app-covering-panel. */
/* Form panels can opt into keyboard clearance while their own editor is
   focused; trackKeyboardInset() assigns .panel--keyboard-lift to that exact
   panel. Keeping the inset off this base rule matters: content-only surfaces
   such as Manual and Add Program have no footer, and a stale global inset
   otherwise becomes an empty footer their text scrolls behind. */
.panel {
    position: fixed;
    top: var(--header-height);
    left: 0;
    right: 0;
    bottom: 0;
    z-index: var(--z-panel);
    display: flex;
    flex-direction: column;
    align-items: center;
    width: 100%;
    padding-bottom: 0;
    background-color: var(--color-background-panel);
    color: var(--color-text);
    animation: panelIn 0.22s ease-out;
    /* Contain scroll to the body region so the header/footer stay put. */
    overflow: hidden;
}

/* Padding rather than a raised bottom keeps the panel background behind the
   keyboard while shrinking its flex content. This class is focus-owned and
   never assigned to chat, whose visual-viewport rectangle already clears the
   keyboard. */
.panel.panel--keyboard-lift {
    padding-bottom: var(--keyboard-inset, 0px);
}

/* A panel reopening onto itself is a repaint, not an arrival - see openPanel()
   in ui.js. Without this the fade-from-transparent ran again and showed the
   page underneath for the length of the animation. */
.panel--repaint {
    animation: none;
}

.panel.app-covering-panel {
    top: 0;
    z-index: var(--z-app-covering-panel);
    padding-top: var(--safe-area-top);
}

/* Inner column keeps the same narrow reading width as main content. */
.panel-header,
.panel-body,
.panel-footer {
    width: 100%;
    max-width: var(--container-width);
}

.panel-header {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    gap: var(--spacing-s);
    min-height: 3.25rem;
    padding: var(--spacing-m);
    border-bottom: 1px solid var(--color-border);
}

.panel-title {
    margin: 0;
    font-size: 1.15rem;
    font-weight: 700;
    text-align: center;
    color: var(--color-text);
    line-height: 1.3;
    overflow-wrap: anywhere;
}

.panel-header--action .panel-title {
    min-width: 0;
    padding-inline: 2.75rem;
}

.profile-manager-panel .panel-title {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Compact title-row actions reclaim the footer height in browse/read panels.
   The 44px target remains comfortable on touchscreens; the chevron dismisses
   while the highlighted check in Reorder commits the order.

   Bare glyphs, no chip: these two used to wear a hairline circle over a
   raised fill, on the reasoning that the reader shouldn't have to guess the
   title row is tappable. The chip did that job and cost more than it was
   worth - two enclosed pucks either side of the title made the header the
   busiest row on a screen whose whole language is flat fills and hairlines.
   The affordance moves into the glyphs themselves: 26px on a heavier stroke,
   with the accent saying which of the two commits. The 44px target is
   unchanged - only the ring is gone, so nothing about where you can tap
   moved. */
.panel-header-action {
    position: absolute;
    left: var(--spacing-s);
    top: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    width: 2.75rem;
    height: 2.75rem;
    padding: 0;
    color: var(--color-text-muted);
    background: none;
    border: 0;
    transform: translateY(-50%);
    transition: color 0.15s ease;
}

.panel-header-action svg {
    width: 1.6rem;
    height: 1.6rem;
}

.panel-header-action.confirm-btn {
    color: var(--color-highlight);
}

/* The chevron with unsaved edits behind it: first tap arms, second discards -
   the same tap-to-arm pulse every destructive control in the app carries.
   With the ring gone this is the glyph alone, which is why it goes red *and*
   pulses: colour on a bare mark is easier to miss than motion is. */
.panel-header-action.armed-discard {
    color: var(--color-danger);
    animation: armedTextPulse var(--pulse-armed) ease-in-out infinite;
}

/* Trailing title-row action - the confirm tick, and the Add-exercise search
   icon. Mirrors .panel-header-action on the opposite side, so a panel can
   carry both a dismiss and a trailing action without colliding. */
.panel-header-action--trailing {
    right: var(--spacing-s);
    left: auto;
}

.panel-body {
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
    /* A panel never scrolls sideways. Anything that would make it - a long
       custom-exercise name, a subpixel flex rounding - is a bug in the row,
       and letting the whole panel drift horizontally is the worst rendering
       of it. */
    overflow-x: hidden;
    -webkit-overflow-scrolling: touch;
    padding: var(--spacing-l) var(--spacing-m);
}

.panel-footer {
    flex-shrink: 0;
    padding: var(--spacing-m);
    padding-bottom: calc(var(--spacing-m) + var(--safe-area-bottom));
    border-top: 1px solid var(--color-border);
    background-color: var(--color-background-panel);
}

/* Typography ---------------------------------------------------------- */
.panel-copy {
    margin-bottom: var(--spacing-s);
    font-size: 1.05rem;
    line-height: 1.55;
    color: var(--color-text);
}

.panel-copy--muted {
    color: var(--color-text-muted);
}

.panel-copy strong {
    color: var(--color-highlight);
    font-weight: 700;
}

.panel-section-title {
    margin: var(--spacing-l) 0 var(--spacing-s);
    font-size: 0.8rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.panel-section-title:first-child {
    margin-top: 0;
}

/* One level below .panel-section-title: "Predefined Programs" is the section,
   and these name the groups inside it. Distinguished by weight and colour
   rather than by size, because at 0.8rem uppercase there is no room below to
   go smaller without it ceasing to read as a heading at all.

   Tighter above than a section title and closer to the list it introduces, so
   a heading visibly belongs to the programs under it rather than floating
   between two groups. */
.preset-category-title {
    margin: var(--spacing-m) 0 2px;
    font-size: 0.8rem;
    font-weight: 600;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-highlight);
}

/* The first group sits directly under "Predefined Programs", which has already
   provided the gap. */
.panel-section-title + .preset-category-title {
    margin-top: var(--spacing-s);
}

.panel-stat {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--spacing-m);
    margin-bottom: 0.55rem;
    font-size: 1rem;
    color: var(--color-text-muted);
}

.panel-stat strong {
    color: var(--color-highlight);
    font-family: var(--font-mono);
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    text-align: right;
}

.panel-bullet-list {
    list-style: none;
    display: flex;
    flex-direction: column;
    gap: 0.4rem;
    margin: var(--spacing-s) 0 var(--spacing-m);
    padding: var(--spacing-m);
    border-radius: var(--radius-medium);
    background-color: var(--color-surface);
    box-shadow: var(--shadow-card);
    color: var(--color-text);
    font-size: 0.98rem;
    line-height: 1.45;
}

.panel-bullet-list li::before {
    content: '· ';
    color: var(--color-highlight);
    font-weight: 700;
}

.panel-warning {
    margin-top: var(--spacing-m);
    padding: var(--spacing-m);
    border-radius: var(--radius-medium);
    /* Derived from the theme's own --color-danger, not the literal one the
       lime theme happens to use. Every theme redefines that token, and the
       light ones pick a much darker red (rgb(220,38,38)) precisely so it holds
       contrast on a light surface - hardcoding lime's rgb(248,113,113) put a
       pale red tint on a pale background on all five of them. Flat declaration
       first as the fallback where color-mix is unsupported. */
    border: 1px solid var(--color-danger);
    border: 1px solid color-mix(in srgb, var(--color-danger) 40%, transparent);
    background-color: var(--color-surface);
    background-color: color-mix(in srgb, var(--color-danger) 10%, transparent);
    color: var(--color-text);
    font-size: 1rem;
    line-height: 1.45;
}

.panel-warning strong {
    color: var(--color-text);
}

/* How-to instructions and their recovery note. */
.how-to-content {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-s);
    line-height: 1.6;
}

.how-to-content p {
    font-size: 1.05rem;
    color: var(--color-text);
}

/* Progress weight trend (scripts/chart.js) ----------------------------
   Same SVG geometry as the exported report's per-exercise chart; each
   context brings its own colors since they're two separate stylesheets. */
.trend-chart-wrap {
    margin-bottom: var(--spacing-m);
    padding: var(--spacing-sm) var(--spacing-m);
    border-radius: var(--radius-medium);
    background-color: var(--color-surface);
    box-shadow: var(--shadow-card);
}

.trend-chart {
    display: block;
    width: 100%;
    height: 64px;
}

.trend-chart-line {
    stroke: var(--color-highlight);
    stroke-width: 2;
    stroke-linejoin: round;
    stroke-linecap: round;
    vector-effect: non-scaling-stroke;
}

.trend-chart-dot {
    fill: var(--color-highlight);
}

.trend-chart-range {
    display: flex;
    justify-content: space-between;
    margin-top: var(--spacing-s);
    font-family: var(--font-mono);
    font-size: 0.78rem;
    font-variant-numeric: tabular-nums;
    color: var(--color-text-muted);
}

/* Progress recent sessions - each entry is a .log-entry card, same
   markup/classes as the main workout log (logEntryHtml/logExerciseLine in
   ui.js) and the printable report (reportHistoryEntryHtml in export.js), so
   session history reads identically in all three places. Just the list
   container is specific to this popup. */
.panel-session-list {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-s);
}

/* Dashed outline (not a solid fill) marks it as a boundary action within
   the form rather than the form's own Save/Cancel pair below - tap to arm,
   tap again to confirm, the same shared armButton()/disarmButton() pattern
   (and TRASH_ICON_SVG) every other delete control in the app already uses,
   just laid out as a full-width icon+label row instead of the icon-only
   .delete-log-btn/.remove-exercise-btn shape, since it needs a label of
   its own here rather than sitting next to one. */
.custom-exercise-delete-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--spacing-s);
    width: 100%;
    margin-bottom: var(--spacing-m);
    padding: var(--spacing-sm) var(--spacing-m);
    color: var(--color-text-muted);
    border: 1px dashed var(--color-border-strong);
    border-radius: var(--radius-control);
    font-size: 0.9rem;
    font-weight: 700;
    transition: color 0.2s ease, border-color 0.2s ease;
}

.custom-exercise-delete-btn svg {
    width: 18px;
    height: 18px;
    flex-shrink: 0;
}

.custom-exercise-delete-btn.confirm {
    color: var(--color-highlight);
    border-color: var(--color-highlight);
    font-weight: 800;
    animation: armedTextPulse var(--pulse-armed) ease-in-out infinite;
}

/* Form fields --------------------------------------------------------- */
/* A ruled row, not a lifted card per field. A form of four or five of these
   stacked read as four or five floating panels; the label and its value are
   one line, and a hairline is enough to separate one from the next. */
.weight-input-container {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-m);
    width: 100%;
    margin-bottom: var(--spacing-sm);
    padding: var(--spacing-s) 0;
    border-bottom: 1px solid var(--color-border);
}

.weight-input-container label {
    font-size: 1rem;
    color: var(--color-text);
}

/* Boxed, not underlined. The row already ends in a hairline, so underlining
   the field inside it drew a second rule a few pixels above the first - two
   parallel lines per field, which is what made a stack of them look broken.
   A full outline reads as "this is the editable part of the row" without
   competing with the row's own divider. */
.weight-input-container input[type="number"],
.weight-input-container input[type="text"],
.weight-input-container input[type="date"] {
    width: 110px;
    height: var(--button-min-height);
    padding: 0 var(--spacing-s);
    font-size: 1.2rem;
    text-align: right;
    border: 1px solid var(--color-border-strong);
    border-radius: var(--radius-small);
    background: none;
    color: var(--color-text);
    caret-color: var(--color-accent);
    transition: border-color 0.15s ease;
}

/* The app's one date field. Left out of the rule above it was the only input
   in the app wearing browser chrome: a 22px-tall bar with a 2px inset border
   and a hard-coded grey fill that no theme could reach, sitting under two
   42px boxed fields. appearance: none is what drops that chrome; everything
   else it then needs is already stated above.

   The calendar glyph is left to the platform rather than replaced, because
   applyTheme() puts color-scheme on <html> and the native indicator follows
   it - it is the one part of this control the page does not draw. */
.weight-input-container input[type="date"] {
    appearance: none;
    -webkit-appearance: none;
}

.weight-input-container input[type="date"]::-webkit-calendar-picker-indicator {
    /* A 20px glyph in a 44px row is a tap target of its own, not decoration. */
    padding: var(--spacing-s);
    margin-right: calc(var(--spacing-s) * -1);
    cursor: pointer;
    opacity: 0.75;
}

.weight-input-container input[type="number"]:focus,
.weight-input-container input[type="text"]:focus,
.weight-input-container input[type="date"]:focus {
    border-color: var(--color-accent);
}

/* A field whose value is decided elsewhere - currently only side B of a
   superset's set count, which has to match side A (see customExerciseFormHtml()
   in ui.js). Muted rather than hidden: the number still matters, and its label
   says which exercise it is matching, so removing the row would leave the pair
   looking as if only one side had been given a count. Both the label and the
   value dim, so the row reads as stated-not-asked rather than as a control
   that has failed. */
.weight-input-container:has(input:disabled) label {
    color: var(--color-text-muted);
}

.weight-input-container input:disabled {
    color: var(--color-text-muted);
    border-color: var(--color-border);
    -webkit-text-fill-color: var(--color-text-muted);
    opacity: 1;
}

.weight-input-container input[type="text"] {
    width: min(100%, 12rem);
    text-align: left;
    padding: 0 var(--spacing-s);
    font-size: 1.05rem;
}

/* Label above a full-width field, for the one value on this form that is
   free text and the longest thing on it. The label drops to the app's
   uppercase micro-label voice (the same one .panel-section-title speaks) so
   it heads its field instead of competing with it as a second column. */
.weight-input-container.stacked-field {
    flex-direction: column;
    align-items: stretch;
    gap: var(--spacing-s);
}

.stacked-field > label {
    font-size: 0.8rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.stacked-field > input[type="text"],
.stacked-field > input[type="date"] {
    width: 100%;
    font-size: 1.05rem;
    text-align: left;
}

/* The app's first textarea, matching the stacked text field above it so the
   goal and the note beneath it read as one form rather than two controls.
   1rem is the iOS zoom floor every text-entry control here observes; resize
   is off because a panel body is the scroller and a dragged corner fights it. */
.stacked-field > textarea {
    width: 100%;
    min-height: 7rem;
    padding: var(--spacing-s);
    font: inherit;
    font-size: 1rem;
    line-height: 1.45;
    color: var(--color-text);
    background-color: var(--color-surface-raised);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-small);
    resize: none;
}
.stacked-field > textarea:focus-visible {
    outline: 1px solid var(--color-accent);
    outline-offset: 0;
    background-color: var(--color-press);
}

/* A decimal field is a *number* field wearing type="text" - the type is
   dropped only so a comma-first keyboard can type 1,25 at all (see
   numberField() in ui.js). It must therefore keep the numeric geometry, not
   inherit the name field's wider left-aligned one from the rule directly
   above; this sits after that rule deliberately, since both are one element
   plus one attribute/class and the later one wins. */
.weight-input-container input.decimal-input {
    width: 110px;
    text-align: right;
    font-size: 1.2rem;
}

.weight-input-container input[type="number"]::placeholder,
.weight-input-container input[type="text"]::placeholder {
    color: var(--color-text-muted);
    opacity: 0.7;
}

/* The set editor keeps its increment shortcuts inside the Weight card, so
   the card's normal bottom margin supplies the same breathing room before
   Reps as every other form field. The two actions reuse Settings' segmented
   track language instead of floating as unrelated outlined pills. */
/* Weight (with its stepper beneath) beside reps, as one row. Stacked, the
   two pushed the plate breakdown below the fold on a phone - and the
   breakdown is what a lifter opens this panel to read on the way to the
   rack. The weight column takes the space left over; the wheel is content-
   width, since a two-digit rep count needs no more. */
.set-main-row {
    display: flex;
    align-items: flex-start;
    gap: var(--spacing-m);
    width: 100%;
}

/* Both columns are ordinary fields with their label above - at half the row
   width there is no room for "Weight (kg)" and a 110px box side by side.
   The row draws no rules of its own: the fields' own bottom borders would
   otherwise stack two hairlines a few pixels apart. */
.set-main-row > .weight-input-container {
    flex-direction: column;
    align-items: stretch;
    gap: var(--spacing-s);
    margin-bottom: 0;
    padding-bottom: 0;
    border-bottom: none;
}

.set-main-row .set-weight-field {
    flex: 1 1 auto;
    min-width: 0;
}

.set-main-row .set-weight-field-main {
    flex-direction: column;
    align-items: stretch;
    gap: var(--spacing-s);
}

/* Reps needs room for two digits and nothing more, so weight takes the rest
   of the row rather than the two splitting it evenly. */
.set-main-row > .weight-input-container:not(.set-weight-field) {
    flex: 0 0 6.5rem;
}

/* Each field fills the column it was given. The container selector is not
   decoration: .weight-input-container input.decimal-input pins weights to
   110px, and a bare .set-main-row input loses to it on specificity - which
   left both boxes at 110px with a gap of nothing between them. */
.set-main-row .weight-input-container input {
    width: 100%;
}

/* Every label in the row speaks the same micro-label voice, whichever field
   it heads - the weight's inherited the 1rem sentence-case of a label that
   sits *beside* its field, which read as a different kind of thing from the
   reps label opposite it. */
.set-main-row label {
    font-size: 0.75rem;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.set-weight-field {
    flex-direction: column;
    align-items: stretch;
    gap: var(--spacing-sm);
}

.set-weight-field-main {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-m);
    width: 100%;
}

/* "≈ Total: Xkg" hint under a bodyweight exercise's added-weight field, once
   the user has entered their own body weight in Settings - see
   bodyWeightTotalHint() in openSetEditPopup()/openWarmupEditPopup(). */
.set-weight-total-hint {
    display: block;
    margin-top: -0.25rem;
    font-size: 0.8rem;
    color: var(--color-text-muted);
}

/* Two flat actions split by a hairline, not a filled segmented track. These
   are shortcuts that fire, not a choice with a selected side - the track
   read as a toggle with neither half chosen. */
.set-increment-segment {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: 0;
    width: 100%;
    padding: 0;
}

/* The picker family's construction (see .load-mode-cell): flat cells and
   one center hairline masked to fade toward both edges, replacing the boxed
   underline pair this used to be - the one stepper in the app now speaks
   the same language as the 2x2 pads and mode pickers around it. It is not a
   picker (nothing stays selected), and it no longer answers the press either:
   what confirms the tap is the number changing beside it, which a flash under
   the thumb only competed with. */
.set-increment-btn {
    /* Anchors the inline step field that a hold puts inside this button. */
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 0.4em;
    min-width: 0;
    min-height: var(--button-min-height);
    padding: 0 var(--spacing-s);
    overflow: hidden;
    font-size: 0.78rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
    color: var(--color-highlight);
    background: none;
    border: none;
    border-radius: 0;
}

/* The sign gets a fixed slot rather than sitting in the text flow, so the two
   amounts land at the same distance from the divider and read as one number
   on one line. Centred as a single "− 2.5 kg" string they did not: the pair
   is centred per half, so any difference in advance width between − and +
   (and letter-spacing applies after the glyph, so even matched glyphs differ
   by one tracking step) offsets one number against the other. */
.set-increment-sign {
    flex: none;
    width: 0.75em;
    text-align: center;
    /* The sign is the one glyph here that is a symbol rather than text; the
       tracking that spaces out the amount only pushes it off its own centre. */
    letter-spacing: normal;
}

/* Shrinks before the button clips. The unit can be two characters wider in
   lb than kg, and a narrow phone in a superset panel is where that shows. */
.set-increment-amount {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
}

.set-increment-btn + .set-increment-btn::before {
    content: '';
    position: absolute;
    inset-block: 0;
    left: 0;
    width: 1px;
    background-color: var(--color-border-strong);
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 30%, #000 70%, transparent 100%);
    mask-image: linear-gradient(to bottom, transparent 0, #000 30%, #000 70%, transparent 100%);
}

.set-increment-btn:disabled {
    color: var(--color-text-muted);
    opacity: 0.4;
}


/* Inline step entry, rendered inside whichever +/- button was held - the same
   field-replaces-the-label shape a set button uses for its reps (see
   .set-btn .reps-input above), squared off rather than pill-shaped to match
   this button. Only the number is typed: the sign stays with the button and
   the unit with the weight field above.

   Qualified with input.reps-input, not just .reps-input: this button lives
   inside the Weight card, which is a .weight-input-container, and that
   container's own boxed number-field styling would otherwise out-specify
   every line below and leave a 110px-wide outlined box overflowing the
   button it is supposed to fill. */
.set-increment-btn input.reps-input {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    padding: 0;
    text-align: center;
    /* 1rem is the floor for every text-entry control in the app, not a design
       choice: iOS zooms the whole page in when a field smaller than 16px takes
       focus, and this one is opened by a gesture that already has the lifter's
       thumb on the button. Larger than the 0.78rem label it replaces, which is
       correct - the label is read, this is typed into. */
    font-size: 1rem;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    letter-spacing: normal;
    border: none;
    border-radius: 0;
    background-color: var(--color-background-log-entry);
    color: var(--color-ink);
}

/* The sign and amount are hidden rather than removed while the field is open,
   so the button keeps the width it had - a stepper that narrowed as it was
   held would drag the other half of the segment sideways under the thumb. */
.set-increment-btn.editing-step .set-increment-sign,
.set-increment-btn.editing-step .set-increment-amount {
    visibility: hidden;
}

/* Built-in exercise guidance stays inside the set editor so opening it never
   destroys staged Weight/Reps values. Custom exercises omit this disclosure. */
.set-guide-disclosure {
    width: 100%;
    margin-bottom: var(--spacing-sm);
    overflow: hidden;
    border-bottom: 1px solid var(--color-border);
}

.set-guide-toggle {
    justify-content: space-between;
    width: 100%;
    min-height: var(--button-min-height);
    padding: 0;
    font-size: 1rem;
    font-weight: 700;
    color: var(--color-text);
}

.set-guide-chevron {
    font-size: 1.35rem;
    line-height: 1;
    color: var(--color-text-muted);
    transition: transform 0.2s ease;
}

.set-guide-toggle[aria-expanded="true"] .set-guide-chevron {
    transform: rotate(90deg);
}

.set-guide-body {
    padding: var(--spacing-m);
    border-top: 1px solid var(--color-border);
}

.set-guide-body .how-to-content p {
    font-size: 0.98rem;
}

/* Plate calculator card below the reps input - see openSetEditPopup() and
   plateCalcBodyHtml() in ui.js. A self-contained elevated card (not a bare
   line of text), one tier lighter than .weight-input-container above it so
   the two stacked cards stay visually distinct. */
/* Read-only output, not another surface: the one thing in this form that
   isn't a control, so it reads as a note under the fields rather than a
   card sat among them. */
.plate-calc {
    width: 100%;
    margin-bottom: var(--spacing-sm);
    padding: var(--spacing-s) 0;
}

.plate-calc-title {
    margin: 0 0 var(--spacing-s);
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.plate-calc-unit {
    text-transform: none;
    letter-spacing: normal;
    font-weight: 400;
    color: var(--color-text-muted);
}

.plate-calc-note {
    margin: 0;
    font-size: 0.95rem;
    color: var(--color-highlight);
}

.plate-calc-row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--spacing-m);
    padding: 0.3rem 0;
}

.plate-calc-row + .plate-calc-row {
    border-top: 1px solid var(--color-border);
}

.plate-calc-label {
    font-size: 0.88rem;
    color: var(--color-text-muted);
}

.plate-calc-value {
    font-family: var(--font-mono);
    font-size: 0.95rem;
    font-weight: 700;
    color: var(--color-highlight);
    font-variant-numeric: tabular-nums;
    text-align: right;
}

/* Actions --------------------------------------------------------------
   Form and confirmation panel footers resolve to one of two shapes:
     - a single dismiss button (Close / Done), centered and full-width
     - a Cancel + Save/Confirm pair, side by side and equal width
   Every action uses the same transparent surface. Intent comes from its
   outline and label (accent = positive, red = destructive), so a primary
   choice stays legible without becoming a solid block inside a flat panel. */
.panel-actions {
    display: flex;
    align-items: stretch;
    justify-content: center;
    gap: var(--spacing-m);
    width: 100%;
}

.panel-actions button {
    flex: 1 1 0;
    min-height: var(--button-min-height);
    padding: var(--spacing-s) var(--spacing-m);
    font-size: 1rem;
    font-weight: 700;
    letter-spacing: 0.01em;
    color: var(--color-text);
    background-color: transparent;
    border: 1px solid var(--color-border-strong);
    border-radius: var(--radius-control);
    transition: background-color 0.15s ease, border-color 0.15s ease;
}

/* Both conflict choices are full sentences in several locales. Stack this
   panel's pair so a narrow phone never squeezes either label into a cramped
   two-line half-width button.

   The draft prompt takes the same treatment for the same reason, and is
   selected on its own button rather than on a panel class because it paints
   into the shared program-manager panel: "Discard Changes" wrapped to two
   lines beside a single-line "Keep as Draft", so the pair sat at different
   text heights and read as two unrelated controls rather than one choice. */
.program-conflict-panel .panel-actions,
.panel-actions:has(.keep-draft-btn) {
    flex-direction: column;
}

.program-conflict-panel .panel-actions button,
.panel-actions:has(.keep-draft-btn) button {
    width: 100%;
}

/* Cancel / Close / Keep: neutral outline, never competes with the primary
   action. */
.panel-actions .cancel-btn,
.panel-actions .close-btn,
.panel-actions .keep-btn {
    color: var(--color-text-muted);
    border-color: var(--color-border-strong);
}

/* The deload prompt has three real choices rather than the usual pair, so
   stack them to keep the exact reduction labels readable on a phone. */
.deload-panel .panel-actions {
    flex-direction: column;
}

.deload-panel .panel-actions button {
    width: 100%;
}

.deload-science {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-s);
    padding: var(--spacing-sm);
    color: var(--color-text-muted);
    background-color: var(--color-surface-raised);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-small);
    font-size: 0.88rem;
    line-height: 1.45;
}

.deload-science > strong {
    color: var(--color-text);
}

.deload-panel .step-btn {
    color: var(--color-highlight);
    border-color: var(--color-accent-border);
    font-variant-numeric: tabular-nums;
}

/* One compact, individually-selectable progression review at final Save. */
.progression-candidate-list {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-s);
}

.progression-candidate {
    display: flex;
    align-items: center;
    justify-content: flex-start;
    gap: var(--spacing-sm);
    width: 100%;
    min-height: 3.25rem;
    padding: var(--spacing-s) var(--spacing-sm);
    color: var(--color-text);
    background-color: var(--color-surface-raised);
    border: 1px solid var(--color-accent-border);
    border-radius: var(--radius-control);
}

.progression-candidate[aria-pressed="false"] {
    color: var(--color-text-muted);
    background-color: transparent;
    border-color: var(--color-border);
}

.progression-candidate-check {
    display: flex;
    align-items: center;
    justify-content: center;
    flex: 0 0 1.65rem;
    width: 1.65rem;
    height: 1.65rem;
    color: var(--color-on-accent);
    background-color: var(--color-accent);
    border-radius: 50%;
}

.progression-candidate[aria-pressed="false"] .progression-candidate-check {
    background-color: var(--color-track);
}

.progression-candidate-copy {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 2px;
    min-width: 0;
    text-align: left;
}

.progression-candidate-copy > span {
    color: var(--color-highlight);
    font-family: var(--font-mono);
    font-size: 0.86rem;
    font-variant-numeric: tabular-nums;
}

.progression-candidate-copy small {
    color: var(--color-text-muted);
}

.progression-review-panel .panel-actions {
    flex-direction: column;
}

.progression-review-panel .panel-actions button {
    width: 100%;
}

.progression-review-panel .progression-skip-btn {
    color: var(--color-highlight);
    border-color: var(--color-accent-border);
}

/* End-workout checkpoint: three equal top-line facts, followed by the same
   flat ruled rows used throughout the panels. The footer is intentionally
   retained here because Save versus Discard is the question this surface
   exists to ask. */
.workout-summary-totals {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: var(--spacing-s);
    margin: 0;
}

.workout-summary-totals > div {
    min-width: 0;
    text-align: center;
}

.workout-summary-totals dt {
    margin-bottom: 0.35rem;
    font-size: 0.64rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.workout-summary-totals dd {
    margin: 0;
    overflow: hidden;
    font-size: 1rem;
    font-weight: 700;
    text-overflow: ellipsis;
    white-space: nowrap;
    color: var(--color-text);
}

.workout-summary-exercises {
    margin: 0;
    padding: 0;
    list-style: none;
}

.workout-summary-exercise {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-m);
    min-height: 3.25rem;
    border-bottom: 1px dotted var(--color-border);
}

.workout-summary-exercise-name {
    min-width: 0;
    overflow: hidden;
    font-weight: 700;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.workout-summary-exercise-facts {
    display: flex;
    flex-direction: column;
    flex: 0 0 auto;
    align-items: flex-end;
    gap: 0.15rem;
    font-size: 0.72rem;
    color: var(--color-text-muted);
}

.workout-summary-exercise-facts strong {
    color: var(--color-text);
}

.workout-summary-panel .summary-discard-btn,
.panel-actions .discard-draft-changes-btn {
    color: var(--color-danger);
    border-color: var(--color-danger);
}

.workout-summary-panel .summary-discard-btn.confirm {
    animation: armedTextPulse var(--pulse-armed) ease-in-out infinite;
}

/* Save / Confirm: the positive outline distinguishes it from the neutral
   action beside it without introducing a filled CTA. */
.panel-actions .confirm-btn {
    color: var(--color-success);
    background-color: transparent;
    border-color: var(--color-success-edge);
}

/* Destructive variant (e.g. Restore) - same outlined shape, red instead of
   the theme accent. */
.panel-actions .confirm-btn.danger {
    color: var(--color-danger);
    background-color: transparent;
    border-color: var(--color-danger);
}

.cutover-panel .panel-actions,
.cutover-complete-panel .panel-actions {
    flex-direction: column;
}

.cutover-panel .panel-actions button,
.cutover-complete-panel .panel-actions button {
    width: 100%;
}

/* Secondary, non-destructive action shown above the main button row
   (e.g. "Apply to all sets", "Use calculated value") - a quiet full-width
   outlined button so it reads as a deliberate action without competing
   with the Cancel/Save pair below it or borrowing the one accent color. */
/* A flat text action over a hairline, not an outlined pill - the same voice
   .profile-add-row and .logs-toggle-btn already speak. The box was the last
   outlined button left outside the panel footer, and beside the flat pickers
   and steppers around it ("Apply to All Sets" sits directly under the
   increment pair) it read as a different app's control. */
.panel-secondary-action {
    display: block;
    width: 100%;
    margin-bottom: var(--spacing-m);
    padding: var(--spacing-sm) var(--spacing-m);
    font-size: 0.78rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    text-align: center;
    color: var(--color-accent);
    background: transparent;
    border: none;
    border-top: 1px solid var(--color-border);
    border-radius: 0;
}

/* The footer already draws the rule that separates it from the body, so the
   button's own would be a second hairline a few pixels below the first. */
.panel-footer .panel-secondary-action {
    border-top: none;
}


/* Training max onboarding (openTrainingMaxSetupPopup) ------------------ */
/* This is a four-step setup, not one twelve-field form. The slim segmented
   rule uses the app's accent as progress without introducing a new family of
   dots, cards or illustrations. Its text stays visible beside it so colour is
   never the only indication of where the lifter is. */
.training-max-progress {
    display: flex;
    align-items: center;
    gap: var(--spacing-sm);
    margin-bottom: var(--spacing-m);
}

.training-max-progress-label {
    flex: 0 0 auto;
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.training-max-progress-track {
    display: flex;
    flex: 1;
    gap: 4px;
    min-width: 0;
}

.training-max-progress-segment {
    flex: 1;
    height: 3px;
    overflow: hidden;
    background-color: var(--color-border-strong);
    border-radius: var(--radius-pill);
}

.training-max-progress-segment.is-reached {
    background-color: var(--color-highlight);
}

/* Complete steps dim slightly so the current segment remains the clearest
   mark, while still leaving the travelled part of the path visible. */
.training-max-progress-segment.is-complete {
    opacity: 0.5;
}

.training-max-setup-intro {
    padding-bottom: var(--spacing-sm);
    margin: 0 0 var(--spacing-m);
    border-bottom: 1px solid var(--color-border);
    font-size: 0.92rem;
    line-height: 1.5;
    color: var(--color-text-muted);
}

.training-max-lift-group {
    width: 100%;
}

.training-max-lift-name {
    margin: 0 0 var(--spacing-m);
    font-size: clamp(1.45rem, 7vw, 1.9rem);
    font-weight: 750;
    line-height: 1.1;
    letter-spacing: -0.025em;
    color: var(--color-text);
    overflow-wrap: anywhere;
}

.training-max-method-tabs {
    margin-bottom: var(--spacing-m);
}

.training-max-subtitle {
    margin: 0;
    font-size: 0.8rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.training-max-suggest-inputs {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    gap: var(--spacing-s);
    margin-top: var(--spacing-sm);
}

/* In this panel the two halves are compact labelled boxes. The shared number
   field is normally a label/value row, but two such rows cannot fit side by
   side on a phone; stacking each label over its box preserves the useful
   weight-and-reps pairing without squeezing either target. */
.training-max-suggest-inputs .weight-input-container {
    flex-direction: column;
    align-items: stretch;
    gap: 0.35rem;
    min-width: 0;
    padding: 0;
    margin: 0;
    border-bottom: none;
}

.training-max-suggest-inputs .weight-input-container label {
    min-height: 2.3em;
    font-size: 0.78rem;
    line-height: 1.15;
    color: var(--color-text-muted);
}

.training-max-suggest-inputs .weight-input-container input[type="number"],
.training-max-suggest-inputs .weight-input-container input[type="text"] {
    width: 100%;
    text-align: left;
}

.training-max-suggest-btn.panel-secondary-action {
    margin: var(--spacing-sm) 0 0;
}

/* The only persisted value sits downstream of the optional calculator. An
   accent rule and highlighted digits make that hierarchy unmistakable while
   retaining the flat, hairline-led form language used throughout the app. */
.training-max-result {
    margin-top: 0;
    padding-left: var(--spacing-m);
    border-left: 2px solid var(--color-accent-border);
    border-top: none;
}

.training-max-result .weight-input-container {
    margin-top: var(--spacing-s);
}

.training-max-result .weight-input-container input {
    font-family: var(--font-mono);
    font-size: 1.3rem;
    font-weight: 700;
    color: var(--color-highlight);
    border-color: var(--color-accent-border);
    font-variant-numeric: tabular-nums;
}

.training-max-history-note {
    margin-top: calc(var(--spacing-s) * -1);
}

/* Small explanatory captions under a training-max field: the history-based
   suggestion's provenance (which set, which program, how long ago), the
   calculator instruction, and the direct-entry explanation. Click
   feedback for the Suggest button itself goes through showNotification()
   instead (the app's normal header status mechanism), not text here. */
.training-max-hint {
    margin-top: 4px;
    margin-bottom: var(--spacing-s);
    font-size: 0.8rem;
    line-height: 1.4;
    color: var(--color-text-muted);
}

.training-max-setup-warning {
    margin-top: var(--spacing-l);
}

.training-max-setup-panel .panel-footer .training-max-next-btn {
    width: 100%;
}

.training-max-setup-panel .training-max-next-btn:disabled {
    color: var(--color-text-muted);
    background-color: var(--color-surface-raised);
    border-color: var(--color-border);
    cursor: default;
    opacity: 0.55;
}


/* Add exercise picker -------------------------------------------------- */
.add-exercise-panel .exercise-buttons {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    margin-top: var(--spacing-s);
}

/* Hairline-divided rows rather than a stack of lifted cards - the picker is
   a list to scan, and 300+ shadowed rectangles was the heaviest surface left
   in the app. */
.add-exercise-panel .exercise-buttons button {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-m);
    width: 100%;
    min-height: 2.6rem;
    padding: 0.4rem 0;
    font-size: 0.95rem;
    color: var(--color-text);
    background: none;
    border-bottom: 1px solid var(--color-border);
    border-radius: 0;
}

.add-exercise-panel .exercise-buttons button:last-child {
    border-bottom: none;
}

.exercise-buttons-empty {
    padding: var(--spacing-m);
    color: var(--color-text-muted);
    font-size: 0.9rem;
    text-align: center;
}

.exercise-pick-name {
    font-weight: 600;
    text-align: left;
}

.exercise-pick-meta {
    flex-shrink: 0;
    font-variant-numeric: tabular-nums;
    color: var(--color-text-muted);
    font-weight: 400;
    font-size: 0.85rem;
}

/* Distinct top action for defining a free-form exercise, above the category
   grid - flagged with a dashed border rather than a color, matching the
   "custom 1RM" treatment. No longer a row inside .exercise-buttons (it's
   promoted above the category grid as its own button), so it carries the
   same base button treatment as those rows directly, rather than
   inheriting it via that ancestor. */
/* Single vs. Superset at the head of the add-exercise dialog. Tabs, not the
   filled pill Settings uses for its preference rows: everything else in this
   panel is flat text separated by hairlines, and a rounded two-tone capsule
   sitting above it read as a control borrowed from another app.

   The vocabulary here is already established elsewhere - the small uppercase
   label of .session-card-start-tag, the hairline of .set-column's rule, and
   an accent underline that means "this one" exactly as #session-underline-bar
   does under the start button. The underline is --color-highlight rather than
   the raw accent: it's a small solid shape, and the raw accent drops to 3.2:1
   in Candy Pop. */
.picker-mode-tabs {
    display: grid;
    grid-template-columns: repeat(2, minmax(0, 1fr));
    margin-bottom: var(--spacing-m);
    border-bottom: 1px solid var(--color-border);
}

.picker-mode-tabs-btn {
    min-height: 2.5rem;
    padding: 0 0 0.55rem;
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--color-text-muted);
    background: none;
    border: none;
    border-radius: 0;
    /* Overlaps the group's own hairline, so the active tab's rule replaces
       that segment of it rather than stacking a second line beneath it. */
    border-bottom: 2px solid transparent;
    margin-bottom: -1px;
    transition: color 0.15s ease, border-color 0.15s ease;
}

.picker-mode-tabs-btn[aria-checked="true"] {
    color: var(--color-highlight);
    border-bottom-color: var(--color-highlight);
}

/* Superset mode's "where am I" line. The whole point of merging the picker
   and the form into one panel was that a superset stopped tearing the screen
   down between sides - so the running selection has to be visible, or the
   list just silently reappears and reads as a failed tap. Sits above
   everything else in the body, since it describes the whole step. */
.exercise-superset-progress {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    margin-bottom: var(--spacing-m);
    padding-bottom: var(--spacing-s);
    border-bottom: 1px solid var(--color-border);
}

.exercise-superset-done {
    font-size: 0.95rem;
    font-weight: 700;
    color: var(--color-highlight);
}

/* Finishes the group, and appears only once there are two members to finish.
   It lives here rather than in the header because that row's two slots are
   already the chevron and the catalog search - and search is exactly what a lifter
   building a circuit needs most. Sized as a real target: this is the one
   control that ends the flow. */
.exercise-superset-finish {
    align-self: flex-start;
    min-height: var(--button-min-height);
    margin-top: var(--spacing-s);
    padding: 0 var(--spacing-m);
    font-size: 0.78rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-highlight);
    background: none;
    border: 1px solid var(--color-accent-border);
    border-radius: var(--radius-control);
}
.exercise-superset-next {
    font-size: 0.8rem;
    color: var(--color-text-muted);
}

/* Add Custom Exercise carries no rule of its own: it is a plain
   .panel-secondary-action, the app's full-width secondary action, shared
   with Add Exercise, Add Workout Card and Apply to All Sets. It briefly read
   as a hairline list row here, which put the one control that *makes*
   something into the same shape as the rows that merely pick something, and
   left the panel with no button-shaped thing above a grid of buttons.
   Matching that idiom without overrides is the point; its own margin-bottom
   sets the gap to the category grid below. */

/* Categories behind a disclosure, reusing the tools-list vocabulary (label,
   value, chevron, expand in place) the same way .history-program-filter does.
   The grid used to sit open at 3 columns, which was fine in English but not
   in the longer languages: "Lichaamsgewicht" or "Kroppsvektsøvelser" in a
   third of the panel width has nowhere to break, so the button pushed the
   panel wider than the screen and left the whole thing scrolling sideways.
   Two columns plus the collapsed default gives each label roughly double the
   room, and `overflow-wrap: anywhere` guarantees even a single long word can
   break rather than setting the panel's width. */
.exercise-category-disclosure {
    margin-bottom: var(--spacing-m);
}

/* A disclosure row in the app's own list voice - label left in the row
   weight every other tappable row uses, active category right, chevron svg
   turning on expand. The bare tracked-out caption this used to be read as a
   heading, not a control. */
.exercise-category-toggle {
    display: flex;
    align-items: center;
    gap: var(--spacing-sm);
    width: 100%;
    min-height: 46px;
    padding: 0;
    border-radius: 0;
    color: var(--color-text);
    font-size: 0.92rem;
    font-weight: 600;
    /* The list rows' quiet dotted rule (see .set-column), so this reads as
       the first row of the list it filters. */
    background-image: linear-gradient(to right, var(--color-border-strong) 1px, transparent 1px);
    background-image: linear-gradient(to right,
        color-mix(in srgb, var(--color-text-muted) 55%, var(--color-background)) 1px, transparent 1px);
    background-size: 6px 1px;
    background-position: bottom left;
    background-repeat: repeat-x;
}

.exercise-category-label {
    white-space: nowrap;
}

.exercise-category-value {
    flex: 1;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    text-align: right;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--color-text-muted);
}

.exercise-category-toggle .tools-list-chevron {
    flex: 0 0 auto;
    width: 16px;
    height: 16px;
    opacity: 0.45;
    color: var(--color-text-muted);
    transition: transform 0.2s ease;
}

.exercise-category-toggle[aria-expanded="true"] .tools-list-chevron {
    transform: rotate(90deg);
}

/* The picker family's construction, stretched to five rows: hairline rules
   between the rows, a vertical divider down the middle that fades away at
   the block's outer edges - so the grid stays pinched rather than caged,
   exactly like the band and load-mode pickers. Chips with gaps were the one
   grid in the app speaking a different language. */
.exercise-category-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    width: calc(100% + 2 * var(--spacing-m));
    margin: 0 calc(-1 * var(--spacing-m)) var(--spacing-s);
}

.exercise-category-btn {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: var(--button-min-height);
    padding: var(--spacing-s) 0.4rem;
    border: none;
    border-radius: 0;
    background: none;
    color: var(--color-text-muted);
    font-size: 0.8rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    line-height: 1.2;
    text-align: center;
    overflow-wrap: anywhere;
    transition: background-color 0.15s ease, color 0.15s ease;
}

.exercise-category-btn:nth-child(2n)::before {
    content: '';
    position: absolute;
    inset-block: 0;
    left: 0;
    width: 1px;
    background-color: var(--color-border-strong);
}

.exercise-category-btn:nth-child(-n + 2)::before {
    -webkit-mask-image: linear-gradient(to top, #000 0, #000 12%, transparent 100%);
    mask-image: linear-gradient(to top, #000 0, #000 12%, transparent 100%);
}

.exercise-category-btn:nth-last-child(-n + 2)::before {
    -webkit-mask-image: linear-gradient(to bottom, #000 0, #000 12%, transparent 100%);
    mask-image: linear-gradient(to bottom, #000 0, #000 12%, transparent 100%);
}

.exercise-category-btn:nth-child(n + 3) {
    border-top: 1px solid var(--color-border);
}

/* The chosen category takes the load-mode block's selected state - brightened
   ink on an accent tint, never a filled box and no underline. This grid is
   hairline-divided on both axes exactly like the load-mode and band-tension
   blocks, and the rule under the live cell landed on the divider below it;
   see --picker-hairline. */
.exercise-category-btn[aria-checked="true"] {
    color: var(--color-text);
    background-color: var(--color-accent-fill);
}

/* Full-width search bar on the dedicated exercise-search page - unlike
   .weight-input-container (capped at 12rem, built for a label+value row),
   this is meant to read as a prominent, edge-to-edge search field. */
/* The field is the control; it does not also need a lifted card around it.
   Underlined rather than boxed, matching the accent rule this app uses for
   "this is the active thing" everywhere else. */
.exercise-search-field {
    display: flex;
    width: 100%;
    margin-bottom: var(--spacing-m);
    padding: 0;
}

.exercise-search-field input {
    width: 100%;
    height: var(--button-min-height);
    padding: 0;
    border: none;
    border-bottom: 1px solid var(--color-border-strong);
    border-radius: 0;
    background: none;
    color: var(--color-text);
    caret-color: var(--color-accent);
    font-size: 1.05rem;
    transition: border-color 0.15s ease;
}

.exercise-search-field input:focus {
    outline: none;
    border-bottom-color: var(--color-accent);
}

/* Panel body copy for the manual - scoped so it only applies inside .manual-panel
   and does not bleed into other panels. */
.manual-content {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-s);
    line-height: 1.6;
    color: var(--color-text);
}

.manual-content h2 {
    margin: var(--spacing-l) 0 0;
    font-size: 1.15rem;
    font-weight: 700;
    color: var(--color-highlight);
    border-bottom: 1px solid var(--color-border);
    padding-bottom: 0.35rem;
}

.manual-content h2:first-child {
    margin-top: 0;
}

.manual-content h3 {
    margin: var(--spacing-m) 0 0;
    font-size: 1rem;
    font-weight: 700;
    color: var(--color-text-muted);
}

.manual-content p {
    margin: 0;
    font-size: 0.98rem;
    color: var(--color-text);
}

.manual-content ul,
.manual-content ol {
    margin: 0 0 0 var(--spacing-l);
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
}

.manual-content li {
    font-size: 0.98rem;
    color: var(--color-text);
    padding-left: 0.2rem;
}

.manual-content strong {
    color: var(--color-highlight);
    font-weight: 700;
}

.manual-content code {
    font-family: monospace;
    font-size: 0.92em;
    color: var(--color-text);
    background: var(--color-surface-raised);
    padding: 1px 5px;
    border-radius: 4px;
}

/* ======================== 9c · Settings panel ============================ */
/* No gap: each row carries its own hairline now, so spacing between them
   would double the separation the rule already provides. */
.settings-list {
    display: flex;
    flex-direction: column;
    gap: 0;
}

.settings-equipment-inventory {
    padding: var(--spacing-sm) 0;
    border-bottom: 1px solid var(--color-border);
}

.settings-equipment-inventory summary {
    cursor: pointer;
    font-weight: 700;
}

.settings-equipment-inventory .settings-note {
    padding: var(--spacing-s) 0;
    text-align: left;
}

/* Ruled rows rather than lifted cards - the same treatment
   .weight-input-container and the panel lists moved to. Settings is a list
   of label/control pairs, and a shadow per pair made a dozen of them read
   as a dozen separate surfaces. */
.settings-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-m);
    padding: var(--spacing-sm) 0;
    border-bottom: 1px solid var(--color-border);
}

.settings-list > .settings-row:last-child {
    border-bottom: none;
}

/* A .settings-row used standalone (not inside a .settings-list) sits among
   .weight-input-container fields - the set editor's AMRAP toggle - so it
   takes the same bottom margin they do, keeping one rhythm down the form. */
.panel-body > .settings-row {
    margin-bottom: var(--spacing-sm);
}

/* Stacked variant for rows whose control is too wide to sit beside the
   label on one line (the theme swatch grid below). */
.settings-row--stacked {
    flex-direction: column;
    align-items: stretch;
    gap: var(--spacing-sm);
}

/* Theme picker - a 2-column grid of swatches (three rows for the current six
   themes) rather than the segmented-pill pattern used for kg/lb, since a
   visual choice like this needs an actual color preview rather than just a
   text label. */
.theme-swatch-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: var(--spacing-s);
    width: 100%;
}

.theme-swatch {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--spacing-s);
    padding: var(--spacing-sm);
    border-radius: var(--radius-control);
    border: 1px solid var(--color-border);
    background-color: transparent;
    transition: border-color 0.15s ease, background-color 0.15s ease;
}

/* Selected state uses the currently-active theme's own accent, so the
   indicator feels integrated with whichever theme is live right now. */
.theme-swatch[aria-checked="true"] {
    border-color: var(--color-accent);
    background-color: var(--color-accent-fill);
}

.theme-swatch-preview {
    position: relative;
    display: block;
    width: 100%;
    height: 40px;
    border-radius: var(--radius-small);
    /* --color-border is white-at-8% on the dark themes, so this is unchanged
       there - but it is a dark hairline on the five light ones, where a white
       8% border was invisible against a light page and the swatches lost their
       outline exactly where they most needed it. The chip's own fill previews
       another theme; this border separates it from the page it sits on, which
       is what --color-border is for. */
    border: 1px solid var(--color-border);
}

.theme-swatch-dot {
    position: absolute;
    bottom: 6px;
    right: 6px;
    width: 14px;
    height: 14px;
    border-radius: 50%;
}

.theme-swatch-label {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--color-text);
}

/* Per-theme literal preview colors - deliberately NOT driven by the active
   [data-theme] tokens, since every swatch must always show what ITS OWN
   theme looks like, regardless of which theme is currently applied. */
.theme-swatch--lime .theme-swatch-preview { background-color: rgb(12, 12, 12); }
.theme-swatch--lime .theme-swatch-dot { background-color: rgb(198, 241, 53); }


.theme-swatch--cobalt .theme-swatch-preview { background-color: rgb(28, 36, 54); }
.theme-swatch--cobalt .theme-swatch-dot { background-color: rgb(96, 165, 250); }


/* The black four. Their previews show --color-surface rather than the pure
   black --color-background every one of them shares: four identical black
   chips would say nothing about which theme is which, and the surface is the
   colour a card is actually drawn in. */
.theme-swatch--cerise .theme-swatch-preview { background-color: rgb(12, 12, 12); }
.theme-swatch--cerise .theme-swatch-dot { background-color: rgb(232, 62, 140); }

.theme-swatch--default .theme-swatch-preview { background-color: rgb(10, 12, 13); }
.theme-swatch--default .theme-swatch-dot { background-color: rgb(0, 199, 255); }

.theme-swatch--red .theme-swatch-preview { background-color: rgb(13, 11, 11); }
.theme-swatch--red .theme-swatch-dot { background-color: rgb(240, 62, 48); }

.theme-swatch--green .theme-swatch-preview { background-color: rgb(11, 13, 11); }
.theme-swatch--green .theme-swatch-dot { background-color: rgb(59, 209, 111); }

/* The white six. Their chips show --color-surface rather than the pure white
   ground five of them share, for the same reason the black six show theirs:
   six identical white chips would say nothing about which theme is which. */
.theme-swatch--scarlett .theme-swatch-preview { background-color: rgb(250, 250, 250); }
.theme-swatch--scarlett .theme-swatch-dot { background-color: rgb(232, 33, 0); }

.theme-swatch--navy .theme-swatch-preview { background-color: rgb(250, 250, 251); }
.theme-swatch--navy .theme-swatch-dot { background-color: rgb(19, 42, 109); }

.theme-swatch--fern .theme-swatch-preview { background-color: rgb(250, 251, 250); }
.theme-swatch--fern .theme-swatch-dot { background-color: rgb(0, 128, 0); }

.theme-swatch--magnolia .theme-swatch-preview { background-color: rgb(248, 244, 255); }
.theme-swatch--magnolia .theme-swatch-dot { background-color: rgb(196, 42, 110); }


.settings-row-label {
    font-size: 1rem;
    color: var(--color-text);
}

/* Custom role="switch" control - no native checkbox chrome appears anywhere
   else in the app, so this matches the pill-button language instead.

   The off track is --color-track, which is a 10-12% wash of the text colour
   and so lands ~1.2:1 against the page in every theme - a fill, not an edge.
   The knob alone was therefore drawing the whole control, and an off switch
   read as a loose dot rather than as something with somewhere to slide to.
   The border is what states "this is a track"; it takes the same treatment
   .set-btn's empty ring does (90% of --color-text-muted, the one per-theme
   value already tuned against the page), measured at 4.2-5.4:1 in thirteen
   themes. Candy Pop is the exception at 2.78, because its muted token is
   itself translucent (72% white) and so mixes paler than the opaque ones;
   left as is, since there the *fill* carries the state on its own (3.7:1
   between the two tracks) and this edge is only the affordance. It replaces
   a transparent 1px border that was already reserved here, so nothing
   moves. */
.settings-toggle {
    flex-shrink: 0;
    justify-content: flex-start;
    width: 3rem;
    height: 1.7rem;
    min-height: 0;
    padding: 3px;
    /* Flat declaration first as the fallback where color-mix is unsupported. */
    border: 1px solid var(--color-text-muted);
    border: 1px solid color-mix(in srgb, var(--color-text-muted) 90%, var(--color-background));
    border-radius: var(--radius-pill);
    background-color: var(--color-track);
    transition: background-color 0.2s ease, border-color 0.2s ease;
}

/* On, the accent fill is its own edge - the border would only muddy it. */
.settings-toggle[aria-checked="true"] {
    background-color: var(--color-accent);
    border-color: transparent;
}

.settings-toggle-knob {
    width: 1.25rem;
    height: 1.25rem;
    border-radius: 50%;
    background-color: var(--color-text);
    transition: transform 0.2s ease, background-color 0.2s ease;
}

/* The knob is ink on --color-accent once the switch is on, so it has to be
   --color-on-accent and not --color-text. --color-text is chosen to contrast
   with the *page*, and on the themes whose accent sits at the same end of the
   scale as their text that put a light knob on a light track: 1.00:1 on Candy
   Pop (white accent, white text - the knob vanished outright), 1.11 Newsprint,
   1.12 Lime, 1.31 Navy, and eight of thirteen themes under the 3:1 floor. --color-on-accent is the token that already answers "what inks a
   solid accent fill" for .settings-segmented-btn just below; through it the
   worst theme is 4.36:1. */
.settings-toggle[aria-checked="true"] .settings-toggle-knob {
    transform: translateX(1.3rem);
    background-color: var(--color-on-accent);
}

/* Decided by something else on the same panel, not broken: the AMRAP toggle
   while the reps wheel sits at 0, since a set with no prescribed count is
   as-many-as-possible by definition. Dimmed rather than hidden - the state
   is still the answer to a question the row is asking, and hiding it would
   read as the setting having been lost. The row's label dims with it, the
   same way .weight-input-container does for a disabled field. */
.settings-toggle[aria-disabled="true"] {
    opacity: 0.55;
}

.weight-input-container:has(.settings-toggle[aria-disabled="true"]) label {
    color: var(--color-text-muted);
}

/* Custom role="radiogroup" control for exclusive choices (e.g. kg vs lb) -
   a two-segment pill sitting on the same track styling as settings-toggle,
   including that control's edge. These two sit in adjacent rows of the same
   panel, so the track has to be drawn the same way in both or the settings
   list reads as two different kinds of control. Unlike the switch it keeps
   its border in every state: no state fills the whole pill, so the accent
   never becomes the edge here the way it does at aria-checked. */
.settings-segmented {
    flex-shrink: 0;
    display: flex;
    gap: 2px;
    padding: 3px;
    border: 1px solid var(--color-text-muted);
    border: 1px solid color-mix(in srgb, var(--color-text-muted) 90%, var(--color-background));
    border-radius: var(--radius-pill);
    background-color: var(--color-track);
}

.settings-segmented-btn {
    min-height: 0;
    padding: 0.25rem 0.85rem;
    border: none;
    border-radius: var(--radius-pill);
    background-color: transparent;
    color: var(--color-text-muted, var(--color-text));
    font-size: 0.875rem;
    font-weight: 600;
    line-height: 1.5;
    cursor: pointer;
    transition: background-color 0.2s ease, color 0.2s ease;
}

.settings-segmented-btn[aria-checked="true"] {
    background-color: var(--color-accent);
    color: var(--color-on-accent);
}

/* Band tension: a 3x2 grid, label in the middle of the top row, the five
   tensions around it reading lightest to heaviest.

   Deliberately not the segmented pill this file uses for two or three options.
   Five of them wrapped, stranding X-HEAVY alone on a second line, and a pill
   sized to its content gives the longest label the most room - the opposite of
   what a scale wants. These cells are equal by construction.

   Full-bleed: it cancels .panel-body's horizontal padding so the band runs
   edge to edge, which is what sets it apart from the stacked form rows above
   and below without needing a box drawn around it. The cells themselves stay
   on the panel ground; only the label is tinted, so the one cell that is not
   a control is the only one wearing a fill. */
/* The optional prescriptions, below the form rather than in it - see
   openExerciseSheetPopup() in ui.js for why the split. Reuses .tools-list, so
   a row that opens something reads the same here as in Data Tools. */
.sheet-list button {
    gap: var(--spacing-s);
}

/* One soft flash on the thing the app just landed the lifter on - a row a
   stat tile jumped to, a workout card that was just added. Orientation, not
   selection, so it decays to nothing rather than staying lit.

   Drawn as an accent overlay rather than by animating the element's own
   background, so that it (a) matches .set-column--inserted exactly, which is
   the same gesture on a set row and was visibly the stronger of the two while
   this one used the neutral --color-press wash, and (b) leaves whatever the
   element's real background is alone. --color-press is a translucent white,
   so it also picked up the lightness of whatever it sat on: the same flash
   read lighter on a builder card than on a panel row purely because the two
   sit on different surfaces. An accent at a fixed opacity does not. */
@keyframes row-landed-flash {
    0%, 50% { opacity: 0.32; }
    100% { opacity: 0; }
}
.sheet-row-highlight,
.profile-landed-flash {
    position: relative;
}
.sheet-row-highlight::after,
.profile-landed-flash::after {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 2;
    background-color: var(--color-accent);
    pointer-events: none;
    animation: row-landed-flash 2.4s ease-out forwards;
}
.sheet-value {
    flex: 0 0 auto;
    font-size: 0.85rem;
    font-variant-numeric: tabular-nums;
    color: var(--color-text-muted);
    text-align: right;
}
/* Marks the chosen option in a one-of-many list. Reserves its width whether or
   not it is showing, so picking a different row does not shift the labels. */
/* Marks a row whose exercise is kept in step with another workout's. Sits
   between the name and the figure so it reads as part of the name rather than
   part of the prescription - it says what this row *is*, not what it asks for. */
.profile-exercise-link {
    flex: 0 0 auto;
    font-size: 0.72rem;
    opacity: 0.75;
}

.sheet-tick {
    flex: 0 0 1rem;
    text-align: center;
    color: var(--color-accent);
    font-weight: 700;
}

/* A second line under a sheet row's value, for a state that is worth reading
   without opening it - currently only the warmup ladder, where "Custom · 5"
   says nothing about the shape. Right-aligned under the value it belongs to,
   and quieter than it, so the row still reads as one answer. */
.sheet-value-sub {
    display: block;
    font-size: 0.68rem;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
    color: var(--color-text-muted);
    opacity: 0.8;
}

/* A sheet row is label + value + tick, and each part wants a different share
   of the row. `.tools-list button span { flex: 1 }` (see above) was giving
   all three an equal third: it is written for the idle menu, whose rows hold
   a single span, and it out-specifies both `.sheet-value` and `.sheet-tick`
   (two elements beat one class), so their own flex has in fact never
   applied. The label was left at a third of the row and wrapped inside it,
   which only became visible once a value was long enough to notice.
   Restated here at matching specificity. */
.sheet-list button > span:first-of-type {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* Sized to its own text, on one line: a wrapped value would push its row
   taller than the ones above and below it. */
.sheet-list button .sheet-value {
    flex: 0 0 auto;
    white-space: nowrap;
}

.sheet-list button .sheet-tick {
    flex: 0 0 1rem;
}

/* ---- warmup ladder editor ---- */

/* The rungs, drawn as the set rows they become. The list keeps the builder's
   own dotted rule and left slot so the ladder reads as a prescription rather
   than a form. */
.warmup-ladder {
    display: flex;
    flex-direction: column;
    width: calc(100% + 2 * var(--spacing-m));
    margin-inline: calc(-1 * var(--spacing-m));
    padding-inline: var(--spacing-m);
}

.warmup-rung-slot {
    display: flex;
    align-items: center;
    height: auto;
    font-size: 0.72rem;
    font-weight: 700;
    color: var(--color-text-muted);
    font-variant-numeric: tabular-nums;
}

/* A ladder rung is a .profile-set-row for its geometry, but not for its
   disclosure: the builder hides a set row's trash until the row's controls
   are opened, and here there are no row controls - the rung list *is* the
   editor, so its delete is always available. */
.warmup-rung > .profile-set-remove {
    display: flex;
}

/* The rung whose controls are showing below - marked with the same accent
   tint the picker family's selected cell wears, since both answer "this is
   the one you are looking at". The difference is what the tint sits behind:
   here a whole disclosed row, there a single chosen option, which also keeps
   its ink at full strength. */
.warmup-rung--open {
    background-color: var(--color-accent-fill);
}

/* The kind picker is the load-mode grid in one row, so it needs the family's
   top rule back - .load-mode-cell only draws one from the third cell on. */
.warmup-kind-picker {
    border-block: 1px solid var(--color-border);
}

/* Which sets run to failure. One cell per set, the band picker's flat-text
   language again - full-bleed, hairline-divided, the cells that are on marked
   the way that family marks a choice. Wraps rather than scrolls:
   WORKING_MAX_SETS is 50, and a strip that ran off the edge would hide the
   very sets it is asking about. */
.amrap-strip {
    display: flex;
    flex-wrap: wrap;
    width: calc(100% + 2 * var(--spacing-m));
    margin: var(--spacing-s) calc(-1 * var(--spacing-m)) var(--spacing-m);
    border-block: 1px solid var(--color-border);
}
.amrap-cell {
    position: relative;
    flex: 1 0 20%;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    min-height: 3.4rem;
    padding: var(--spacing-s) 0.25rem;
    background: none;
    border: none;
    border-radius: 0;
    color: var(--color-text-muted);
}
.amrap-cell + .amrap-cell::before {
    content: '';
    position: absolute;
    inset-block: 0;
    left: 0;
    width: 1px;
    background-color: var(--color-border-strong);
}
.amrap-cell-number {
    font-size: 0.95rem;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
}
.amrap-cell-mark {
    font-size: 0.8rem;
    line-height: 1;
}
/* The load-mode block's selected state; see that rule. */
.amrap-cell[aria-checked="true"] {
    color: var(--color-text);
    background-color: var(--color-accent-fill);
}
.sheet-quick-actions {
    display: flex;
    gap: var(--spacing-s);
}
.sheet-quick-actions .panel-secondary-action {
    flex: 1;
    padding-inline: 0.4rem;
    font-size: 0.72rem;
    line-height: 1.2;
}

/* The same flat-cell language as .band-tension-picker below, in a 2x2 - see
   loadModePickerHtml() in ui.js for why this is not the segmented pill. Words
   rather than tension names, so they wrap to a second line instead of being
   clipped: "Lichaamsgewicht" does not fit one line at 360px. */
.load-mode-picker {
    display: grid;
    grid-template-columns: 1fr 1fr;
    width: calc(100% + 2 * var(--spacing-m));
    margin: var(--spacing-s) calc(-1 * var(--spacing-m)) var(--spacing-m);
}
.load-mode-cell {
    position: relative;
    min-height: var(--button-min-height);
    padding: var(--spacing-s) 0.35rem;
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--color-text-muted);
    background: none;
    border: none;
    border-radius: 0;
}
/* The band picker's construction exactly: one full-strength rule at the
   grid's middle, and vertical hairlines masked to fade toward the outer
   edges - pinched at its center, never caged. The boxed border-block this
   grid used to carry was the one place the family look broke. */
.load-mode-cell:nth-child(2n)::before {
    content: '';
    position: absolute;
    inset-block: 0;
    left: 0;
    width: 1px;
    background-color: var(--color-border-strong);
}
.load-mode-cell:nth-child(-n + 2)::before {
    -webkit-mask-image: linear-gradient(to top, #000 0, #000 12%, transparent 100%);
    mask-image: linear-gradient(to top, #000 0, #000 12%, transparent 100%);
}
.load-mode-cell:nth-child(n + 3)::before {
    -webkit-mask-image: linear-gradient(to bottom, #000 0, #000 12%, transparent 100%);
    mask-image: linear-gradient(to bottom, #000 0, #000 12%, transparent 100%);
}
.load-mode-cell:nth-child(n + 3) {
    border-top: 1px solid var(--color-border);
}
/* The canonical selected state for this family - the 2x2 block, the warmup
   rung strip that shares this class, and the band/category/AMRAP/report-window
   grids that point back here.

   Two channels, and it needs both. It was --color-highlight on the word alone,
   which is a hue change and nothing else: in eleven of the themes that accent
   sits at the same lightness as the --color-text-muted the unselected cells
   wear (1.03:1 Fern, 1.04 Magnolia, plus 1.06 on Bronze and 1.09 on Violet,
   the two themes this was first measured on and both since gone), so where the
   accent is a warm grey or shares the muted ink's cast there was no visible
   difference at all and the picker looked like it had ignored the tap.
   Full-strength ink is the channel that survives every palette - 2.5-3.3:1
   against muted, Candy Pop's white-on-pink aside - and --color-accent-fill
   behind it carries the hue the accent used to carry alone, the same pairing
   .theme-swatch already uses for its own selected state.

   Still no pill, no box and no underline: the tint is a ground, not a frame,
   so the hairline geometry of these grids is untouched. The underline this
   family used to carry went for a different reason - it landed on the divider
   below the live cell; see --picker-hairline. */
.load-mode-cell[aria-checked="true"] {
    color: var(--color-text);
    background-color: var(--color-accent-fill);
}
.band-tension-picker {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    width: calc(100% + 2 * var(--spacing-m));
    margin: var(--spacing-s) calc(-1 * var(--spacing-m)) var(--spacing-m);
}

/* Reads as a caption, not an option: smaller, tracked much wider, muted, and
   it takes no selected state.

   It used to carry a --color-surface-raised tint for that job, which stopped
   working the moment the chosen band started wearing a tint of its own: a
   tint had come to mean "this is the one", and on the since-replaced Bronze
   (a warm tan where Default is now cyan) the two grounds
   landed 1.008:1 apart, so the caption and the selected band were the same
   block. The type carries the distinction instead - 0.62rem against 0.75rem
   and 0.16em of tracking against 0.06em - and the caption is the only cell in
   the grid that is not a control. */
.band-tension-title {
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 0.62rem;
    font-weight: 700;
    letter-spacing: 0.16em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

/* Where the caption is also the control that flips the direction. It keeps the
   caption look above wholesale - it must not start reading as a sixth band -
   so this strips the button chrome and adds the mark, and nothing else.

   font-family alone, deliberately: the rule above already states the size,
   weight, tracking, transform and colour, and every one of them outranks the
   UA button styling. Only the family does not, so only the family is restated.
   The obvious `font: inherit; color: inherit` idiom for de-chroming a button is
   wrong here - being the more specific selector, it wins against the caption
   rule and hands the toggle the panel's own 16px white body text, which is
   precisely the sixth-band reading the muted tint exists to prevent.

   The affordance is the swap glyph beside the word, not an underline. It
   briefly was one - the 2px hairline the retired RESISTANCE/ASSISTED strip used
   for its chosen cell - but that mark has since left the app entirely (see
   --picker-hairline), and it was answering the wrong question anyway: an
   underline says "this one is chosen", and what this control needs to say is
   "there are two of these and tapping swaps them". The glyph states that and
   cannot be confused with a selection. The inert caption omits it, which is
   what makes "is this pressable" answerable without tapping. */
.band-tension-title--toggle {
    position: relative;
    gap: 0.5em;
    padding: 0;
    border: none;
    border-radius: 0;
    font-family: inherit;
}

/* Sized in em so it tracks the caption's own 0.62rem rather than being set
   against the root and drifting away from the word it belongs to, and painted
   from currentColor so it stays muted with the text and re-tints per theme
   without naming a single colour here. */
.band-tension-title-swap {
    flex: none;
    width: 1.15em;
    height: 1.15em;
}

.band-tension-cell {
    position: relative;
    min-height: var(--button-min-height);
    padding: var(--spacing-s) 0.25rem;
    font-size: 0.8rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    color: var(--color-text-muted);
    background: none;
    border: none;
    border-radius: 0;
}

/* The divider belongs to every cell that is not first in its row - including
   the label, which is a <span> rather than a button. Scoping this to the
   buttons alone is what left X-LIGHT open on its right. */
.band-tension-picker > *:not(:nth-child(3n + 1)) {
    position: relative;
}

.band-tension-picker > *:not(:nth-child(3n + 1))::before {
    content: '';
    position: absolute;
    inset-block: 0;
    left: 0;
    width: 1px;
    background-color: var(--color-border-strong);
}

/* Top row fades upward; the bottom row mirrors it and fades downward. Both are at full
   strength where they meet the rule between the rows and gone at the block's
   outer edges, so the grid is pinched at its middle rather than caged. */
.band-tension-picker > *:nth-child(-n + 3)::before {
    -webkit-mask-image: linear-gradient(to top, #000 0, #000 12%, transparent 100%);
    mask-image: linear-gradient(to top, #000 0, #000 12%, transparent 100%);
}

.band-tension-picker > *:nth-child(n + 4)::before {
    -webkit-mask-image: linear-gradient(to bottom, #000 0, #000 12%, transparent 100%);
    mask-image: linear-gradient(to bottom, #000 0, #000 12%, transparent 100%);
}

/* The one full-strength line in the block, and what both sets of verticals
   run into. */
.band-tension-picker > *:nth-child(n + 4) {
    border-top: 1px solid var(--color-border);
}

/* The load-mode block's selected state exactly - brightened ink on an accent
   tint, no pill and no underline; see that rule for why one channel was not
   enough. This grid is hairline-divided on both axes, which is why the
   underline it used to carry could not come back. */
.band-tension-cell[aria-checked="true"] {
    color: var(--color-text);
    background-color: var(--color-accent-fill);
}

.settings-row--segmented-wide {
    flex-direction: column;
    align-items: stretch;
}

.settings-row--segmented-wide .settings-segmented {
    width: 100%;
}

.settings-row--segmented-wide .settings-segmented-btn {
    flex: 1 1 0;
    padding-inline: var(--spacing-s);
}

/* The sequence sheet has two linked choices, so both use the same stacked
   rhythm even though the second has only two options. Keeping one inline made
   its label wrap into a narrow two-line gutter on a phone while the first
   choice occupied the full panel width immediately above it. */
.sequence-transition-settings .settings-row {
    flex-direction: column;
    align-items: stretch;
}

.sequence-transition-settings .settings-segmented {
    width: 100%;
}

.sequence-transition-settings .settings-segmented-btn {
    flex: 1 1 0;
}

/* ======================== 9d · Reorder panel ============================= */
.reorder-list {
    display: flex;
    flex-direction: column;
    list-style: none;
    margin: 0;
    padding: 0;
}

/* Flat hairline rows at rest, like every other list in the app. The lifted
   surface moves to .dragging below, where it earns its keep: it is what
   separates the row being moved from the ones it is moving past. The
   spacing is the builder's row density - the old gap + sm padding stacked
   to ~64px rows in a panel whose whole job is scanning a list. */
.reorder-item {
    display: flex;
    align-items: center;
    gap: var(--spacing-s);
    padding: var(--spacing-s) 0;
    border-bottom: 1px solid var(--color-border);
    /* The dragged item is repositioned with `transform` (see enableDragReorder()
       in ui.js) - `position: relative` makes that a no-op for layout while
       letting `z-index` lift it above its neighbors while dragging. */
    position: relative;
}

.reorder-item:focus-visible {
    outline: 2px solid var(--color-highlight);
    outline-offset: 2px;
}

/* The names sit between the shared handle and the shared trash. A column
   because a superset row stacks its label above both exercise names, while an
   ordinary row holds a single name and looks unchanged. */
.reorder-item-body {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
    flex: 1 1 auto;
}

/* A superset moves and is deleted as one unit, so it is one row - and it says
   so, rather than leaving two adjacent names to be read as unrelated. The
   tinted block is deliberately the same treatment the program builder gives a
   pair (.profile-superset-editor above): the two panels are both "a list of
   this workout's exercises, being edited", and a superset should not look like
   a different idea in each. Its own border replaces the row hairline, so the
   block reads as one object instead of as rows inside a rule. */
.reorder-item--superset {
    align-items: center;
    margin: var(--spacing-s) 0;
    padding: var(--spacing-s);
    background-color: var(--color-accent-fill);
    border: 1px solid var(--color-accent-border);
    border-radius: var(--radius-small);
}

.reorder-superset-label {
    color: var(--color-highlight);
    font-size: 0.65rem;
    font-weight: 800;
    letter-spacing: 0.12em;
    text-transform: uppercase;
}

/* Pushed to the row's right edge, away from the drag handle on the left, so a
   grab and a delete are at opposite ends of a row the finger is already
   dragging. Bare glyph, matching .set-delete-btn - the destructive control in
   a row should not also be its heaviest object. */
.reorder-delete-btn {
    display: flex;
    align-items: center;
    flex-shrink: 0;
    margin-left: auto;
    padding: var(--spacing-s);
    color: var(--color-text-muted);
    background: none;
    border: none;
}

/* TRASH_ICON_SVG carries no intrinsic size - every consumer states one (see
   .set-delete-btn svg above). Without this the button is present and clickable
   but draws nothing. */
.reorder-delete-btn svg {
    width: 18px;
    height: 18px;
    flex-shrink: 0;
}

/* Armed by onDelete() in main.js, which only arms when the row would discard
   recorded work - the pulse comes from .confirm's shared armed animation. */
.reorder-delete-btn.confirm {
    color: var(--color-danger);
}

.reorder-item.dragging {
    z-index: 5;
    background-color: var(--color-surface-raised);
    border-bottom-color: transparent;
    border-radius: var(--radius-medium);
    box-shadow: var(--shadow-raised);
}

/* A large, dedicated grab target - dragging only starts from here (see
   enableDragReorder()), so `touch-action: none` is scoped to just this
   handle rather than the whole row, leaving panel scrolling untouched
   everywhere else. */
.reorder-handle {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    width: 2rem;
    height: 2rem;
    margin: -0.35rem;
    color: var(--color-text-muted);
    cursor: grab;
    touch-action: none;
}
/* The drawn box is 32px, under both the 44px a thumb wants and the app's own
   --button-min-height. It is not grown to suit, because the negative margin
   above exists precisely so the handle does not inflate the row it sits in -
   the hit area is extended past the glyph instead (32 + 2×6 = 44). pointerdown
   still resolves through closest('.reorder-handle'), so enableDragReorder()
   picks this up unchanged. */
.reorder-handle::after {
    content: '';
    position: absolute;
    inset: -6px;
}

.reorder-item.dragging .reorder-handle {
    cursor: grabbing;
    color: var(--color-text);
}

/* Quieter than the old 22px - at that size the three bars out-weighed the
   names they sat beside on every panel that reorders. */
.reorder-handle svg {
    width: 16px;
    height: 16px;
}

.reorder-name {
    font-size: 1rem;
    color: var(--color-text);
}

/* Name left, prescription right, on one line per exercise - a superset row
   holds two of these stacked, which is why the pairing lives here rather than
   on .reorder-item-body. */
/* A button since it opens the exercise's sheet editor - flat text in the
   row's own type, not a boxed control (the global button rule's centered
   content and min-height are both undone here). */
.reorder-name-row {
    display: flex;
    align-items: baseline;
    justify-content: flex-start;
    gap: var(--spacing-s);
    min-width: 0;
    min-height: 0;
    padding: 0.15rem 0;
    color: inherit;
    background: none;
    border: none;
    border-radius: 0;
    text-align: left;
}
.reorder-name-row .reorder-name {
    flex: 1 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* The same figure the live set row draws - see prescriptionFigureHtml() in
   ui.js. Tabular so a column of them lines up, and never the accent: this
   states what is prescribed, it is not a control. */
.exercise-prescription {
    flex: 0 0 auto;
    font-size: 0.9rem;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    color: var(--color-text);
    white-space: nowrap;
}
/* Muted and thin, so the two numbers read as the figures and the separator
   stays out of their way. */
.exercise-prescription-x {
    margin: 0 0.15em;
    font-weight: 400;
    color: var(--color-text-muted);
}
/* BW / BAND: only for the modes the numbers alone cannot tell apart. Same
   uppercase micro-label the rest of the app uses for this job. */
.exercise-prescription-mark {
    margin-left: 0.4em;
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.06em;
    color: var(--color-text-muted);
}

/* ==================== 9e · Data tools (backup/export) =================== */
#data-tools {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--spacing-s);
    width: 100%;
}

/* The Data Tools panel groups its rows (transfer / export / backup) rather
   than running them together: "Backup Data" and "Share Program" both produce
   a .json file and are very different things to hand someone, so the heading
   above each group is doing real work. */
.tools-group-title {
    /* Asymmetric on purpose: a heading belongs to the rows below it, so the
       gap that separates it from the previous group has to be clearly larger
       than the one under it, or the three groups read as one list with stray
       labels in it. */
    margin: var(--spacing-l) 0 var(--spacing-s);
    padding: 0;
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.tools-panel .tools-group-title:first-child {
    margin-top: 0;
}

/* Tools uses the same inset, unfilled rows as the start page. Group headings
   provide the outer structure; the rules only separate destinations. */
.tools-panel-list {
    padding: 0;
    overflow: visible;
    background: none;
    border: none;
    border-radius: 0;
}

.tools-panel-list button {
    min-height: 3.35rem;
    padding-inline: 0;
    background-image: linear-gradient(to right, var(--color-border), var(--color-border));
    background-size: calc(100% - 2.9rem) 1px;
    background-position: right bottom;
    background-repeat: no-repeat;
    border-radius: 0;
}

/* The two per-entry actions travel together at the end of the footer.
   Without this wrapper the footer's space-between strands the upload button
   in the middle of the row, reading as unrelated to the trash beside it. */
.log-actions {
    display: flex;
    align-items: center;
    gap: var(--spacing-s);
}

/* Beside .delete-log-btn in a log entry's footer, and quiet in the same way:
   the row is a record, not a toolbar. */
.export-log-btn {
    padding: var(--spacing-xs);
    color: var(--color-text-muted);
    background: none;
    border: none;
}

.export-log-btn svg {
    width: 17px;
    height: 17px;
}

/* One shared card per row: the name on its own line, its exercises beneath in
   the muted secondary size, so a long list wraps without pushing the name out
   of alignment. */
.shared-card-list li {
    display: flex;
    flex-direction: column;
    gap: 2px;
}

/* No leading '·' here. It is an inline pseudo-element, and a flex column makes
   it a flex item of its own - so it rendered as a lone dot on the line above
   each card name. The bold name already opens the row, which is what the
   marker was for on the single-line bullet lists this class inherits from. */
.shared-card-list li::before {
    content: none;
}

.shared-card-exercises {
    font-size: 0.82rem;
    color: var(--color-text-muted);
    overflow-wrap: anywhere;
}

/* --- Insights ----------------------------------------------------------
   The sections the exported report prints, drawn in this app's tokens.
   reportSectionsHtml() in scripts/report.js builds the markup for both
   surfaces, so these class names arrive here from another file - which is why
   they carry a report- prefix. Unprefixed, .panel above is the fixed
   full-screen overlay and .exercise-list is the live session's card column,
   and the exported report would have rendered inside two of them.

   Flat on purpose. The document is a set of cards because it is going on
   paper, where a card is how you group things with no scroll to do it. On
   screen the same treatment gave every stat, finding, lift and note its own
   raised, rounded, shadowed box - eleven boxes before the first exercise -
   and the page read as decoration wrapped around numbers. Here the grouping
   is a heading and a hairline, which is what the rest of the app uses.

   NOTHING HERE MAY HARDCODE A COLOUR. Ten themes read these rules; the one
   fixed palette lives in REPORT_STYLES, where it is going on paper. */
.insights-controls {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-m);
    margin-bottom: var(--spacing-l);
}

.insights-export-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: var(--spacing-s);
    width: 100%;
    min-height: 2.75rem;
    padding: var(--spacing-s) var(--spacing-m);
    color: var(--color-accent);
    background-color: transparent;
    border: 1px solid var(--color-accent);
    border-radius: var(--radius-control);
    font-size: 0.8rem;
    font-weight: 750;
    letter-spacing: 0.06em;
    text-transform: uppercase;
}

.insights-export-btn svg {
    width: 1.1rem;
    height: 1.1rem;
}

/* Sections are separated by space alone now that nothing carries a card edge,
   so the gap has to be clearly larger than the gap inside one. --spacing-l is
   the largest token; doubling it is the step, not a new token, since nothing
   else in the app needs this size. */
.insights-content {
    display: flex;
    flex-direction: column;
    gap: calc(var(--spacing-l) * 1.5);
}

.report-section {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-sm);
}

.report-section-head h2 {
    font-size: 1.05rem;
    color: var(--color-text);
}

.report-section-head p,
.report-empty {
    margin-top: 0.2rem;
    font-size: 0.78rem;
    line-height: 1.45;
    color: var(--color-text-muted);
}

/* The four summary figures, as rows rather than tiles. As a grid they were
   five cards in a two-column layout, which leaves one alone on the last row
   whatever the column count - and a stat is a label and a number, which is a
   line, not a box. Label and its comparison stack on the left; the figure sits
   right, in the same mono column the rest of the app puts numbers in. */
.report-hero {
    display: flex;
    flex-direction: column;
}

.insights-content > .report-method {
    margin: 0;
    padding: var(--spacing-sm) var(--spacing-m);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-m);
    font-size: 0.78rem;
    line-height: 1.5;
    color: var(--color-text-muted);
}

.insights-content > .report-method strong {
    color: var(--color-text);
}

.report-stat {
    display: grid;
    grid-template-columns: 1fr auto;
    gap: 0 var(--spacing-m);
    padding: var(--spacing-sm) 0;
    border-bottom: 1px solid var(--color-border);
}

.report-stat-label {
    grid-column: 1;
    grid-row: 1;
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.07em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.report-stat-support {
    grid-column: 1;
    grid-row: 2;
    margin-top: 0.15rem;
    font-size: 0.74rem;
    color: var(--color-text-muted);
}

.report-stat-value {
    grid-column: 2;
    grid-row: 1 / span 2;
    align-self: center;
    font-family: var(--font-mono);
    font-size: 1.15rem;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
    color: var(--color-text);
}

/* Findings, lifts and detail cards all become hairline-separated rows. The
   last of each list drops its rule, so a section ends on its content rather
   than on a line with nothing under it. */
.report-findings,
.report-exercise-list,
.report-exercise-details {
    display: flex;
    flex-direction: column;
}

.report-finding,
.report-exercise-row,
.report-exercise-detail {
    padding: var(--spacing-sm) 0;
    border-bottom: 1px solid var(--color-border);
}

.report-findings > :last-child,
.report-exercise-list > :last-child,
.report-exercise-details > :last-child,
.report-hero > :last-child {
    border-bottom: none;
}

.report-finding {
    display: flex;
    gap: var(--spacing-s);
    align-items: flex-start;
}

/* Hollow and small by default, filled and larger when the finding is actually
   a signal. Measured, not assumed: filled in --color-success against filled in
   --color-text-muted is 1.03:1 on Fern and 1.04 on Magnolia (and was 1.04 on
   the departed Bronze) -
   the same dot in greyscale. Those tokens separate by hue, and on an 8px mark
   hue alone is not a difference. Fill and size are, in every theme at once.

   Positive and review stay hue-coded against each other, which is fine: they
   are never ambiguous in context, because the heading beside the mark says
   "Moving up" or "Worth a look" in words. The mark is emphasis, and it is
   aria-hidden precisely because it carries nothing a reader would miss. */
.report-finding-mark {
    flex: 0 0 auto;
    width: 6px;
    height: 6px;
    margin-top: 0.45rem;
    border: 1.5px solid var(--color-text-muted);
    border-radius: var(--radius-pill);
}

.report-finding--positive .report-finding-mark,
.report-finding--review .report-finding-mark {
    width: 9px;
    height: 9px;
    margin-top: 0.35rem;
    border: none;
}

.report-finding--positive .report-finding-mark { background-color: var(--color-success); }
.report-finding--review .report-finding-mark { background-color: var(--color-danger); }

.report-finding h3,
.report-exercise-row h3,
.report-exercise-detail h3 {
    font-size: 0.92rem;
    color: var(--color-text);
}

.report-finding p,
.report-exercise-row p,
.report-exercise-detail p,
.report-notes p {
    margin-top: 0.15rem;
    font-size: 0.82rem;
    line-height: 1.5;
    color: var(--color-text-muted);
}

/* Muscles and movements stack rather than sitting side by side: at 375px two
   columns left the label column too narrow for "Horizontal push". */
.report-balance-grid {
    display: flex;
    flex-direction: column;
    gap: var(--spacing-l);
}

.report-panel h3 {
    font-size: 0.7rem;
    font-weight: 700;
    letter-spacing: 0.07em;
    text-transform: uppercase;
    color: var(--color-text-muted);
}

.report-bars {
    display: flex;
    flex-direction: column;
    gap: 0.55rem;
    margin-top: var(--spacing-s);
}

.report-bar-row {
    display: grid;
    grid-template-columns: 6.5rem 1fr 2.5rem;
    gap: var(--spacing-s);
    align-items: center;
    font-size: 0.78rem;
    color: var(--color-text-muted);
}

.report-bar-row strong {
    font-family: var(--font-mono);
    font-variant-numeric: tabular-nums;
    text-align: right;
    color: var(--color-text);
}

/* The comparison against the previous period wraps under the bar rather than
   squeezing the label column, which has to hold "Squat / knee-dominant". */
.report-bar-row small {
    grid-column: 2 / -1;
    font-size: 0.68rem;
}

.report-bar-track {
    height: 4px;
    overflow: hidden;
    border-radius: var(--radius-pill);
    background-color: var(--color-track);
}

/* --color-highlight rather than the raw accent, for the reason the picker
   tabs give: these are small solid shapes, and the raw accent does not carry
   enough contrast at this size in every theme. */
.report-bar-fill,
.report-week-bar {
    display: block;
    height: 100%;
    border-radius: inherit;
    background-color: var(--color-highlight);
}

/* Three explicit rows - bars, counts, dates - with each week contributing one
   child to each. As a column of flex items per week, a date that wrapped to
   two lines pushed that column's bar and count up while its neighbours stayed
   put, so nothing lined up across the chart. display: contents hands the
   week's three children straight to this grid, which is what pins them to a
   shared baseline. */
.report-week-chart {
    display: grid;
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
    grid-template-rows: 1fr auto auto;
    gap: 0 4px;
    align-items: end;
    height: 170px;
}

.report-week {
    display: contents;
}

.report-week-bar-wrap {
    display: flex;
    align-items: flex-end;
    height: 100%;
    min-height: 0;
}

.report-week-bar {
    width: min(70%, 22px);
    margin: 0 auto;
    border-radius: 3px 3px 0 0;
}

/* The bars are bottom-aligned by the grid; the two label rows are not. At
   twelve columns on a 375px screen each column is ~25px, so "May 19" wraps to
   two lines and "Jun 2" does not - and bottom-aligned, the wrapped ones began
   11px above their neighbours, leaving the dates on two different baselines
   across one chart. Aligned to the top of their row they all start together
   and the wrapped ones grow downward. */
.report-week > strong,
.report-week > span {
    align-self: start;
    text-align: center;
}

.report-week > strong {
    margin-top: 0.3rem;
    font-family: var(--font-mono);
    font-size: 0.7rem;
    font-variant-numeric: tabular-nums;
    color: var(--color-text);
}

.report-week > span {
    font-size: 0.56rem;
    line-height: 1.2;
    color: var(--color-text-muted);
}

.report-exercise-row,
.report-exercise-detail header {
    display: flex;
    gap: var(--spacing-s);
    align-items: flex-start;
    justify-content: space-between;
}

.report-status {
    flex: 0 0 auto;
    padding: 3px 7px;
    border-radius: var(--radius-small);
    font-size: 0.6rem;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    white-space: nowrap;
    color: var(--color-text-muted);
    background-color: var(--color-surface-raised);
}

.report-status--progressing {
    color: var(--color-highlight);
    background-color: var(--color-accent-fill);
}

/* Flat declaration first as the fallback where color-mix is unsupported -
   the same pair, and the same reason, as .panel-warning above. */
.report-status--stalled,
.report-status--declining {
    color: var(--color-danger);
    background-color: var(--color-surface-raised);
    background-color: color-mix(in srgb, var(--color-danger) 12%, transparent);
}

/* .trend-chart / -line / -dot are already themed further up this file for the
   Progress panel, and chart.js emits nothing but those class names, so the
   line arrives coloured and only its frame needs a rule here. */
.report-trend {
    margin-top: var(--spacing-s);
}

.report-trend > div {
    display: flex;
    justify-content: space-between;
    font-size: 0.66rem;
    color: var(--color-text-muted);
}

/* The one place a rule is still doing grouping work: a review prompt is an
   aside inside a row that already has its own heading and figures. */
.report-review-prompt {
    margin-top: var(--spacing-s);
    padding-left: var(--spacing-s);
    border-left: 2px solid var(--color-border-strong);
}

.report-review-prompt strong { color: var(--color-text); }

.report-notes {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    padding-top: var(--spacing-sm);
    border-top: 1px solid var(--color-border);
}

/* ======================= 10 · Install and update ======================= */
/* The install offer is now the last row of the start menu (see index.html),
   not a standalone button below it. It was the app's one remaining filled CTA
   and the only place the bevel language - lift shadow plus a highlight arc -
   still appeared; #workout-timer-badge and .session-card-start had both been
   flattened out of it years apart, leaving it with no sibling to match and
   nothing on the start page as loud as it. Both those rules are deleted, which
   removes the last use of --shadow-bevel outside the theme definitions.

   Only the colour separates it from the rows above: it is an offer rather than
   a place to go, and it is the one row that comes and goes on its own. The
   precedent is .profile-add-row, which does the same thing in the program
   list. Everything else - height, tracking, icon size, the dotted rule - it
   inherits from .tools-list unchanged, which is the whole point. */
.tools-list button.tools-list-accent {
    color: var(--color-accent);
}

/* --------------------------- Splash screen -----------------------------
   The curtain over boot. Sits above everything including notifications,
   because a toast raised during init() belongs to the app behind it.

   It has two grounds, and which one it is wearing is the whole design.

   The first frame is literal black, matching manifest.json's background_color
   so the platform's own launch screen hands over to a document painted the
   same colour and the seam is invisible. That is the one thing here that can
   never be a token: the platform picked its colour from a static file long
   before this stylesheet loaded, so nothing theme-driven can agree with it.

   Then, at the moment the mark arrives, .splash--themed cross-fades the whole
   curtain into the active theme's own ground and the mark into its accent - so
   each theme gets its own splash screen rather than fourteen themes sharing
   one black-and-gold screen. The flip is safe to make token-driven because
   theme-init.js is a blocking <head> script: [data-theme] is on <html> before
   this stylesheet is even fetched, so there is no frame in which a token here
   would resolve to the wrong palette.

   Layout is deliberately trivial - one centred mark, no text, nothing to
   localise - so there is no state in which it can render half-formed. */
#splash {
    position: fixed;
    inset: 0;
    z-index: calc(var(--z-notification) + 2);
    display: flex;
    align-items: center;
    justify-content: center;
    /* Fades out; nothing fades it in. The first frame is already black, so an
       entry animation would only make the app appear to hesitate. The ground
       is listed here rather than on .splash--themed alone so both states carry
       the same transition and neither has to restate the other's. */
    transition: opacity var(--splash-fade) ease var(--splash-mark-fade),
                background-color var(--splash-mark-fade-in) ease-in-out;
    /* The last resort, and the reason it is here rather than in main.js with
       the other one: dismissSplash()'s failsafe cannot run if main.js itself
       never loads - a failed module fetch on a cold, offline first open would
       otherwise leave a black screen with nothing on the page able to clear
       it. This needs no script at all.

       Eight seconds because it must never fire on a merely slow boot; by then
       the normal path has been and gone (~1.3s), so reaching this at all means
       the app is not coming. */
    animation: splash-failsafe 400ms 8s forwards;
}

/* The launch colour, in literal black, for exactly as long as the curtain is
   still standing in for the platform's launch screen. Written as a :not()
   rather than as the base rule's own value so that dropping the class is what
   hands the ground back to the theme - see .splash--themed below. */
#splash:not(.splash--themed) {
    background-color: #000;
}

/* Added by revealSplashMark(), with the mark's own fade-in, so the theme
   arrives as one event rather than as a colour change the mark then lands on
   top of. The token is read here rather than a copy of each theme's ground
   being kept beside it, so the two cannot drift apart. */
#splash.splash--themed {
    background-color: var(--color-background);
}

/* visibility flips only in the last hundredth, so it does not cut the fade
   short: a discrete property switches at the midpoint of its own interval,
   and 99->100 puts that at the end. Without it a fully transparent curtain
   would sit over the app swallowing every tap. */
@keyframes splash-failsafe {
    0% { opacity: 1; }
    99% { opacity: 0; visibility: visible; }
    100% { opacity: 0; visibility: hidden; }
}

/* The other half of that failsafe - see the html:has(#splash...) rule for why
   the ground needs one of its own. The curtain fading out is not enough: the
   over-scroll area and the safe-area strips are painted from the document's
   background, which no script is left to release.

   var(--color-background) resolves against <html>, where theme-init.js put
   [data-theme] before any of this loaded, so even the boot that reaches here
   with no modules at all lands on the right theme. Nothing runs it on the
   normal path: the 8s delay is a lifetime past .splash--themed, and by then
   the selector carrying it has long stopped matching, which cancels it. */
@keyframes splash-ground-failsafe {
    from { background-color: #000; }
    to { background-color: var(--color-background); }
}

/* Holds the injected SVG - the app's only remaining copy of the mark, now
   that the start page's footer logo is gone. Coloured through currentColor,
   sized against the viewport so it keeps its proportion from a small phone
   to a desktop window, capped so it never becomes a billboard. */
.splash-mark {
    width: min(56vw, 260px);
    /* Hidden until kickViewport() says the viewport has stopped moving - see
       that function. The curtain centres this mark against a viewport that
       grows by 59px mid-boot on iOS, so a mark shown on load visibly jumped
       down 29.5px.

       It then arrives the way a native launch screen does: a decelerating
       fade with a slight scale up, rather than an abrupt appearance a quarter
       of a second into a black screen. The scale is small on purpose - at
       0.94 it reads as the mark settling, and anything more reads as an
       animation the launch is waiting on. Exit is a plain fade, and shorter
       (see --splash-mark-fade): coming in wants ceremony, going out wants to
       be out of the way. */
    opacity: 0;
    transform: scale(0.94);
    /* Two curves on purpose. --splash-ease-out is a presentation curve: it
       front-loads the movement so the scale decelerates into place, which is
       what makes it read as native. Run opacity on that same curve and it is
       81% opaque a third of the way through - measured - so the mark appears
       to snap on and then finish invisibly. Brightness wants the even curve
       and position wants the decelerating one. */
    transition: opacity var(--splash-mark-fade-in) ease-in-out,
                transform var(--splash-mark-fade-in) var(--splash-ease-out);
    /* The theme's accent, so each theme's splash is its own. Safe as a token
       where the *ground* is not: the mark is at
       opacity 0 until revealSplashMark() shows it, so unlike the ground it has
       no first frame to get wrong, and by then theme-init.js has long since
       put [data-theme] on <html>. (It was literal #E8B442 while the curtain
       was one black-and-gold screen in every theme.) */
    color: var(--color-accent);
}

.splash-mark svg {
    display: block;
    width: 100%;
    height: auto;
}

.splash-mark--in {
    opacity: 1;
    transform: scale(1);
}

/* Added by dismissSplash(); the element is removed on transitionend, with a
   timer behind it in case the transition never fires (a background tab does
   not run one). Pointer events go first so the app underneath is live for the
   whole fade rather than only after it. */
/* Restates the transition rather than inheriting the entry's: a transition is
   started from the after-change style, so naming the exit duration here is
   what makes the mark leave in --splash-mark-fade having arrived in
   --splash-mark-fade-in. Opacity only - the entry's scale stays where it
   landed, because a mark that shrinks on the way out draws attention to
   itself at the exact moment it is meant to stop existing. */
#splash.splash--out .splash-mark {
    transition: opacity var(--splash-mark-fade) ease;
    opacity: 0;
}

#splash.splash--out {
    opacity: 0;
    pointer-events: none;
}

/* Someone who has asked for less motion gets the curtain taken away rather
   than faded. dismissSplash() still waits for its own timer either way, so
   the logo is on screen for the same length of time. */
@media (prefers-reduced-motion: reduce) {
    #splash {
        transition: none;
    }

    /* The mark still has to become visible - it starts at opacity 0 and is
       revealed by a class - so this removes the movement, not the reveal.
       Without the transform reset it would sit permanently at scale(0.94),
       since the transition that was going to correct it is gone. */
    .splash-mark {
        transition: none;
        transform: none;
    }

    /* The theme arrives with the curtain rather than easing in behind it. Both
       surfaces that change colour at .splash--themed have to agree: with
       #splash snapping and this still cross-fading, the safe-area strips would
       wash in for 520ms around a curtain that had already finished. */
    html:has(#splash),
    html:has(#splash) body {
        transition: none;
    }
}

/* ========================== 11 · Animations ============================ */
/* Short tactile compression for substantial navigation and commit controls.
   Set markers and hold gestures keep their purpose-built motion; tiny icon
   buttons stay still so a dense toolbar does not wobble under the thumb. */
:where(
    .start-menu-list button,
    .tools-panel-list button,
    .panel-actions button,
    .panel-secondary-action,
    .language-option,
    .report-window-option,
    .insights-export-btn,
    .ai-action,
    #end-workout-btn
) {
    transform-origin: center;
    touch-action: manipulation;
    transition: transform 110ms cubic-bezier(0.2, 0.8, 0.2, 1),
                filter 110ms ease;
}

:where(
    .start-menu-list button,
    .tools-panel-list button,
    .panel-actions button,
    .panel-secondary-action,
    .language-option,
    .report-window-option,
    .insights-export-btn,
    .ai-action,
    #end-workout-btn
):not(:disabled):active {
    transform: scale(0.965);
    filter: brightness(1.08);
}

/* The two armed pulses. A pulsing control means one thing everywhere in the
   app - "you have pressed this once; a second press does something" - so both
   keyframes swing the same --pulse-armed-dim/-lit range at the same
   --pulse-armed period, and differ only where the control itself differs.
   armButton() in main.js is what adds .confirm; .set-btn.pending is the one
   armed state that isn't a .confirm.

   Both couple size to brightness so the two reinforce: dim is small, lit is
   large. Every dimension says "less" at one end and "more" at the other
   instead of pulling against each other.

   Brightness, not opacity. Opacity fades an element toward whatever is behind
   it, which is exactly wrong here: a filled dot has its fill pulled toward the
   page colour, so it washes out on the light themes and darkens on the dark
   ones - one rule reading as two different animations, and on MYONIC the
   aubergine dot composited to a pale mauve at the low point. brightness()
   scales the element's own luminance and never pulls it toward the page, so
   the pulse reads the same everywhere. (This is the same reasoning the old
   brightnessPulse carried; it and opacityPulse were replaced by these two.)

   Scale only ever grows, never shrinks below 1. The set dot's tap target is
   .set-btn::after and it scales with the button - contracting would shrink a
   44px target during exactly the window in which the lifter may be tapping
   again to correct a mistake. */
@keyframes armedFillPulse {
    0%, 100% {
        scale: 1;
        filter: brightness(var(--pulse-armed-dim)) saturate(1.15);
        box-shadow: inset 0 0 0 0.16rem var(--color-background);
    }
    50% {
        scale: 1.06;
        filter: brightness(var(--pulse-armed-lit));
        box-shadow: inset 0 0 0 0 var(--color-background);
    }
}

/* Chrome-free armed controls - flat text (Delete Log, a program's Delete)
   and bare glyphs (a set row's ✕). No ring: there is no
   fill for one to core out. The scale swing is the larger of the two because
   scale and brightness are all these have, where the dot also has the ring -
   and a word carries a given percentage far less visibly than a small circle
   does. */
@keyframes armedTextPulse {
    0%, 100% { scale: 1; filter: brightness(var(--pulse-armed-dim)) saturate(1.15); }
    50% { scale: 1.09; filter: brightness(var(--pulse-armed-lit)); }
}

@keyframes pausedLabelPulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.65; }
}

@keyframes rowControlPulse {
    0%, 100% { filter: brightness(1); }
    50% { filter: brightness(0.72) saturate(1.15); }
}

    100% { opacity: 0; }
}

/* Content panel: soft rise + fade from just below its resting position. */
@keyframes panelIn {
    from { opacity: 0; transform: translateY(12px); }
    to { opacity: 1; transform: translateY(0); }
}

/* A set/warmup button settling into its completed/partial fill - a short
   overshoot rather than an instant color swap, the one moment of motion
   beyond the existing pulses/fades above. Applied via .just-settled, added
   by updateExerciseCard()'s markSettledIfNeeded() (ui.js) only on the
   transition into a settled state - never on an incidental re-render of a
   button that was already settled, and never on a full initial render (a
   restored session's already-completed sets don't all bounce in at once). */
@keyframes setSettle {
    0% { transform: scale(0.72); }
    62% { transform: scale(1.12); }
    100% { transform: scale(1); }
}

@keyframes chatThinkingPulse {
    0%, 100% { opacity: 0.45; }
    50% { opacity: 1; }
}

.just-settled {
    animation: setSettle 0.3s cubic-bezier(0.22, 1.6, 0.4, 1);
}


@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }

    :where(
        .start-menu-list button,
        .tools-panel-list button,
        .panel-actions button,
        .panel-secondary-action,
        .language-option,
        .report-window-option,
        .insights-export-btn,
        .ai-action,
        #end-workout-btn
    ):active {
        transform: none;
        filter: none;
    }

    /* The rule above has just stopped the one thing separating a pending set
       from a completed one. .set-btn.pending and .set-btn.completed carry the
       same fill on purpose (see their contrast note), and the pulse is what
       tells them apart - so with motion reduced they became identical, and the
       blink window in which a mistaken tap can still be corrected stopped being
       visible at all.

       Substituted with a difference in form, the same way every other set
       state avoids leaning on hue: the fill is inset by a band of the page
       colour, so a pending set is a visibly smaller disc than a completed
       one. Against the rest of the vocabulary that stays unambiguous - an
       empty ring for not-done, a part-swept wedge for partial, a full disc
       for done, and this shrunken disc for the correctable window - and it
       inherits --color-highlight's contrast in every theme rather than
       introducing another colour to re-check across all ten. An inset shadow
       rather than a border or outline so the dot keeps its exact footprint,
       and so it cannot be mistaken for the focus-visible outline every button
       already uses.

       The width below is deliberately the same 0.16rem armedFillPulse
       contracts to, so this is one frame of that animation held still rather
       than a second, separately-tuned idea. Keep the two in step; a test
       asserts they match.

       The armed *text* controls need no equivalent: each keeps a static tell
       with motion off - the swapped "Confirm" label, --color-highlight or
       --color-danger, and font-weight 800. */
    .set-btn.pending {
        box-shadow: inset 0 0 0 0.16rem var(--color-background);
    }
}

/* ===================== Language picker ===================== */
/* The Language row states its current value and opens a list, rather than
   carrying an inline control - nine names in several scripts do not fit a
   segmented control, and unlike Theme there is nothing to preview.

   Every font-size in this section is explicit. The global `button` reset
   (section 2) inherits font-*family* but not font-*size*, so a button that
   states neither renders at the user agent's own default - visibly smaller
   than the 1rem text beside it. That is what made this panel read as shrunken
   next to the rest of Settings. */
.settings-row-value {
    display: flex;
    align-items: center;
    gap: 0.35rem;
    padding: 0;
    border: none;
    background: none;
    color: var(--color-text-muted);
    /* Matches .settings-row-label, which shares the line with it. */
    font-size: 1rem;
    font-weight: var(--font-weight-body);
}

.settings-row-chevron {
    color: var(--color-text-muted);
    font-size: 1.1em;
    line-height: 1;
}

/* Deliberately the add-exercise picker's list, to the pixel: both are "scan a
   long list and tap one", so they get the same row height, rhythm and type
   sizes. See .add-exercise-panel .exercise-buttons - if one moves the other
   should follow. */
.language-list {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    margin-top: var(--spacing-s);
}

.language-option {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-m);
    width: 100%;
    min-height: 2.6rem;
    padding: 0.4rem 0;
    border: none;
    border-bottom: 1px solid var(--color-border);
    border-radius: 0;
    background: none;
    text-align: left;
    color: var(--color-text);
    font-size: 0.95rem;
}

.language-list > .language-option:last-child {
    border-bottom: none;
}

/* The endonym carries the row, as .exercise-pick-name does in the picker. */
.language-option-name {
    font-weight: 600;
    text-align: left;
}

/* The English name, so someone can find their language from either side.
   Same treatment as .exercise-pick-meta: muted, smaller, never wrapping. */
.language-option-english {
    margin-left: auto;
    flex-shrink: 0;
    color: var(--color-text-muted);
    font-weight: 400;
    font-size: 0.85rem;
}

/* The report picker asks the same shape of question as the language list, but
   each option carries a sentence rather than a second name. `-english` above
   is deliberately flex-shrink: 0 and single-line, which is right for "Norwegian
   Bokmål" beside "Norsk bokmål" and wrong for a description - it pushed the
   text straight off the panel's right edge. These rows stack instead. */
.language-option--stacked {
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    gap: 0.1rem;
    padding: 0.65rem 0;
}

.language-option--stacked .language-option-english {
    margin-left: 0;
    flex-shrink: 1;
    white-space: normal;
    line-height: 1.35;
}

/* Report range is a real selected state, unlike the export rows beneath it:
   one compact segmented control, with the app's usual coloured-word mark. */
.report-window-block {
    padding: var(--spacing-m) 0;
    border-bottom: 1px solid var(--color-border);
}

.report-window-label {
    display: block;
    margin-bottom: var(--spacing-s);
    color: var(--color-text-muted);
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
}

.report-window-picker {
    display: grid;
    grid-template-columns: repeat(3, 1fr);
    overflow: hidden;
    border: 1px solid var(--color-border-strong);
    border-radius: var(--radius-control);
}

.report-window-option {
    min-height: var(--button-min-height);
    border: 0;
    border-right: 1px solid var(--color-border-strong);
    border-radius: 0;
    background: transparent;
    color: var(--color-text-muted);
    font: inherit;
    font-weight: 700;
}

.report-window-option:last-child { border-right: 0; }

/* The load-mode block's selected state; see that rule. This is the picker the
   invisible-selection report came in on: on Bronze, the warm tan Default
   replaced, the chosen window was a grey word among grey words. */
.report-window-option[aria-checked="true"] {
    color: var(--color-text);
    background-color: var(--color-accent-fill);
}

.report-window-hint {
    margin: var(--spacing-s) 0 0;
    color: var(--color-text-muted);
    font-size: 0.75rem;
    line-height: 1.4;
}

/* Selected reads in the app's flat language - a colour change and a check,
   not a filled pill. Matches the band tension picker's own convention.

   The check occupies its column whether or not it is showing, so selecting a
   language cannot shuffle the names sideways: it is hidden with visibility,
   not removed. */
.language-option-check {
    flex-shrink: 0;
    width: 1em;
    color: var(--color-highlight);
    visibility: hidden;
}

.language-option[aria-checked="true"] .language-option-name {
    color: var(--color-highlight);
    font-weight: 700;
}

.language-option[aria-checked="true"] .language-option-check {
    visibility: visible;
}

/* The contact line closing the Settings panel. It is the last thing in a
   scrolling .panel-body, and this panel passes no footer, so nothing below it
   accounts for the home indicator - hence the safe-area inset here rather than
   relying on .panel-footer's, which only exists on panels that have one. */
.settings-note {
    padding: var(--spacing-m) 0 calc(var(--spacing-s) + var(--safe-area-bottom));
    color: var(--color-text-muted);
    font-size: 0.85rem;
    line-height: 1.5;
    text-align: center;
}

/* Which build is answering, under the contact line. Its own block so it takes
   a line of its own beside the mailto's padded touch target, and quieter than
   the note around it: this is a fact to look up when something is being
   tested, not something to read on the way past. Empty and invisible when no
   worker answered - :empty rather than a hidden attribute, so the element can
   simply be filled in on arrival with nothing to toggle. */
.settings-version {
    display: block;
    margin-top: var(--spacing-s);
    font-size: 0.75rem;
    letter-spacing: 0.04em;
    opacity: 0.65;
}

.settings-version:empty {
    display: none;
}

/* The app's only anchor. It is a mailto, so it gets the accent colour to read
   as tappable, but no underline - it closes a list of ruled rows and an
   underline there reads as one more rule. The touch target is padded out to
   the same 2.6rem the rest of the panel's controls use, since a single line of
   text is otherwise a 16px-tall tap target.

   Set in caps by text-transform rather than by upper-casing the address in the
   markup, for two reasons. The href stays lowercase, which matters because the
   local part of an address is case-sensitive by the letter of RFC 5321 even
   though providers overwhelmingly ignore that. And the accessible name stays
   lowercase too, so a screen reader reads the address out rather than spelling
   it letter by letter - the same reasoning as .session-card-start's label. */
.settings-contact-link {
    display: inline-flex;
    align-items: center;
    min-height: var(--button-min-height);
    color: var(--color-accent);
    font-size: 1rem;
    font-weight: 700;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    text-decoration: none;
    overflow-wrap: anywhere;
}

/* --- AI settings ------------------------------------------------------ */

/* A settings row whose whole width opens something, for rows with no value to
   show beside the label.

   .settings-row-value (which languageRow uses) is only tappable because the
   value text lives inside it. A row without one leaves that button holding the
   chevron alone - measured 7.4px wide, one hit in 96 points sampled across the
   row, and a label that did nothing. The row keeps the hairline and the button
   takes the padding, so the target is the full row. */
.settings-row--button {
    padding: 0;
}

.settings-row--button .settings-row-button {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-m);
    width: 100%;
    min-height: var(--button-min-height);
    padding: var(--spacing-sm) 0;
    border: none;
    background: none;
    font: inherit;
    text-align: left;
}

/* A form inside a list: the rows above this are label/control pairs on one
   line, and these are a label with a full-width field under it. Same hairline
   rhythm either way, so the two read as one column rather than two panels. */
.ai-settings-panel .settings-field,
.backup-password-panel .settings-field {
    display: grid;
    gap: var(--spacing-s);
    padding: var(--spacing-sm) 0;
    border-bottom: 1px solid var(--color-border);
}

.ai-settings-panel .settings-field input,
.ai-settings-panel .settings-field textarea,
.backup-password-panel .settings-field input {
    width: 100%;
    box-sizing: border-box;
    padding: var(--spacing-sm);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-small);
    background: var(--color-surface);
    color: var(--color-text);
    /* The panel's own type, not the browser's 13px form default - a key or a
       model id is read character by character when it is checked. */
    font: inherit;
}

.ai-settings-panel .settings-field textarea {
    resize: vertical;
}

/* The safety block has no control to label, so it takes the same weight the
   <label>s beside it get rather than looking like body copy. */
.ai-settings-panel .settings-field-label {
    font-weight: 700;
}

.ai-settings-panel .settings-field-help,
.backup-password-panel .settings-field-help {
    margin: 0;
    color: var(--color-text-muted);
    font-size: 0.85rem;
}

.ai-settings-panel .ai-context-setting-help {
    padding: var(--spacing-s) 0 var(--spacing-m);
}

.ai-model-suggestions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--spacing-s);
}

.ai-model-suggestion {
    padding: var(--spacing-s) var(--spacing-sm);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-pill);
    background: var(--color-surface);
    color: var(--color-text);
    font-size: 0.85rem;
}

/* Which model is in the field, not which pill is under a finger. This
   survives the finger lifting, which is the whole difference between a state
   class and press feedback.

   Full-strength ink over an accent tint, exactly as .load-mode-cell does, and
   for the reason recorded there: --color-accent as the *ink* sits at nearly
   the lightness of the surrounding text in several palettes, and measured
   3.66:1 on Magnolia here - under AA for a 0.85rem label. --color-text over
   the same tint clears 15:1 in every theme, because the tint is a ground
   carrying the hue and the ink is not asked to carry it too. */
.ai-model-suggestion[aria-pressed="true"] {
    border-color: var(--color-accent-border);
    background: var(--color-accent-fill);
    color: var(--color-text);
}

.ai-model-catalog,
.ai-model-browser {
    display: grid;
    gap: var(--spacing-s);
}

.ai-model-status {
    color: var(--color-text-muted);
    font-size: 0.85rem;
}

.ai-model-browser[hidden] {
    display: none;
}

.ai-model-browser > label {
    font-size: 0.85rem;
    font-weight: 700;
}

.ai-model-list {
    max-height: min(18rem, 42vh);
    overflow-y: auto;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-small);
    background: var(--color-surface);
    overscroll-behavior: contain;
}

.ai-model-option {
    display: grid;
    gap: 2px;
    width: 100%;
    min-height: var(--button-min-height);
    padding: var(--spacing-s) var(--spacing-sm);
    border: none;
    border-bottom: 1px solid var(--color-border);
    background: transparent;
    color: var(--color-text);
    font: inherit;
    text-align: left;
}

.ai-model-option:last-child {
    border-bottom: none;
}

.ai-model-option[aria-selected="true"] {
    background: var(--color-accent-fill);
}

.ai-model-option-name {
    font-weight: 700;
}

.ai-model-option-id {
    overflow-wrap: anywhere;
    color: var(--color-text-muted);
    font-size: 0.8rem;
}

.ai-model-empty {
    margin: 0;
    padding: var(--spacing-sm);
    color: var(--color-text-muted);
    font-size: 0.85rem;
}

/* Scrollable rather than truncated: these rules are the one thing on this
   screen the lifter cannot edit, so they have to be readable in full. */
.ai-safety-preamble {
    max-height: 10rem;
    overflow: auto;
    margin: 0;
    padding: var(--spacing-sm);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-small);
    background: var(--color-surface-raised);
    color: var(--color-text-muted);
    font-family: inherit;
    font-size: 0.8rem;
    line-height: 1.5;
    white-space: pre-wrap;
}

/* Restore default sits under the prompt it resets, so it is a link in the
   flow rather than a button competing with the two real actions below.

   Bold for the same reason .settings-contact-link is: --color-accent as ink
   is the tightest pairing in this panel (measured 4.52:1 on Scarlett against
   5:1-plus everywhere else, since that palette's accent is a saturated red on
   near-white), and weight is the one channel left once the token is fixed. */
.ai-inline-action {
    justify-self: start;
    padding: 0;
    border: none;
    background: none;
    color: var(--color-accent);
    font-size: 0.9rem;
    font-weight: 700;
}

.ai-key-feedback {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--spacing-sm);
    min-height: 1.4rem;
}

.ai-key-status {
    margin: 0;
    color: var(--color-danger);
    font-size: 0.82rem;
    line-height: 1.35;
}

#ai-key-input[aria-invalid="true"] {
    border-color: var(--color-danger);
    box-shadow: 0 0 0 2px color-mix(in srgb, var(--color-danger) 18%, transparent);
}

.ai-actions {
    display: flex;
    flex-wrap: wrap;
    gap: var(--spacing-s);
    padding: var(--spacing-sm) 0;
}

.ai-action {
    flex: 1 1 10rem;
    padding: var(--spacing-sm);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-control);
    background: var(--color-surface);
    color: var(--color-text);
    font-size: 0.9rem;
    font-weight: 700;
}

.ai-action--danger {
    border-color: var(--color-danger);
    color: var(--color-danger);
}

/* Test Connection while a request is in flight. A state that lasts as long as
   the request does, not press feedback - the button is genuinely inert here,
   and the result line above says what it is waiting for. */
.ai-action:disabled {
    opacity: 0.55;
}

/* Holds its line even when empty, so pressing Test does not shift the two
   buttons above it up and down as the result appears and clears. */
.ai-test-result {
    min-height: 1.4em;
    margin: 0 0 var(--spacing-sm);
    color: var(--color-text-muted);
    font-size: 0.9rem;
}

.backup-password-status {
    min-height: 1.4em;
    margin: 0;
    color: var(--color-text-muted);
    font-size: 0.9rem;
}

/* ======================== 9f · AI chat panel ======================== */

.chat-panel .panel-body {
    padding: var(--spacing-m);
}

/* Chat contains the app's one persistent text editor. iOS can paint a fixed
   parent at its keyboard-panned position while keeping the caret at the
   parent's layout position; the visible field then moves above its own caret.
   Make this one covering surface absolute so its content and caret remain in
   the same coordinate space. visualViewport can then pan down while the
   keyboard opens. Because this surface is absolute, its top must use the
   visual viewport's pageTop (relative to the initial containing block), not
   offsetTop (relative only to the layout viewport); otherwise iOS document
   scrolling is omitted and leaves the composer one scroll-distance high.
   pageTop and height together describe the exact visible rectangle above the keyboard. Chat also
   opts out of .panel's generic keyboard padding or it would avoid the
   keyboard twice. No entrance transform for the same reason: the editor must
   never inherit a transformed coordinate space on iOS. */
.chat-panel.app-covering-panel {
    position: absolute;
    top: var(--chat-viewport-top, 0px);
    bottom: auto;
    padding-bottom: 0;
    height: 100vh;
    height: 100dvh;
    height: var(--chat-viewport-height, var(--app-height, 100dvh));
    animation: none;
}

.chat-panel .panel-footer {
    display: flex;
    flex-direction: column;
    flex-shrink: 1;
    min-width: 0;
    min-height: calc(3.375rem + var(--spacing-m) * 2 + 1px);
    max-height: 28rem;
    max-height: min(52dvh, 28rem, calc(100% - 3.25rem));
    overflow: hidden;
}

.chat-panel .panel-footer:has(.chat-composer-status:not([hidden])) {
    min-height: calc(5.125rem + var(--spacing-m) * 2 + 1px);
}

/* The home-indicator inset belongs below a footer only while that footer is
   at the physical screen edge. The keyboard already owns that inset when it
   is open; retaining it here creates a blank strip between editor and keys. */
html[data-keyboard-open] .chat-panel .panel-footer {
    padding-bottom: var(--spacing-m);
}

.chat-list {
    gap: var(--spacing-sm);
}

.chat-new-row {
    display: flex;
    width: 100%;
    min-height: 2.75rem;
    align-items: center;
    justify-content: center;
    gap: var(--spacing-s);
    padding: var(--spacing-s) var(--spacing-m);
    border: 1px solid var(--color-accent);
    border-radius: var(--radius-control);
    background: transparent;
    color: var(--color-accent);
    font: inherit;
    font-size: 0.9rem;
    font-weight: 700;
}

.chat-new-row svg {
    flex: 0 0 auto;
    width: 1.1rem;
    height: 1.1rem;
}

.chat-list-item {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: stretch;
    overflow: hidden;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-medium);
    background: var(--color-surface);
}

.chat-list-select {
    display: flex;
    min-width: 0;
    min-height: 4.25rem;
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    gap: 0.2rem;
    padding: var(--spacing-sm) var(--spacing-m);
    border: 0;
    background: none;
    color: var(--color-text);
    text-align: left;
}

.chat-list-select strong {
    display: block;
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    font-size: 0.96rem;
}

.chat-list-meta {
    color: var(--color-text-muted);
    font-size: 0.78rem;
}

.chat-list-delete {
    align-self: center;
    width: 2.75rem;
    height: 2.75rem;
    min-height: 2.75rem;
    margin-right: var(--spacing-s);
    padding: 0;
    border: 1px solid transparent;
    border-radius: var(--radius-pill);
    background: none;
    color: var(--color-text-muted);
    font-size: 0.78rem;
    font-weight: 700;
}

.chat-list-delete svg {
    width: 1.15rem;
    height: 1.15rem;
}

.chat-list-delete.confirm {
    width: auto;
    min-width: 5.5rem;
    padding: 0 var(--spacing-sm);
    border-color: var(--color-danger);
    color: var(--color-danger);
    animation: armedTextPulse var(--pulse-armed) ease-in-out infinite;
}

.chat-setup {
    display: grid;
    gap: var(--spacing-sm);
    padding: var(--spacing-l);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-medium);
    background: var(--color-surface);
}

.chat-setup .panel-copy,
.chat-setup .panel-section-title {
    margin: 0;
}

.chat-thread {
    display: flex;
    width: 100%;
    min-width: 0;
    min-height: 100%;
    flex-direction: column;
    justify-content: flex-end;
    gap: var(--spacing-sm);
    padding: var(--spacing-s) 0;
}

.chat-suggestions {
    display: grid;
    gap: var(--spacing-s);
    margin-bottom: var(--spacing-sm);
}

.chat-suggestions h3 {
    margin: 0;
    color: var(--color-text-muted);
    font-size: 0.78rem;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
}

.chat-suggestions-list {
    display: grid;
    gap: var(--spacing-s);
}

.chat-suggestion {
    min-height: 2.75rem;
    padding: var(--spacing-sm) var(--spacing-m);
    border: 1px solid var(--color-border-strong);
    border-radius: var(--radius-control);
    background: var(--color-surface);
    color: var(--color-text);
    font: inherit;
    line-height: 1.35;
    text-align: left;
}

.chat-msg {
    display: flex;
    width: 100%;
    min-width: 0;
}

.chat-msg--user {
    justify-content: flex-end;
}

.chat-msg--assistant {
    justify-content: flex-start;
}

.chat-message-content {
    display: grid;
    width: min(84%, 34rem);
    min-width: 0;
    gap: var(--spacing-s);
}

.chat-bubble {
    min-width: 0;
    max-width: 100%;
    overflow: hidden;
    padding: var(--spacing-sm) var(--spacing-m);
    border-radius: var(--radius-medium);
    font-size: 0.96rem;
    line-height: 1.5;
    overflow-wrap: anywhere;
    word-break: break-word;
}

.chat-msg--user .chat-bubble {
    background: var(--color-accent);
    color: var(--color-on-accent);
    border-bottom-right-radius: var(--radius-small);
    white-space: pre-wrap;
}

.chat-msg--assistant .chat-bubble {
    background: var(--color-surface-raised);
    color: var(--color-text);
    border-bottom-left-radius: var(--radius-small);
}

/* Assistant Markdown is an allowlisted renderer, not browser HTML. Its block
   rhythm lives here because the base reset removes every native margin. */
.chat-msg--assistant .chat-bubble > * + * {
    margin-top: var(--spacing-s);
}

.chat-markdown-heading {
    color: var(--color-text);
    font-size: 1rem;
    font-weight: 750;
    line-height: 1.3;
}

.chat-msg--assistant .chat-bubble ul,
.chat-msg--assistant .chat-bubble ol {
    display: grid;
    gap: 0.25rem;
    padding-left: 1.25rem;
}

.chat-msg--assistant .chat-bubble blockquote {
    padding-left: var(--spacing-sm);
    border-left: 2px solid var(--color-border-strong);
    color: var(--color-text-muted);
}

.chat-msg--assistant .chat-bubble a {
    color: var(--color-highlight);
    font-weight: 650;
    text-decoration: underline;
    text-decoration-thickness: 1px;
    text-underline-offset: 0.15em;
    overflow-wrap: anywhere;
}

.chat-inline-code {
    padding: 0.08em 0.28em;
    border: 1px solid var(--color-border);
    border-radius: 0.3rem;
    background: var(--color-background);
    font-family: var(--font-mono);
    font-size: 0.85em;
    overflow-wrap: anywhere;
}

.chat-markdown-rule {
    height: 1px;
    border: 0;
    background: var(--color-border-strong);
}

.chat-markdown-table-wrap {
    width: 100%;
    max-width: 100%;
    min-width: 0;
    overflow-x: auto;
    border: 1px solid var(--color-border);
    border-radius: var(--radius-small);
}

.chat-markdown-table-wrap table {
    width: 100%;
    border-collapse: collapse;
    font-size: 0.84rem;
}

.chat-markdown-table-wrap th,
.chat-markdown-table-wrap td {
    min-width: 5rem;
    padding: 0.4rem var(--spacing-s);
    border-bottom: 1px solid var(--color-border);
    text-align: left;
    vertical-align: top;
    overflow-wrap: anywhere;
}

.chat-markdown-table-wrap th {
    background: var(--color-background);
    font-weight: 700;
}

.chat-markdown-table-wrap tbody tr:last-child td {
    border-bottom: 0;
}

/* Ordinary fenced snippets get their own bounded horizontal scroller. Program
   proposal JSON is kept behind the generating status while it streams and is
   replaced by the validated preview when complete; malformed or interrupted
   output can still land here, safely escaped, so this box remains defensive. */
.chat-code-block {
    width: 100%;
    max-width: 100%;
    min-width: 0;
    overflow-x: auto;
    margin: var(--spacing-s) 0;
    padding: var(--spacing-sm);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-small);
    background: var(--color-background);
    color: var(--color-text-muted);
    font-family: var(--font-mono);
    font-size: 0.72rem;
    line-height: 1.45;
    overflow-wrap: normal;
    word-break: normal;
    tab-size: 2;
    white-space: pre;
}

.chat-code-block code {
    font: inherit;
}

.chat-code-block--open {
    border-bottom-style: dashed;
}

/* A provider can take several seconds before its first token. Keep the
   pending label visibly alive so latency does not look like a frozen chat;
   only the text breathes, leaving the bubble itself steady. The global
   reduced-motion rule below collapses this to one static frame. */
.chat-thinking-label {
    display: inline-block;
    color: var(--color-text-muted);
    animation: chatThinkingPulse 1.5s ease-in-out infinite;
}

.chat-program-preview,
.chat-program-invalid {
    display: grid;
    min-width: 0;
    gap: var(--spacing-sm);
    padding: var(--spacing-m);
    border: 1px solid var(--color-accent-border);
    border-radius: var(--radius-medium);
    background: var(--color-surface);
}

.chat-program-invalid {
    border-color: var(--color-danger);
}

.chat-program-invalid p {
    margin: 0;
    font-size: 0.78rem;
    line-height: 1.4;
    overflow-wrap: anywhere;
}

.chat-program-preview h4,
.chat-program-preview h5,
.chat-program-preview p,
.chat-program-preview ul,
.chat-program-preview ol {
    margin: 0;
}

.chat-program-preview h4 {
    overflow-wrap: anywhere;
    font-size: 1rem;
}

.chat-program-preview h5,
.chat-program-kicker {
    color: var(--color-text-muted);
    font-size: 0.72rem;
    font-weight: 800;
    letter-spacing: 0.04em;
    text-transform: uppercase;
}

.chat-program-preview > p:not(.chat-program-kicker),
.chat-program-custom {
    font-size: 0.78rem;
    line-height: 1.4;
}

.chat-program-custom ul {
    padding-left: 1.2rem;
}

.chat-program-custom--none {
    color: var(--color-text-muted);
}

.chat-program-preview-section {
    display: grid;
    gap: 0.25rem;
}

.chat-program-outline ol {
    display: grid;
    gap: var(--spacing-s);
    padding-left: 1.25rem;
    font-size: 0.78rem;
}

.chat-program-outline li,
.chat-program-outline li span {
    min-width: 0;
    overflow-wrap: anywhere;
}

.chat-program-outline li span {
    display: block;
    margin-top: 0.1rem;
    color: var(--color-text-muted);
}

.timed-reps-config {
    display: grid;
    gap: var(--spacing-s);
}

.timed-reps-floor .panel-copy {
    margin-top: var(--spacing-xs);
}

.chat-program-preview-list {
    display: grid;
    gap: 0.25rem;
    padding: 0;
    list-style: none;
    font-size: 0.76rem;
}

.chat-program-preview-list li {
    display: flex;
    min-width: 0;
    justify-content: space-between;
    gap: var(--spacing-s);
}

.chat-program-preview-list li span:first-child {
    min-width: 0;
    overflow-wrap: anywhere;
}

.chat-program-preview-list li span:last-child {
    flex-shrink: 0;
    color: var(--color-text-muted);
}

.chat-program-install {
    min-height: 2.75rem;
}

.chat-composer {
    display: flex;
    flex-direction: column;
    width: 100%;
    min-width: 0;
    /* The scrollable context area may collapse against a mobile keyboard;
       the fixed 44px input row, its inset and border may not. */
    min-height: 3.375rem;
    gap: var(--spacing-s);
}

.chat-composer:has(.chat-composer-status:not([hidden])) {
    min-height: 5.125rem;
}

.chat-composer-scroll {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    min-width: 0;
    min-height: 0;
    overflow-x: hidden;
    overflow-y: auto;
    gap: var(--spacing-s);
    overscroll-behavior: contain;
}

.chat-attachment-picker {
    display: grid;
    min-width: 0;
    gap: 0.25rem;
}

.chat-attachment-heading {
    display: flex;
    width: 100%;
    min-width: 0;
    min-height: 1.6rem;
    align-items: center;
    justify-content: space-between;
    gap: 0.25rem;
}

.chat-attachment-heading p {
    margin: 0;
    color: var(--color-text-muted);
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.04em;
    text-transform: uppercase;
}

.chat-attachment-actions {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    width: 100%;
    min-width: 0;
    gap: 0.25rem;
}

.chat-attachment-toggle {
    display: flex;
    min-width: 0;
    min-height: 2.75rem;
    align-items: center;
    justify-content: center;
    padding: 0.25rem;
    border: 1px solid var(--color-border-strong);
    border-radius: var(--radius-control);
    background: var(--color-surface);
    color: var(--color-text);
    font: inherit;
    font-size: 0.76rem;
    font-weight: 700;
}

.chat-attachment-toggle span {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.chat-attachment-toggle[aria-pressed="true"] {
    border-color: var(--color-accent);
    background: var(--color-accent);
    color: var(--color-on-accent);
}

.chat-program-intent-note {
    margin: 0;
    color: var(--color-text-muted);
    font-size: 0.72rem;
    line-height: 1.35;
}

.chat-context-review,
.chat-context-view,
.chat-context-close {
    min-height: 2.75rem;
    padding: var(--spacing-s) 0;
    border: none;
    background: none;
    color: var(--color-accent);
    font: inherit;
    font-size: 0.78rem;
    font-weight: 700;
    text-align: left;
}

.chat-context-review {
    min-height: 2.75rem;
    justify-self: end;
    padding: 0 0.25rem;
}

.chat-context-preview:not([hidden]) {
    display: grid;
    width: 100%;
    min-width: 0;
    gap: var(--spacing-s);
    padding: var(--spacing-sm);
    border: 1px solid var(--color-accent-border);
    border-radius: var(--radius-medium);
    background: var(--color-accent-fill);
}

.chat-context-preview-heading {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-s);
}

.chat-context-preview h3,
.chat-context-preview p {
    margin: 0;
}

.chat-context-preview h3 {
    font-size: 0.9rem;
}

.chat-context-preview p {
    font-size: 0.78rem;
    line-height: 1.4;
}

.chat-context-reduction {
    color: var(--color-text-muted);
}

.chat-context-close {
    min-width: 2.75rem;
    padding: 0;
    color: var(--color-text-muted);
    font-size: 1.25rem;
    text-align: center;
}

.chat-context-view {
    justify-self: start;
}

.chat-context-data {
    width: 100%;
    min-width: 0;
    max-height: 12rem;
    overflow: auto;
    margin: 0;
    padding: var(--spacing-sm);
    border: 1px solid var(--color-border);
    border-radius: var(--radius-small);
    background: var(--color-surface-raised);
    color: var(--color-text);
    font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
    font-size: 0.68rem;
    line-height: 1.45;
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

.chat-context-confirm {
    min-height: 2.75rem;
}

.chat-composer-status {
    display: flex;
    flex: 0 0 auto;
    min-height: 1.25rem;
    align-items: center;
    justify-content: space-between;
    gap: var(--spacing-s);
}

.chat-composer-note {
    margin: 0;
    color: var(--color-text-muted);
    font-size: 0.78rem;
    line-height: 1.35;
}

.chat-composer-retry {
    flex: 0 0 auto;
}

.chat-composer-row {
    --chat-composer-inset: 0.25rem;
    display: grid;
    flex: 0 0 auto;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: end;
    gap: var(--chat-composer-inset);
    padding: var(--chat-composer-inset);
    border: 1px solid var(--color-border-strong);
    border-radius: var(--radius-medium);
    background: var(--color-surface);
}

.chat-message-warning {
    margin: var(--spacing-xs) 0 0;
    color: var(--color-text-muted);
    font-size: 0.76rem;
    line-height: 1.35;
}

.chat-composer-row:focus-within {
    outline: 2px solid var(--color-highlight);
    outline-offset: 2px;
}

.chat-composer-input {
    width: 100%;
    min-height: 2.75rem;
    max-height: 8rem;
    resize: none;
    overflow-y: hidden;
    padding: 0.6rem 0.5rem;
    border: none;
    border-radius: 0;
    outline: none;
    background: transparent;
    color: var(--color-text);
    caret-color: var(--color-accent);
    font: inherit;
    line-height: 1.4;
}

.chat-icon-action {
    display: inline-flex;
    flex: 0 0 auto;
    width: 2.75rem;
    min-height: 2.75rem;
    align-items: center;
    justify-content: center;
    padding: 0;
    border: 1px solid var(--color-border-strong);
    border-radius: var(--radius-pill);
    background: var(--color-surface);
    color: var(--color-text);
}

.chat-icon-action svg {
    width: 1.2rem;
    height: 1.2rem;
}

.chat-composer-send {
    border-color: var(--color-accent);
    background: transparent;
    color: var(--color-accent);
}

.chat-composer-send,
.chat-composer-stop {
    /* The control is inset inside .chat-composer-row, so its curve is the
       outer curve minus that inset. A circle put an unrelated curve inside a
       rounded rectangle; this keeps the two silhouettes concentric. */
    border-radius: calc(var(--radius-control) - var(--chat-composer-inset));
}

.chat-composer-stop {
    border-color: var(--color-danger);
    background: var(--color-surface);
    color: var(--color-danger);
}

.chat-composer-send:disabled,
.chat-composer-retry:disabled {
    opacity: 0.5;
}

@media (max-width: 360px) {
    .chat-panel .panel-footer {
        padding-inline: var(--spacing-sm);
    }

    .chat-message-content {
        width: 90%;
    }
}
