/* ============================================================
   THEME
   ------------------------------------------------------------
   Light is the default and dark is opt-in, switched by the header
   toggle, which stamps data-theme on <html> and remembers the choice
   (see the theme block at the top of js/app.js). The OS preference is
   deliberately NOT consulted: a first visit should look the same for
   everyone, and that first look is light.

   Every color the app draws is declared once here and redefined
   wholesale in the [data-theme="dark"] block below, so no rule further
   down this file ever has to know which theme is on. The one exception
   is a color that belongs to someone else — Strava's orange — which is
   the same in both because it is their brand, not our palette.

   The light/dark relationship to hold on to: --color-surface is what a
   CARD is, --color-bg is both the page behind it and the INSET panels
   inside it (summary tiles, inputs, week headers, chart wells). In dark
   the inset is darker than the card; in light it is a shade of grey on
   white. Same tokens, same meaning, opposite direction.
   ============================================================ */
:root {
  color-scheme: light;

  /* Deliberately a shade deeper than the near-white it started at. This
     one value is doing two jobs — the page behind the cards AND the inset
     panels inside them — and at #f2f4f9 neither read: white cards
     disappeared into the page, and the summary tiles disappeared into the
     card. */
  --color-bg: #eceff6;
  --color-surface: #ffffff;
  --color-text: #151a26;
  --color-text-muted: #697388;
  --color-border: #e3e7f0;
  /* The SAME edge, one step further along, for a panel whose own back is
     --color-bg rather than --color-surface. --color-border is tuned to sit
     on a card, and against the page colour it is very nearly the page
     colour — in light, #e3e7f0 on #eceff6 is a hairline nobody can see, so
     the summary tiles, the pace legend and the chart well all lost their
     shape while claiming to have an edge. Mixing toward the muted text ink
     rather than hard-coding a second hex keeps it moving with the palette,
     and keeps both themes on one rule: light gains the edge it was
     missing, dark keeps the one it already had.
     Which token a panel takes follows only from what is BEHIND it:
     --color-border over --color-surface, this over --color-bg. */
  --color-border-inset: color-mix(in srgb, var(--color-border) 62%, var(--color-text-muted));
  --color-primary: #4f46e5;
  /* Not literally "darker than primary" any more — it is the primary
     used as TEXT, which on white has to go darker and on near-black had
     to go lighter. The name is kept because every use site means the
     same thing by it. */
  --color-primary-dark: #4338ca;
  --color-primary-light: rgba(79, 70, 229, 0.1);
  /* Reserved specifically for content the app filled in automatically —
     AI review notes/tips and Strava auto-fill badges (see color audit
     below). Nothing else should reach for this color; it needs to keep
     meaning "the app did this for you" at a glance. */
  --color-accent: #0d8f72;
  --color-accent-light: rgba(13, 143, 114, 0.1);
  /* Formerly one-off hex values inlined into .form-error/.form-warning —
     pulled up here so they sit alongside the rest of the palette instead
     of being the only colors in the file that don't go through a token. */
  --color-error: #c0392b;
  --color-error-bg: #fdeceb;
  --color-error-border: #f3c9c4;
  --color-warning: #8a6100;
  --color-warning-bg: #fff6e2;
  --color-warning-border: #f0dca8;

  /* The header's own ramp and the text that sits on it.
     These are deliberately NOT the near-white first draft (#dfe3ff /
     #eaedfb): measured against --color-bg, that ramp moved 13/12/9 per
     channel where the dark theme's moves 27/30/78, so the light header
     read as one flat block while the dark one had a visible glow. Light
     backgrounds hide a gradient far more readily than dark ones, so the
     light stops have to start further from the page color to travel the
     same visual distance. */
  --header-from: #c6ccf9;
  --header-mid: #dee3fb;
  --header-text: #1b2136;
  /* "The surface color at zero alpha", for the one gradient that fades a
     surface out (.form-submit-bar). Spelled out per theme rather than
     using `transparent`, which Safari interpolates through transparent
     BLACK — a dark smear across the top of the fade. */
  --surface-fade: rgba(255, 255, 255, 0);

  /* Where the today-card's wash starts (see .today-card). Opaque, and its
     own token rather than reusing --color-primary-light: that one is a
     10% indigo, which over the white card composited to rgb(237,237,252)
     against an rgb(236,239,246) end stop — a gradient one channel wide,
     i.e. no gradient at all. It stays 10% because half a dozen tints
     around the app depend on it being that light. */
  --today-wash: #d2d6f8;
  --color-dummy-text: #7a5600;
  --color-dummy-bg: rgba(255, 183, 3, 0.16);
  --color-dummy-border: rgba(196, 140, 0, 0.45);

  /* Session type colors. Every one of these is used two ways: as a badge
     fill with WHITE text on it, and as a bare chart bar or dot. The badge
     is the binding constraint, which is why the light set below is
     noticeably deeper than the dark set — the dark-theme values are mid
     tones chosen to glow against near-black, and white text on those same
     values over a white card is unreadable. */
  --type-recovery: #776a5e;
  --type-easy: #3f7530;
  --type-longrun: #0f7460;
  --type-tempo: #8a6512;
  --type-interval: #b34f24;
  --type-repetition: #b52539;
  --type-rest: #656d7d;
  /* Weekday rest days suggest strength training (see app.js's
     restDisplayKey) — a distinct violet (a hue nothing else in this list
     uses) so that badge reads as its own thing at a glance instead of
     blending into plain --type-rest gray. Weekend rest keeps --type-rest;
     this is only for the weekday variant. */
  --type-rest-strength: #6f4fac;
  /* Days inside a break the runner declared (see BREAK_LEVELS in
     js/planGenerator.js). A cool, low-saturation steel blue: it has to
     read as "nothing is expected of you here" next to plain --type-rest
     gray while still being obviously deliberate rather than absent, and
     it has to not be mistaken for --type-rest-strength violet (a slot
     that IS asking for something) or --type-race indigo. */
  --type-break: #46718f;
  --type-race: var(--color-primary);

  /* Chart series colours for the VDOT trend plot (see renderVdotTrend in
     js/app.js). Two categorical slots, validated as a pair against the
     chart surface with the dataviz palette validator — lightness band,
     chroma floor, CVD separation (deutan ΔE 23.8, tritan 10.2),
     normal-vision separation (ΔE 28.9) and 3:1 contrast all pass in this
     mode. Do NOT retune either value without re-running that check: the
     two series are the whole point of the chart, and a pair that collapses
     under colour-vision deficiency makes it unreadable.
     --chart-planned reuses the primary indigo, which already means "the
     plan" everywhere else in this app. --chart-measured is its own token
     rather than --color-accent because the dark-mode accent (#4fd6b5) sits
     above the validator's lightness ceiling for a chart mark, while still
     being right for the badges and text it was picked for. */
  --chart-planned: var(--color-primary);
  --chart-measured: #0d8f72;

  /* SPACING GRID — 2px, and every padding, margin and gap in this file
     sits on it. There are no --space-* tokens on purpose: the steps in
     use run 2, 4, 6, 8, 10, 12, 14, 16, 18, 20, then 24, 26, 28 for
     section breaks, and naming fifteen numbers after the pixels they
     already state would make `padding: var(--space-14) var(--space-16)`
     harder to read than `padding: 14px 16px`, not easier. The grid is
     the rule; this comment is where it is written down.
     It was already the de-facto rule before it was written down — 88% of
     the 409 spacing values in this file were even. The other 12% were
     hand-tuned odd numbers (7px and 9px paddings on four different
     buttons, 11px and 13px on a tile) with no comment anywhere saying
     why, which is what makes "is 7 or 8 right here?" unanswerable for
     the next person. They are snapped now.
     TWO EXCEPTIONS, both marked at their own rule, and both the same
     kind of thing: a number derived from another dimension rather than
     chosen as spacing. The visually-hidden inputs cancel their own 1px
     box with margin: -1px, and anything centring itself uses translateX
     rather than half its own width negated. If a value has to leave the
     grid, it needs a reason of that shape and a comment saying so. */

  /* Softer, larger corners read as calmer, and are what every current
     phone UI does; --radius-sm is for the panels nested inside a card,
     which need to stay visibly tighter than the card containing them. */
  --radius: 20px;
  --radius-sm: 14px;
  /* The two steps below were in use long before they were named: 10px on
     rows and notices inside a panel, 6px on inline chrome (a strip, a
     small toggle, a checkbox). Naming them is what stops the drift that
     had already set in — the same "row inside a panel" shape was written
     8px in eight places, 9px in one and 10px in five, and the same
     "panel" shape 12px, 13px and 14px, none of which reads as deliberate
     at 1px apart. --radius-control is the form-control step and is
     deliberately NOT one of the others: it is tuned to the 51px control
     height set in the input rule, so it moves with that and nothing
     else. Everything meant to be a pill uses 999px, not a radius large
     enough to clamp into one. */
  --radius-item: 10px;
  --radius-chrome: 6px;
  --radius-control: 12px;
  /* Two layers: a wide soft one for depth and a hairline-tight one so the
     card still has a defined edge against a light page, which a single
     large blur alone doesn't give. */
  --shadow: 0 8px 24px rgba(21, 26, 38, 0.06), 0 1px 2px rgba(21, 26, 38, 0.05);
  /* How far a not-yet-run volume-chart bar recedes. This is a color-mix
     strength (how much of the phase color survives against the chart's
     own --color-bg), NOT an element opacity: transparency dimmed the
     hairline edge along with the fill, which left a light-theme bar
     reading as a washed pastel with no defined edge and a visibly
     different hue from the full-strength dot the legend names it with.
     Mixing the FILL only, and keeping the edge at full strength, dims the
     bar without ever changing which phase color it looks like. Light
     needs a stronger survival rate than dark: these phase colors are
     text-grade and dark, so on the pale page a low mix washes them out,
     while on near-black the same color already recedes on its own. */
  --chart-dim: 0.62;
  /* Same idea on hover — the bar steps toward its true color. */
  --chart-dim-hover: 0.74;
  /* The selected pill in a segmented control (.plan-tabs). A white pill
     on the --color-bg track read as barely-there — from arm's length the
     two halves looked like one band. It's filled with the brand primary
     instead, so which tab you're on is legible at a glance in either
     theme, with white text on top. */
  --tab-active-bg: var(--color-primary);
  --tab-active-border: var(--color-primary-dark);
  --tab-active-fg: #ffffff;
  /* The track behind the pills: tinted rather than flat --color-bg so the
     control reads as a switch, not a stray row of buttons. */
  --tab-track-bg: var(--color-primary-light);
  --tab-track-border: color-mix(in srgb, var(--color-primary) 32%, transparent);
  /* One family, four weights. There used to be a second face here — Space
     Grotesk — nominally the "display" font, but both it and Manrope are
     geometric sans with the same skeleton and near-identical x-height, so
     the pairing never read as two voices: it read as one voice that kept
     slipping. Hierarchy is carried by the scale below and by weight (800
     for anything that was reaching for the display face) instead, which
     also drops the loaded weights from eight files to four. */
  --font-body: 'Manrope', -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;

  /* ---- Type scale ------------------------------------------------------
     Sizes used to be picked per rule, which left 39 distinct values in this
     file — 0.84, 0.85, 0.86, 0.87, 0.88, 0.9, 0.92, 0.95, 0.96, 0.98, 1rem
     all living at once. Steps that small (0.01rem is a fifth of a pixel)
     are not read as a difference in rank; they are read as the page being
     sloppy. Worse, only 8 of ~150 declarations were >= 1rem, so almost
     every string in the app was smaller than the body default and the
     whole UI sat one notch too tight.
     Eight steps, each far enough from its neighbour to be legible AS a
     step. Pick the nearest one; do not invent a value in between. Any rule
     that still carries a raw rem is a documented exception with its reason
     written next to it. */
  --text-2xs: 0.6875rem; /* 11px. Reserved for labels wedged into a fixed
                            width — volume-chart column captions, the VDOT
                            chart's axis text. Not for prose, ever. */
  --text-xs: 0.75rem;    /* 12px. Uppercase eyebrows, badges, chips. */
  --text-sm: 0.875rem;   /* 14px. Captions, meta lines, helper text. */
  --text-base: 1rem;     /* 16px. Body copy, controls, card contents. */
  --text-md: 1.125rem;   /* 18px. Card and panel titles, today-card title,
                            primary CTA. */
  --text-lg: 1.375rem;   /* 22px. Metric figures. */
  --text-xl: 1.75rem;    /* 28px. Page and section headings, wordmark, the
                            one hero figure (today's distance). */

  /* Line height ran to 11 values (1.45 / 1.5 / 1.55 / 1.6 ...) and letter
     spacing to 16, including -0.005em and -0.011em — differences no eye
     resolves. Three of each, chosen by what the text IS. */
  --leading-tight: 1.2;    /* Headings and big figures. */
  --leading-snug: 1.35;    /* Two-line labels, table cells. */
  --leading-normal: 1.5;   /* UI copy. */
  --leading-relaxed: 1.65; /* Paragraphs meant to be read through. */

  --tracking-tight: -0.02em; /* Large type only; it needs closing up. */
  --tracking-normal: 0;
  --tracking-wide: 0.06em;   /* Uppercase micro-labels only. */
  /* ---------------------------------------------------------------- */

  /* Mask sources for the pseudo-element icons further down (see the
     ::before block under "Icons"). Same shapes as index.html's sprite;
     duplicated as data URIs only because mask-image cannot point at a
     <symbol>. Stroke color is irrelevant to a mask — only the alpha is
     read — so these all draw in black and take their real color from the
     element's own currentColor. */
  --i-check: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m4.5 12.5 5 5 10-10.5'/%3E%3C/svg%3E");
  --i-sparkle: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M10 2.8 11.7 8 17 9.8 11.7 11.5 10 16.8 8.3 11.5 3 9.8 8.3 8 10 2.8Z'/%3E%3Cpath d='m18 14.2.9 2.4 2.4.9-2.4.9-.9 2.4-.9-2.4-2.4-.9 2.4-.9.9-2.4Z'/%3E%3C/svg%3E");
  --i-alert: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 3.5 2.8 20h18.4L12 3.5Z'/%3E%3Cpath d='M12 9.5v4.2'/%3E%3Ccircle cx='12' cy='16.9' r='1' fill='%23000' stroke='none'/%3E%3C/svg%3E");
  --i-chart: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M3.2 20.4h17.6'/%3E%3Cpath d='M6.8 20.4v-7.6'/%3E%3Cpath d='M12 20.4V4.6'/%3E%3Cpath d='M17.2 20.4v-5.2'/%3E%3C/svg%3E");
  --i-flag: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M5.2 21.2V3.2'/%3E%3Cpath d='M5.2 4.4h13.4l-2.6 4.3 2.6 4.3H5.2'/%3E%3C/svg%3E");
  --i-check-circle: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='1.9' stroke-linecap='round' stroke-linejoin='round'%3E%3Ccircle cx='12' cy='12' r='8.8'/%3E%3Cpath d='m8 12.2 2.8 2.8 5.4-5.6'/%3E%3C/svg%3E");
}

/* The whole dark theme, in one place. Only tokens are redefined — if a
   rule further down this file needs a [data-theme="dark"] variant of its
   own, that is a sign it is drawing a color literal it should have been
   taking from a token. */
:root[data-theme="dark"] {
  color-scheme: dark;

  --color-bg: #10131a;
  --color-surface: #1a1f2b;
  --color-text: #e8eaf0;
  --color-text-muted: #939bac;
  --color-border: #2b3243;
  --color-primary: #6366f1;
  --color-primary-dark: #a5b4fc;
  --color-primary-light: rgba(99, 102, 241, 0.18);
  --color-accent: #4fd6b5;
  /* Own step, not an automatic flip of the light value — re-validated
     against the dark chart surface (#1a1f2b): deutan ΔE 23.0, tritan 10.5,
     normal ΔE 27.2, all bands pass. See --chart-measured in :root. */
  --chart-measured: #22a888;
  --color-accent-light: rgba(79, 214, 181, 0.14);
  --color-error: #ff9c90;
  --color-error-bg: #3a1a1a;
  --color-error-border: #63302a;
  --color-warning: #f0c66a;
  --color-warning-bg: #3a2c10;
  --color-warning-border: #5c4519;

  --header-from: #2b3168;
  --header-mid: #171b2e;
  --header-text: #ffffff;
  --today-wash: #272c4f;
  --surface-fade: rgba(26, 31, 43, 0);

  --color-dummy-text: #ffd98a;
  --color-dummy-bg: rgba(255, 183, 3, 0.16);
  --color-dummy-border: rgba(255, 183, 3, 0.4);

  /* Mid tones, chosen to glow against near-black rather than to carry
     white text on white — see the light set's own note. */
  --type-recovery: #b0a396;
  --type-easy: #74b358;
  --type-longrun: #3aa98c;
  --type-tempo: #d9a53a;
  --type-interval: #d97a4f;
  --type-repetition: #d9455a;
  --type-rest: #8b93a3;
  --type-rest-strength: #9d84c9;
  --type-break: #6f97b8;

  /* A dark card's edge comes from its own surface tone against the page,
     so the shadow here is doing depth only and can be deeper and softer
     than the light one, with no hairline layer. */
  --shadow: 0 10px 30px rgba(0, 0, 0, 0.4);
  --chart-dim: 0.45;
  --chart-dim-hover: 0.68;
  /* Dark can't reuse --color-surface here: on the near-black --color-bg
     track it is only a few steps lighter, and the shadow that carries the
     lift in light does nothing against near-black. So the selected pill
     steps up past the border tone and takes a hairline of its own — the
     same "which half am I on" answer, given with value instead of
     shadow. */
  /* One step brighter than the dark --color-primary so the filled pill
     still separates from the near-black track; the border adds the edge
     that the light theme gets from the page around it. */
  --tab-active-bg: #5b5ef0;
  --tab-active-border: #8b8ef7;
  --tab-active-fg: #ffffff;
  --tab-track-bg: rgba(99, 102, 241, 0.16);
  --tab-track-border: rgba(139, 142, 247, 0.42);
}

* { box-sizing: border-box; }
[hidden] { display: none !important; }

/* ---------- Icons ---------- */
/* Every icon is a <use> into the sprite at the top of index.html. Sized in
   em so it scales with whatever text it sits beside, and stroked in
   currentColor so it takes that text's exact color — the thing emoji
   could never do, since each renders in its own fixed vendor palette. */
.icon-sprite { display: none; }
.icon {
  width: 1.05em;
  height: 1.05em;
  flex: none;
  vertical-align: -0.16em;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.9;
  stroke-linecap: round;
  stroke-linejoin: round;
}
/* Pseudo-element form, for the handful of places where the icon is a
   fixed property of a message class rather than part of specific markup
   (.sync-status, .ai-intro, ...). Those elements have their text written
   via textContent — some of it server- or AI-authored — so the icon can't
   be markup there without giving up that safety. A mask over
   background:currentColor keeps the same inherit-the-text-color behavior
   the <use> icons have. */
.sync-status::before,
.strava-fill-badge::before,
.ai-intro::before,
.ai-tips::before,
.week-ai-note::before,
.week-race-tips::before,
.completed-badge::before {
  content: "";
  display: inline-block;
  width: 1.05em;
  height: 1.05em;
  vertical-align: -0.16em;
  margin-right: 0.42em;
  background: currentColor;
  -webkit-mask-repeat: no-repeat;
  mask-repeat: no-repeat;
  -webkit-mask-position: center;
  mask-position: center;
  -webkit-mask-size: contain;
  mask-size: contain;
}
/* Same geometry as the matching <symbol> in index.html's sprite, inlined
   here because mask-image can't reference a sprite symbol. Keep the two
   in step if an icon is ever redrawn. */
.sync-status::before {
  -webkit-mask-image: var(--i-check);
  mask-image: var(--i-check);
}
.sync-status.is-ai::before,
.ai-intro::before,
.ai-tips::before,
.week-ai-note::before {
  -webkit-mask-image: var(--i-sparkle);
  mask-image: var(--i-sparkle);
}
.sync-status.is-error::before {
  -webkit-mask-image: var(--i-alert);
  mask-image: var(--i-alert);
}
.strava-fill-badge::before {
  -webkit-mask-image: var(--i-chart);
  mask-image: var(--i-chart);
}
.week-race-tips::before {
  -webkit-mask-image: var(--i-flag);
  mask-image: var(--i-flag);
}
.completed-badge::before {
  -webkit-mask-image: var(--i-check-circle);
  mask-image: var(--i-check-circle);
}

body {
  margin: 0;
  font-family: var(--font-body);
  background: var(--color-bg);
  color: var(--color-text);
  line-height: var(--leading-relaxed);
  letter-spacing: var(--tracking-normal);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  /* Mobile polish: kill the grey flash Android paints over every tap
     target (the app draws its own :active states), and point the few
     remaining native controls — the date pickers, the select — at the
     brand color instead of the browser's default blue. */
  -webkit-tap-highlight-color: transparent;
  accent-color: var(--color-primary);
}

/* Figures that sit in a column, or that change while the runner watches.
   Proportional digits are narrower on 1 than on 0, so a pace ticking from
   5:19 to 5:20, or a stack of weekly totals, shifts sideways by a pixel or
   two on every redraw — small, but it reads as the layout being unsteady.
   Tabular digits are all one width, so the numbers stay put.
   Deliberately NOT set on body: tabular figures pad the 1 with air, which
   is right in a table and wrong in a sentence, and most of the running
   prose in this app (the AI notes, the field help) is sentences. So this
   is a list of the places that are actually numeric, rather than one
   blanket rule — it used to be nineteen declarations scattered down the
   file, which is how half the numeric elements came to be missed. */
.day-table td,
.week-header .week-total,
.week-progress,
.week-header .week-phase,
.summary-item .value,
.recap-metric-value,
.recap-metric-sub,
.recap-range,
.today-card-metric strong,
.today-card-week,
.today-card-next,
.today-progress-label,
.volume-chart-value,
.volume-chart-week-no,
.vt-axis-text,
.pace-zone-table td,
.pace-goal-item .value,
.completed-badge,
.completed-analysis .rep-splits,
.completed-analysis .gap-note,
.block-history-meta,
.break-item-meta,
.fitness-strip,
.vdot-trend-tip-value,
.vdot-trend-table-wrap td,
.upcoming-meta,
.legend-step,
.gate-mock-km,
.gate-zone-pace,
.gate-preview-total,
.gate-preview-km {
  font-variant-numeric: tabular-nums;
}

.container {
  max-width: 900px;
  margin: 0 auto;
  padding: 0 20px;
}

/* ---------- Header ---------- */
/* A soft halo behind the wordmark that resolves into the page background,
   rather than the old 135deg navy-to-near-black ramp — that one ended on a
   different color than --color-bg, so the header met the page at a visible
   seam right where the first card overlaps it. */
.site-header {
  /* Two layers, not one. The radial alone only reaches --color-bg at 100%
     of its radius, and the header's bottom edge sits at ~71% of it — so
     the element ended on a mid lavender and met the page background at a
     hard horizontal line, level with the action row under the title. The
     linear layer on top fades the whole width to exactly --color-bg by
     the bottom edge, so there is nothing left to see a seam against. */
  background:
    linear-gradient(to bottom, transparent 45%, var(--color-bg) 100%),
    radial-gradient(120% 140% at 50% 0%, var(--header-from) 0%, var(--header-mid) 45%, var(--color-bg) 100%);
  color: var(--header-text);
  padding: 26px 0 38px;
}
.header-inner {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  column-gap: 12px;
  text-align: center;
}
/* grid-row: 1 on both, not just a column each. .header-tools comes first in
   the DOM, so once it claimed column 3 the auto-placement cursor had passed
   column 2 and the brand — which only named a column — was pushed onto a row
   of its own, leaving the theme toggle floating a row above the wordmark.
   Naming the row puts them back on the same line, vertically centred. */
.brand { grid-column: 2; grid-row: 1; }
.tagline, .sync-status { grid-column: 1 / -1; }

/* Three columns rather than a centred brand with the tools absolutely
   positioned over it. Absolute positioning takes the tools out of flow,
   so nothing stopped them and the brand occupying the same pixels — and
   once the theme toggle joined the cluster they did: at 760px the brand
   overran the tools by 64px, stacking the MODE DUMMY badge, the toggle
   and the account name on top of each other. Grid columns cannot overlap,
   so this cannot come back at any width. The 1fr on either side is what
   keeps the brand optically centred. */
.header-tools {
  grid-column: 3;
  grid-row: 1;
  justify-self: end;
  display: flex;
  align-items: center;
  gap: 8px;
}
.auth-box {
  display: flex;
  align-items: center;
  gap: 10px;
}
/* A circular icon button, the shape every phone UI uses for a single
   header action. Translucent rather than a fixed color so it sits on the
   header's gradient in both themes without needing a variant of its own. */
.theme-toggle {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 34px;
  height: 34px;
  flex: none;
  padding: 0;
  border: 1px solid color-mix(in srgb, var(--header-text) 18%, transparent);
  border-radius: 50%;
  background: color-mix(in srgb, var(--header-text) 7%, transparent);
  color: var(--header-text);
  cursor: pointer;
  transition: background 0.15s ease, border-color 0.15s ease, transform 0.15s ease;
}
.theme-toggle .icon { width: 17px; height: 17px; vertical-align: 0; }
.theme-toggle:hover {
  background: color-mix(in srgb, var(--header-text) 14%, transparent);
  border-color: color-mix(in srgb, var(--header-text) 32%, transparent);
}
.theme-toggle:active { transform: scale(0.94); }
.theme-toggle:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }
.auth-box .btn-link { color: var(--header-text); opacity: 0.75; }
.auth-box .btn-link:hover { opacity: 1; }
.user-info { display: flex; align-items: center; gap: 10px; }
/* Bounded and on one line: a Strava display name is whatever the runner
   set it to, and left free it wraps to two lines and grows the header for
   a label nobody reads twice. */
.user-email {
  font-size: var(--text-sm);
  opacity: 0.85;
  max-width: 170px;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.sync-status {
  margin: 10px 0 0;
  font-size: var(--text-sm);
  font-style: italic;
  opacity: 0.75;
}
.sync-status.is-error { color: var(--color-error); opacity: 1; font-style: normal; }

/* ---------- Header, per screen ---------- */
/* <body data-screen> is set by setScreen() in js/app.js. The tagline and
   the header's generous top padding are first-impression furniture: they
   earn their space on the gate and the empty form, and stop earning it the
   moment the runner is looking at a plan they have already generated. On a
   390x780 phone this header measured 212px, which — stacked on top of the
   195px action row that used to sit under the result heading — was landing
   #todayCard, the one thing this page gets opened for on an ordinary
   morning, at y=715. Trimming here is worth more than anywhere else on the
   page because it is paid on every single visit. */
/* Everywhere except the gate, the header stops being a centred wordmark
   lockup and becomes an app bar: the mark stays as a mark, the screen
   names itself, and the account/theme controls sit at the far end. The
   centred-logo shape is a landing page's, and it was costing 19% of a
   390x820 phone on every single visit to a plan the runner has already
   generated. The gate is the one screen where the wordmark IS the
   content, so it keeps the full lockup and the tagline. */
body:not([data-screen="gate"]) .site-header { padding: 14px 0 26px; }
body:not([data-screen="gate"]) .header-inner {
  grid-template-columns: auto minmax(0, 1fr) auto;
  text-align: left;
}
body:not([data-screen="gate"]) .brand { grid-column: 1; grid-row: 1; justify-self: start; }
/* The MODE DUMMY badge goes too: sitting between the mark and the screen
   title it split the lockup and read as part of the title. The gate
   explains dummy mode at length, and the sync line under this header says
   "(mode dummy — lokal)" on every save. */
body:not([data-screen="gate"]) .brand-name,
body:not([data-screen="gate"]) .dummy-badge,
body:not([data-screen="gate"]) .tagline { display: none; }
body:not([data-screen="gate"]) .header-tools { grid-column: 3; grid-row: 1; }
/* The in-page heading would now say the same thing twice, three lines
   apart. The header carries it; the section drops it. */
body[data-screen="result"] .result-header h2,
body[data-screen="form"] .form-card > h1 { display: none; }

.header-title {
  grid-column: 2;
  justify-self: start;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--text-md);
  font-weight: 800;
  letter-spacing: var(--tracking-tight);
  color: var(--header-text);
}

/* The display name goes well before the phone breakpoint. Above 640px the
   brand is centred and .header-tools is absolutely positioned at the right
   edge, so the two can and did collide: at 760px the brand's right edge
   overran the tools' left edge by 64px, piling the MODE DUMMY badge, the
   theme toggle and the name on top of each other. 900px is where the
   container stops growing, and at that width the widest case (brand plus
   badge, tools plus name) fits with room to spare. Below it the name is
   the part worth losing — one account is signed in at a time, and
   "Logout" is the half that does any work. */
@media (max-width: 900px) {
  .user-email { display: none; }
}

@media (max-width: 640px) {
  /* Brand and account on one row, the way an app's own chrome is shaped —
     rather than the account line stacked above a centered wordmark, which
     is what `position: static` alone gave and which cost a whole extra row
     of header on the narrowest screens. The tagline keeps its own full
     width row underneath (on the screens that still show it). */
  /* minmax(0, 1fr), not 1fr: a bare 1fr floors at the brand lockup's own
     min-content width, and with the MODE DUMMY badge in the row that came
     to 250px — which, next to the account column, pushed the whole grid
     35px wider than the phone and gave the page a horizontal scrollbar.
     The brand wraps instead. */
  .header-inner {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: center;
    column-gap: 12px;
    text-align: left;
  }
  .brand { grid-column: 1; grid-row: 1; justify-self: start; min-width: 0; flex-wrap: wrap; row-gap: 4px; }
  .header-tools { grid-column: 2; grid-row: 1; }
  /* The display name goes entirely. Truncated to fit beside the theme
     toggle it was down to "Demo Runn…", which identifies nothing, and the
     column it was taking left the brand lockup too narrow — logo,
     wordmark and badge each wrapped onto a line of their own, 112px of
     header for a name nobody reads twice. This app has one account
     signed in at a time; "Logout" is the part that does any work. */
  .user-info { flex-wrap: nowrap; }
  .auth-box .btn-link { white-space: nowrap; }
}
/* A lockup — mark beside wordmark — rather than the mark stacked above it.
   The stack is a landing-page shape; a row reads as an app's identity and
   gives back ~60px of vertical space at the top of every single visit,
   which on a phone was 336px of chrome before any content. */
.brand {
  display: inline-flex;
  flex-direction: row;
  align-items: center;
  gap: 12px;
}
.brand-logo { width: 34px; height: 34px; }
.brand-name {
  font-size: var(--text-xl);
  font-weight: 800;
  letter-spacing: var(--tracking-tight);
}
.dummy-badge {
  display: inline-block;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-wide);
  color: var(--color-dummy-text);
  background: var(--color-dummy-bg);
  border: 1px solid var(--color-dummy-border);
  border-radius: 999px;
  padding: 4px 10px;
}
/* Subordinate to the wordmark now, instead of competing with it: same
   display face at bold 1.05rem meant two headline-weight lines stacked on
   top of each other with no hierarchy between them. */
.tagline {
  margin: 8px 0 0;
  font-size: var(--text-base);
  font-weight: 500;
  letter-spacing: var(--tracking-normal);
  opacity: 0.6;
}

/* 24px, not 60: the last card already carries 24px of its own margin
   below it and the footer another 24 above, so a 60px band here put 118px
   of empty page between the end of the plan and "Made with ❤️" — enough
   that the footer read as unmoored from the content rather than as the
   end of it. */
main.container { margin-top: -28px; padding-bottom: 24px; }

/* ---------- Card ---------- */
.card {
  /* No hairline border — the surface tone against --color-bg plus the
     shadow below is enough of an elevation cue on its own, and dropping
     the outline reads as a calmer, less "boxed-in" surface. */
  background: var(--color-surface);
  border-radius: var(--radius);
  box-shadow: var(--shadow);
  padding: 28px 28px 32px;
  margin-bottom: 24px;
}
.card h1 {
  margin-top: 0;
  font-size: var(--text-xl);
  font-weight: 800;
  letter-spacing: var(--tracking-tight);
}
.section-desc { color: var(--color-text-muted); margin-top: -6px; line-height: var(--leading-relaxed); }

/* ---------- Loading screen (between form submit and result render) ---------- */
.loading-card { text-align: center; padding: 56px 28px; }
.loading-card h1 { margin-top: 0; font-size: var(--text-lg); }
.loading-card .section-desc { max-width: 420px; margin-left: auto; margin-right: auto; }

/* Shimmering placeholders shaped like the summary-cards row + a week block
   that are about to render — gives the wait something concrete to look at
   instead of a content-free spinner, and roughly previews the shape of
   what's coming. */
.loading-skeleton {
  max-width: 480px;
  margin: 28px auto 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
  text-align: left;
}
.skeleton-row { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; }
.skeleton-block, .skeleton-bar {
  border-radius: var(--radius-item);
  background: linear-gradient(90deg, var(--color-border) 25%, var(--color-surface) 37%, var(--color-border) 63%);
  background-size: 400% 100%;
  animation: skeleton-shimmer 1.6s ease-in-out infinite;
}
.skeleton-block { height: 52px; }
.skeleton-bar { display: block; height: 14px; width: 100%; }
.skeleton-bar-title { width: 55%; height: 18px; margin-top: 6px; }
.skeleton-bar-short { width: 40%; }
@keyframes skeleton-shimmer {
  0% { background-position: 100% 50%; }
  100% { background-position: 0 50%; }
}
@media (prefers-reduced-motion: reduce) {
  .skeleton-block, .skeleton-bar { animation-duration: 3.2s; }
}

/* ---------- Disclosure reveal ---------- */
/* Every <details> in the app (week accordions, plan warnings, field help)
   snapped its content into place with no transition at all, which on the
   week blocks meant a whole table appearing between two frames. Only the
   opening is animated: a closing <details> removes its content from the
   box tree immediately, so there is nothing left to animate out without
   reaching for height interpolation this doesn't need. */
@keyframes details-reveal {
  from { opacity: 0; transform: translateY(-4px); }
  to { opacity: 1; transform: none; }
}
.week-block[open] > *:not(summary),
.result-warning[open] > *:not(summary),
.field-help[open] > *:not(summary) {
  animation: details-reveal 0.18s ease-out;
}
@media (prefers-reduced-motion: reduce) {
  .week-block[open] > *:not(summary),
  .result-warning[open] > *:not(summary),
  .field-help[open] > *:not(summary) { animation: none; }
}

/* SATU chevron untuk semua disclosure di app ini. Bentuk yang sama tadinya
   digambar ulang di lima tempat dengan lima ukuran (0.48em, 0.5em, 0.55em,
   8px, dan sebuah glyph teks), sumber warna berbeda, dan translate()
   penengah yang berbeda-beda — jadi header step, peringatan plan, bantuan
   field, tabel zona pace, dan akordeon minggu masing-masing punya panah
   sendiri yang mirip tapi tidak pernah sama. Sekarang bentuk, ukuran, tebal
   garis, dan transisinya ditulis sekali di sini; tiap pemakainya cuma
   menentukan warna, posisi, dan arah bukanya.

   Bentuknya pojok kotak (border kanan + bawah) yang diputar, bukan glyph
   teks: '▾' yang dipakai akordeon minggu tebal dan tingginya berbeda-beda
   per platform, dan tidak pernah cocok dengan empat chevron CSS di
   sebelahnya. Arah dasar = menunjuk ke BAWAH, yaitu keadaan tertutup untuk
   semuanya kecuali header step (lihat .legend-chevron). */
.legend-chevron,
.week-toggle-icon,
.result-warning-summary::after,
.field-help > summary::after,
.pace-zone-details > summary::after {
  flex: none;
  width: 8px;
  height: 8px;
  /* Glyph '▾' di .week-toggle-icon dibuang lewat CSS supaya markup di
     js/app.js tidak perlu tahu soal ini; font-size: 0 menyembunyikannya
     tanpa menghapus elemennya. */
  font-size: 0;
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(45deg);
  transition: transform 0.15s ease;
}
/* Terbuka = menunjuk ke atas, satu putaran yang sama di mana pun. */
.week-block[open] > summary .week-toggle-icon,
.result-warning[open] .result-warning-summary::after,
.field-help[open] > summary::after,
.pace-zone-details[open] > summary::after { transform: rotate(-135deg); }

/* ---------- Login gate ---------- */
.gate-card { text-align: center; padding: 36px 24px; }
/* The gate is the first screen, but its heading was running at the full
   page-heading size above an equally large CTA. One notch down keeps the
   hierarchy and takes the bulk out. */
.gate-card h1 { margin-top: 0; font-size: var(--text-lg); }
.gate-card .section-desc { max-width: 440px; margin-left: auto; margin-right: auto; font-size: var(--text-sm); }
.gate-actions { max-width: 320px; margin: 20px auto 0; }
.dummy-note {
  max-width: 480px;
  margin: 16px auto 0;
  font-size: var(--text-sm);
  line-height: var(--leading-relaxed);
  color: var(--color-text-muted);
}

/* A scaled-down, made-up week block — the same header/row/badge shapes the
   real schedule uses (see .week-header and .type-badge below), so this
   screen shows what login gets you rather than only describing it in two
   paragraphs. Deliberately quiet: it sits behind the button in the visual
   order, and the caption underneath says the numbers are invented. */
/* ---------- Login gate preview carousel ---------- */
/* The frame the slides scroll inside. Sized once here so every slide is
   exactly one viewport wide and the whole thing never grows past the
   caption underneath it. */
.gate-carousel {
  max-width: 340px;
  margin: 28px auto 0;
  text-align: left;
}
/* The scroll container itself. scroll-snap does the paging, which means
   swipe on a phone, two-finger scroll on a trackpad and arrow keys in the
   focused track all work with no JS at all — js/app.js only adds the
   auto-advance and the dots. */
.gate-carousel-track {
  display: flex;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scroll-snap-type: x mandatory;
  scrollbar-width: none;
  -ms-overflow-style: none;
  border-radius: var(--radius-sm);
}
.gate-carousel-track::-webkit-scrollbar { display: none; }
.gate-carousel-track:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 3px; }
/* flex: 0 0 100% makes each slide exactly one frame wide; the default
   stretch alignment makes every slide as tall as the tallest one, so the
   carousel has a stable height without any measuring. */
.gate-slide {
  flex: 0 0 100%;
  scroll-snap-align: center;
  scroll-snap-stop: always;
  display: flex;
  flex-direction: column;
  gap: 6px;
  /* The track is an overflow-x scroll container, so it clips at its
     content edge, and this slide's own text used to start flush against
     that edge — the first glyph of each label lost about a pixel of ink
     off its left side, enough that the S of "SESI HARI INI" read as a 3.
     The inset goes here rather than on the track: padding on the scroll
     container itself narrows the slides (they are flex-basis 100% of its
     content box), which lets the next slide peek in at the right edge.
     Inside the slide, the slide stays a full viewport wide. */
  padding-inline: 2px;
}
.gate-slide-label {
  margin: 0;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--color-text-muted);
}
/* The card each slide draws inside. flex:1 so short slides fill the
   height the tallest one sets, rather than floating in it. */
.gate-mock {
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 8px;
  padding: 12px 14px;
  background: var(--color-bg);
  border: 1px solid var(--color-border);
  border-left: 3px solid var(--color-accent);
  border-radius: var(--radius-sm);
}
/* The week slide draws its own internal rules, so it keeps the padding
   on its parts instead of the box. */
.gate-mock-week { padding: 0; gap: 0; overflow: hidden; }

.gate-mock-eyebrow {
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--color-primary-dark);
}
.gate-mock-title {
  margin: 0;
  font-size: var(--text-base);
  font-weight: 800;
  letter-spacing: var(--tracking-normal);
}
.gate-mock-line { display: flex; align-items: center; gap: 10px; font-size: var(--text-sm); }
.gate-mock-muted { color: var(--color-text-muted); }
.gate-mock-caption { margin: 0; font-size: var(--text-xs); color: var(--color-text-muted); }
/* Same segmented shape as .workout-structure in the real plan — which
   means the pill and the clip live on the bar, exactly as .structure-bar
   does it, rather than a 3px radius on each segment. That is what "same
   shape" was already claiming. */
.gate-mock-bar { display: flex; gap: 2px; height: 8px; border-radius: 999px; overflow: hidden; }

.gate-chart-bars {
  display: flex;
  align-items: flex-end;
  gap: 4px;
  height: 74px;
}
.gate-chart-bars > span { flex: 1; border-radius: 3px 3px 1px 1px; }

.gate-mock-zones { gap: 0; }
.gate-zone-row {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 6px 0;
  font-size: var(--text-sm);
}
.gate-zone-row + .gate-mock-caption { margin-top: 6px; }
.gate-zone-pace { margin-left: auto; font-weight: 700; }
/* The .zone-dot rule further down is scoped to .pace-zone-table, so these
   need their own — same shape, one size smaller. */
.gate-zone-row .zone-dot { width: 9px; height: 9px; border-radius: 50%; display: inline-block; flex-shrink: 0; }

.gate-carousel-dots {
  display: flex;
  justify-content: center;
  gap: 8px;
  margin-top: 10px;
}
/* A tap target bigger than the dot it draws — the dot itself is 7px, which
   is nowhere near thumb-sized, so the button is padded out to 22px and
   paints the dot with a pseudo-element inside it. */
.gate-carousel-dot {
  width: 22px;
  height: 22px;
  padding: 0;
  border: none;
  background: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.gate-carousel-dot::before {
  content: "";
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--color-border);
  transition: background 0.2s ease, width 0.2s ease;
}
.gate-carousel-dot:hover::before { background: var(--color-text-muted); }
.gate-carousel-dot[aria-selected="true"]::before {
  width: 18px;
  /* The dot is 7px tall and grows sideways into a bar, so this is the
     same pill its 50% circle already was — 4px only looked like a
     third shape between them. */
  border-radius: 999px;
  background: var(--color-primary);
}
.gate-carousel-dot:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 0; border-radius: var(--radius-chrome); }

.gate-preview-head {
  display: flex;
  align-items: baseline;
  gap: 8px;
  padding: 10px 14px;
  border-bottom: 1px solid var(--color-border);
  font-size: var(--text-sm);
}
.gate-preview-week { font-weight: 800; }
.gate-preview-phase {
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-wide);
  /* --color-accent is the Build phase color in PHASE_COLORS (js/app.js),
     matching the "BUILD" this sample week is labelled with. */
  color: var(--color-accent);
}
.gate-preview-total { margin-left: auto; font-weight: 700; color: var(--color-primary-dark); }
.gate-preview-rows { padding: 4px 0; }
.gate-preview-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 8px 14px;
  font-size: var(--text-sm);
}
.gate-preview-day { width: 26px; color: var(--color-text-muted); }
.gate-preview-km { margin-left: auto; font-weight: 600; }
.gate-preview-caption {
  max-width: 340px;
  margin: 8px auto 0;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}

/* ---------- Login gate: exactly one screen, no page scroll ---------- */
/* The gate has one job — explain the app and offer the Strava button — and
   a visitor should be able to do that job without moving anything. Both on
   a phone and on a desktop the screen was taller than the viewport, so the
   button and the preview carousel could sit below the fold on the very
   first thing anyone sees. Instead of trimming content, the gate is laid
   out as a fixed-height column: header and footer keep their natural
   height, the card takes whatever is left, and the carousel — the one part
   with no fixed size — absorbs the difference. Only applies while
   data-screen is "gate" (see setScreen in js/app.js); every other screen
   scrolls normally. */
body[data-screen="gate"] {
  /* dvh, not vh: on mobile browsers vh counts the URL bar's hidden state,
     which would put the footer under it in exactly the case this rule
     exists to fix. */
  height: 100dvh;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}
body[data-screen="gate"] .site-header { flex: none; padding: 22px 0 30px; }
body[data-screen="gate"] main.container {
  flex: 1 1 auto;
  /* Both minimums matter: without min-height the card can't shrink inside
     the column, and without min-width/width the container — now a flex
     item — refuses to go below its content's min-content width, which
     pushed the card 60px wider than a 320px phone. */
  min-height: 0;
  min-width: 0;
  width: 100%;
  box-sizing: border-box;
  display: flex;
  /* The -28px pull that lets a card overlap the header everywhere else has
     to go here: the card is vertically centred in the remaining space, so
     the pull just slides it up into the tagline — on a 650px window the
     two touched. */
  margin-top: 0;
  padding-bottom: 0;
}
body[data-screen="gate"] .gate-card { min-width: 0; }
/* The card wraps its content and is centred in what's left of the window,
   rather than being stretched to fill it — a full-height white box with
   the content floating in the middle of it is the untidy version of the
   same centring. */
body[data-screen="gate"] main.container { align-items: center; }
body[data-screen="gate"] .gate-card {
  flex: 0 1 auto;
  max-height: 100%;
  min-height: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  margin-bottom: 0;
  /* The last resort, not the layout: everything below is sized so the card
     fits, but a very short window (or a very large text-size setting, or
     the dummy-mode note) must not clip the button. Then, and only then,
     the card's own contents scroll — the page still doesn't. */
  overflow: auto;
}
/* No stretching inside the card. Both blocks keep their natural height and
   the card centres the stack as a whole, so the leftover space shows up as
   equal margins top and bottom instead of a gap opening up in the middle
   of the content. */
body[data-screen="gate"] .gate-preview {
  /* Nothing in the card shrinks: a squeezed mock clipped its own last line
     mid-glyph, and a squeezed preview let the carousel overlap the caption
     under it. The blocks keep their height and the card's own overflow
     (above) is what gives on a window too short for them — the page still
     never scrolls. */
  flex: 0 0 auto;
  display: flex;
  flex-direction: column;
}
body[data-screen="gate"] .gate-carousel {
  flex: 0 0 auto;
  /* min-width: auto on a flex item floors at the track's min-content width,
     which is a whole slide — so the 340px cap became a 340px floor and the
     carousel hung 40px past the card's right edge on a 375px phone. */
  min-width: 0;
  width: 100%;
  display: flex;
  flex-direction: column;
  margin-top: 22px;
}
/* Capped, or on a tall phone the four mock cards stretch into half a
   screen of empty space around three lines of text. */
/* min-height as well as max: squeezed below this the mock card clips its
   own last line mid-glyph, which looks broken. At that point the card's
   own overflow (above) takes over instead — the page still never
   scrolls, and nothing is cut in half. */
body[data-screen="gate"] .gate-carousel-track { flex: 0 0 auto; min-width: 0; max-height: 260px; }
body[data-screen="gate"] .gate-carousel-dots { flex: none; }
/* The bar chart is the only mock with a hard height; let it give some of
   it back when the viewport is short rather than forcing the card taller. */
body[data-screen="gate"] .gate-chart-bars { height: auto; flex: 1 1 auto; min-height: 46px; }
body[data-screen="gate"] .site-footer { flex: none; padding: 14px 0 18px; }

/* Short windows (landscape phones, small laptops): take the remaining
   slack out of the chrome before anything starts scrolling. */
@media (max-height: 760px) {
  body[data-screen="gate"] .site-header { padding: 10px 0 12px; }
  body[data-screen="gate"] .tagline { margin-top: 4px; font-size: var(--text-sm); }
  body[data-screen="gate"] .gate-card { padding: 16px 18px 18px; }
  body[data-screen="gate"] .gate-card h1 { font-size: var(--text-md); }
  body[data-screen="gate"] .gate-actions { margin-top: 14px; }
  body[data-screen="gate"] .gate-carousel { margin-top: 12px; }
  body[data-screen="gate"] .gate-carousel-dots { margin-top: 6px; }
  /* The mock is the tallest single thing on the screen, so it is where the
     last few tens of pixels come from before anything has to scroll. */
  body[data-screen="gate"] .gate-mock { padding: 10px 12px; gap: 6px; }
  body[data-screen="gate"] .gate-preview-caption { margin-top: 6px; font-size: var(--text-xs); }
  body[data-screen="gate"] .site-footer { padding: 10px 0 12px; }
}

/* Desktop keeps the same single centred column the phone gets — the two
   are the same screen at two sizes, and the column is what this gate is.
   All the desktop needs is for the whole of it, footer included, to fit
   without scrolling, so the card is capped at a comfortable measure and
   the parts that can give height back do. */
@media (min-width: 760px) {
  body[data-screen="gate"] .gate-card {
    /* Narrower than the 900px container: a login card stretched to the
       full width of a desktop window is a banner, not a card. */
    max-width: 560px;
    width: 100%;
    margin-inline: auto;
    padding: 30px 32px 32px;
  }
}

/* ---------- Form ---------- */
/* Tiap step adalah sebuah KARTU, bukan blok teks yang dipisah garis tipis.
   Sebelumnya baris-baris step melayang di atas permukaan kartu yang sama
   dengan isinya, jadi tidak ada apa pun yang bilang "baris ini sebuah
   objek yang bisa ditekan" — terbaca sebagai daftar teks, bukan kontrol.
   Punggungnya --color-bg: konvensi panel inset di file ini (lihat catatan
   token di paling atas), yang otomatis benar di kedua tema. */
fieldset {
  /* Bawaan <fieldset> adalah `min-inline-size: min-content`, yang membuat
     kartunya melar melebihi layar begitu ada ringkasan satu baris panjang
     ("3 hari/minggu · Sel, Rab, Sab · long run Selasa") di dalamnya —
     di layar 390px kartunya jadi 401px. */
  min-width: 0;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  background: var(--color-bg);
  /* 14px, sama dengan jarak di ATAS baris pertama (margin bawah <legend>),
     supaya isi kartu duduk simetris. Pasangannya aturan :last-child di
     bawah: tanpa itu margin bawah field terakhir menumpuk di atas padding
     ini dan tiap section terbuka menyisakan ~31px kosong di bawah lawan
     14px di atas — bagian paling terlihat "tidak rapi" di form ini. */
  padding: 0 14px 12px;
  margin: 0 0 10px;
  transition: border-color 0.15s ease;
}
/* Jarak antar-baris di dalam section datang dari margin bawah .field (dan
   gap .field-row untuk baris yang wrap); di baris terakhir tidak ada lagi
   yang perlu dijarakkan, jadi yang tersisa cuma ruang kosong. */
fieldset > *:not(legend):last-child,
fieldset > *:not(legend):last-child > .field,
fieldset > .field:last-child { margin-bottom: 0; }
/* Menutup float legend (lihat catatan di aturan `legend` di bawah). Isi
   section-nya juga di-clear: header-nya sengaja lebih lebar dari kotak isi
   (full-bleed), dan tanpa clear, blok pertama sesudahnya digeser ke samping
   float itu — keluar dari kartu. */
fieldset::after { content: ""; display: table; clear: both; }
fieldset > *:not(legend) { clear: both; }
/* --color-text-muted directly: this used to name a --color-border-strong
   that is defined in neither theme, so the fallback was the only branch
   that ever ran. */
fieldset:hover { border-color: var(--color-text-muted); }
/* Terlipat = tinggal headernya, jadi padding bawahnya ikut hilang;
   kalau tidak, tiap baris ringkasan menyisakan 16px kosong di bawahnya. */
fieldset.is-collapsed { padding-bottom: 0; }
/* Section yang sedang terbuka adalah satu-satunya yang terbuka (lihat
   openOnly di js/app.js), jadi dia boleh menonjol: permukaan kartu penuh
   dan border indigo tipis, supaya jelas di mana kamu sekarang. */
fieldset:not(.is-collapsed) {
  background: var(--color-surface);
  border-color: var(--color-primary);
  /* The app's one elevation token rather than a hand-mixed near-black
     shadow, which had no dark-theme counterpart and so simply vanished
     on the dark surface this sits on. */
  box-shadow: var(--shadow);
}
/* A section header, built by renumberFieldsets in js/app.js as a numbered
   step rather than the small uppercase "1. DETAIL RACE" line it was. The
   form runs ~1750px; a bare number promised nothing about how much was
   left, so the scroll read as open-ended. Number in a circle, title at
   readable size, and how far through you are spelled out beside it. */
/* Header menjangkau selebar kartu (padding kartu ditarik balik lewat
   margin negatif) supaya area tekannya benar-benar seluruh baris.
   `float: left` bukan gaya, melainkan syarat: sebuah <legend> secara
   default digambar MENIMPA garis atas fieldset-nya, jadi begitu fieldset
   punya border + latar kartu, judulnya melayang di luar kotak. Di-float,
   legend berperilaku sebagai blok biasa di dalam kartu — karenanya
   clearfix di fieldset di atas. */
legend {
  float: left;
  width: calc(100% + 28px);
  padding: 0;
  margin: 0 -14px 12px;
}
/* The header is one full-width button: the whole row folds the section,
   with the chevron as its affordance. Same interaction the week
   accordions, the plan warnings and the field help already use, so there
   is nothing new here to learn. */
.legend-toggle {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 11px 14px;
  margin: 0;
  border: none;
  border-radius: var(--radius-sm);
  background: none;
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background 0.15s ease;
}
.legend-toggle:hover { background: var(--color-primary-light); }
/* Judul dan ringkasannya satu kolom, bukan dua ujung baris yang berjauhan:
   di layar lebar keduanya dulu terpisah ~500px ruang kosong, jadi tidak
   terbaca sebagai satu hal. Ini juga menyamakan desktop dengan bentuk
   yang sudah dipakai di mobile. */
.legend-text {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  flex: 1 1 auto;
}
.legend-toggle:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 0; }
.legend-chevron {
  margin-left: 4px;
  color: var(--color-text-muted);
}
/* Terlipat = menunjuk ke kanan ("buka aku"), terbuka = menunjuk ke bawah
   (nilai dasar .chevron). Ini satu-satunya chevron di app yang punya
   keadaan "kanan"; sisanya bawah/atas. */
fieldset.is-collapsed .legend-chevron { transform: rotate(-45deg); }
/* Everything except the header goes; no wrapper element needed around the
   fields, which keeps index.html's markup exactly as it was.
   The class is doubled to raise specificity: .field-row's own display is
   set by `.field-row:not(:has(.field-small))` in the subgrid block above,
   which carries two classes and would otherwise win and leave a folded
   section's rows still on screen. Same trick as .type-badge.type-badge-quiet
   further down, and preferred to !important for the same reason. */
fieldset.is-collapsed.is-collapsed > *:not(legend) { display: none; }
fieldset.is-collapsed > legend { margin-bottom: 0; }
/* Ringkasan hanya muncul saat terlipat: section yang sedang terbuka sudah
   menunjukkan field-nya sendiri, jadi mengulangnya di header cuma bising. */
.legend-summary { display: none; }
fieldset.is-collapsed .legend-summary {
  display: block;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: var(--text-sm);
  font-weight: 500;
  color: var(--color-text-muted);
}
/* A section with nothing in it yet says so, rather than folding away to a
   blank line that reads as broken. Italic so "Belum diisi" is visibly a
   state and not a value someone typed. */
.legend-summary.is-empty { font-style: italic; opacity: 0.75; }
/* Section yang memang tidak wajib: chip netral kecil, bukan "Belum diisi"
   miring yang terbaca seperti ada yang kurang. */
fieldset.is-collapsed .legend-summary.is-optional {
  align-self: flex-start;
  padding: 2px 8px;
  border: 1px solid var(--color-border);
  border-radius: 999px;
  font-size: var(--text-xs);
  font-weight: 600;
  letter-spacing: var(--tracking-normal);
  font-style: normal;
  opacity: 1;
}
.legend-step {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  /* Belum selesai = lingkaran netral bernomor; selesai = lingkaran hijau
     bercentang (.is-done di bawah). Satu bentuk di satu posisi yang
     berganti isi — sebelumnya nomor indigo dan centang hijau terbaca
     sebagai dua jenis benda yang berbeda di dalam satu daftar. */
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  color: var(--color-text-muted);
  font-size: var(--text-sm);
  font-weight: 800;
  transition: background 0.15s ease, color 0.15s ease;
}
/* Filled in once every required field in the section has a value — see
   updateFieldsetProgress, which is deliberately careful about what it
   claims. The number is replaced by a tick rather than sitting beside
   one, so the circle stays the same size either way and the row of
   headers doesn't shift as you fill the form in. */
fieldset:not(.is-collapsed) .legend-step {
  background: var(--color-primary);
  border-color: var(--color-primary);
  color: #fff;
}
.legend-step.is-done,
fieldset:not(.is-collapsed) .legend-step.is-done {
  background: var(--color-accent);
  border-color: var(--color-accent);
  color: #fff;
  font-size: 0;
}
.legend-step.is-done::after {
  content: "";
  width: 13px;
  height: 13px;
  background: currentColor;
  -webkit-mask: var(--i-check) no-repeat center / contain;
  mask: var(--i-check) no-repeat center / contain;
}
.legend-title {
  font-size: var(--text-base);
  font-weight: 800;
  letter-spacing: var(--tracking-normal);
  color: var(--color-text);
}
/* Pushed to the far end and quiet — it is a position report, not part of
   the heading. Hidden on a phone, where the row has no width to spare and
   the numbered circle already carries the sequence. */
/* Kemajuan seluruh form, sekali di atas daftar step — menggantikan
   "Langkah x dari y" yang dulu diulang di setiap baris. Penyebutnya hanya
   section yang bisa benar/salah, jadi mode yang menyembunyikan section
   (First-timer) otomatis punya total yang lebih kecil. */
.form-progress { margin: 0 0 14px; }
.form-progress-track {
  height: 5px;
  border-radius: 999px;
  background: var(--color-bg);
  overflow: hidden;
}
.form-progress-track > span {
  display: block;
  height: 100%;
  width: 0;
  border-radius: 999px;
  background: var(--color-primary);
  transition: width 0.25s ease, background-color 0.25s ease;
}
.form-progress-text {
  margin: 8px 0 0;
  font-size: var(--text-sm);
  font-weight: 600;
  color: var(--color-text-muted);
}
.form-progress-text.is-complete { color: var(--color-accent); }
.form-progress-text.is-complete ~ * { color: inherit; }
.form-progress:has(.is-complete) .form-progress-track > span { background: var(--color-accent); }

.field-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 12px;
}
.field { margin-bottom: 12px; display: flex; flex-direction: column; }
/* The 220px and 90px are WIDTHS — how much of a multi-column .field-row
   each field asks for — so they have to be scoped to that row. Left
   unscoped they also applied inside the column-direction panels
   (.breaks-form and friends), where flex-basis means HEIGHT: every field
   in "Ubah hari latihan" was being inflated to a 220px-tall box holding
   about 85px of label and chips, which is where the huge empty gaps
   between those fields came from. */
.field-row .field { flex: 1 1 220px; }
.field-row .field-small { flex: 0 1 90px; }

/* A label is the question; the value in the box below it is the answer,
   and the answer is what a runner scans for when checking their numbers
   over. Both used to be 600 weight in full --color-text, so every row
   carried the same weight and there was nothing to sweep your eye down.
   Deliberately NOT --color-text-muted, though: these labels are whole
   Indonesian sentences ("Rata-rata jarak lari per minggu saat ini"), not
   one-word field names, so they genuinely have to be read at least once.
   82% of the text color steps back without going quiet. */
label {
  display: block;
  font-weight: 500;
  font-size: var(--text-sm);
  letter-spacing: var(--tracking-normal);
  color: color-mix(in srgb, var(--color-text) 82%, transparent);
  margin-bottom: 6px;
}

/* Keeping the controls in a multi-column row lined up used to be done by
   reserving two lines of height on EVERY label (min-height: 3.1em), so a
   one-line label wouldn't leave its input sitting higher than one next to
   a wrapped label. It worked, but it charged every field ~21px of empty
   space for a wrap that, measured across 620–1040px, never actually
   happens: every label in this form renders on one line, and the one row
   where they do wrap wraps both of them together.
   Subgrid does the same job for nothing: the label / control / hint rows
   belong to the ROW, not to each field, so the controls line up whatever
   the labels above them do, and a one-line label takes one line.
   Rows containing .field-small are left on the flex fallback — those are
   the hour/minute/second triplets, whose labels are single words that
   cannot wrap, and whose columns are deliberately unequal widths. */
@supports (grid-template-rows: subgrid) {
  .field-row:not(:has(.field-small)) {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
    grid-template-rows: auto auto auto;
    align-items: start;
    column-gap: 16px;
    /* Explicitly zero, because the rows here are label / control / hint —
       parts of one field, not separate fields. Inheriting the flex
       fallback's 16px gap put that much air between every label and its
       own input. Spacing between fields comes from .field's own margin
       and the label's, as it does everywhere else. */
    row-gap: 0;
  }
  .field-row:not(:has(.field-small)) > .field {
    display: grid;
    grid-template-rows: subgrid;
    grid-row: span 3;
  }
}

input[type="text"],
input[type="number"],
input[type="date"],
select,
textarea {
  width: 100%;
  /* ~45px tall. Still a comfortable phone target, one notch down from
     the 51px this used to run at — at that height the form scrolled far
     longer than it had content for. */
  padding: 11px 14px;
  /* The border is transparent, not absent: .has-error and :focus below
     both paint it, and a border that appears from nowhere would shift the
     control by a pixel as it did. At rest the fill alone marks the field
     out — it used to also carry a --color-border hairline, which against
     a --color-bg fill is very nearly the same value in both themes, so it
     drew a slab with a faint dirty edge rather than a clean inset. */
  border: 1px solid transparent;
  border-radius: var(--radius-control);
  font-size: var(--text-base);
  font-family: inherit;
  /* The other half of the hierarchy the label rule above sets up: the
     value a runner typed is the thing worth scanning, so it is the
     heavier of the two. */
  font-weight: 600;
  background: var(--color-bg);
  color: var(--color-text);
}
textarea { resize: vertical; min-height: 60px; }
/* iOS Safari shrink-wraps a date input to whatever its own value renders
   as ("26 Oct 2026") and ignores the width:100% above, so on a phone these
   came out as stubby boxes underneath full-width selects — the one place
   on the form where two controls in the same column had visibly different
   widths. Dropping the native appearance makes them honor that width like
   every other field; the min-height then restores the row height the
   padding gives a text input, which the stripped-down control no longer
   works out on its own. */
input[type="date"] {
  -webkit-appearance: none;
  appearance: none;
  min-height: 51px;
}
/* Normal weight, unlike the control's own 600: a placeholder is an
   example of what to type, not something the runner has typed. */
input::placeholder, textarea::placeholder { color: var(--color-text-muted); opacity: 1; font-weight: 400; }
input:focus, select:focus, textarea:focus {
  outline: none;
  border-color: var(--color-primary);
  box-shadow: 0 0 0 3px var(--color-primary-light);
}

/* Custom checkbox & radio (replaces native browser controls) */
input[type="checkbox"],
input[type="radio"] {
  appearance: none;
  -webkit-appearance: none;
  width: 18px;
  height: 18px;
  min-width: 18px;
  margin: 0;
  border: 1.5px solid var(--color-border);
  background: var(--color-bg);
  display: inline-grid;
  place-content: center;
  cursor: pointer;
  transition: background 0.15s, border-color 0.15s;
}
input[type="checkbox"] { border-radius: var(--radius-chrome); }
input[type="radio"] { border-radius: 50%; }
input[type="checkbox"]::before,
input[type="radio"]::before {
  content: "";
  background: #fff;
  transform: scale(0);
  transition: transform 0.12s ease-in-out;
}
input[type="checkbox"]::before {
  width: 10px;
  height: 10px;
  clip-path: polygon(14% 44%, 0 65%, 50% 100%, 100% 16%, 80% 0%, 45% 62%);
}
input[type="radio"]::before { width: 8px; height: 8px; border-radius: 50%; }
input[type="checkbox"]:checked,
input[type="radio"]:checked {
  background: var(--color-primary);
  border-color: var(--color-primary);
}
input[type="checkbox"]:checked::before,
input[type="radio"]:checked::before { transform: scale(1); }
input[type="checkbox"]:focus-visible,
input[type="radio"]:focus-visible {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}

/* Chip toggles (mode/day) carry their own checked styling on the pill —
   the underlying input stays functional but visually hidden. */
.mode-chip input,
.day-chip input {
  position: absolute;
  width: 1px;
  height: 1px;
  /* Off the spacing grid on purpose, and not a spacing value at all: this
     is the visually-hidden idiom, where the negative margin cancels the
     1px box directly above it. It moves with that box, not with the
     grid. */
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.mode-chip:has(input:focus-visible),
.day-chip:has(input:focus-visible) {
  outline: 2px solid var(--color-primary);
  outline-offset: 2px;
}
.field-hint {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  margin: 4px 0 0;
}

/* ---------- Foldable field help ---------- */
/* The three longest .field-hint paragraphs on this form ran to three or
   four lines each and were re-read on every visit by everyone, including
   the runner who already knows perfectly well why their weekly mileage is
   being asked for. Nothing is removed — the same words are one tap away,
   and the summary line says what is behind it. Short hints stay inline:
   the point is to fold away the paragraphs, not to hide the labels. */
.field-help { margin: 6px 0 14px; font-size: var(--text-sm); }
.field-help > summary {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  list-style: none;
  cursor: pointer;
  color: var(--color-text-muted);
  width: fit-content;
  padding: 2px 0;
}
.field-help > summary::-webkit-details-marker { display: none; }
.field-help > summary:hover { color: var(--color-text); }
/* Bentuk & rotasinya ikut blok chevron bersama (lihat "Disclosure
   reveal"); yang khas di sini cuma redupnya. */
.field-help > summary::after { content: ""; opacity: 0.7; }
.field-help > summary:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; border-radius: var(--radius-chrome); }
.field-help p { margin: 6px 0 0; color: var(--color-text-muted); line-height: var(--leading-relaxed); }

.strava-fill-badge {
  display: inline-block;
  font-size: var(--text-sm);
  font-weight: 600;
  color: var(--color-accent);
  background: var(--color-accent-light);
  border: 1px solid var(--color-accent);
  border-radius: var(--radius-item);
  padding: 6px 10px;
  margin: 8px 0 0;
}

.checkbox-field { margin-bottom: 10px; }
.checkbox-option { display: flex; align-items: flex-start; gap: 10px; cursor: pointer; }
.checkbox-option input { margin-top: 2px; flex-shrink: 0; }
.checkbox-option-title { display: block; font-weight: 600; }
.checkbox-option-desc { display: block; font-weight: 400; font-size: var(--text-sm); color: var(--color-text-muted); margin-top: 2px; }

.mode-toggle { display: flex; flex-wrap: wrap; gap: 7px; margin-bottom: 12px; }
/* That margin is for a hint or another control following the chips —
   when the chips are the last thing in their field it is just trailing
   space, doubled up with the field's own spacing underneath. */
.field > .mode-toggle:last-child { margin-bottom: 0; }
.mode-chip {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  border: 1px solid var(--color-border);
  border-radius: 999px;
  padding: 7px 12px;
  font-weight: 600;
  font-size: var(--text-sm);
  cursor: pointer;
  margin: 0;
  user-select: none;
  transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.mode-chip input { width: auto; margin: 0; }
.mode-chip:has(input:checked) {
  background: var(--color-primary);
  border-color: var(--color-primary);
  color: #fff;
}
/* A locked chip group — today only #daysPerWeekToggle, pinned to 3 days
   for the First-timer program (see updateGoalTypeUI in js/app.js). The
   inputs are genuinely disabled; this is what stops the chips still
   looking tappable, since .mode-chip carries all its own styling and knows
   nothing about the input's state otherwise. */
.mode-toggle.is-disabled { opacity: 0.55; }
.mode-toggle.is-disabled .mode-chip { cursor: not-allowed; }

.day-checkboxes { display: flex; flex-wrap: wrap; gap: 8px; }
.day-chip {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  border: 1px solid var(--color-border);
  border-radius: 999px;
  padding: 7px 12px;
  font-weight: 600;
  font-size: var(--text-sm);
  cursor: pointer;
  margin: 0;
  user-select: none;
  transition: background 0.15s, border-color 0.15s, color 0.15s;
}
.day-chip input { width: auto; margin: 0; }
.day-chip:has(input:checked) {
  background: var(--color-primary);
  border-color: var(--color-primary);
  color: #fff;
}

.form-error, .form-warning {
  border-radius: var(--radius-item);
  padding: 12px 14px;
  font-size: var(--text-base);
  margin: 14px 0;
}
.form-error {
  background: var(--color-error-bg);
  color: var(--color-error);
  border: 1px solid var(--color-error-border);
}
/* Marks the one input a form error is actually about (see showError in
   js/app.js, which also scrolls to and focuses it) — the message itself
   is pinned below the whole form, too far from the box in question to
   point at it on its own. Also applied to the day-chip group, which is a
   container rather than a single input: the outline is the point there,
   the focus() is a no-op. */
/* A box-shadow ring rather than an outline, so the same rule works on a
   bordered <input> and on an un-bordered container div (the day-chip
   group) alike. The :focus variant has to be spelled out because showError
   focuses the field it marks, and `input:focus` above — which kills the
   outline and paints its own primary-colored ring — outranks a bare class
   on specificity, so without it every error ring would render as the
   ordinary blue focus ring. */
.has-error {
  border-color: var(--color-error);
  box-shadow: 0 0 0 2px var(--color-error);
  border-radius: var(--radius-control);
}
input.has-error:focus,
select.has-error:focus,
textarea.has-error:focus {
  border-color: var(--color-error);
  box-shadow: 0 0 0 3px var(--color-error);
}
.form-warning {
  background: var(--color-warning-bg);
  color: var(--color-warning);
  border: 1px solid var(--color-warning-border);
}

/* inline-flex, not inline-block: every button label is now icon + text,
   and baseline-aligning an SVG next to text is guesswork where centering
   two flex children is not. */
/* ---------- Buttons ---------- */
/* SHAPE RULE, for everything in this file that can be pressed:
     - a pill (999px) if it carries a text label — every .btn at every
       size, the mode/day chips, the swap-mode toggle;
     - a circle if it is an icon on its own — .theme-toggle, .swap-day-btn;
     - a rounded rectangle ONLY for things that are not buttons — inputs,
       cards, panels, the rows inside a dropdown menu.
   Before this, .btn was 8px but .btn-large was 999px, so two buttons with
   the identical role came out different shapes purely because of their
   size — and the pill was simultaneously meaning "marquee CTA" and "pick
   one of these" on the chips. Shape said nothing you could rely on. It
   does now. */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  border: none;
  border-radius: 999px;
  padding: 12px 20px;
  font-size: var(--text-base);
  font-weight: 600;
  letter-spacing: var(--tracking-normal);
  cursor: pointer;
  font-family: inherit;
  transition: transform 0.1s, filter 0.15s, background 0.15s, border-color 0.15s;
}
.btn:hover { transform: translateY(-1px); }
.btn:active { transform: translateY(1px); }
.btn-primary { background: var(--color-primary); color: #fff; }
.btn-primary:hover { filter: brightness(1.06); }
/* Strava's orange (#FC4C02), deliberately outside this file's palette and
   its token system — it is Strava's brand color, not one of ours, and
   their guidelines for API apps ask that the "Connect with Strava" button
   be recognisably theirs rather than restyled into the host app's look.
   Same reason its label stays in English while everything around it is
   Indonesian. Used for that one button and nothing else. */
.btn-strava { background: #fc4c02; color: #fff; }
.btn-strava:hover { filter: brightness(1.08); }
.btn-strava .icon { width: 1.15em; height: 1.15em; }
/* The two marquee calls-to-action (Strava login, generate plan). What
   marks them out is full width, a solid fill and the extra size — the
   radius no longer does any of that work, since every button is a pill
   now (see the shape rule above); it is repeated here only so this rule
   still stands on its own if .btn's own radius ever changes. */
.btn-large { width: 100%; padding: 13px 18px; font-size: var(--text-base); margin-top: 10px; border-radius: 999px; }
/* Neutral, not accent-colored: this is a plain secondary action (export a
   PDF, open the adjust-schedule panel), not something the app filled in
   automatically — see the --color-accent comment in :root. Sits one step
   up from .btn-ghost (a visible fill instead of just an outline) without
   borrowing a color that's supposed to mean something else. */
.btn-secondary { background: var(--color-bg); color: var(--color-text); border: 1px solid var(--color-border); }
.btn-secondary:hover { border-color: var(--color-primary-dark); }
/* A toggle that opens a panel stays lit while that panel is open. Without
   it the only sign that "Lagi nggak fit?" is the thing you just opened is
   the panel itself, further down the page — on a phone the button can be
   scrolled out of view entirely, and coming back to it, it looks
   untouched. Keyed off aria-expanded so the visual state and the state
   screen readers are told can't drift apart. */
.btn-secondary[aria-expanded="true"] {
  background: var(--color-primary);
  border-color: var(--color-primary-dark);
  color: #fff;
}
.btn-secondary[aria-expanded="true"]:hover { background: var(--color-primary-dark); border-color: var(--color-primary-dark); }
.btn-ghost { background: transparent; color: var(--color-text-muted); border: 1px solid var(--color-border); }
.btn-ghost:hover { border-color: var(--color-text-muted); color: var(--color-text); }
.btn-small { padding: 8px 14px; font-size: var(--text-sm); }
.btn:disabled { opacity: 0.6; cursor: wait; }

/* ---------- Overflow menu (result actions) ---------- */
/* See .result-actions in index.html. Plain absolute positioning rather
   than a <dialog> or popover: this needs to open under its own button
   inside a normal card, and it is three buttons — the open/close/dismiss
   logic it does need is a dozen lines in js/app.js. */
.actions-more { position: relative; }
.actions-more-caret { font-size: 0.75em; opacity: 0.7; }
.actions-menu {
  position: absolute;
  top: calc(100% + 6px);
  right: 0;
  z-index: 20;
  /* Wide enough for the longest title ("Pakai sisa waktu sebelum race")
     and its description to sit on one line each — wrapped, they threw
     the menu's row rhythm out. */
  min-width: 324px;
  display: flex;
  flex-direction: column;
  padding: 8px;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  box-shadow: var(--shadow);
  /* Nine rows with descriptions is taller than a short laptop window.
     Capped and scrolled rather than left to run off the bottom, where
     "Buat Plan Baru" would simply be unreachable. */
  max-height: min(76vh, 560px);
  overflow-y: auto;
  overscroll-behavior: contain;
  animation: actions-menu-in 0.14s ease-out;
}
@keyframes actions-menu-in {
  from { opacity: 0; transform: translateY(-6px); }
  to { opacity: 1; transform: none; }
}
/* The group headings. Deliberately small and quiet: they are signposts
   scanned on the way to an item, not items themselves. */
.actions-menu-label {
  margin: 10px 10px 4px;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--color-text-muted);
}
.actions-menu-label:first-child { margin-top: 2px; }
.actions-menu-group { display: contents; }
/* display:contents / display:block above out-rank the UA's own
   [hidden] rule, so the JS that empties a group (syncActionsMenuGroups)
   would otherwise hide nothing. */
.actions-menu-group[hidden], .actions-menu-label[hidden] { display: none; }
/* Only the reset group gets a rule above it — one hard line in the menu,
   between "things that keep your plan" and the one thing that doesn't. */
.actions-menu-group-last {
  display: block;
  margin-top: 8px;
  padding-top: 8px;
  border-top: 1px solid var(--color-border);
}
.actions-menu-item {
  display: flex;
  align-items: center;
  gap: 10px;
  width: 100%;
  padding: 7px 9px;
  border: none;
  border-radius: var(--radius-item);
  background: none;
  color: var(--color-text);
  font: inherit;
  text-align: left;
  cursor: pointer;
}
/* The icon tile. What makes eight rows tellable apart at a glance is the
   tinted square, not the 24px glyph inside it — at menu size the glyphs
   alone (three of which are calendars) all read as "small grey mark". */
.actions-menu-icon {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 28px;
  height: 28px;
  border-radius: var(--radius-item);
  background: var(--color-primary-light);
  color: var(--color-primary-dark);
}
.actions-menu-icon .icon { width: 15px; height: 15px; vertical-align: 0; }
.actions-menu-text { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.actions-menu-title { font-size: var(--text-sm); font-weight: 600; line-height: var(--leading-snug); }
.actions-menu-desc {
  font-size: var(--text-xs);
  font-weight: 500;
  line-height: var(--leading-snug);
  color: var(--color-text-muted);
}
.actions-menu-item:hover { background: var(--color-bg); }
.actions-menu-item:hover .actions-menu-icon { background: var(--color-primary); color: #fff; }
.actions-menu-item:focus-visible { outline: 2px solid var(--color-primary); outline-offset: -2px; }
/* .btn:disabled doesn't reach these — they are menu rows, not buttons in
   the .btn family — and the PDF export does disable its own row while it
   works (see downloadPlanAsPdf). */
.actions-menu-item:disabled { opacity: 0.6; cursor: wait; }
/* Not an error state — nothing has gone wrong — but the one item that
   discards work, so it carries the warning palette rather than the
   indigo every reversible item above it uses. */
.actions-menu-item-reset .actions-menu-icon {
  background: var(--color-error-bg);
  color: var(--color-error);
}
.actions-menu-item-reset:hover .actions-menu-icon { background: var(--color-error); color: #fff; }
.actions-menu-item-reset .actions-menu-title { color: var(--color-error); }
@media (prefers-reduced-motion: reduce) {
  .actions-menu { animation: none; }
}

/* ---------- Sticky form submit ---------- */
/* Full-bleed inside the card's own padding, so the bar's background spans
   the card edge to edge once it sticks. The gradient rather than a flat
   fill is what keeps the field under it legible-but-clearly-behind as it
   scrolls past, instead of being chopped off at a hard line. */
.form-submit-bar { margin-top: 4px; }
@media (max-width: 600px) {
  .form-submit-bar {
    position: sticky;
    bottom: 0;
    z-index: 10;
    margin: 0 -16px -24px;
    /* env() so the button clears the home indicator on a gesture-nav
       phone instead of sitting under it; the fallback is the plain 14px
       everywhere that reports no inset. */
    padding: 26px 16px calc(14px + env(safe-area-inset-bottom, 0px));
    /* rgba() of --color-surface's own value rather than `transparent`:
       in Safari `transparent` interpolates through transparent BLACK,
       which draws a dark smear across the top of the fade. */
    background: linear-gradient(to top, var(--color-surface) 0%, var(--color-surface) 58%, var(--surface-fade) 100%);
  }
  .form-submit-bar .btn-large { margin-top: 0; padding: 12px 18px; }
}

/* ---------- AI enhancement ---------- */
.ai-status-row {
  display: flex;
  align-items: center;
  gap: 10px;
  /* Was 10px, then 18px — the action-button row above and the AI
     status/intro block below were reading as one dense cluster; a bit
     more air separates "things you can do" from "what the app found out"
     without adding any new visual furniture. 18px was still within a
     rounding error of the 16px between this line and the note it
     introduces, so all four rows read as one undifferentiated stack. The
     status line and the note are one thing (who wrote this, and what they
     wrote); the gap that separates them from the buttons has to be
     visibly the larger of the two. */
  margin: 26px 0 0;
  min-height: 1.3em;
}
.ai-status {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  font-style: italic;
  margin: 0;
}
.ai-status.is-error { color: var(--color-error); font-style: normal; }
.btn-link {
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  font-size: var(--text-sm);
  font-weight: 700;
  color: var(--color-accent);
  cursor: pointer;
  font-family: inherit;
  text-decoration: underline;
}
.btn-link:hover { filter: brightness(1.1); }
/* The way back out of "Buat Plan Baru" when it was a mis-click (see
   backToPlanBtn in js/app.js). Dulu ini .btn-link abu-abu polos tanpa
   garis bawah dan nyaris tak terlihat — user tidak sadar plan lamanya
   masih ada dan form ini bisa ditinggalkan. Sekarang memakai .btn
   .btn-secondary .btn-small apa adanya: bentuk pil, border netral, ukuran
   kecil. Sengaja BUKAN --color-accent meski itu bikin lebih mencolok —
   warna itu dicadangkan untuk "app yang mengisi ini otomatis" (lihat
   komentar --color-accent di :root), sedangkan ini aksi navigasi biasa.
   Yang bikin sadar di sini bentuk tombolnya, bukan warnanya. Rule ini
   tinggal urusan penempatan. */
.back-to-plan {
  align-self: flex-start;
  margin-bottom: 18px;
}
/* A left rule, not a filled box. As a tinted panel with a border this was
   the loudest thing on the result screen — a teal block competing with
   the indigo hero above it, the phase colours below it and the amber
   warnings between, on a screen that already has more accent colours than
   it needs. The words are worth reading; the container around them was
   not. Same treatment .result-warning already uses, and the rule keeps
   the accent so "the app wrote this" still reads at a glance. */
.ai-intro, .ai-tips {
  background: none;
  border: none;
  border-left: 3px solid var(--color-accent);
  border-radius: 0;
  padding: 2px 0 2px 14px;
  font-size: var(--text-sm);
  line-height: var(--leading-relaxed);
  margin: 16px 0;
}
/* The icon keeps the accent even though the text around it no longer
   does — it is the part that says who wrote this. */
.ai-intro::before, .ai-tips::before { color: var(--color-accent); }
.ai-tips-title { font-weight: 700; display: block; margin-bottom: 4px; }
.week-ai-note {
  background: var(--color-accent-light);
  color: var(--color-accent);
  border-top: 1px solid var(--color-border);
  padding: 10px 16px;
  font-size: var(--text-sm);
  font-style: italic;
}

/* Race-day pacing/nutrition/mental tips — same visual language as .ai-tips
   (it's the same kind of AI-written advice), but appended straight into
   the race week's own <details> block (see applyPendingAiReviewToDom in
   js/app.js) instead of floating in a separate card below the whole
   schedule, so it reads as part of that week rather than disconnected
   from it. Margin trimmed to sit flush against the block's own padded
   edges instead of .ai-tips's free-floating 16px 0. */
.week-race-tips {
  background: none;
  border: none;
  border-left: 3px solid var(--color-accent);
  border-radius: 0;
  padding: 2px 0 2px 14px;
  font-size: var(--text-sm);
  line-height: var(--leading-relaxed);
  margin: 12px 16px 16px;
}
.week-race-tips::before { color: var(--color-accent); }

/* ---------- Results ---------- */
/* This section keeps the .card class for its siblings' sake but drops
   everything that made it a card. It was a white box containing grey
   panels containing their own boxes — four levels of surface deep,
   measured, which was the strongest "old layout" signal the app had. The
   panels below ARE the cards now, and they sit straight on the page.
   The form and the gate keep their card, and that difference is the
   point, not an inconsistency: a form is one object you fill in, while
   this screen is a feed of unrelated things — today, some totals, a
   chart, a list of weeks — that were never one object to begin with. */
.result-card {
  background: none;
  box-shadow: none;
  padding: 0;
  margin-bottom: 0;
}
/* The shared LIFT for the five panels of the result screen: one shadow, so
   they read as one family of blocks sitting on the page rather than five
   separately-tuned boxes.
   Backgrounds are deliberately NOT set here. This rule used to declare
   `background: var(--color-surface)` for all five, but four of them
   overrode it further down — .summary-item and .volume-chart back onto
   --color-bg (they are wells, and their edge token says so), and
   .today-card draws its own --today-wash gradient. .week-block and
   .pace-legend take the surface, so they say that on their own rules and
   this one stops claiming something its other selectors contradict. */
.today-card,
.summary-item,
.volume-chart,
.pace-legend,
.upcoming-panel,
.week-block {
  box-shadow: var(--shadow);
}

/* The top margin is load-bearing, not decoration. `main` is pulled up by
   -28px so a card tucks under the header band, and the h2 that used to
   absorb that pull is display:none on this screen (the app bar names it
   now). With both, the action row landed at exactly the header title's
   bottom edge — 0px between them, the buttons riding up into the header's
   own tint. The phone layout restates its own spacing instead: there
   .result-header is display:contents and this margin never applies. */
.result-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  flex-wrap: wrap;
  gap: 12px;
  margin-top: 22px;
}
.result-header h2 {
  margin: 0;
  font-weight: 800;
  letter-spacing: var(--tracking-tight);
}
/* Four actions of identical weight used to wrap 3-and-1 and read as a
   toolbar of equals. They aren't: "Lagi nggak fit?" is the one a runner
   reaches for mid-block, the other three are occasional. So the set is
   compact icon+label chips at a smaller size than body buttons, the
   frequent one carries the tinted variant, and the whole row sits beside
   the heading instead of under it wherever there's room. */
.result-actions { display: flex; gap: 8px; flex-wrap: wrap; }
/* A notch smaller than a body-text button. This row sits above the plan
   as a pair of standing controls, not a call to action — at .btn's full
   size the two of them read as the loudest thing on the results screen,
   ahead of the plan they act on. */
.result-actions .btn {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 8px 14px;
  font-size: var(--text-sm);
}
.result-actions .btn .icon { width: 16px; height: 16px; }
.result-actions .btn .icon { opacity: 0.75; }
.result-actions .btn:hover .icon { opacity: 1; }
/* The mid-block action, marked out from its neighbours by tint alone —
   still a secondary button, just not an anonymous one. */
#feelingOffBtn {
  background: var(--color-primary-light);
  border-color: var(--color-primary);
  color: var(--color-primary-dark);
}
/* Open state. Needs its own ID-level rule: the tint above is an ID
   selector, so the generic .btn-secondary[aria-expanded="true"] rule
   loses to it on specificity and the button stayed tinted-but-not-lit
   while its panel was open. */
#feelingOffBtn[aria-expanded="true"] {
  background: var(--color-primary);
  border-color: var(--color-primary-dark);
  color: #fff;
}
#feelingOffBtn[aria-expanded="true"]:hover { background: var(--color-primary-dark); }
#feelingOffBtn[aria-expanded="true"] .icon { opacity: 1; }

/* ---------- Left-rule prose blocks ----------
   .result-warning, .ai-intro/.ai-tips and .week-race-tips are one family:
   a 3px accent rule, no fill, two or three paragraphs of explanation that
   say the same thing on every visit for the life of the block. They sit
   one step below body copy on purpose. The scale pass first mapped them
   to --text-base with the rest of the 0.88-0.92 band, and at 16px over
   1.65 leading a block this long stops reading as a footnote and starts
   competing with the plan it is annotating. Length is what decides here,
   not the old number: keep all four on --text-sm together. */
/* ---------- Plan warnings (collapsible) ---------- */
/* A left rule instead of a filled yellow block: these are two or three
   paragraphs long and appear on every visit for the life of the block, so
   a full-bleed warning fill made the whole page feel like an alert. See
   renderPlan for why it opens by default. */
.result-warning {
  margin: 18px 0;
  padding: 0 0 0 14px;
  border-left: 3px solid var(--color-warning);
  font-size: var(--text-sm);
  line-height: var(--leading-relaxed);
}
.result-warning-summary {
  display: flex;
  align-items: center;
  gap: 8px;
  list-style: none;
  cursor: pointer;
  color: var(--color-warning);
  font-weight: 600;
  padding: 2px 0;
}
.result-warning-summary::-webkit-details-marker { display: none; }
/* Chevron, rotating on open — same affordance the week accordions use. */
.result-warning-summary::after {
  content: "";
  margin-left: 2px;
  opacity: 0.7;
}
.result-warning-body { color: var(--color-text-muted); }
.result-warning-body p { margin: 8px 0 0; }
/* The one warning that has something the runner can act on right there
   (see renderPlan's prepOffer) — spaced off the text, and separated by a
   rule, so it reads as an answer to the notes above rather than another
   line of them. */
.result-warning-action {
  margin-top: 16px;
  padding-top: 14px;
  border-top: 1px solid var(--color-warning-border);
}

/* Opened by #feelingOffBtn — see handleFeedbackSubmit in js/app.js. */
.feedback-panel { margin: 16px 0; display: flex; flex-direction: column; gap: 10px; }

.feedback-panel textarea { width: 100%; resize: vertical; }
.feedback-actions { display: flex; gap: 10px; }

/* The running-adjustments list's own heading row. Sized between
   .breaks-panel h3 and .breaks-intro: it labels a section inside the
   panel, not the panel itself, so it must not compete with the "Lagi
   nggak fit?" title above it. */
.adjust-list-head { display: flex; align-items: center; gap: 8px; }
.adjust-list-head h4 { margin: 0; font-size: var(--text-sm); font-weight: 600; }
.adjust-count {
  font-size: var(--text-xs);
  font-weight: 600;
  line-height: 1;
  padding: 4px 8px;
  border-radius: 999px;
  background: var(--color-primary-light);
  color: var(--color-primary-dark);
}

/* Same pill on #feelingOffBtn out on the results header, where it's the
   page's only indicator that adjustments are live. Inherits the button's
   own color rather than the accent used inside the panel — on a tinted
   secondary button an accent pill reads as a second, unrelated control. */
.btn-badge {
  font-size: var(--text-xs);
  font-weight: 700;
  line-height: 1;
  min-width: 17px;
  padding: 4px 6px;
  border-radius: 999px;
  text-align: center;
  background: var(--color-primary);
  color: #fff;
}
/* On a lit-up toggle the badge's own fill is the same indigo as the
   button behind it, so it inverts rather than vanishing. */
.btn[aria-expanded="true"] .btn-badge { background: #fff; color: var(--color-primary-dark); }

/* Quick-fill shortcuts above the date range. Wrap rather than scroll:
   on a narrow phone all four still need to be reachable without a
   horizontal swipe most people won't discover. */
.feedback-presets { display: flex; flex-wrap: wrap; gap: 6px; }

/* One running adjustment in #feedbackList. Reuses .break-item's shell
   (same shape, same remove affordance on the right) and only adds the
   parts a break doesn't have: the runner's own note, quoted back so they
   can tell two adjustments over the same week apart. */
.adjust-item-note {
  display: block;
  color: var(--color-text-muted);
  font-size: var(--text-sm);
  font-style: italic;
  margin-top: 2px;
}
.feedback-status { font-size: var(--text-sm); color: var(--color-accent); margin: 0; }
.feedback-status.is-error { color: var(--color-error); }

/* Calendar export panel (see #calendarPanel in index.html) — the two
   options stack rather than sitting side by side even on desktop: they're
   a choice between two workflows, not two buttons of equal weight, and
   the explanation under each is what actually decides it. */
.calendar-panel { margin: 16px 0; display: flex; flex-direction: column; gap: 18px; }
.calendar-option h3 { margin: 0 0 4px; font-size: var(--text-base); }
.calendar-option p { margin: 0 0 10px; font-size: var(--text-sm); color: var(--color-text-muted); line-height: var(--leading-relaxed); }
/* The feed URL is long and unbreakable — wrap it anywhere rather than
   letting it push the card wider than the phone screen, and make one tap
   select the whole thing for anyone whose browser blocked the automatic
   clipboard copy. */
.calendar-url {
  margin: 10px 0 0;
  padding: 8px 10px;
  background: var(--color-bg);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-chrome);
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: var(--text-xs);
  line-height: var(--leading-normal);
  color: var(--color-text-muted);
  word-break: break-all;
  user-select: all;
}

/* Auto-shown suggestion, not a runner-opened panel — deliberately the
   error palette (same soft/muted red as .form-error), not .form-warning's
   amber: this sits right next to plain informational notices (e.g. "3
   minggu ekstra sebelum race") that also use the warning color, and a
   missed week is a step up from those — something actionable the runner
   should actually notice, not just a heads-up. Still the app's existing
   muted dark-theme red, not a jarring bright one. See
   detectMissedWeek/renderMissedWeekBanner in js/app.js. */
.missed-week-banner {
  background: var(--color-error-bg);
  color: var(--color-error);
  border: 1px solid var(--color-error-border);
  border-radius: var(--radius-item);
  padding: 12px 14px;
  margin: 16px 0;
  display: flex;
  flex-direction: column;
  gap: 10px;
  font-size: var(--text-base);
}
.missed-week-banner p { margin: 0; line-height: var(--leading-normal); }
.missed-week-actions { display: flex; gap: 10px; flex-wrap: wrap; }

/* ---------- Weekly recap panel ---------- */
/* #weeklyRecap — the Monday read-back of the week that just ended (see
   detectWeeklyRecap/renderWeeklyRecap in js/app.js). Deliberately NOT the
   error red of .missed-week-banner directly above it: the two can appear
   together, and if both shouted the same alarm colour the runner would
   read them as one long complaint. This one is a neutral report card —
   card chrome plus a single accent hairline down the left so it still
   registers as "new since your last visit" rather than as permanent page
   furniture. */
.weekly-recap {
  border: 1px solid var(--color-border);
  border-left: 3px solid var(--color-primary);
  margin: 16px 0;
  padding: 16px 18px 18px;
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.recap-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
}
.recap-title {
  margin: 0;
  font-weight: 700;
  font-size: var(--text-base);
  display: flex;
  align-items: center;
  gap: 7px;
}
.recap-range {
  margin: 3px 0 0;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}
.recap-dismiss { flex-shrink: 0; padding: 4px 8px; }

/* Three equal columns on desktop, stacking to one on narrow screens (see
   the media query at the bottom of this file). auto-fit rather than a
   fixed 3 so the middle breakpoint gives two columns instead of jumping
   straight from three to one. */
.recap-metrics {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 14px;
}
.recap-metric {
  display: flex;
  flex-direction: column;
  gap: 3px;
  padding: 12px 14px;
  border-radius: var(--radius-item);
  background: var(--color-bg);
}
.recap-metric-label {
  margin: 0;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--color-text-muted);
}
.recap-metric-value {
  margin: 0;
  font-size: var(--text-lg);
  font-weight: 800;
  line-height: var(--leading-tight);
}
.recap-metric-unit { font-size: var(--text-sm); font-weight: 600; color: var(--color-text-muted); }
.recap-metric-sub { margin: 0; font-size: var(--text-sm); color: var(--color-text-muted); line-height: var(--leading-normal); }

/* The VDOT direction badge (naik / turun / tetap). Sits on the number's own
   line and is sized off it, so it reads as a suffix to the figure rather
   than as a second value. Shape carries the meaning (see the three symbols
   in index.html) and colour only reinforces it — a runner who can't tell
   the green from the red still sees an arrow pointing down. */
.recap-delta {
  display: inline-flex;
  align-items: center;
  gap: 3px;
  margin-left: 8px;
  padding: 2px 8px 2px 6px;
  border-radius: 999px;
  font-size: var(--text-sm);
  font-weight: 700;
  vertical-align: 3px;
  /* Neutral by default — the "tetap" case and the first-ever reading both
     land here, so the variants below only need to override a colour pair. */
  color: var(--color-text-muted);
  background: color-mix(in srgb, var(--color-text-muted) 14%, transparent);
}
.recap-delta .icon { width: 14px; height: 14px; }
.recap-delta.is-up {
  color: var(--color-accent);
  background: var(--color-accent-light);
}
.recap-delta.is-down {
  color: var(--color-error);
  background: var(--color-error-bg);
}

/* The adherence bar. Its fill colour is set inline from the same number
   the label shows (see renderWeeklyRecap) — three bands rather than a
   continuous gradient, because a runner reading their own week wants
   "good / patchy / gone" and not a hue to decode. */
.recap-bar {
  margin-top: 6px;
  height: 6px;
  border-radius: 999px;
  background: var(--color-border);
  overflow: hidden;
}
.recap-bar-fill {
  display: block;
  height: 100%;
  border-radius: 999px;
  background: var(--color-primary);
  transition: width 0.4s ease;
}

/* The coach note. One sentence, always — it is the panel's conclusion, so
   it gets the accent tint that separates a judgement from the raw numbers
   above it. */
.recap-note {
  margin: 0;
  font-size: var(--text-base);
  line-height: var(--leading-relaxed);
  padding: 10px 12px;
  border-radius: var(--radius-item);
  background: var(--color-primary-light);
  color: var(--color-text);
}

/* ---------- Break (libur) panel + markers ---------- */
/* See #breaksPanel in index.html. Structurally the same stacked card as
   .calendar-panel above; what it adds is the list of breaks already on
   the plan, which has to come FIRST — the common visit here is not
   "add a trip" but "did my trip actually register, and can I take it
   back off". */
.breaks-panel { margin: 16px 0; display: flex; flex-direction: column; gap: 14px; }
.breaks-panel h3 { margin: 0; font-size: var(--text-base); }
.breaks-intro { margin: 0; font-size: var(--text-sm); color: var(--color-text-muted); line-height: var(--leading-relaxed); }
.breaks-list { display: flex; flex-direction: column; gap: 8px; }
.breaks-list:empty { display: none; }
.break-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 12px;
  background: var(--color-bg);
  border: 1px solid var(--color-border);
  /* Left edge in the break color, matching how .week-block carries its
     phase color — the same visual grammar for "this row is about that
     thing" already used in the schedule below. */
  border-left: 3px solid var(--type-break);
  border-radius: var(--radius-item);
  font-size: var(--text-sm);
}
.break-item-text { flex: 1; line-height: var(--leading-normal); }
.break-item-dates { font-weight: 600; }
.break-item-meta { display: block; color: var(--color-text-muted); font-size: var(--text-sm); }
.break-remove-btn {
  flex-shrink: 0;
  background: none;
  border: none;
  padding: 4px;
  cursor: pointer;
  color: var(--color-text-muted);
  line-height: 0;
  border-radius: var(--radius-chrome);
}
.break-remove-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
.breaks-form { display: flex; flex-direction: column; gap: 12px; }
/* The container already spaces its children with `gap`; .field's own
   bottom margin on top of that was 26px between every pair. */
.breaks-form > .field { margin-bottom: 0; }
/* Three options whose labels are full sentences, not one-word chips —
   they wrap to their own lines on a phone rather than being squeezed to
   an unreadable width, which is the whole reason this isn't a <select>:
   the difference between them is the thing being chosen. */
.breaks-levels { flex-wrap: wrap; }
.breaks-empty { margin: 0; font-size: var(--text-sm); color: var(--color-text-muted); font-style: italic; }

/* A day inside a break, in the schedule table. The badge already carries
   the break color for a rested day (see restDisplayKey -> breakRest); this
   is for the 'light'/'flex' days, which keep a real session badge and so
   need the row itself to say why that session is smaller than the plan
   otherwise implies. Deliberately a left edge and not a background fill:
   .is-today/.is-completed already own the row's background, and a break
   day is frequently also today. */
.day-table tr.is-break > td:first-child { box-shadow: inset 3px 0 0 var(--type-break); }
.break-chip {
  display: inline-block;
  margin-left: 6px;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--type-break);
  border: 1px solid var(--type-break);
  border-radius: 999px;
  padding: 2px 6px;
  vertical-align: middle;
}
/* Same chip in a week's collapsed header, so a break is findable without
   opening all twelve weeks — the one place a runner scanning for "which
   week was my trip again" actually looks. */
.week-break-badge {
  display: inline-block;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-wide);
  color: var(--type-break);
  border: 1px solid var(--type-break);
  border-radius: 999px;
  padding: 2px 8px;
}

/* ---------- In-page replacements for alert()/confirm() ---------- */
/* Result-level messages (PDF export failures) — sits right under the
   buttons that raise them, so unlike .week-notice below it never needs to
   be scrolled to. Dismissed by hand rather than on a timer: an error the
   runner glanced away from shouldn't quietly disappear. */
.result-notice {
  position: relative;
  background: var(--color-error-bg);
  color: var(--color-error);
  border: 1px solid var(--color-error-border);
  border-radius: var(--radius-item);
  padding: 12px 40px 12px 14px;
  margin: 14px 0;
  font-size: var(--text-base);
}
.result-notice p { margin: 0; line-height: var(--leading-normal); }.result-notice-dismiss {
  position: absolute;
  top: 6px;
  right: 8px;
  background: none;
  border: none;
  color: inherit;
  opacity: 0.7;
  cursor: pointer;
  font-size: var(--text-base);
  line-height: 1;
  padding: 4px;
}
.result-notice-dismiss:hover { opacity: 1; }

/* Everything the day-swap flow says, inside the week block it's about
   (see showWeekNotice in js/app.js) — a page-level banner would be
   several screens from the swap button by week 5. Neutral primary tint for
   the "now pick the second day" prompt and the long-run question; the
   error variant for a swap that was refused outright. */
.week-notice {
  margin: 0 16px 12px;
  padding: 10px 12px;
  border-radius: var(--radius-item);
  background: var(--color-primary-light);
  border: 1px solid var(--color-primary);
  font-size: var(--text-sm);
  line-height: var(--leading-normal);
}
.week-notice.is-error {
  background: var(--color-error-bg);
  color: var(--color-error);
  border-color: var(--color-error-border);
}
.week-notice p { margin: 0; }
.week-notice-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 10px; }

/* ---------- Today's session (hero at the top of the result) ---------- */
/* Carries a solid left rule in the primary color plus a wash of that same
   color across the panel — the one element on this page that is about a
   single day rather than the whole block, and the thing a runner opening
   the app mid-training is actually looking for. It used to sit on flat
   --color-bg exactly like the summary tiles underneath it, which left the
   left rule doing all the work of saying "this one is different". The
   gradient resolves into --color-bg so it still belongs to the same
   family of inset panels, and both stops are tokens, so it lands right in
   either theme. */
.today-card {
  margin: 20px 0 0;
  padding: 18px 18px 16px;
  background: linear-gradient(135deg, var(--today-wash) 0%, var(--color-surface) 62%);
  border-radius: var(--radius-sm);
  border-left: 4px solid var(--color-primary);
}
.today-card-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 2px 14px;
}
.today-card-eyebrow {
  font-size: var(--text-xs);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: var(--tracking-normal);
  color: var(--color-primary-dark);
}
.today-card-week { font-size: var(--text-xs); color: var(--color-text-muted); }
/* The largest type on the results page, on purpose — everything else here
   is context for this one line. */
.today-card-title {
  margin: 4px 0 0;
  font-size: var(--text-md);
  font-weight: 800;
  letter-spacing: var(--tracking-tight);
  line-height: var(--leading-snug);
}
.today-card-body {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px 14px;
  margin-top: 10px;
}
.today-card-metric { font-size: var(--text-sm); color: var(--color-text-muted); }
.today-card-metric strong { color: var(--color-text); }
/* The one number this whole screen is about — still the largest thing on
   the page, but a notch down from the hero size: at 36px the card's type
   crowded its own box on a phone. */
.today-card-distance strong {
  display: block;
  font-size: var(--text-xl);
  font-weight: 800;
  line-height: var(--leading-tight);
  letter-spacing: var(--tracking-tight);
}
/* Same accent as the completed badge and per-session analysis in the table below
   — "the app matched this to a real Strava activity". */
.today-card-done { color: var(--color-accent); }
.today-card .workout-structure { margin-top: 12px; }
.today-card-next {
  margin: 12px 0 0;
  font-size: var(--text-xs);
  color: var(--color-text-muted);
}
.today-card-next strong { color: var(--color-text); font-weight: 600; }
/* Where the runner is in the block, and how much is left — see
   renderTodayCard. Lives here rather than as two more summary tiles
   because it's the same "right now" question the rest of this card
   answers, and it keeps both on the first screen. */
.today-card-progress { margin-top: 14px; }
.today-progress-track {
  height: 6px;
  border-radius: 999px;
  background: var(--color-border);
  overflow: hidden;
}
.today-progress-fill {
  display: block;
  height: 100%;
  border-radius: 999px;
  background: var(--color-primary);
}
.today-progress-label {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  gap: 2px 12px;
  margin: 6px 0 0;
  font-size: var(--text-xs);
  color: var(--color-text-muted);
}

/* ---------- Plan tabs ---------- */
/* The result screen used to stack seven independent blocks under the
   today card — summary tiles, VDOT trend, warnings, volume chart, pace
   zones, the week accordion, block history — all expanded, all at once.
   Nothing there was wrong on its own; together they measured over four
   screens of scrolling before the first training week came into view,
   and the runner had to skip past a fitness chart to reach the schedule.
   Two tabs split them by the question being asked: "Jadwal" answers what
   to run, "Progres" answers how it's going. The today card stays above
   both, because that question outranks the split. */
.plan-tabs {
  display: flex;
  gap: 4px;
  margin: 22px 0 0;
  padding: 3px;
  background: var(--tab-track-bg);
  border: 1px solid var(--tab-track-border);
  border-radius: 999px;
  /* Shrink-wrapped on desktop — a 980px-wide two-way switch stops reading
     as a control and starts reading as a header band. It stretches to the
     full width on a phone instead (see the 600px block), where two
     thumb-sized halves is exactly what it should be. */
  width: fit-content;
  /* Deliberately NOT sticky. It would have to share `top: 0` with
     summary.week-header, which sticks there so the week you're reading
     stays named — and one of the two would end up hidden under the other
     for the whole length of a 16-week accordion. The week you're in is
     worth more of that edge than a tab row you touch a handful of times a
     block. */
}
.plan-tab {
  flex: 1 1 auto;
  /* Was 152px, which padded a two-word label out into a button wider than
     anything it switches between. 116px still keeps both pills the same
     width and thumb-sized. */
  min-width: 116px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  padding: 8px 14px;
  /* Transparent rather than none so the inactive and active pills are the
     same size — .is-active draws this border for real in dark. */
  border: 1px solid transparent;
  border-radius: 999px;
  background: none;
  color: var(--color-text-muted);
  font: inherit;
  font-size: var(--text-sm);
  font-weight: 600;
  cursor: pointer;
  transition: background-color 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}
.plan-tab .icon { width: 15px; height: 15px; }
.plan-tab:hover { color: var(--color-text); }
.plan-tab.is-active,
.plan-tab.is-active:hover {
  background: var(--tab-active-bg);
  color: var(--tab-active-fg);
  box-shadow: 0 2px 8px color-mix(in srgb, var(--tab-active-bg) 45%, transparent);
  /* A real hairline in both themes now that the pill is filled — it keeps
     the fill from bleeding into the track it sits on. Always present so
     the pill's size doesn't shift between themes. */
  border: 1px solid var(--tab-active-border);
}
.plan-tab:focus-visible {
  outline: 2px solid var(--color-primary-dark);
  outline-offset: 2px;
}
/* The panel's first child carries the margin that used to separate these
   blocks from the heading above them; the tab row now provides it. */
.plan-tab-panel > :first-child { margin-top: 14px; }

/* 170px so a two-word value ("Half Marathon") and the longest label
   ("Estimasi dari Race Terakhir") each get a line to themselves instead of
   breaking mid-phrase. The phone override at the bottom of this file drops
   it back to 140px, which is the widest a 375px screen can take and still
   fit two columns (~302px of usable card width there). */
.summary-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 12px;
  margin: 20px 0;
}
/* Neutral, not accent-teal — these are plain facts about the plan (race
   distance, dates, peak volume), not something the app auto-filled or an
   AI wrote, so they shouldn't borrow that color's meaning (see the
   --color-accent comment in :root). Sits on --color-bg rather than the
   card's own --color-surface so the tiles still read as a distinct
   group, and lifts slightly on hover the way the rest of this pass's
   interactive surfaces do. */
/* Column + space-between so the VALUES line up along a common bottom edge
   across the row even when one tile's label wraps to two lines ("Lulus
   (Perkiraan)", "Estimasi dari Race Terakhir") and its neighbours' don't —
   grid already stretches every tile to the row's height, this is what puts
   that extra height above the number instead of below it. */
.summary-item {
  background: var(--color-bg);
  /* Tepi eksplisit, bukan cuma beda background: di dark theme
     --color-bg dan latar kartu induknya nyaris sewarna, jadi tile-nya
     kehilangan bentuk tanpa garis ini. --color-border-inset, sama seperti
     .volume-chart — tile ini duduk di atas --color-bg. */
  border: 1px solid var(--color-border-inset);
  border-radius: var(--radius-sm);
  padding: 12px 14px;
  transition: transform 0.15s ease;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}
.summary-item:hover { transform: translateY(-2px); }
/* Sentence case, not uppercase-with-letterspacing: "PEAK WEEKLY VOLUME"
   and "ESTIMASI DARI RACE TERAKHIR" both wrapped to two lines purely
   because the tracking stretched them, which then dragged every tile in
   the row taller. */
.summary-item .label {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  letter-spacing: var(--tracking-normal);
  margin-bottom: 4px;
}
/* Neutral, not --color-primary-dark. Every one of these values used to be
   set in the brand indigo at 1.35rem, which put five to seven identically
   loud elements on one screen — when everything is emphasized nothing is,
   and the one number a runner actually came for (today's session, in the
   card above) had nothing left to stand out against. */
.summary-item .value {
  /* Up from 1.18rem. These are the block's headline numbers and they were
     the same size as body copy, so nothing on the screen read as a
     figure worth looking at. */
  font-size: var(--text-lg);
  font-weight: 800;
  letter-spacing: var(--tracking-tight);
  line-height: var(--leading-tight);
  color: var(--color-text);
  /* Nothing here is allowed to run past its tile, whatever a future race
     name turns out to be. */
  overflow-wrap: anywhere;
}
/* Tiles whose value is a date or a whole phrase rather than a short number
   — they need the smaller size to fit, but it belongs here next to the
   base rule, not as an inline style on the markup that sets it. */
/* Tiles whose value is a phrase or a date rather than a short number.
   They cannot carry the headline size — "General Improvement" at 1.6rem
   runs straight out of a 150px tile — but they were dropping all the way
   to 0.95rem, which next to a 1.6rem neighbour looked like a different
   component. 1.15rem is the compromise. */
.summary-item .value.is-small { font-size: var(--text-md); line-height: var(--leading-snug); }
/* The distance under a named race ("Jakarta Marathon 2026" / "Full
   Marathon (42.2K)"). Deliberately quiet: once the race has a name, the
   name is the tile's answer and the distance is only there to confirm
   which block this is. */
.summary-item .value .value-sub {
  display: block;
  margin-top: 4px;
  font-size: var(--text-sm);
  font-weight: 500;
  letter-spacing: var(--tracking-normal);
  color: var(--color-text-muted);
}

/* Aligning the values by pushing them to the BOTTOM of each tile (the
   flex space-between above, kept as the fallback) only looks right when
   the tiles are the same height. They aren't: a wrapped race name plus
   its distance subline makes the first tile twice as tall as the rest,
   and every neighbour then shows its label stranded at the top with a
   band of dead space above a number pinned to the floor — which is
   exactly the ragged look this block fixes.
   Subgrid puts the labels in one shared row and the values in another,
   so both line up across the whole row no matter how tall the tallest
   tile grows, and the leftover height falls below the values instead of
   between them. `align-self: baseline` on the value makes the 1.6rem
   numbers and the 1.15rem phrases sit on one baseline rather than one
   top edge. row-gap: 0 because the parent's 12px gap would otherwise be
   inherited between a tile's own two rows; the label's own margin is the
   spacing there. */
@supports (grid-template-rows: subgrid) {
  .summary-item {
    display: grid;
    grid-template-rows: subgrid;
    grid-row: span 2;
    row-gap: 0;
  }
  .summary-item .value { align-self: baseline; }
}

/* Reuses .pace-zone-header/.pace-zone-title/.pace-zone-source below for
   its own header row (same "reference card" look as .pace-legend right
   after it) rather than defining a parallel set of header classes. */
.volume-chart {
  margin: 20px 0 0;
  padding: 14px;
  background: var(--color-bg);
  /* Tepi eksplisit supaya panel tetap terbaca di dark theme, di mana
     box-shadow hampir tak terlihat. Punggungnya --color-bg, jadi yang
     dipakai --color-border-inset, bukan --color-border — alasannya
     ditulis di tokennya. */
  border: 1px solid var(--color-border-inset);
  border-radius: var(--radius-sm);
}
/* .table-scroll (defined further down, shared with .day-table/.pace-zone-table)
   lets a long marathon-length plan (16-20+ columns) scroll horizontally on a
   narrow phone instead of squeezing every bar down to unreadable. */
.volume-chart-bars {
  display: flex;
  align-items: flex-end;
  gap: 4px;
  margin-top: 4px;
}
/* 34px, not the 28px a bare "28"-style label needed — a started week's
   label is now "24/28", and .table-scroll already exists precisely so a
   many-week plan pays for this in horizontal scroll rather than in every
   column's legibility. */
.volume-chart-col {
  flex: 1 1 0;
  min-width: 34px;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 4px;
}
.volume-chart-value {
  font-size: var(--text-2xs);
  color: var(--color-text-muted);
  white-space: nowrap;
}
/* The logged-so-far half of an "actual/plan" label — see
   .volume-chart-value-actual's counterpart, .volume-chart-done, below.
   Heavier than the plan figure beside it: this is the number that changes
   as the week goes on, so it's the one worth the runner's eye landing on
   first. Color is set inline per instance (that week's own phase color,
   matching its bar and the phase legend), not fixed here. */
.volume-chart-value-actual { font-weight: 700; }
.volume-chart-value-sep { opacity: 0.5; padding: 0 2px; }
/* Fixed-height track the bar sits at the bottom of — a real pixel height
   here (set inline per bar in JS) instead of a percentage of some scaled
   viewBox is what keeps the value/week-number text above and below it
   crisp, unlike the SVG version this replaced. Positioned now (rather
   than a plain flex row) so a started week's "done" and "left to run"
   zones can stack sharing the same baseline instead of sitting side by
   side — see .volume-chart-done/.volume-chart-remainder below. */
.volume-chart-track { position: relative; height: 90px; width: 100%; }
/* Sized in JS to exactly this week's own rendered height (the taller of
   its target and its actual, see renderVolumeChart) rather than filling
   the fixed 90px .volume-chart-track every column sits in — purely a
   positioning anchor for whichever zone(s) below it needs, and the thing
   the current-week ring attaches to (see .is-current below), so a short
   week's ring hugs its own short bar instead of framing an oversized box
   reaching up toward the chart's tallest week. */
.volume-chart-bar { position: absolute; left: 0; right: 0; bottom: 0; }
/* A week that hasn't started yet — plan only, nothing to compare against.
   Dimmed rather than the solid full-opacity look this used to always
   have: full brightness is now reserved for "already happened" (a
   completed week's .volume-chart-done zone below, or a past week's own
   .volume-chart-remainder.is-past), so a week that's still just a future
   plan visibly recedes next to it instead of every bar competing for the
   same attention regardless of whether it's real yet. */
/* --bar-color is this week's own phase color, set inline per bar in JS.
   The fill is that color mixed down toward the chart's background (see
   --chart-dim) while the hairline edge stays at full strength, so a
   dimmed bar still shows the exact hue the legend dot names — and still
   has a defined edge on the pale page, which a plain opacity fade took
   away along with the color. */
.volume-chart-fill {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  min-width: 16px;
  border-radius: 4px 4px 0 0;
  background: color-mix(in srgb, var(--bar-color) calc(var(--chart-dim) * 100%), var(--color-bg));
  box-shadow: inset 0 0 0 1px var(--bar-color);
  transition: background 0.15s, height 0.2s ease;
}
.volume-chart-col:hover .volume-chart-fill {
  background: color-mix(in srgb, var(--bar-color) calc(var(--chart-dim-hover) * 100%), var(--color-bg));
}
/* The "still to run" zone of a started week that hasn't hit its target
   yet. Sits on TOP of .volume-chart-done (offset up by `bottom`, set
   inline in JS to the done zone's own height), so together the two form
   one continuous bar reaching the week's planned total (or the done
   zone's own taller height, on an overshoot). Dim by default — same
   reasoning and same opacity as a future week's whole bar, since for the
   CURRENT week this km hasn't happened yet either and there's still time
   left to close the gap. */
.volume-chart-remainder {
  position: absolute;
  left: 0;
  right: 0;
  border-radius: 4px 4px 0 0;
  background: color-mix(in srgb, var(--bar-color) calc(var(--chart-dim) * 100%), var(--color-bg));
  /* Edge on three sides only: the bottom of this zone is the seam where it
     meets the bright done zone underneath, and a line there would read as
     a gap between two halves of what is one continuous bar. */
  box-shadow: inset 1px 1px 0 0 var(--bar-color), inset -1px 0 0 0 var(--bar-color);
}
.volume-chart-col:hover .volume-chart-remainder {
  background: color-mix(in srgb, var(--bar-color) calc(var(--chart-dim-hover) * 100%), var(--color-bg));
}
/* A PAST week's own remainder, though — that gap already happened (or
   didn't). It's a real missed session, not a still-undecided plan, so it
   prints at full brightness like any other plan color instead of being
   softened into looking hypothetical. */
.volume-chart-remainder.is-past,
.volume-chart-col:hover .volume-chart-remainder.is-past {
  background: var(--bar-color);
}
/* The actually-logged-km zone (see weekActualKm/renderVolumeChart) — full
   width, same as every bar in this chart, stacked at the bar's own base.
   A first version centered a narrower bright bar over a wide dim one
   (a bullet graph); next to a row of bold full-width bars that read as
   "this one's too small" rather than "here's a comparison", on exactly
   the week most worth looking at. Full width fixes that, and the glow is
   what still makes it read as LIVE data rather than a third static phase
   color — a flat fill in the same hue looked like just another bar. Its
   own rounded top corners only apply via .is-full below, when there's no
   dim remainder sitting on top of it to own that corner instead. */
/* --bar-color and box-shadow are set inline per instance in JS — this
   week's own phase color plus a glow tinted to match it via color-mix(),
   not a fixed hue here (see renderVolumeChart's own comment on why: the
   done zone borrows whichever phase color the week is already in, rather
   than a separate "actual" color, so it reads as more of the same phase
   instead of an unrelated measurement). What IS fixed is that this zone
   takes the phase color at FULL strength, unmixed — the same weight a
   past week's own remainder carries (see .is-past above) — so "really
   happened" always reads at one consistent brightness, and exactly the
   color the legend names, regardless of which phase it's in. */
.volume-chart-done {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  background: var(--bar-color);
  transition: height 0.2s ease;
}
.volume-chart-done.is-full { border-radius: 4px 4px 0 0; }
.volume-chart-week-no { font-size: var(--text-2xs); color: var(--color-text-muted); }
/* The current week needs to be unmistakable, not just a slightly brighter
   bar in a row of a dozen similar ones — a ring on .volume-chart-bar
   (sized to this week's own bar, not the shared 90px track every column
   sits in — see .volume-chart-bar's own comment) plus bolded/colored
   labels above and below it, backed up by the explicit "Sekarang: Minggu
   N" text in the header line above (see renderVolumeChart). */
/* Two layers: the ring itself, plus a wider soft halo in the same hue so
   it still separates from the bar it hugs — a single 2px line sitting
   directly on a saturated bar was easy to lose. */
.volume-chart-col.is-current .volume-chart-bar {
  box-shadow: 0 0 0 2px var(--color-primary-dark),
              0 0 0 4px color-mix(in srgb, var(--color-primary-dark) 22%, transparent);
  border-radius: 4px 4px 0 0;
}
.volume-chart-col.is-current .volume-chart-value,
.volume-chart-col.is-current .volume-chart-week-no {
  color: var(--color-primary-dark);
  font-weight: 700;
}
/* .volume-chart-value-actual carries its own inline color (that week's
   phase color) regardless of the rule above — inline style already wins
   over it, no override needed to keep it legible against the ring. */
.volume-chart-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 14px;
  margin-top: 8px;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}
.volume-chart-legend-item { display: inline-flex; align-items: center; gap: 6px; }
.volume-chart-legend-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }

/* #blockHistoryChart itself already gets the boxed "reference card" look
   for free by reusing the .volume-chart/.volume-chart-bars/.volume-chart-col
   classes (see loadAndRenderBlockHistory in js/app.js) — this only styles
   the plain list of past blocks underneath that chart. */
.block-history { margin: 20px 0 0; }
.block-history-list { display: flex; flex-direction: column; gap: 2px; margin-top: 10px; }
.block-history-row {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 4px 12px;
  padding: 8px 4px;
  border-bottom: 1px solid var(--color-border);
  font-size: var(--text-sm);
}
.block-history-row:last-child { border-bottom: none; }
.block-history-label { font-weight: 600; }
.block-history-meta { color: var(--color-text-muted); }

.pace-legend {
  margin: 18px 0 6px;
  padding: 14px;
  /* Kartu ini memakai permukaan yang sama di kedua state (terlipat dan
     terbuka). Dulu hanya state terlipat yang diberi --color-surface, jadi
     background-nya berkedip jadi abu-abu begitu tabel dibuka — perubahan
     warna yang tidak berarti apa-apa buat pembaca. Penekanan saat terlipat
     sekarang murni dari aksen (border primary, judul, pil CTA). */
  background: var(--color-surface);
  /* Sama seperti .summary-item: garis tepi supaya kartu tetap terbaca
     di dark theme, di mana box-shadow hampir tak terlihat. */
  border: 1px solid var(--color-border);
  border-left: 3px solid transparent;
  border-radius: var(--radius-sm);
  /* Shadow comes from the shared LIFT rule above. */
  transition: border-left-color 0.15s ease;
}
/* renderPaceZones (js/app.js) empties this container rather than removing
   it whenever the plan has no VDOT to build a zone table from — most
   visibly the whole First-timer mode, whose run/walk sessions have no
   prescribed pace at all. Without this, the padding + background above
   still paint, leaving a stray empty slab between the volume chart and
   week 1. */
.pace-legend:empty { display: none; }
/* The zone table folds away (see renderPaceZones in js/app.js for why);
   .pace-zone-header doubles as its <summary>, so it needs the affordances
   a summary doesn't get for free — a pointer, its own marker suppressed,
   and display:flex restored (a <summary> is display:list-item). */
.pace-zone-details > summary {
  position: relative;
  /* Room for the caret pinned in the corner below. */
  padding-right: 22px;
  cursor: pointer;
  list-style: none;
}
.pace-zone-details > summary::-webkit-details-marker { display: none; }
/* Pinned to the corner rather than laid out as a flex item: this row
   wraps on a phone (the source line drops under the title), and an
   in-flow caret came to rest on a third line of its own below both. */
.pace-zone-details > summary::after {
  content: '';
  position: absolute;
  top: 6px;
  right: 2px;
  color: var(--color-text-muted);
}
/* The header carries no bottom margin while folded — it is the whole card. */
.pace-zone-details:not([open]) > .pace-zone-header { margin-bottom: 0; }

/* Folded, this card used to be a grey line on a grey panel between the
   volume chart and week 1, so runners scrolled straight past it and never
   found out their five training paces were in there at all. While folded
   it reads as the one thing on the page asking to be opened: a
   primary-tinted border with a thicker left edge, the primary colour on
   its icon and title, and an explicit "Lihat 5 zona pace" pill where a
   chevron alone was the only hint. Only the accent drops away once the
   table is open — the surface, shadow and geometry stay identical in both
   states, so expanding reveals the table without the card changing
   colour underneath it. */
.pace-legend:has(.pace-zone-details:not([open])) {
  border-color: var(--color-primary-light);
  border-left-color: var(--color-primary);
}
.pace-zone-details:not([open]) > summary::after { color: var(--color-primary); }
.pace-zone-details:not([open]) .pace-zone-title { color: var(--color-primary); }

/* The affordance itself: a pill, not another muted line, because the two
   lines it sits beside are already muted. Hidden outright once open —
   "Lihat" is nonsense on a table you are looking at, and the chevron
   still says how to close it. */
.pace-zone-cta {
  flex: none;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-normal);
  color: var(--color-primary);
  background: var(--color-primary-light);
  border-radius: 999px;
  padding: 4px 10px;
  white-space: nowrap;
}
.pace-zone-details[open] .pace-zone-cta { display: none; }

.pace-zone-header {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 6px 14px;
  margin-bottom: 10px;
}
/* The source line takes the leftover width so the CTA pill after it stays
   pinned to the right edge (next to the caret) instead of drifting into
   the middle of the row on a wide screen. */
.pace-zone-source { flex: 1 1 auto; }
.pace-zone-title {
  font-weight: 800;
  font-size: var(--text-base);
}
.pace-zone-source { font-size: var(--text-sm); color: var(--color-text-muted); }
.pace-zone-table { width: 100%; border-collapse: collapse; font-size: var(--text-base); }
.pace-zone-table th, .pace-zone-table td { padding: 6px 10px; text-align: left; white-space: nowrap; }
.pace-zone-table thead th { color: var(--color-text-muted); font-size: var(--text-sm); text-transform: uppercase; letter-spacing: var(--tracking-wide); font-weight: 600; }
.pace-zone-table tbody tr:not(:last-child) { border-bottom: 1px solid var(--color-border); }
.pace-zone-table .zone-name { display: flex; align-items: center; gap: 8px; }
.pace-zone-table .zone-dot { width: 10px; height: 10px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.pace-zone-note { font-size: var(--text-sm); color: var(--color-text-muted); margin: 10px 0 0; }

/* Goal pace + the Riegel estimate, relocated here from the summary tiles
   at the top of the page (see renderPaceZones in js/app.js for why). A
   rule above them so they read as a footnote to the zone table rather
   than two more rows of it. */
.pace-goal-row {
  display: flex;
  flex-wrap: wrap;
  gap: 6px 28px;
  margin-top: 12px;
  padding-top: 12px;
  border-top: 1px solid var(--color-border);
}
.pace-goal-item { display: flex; align-items: baseline; gap: 8px; font-size: var(--text-sm); }
.pace-goal-item .label { color: var(--color-text-muted); }
.pace-goal-item .value { font-weight: 700; }

/* Week blocks */
/* The phase color carried on the left edge (set per block as
   --week-phase-color by renderPlan, from the same PHASE_COLORS the volume
   chart's bars use) — collapsed, twelve week headers were otherwise
   twelve identical strips, and the base-build-peak-taper shape the chart
   above draws had no counterpart in the list underneath it.
   No `overflow: hidden` any more: it made this element a scroll container,
   which silently disabled `position: sticky` on the header inside it. The
   corners it used to clip are now rounded on that header directly. */
.week-block {
  /* The one panel of the shared-lift group that really is card-coloured:
     its own header is the --color-bg inset, so the block behind it has to
     be the surface for that header to read as inset at all. */
  background: var(--color-surface);
  /* Only the phase edge left: the hairline all the way round was drawing
     a box on a card that now has a shadow of its own to say where it
     ends. */
  border-left: 3px solid var(--week-phase-color, var(--color-border));
  border-radius: var(--radius-sm);
  margin-top: 16px;
}
/* A CSS grid, not flex — each .week-header is its own independent flex
   container (one per <details>, no shared row context across weeks), so
   with flexbox alone the "Total"/phase columns drift left-right between
   weeks depending on how wide that week's own title/badge/phase text
   happens to be (e.g. the "Saat ini" badge only on one week,
   "BASE" vs "RACE WEEK" varying in length). Fixed track widths on the
   title and total columns keep both lined up in a straight column down
   the page regardless of any single week's own content width. */
.week-header {
  background: var(--color-bg);
  padding: 12px 16px;
  display: grid;
  grid-template-columns: minmax(0, 230px) 1fr minmax(0, 110px) auto;
  align-items: center;
  gap: 8px;
}
.week-title-group { display: flex; align-items: center; gap: 8px; min-width: 0; }
.week-header .week-title { font-weight: 800; letter-spacing: var(--tracking-normal); }
/* Labels whichever week findCurrentWeek (js/app.js) says today falls
   inside — the same week that starts open by default, but shown even if
   the runner later collapses it, so it's still obvious at a glance which
   one that was. */
.week-current-badge {
  display: inline-block;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-wide);
  color: var(--color-primary-dark);
  background: var(--color-primary-light);
  border: 1px solid var(--color-primary);
  border-radius: 999px;
  padding: 2px 8px;
}
/* "3/4 sesi" — how much of a week that has already started was actually
   run, from the same Strava matching that draws the checkmarks inside it
   (see updateWeekProgress in js/app.js). Collapsed, a week header said
   what was PLANNED and nothing about what happened, so the only way to
   find out was to open every one of them. Muted by default: a week that
   fell short is a fact, not a telling-off — the missed-week banner is
   what escalates that. */
.week-progress {
  font-size: var(--text-xs);
  font-weight: 600;
  color: var(--color-text-muted);
  border: 1px solid var(--color-border);
  border-radius: 999px;
  padding: 2px 8px;
  white-space: nowrap;
}
.week-progress.is-complete {
  color: var(--color-accent);
  border-color: var(--color-accent);
  background: var(--color-accent-light);
}

.week-header .week-phase {
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
  color: var(--color-text-muted);
  /* Wraps instead of truncating: at in-between widths (roughly 600-790px,
     which includes iPad-portrait's 768px) the fixed title/total columns
     leave this middle column too narrow for the full "PHASE • date range"
     string, and ellipsis was silently hiding the week's date range instead
     of just reflowing it onto a second line. The grid row (align-items:
     center) grows to fit a wrapped 2-line phase with no other side effect. */
  white-space: normal;
}
/* Colored per phase (see PHASE_COLORS in js/app.js) so Base/Build/Peak/
   Cutback/Taper/Race Week reads apart at a glance instead of all six
   sitting in the same muted gray as the date range next to it. The values
   all come from the --type-* exertion family above — see PHASE_COLORS for
   why nothing here may borrow --color-accent or another brand token. */
.week-header .phase-label { font-weight: 700; }
.week-header .week-total {
  font-weight: 700;
  color: var(--color-primary-dark);
  text-align: left;
}
/* .week-block is a <details> element (see js/app.js renderPlan) so each
   week collapses to just this header — cursor + the two marker-removal
   rules below strip the browser's own disclosure triangle so the custom
   .week-toggle-icon (bentuk & rotasinya di blok chevron bersama, lihat
   "Disclosure reveal") is the only one shown, consistently across
   browsers. */
/* Sticky so an opened week still says which week it is while its own
   table scrolls past — a marathon block's week runs well over a screen
   tall, and the answer to "which week am I looking at" was scrolled off
   the top for most of the time anyone spends reading one.
   The radii are here rather than on .week-block because that element can
   no longer clip them (see its comment above). */
summary.week-header {
  position: sticky;
  top: 0;
  z-index: 3;
  border-radius: calc(var(--radius-sm) - 1px);
  cursor: pointer;
  list-style: none;
  transition: background 0.15s;
}
.week-block[open] > summary.week-header { border-radius: calc(var(--radius-sm) - 1px) calc(var(--radius-sm) - 1px) 0 0; }
summary.week-header::-webkit-details-marker { display: none; }
/* Tactile feedback for a clickable row that otherwise looks identical to
   any other header — same idea as the row-hover below on .day-table. */
summary.week-header:hover { background: var(--color-surface); }
.week-toggle-icon { color: var(--color-text-muted); }
/* The strip between a week's header and its table: which days are rest,
   and the control that turns this week's swap buttons on. min-width: 0 on
   the text so a long list wraps rather than pushing the button off the
   right edge of a phone. */
.week-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 10px;
  padding: 8px 16px 0;
}
/* Three of the six rows in a typical week are rest days, and they were in
   the table only so they could be swapped — which is an opt-in mode now,
   so the rows can be too. Measured: 117px of a 624px week block spent
   saying "nothing today". */
.week-block:not(.is-swapping) .day-table tr[data-date].is-rest { display: none; }
/* Every day row carries its own date, so the header's range is telling
   you something the rows below repeat six times over. It earns its place
   only while the week is folded shut. */
.week-block[open] .week-dates { display: none; }
.week-block:not([open]) .week-vdot-tag { display: none; }
/* Quiet until pressed — this is a mode switch for one week, not an action
   competing with the schedule it sits above. */
.week-swap-toggle {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  flex: none;
  padding: 4px 10px;
  border: 1px solid var(--color-border);
  border-radius: 999px;
  background: none;
  color: var(--color-text-muted);
  font: inherit;
  font-size: var(--text-xs);
  font-weight: 600;
  cursor: pointer;
  transition: color 0.12s ease, border-color 0.12s ease, background 0.12s ease;
}
.week-swap-toggle .icon { width: 13px; height: 13px; vertical-align: 0; }
.week-swap-toggle:hover { color: var(--color-text); border-color: var(--color-text-muted); }
.week-swap-toggle[aria-pressed="true"] {
  color: var(--color-primary-dark);
  border-color: var(--color-primary);
  background: var(--color-primary-light);
}
.week-swap-toggle:focus-visible { outline: 2px solid var(--color-primary); outline-offset: 2px; }

/* Lets the table scroll horizontally on its own if it's ever too wide for
   the screen (long labels, very small phones) instead of silently clipping
   against .week-block's overflow:hidden. */
.table-scroll { overflow-x: auto; -webkit-overflow-scrolling: touch; }
.day-table { width: 100%; border-collapse: collapse; }
.day-table th {
  text-align: left;
  font-size: var(--text-xs);
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: var(--tracking-wide);
  color: var(--color-text-muted);
  padding: 8px 16px;
  border-bottom: 1px solid var(--color-border);
}
.day-table td {
  padding: 10px 16px;
  border-bottom: 1px solid var(--color-border);
  font-size: var(--text-base);
  vertical-align: middle;
}
.day-table tr:last-child td { border-bottom: none; }
/* Softer than a full --color-border hairline. With the rest days gone
   the table is three sessions, each already separated by its own padding
   and, on the structured ones, a colored bar — a full-strength rule under
   every one of them was drawing more lines than there is content to
   divide. Kept rather than removed outright: the structure bar and the
   completed-session card belong to the row above them, and without any
   rule at all it stops being obvious where one session ends. */
.day-table td,
.structure-row td,
.completed-analysis-row td { border-bottom-color: color-mix(in srgb, var(--color-border) 55%, transparent); }
.day-table tr.is-rest { color: var(--color-text-muted); }
/* Today's row — the same session the hero card at the top of the result
   is about (see renderTodayCard in js/app.js), so scrolling down into the
   schedule lands on something recognizable rather than a wall of
   identical rows. Declared BEFORE is-race/is-swap-selected/is-completed
   on purpose: on race day, mid-swap, or once a session is logged, those
   states are the more useful thing for the row to be saying, and equal
   specificity means the later rule wins. */
.day-table tr.is-today { background: var(--color-primary-light); box-shadow: inset 3px 0 0 var(--color-primary); }
.day-table tr.is-race { background: var(--color-primary-light); font-weight: 700; }
/* Highlights a row picked as a day-swap's first side while its second
   click is still pending — see handleSwapDayClick in js/app.js. */
.day-table tr.is-swap-selected { background: var(--color-primary-light); box-shadow: inset 3px 0 0 var(--color-primary); }
/* Drag-and-drop day swap (desktop mouse only — see handleDayDragStart's
   comment in js/app.js): the row being dragged fades out, a row it's
   currently hovering over gets a dashed outline as a valid-drop cue. */
.day-table tr[draggable="true"] { cursor: grab; transition: background 0.1s; }
.day-table tr.is-dragging { opacity: 0.4; }
.day-table tr.is-drag-over { outline: 2px dashed var(--color-primary); outline-offset: -2px; }
/* Row-hover feedback (skipped on rows already carrying a state tint, so
   hovering a race/swap-selected row doesn't visually flatten it back to
   neutral) — a plain white/dark row otherwise gives no hint that it's
   draggable or has a swap button tucked in its last cell. */
.day-table tr[draggable="true"]:not(.is-race):not(.is-swap-selected):not(.is-today):hover { background: var(--color-bg); }
/* One of these sits on every day of every week — 83 of them on a 12-week
   plan. As bordered boxes they read as 83 competing controls; the schedule
   is meant to be read far more often than edited. So: no border, low
   opacity at rest, and full strength once the pointer is anywhere on that
   row. Touch has no hover, so the mobile rules further down restore a
   permanently visible (and larger) version — see .day-table tr[data-date]
   .swap-day-btn under the 600px query. */
/* Fully opaque, unlike the near-invisible hover-to-reveal glyph this used
   to be: it now only exists while the runner has explicitly asked for it
   (see .week-swap-toggle), and a control you just switched on should not
   then hide from you. */
.swap-day-btn {
  float: right;
  border: 1px solid var(--color-border);
  background: transparent;
  color: var(--color-text-muted);
  border-radius: 50%;
  width: 26px;
  height: 26px;
  align-items: center;
  justify-content: center;
  line-height: 1;
  cursor: pointer;
  transition: border-color 0.12s ease, color 0.12s ease;
}
.swap-day-btn .icon { width: 15px; height: 15px; vertical-align: 0; }
/* Hidden until this week's "Tukar hari" toggle is on (see .week-toolbar
   above). Seven outlined boxes down every open week were a permanent
   fixture for an occasional action, with no hover on a phone to hide
   them behind. Rows stay draggable regardless, so the desktop
   drag-and-drop path is untouched by this. */
.swap-day-btn { display: none; }
.week-block.is-swapping .swap-day-btn { display: inline-flex; }
.swap-day-btn:hover,
.is-swap-selected .swap-day-btn { border-color: var(--color-primary); color: var(--color-primary); }

.type-badge {
  display: inline-block;
  padding: 4px 10px;
  border-radius: 999px;
  font-size: var(--text-sm);
  font-weight: 700;
  letter-spacing: var(--tracking-normal);
  color: #fff;
}
/* Rest days in the schedule table (see renderDayRow in js/app.js — the
   hero card's own badge is deliberately NOT this). "Rest / Strength
   Training" is the longest string in the table and shows up three times
   in a typical week; as a filled pill it was the loudest thing on the
   screen, louder than the sessions it sits between. It keeps its color,
   set inline as `color` instead of `background`, and drops the fill and
   the horizontal padding so it reads as a label rather than an object. */
/* Doubled-up selector so it outranks the plain .type-badge padding the
   phone block at the bottom of this file sets, whichever order they end
   up in. */
.type-badge.type-badge-quiet {
  padding-left: 0;
  padding-right: 0;
  font-weight: 600;
  background: none;
}

/* A day with no run at all (see renderDayRow): the Sesi cell shows the
   same "—" its Jarak/Pace cells do, in the same muted color, so an empty
   day reads as empty across the whole row instead of announcing itself
   with the table's longest label. */
.type-badge.type-badge-none {
  color: var(--color-text-muted);
  font-weight: 500;
}

/* Marks a day whose scheduled session has a matching completed Strava
   activity — added after the fact by markCompletedSessionsFromStrava in
   js/app.js, not part of the initial render, so this stays a lightweight
   overlay rather than a distinct row variant baked into day-table styles.
   Applied to the day row itself plus its structure-bar/analysis sibling
   rows (see markCompletedSessionsFromStrava) so the tint + left stripe
   reads as one continuous "done" block, not just the top row of it. A
   left border (rather than relying on the background tint alone, which a
   colored type-badge/structure-bar can wash out) is what actually makes a
   completed session distinguishable from a merely-scheduled one at a
   glance. */
.day-table tr.is-completed {
  background: var(--color-accent-light);
  box-shadow: inset 3px 0 0 var(--color-accent);
}
.completed-badge {
  display: inline-flex;
  align-items: center;
  margin-left: 6px;
  font-size: var(--text-md);
  cursor: default;
}

/* Per-session analysis (actual distance/pace/duration vs. what was
   planned) — filled in and unhidden alongside the completed badge above, once a
   matching Strava activity is found; see markCompletedSessionsFromStrava. */
.completed-analysis-row td { padding: 0 16px 12px; border-bottom: 1px solid var(--color-border); }
.completed-analysis {
  font-size: var(--text-sm);
  color: var(--color-accent);
  background: var(--color-accent-light);
  border-radius: var(--radius-item);
  padding: 6px 10px;
  display: inline-block;
}

/* Per-rep splits ("rep 6:31 / 6:25 / 6:23 / 6:28") appended to the line
   above — supporting detail behind the average, so it's dimmed to sit
   under the headline pace rather than competing with it. Tabular figures
   keep the times on a consistent width, which is what makes a rep-to-rep
   drift readable at a glance instead of needing to be read digit by
   digit. */
.completed-analysis .rep-splits {
  opacity: 0.75;
}

/* Grade-adjusted pace, shown beside the raw pace on a rolling-route
   session (see buildPaceComparisonLabel in js/app.js). Dimmed like the rep
   splits above and for the same reason: the number on the runner's watch
   is the headline, and this is the correction that explains the verdict
   sitting after it. */
.completed-analysis .gap-note {
  opacity: 0.75;
}

/* Workout-structure bar: warm up / work / recovery / cool down, proportional
   to duration — a quick visual read of what an interval or tempo session
   actually feels like. */
.structure-row td { padding: 2px 16px 14px; border-bottom: 1px solid var(--color-border); }
.structure-row.is-rest td { border-bottom: none; }
.workout-structure { display: flex; flex-direction: column; gap: 6px; }
.structure-bar {
  display: flex;
  align-items: stretch;
  height: 14px;
  border-radius: 999px;
  overflow: hidden;
  gap: 2px;
  background: var(--color-border);
}
.structure-seg { flex: 0 0 auto; min-width: 3px; } /* color set inline per segment — see renderWorkoutStructure in js/app.js */
.structure-caption {
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  line-height: var(--leading-normal);
}

/* Diberi warna warning (bukan abu-abu muted seperti dulu) supaya catatan
   "ini bukan pengganti saran medis" benar-benar kebaca, tidak terlewat
   sebagai footer biasa. Tokennya sudah punya pasangan dark-mode. */
.disclaimer {
  margin-top: 26px;
  font-size: var(--text-sm);
  color: var(--color-warning);
  background: var(--color-warning-bg);
  border: 1px solid var(--color-warning-border);
  border-radius: var(--radius-item);
  padding: 12px 14px;
  font-weight: 500;
}
.disclaimer .icon { color: var(--color-warning); }

/* ---------- Footer ---------- */
.site-footer {
  text-align: center;
  color: var(--color-text-muted);
  font-size: var(--text-sm);
  padding: 24px 0 36px;
}
/* The two lines are one block; the 6px between them comes from
   .strava-credit below, not from a default 1em paragraph margin. */
.site-footer p { margin: 0; }
/* The attribution Strava's brand guidelines require on any view showing
   their data — which here is every logged-in screen (weekly volume,
   longest run, recent race and the completed-session checkmarks all come
   from Strava), so it lives in the footer rather than being repeated per
   surface. In their orange, like the button, and for the same reason. */
/* Scoped to the footer so it outranks the paragraph reset just above,
   which is a class+element selector and would otherwise win. */
.site-footer .strava-credit {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 6px;
  color: #fc4c02;
  font-size: var(--text-sm);
  font-weight: 600;
  letter-spacing: var(--tracking-normal);
  margin: 6px 0 0;
}

/* ---------- Print ---------- */
@media print {
  /* Browsers strip background colors/images when printing by default (to
     save ink) unless a page opts back in — without this, the type badges
     and the workout-structure bars (both pure background-color, no visible
     border/text of their own) print as blank/invisible instead of matching
     what's shown on screen. */
  * {
    -webkit-print-color-adjust: exact !important;
    print-color-adjust: exact !important;
    color-adjust: exact !important;
  }
  /* Paper is light, so this only has to undo a reader who happens to have
     dark mode switched on. The selector has to out-specify
     :root[data-theme="dark"], hence the doubled attribute.

     It restates the LIGHT values, and they have to be exactly the light
     values: this block used to carry a whole palette of its own from back
     when dark was the theme, and it was never brought back in step — it
     printed a page-grey of #f5f6f8 against the screen's #eceff6, ink of
     #1c2230 against #151a26, a --color-primary-dark of #2a4368 (a slate
     blue) where the app's is #4338ca (indigo), and a --color-accent that
     was a different green. So a printed plan quietly came out in a
     seventh-hand palette nobody was maintaining. Keep these in sync with
     :root at the top of this file; anything not listed here is either
     identical in both themes or not printed. */
  :root[data-theme][data-theme] {
    color-scheme: light;
    --color-bg: #eceff6;
    --color-surface: #ffffff;
    --color-text: #151a26;
    --color-text-muted: #697388;
    --color-border: #e3e7f0;
    --color-primary: #4f46e5;
    --color-primary-dark: #4338ca;
    --color-primary-light: rgba(79, 70, 229, 0.1);
    --color-accent: #0d8f72;
    --color-accent-light: rgba(13, 143, 114, 0.1);
    --today-wash: #d2d6f8;
    --type-recovery: #776a5e;
    --type-easy: #3f7530;
    --type-longrun: #0f7460;
    --type-tempo: #8a6512;
    --type-interval: #b34f24;
    --type-repetition: #b52539;
    --type-rest: #656d7d;
    --type-rest-strength: #6f4fac;
    --type-break: #46718f;
    /* Printed too, and different in the two themes, so they belong here
       for the same reason as everything above. Without them a plan
       printed FROM dark mode kept the dark warning gold (#f0c66a, which
       all but disappears as .result-warning's left rule on white paper),
       the dark VDOT series green, and --chart-dim 0.4 — a value tuned to
       recede against near-black, which on paper washed every not-yet-run
       volume bar most of the way out. */
    --color-error: #c0392b;
    --color-error-bg: #fdeceb;
    --color-error-border: #f3c9c4;
    --color-warning: #8a6100;
    --color-warning-bg: #fff6e2;
    --color-warning-border: #f0dca8;
    --chart-measured: #0d8f72;
    --chart-dim: 0.62;
    --chart-dim-hover: 0.74;
  }
  .site-header, .site-footer, #formSection, .no-print { display: none !important; }
  /* Tabs are a screen affordance — paper has no "other tab" to switch
     to, so the inactive panel prints in full rather than vanishing. */
  .plan-tab-panel[hidden] { display: block !important; }
  /* The folded pace-zone table has no rule here on purpose: it is opened
     for real by a beforeprint handler in js/app.js, because a closed
     <details> hides its content through the UA's own shadow slot, which
     no author rule reliably overrides. */
  body { background: var(--color-surface); color: var(--color-text); }
  /* A glow is a screen effect sized for a dark background; on white paper
     it just blurs the bar's edge. .volume-chart-done's glow is set inline
     (color-mixed from that week's own phase color — see renderVolumeChart)
     so a plain class rule can't reach it; !important is the one place in
     this file that's the right tool, specifically to beat an inline style
     rather than to fight this stylesheet's own cascade. */
  .volume-chart-done { box-shadow: none !important; }
  .card { box-shadow: none; padding: 0; border: none; }
  .week-block { break-inside: avoid; }
  /* The accordion (see .week-block[open] above) is a screen-only reading
     aid — printing should always show every week's full schedule
     regardless of which one happens to be open on screen. */
  .week-block:not([open]) > *:not(summary) { display: block !important; }
  .week-toggle-icon { display: none; }
  /* Sticky is a scrolling affordance and nothing else — on paper it can
     only cause a repeated header at a page break. Same for the reveal
     animation, which has nothing to reveal in a printed document. */
  summary.week-header { position: static; }
  /* Controls, not content — the swap toggle has nothing to do on paper,
     and the buttons it governs are already hidden by default. */
  .week-swap-toggle { display: none; }
  .week-block[open] > *:not(summary),
  .result-warning[open] > *:not(summary) { animation: none; }
  /* Same reasoning for the plan warnings: folding them away is a
     screen-reading convenience, but a printed plan should carry them. */
  .result-warning:not([open]) > *:not(summary) { display: block !important; }
  .result-warning-summary::after { display: none; }
}

/* ---------- Responsive ---------- */
@media (max-width: 600px) {
  .card { padding: 20px 16px 24px; }
  /* The hours/minutes/seconds triplets are the smallest tap targets on
     the form and the only ones a thumb has to hit three times in a row —
     inputmode="numeric" in index.html brings up the number pad, this
     gives them a target worth aiming at. */
  .field-small input { padding: 14px 10px; }
  /* No room for it beside the title at this width, and the numbered
     circle already carries the sequence on its own. */
  .legend-title { min-width: 0; }
  /* Judul + ringkasan sudah bertumpuk di .legend-text untuk semua ukuran
     layar, jadi tidak ada lagi yang perlu dipindah baris khusus di sini.
     Yang tersisa cuma merapatkan paddingnya. */
  .legend-toggle { padding: 10px 12px; gap: 9px; }
  fieldset { padding: 0 12px 12px; }
  legend { width: calc(100% + 24px); margin: 0 -12px 12px; }

  /* One field per row, with the hours/minutes/seconds triplets as the
     single exception. .field's 220px flex-basis meant a phone got two
     fields side by side whenever two happened to fit — a full-width
     select next to a 90px "Jarak custom (km)" whose label needed two
     lines to say so — and then wrapped the leftovers into a ragged
     third line. Given a whole row each, nothing is squeezed and nothing
     sits beside anything it has to line up with. */
  .field-row > .field { flex-basis: 100%; }
  .field-row > .field-time { flex: 1 1 0; min-width: 0; }
  /* Which is also what retires the two-line label reservation here: with
     one field per row there is no neighbour to align an input against,
     so all that 3.1em bought on a phone was a conspicuous gap between
     every one-line label and its own input. The triplets keep their
     inputs aligned on their own — "Jam"/"Menit"/"Detik" are one line
     each by construction. */
  .field-row .field > label { min-height: 0; }
  .brand-name { font-size: var(--text-lg); }
  .brand-logo { width: 29px; height: 29px; }
  .site-header { padding: 20px 0 32px; }
  /* See .summary-grid's own comment — 140px is the widest that still
     yields two columns on a 375px screen. */
  .summary-grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
  /* Two buttons now, not four (the other three moved into the overflow
     menu — see .result-actions in index.html), so they fit side by side
     on one line at every phone width instead of stacking into 195px of
     chrome above the plan. */
  .result-actions { gap: 8px; }
  .plan-tabs { margin-top: 18px; width: auto; }
  .plan-tab { min-width: 0; padding: 8px 10px; font-size: var(--text-sm); }
  .result-actions > .btn, .actions-more > .btn { flex: 1 1 auto; }
  /* On a phone this stops being a dropdown at all and becomes a bottom
     sheet. Anchoring it to the button never worked here: left:0 AND
     right:0 pinned it to the ~110px "Lainnya" button so every item wrapped
     onto three lines, and anchoring to the button's own edge sends the
     panel off-screen the moment the two buttons wrap and "Lainnya" starts
     the second line. Both were fights over where a menu could fit near
     the top of the screen — where, with nine rows, it also covered the
     plan it was about and sat furthest from the thumb. A sheet at the
     bottom sidesteps all of it: full width (so no wrapping), reachable,
     and it dims the page behind so the menu reads as a decision to make
     rather than a panel floating over the card. */
  .actions-more { position: static; }
  .actions-menu {
    position: fixed;
    top: auto;
    right: 0;
    bottom: 0;
    left: 0;
    z-index: 100;
    min-width: 0;
    max-width: none;
    /* env() so the last row clears the home indicator on a gesture-nav
       phone instead of sitting under it. */
    padding: 20px 10px calc(14px + env(safe-area-inset-bottom, 0px));
    border-width: 1px 0 0;
    border-radius: var(--radius) var(--radius) 0 0;
    max-height: 82vh;
    animation: actions-sheet-in 0.2s ease-out;
  }
  /* The scrim, as a spread shadow rather than an overlay element or a
     pseudo-element: both of those have to sit somewhere in the stacking
     order relative to the sheet's own content, and a ::before with a
     negative z-index inside the sheet's stacking context washes the rows
     out instead of the page. A shadow paints strictly OUTSIDE the sheet
     and is not hit-testable, so a tap on the dimmed page lands on the
     page — which is exactly the outside-click the close handler in
     js/app.js already watches for. */
  .actions-menu { box-shadow: 0 0 0 100vmax rgba(9, 12, 22, 0.45), var(--shadow); }
  /* The grab handle. It does nothing (there is no drag gesture to wire
     up) beyond saying "this is a sheet, it goes away downwards" — which
     is the whole reason a first-time tapper doesn't feel trapped. */
  .actions-menu::after {
    content: '';
    position: absolute;
    top: 8px;
    left: 50%;
    width: 38px;
    height: 4px;
    /* Was margin-left: -19px — half the width, negated, which is a
       centring trick that silently goes off-centre the moment the width
       changes (and is not a spacing value, so it did not belong on the
       grid either). translateX does the same job from the width itself. */
    transform: translateX(-50%);
    border-radius: 999px;
    background: var(--color-border);
  }
  @keyframes actions-sheet-in {
    from { transform: translateY(14%); opacity: 0.6; }
    to { transform: none; opacity: 1; }
  }
  @media (prefers-reduced-motion: reduce) {
    .actions-menu { animation: none; }
  }
  /* Tighter than the desktop menu, not looser. A full-width row is
     already an easy target across a phone's whole width, so the vertical
     padding was buying nothing but sheet height — and every pixel of
     sheet height is a pixel of the plan behind it that the runner can no
     longer see while deciding. 6px still leaves each row ~48px tall
     (32px icon tile + padding), so nothing here gets harder to hit.
     Side padding stays trimmed: the sheet's own edge is the screen's. */
  .actions-menu-item { padding: 6px 8px; }
  /* Same trade on the headings and on the rule above the reset row —
     they were spaced for a floating panel, not for a sheet. */
  .actions-menu-label { margin: 8px 10px 2px; }
  .actions-menu-label:first-child { margin-top: 0; }
  .actions-menu-group-last { margin-top: 6px; padding-top: 6px; }

  /* Today's session, above the heading and the buttons. Even with the row
     above trimmed to one line, "Training Plan Kamu" plus four action
     buttons plus the AI intro paragraph all outranked the answer to the
     question this page is actually opened with. display:contents lifts
     the heading and the action row out of .result-header so all three can
     be ordered against each other; the DOM order is untouched, which is
     what keeps the desktop layout (and the print stylesheet) as it was.
     Order is: anything urgent (the missed-week banner), today, the
     heading, then the actions. Spacing has to be restated here:
     .result-header's own `gap` goes away with display:contents, and the
     h2 carries `margin: 0`. */
  .result-card { display: flex; flex-direction: column; }
  .result-header { display: contents; }
  .missed-week-banner { order: -5; }
  .weekly-recap { order: -4; }
  #todayCard { order: -3; margin-top: 0; }
  .result-header h2 { order: -2; margin: 20px 0 10px; }
  /* The gap above these used to be the h2's own top margin. The header
     bar names the screen now, so that h2 is hidden on this screen and
     took its spacing with it, leaving the buttons pressed against the
     bottom edge of the today card. */
  .result-actions { order: -1; margin-top: 18px; }

  /* Five columns never actually fit here. .table-scroll kept the page from
     breaking, but Pace Target — and the swap button that lives inside
     that cell, i.e. the plan's main interaction — sat past the visible
     edge, invisible unless you knew to swipe sideways. So below this width
     each day becomes a stacked block instead: day + date, then the session
     badge, then distance + pace as labelled lines, with the swap button
     lifted to the block's top-right corner where a thumb can reach it.
     Same <table> markup either way — every tr/td selector in js/app.js
     (completed-session matching, swap, drag-and-drop) still finds exactly
     what it did before; only the layout changes. */
  .day-table thead { display: none; }
  .day-table, .day-table tbody, .day-table tr, .day-table td { display: block; }
  /* tr[data-date] is precisely the day rows — the structure-bar and
     completed-analysis rows below them carry no data-date and stay plain
     full-width blocks (their single colspan=5 cell needs no grid). */
  /* Two rows, not three. The session badge used to get a line to itself
     with distance and pace on a third line below it, which made every
     ordinary session four lines tall once its structure bar is counted.
     Badge, distance and pace now share row two as three aligned columns,
     so they read down the week as a small table rather than as seven
     stacked paragraphs. Column 1 is sized by the widest badge, which is
     what puts every day name and every badge on a common left edge. */
  .day-table tr[data-date] {
    position: relative;
    display: grid;
    /* A fixed first column so the dates line up down the week. Every <tr>
       is its own independent grid, so with an auto first column each row
       sized it to its own day name and the dates came out ragged. 60px
       clears the longest ("Minggu"). */
    grid-template-columns: 60px 1fr auto auto;
    grid-template-areas:
      "hari tanggal tanggal tanggal"
      "sesi sesi    km      pace";
    align-items: center;
    column-gap: 10px;
    row-gap: 4px;
    padding: 10px 6px;
    /* The separator moves from the cells to the block itself — the cells
       are no longer a row, so a border on each would draw three lines. */
    border-bottom: 1px solid var(--color-border);
  }
  /* Only while this week's swap buttons are showing — the reserved gutter
     is dead space the rest of the time. */
  .week-block.is-swapping .day-table tr[data-date] { padding-right: 44px; }
  .day-table tr[data-date] > td { padding: 0; border-bottom: none; font-size: var(--text-sm); }
  .day-table tr[data-date] > td:nth-child(1) { grid-area: hari; font-weight: 600; }
  .day-table tr[data-date] > td:nth-child(2) { grid-area: tanggal; color: var(--color-text-muted); }
  .day-table tr[data-date] > td:nth-child(3) { grid-area: sesi; }
  /* Right-aligned so the distances form a clean number column down the
     week (the cells already carry tabular-nums), with the pace, on the
     rows that still show one, hanging off the end of it. */
  .day-table tr[data-date] > td:nth-child(4) { grid-area: km; text-align: right; font-weight: 600; }
  .day-table tr[data-date] > td:nth-child(5) { grid-area: pace; text-align: right; }
  /* No reattached uppercase <thead> label: it would sit above the value as
     a column header for a one-value column. The naming happens inline
     instead, through the "Pace:" prefix below. */
  .day-table tr[data-date] > td[data-label]::before { content: none; }
  /* Muted and a touch smaller, so the zone reads as a qualifier on the
     distance beside it rather than as a second figure competing with it. */
  .day-table tr[data-date] .pace-text {
    color: var(--color-text-muted);
    font-size: var(--text-sm);
  }
  /* A bare "Easy" or "Tempo" hanging off the end of the distance doesn't
     say what it measures once there's no column header, so the zone names
     itself here. Only the five zone labels get the prefix — see
     renderDayRow in js/app.js for why "Race Pace" and "Time Trial" don't
     (they'd stutter), which is what .is-zone-pace marks. */
  .day-table tr[data-date] .pace-text.is-zone-pace::before { content: "Pace: "; }
  /* A rest day has no distance and no pace target — "— —" under two
     labels is noise, so both lines are dropped. The swap button is NOT:
     rest days are swappable like any other, and it sits in the pace cell,
     which is why only that cell's text (not the cell) is hidden. */
  .day-table tr[data-date].is-rest > td:nth-child(4) { display: none; }
  .day-table tr[data-date].is-rest .pace-text { display: none; }
  /* With those two lines gone a rest day is one line of content, yet it
     still occupied a block the same height as a session with a distance,
     a pace and a structure bar. In a 4-day week that is three of the
     seven blocks given the weight of a training day for saying "nothing
     today" — so day, date and badge collapse onto a single row and the
     whole block gets a tighter rhythm. */
  /* Fixed date column here too (session rows get theirs from the 1fr):
     with it auto-sized, "7 Sep" and "11 Sep" started the label at
     different x and the rest days read as a ragged column. */
  .day-table tr[data-date].is-rest {
    grid-template-columns: 60px 58px 1fr;
    grid-template-areas: "hari tanggal sesi";
    padding-top: 8px;
    padding-bottom: 8px;
    row-gap: 0;
  }
  /* The absolute top: 8px below is measured for a three-row block; on a
     single-row one it would hang past the bottom edge. */
  .day-table tr[data-date].is-rest .swap-day-btn { top: 50%; transform: translateY(-50%); }
  /* Lifted out of the pace cell to the block's top-right corner, where a
     thumb can reach it, and sized to a 34px tap target. */
  .day-table tr[data-date] .swap-day-btn {
    float: none;
    position: absolute;
    top: 8px;
    right: 4px;
    width: 34px;
    height: 34px;
  }
  .type-badge { padding: 4px 8px; font-size: var(--text-xs); }
  .structure-row td { padding: 2px 6px 12px; }
  .structure-caption { font-size: var(--text-xs); }

  /* Same problem as .day-table above, worse: .table-scroll made this
     horizontally scrollable, but with no scroll affordance the "Pace /km"
     column (the actual number a runner needs) sat past the visible edge on
     a phone, invisible unless you knew to swipe. Tightened enough that all
     3 columns fit without scrolling on a 375px-wide screen.
     The earlier fix over-corrected: at 0.62/0.71rem the paces — the one
     thing this table exists to show — were smaller than every other number
     on the page. The three columns are short strings ("Marathon",
     "105–120%", "7:07–7:48 /km"), so they still clear a 375px screen at
     the real type scale with tight padding. */
  .pace-zone-table th { padding: 6px 4px; font-size: var(--text-2xs); letter-spacing: 0.02em; }
  .pace-zone-table td { padding: 7px 4px; font-size: var(--text-sm); }
  .pace-zone-table .zone-name { gap: 6px; }
  .pace-zone-table .zone-dot { width: 8px; height: 8px; }
  /* Two lines of methodology footnote set at the same size as the paces
     above it read as body copy on a phone. It is a citation, not content. */
  .pace-zone-note { font-size: var(--text-2xs); line-height: 1.45; margin-top: 8px; }
  /* "per 7 September 2026" repeats the date range in the week header two
     lines above, and on a phone it is what pushes this line onto a second
     row beside the swap toggle. */
  /* Label beside value only works while both fit on one line. Under the
     zone table the second of these reads "Estimasi dari race terakhir /
     19:43 / 3.2 km → 1:05:58 10K", which on a phone is a two-line label
     baseline-aligned against a two-line value — four lines of text with
     no straight edge anywhere in them. Stacked, each is a caption over
     its own number, and the two entries read as a short list. */
  .pace-goal-row { flex-direction: column; gap: 10px; }
  .pace-goal-item { flex-direction: column; align-items: flex-start; gap: 2px; }
  /* The fixed-width title/total columns (see .week-header above) are
     sized for desktop — on a narrow phone screen they'd squeeze the phase
     text down to almost nothing, so stack title / phase / (total +
     chevron) as their own full-width rows instead of forcing 4 columns
     into too little space. */
  .week-header {
    padding: 10px 12px;
    grid-template-columns: 1fr auto;
    grid-template-areas: "title title" "phase phase" "total chevron";
    row-gap: 4px;
  }
  .week-title-group { grid-area: title; }
  .week-header .week-phase { grid-area: phase; }
  .week-header .week-total { grid-area: total; }
  .week-toggle-icon { grid-area: chevron; }
}

/* Twin of .week-break-badge above, in race indigo rather than break blue —
   see TYPE_COLORS.bRace in js/app.js for why a B-race deliberately shares
   the A race's color instead of taking a tenth hue in this palette. */
.week-brace-badge {
  display: inline-block;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-wide);
  color: var(--color-primary-dark);
  background: var(--color-primary-light);
  border: 1px solid var(--type-race);
  border-radius: 999px;
  padding: 2px 8px;
}

/* The B-race result offer (see #bRaceResultOffer in index.html).
   Deliberately NOT .missed-week-banner's error red: nothing has gone
   wrong, the runner just raced and there's something useful to do with
   it. Accent-tinted instead — the same "here's an opportunity" register
   the AI-notes intro uses, one step above plain card chrome so it isn't
   missed, well short of an alarm. */
.brace-offer {
  border: 1px solid var(--color-primary);
  border-left: 3px solid var(--color-primary);
  margin: 16px 0;
  display: flex;
  flex-direction: column;
  gap: 12px;
  font-size: var(--text-base);
}
.brace-offer p { margin: 0; line-height: var(--leading-relaxed); }
.brace-offer-input { display: flex; flex-direction: column; gap: 6px; }
.brace-offer-input > label {
  font-size: var(--text-base);
  font-weight: 600;
  color: var(--color-text-muted);
}
/* Three narrow number fields rather than one free-text box: a finish time
   typed as text arrives in every format a runner can imagine ("48:12",
   "48.12", "0:48:12", "48 menit"), and each of those has to either be
   parsed or rejected. Split fields make the shape unambiguous without a
   single validation message. */
.brace-offer-time { gap: 8px; }
.brace-offer-time .field-small { max-width: 90px; }

/* #fitnessAdjustStrip — the standing "your paces came from your training,
   not from the form" line. Deliberately quieter than .brace-offer above:
   that one is asking a question and has to be noticed, this one is a
   persistent statement of fact that sits on the page for weeks. Muted
   chrome, one line, no card. */
.fitness-strip {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
  margin: 12px 0;
  padding: 8px 12px;
  border-left: 3px solid var(--color-border);
  /* --color-bg, the same recessed surface every other quiet strip in
     this file sits on. This used to name a --color-surface-muted defined
     in neither theme, falling back to a theme-neutral gray wash that
     tracked neither the light page nor the dark one. */
  background: var(--color-bg);
  border-radius: var(--radius-chrome);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
  line-height: var(--leading-normal);
}
.fitness-strip .btn { flex: none; }

/* The applied-result line inside a B-race's row in #bRacesList, with its
   own undo. Sits under the race's cost line rather than beside it: on a
   phone the two together are already two lines, and a third inline
   element would push the remove button off the row. */
.brace-result-line {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-wrap: wrap;
  margin-top: 4px;
  color: var(--color-primary);
}
.brace-result-line .icon { flex-shrink: 0; }
.brace-result-undo { font-size: var(--text-sm); }


/* --- Tren VDOT: target plan vs pembacaan terukur -------------------------
   Marks follow the fixed specs: 2px lines with round joins, markers at
   r=4 (8px) carrying a 2px ring in the surface colour so they stay legible
   where they cross a line, hairline SOLID gridlines one step off the
   surface (never dashed — dashing reads as "projection" when it is just a
   grid), and text in text tokens rather than the series colour. */
.vdot-trend {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-sm);
  padding: 18px 18px 14px;
  margin-top: 18px;
}
.vdot-trend-head { margin-bottom: 4px; }
.vdot-trend-title {
  margin: 0;
  font-size: var(--text-base);
  font-weight: 700;
  color: var(--color-text);
}
.vdot-trend-sub {
  margin: 6px 0 0;
  font-size: var(--text-sm);
  line-height: var(--leading-relaxed);
  color: var(--color-text-muted);
}
/* Status first, in ink: it is the runner's own number and the one line
   they re-read every visit. The gloss below it explains the two series
   once, and is sized to be skippable. */
.vdot-trend-status {
  display: block;
  color: var(--color-text);
  font-size: var(--text-sm);
  font-weight: 600;
}
.vdot-trend-gloss {
  display: block;
  margin-top: 4px;
  max-width: 62ch;
  font-size: var(--text-sm);
}
.vdot-trend-legend {
  display: flex;
  flex-wrap: wrap;
  gap: 16px;
  margin: 14px 0 0;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}
.vdot-trend-legend-item { display: flex; align-items: center; gap: 8px; }
/* A line key, matching the mark it stands for (both series are lines). */
.vdot-trend-legend-key {
  width: 18px;
  height: 0;
  border-top: 2px solid currentColor;
  flex: none;
}
.vdot-trend-legend-item.is-planned .vdot-trend-legend-key { color: var(--chart-planned); }
.vdot-trend-legend-item.is-measured .vdot-trend-legend-key { color: var(--chart-measured); }
/* Sized to include the x-axis band, so the axis labels are never cropped
   into their own nested scrollbar. */
.vdot-trend-plot { position: relative; margin-top: 2px; }
.vdot-trend-plot svg { display: block; width: 100%; height: auto; touch-action: pan-y; }

/* Hairlines, and quieter than the border they borrow their colour from:
   at full strength four of them read as a table rather than as a
   background the two lines sit on. */
.vt-grid { stroke: var(--color-border); stroke-width: 1; opacity: 0.55; }
.vt-axis-text {
  fill: var(--color-text-muted);
  font-size: var(--text-2xs);
  opacity: 0.85;
}
.vt-line { fill: none; stroke-width: 2; stroke-linejoin: round; stroke-linecap: round; }
.vt-line--planned { stroke: var(--chart-planned); }
.vt-line--measured { stroke: var(--chart-measured); }
/* The handover from the last reading to the plan's next target. Dashed so
   it never reads as measured data, and stroked with a gradient between the
   two series colours so it visibly belongs to neither. */
.vt-bridge {
  stroke: url(#vtBridgeGrad);
  stroke-width: 2;
  stroke-dasharray: 4 4;
  opacity: 0.85;
}
.vt-bridge-stop--measured { stop-color: var(--chart-measured); }
.vt-bridge-stop--planned { stop-color: var(--chart-planned); }
.vt-dot { stroke: var(--color-surface); stroke-width: 2; }
.vt-dot--planned { fill: var(--chart-planned); }
.vt-dot--measured { fill: var(--chart-measured); }
/* Hollow: a reading that was asserted or estimated, not raced. The surface
   fill keeps it readable where it sits on top of the target line, and the
   ring stays the series colour so it still reads as the same series. */
.vt-dot--asserted {
  fill: var(--color-surface);
  stroke: var(--chart-measured);
}
/* End labels wear text ink; the coloured marker beside them carries identity. */
.vt-end-label { fill: var(--color-text); font-size: var(--text-2xs); font-weight: 700; }
.vt-crosshair { stroke: var(--color-text-muted); stroke-width: 1; opacity: 0.5; }
.vt-hit { fill: transparent; }

.vdot-trend-tip {
  position: absolute;
  z-index: 4;
  pointer-events: none;
  min-width: 132px;
  padding: 8px 10px;
  border-radius: var(--radius-item);
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  /* The shared card shadow, not a literal of its own: the value that used
     to be here (a fixed black at 13%) was tuned for the light theme and
     was the only elevation in this file that did not flip with the theme,
     so on near-black it lifted nothing. */
  box-shadow: var(--shadow);
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}
.vdot-trend-tip-date { font-weight: 700; color: var(--color-text); margin-bottom: 6px; }
.vdot-trend-tip-row { display: flex; align-items: baseline; gap: 8px; margin-top: 4px; }
.vdot-trend-tip-key { width: 14px; height: 0; border-top: 2px solid currentColor; flex: none; transform: translateY(-3px); }
.vdot-trend-tip-row.is-planned .vdot-trend-tip-key { color: var(--chart-planned); }
.vdot-trend-tip-row.is-measured .vdot-trend-tip-key { color: var(--chart-measured); }
/* Value leads, series name follows — the legend's hierarchy inverted,
   because here the reader already knows the series and wants the number. */
.vdot-trend-tip-value { font-weight: 700; color: var(--color-text); }
.vdot-trend-tip-name { font-size: var(--text-xs); }
.vdot-trend-tip-note { margin-top: 6px; font-size: var(--text-xs); line-height: var(--leading-normal); }

.vdot-trend-table-wrap { margin-top: 12px; }
.vdot-trend-table-wrap > summary {
  cursor: pointer;
  font-size: var(--text-sm);
  color: var(--color-text-muted);
}
.vdot-trend-table-wrap table { width: 100%; border-collapse: collapse; margin-top: 10px; font-size: var(--text-sm); }
.vdot-trend-table-wrap th,
.vdot-trend-table-wrap td {
  text-align: left;
  padding: 6px 8px;
  border-bottom: 1px solid var(--color-border);
  white-space: nowrap;
}
.vdot-trend-table-wrap th { color: var(--color-text-muted); font-weight: 600; }
.vdot-trend-table-wrap td { color: var(--color-text); }
.vdot-trend-table-scroll { overflow-x: auto; }

/* --- Upcoming plans (#upcomingPanel) ---------------------------------
   The chain of blocks: the one running now plus whatever the runner has
   queued behind it (see the "Upcoming plans" section in js/app.js). Sits
   just above the result header, deliberately quieter than the plan itself
   — it is context for the block on screen, not a second plan competing
   with it for attention. */
.upcoming-panel {
  /* Same well treatment as .volume-chart: its own
     --color-bg back, the inset edge that back calls for (plain
     --color-border over the page colour is the hairline described at the
     token definition), and the shared panel shadow — see the
     .today-card/.summary-item/.volume-chart/.pace-legend/.week-block LIFT
     rule this selector joins. */
  background: var(--color-bg);
  border: 1px solid var(--color-border-inset);
  border-radius: var(--radius-sm);
  padding: 14px;
  margin: 0 0 20px;
  display: flex;
  flex-direction: column;
  gap: 12px;
}
.upcoming-head h3 { margin: 0; font-size: var(--text-base); }
.upcoming-desc {
  margin: 4px 0 0;
  font-size: var(--text-sm);
  line-height: var(--leading-normal);
  color: var(--color-text-muted);
}
.upcoming-list { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 8px; }
.upcoming-item {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 12px;
  /* The rows carry the surface so they read as items sitting IN the well,
     the way the bars do in .volume-chart; the edge still answers to the
     --color-bg behind the panel, so it stays the inset token. */
  background: var(--color-surface);
  border: 1px solid var(--color-border-inset);
  border-radius: var(--radius-item);
}
/* The active block is the one the page is actually about — it keeps the
   accent border so a queued block never reads as the plan on screen. */
.upcoming-item.is-current { border-left: 3px solid var(--color-primary); }
.upcoming-tag {
  flex: none;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-wide);
  text-transform: uppercase;
  color: var(--color-text-muted);
  min-width: 74px;
}
.upcoming-item.is-current .upcoming-tag { color: var(--color-primary); }
.upcoming-body { flex: 1; min-width: 0; }
.upcoming-title { margin: 0; font-weight: 600; font-size: var(--text-base); }
.upcoming-meta { margin: 2px 0 0; font-size: var(--text-sm); color: var(--color-text-muted); line-height: var(--leading-normal); }
.upcoming-remove-btn { flex: none; }
.upcoming-offer {
  border: 1px solid var(--color-primary);
  border-left: 3px solid var(--color-primary);
  border-radius: var(--radius-item);
  padding: 12px;
  display: flex;
  flex-direction: column;
  gap: 10px;
  font-size: var(--text-base);
}
.upcoming-offer p { margin: 0; line-height: var(--leading-relaxed); }
.upcoming-offer-actions { display: flex; flex-wrap: wrap; gap: 8px; }
.upcoming-add-btn { align-self: flex-start; }

/* The queue-mode banner on the form — same "nothing is being replaced"
   reassurance #backToPlanBtn exists for, said before the runner starts
   filling anything in. */
.form-queue-note {
  margin: 0 0 12px;
  padding: 10px 12px;
  border-radius: var(--radius-item);
  border: 1px solid var(--color-primary);
  font-size: var(--text-base);
  line-height: var(--leading-normal);
}

/* Baris kedua di dalam banner yang sama: angka kondisi awal yang dipakai
   blok baru, karena section "Kondisi Saat Ini" sendiri tidak ditampilkan di
   mode antrean. Sengaja satu blok terpisah, bukan menyambung kalimat di
   atasnya — isinya angka, bukan penjelasan. */
.form-queue-note-condition {
  display: block;
  margin-top: 6px;
  opacity: 0.85;
}

/* On a phone the tag column squeezed the title into a two-word-per-line
   column ("Post-Race / Recovery Plan" over five lines of dates). The tag
   is one short word — it goes above the title there instead of beside it,
   and the remove button pins to the corner rather than taking a third
   column of its own. */
@media (max-width: 560px) {
  .upcoming-item {
    position: relative;
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
  }
  .upcoming-tag { min-width: 0; }
  .upcoming-body { width: 100%; }
  .upcoming-title { padding-right: 68px; }
  .upcoming-remove-btn { position: absolute; top: 8px; right: 8px; }
}
