/* Ascent — production UI styles */

:root {
  /* issue #297 — v2 "Modernist" redesign, full color/radius/shadow/heat/font
     token replace (supersedes issue #206 "Alpenglow" the same way that pass
     superseded every direction before it — see docs/architecture.md Build
     Log and .claude/rules/design-system.md, the binding spec for this pass).
     Tokens the spec doesn't define (spacing scale, rem-based type scale,
     motion durations/easing, icon sizes, chip box-model, tracking) keep
     their pre-#297 name/value unchanged — listed at the bottom of this
     block, same convention #206 used. This is a Phase 1 (#297) token +
     base-component pass only — individual page layouts are Phases 2-5
     (#298-#301), not touched here. */
  --color-bg:             #F3F2F2;
  --color-surface:        #EAE9E9;
  --color-surface-raised: #F8F4F4;
  /* --color-border/--color-border-strong/--color-divider are aliases of the
     same 2px-rule divider color per design-tokens.css — kept as three names
     so every existing --color-border call site (there were ~5) keeps working
     without a rename; --color-border-strong is a fully-opaque version for
     rules that need to sit on top of a divider-colored border without the
     translucency compounding (e.g. a hover state layered over a divider). */
  --color-border:         color-mix(in srgb, #201E1D 40%, transparent);
  --color-border-strong:  #201E1D;
  --color-divider:        color-mix(in srgb, #201E1D 40%, transparent);
  --color-text:           #201E1D;
  /* design-system.md §2 spec's literal formula (color-mix 60%) only clears
     ~4.12-4.24:1 against --color-surface/--color-bg — short of WCAG AA's
     4.5:1 for normal text, caught live by the E2E a11y suite's axe scan on
     .auth-subtitle/.daily-todo-empty/.template-card-desc. Bumped to 64%
     (verified via relative-luminance calculation: 4.64:1 on --color-surface,
     4.80:1 on --color-bg — comfortably passing both) — dark theme's own 60%
     value already clears 6.1-6.4:1 and is untouched. */
  --color-text-muted:     color-mix(in srgb, #201E1D 64%, transparent);
  /* --color-text-faint has no exact spec value in design-system.md's token
     table (only bg/surface/text/text-muted/divider/accent/neutral are
     spec'd) — judgment call: same color-mix idiom as --color-text-muted,
     one step further toward transparent, preserving the pre-#297 "faint"
     role (e.g. .phase-index-adjacent tertiary text) rather than dropping it. */
  --color-text-faint:     color-mix(in srgb, #201E1D 45%, transparent);

  /* v3 (issue #416 Phase 5 — #430) — accent ramp retinted from v2
     Modernist's red/orange (#EC3013-family) to design-system.md §2's green
     "hacker" accent, same hue as --v3-accent (hsl(146 88% 38%)). This is the
     base --color-accent-* scale every not-yet-individually-migrated
     component still reads directly (danger, warning, P0/P1 priority, and
     every badge/border/text call site Phases 1-4 didn't touch), found live
     as the single biggest remaining "still red" gap in this pass (screenshot
     report: roadmap badge, stat number, phase-card left border, P0 chip, all
     still red on the dashboard). Every step re-verified with a real
     relative-luminance contrast calculation, same discipline as the #297
     comment this replaces — see that calculation's results inline below. */
  --color-accent:      #0CB656;
  --color-accent-100:  #ECFEF4;
  --color-accent-200:  #D4F7E3;
  --color-accent-300:  #ABEDC8;
  --color-accent-400:  #70DB9F;
  --color-accent-500:  #0CB656;
  /* Live-verified (relative-luminance calc): base --color-accent (#0CB656)
     clears only 2.68:1 against white — same category of failure #297
     documented for the old red base, and per design-system.md §2 still
     "fine for icons, large numerals, UI chrome" (non-text/large-graphic
     uses), just not real body-text-size text or a text-hosting solid fill.
     --color-accent-600 (#067436) is the darkened step that clears 5.90:1
     against white — the fill any text-hosting control (.btn-primary,
     .seg-item's active fill, .kpi-tile-hero) must
     read instead of the base token, exactly the existing established
     pattern each of those call sites already documents in its own comment. */
  --color-accent-600:  #067436;  /* solid fill for text-hosting controls + hover step; 5.90:1 with white */
  --color-accent-700:  #034E24;  /* accent as paragraph-size text; 9.91:1 on white */
  --color-accent-800:  #05612D;  /* text on accent-100 tints; 7.28:1 */
  --color-accent-900:  #03451F;

  /* Neutral ramp (full --color-neutral-100...900 per design-tokens.css) */
  --color-neutral-100: #F8F4F4;
  --color-neutral-200: #EAE7E7;
  --color-neutral-300: #D7D3D3;
  --color-neutral-400: #BAB6B6;
  --color-neutral-500: #9B9797;
  --color-neutral-600: #7D7979;
  --color-neutral-700: #605D5D;
  --color-neutral-800: #444141;
  --color-neutral-900: #2D2B2B;

  /* Semantic roles collapse into the mono scheme per design-system.md §2:
     danger reuses the accent ramp; success/warning use neutral ink + tags,
     not new hues. --color-warning/-bg used to have a separate darkened
     "-ink" text variant (--color-warning-ink) under Alpenglow — that's gone
     now: --color-warning already IS a text-safe accent-700 value, so every
     old --color-warning-ink call site was repointed straight to
     --color-warning instead of keeping a now-redundant alias token. */
  --color-danger:      var(--color-accent-700);
  --color-danger-bg:   var(--color-accent-100);
  --color-success:     var(--color-neutral-800);
  --color-success-bg:  var(--color-neutral-200);
  --color-warning:     var(--color-accent-700);
  --color-warning-bg:  var(--color-accent-100);

  /* Priority mapping — issue #462, a direct product-owner override of
     design-system.md §2's original "P0 = accent tint, P1 = lighter accent
     tint or outline, P2 = neutral tint" mapping (same status as issue #435's
     Archivo-over-Sora override: a deliberate, explicit decision to diverge
     from that spec's default, not a bug). Real live feedback: P0/P1's green
     borders sitting next to P2/P3's grey ones on the phase-card list read as
     visually inconsistent, like a rendering glitch, not an intentional
     priority signal — "why are some borders green and some grey." All four
     tiers now read as *shades of the same green accent ramp*, descending in
     intensity from P0 (most vivid/prominent) to P3 (most muted/pale), so the
     whole set reads as one coherent color story. Live-verified contrast
     (relative-luminance calc, this file's usual discipline) for the two
     newly-added ink shades against every surface they can realistically sit
     on: light-theme --color-accent-700 (P2-ink, reused from P1) clears
     8.87:1 on --color-bg, 8.18:1 on --color-surface; --color-accent-600
     (P3-ink) clears 5.28:1 on --color-bg, 4.87:1 on --color-surface — both
     comfortably passing WCAG AA for the 9px badge text and small
     .priority-tag text this pairs with (the same use cases the previous
     grey P2/P3 tokens covered). Never drop P3-ink below --color-accent-600
     in light theme — the next step down, the base --color-accent (500)
     itself, clears only 2.68:1 on white and fails AA outright (documented
     on --color-accent's own comment above). */
  --color-p0:     var(--color-accent);
  --color-p0-ink: var(--color-accent-800);
  --color-p0-bg:  var(--color-accent-100);
  --color-p1:     var(--color-accent-400);
  --color-p1-ink: var(--color-accent-700);
  --color-p1-bg:  var(--color-accent-100);
  --color-p2:     var(--color-accent-300);
  --color-p2-ink: var(--color-accent-700);
  --color-p2-bg:  var(--color-accent-100);
  --color-p3:     var(--color-accent-200);
  --color-p3-ink: var(--color-accent-600);
  --color-p3-bg:  var(--color-accent-100);

  /* Text on solid accent fills (buttons, filled badges) — replaces
     --color-ink-on-gold. */
  --color-ink-on-accent: #F3F2F2;

  /* issue #435 — design-system.md §3's v3 redesign added --font-heading-v3/
     --font-body-v3 (Sora/Outfit) below, used explicitly by ~10 landing/auth
     call sites via a `var(--font-heading-v3, var(--font-display))` fallback
     chain. An app-wide switch of these *base* tokens to Sora/Outfit was
     tried in this same PR and explicitly rejected after a direct side-by-side
     comparison — the product owner prefers the existing Archivo look
     app-wide and asked to keep it. Base tokens stay on Archivo; the -v3
     tokens remain available for any call site that wants to opt in
     explicitly, but nothing should read them by default going forward. */
  --font-body:    "Archivo", system-ui, sans-serif;
  --font-heading: "Archivo", system-ui, sans-serif;
  /* --font-display is kept as an alias (not removed) so the ~8 existing
     call sites using this name don't all need a mechanical rename in this
     CSS-only pass — it now resolves to Archivo like everything else, closing
     the "no separate display font" gap design-system.md §1 calls for, with
     zero risk of missing a call site. New code should read --font-heading
     directly. */
  --font-display: var(--font-heading);
  /* --font-mono — judgment call: design-system.md doesn't explicitly forbid
     monospace for code/numeric contexts (tabular phase indices, the AI-import
     JSON paste area, feedback report reference codes), only that heading/body
     text must be Archivo. Dropped the old custom 'Geist Mono' webfont (no
     additional font family beyond Archivo should be loaded per §1) in favor
     of a system monospace stack — visually neutral, zero extra network cost,
     and unaffected by the CSP font-src change below since it needs no
     Google Fonts request. */
  --font-mono:    ui-monospace, "SF Mono", Consolas, monospace;

  /* Type scale (rem) — unrelated to this pass's token scope, unchanged, except
     --text-2xl/-3xl/-4xl below (issue #482 A1): those three are page-heading
     sizes and are now fluid clamp() pairs so 360px→1920px needs no breakpoint
     jump, instead of a single fixed size. Token names are unchanged — only the
     values became clamp() — so every existing call site keeps working as-is. */
  /* issue #496 follow-up (live report) — Text size (settings.js's Preferences
     tab) used to scale the whole app, not just text: it set `font-size` on
     `<html>`, and every spacing/icon/radius token in this file happens to
     also be `rem`-based, so "Largest" inflated gaps, icon tiles, and card
     padding right along with the type scale — real, reported bloat on the
     onboarding roadmap grid (icon boxes, badge-to-heading gaps, and dead
     space all growing together, not just the text). `--text-scale` is now
     the only thing `data-text-size` touches (see the `html[data-text-size=...]`
     rule below) — every `--text-*` token is wrapped in `calc(... *
     var(--text-scale))` so only font-size responds to the preference;
     `--space-*`/`--radius-*`/icon-size tokens are untouched regardless of
     text size. */
  --text-scale: 1;
  --text-xs:   calc(0.75rem * var(--text-scale));
  --text-sm:   calc(0.875rem * var(--text-scale));
  --text-base: calc(1rem * var(--text-scale));
  --text-lg:   calc(1.25rem * var(--text-scale));
  --text-xl:   calc(1.5625rem * var(--text-scale));
  --text-2xl:  calc(clamp(1.5rem, 1.3rem + 1vw, 1.953rem) * var(--text-scale));
  --text-3xl:  calc(clamp(1.75rem, 1.4rem + 1.75vw, 2.441rem) * var(--text-scale));
  --text-4xl:  calc(clamp(2rem, 1.5rem + 2.5vw, 3.052rem) * var(--text-scale));

  /* Responsive layout primitives (issue #482 A1) — the one shared place that
     decides content width and minimum tap-target size, so every page/component
     folds the same way instead of each setting its own ad hoc breakpoint. */
  --content-max: 1080px;
  --tap-min: 48px;
  /* issue #492 — width reserved down the dashboard phase list's left gutter
     for the progress spine (track + one dot per phase). */
  --phase-spine-gutter: clamp(26px, 3vw, 34px);
  /* issue #503 follow-up — real, reported bug: `.dashboard-header`'s own
     left/right padding is what insets its children
     (`.roadmap-summary-card`/`.next-up-card`/`.roadmap-filters-card`) from
     the page edge; `.daily-todo-panel` and `.dashboard-content` are
     siblings of that header, not children of it, so they never got the
     same inset and rendered visibly wider (their own card border sitting
     right at `.app-content`'s edge instead of matching the others). One
     shared token, read by all three, so a future breakpoint retune can't
     silently drift them out of sync again — see each selector's own
     `max-width: 768px`/`480px`/`375px` override below for the matching
     narrower values. */
  --dashboard-gutter-x: 20px;

  --font-weight-regular:  400;
  --font-weight-medium:   500;
  --font-weight-semibold: 600;
  --font-weight-bold:     700;
  /* Archivo's heaviest weight, used for headings per design-system.md §3. */
  --font-weight-extrabold: 800;

  --leading-tight:  1.2;
  --leading-snug:   1.3;
  --leading-normal: 1.5;
  --leading-loose:  1.7;

  --tracking-tight: -0.03em;
  --tracking-snug: -0.01em;
  --tracking-caps: 0.07em;

  /* Spacing scale — unrelated to this pass's token scope, unchanged. */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-6: 1.5rem;
  --space-8: 2rem;
  --space-12: 3rem;
  --space-16: 4rem;
  --space-24: 6rem;

  /* App shell topbar height — unrelated to this pass's token scope, unchanged. */
  --topbar-h: 56px;

  /* Bottom tab bar height (issue #484) — plus env(safe-area-inset-bottom),
     applied directly on .bottom-nav/.app-content rather than folded into
     this token, since the safe-area inset is device-dependent. */
  --bottom-nav-h: 56px;

  /* Icon size scale — unrelated to this pass's token scope, unchanged. */
  --icon-size-xs: 16px;
  --icon-size-sm: 20px;
  --icon-size-md: 24px;
  --icon-size-lg: 32px;

  /* Shared "metadata chip" box-model scale — height/padding unrelated to
     this pass's token scope, unchanged. --chip-radius is explicitly in
     scope: design-system.md §4/§5 calls for "no rounded... chips", zeroed
     alongside --radius-*/--radius-full below. */
  --chip-height: 22px;
  --chip-padding-x: 9px;
  /* v3 (issue #416 Phase 5 — #430) — design-system.md §4's three-step
     radius scale, the exact opposite of v2 Modernist's "radius 0
     everywhere" rule quoted below. Every remaining component that still
     reads --radius-sm/md/lg/full/--chip-radius directly (i.e. every
     surface Phases 1-4 didn't individually touch, since those phases
     migrated call sites onto a parallel --v3-* namespace rather than
     retuning these base tokens) now gets the v3 look automatically — this
     was the single biggest source of "still looks like the old UI" drift
     going into Phase 5's compliance sweep. --v3-radius-sm/md/lg above stay
     as the canonical values; these alias them so both namespaces resolve
     to one scale. --radius-full is restored to a real pill radius (was
     zeroed for v2's "avatars/toggle tracks go square too" rule, itself
     retired by v3 §8's "avatars: --radius-lg rounded square, not v2's hard
     square" — genuinely pill-shaped elements, e.g. toggle-track thumbs,
     segmented-control selection, still want a full pill, not a square). */
  --radius-sm: var(--v3-radius-sm);
  --radius-md: var(--v3-radius-md);
  --radius-lg: var(--v3-radius-lg);
  --radius-full: 9999px;
  --chip-radius: var(--v3-radius-md);

  /* Shadow scale — design-system.md §4: "Elevation only for overlays
     (dialog, dropdown, command palette, toasts) via --shadow-sm/md/lg. Flat
     surfaces otherwise." --shadow-sm/md/lg are the spec's exact values.
     --shadow-xs/-xl have no spec equivalent (spec only defines 3 tiers) —
     kept defined (reduce risk of a dangling var()), derived from the same
     neutral-ink shadow family as -sm/-md/-lg rather than left at their old
     values. --shadow-brand had zero call sites at the time of this pass
     (grepped and confirmed) — no colored/gold shadow is allowed on a flat
     surface per §4/§9's checklist, so rather than delete the token outright
     (a future accent-emphasis use might want a shadow keyed to the accent
     color specifically, e.g. a hovering "you can drop this here" affordance)
     it's redefined as a low-opacity accent-tinted shadow, not a neutral one
     — documented here since it's a deliberate exception to "flat surfaces
     otherwise," to be used only if a genuine future need arises, not
     reintroduced as a default button/card shadow. */
  --shadow-xs: 0 1px 2px color-mix(in srgb, var(--color-neutral-900) 8%, transparent), 0 0 0 1px color-mix(in srgb, var(--color-neutral-900) 4%, transparent);
  --shadow-sm: 0 1px 2px  color-mix(in srgb, #2D2B2B 14%, transparent);
  --shadow-md: 0 3px 10px color-mix(in srgb, #2D2B2B 16%, transparent);
  --shadow-lg: 0 12px 32px color-mix(in srgb, #2D2B2B 22%, transparent);
  --shadow-xl: 0 24px 48px color-mix(in srgb, var(--color-neutral-900) 20%, transparent), 0 8px 16px color-mix(in srgb, var(--color-neutral-900) 10%, transparent);
  --shadow-brand: 0 8px 24px color-mix(in srgb, var(--color-accent) 20%, transparent);

  /* Motion (durations/easing) — unrelated to this pass's token scope,
     unchanged; design-system.md §7 reuses --duration-fast/base and
     --ease-out for the 120-200ms opacity/transform hover/press rule. */
  --ease-standard: cubic-bezier(0.2, 0, 0, 1);
  --ease-decelerate: cubic-bezier(0, 0, 0, 1);
  --ease-accelerate: cubic-bezier(0.3, 0, 1, 1);
  --ease-spring: cubic-bezier(0.2, 0.9, 0.3, 1);
  --ease-out: cubic-bezier(0, 0, 0.2, 1);
  --ease-in-out: cubic-bezier(0.4, 0, 0.2, 1);
  --ease-bounce: cubic-bezier(0.34, 1.56, 0.64, 1);

  --duration-instant: 80ms;
  --duration-fast: 120ms;
  --duration-base: 200ms;
  --duration-slow: 360ms;
  --duration-enter: 480ms;

  /* issue #499 (D3) — motion pass: standardize the last few remaining
     ad hoc literal durations (route fade 420ms, sheet slide 280ms) onto
     named tokens per the issue's exact spec. --duration-tick reuses
     --ease-standard (already what .check-pop reads) — only the name is
     new, so the "row tick spring" gets a self-documenting token instead
     of a bare --duration-fast reference that doesn't say what it's for. */
  --duration-route: 180ms;
  --duration-tick: 160ms;
  --duration-sheet: 220ms;

  --stagger-base: 40ms;

  /* v3 "developer portfolio" redesign (issue #416, Phase 4 — #428):
     design-system.md §5 "5-step green ramp replacing v2's red ramp — needs
     new stops, not a hue-rotate." Floor is --v3-surface-elevated (neutral,
     "no activity"), each step progressively more saturated green, hottest
     step anchored to --v3-accent itself. HSL components (not hex) to match
     every other --v3-* token's alpha-compositing pattern. */
  --v3-heat-0: var(--v3-surface-elevated);
  --v3-heat-1: 146 55% 85%;
  --v3-heat-2: 146 65% 68%;
  --v3-heat-3: 146 75% 50%;
  --v3-heat-4: var(--v3-accent);

  /* Old bare --neutral-* scale (no "color-" prefix) — a genuinely distinct,
     pre-existing, theme-INVARIANT scale (issue #6 Phase 1.3), separate from
     the new theme-flipping --color-neutral-* ramp above. Confirmed via grep
     before deciding to keep it: only --neutral-900 currently has a live call
     site (.tooltip-bubble's fixed-dark-in-both-themes background), but the
     whole scale is kept, unchanged, per this task's own "if genuinely still
     needed for something unrelated to the redesign, keep it" instruction —
     out of scope for the #297 color redesign, not deleted. */
  --neutral-50: #f8fafc;
  --neutral-100: #f1f5f9;
  --neutral-200: #e2e8f0;
  --neutral-400: #94a3b8;
  --neutral-500: #64748b;
  --neutral-700: #334155;
  --neutral-900: #0f172a;

  /* DELETED (do not reintroduce, issue #297): --color-brand-gold,
     --color-brand-rose, --gradient-alpenglow, --color-brand-gold-ink,
     --color-warning-ink, --color-brand-rose-ink, --color-ink-on-gold,
     old --accent-50/400/500 (orange set), bg-grid-glow / text-gradient-brand
     classes. --color-p3/--color-p3-ink were also removed here at the time,
     then reinstated in issue #301 (Phase 5) as their own token pair — see
     the priority-mapping comment above `:root`'s --color-p0 block for why. */

  /* ============================================================
     v3 "developer portfolio" redesign (issue #416, Phase 1 — #420).
     design-system.md §2/§4/§5 tokens: HSL-*components* (not full color
     values), so alpha compositing (e.g. hsl(var(--v3-divider) / 0.6)) works
     the same way the portfolio source does. Kept as a separate `--v3-*`
     layer rather than overwriting --color-bg/--color-accent/etc above —
     this phase only restyles .btn-primary/.btn-secondary/.field-input/
     .tag-chip/.card (the "primitives" this issue scopes), not every one of
     the ~40 v2-token call sites elsewhere in this file. Those get migrated
     surface-by-surface in Phases 2-5, per design-system.md's own phased
     rollout note — do not delete the --color-* block above until every
     phase lands. Dark-theme values for the theme-flipping tokens below
     live in `:root[data-theme='dark']`. */
  /* issue #440 — --v3-bg/--v3-surface/--v3-surface-elevated used to be a pure
     white/cool-blue-white HSL triple with no relation to this app's actual
     warm-neutral --color-bg/--color-surface/--color-surface-raised hex
     values. Harder to notice in light theme (white vs. off-white reads
     similar), but the same mismatch is stark in dark theme (see that block's
     own comment) — retuned to the exact HSL-component equivalent of the real
     tokens so every live glass surface (not just the opaque fallback paths
     #435 already fixed) renders the correct, consistent hue. */
  --v3-bg:               0 4% 95%;
  --v3-text:             220 20% 8%;
  --v3-accent:           146 88% 38%;
  --v3-accent-muted:     146 92% 30%;
  --v3-accent-glow-alpha: 0.28;
  --v3-surface:          0 2% 92%;
  --v3-surface-elevated: 0 22% 96%;
  --v3-divider:          220 12% 90%;
  --v3-text-muted:       220 10% 44%;
  --v3-gradient-start:   146 88% 38%;
  --v3-gradient-end:     160 80% 35%;
  /* design-system.md §2 — light-mode-only text-safe accent variant; base
     --v3-accent measures ~2.8:1 on --v3-bg, short of the 4.5:1 body-text
     floor. This starting value (~6.5:1 on white) still needs a real
     contrast-tool re-check per §9's review checklist before any phase
     ships paragraph-size accent text with it — flagged here, not silently
     trusted. */
  --v3-accent-ink:       146 92% 22%;

  /* Theme-invariant — declared once here, read by both themes. */
  --v3-radius-sm: 0.5rem;
  --v3-radius-md: 0.75rem;
  --v3-radius-lg: 1rem;
  --v3-shadow-glow: 0 20px 50px -30px hsl(var(--v3-accent) / 0.6);
  --v3-shadow-glow-lg: 0 30px 80px -40px hsl(var(--v3-accent) / 0.55);
  --font-heading-v3: "Sora", var(--font-heading);
  --font-body-v3: "Outfit", var(--font-body);
  --font-mono-v3: "JetBrains Mono", var(--font-mono);
}

:root[data-theme='dark'] {
  --color-bg:             #141312;
  --color-surface:        #1E1C1B;
  --color-surface-raised: #252221;
  --color-border:         color-mix(in srgb, #F1EFED 32%, transparent);
  --color-border-strong:  #F1EFED;
  --color-divider:        color-mix(in srgb, #F1EFED 32%, transparent);
  --color-text:           #F1EFED;
  --color-text-muted:     color-mix(in srgb, #F1EFED 60%, transparent);
  --color-text-faint:     color-mix(in srgb, #F1EFED 45%, transparent);

  /* v3 (issue #416 Phase 5 — #430) — dark-theme half of the green retint
     above, same hue as --v3-accent's dark value (hsl(145 92% 55%)). Live-
     verified: base --color-accent (#23F67B) on --color-bg (#141312) clears
     12.85:1 — dark theme's existing --color-ink-on-accent (near-black,
     below) already reads fine directly against the base accent, so no
     darkened-fill workaround is needed here the way light theme's
     --color-accent-600 is (dark theme's own --color-accent-600 stays a
     *lighter* hover step, per the pre-existing "hover steps LIGHTER on dark
     ground" convention this replaces). */
  --color-accent:      #23F67B;
  --color-accent-100:  #0D2617;
  --color-accent-200:  #103720;
  --color-accent-300:  #124929;
  --color-accent-400:  #14713B;
  --color-accent-500:  #23F67B;
  --color-accent-600:  #47F590;  /* hover steps LIGHTER on dark ground; 13.30:1 with dark ink */
  --color-accent-700:  #8CF2B7;  /* accent as text; 14.68:1 on --color-bg */
  --color-accent-800:  #8CF2B7;
  --color-accent-900:  #BEF4D4;

  --color-neutral-100: #1E1C1B;
  --color-neutral-200: #2B2827;
  --color-neutral-300: #3A3634;
  --color-neutral-400: #4A4643;
  --color-neutral-500: #6B6663;
  --color-neutral-600: #8C8783;
  --color-neutral-700: #ADA8A4;
  --color-neutral-800: #D7D3D3;
  --color-neutral-900: #EAE7E7;

  --color-danger:      var(--color-accent-700);
  --color-danger-bg:   var(--color-accent-100);
  --color-success:     var(--color-neutral-800);
  --color-success-bg:  var(--color-neutral-200);
  --color-warning:     var(--color-accent-700);
  --color-warning-bg:  var(--color-accent-100);

  /* issue #462 — dark-theme half of the light block's P2/P3 override above
     (see that comment for the full rationale). Dark theme's accent ramp
     runs the opposite direction from light's (100 is near-black, 900 is
     palest — see --color-accent's own dark-theme comment), so "descending
     intensity toward P3" here means stepping *down* toward the ramp's darker
     end (300 → 200), the same direction P1 already uses relative to P0
     (400, darker/more muted than the vivid 500 base). Live-verified: P2-ink
     (--color-accent-700, reused from P1) clears 13.70:1 on --color-bg,
     12.53:1 on --color-surface; P3-ink (--color-accent-600) clears 13.02:1
     on --color-bg, 11.91:1 on --color-surface — both far past WCAG AA,
     dark theme's accent ramp stays highly legible at every step unlike
     light theme's tighter margin at the low end. */
  --color-p0:     var(--color-accent);
  --color-p0-ink: var(--color-accent-800);
  --color-p0-bg:  var(--color-accent-100);
  --color-p1:     var(--color-accent-400);
  --color-p1-ink: var(--color-accent-700);
  --color-p1-bg:  var(--color-accent-100);
  --color-p2:     var(--color-accent-300);
  --color-p2-ink: var(--color-accent-700);
  --color-p2-bg:  var(--color-accent-100);
  --color-p3:     var(--color-accent-200);
  --color-p3-ink: var(--color-accent-600);
  --color-p3-bg:  var(--color-accent-100);

  --color-ink-on-accent: #141312;

  --shadow-xs: 0 1px 2px rgba(0, 0, 0, 0.35), 0 0 0 1px rgba(0, 0, 0, 0.18);
  --shadow-sm: 0 1px 2px  rgba(0,0,0,.5);
  --shadow-md: 0 3px 10px rgba(0,0,0,.55);
  --shadow-lg: 0 12px 32px rgba(0,0,0,.65);
  --shadow-xl: 0 24px 48px rgba(0,0,0,.7), 0 8px 16px rgba(0,0,0,.4);
  --shadow-brand: 0 8px 24px color-mix(in srgb, var(--color-accent) 24%, transparent);

  /* v3 redesign (issue #416, Phase 1 — #420) — dark-theme HSL-component
     tokens. See the light `:root` block's matching comment above --v3-bg
     for why this is a separate `--v3-*` layer, not an overwrite of
     --color-* above. --v3-radius-*/--v3-shadow-glow*/--font-*-v3 are
     theme-invariant, declared once in the light block only.
     issue #440 — --v3-bg/--v3-surface/--v3-surface-elevated were a cool
     blue-gray (hue 220) with no relation to this app's actual warm-neutral
     --color-bg/--color-surface/--color-surface-raised (#141312/#1E1C1B/
     #252221) — real, reported bug: every *live* glass surface reading these
     tokens directly (`.app-topbar`, `.app-sidebar`, `.card`, etc. — not just
     the opaque `@supports`/`[data-scrolling]` fallback paths #435 already
     fixed) rendered a visibly lighter, cooler-toned band at all times, not
     only during scroll, most obvious on the always-sticky/always-blurred
     topbar. Retuned to the exact HSL-component equivalent of the real hex
     values so every reader — live and fallback alike — resolves to one
     consistent color. */
  --v3-bg:               30 5% 7%;
  --v3-text:             0 0% 98%;
  --v3-accent:           145 92% 55%;
  --v3-accent-muted:     145 92% 45%;
  --v3-accent-glow-alpha: 0.38;
  --v3-surface:          20 5% 11%;
  --v3-surface-elevated: 15 6% 14%;
  --v3-divider:          220 12% 22%;
  --v3-text-muted:       220 12% 70%;
  --v3-gradient-start:   145 92% 55%;
  --v3-gradient-end:     165 80% 46%;
  /* design-system.md §2 — dark theme's base --v3-accent already clears
     ~13:1 on --v3-bg, no separate text-safe ink variant needed. */
  --v3-accent-ink:       145 92% 55%;

  /* v3 redesign (issue #416, Phase 4 — #428) — dark-theme heat-ramp middle
     stops. --v3-heat-0/-4 flip automatically via --v3-surface-elevated/
     --v3-accent above; only the two middle stops need their own dark values
     (a straight light-mode carry-forward reads too washed-out against the
     near-black --v3-bg). */
  --v3-heat-1: 145 30% 22%;
  --v3-heat-2: 145 55% 32%;
  --v3-heat-3: 145 75% 42%;
}

*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }

html { scroll-behavior: smooth; }
html, body { min-height: 100%; }

/* Text size preference (issue #495, revised by issue #496 follow-up) — sets
   `--text-scale` (:root's default `1`) rather than root `font-size`. Every
   `--text-*` token reads `--text-scale` directly (see the `:root` block
   above), so only font-size responds to this preference now — `--space-*`,
   `--radius-*`, icon-size tokens, and anything else `rem`-based that isn't a
   `--text-*` token stays fixed regardless of text size. Applied
   synchronously before first paint by themeBootstrap.js to avoid a layout
   flash on reload. */
html[data-text-size='large'] { --text-scale: 1.125; }
html[data-text-size='largest'] { --text-scale: 1.25; }

body {
  font-family: var(--font-body);
  /* issue #206 §5 follow-up — was a page-wide radial gold/rose glow behind
     every single screen (dashboard, onboarding, progress, settings); found
     live to read as a large, high-frequency gradient fill, the same category
     of thing the earlier "no gradients anywhere, solid colors only" user
     decision already removed from the brand mark/progress ring/template-card
     border (see `--gradient-alpenglow`'s own comment below — it has zero
     remaining call sites app-wide) — this was simply a spot that decision's
     original sweep had missed, since it targeted component-level gradient
     fills, not this page-wide ambient background. Solid `--color-bg` now,
     matching the token's own documented "warm neutral, not pure white/black"
     role. */
  background: var(--color-bg);
  color: var(--color-text);
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
  transition: background-color 200ms ease, color 200ms ease;
}

.app-shell { min-height: 100vh; min-height: 100dvh; }

/* issue #6 Phase 9 — off-screen until focused (Tab from the page's very top),
   then pinned to the top-left corner above everything else. `top: -100%`
   rather than `display: none`/`visibility: hidden` so it stays in the
   keyboard tab order — a hidden-but-still-focusable link is the whole point
   of a skip link. */
.skip-link {
  position: fixed;
  top: -100%;
  left: 12px;
  z-index: 999;
  background: var(--color-text);
  color: var(--color-bg);
  padding: 10px 18px;
  border-radius: var(--radius-md);
  font-weight: 700;
  font-size: var(--text-base);
  text-decoration: none;
  transition: top var(--duration-fast) var(--ease-out);
}
.skip-link:focus {
  top: 12px;
}
.skip-link:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

/* Animations */
/* Issue #102 follow-up — this used to be `... both`, which per the
   `animation-fill-mode: forwards` half of `both` keeps the `to` keyframe's
   declared properties applied forever after the animation completes, not
   just during it. `transform: translateY(0)` is a visual no-op at rest, but
   a *declared* `transform` (any value, even an identity one) still
   establishes a new containing block for every `position: fixed` descendant
   — which silently broke any fixed-position element anywhere inside
   `.app-shell-2.dashboard.fade-in` (e.g. a `position: fixed` dropdown menu,
   reported live: it rendered positioned relative to this div's full
   scrollable content height instead of the viewport, landing thousands of
   pixels off-screen). `.fade-in` is only ever applied once on initial mount
   of a fresh page-level container (`authShell.js`, `onboarding.js`,
   `dashboard.js` — never toggled on/off an existing node), so there is
   nothing to "snap back" to once the animation ends; dropping the
   fill-mode lets the element fall back to its normal cascade (`transform:
   none`) the instant the animation finishes, which is visually identical to
   `translateY(0)` and removes the phantom containing block. Never add
   `forwards`/`both` back to this rule without confirming nothing depends on
   the `to` keyframe's values persisting — if you need that, apply the
   equivalent resting styles as a normal (non-animation) rule instead, so a
   plain `transform: none` there can never be confused with a "some
   transform is set" state. */
/* issue #499 (D3) — route fade retuned from a literal 420ms to the
   --duration-route token (180ms), per the motion pass's exact spec and its
   "no animation longer than 250ms except the sheet" testing requirement. */
.fade-in { animation: fade-in var(--duration-route) var(--ease-out); }
@keyframes fade-in {
  from { opacity: 0; transform: translateY(8px); }
  to { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .fade-in { animation: none; }
}

@keyframes spin { to { transform: rotate(360deg); } }

@keyframes slide-panel {
  from { transform: translateX(100%); }
  to { transform: translateX(0); }
}

/* Brand */
.brand {
  display: inline-flex;
  align-items: center;
  gap: 10px;
  text-decoration: none;
  color: inherit;
  transition: opacity 0.15s ease;
}
a.brand:hover { opacity: 0.85; }
a.brand:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 4px; border-radius: var(--radius-md); }

/* intentional: fixed white glyph on solid accent fill, same in both themes (issue
   #116) — design-system.md §1's "triangle glyph, flat accent fill" brand mark. issue
   #298 — radius0'd (was a stray 12px rounded-square left over from Phase 1's token
   pass, since this file is component-level, not a token) and the glow shadow
   removed entirely (§2: "No glows, no drop-shadows on flat surfaces" — this was a
   leftover fixed gold-tinted rgba() glow from the pre-#297 Alpenglow direction). */
/* Requested directly: the triangle glyph switches from white to black on
   the green fill — this is now the app's one official brand mark (also
   drives the favicon/PWA icons via scripts/generate-brand-assets.mjs,
   which rasterizes public/favicon.svg — that file's own fill needs the
   same black-on-green update, done alongside this, to stay in sync). */
.brand-mark {
  width: 40px;
  height: 40px;
  background: var(--color-accent);
  color: #000;
  font-weight: 900;
  font-size: 20px;
  display: grid;
  place-items: center;
}

/* issue #298 — design-system.md §1: "wordmark ASCENT in Archivo 800 uppercase
   +0.05em". Was missing the uppercase/tracking transform since Phase 1. */
.brand-name { font-weight: 800; font-size: var(--text-xl); text-transform: uppercase; letter-spacing: 0.05em; }
.brand-tagline { font-size: 12px; color: var(--color-text-muted); font-weight: 600; }

/* ============================================================
   ZeBeyond-inspired design language (issue #155) — eyebrow labels,
   gradient headline text, two-tone tag chips, icon tiles, floating pill
   nav, grouped icon buttons, and a decorative grid+glow background layer.
   See the "Visual design language" section of .claude/rules/ui-styling.md
   for the reference this replaces and which call sites use which class.
   ============================================================ */

/* Uppercase, letter-spaced kicker label ("CASE STUDIES", "WHY CHOOSE US"
   in the reference) — generalizes the ad hoc uppercase-label pattern
   already used piecemeal by .current-roadmap-badge/.toolbar-label/
   .panel-kicker into one reusable class. */
/* v3 (issue #416 Phase 3) — recolored from the v2 red/orange
   --color-accent-700 to the v3 green accent (design-system.md §3's kicker
   spec), both themes. authMarketingPanel.js's always-dark panel keeps its
   own fixed-literal override (.auth-marketing-eyebrow, below) regardless of
   this change. */
.eyebrow {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  font-size: var(--text-xs);
  font-weight: 700;
  letter-spacing: var(--tracking-caps);
  text-transform: uppercase;
  color: var(--v3-accent-ink, hsl(var(--v3-accent)));
}

/* v3 (issue #416 Phase 3) — design-system.md §2/§4 explicitly re-enables
   gradients for headline text ("Gradients are back and encouraged"),
   reversing the Alpenglow-era "no gradients, solid only" decision this class
   used to follow (see the removed comment this replaces). One accent phrase
   inside an otherwise plain-color heading only — never a fully gradient-
   filled heading, since that can't be evaluated by contrast tooling against
   a single foreground color. */
.text-gradient-brand {
  background: linear-gradient(135deg, hsl(var(--v3-gradient-start)), hsl(var(--v3-gradient-end)));
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
}
@supports not (background-clip: text) {
  .text-gradient-brand { color: var(--v3-accent-ink, var(--color-accent-700)); }
}

/* v3 (issue #416 Phase 3) — design-system.md §3's hero-kicker dot,
   "un-retired": a small glowing accent dot paired with an .eyebrow label. */
.eyebrow-dot {
  display: inline-block;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: hsl(var(--v3-accent));
  box-shadow: 0 0 8px hsl(var(--v3-accent) / 0.8);
  flex-shrink: 0;
}

/* Two-tone tag-chip pair (the "EPOP CONCEPT" + "AUTOMOTIVE" chip pattern) —
   built on the same --chip-height/--chip-padding-x/--chip-radius scale
   .badge/.resource-count already use, so a tag chip is pixel-identical in
   height to every other small pill in the app; only the neutral/accent
   color pairing is new. */
.tag-chip {
  display: inline-flex;
  align-items: center;
  height: var(--chip-height);
  padding: 0 var(--chip-padding-x);
  /* v3 (issue #416 Phase 1 — #420): design-system.md §5 "chips: --radius-md,
     glass fill, 1px divider border" — was --radius-full/flat surface. */
  border-radius: var(--v3-radius-md);
  font-size: var(--text-base);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: var(--tracking-caps);
  white-space: nowrap;
  /* issue #455 — flat content surfaces must stay close to --color-bg's own
     brightness, not --color-surface-raised (meaningfully lighter in dark
     theme); see .phase-card's own comment for the full history. */
  background: hsl(var(--v3-surface));
  border: 1px solid var(--color-divider);
  color: var(--color-text-muted);
}
.tag-chip-accent { background: hsl(var(--v3-accent) / 0.12); border-color: hsl(var(--v3-accent) / 0.4); color: var(--v3-accent-ink, var(--color-accent-700)); }

/* v3 (issue #416 Phase 1 — #420) — the shared glass-card primitive
   design-system.md §4 calls for (".card and any elevated panel"). No
   generic .card class existed before this phase — every card-like surface
   in the app (`.phase-card`, `.template-card`, `.kpi-tile`, etc.) is its
   own bespoke class. Built here per this issue's scope ("base primitives"),
   not yet wired into any of those existing surfaces — that migration is
   Phase 2+'s per-page work, same "built, not yet adopted" precedent as
   `.tabs` (issue #125, `.claude/rules/ui-styling.md`). Reach for `.card` on
   any genuinely new card surface in the meantime rather than inventing a
   fresh one-off class. */
.card {
  position: relative;
  border-radius: var(--v3-radius-lg);
  /* issue #455 — flat content surfaces must stay close to --color-bg's own
     brightness, not --color-surface-raised; see .phase-card's own comment. */
  background: hsl(var(--v3-surface));
  border: 1px solid var(--color-divider);
  transition: transform var(--duration-slow, 300ms) var(--ease-out), box-shadow var(--duration-slow, 300ms) var(--ease-out);
}
/* Hairline gradient border (mask-composite technique, portfolio's
   `.card::before` verbatim) — a 1px gradient ring drawn as a pseudo-element
   rather than a real `border`, so the base 1px divider border above still
   renders underneath it. */
.card::before {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  padding: 1px;
  background: linear-gradient(135deg, hsl(var(--v3-gradient-start) / 0.4), hsl(var(--v3-gradient-end) / 0.05));
  /* intentional: mask-composite technique — #000 is an opaque-alpha marker for
     the mask layer, not a rendered color (issue #416 Phase 1). */
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor;
  mask-composite: exclude;
  pointer-events: none;
}
.card:hover { transform: translateY(-4px); border-color: hsl(var(--v3-accent) / 0.4); }

/* Small rounded-square icon container for card headers (the icon-topped
   case-study-card pattern) — wraps a createIcon()/createDecorativeIcon()
   node, replacing a bare inline icon with no visual container. */
.icon-tile {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 44px;
  height: 44px;
  flex-shrink: 0;
  border-radius: var(--radius-md);
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  color: var(--color-accent-700);
}
/* issue #155 v2 Phase C — landing-scoped recolor; falls back to mint in light theme. */
:root[data-theme='dark'] .icon-tile { color: var(--color-accent-700); }

/* issue #297 — design-system.md §5 explicitly merges .btn-cta into
   .btn-primary ("`.btn-cta` is merged into `.btn-primary`"). landing.js's 3
   call sites now read className: 'btn-primary'/'btn-primary btn-lg'
   directly — no separate CTA class/shape survives this phase. */

/* Bordered pill container that visually groups adjacent icon-only buttons
   (the reference's search/mail/sign-in icon cluster) — a shared wrapper,
   not a change to the icon buttons themselves. */
/* Issue #444 — this container was described as a "pill" in the comment
   above but rendered at --chip-radius (12px, a rounded rect), while every
   other chip/badge-shaped element in the app (.badge, .filter-chip,
   .resource-count, .sync-pill next to it in this same row, etc.) uses the
   fully-rounded --radius-full capsule shape — a real, live-reported "topbar
   controls don't match the rest of the app's shape language" bug.
   --radius-full here, and on the child buttons/.sync-pill below, makes this
   container an actual pill, matching the rest of the app instead of being
   the one rounded-rect outlier next to it. */
.icon-btn-group {
  display: inline-flex;
  align-items: center;
  gap: 2px;
  padding: 3px;
  border-radius: var(--radius-full);
  border: 1px solid var(--color-border);
  background: var(--color-surface-raised);
}
/* Every real call site (topbar's search/avatar-menu buttons) already resets
   its own border/background for its unstyled-until-hover look
   (.app-topbar-command-btn, .app-topbar-avatar-btn); this is a defensive
   reset for any future child that doesn't. */
.icon-btn-group > * { border: none; background: transparent; }

/* issue #297 — design-system.md §2 explicitly bans this class by name
   ("No glows, no bg-grid-glow"), not just gradient color washes generally —
   the previous repeating-linear-gradient grid-line texture (a structural,
   not color, gradient) is removed outright rather than retuned. Kept as a
   named no-op class (matching every existing JS call site in landing.js/
   authMarketingPanel.js, same "keep the name, drop the effect" treatment
   already used for .text-gradient-brand above) so this stays a CSS-only
   pass with zero JS diffs — the wrapping section is now a plain flat
   surface with no decorative layer at all, matching §1's "flat,
   architectural" identity. */
.bg-grid-glow {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* Theme toggle */
.theme-toggle {
  font-size: 17px;
  line-height: 1;
}

/* Buttons (issue #206 §3.1 — Alpenglow redesign). Base rule is
   variant-agnostic layout only; each variant below sets its own bg/text/
   border/hover/shadow. issue #297 — text-decoration: none moved here (was
   only ever set on the now-removed .btn-cta) so every .btn variant used on
   an <a> (landing.js's "Start for free"/"Sign in" links use
   .btn-primary/.btn-ghost/.btn-secondary, not just the old CTA class) is
   covered by construction instead of needing its own per-variant override. */
/* issue #338 — `justify-content: center` restored to the base rule, reversing
   issue #309's flush-left change: the product owner explicitly requested
   centered button labels after seeing the sign-in screen (see
   design-system.md §4, updated in the same PR to state the new rule). Any
   trailing icon that previously relied on `margin-left: auto` to push itself
   right against a flush-left label now just centers alongside the label text
   as part of the same centered group — checked every `.btn`-classed call
   site, none currently set `margin-left: auto` on a child, so no follow-up
   change was needed there. `.btn-icon` (below) already centers its single
   icon child; this rule now agrees with it instead of deliberately
   diverging, since a square icon-only button was never covered by the old
   flush-left rule to begin with. */
.btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: 40px;
  padding: 10px var(--space-4);
  border-radius: var(--radius-md);
  border: 1px solid transparent;
  text-decoration: none;
  font: inherit;
  font-weight: var(--font-weight-bold);
  font-size: var(--text-base);
  cursor: pointer;
  transition: background-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard), transform var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard);
}

.btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; box-shadow: none; }

/* Primary — filled accent, fixed ground-colored text in both themes (see
   --color-ink-on-accent's own comment above for the cross-theme contrast
   writeup this token exists to solve). issue #297: also the target of the
   .btn-cta merge above — every former .btn-cta call site (landing.js) now
   renders through this rule directly. Fill reads --color-accent-600, not the
   base --color-accent — see that token's own comment for the axe-caught
   contrast fix this is (dark theme's base --color-accent already passes
   with dark ground-text at 5.88:1 and is intentionally left as-is via that
   token's own light/dark values; only light theme needed the darkened fill). */
/* v3 (issue #416 Phase 1 — #420): design-system.md §5 ".btn-primary —
   gradient fill, --radius-lg, --shadow-glow resting -> --shadow-glow-lg +
   lift on hover." Text is literal black in both themes, matching the
   portfolio source (its gradient is always light green) — per §5, verify
   this still passes contrast against the lightest gradient stop before this
   class ships on a phase-owned surface; not yet re-checked here. */
.btn-primary {
  background: linear-gradient(135deg, hsl(var(--v3-gradient-start)), hsl(var(--v3-gradient-end)));
  /* intentional: fixed black, same in both themes — the portfolio source's
     gradient is always light green regardless of theme (issue #416 Phase 1). */
  color: #000;
  border-color: transparent;
  box-shadow: var(--v3-shadow-glow);
}
.btn-primary:hover:not(:disabled) { box-shadow: var(--v3-shadow-glow-lg); transform: translateY(-2px); }
.btn-primary:disabled:hover { box-shadow: var(--v3-shadow-glow); transform: none; }

/* Secondary — glass surface, per design-system.md §5. */
.btn-secondary {
  /* issue #455 — stay close to --color-bg brightness; see .phase-card. */
  background: hsl(var(--v3-surface));
  color: var(--color-text);
  border-color: var(--color-divider);
}
.btn-secondary:hover:not(:disabled) { border-color: hsl(var(--v3-accent) / 0.4); color: var(--v3-accent-ink, var(--color-accent)); transform: translateY(-2px); }

/* Ghost/tertiary — transparent until hovered. */
.btn-ghost { background: transparent; border-color: transparent; color: var(--color-text-muted); }
.btn-ghost:hover:not(:disabled) { background: var(--color-surface-raised); }

/* Danger — filled, same lift treatment as primary. `--color-danger` is
   `--color-accent-700` (design-system.md's "danger reuses the accent ramp"
   rule) — a dark red in light theme, a light pink in dark theme (it's tuned
   for use as *text*, not a button fill). `color: var(--color-text)` here
   measured 2.32:1 light / 1.32:1 dark against that fill — both catastrophic
   contrast failures, found via a real relative-luminance check (issue #301
   follow-up). `--color-ink-on-accent` (the same text token `.btn-primary`
   already uses against the base `--color-accent` fill) clears 6.41:1 light /
   12.23:1 dark instead. */
.btn-danger {
  background: var(--color-danger);
  color: var(--color-ink-on-accent);
  border-color: var(--color-danger);
  box-shadow: var(--shadow-sm);
}
.btn-danger:hover:not(:disabled) { box-shadow: var(--shadow-md); transform: translateY(-1px); }
.btn-danger:disabled:hover { box-shadow: none; transform: none; }

/* Danger, text-only variant (e.g. an inline "Delete" affordance that isn't
   a full filled button). */
.btn-danger-text { background: transparent; border-color: transparent; color: var(--color-danger); }
.btn-danger-text:hover:not(:disabled) { background: var(--color-danger-bg); }

/* Issue #6 Phase 5.3 — page-agnostic submit-button loading spinner
   (setButtonLoading(), src/ui/utils/buttonLoading.js). Same sizing/animation
   as the pre-existing .save-badge .spin (dashboard.js), promoted to a
   standalone class so any button can use it, not just the save badge. */
.btn-spinner {
  width: 12px; height: 12px; border-radius: 50%;
  /* intentional: neutral gray track works against any --color-surface value in either theme; the spinning edge is currentColor (issue #116) */
  border: 2px solid rgba(127, 127, 127, 0.3);
  border-top-color: currentColor;
  animation: spin 1s linear infinite;
  flex-shrink: 0;
}
.btn-block { width: 100%; }
.btn-sm { min-height: 34px; padding: 6px 12px; font-size: var(--text-base); }
.btn-lg { min-height: 48px; padding: 12px 24px; font-size: var(--text-base); }
/* Re-centers explicitly (issue #309) — `.btn`'s own base rule no longer
   centers, but a square icon-only button has no text "label" for the
   flush-left rule to apply to; its icon should stay centered in the box. */
.btn-icon { width: 36px; height: 36px; padding: 0; font-size: 22px; line-height: 1; justify-content: center; }

.link { color: var(--color-accent-700); font-weight: 700; text-decoration: none; }
/* issue #155 v2 Phase D3 (full completion pass) — the app's generic inline-link class;
   straight text swap. */
:root[data-theme='dark'] .link { color: var(--color-accent-700); }
.link:hover { text-decoration: underline; }

/* Form fields */
.field { display: grid; gap: 6px; }
.field-label { font-size: var(--text-base); font-weight: 700; color: var(--color-text); opacity: 0.85; }
/* v3 (issue #416 Phase 1 — #420): design-system.md §5 ".input — glass fill,
   1px divider border, --radius-md, accent caret, accent-glow ring on
   focus." */
.field-input {
  width: 100%;
  padding: var(--space-3);
  border-radius: var(--v3-radius-md);
  border: 1px solid var(--color-divider);
  /* issue #455 — stay close to --color-bg brightness; see .phase-card. */
  background: hsl(var(--v3-surface));
  color: var(--color-text);
  font: inherit;
  font-size: var(--text-base);
  transition: border-color var(--duration-fast) var(--ease-standard), box-shadow var(--duration-fast) var(--ease-standard);
}
/* §6 — accent-glow ring on focus, in addition to (not instead of) the
   :focus-visible outline every other focusable element already has. */
.field-input:focus { outline: none; border-color: hsl(var(--v3-accent)); box-shadow: 0 0 0 3px hsl(var(--v3-accent) / 0.15); }
.field-input.compact { padding: var(--space-2) 10px; font-size: var(--text-base); }
/* issue #297 — design-system.md §5's "flat surface fill, 1px divider border,
   accent caret, accent border on focus" input spec. .field-input (above) is
   this app's one input base class (no separate .input exists app-wide) and
   already satisfied every part of that spec except the caret color. */
.field-input, .search-input, .import-paste-area { caret-color: var(--color-accent); }

/* Segmented control (design-system.md §5) — for exclusive-choice groups
   (priority filter, theme picker; settings.js's Text size row, issue #495,
   this control's first real call site). `border-radius` + `overflow: hidden`
   on the `.seg` container (not on each `.seg-item`) rounds the group's outer
   corners while clipping every inner item to a plain rectangle — the
   standard segmented-control technique, and simpler than rounding only the
   first/last item's outer corners by hand. */
.seg { display: inline-flex; border: 1px solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; }
.seg-item {
  padding: var(--space-2) var(--space-4);
  background: var(--color-surface);
  color: var(--color-text);
  border: none;
  font: inherit;
  font-weight: var(--font-weight-bold);
  font-size: var(--text-base);
  cursor: pointer;
  transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard);
}
.seg-item + .seg-item { border-left: 1px solid var(--color-border); }
/* --color-accent-600, not the base --color-accent — same axe-caught
   contrast fix as .btn-primary above, since this is also body-size text
   on a solid accent fill. */
.seg-item[aria-selected='true'], .seg-item.active { background: var(--color-accent-600); color: var(--color-ink-on-accent); }
.seg-item:focus-visible { outline: 2px solid var(--color-accent); outline-offset: -2px; }

.form-message { min-height: 20px; font-size: var(--text-base); font-weight: 600; color: var(--color-text-muted); }
.form-message.error { color: var(--color-danger); }
.form-message.success { color: var(--color-success); }
/* Issue #444 — inside a `.modal-card`'s translucent/blurred glass background
   (unlike the opaque page backgrounds every other `.form-message` call site
   sits on), `--color-text-muted`'s contrast against whatever page content is
   blurred underneath is close enough to the 4.5:1 AA floor that ordinary
   render variance (scroll position, which dashboard content happens to be
   behind the modal) can tip it under — reproduced locally as a real,
   non-sampler-false-positive axe failure (measured 4.48:1), independent of
   any other change in this PR. Full-contrast `--color-text` inside a modal
   is comfortably clear of this regardless of what's rendered behind it. */
.modal-card .form-message { color: var(--color-text); }
/* Issue #121 item 1 — deliberately not .form-message.error: a Playwright
   locator scoped to that class needs to match exactly one element
   (summaryMsg's "N things need fixing" line), never this callout too. */
.import-corruption-hint { font-size: var(--text-base); font-weight: 600; color: var(--color-danger); margin: 0; }
.form-error { color: var(--color-danger); font-size: var(--text-base); font-weight: 600; min-height: 18px; }
.field-error { color: var(--color-danger); font-size: var(--text-base); font-weight: 600; min-height: 16px; }

/* Password field with show/hide toggle */
.field-input-wrap { position: relative; display: grid; }
.field-input-wrap .field-input { padding-right: 52px; }
.password-toggle {
  position: absolute;
  right: 10px;
  top: 50%;
  transform: translateY(-50%);
  background: none;
  border: none;
  padding: 4px 6px;
  cursor: pointer;
  color: var(--color-text-muted);
  font-size: var(--text-base);
  font-weight: 800;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  border-radius: var(--v3-radius-sm);
  line-height: 1;
}
.password-toggle:hover { color: var(--color-text); }
.password-toggle:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
/* issue #155 v2 Phase D3 (full completion pass) — straight outline swap. */
:root[data-theme='dark'] .password-toggle:focus-visible { outline-color: var(--color-accent); }

/* Issue #6 Phase 5.3 — inline ✓/✕ blur validation icon, shares
   .field-input-wrap with the password-toggle button above but must not
   overlap it — .has-toggle shifts the icon left and gives the input extra
   right padding to clear both. */
/* issue #136 Phase 2 follow-up — was text-based ('✓'/'✕' glyphs, sized via
   font-size); now holds a createIcon() svg, so it needs inline-flex
   centering instead of relying on line-height/font-size for alignment. */
.field-validation-icon {
  position: absolute;
  right: 10px;
  top: 50%;
  transform: translateY(-50%);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  pointer-events: none;
}
.field-validation-icon.valid { color: var(--color-accent-700); }
.field-validation-icon.invalid { color: var(--color-danger); }
/* issue #155 v2 Phase D3 (full completion pass) — straight icon-color swap. */
:root[data-theme='dark'] .field-validation-icon.valid { color: var(--color-accent-700); }
.field-input-wrap.has-toggle .field-validation-icon { right: 46px; }
.field-input-wrap.has-toggle .field-input { padding-right: 84px; }

/* Password strength meter — 4 segment bar below the password field */
.strength-meter { display: flex; gap: 4px; margin-top: 4px; }
.strength-segment { flex: 1; height: 3px; border-radius: 2px; background: var(--color-border); transition: background var(--duration-base) var(--ease-standard); }
.strength-segment.weak { background: var(--color-danger); }
.strength-segment.fair { background: var(--color-warning); }
.strength-segment.strong { background: var(--color-success); }

.field-label-row { display: flex; justify-content: space-between; align-items: center; }
.forgot-link { background: none; border: none; padding: 0; font-size: 12px; font-weight: 600; color: var(--color-text-muted); cursor: pointer; line-height: 1; }
.forgot-link:hover { color: var(--color-accent-700); text-decoration: underline; }
.forgot-link:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; border-radius: 2px; }
.forgot-link:disabled { opacity: 0.5; cursor: not-allowed; }
/* issue #155 v2 Phase D3 (full completion pass) — straight text/outline swap. */
:root[data-theme='dark'] .forgot-link:hover { color: var(--color-accent-700); }
:root[data-theme='dark'] .forgot-link:focus-visible { outline-color: var(--color-accent); }

.reset-success-msg { display: flex; gap: 10px; align-items: flex-start; font-size: var(--text-base); color: var(--color-text); line-height: 1.5; }
/* v3 (issue #416 Phase 5 — #430) — reads --color-accent-600, not the base
   --color-accent: the checkmark glyph is body-text-size content hosted on a
   solid fill, the same "text-hosting fill needs the darkened step" pattern
   .btn-primary/.seg-item already document (--color-ink-on-accent clears
   only 2.68:1 on the base green in light theme; 5.90:1 on --color-accent-600).
   Dark theme's own --color-ink-on-accent is already dark ink, which reads
   fine straight against the (bright) base accent, so no fill change needed
   there. */
.reset-success-icon { flex-shrink: 0; display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: 50%; background: var(--color-accent-600); color: var(--color-ink-on-accent); font-size: 13px; font-weight: 900; }
:root[data-theme='dark'] .reset-success-icon { background: var(--color-accent); }

/* Auth pages — issue #6 Phase 5.1: .auth-page is a two-column split
   (marketing panel + form) above the ≤1024px breakpoint tier, single-column
   (today's original centered-card layout) at/below it — see the responsive
   override at the bottom of this file, grouped with the other ≤1024px rules
   rather than a new breakpoint. */
.auth-page {
  min-height: 100vh;
  min-height: 100dvh;
  display: grid;
  grid-template-columns: minmax(360px, 1fr) min(440px, 100%);
  position: relative;
}

/* .auth-page-right carries the exact rules .auth-page itself used to have
   pre-Phase-5 (centers the card in its own column) — renamed, not rewritten. */
.auth-page-right {
  display: grid;
  place-items: center;
  padding: max(24px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(40px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
  position: relative;
  min-width: 0;
}

/* issue #206 §5 follow-up — was two radial gold/rose glows covering the
   entire auth-page right column; removed for the same "ambient gradient
   background reads as a high-frequency fill" reason as `body`'s own fix
   above. `.auth-page-bg` is now solid `--color-bg`, effectively a no-op
   layer (it exists mainly so `.auth-page-right`'s content can sit at a
   z-index above it without a separate background rule) — left in place
   rather than deleted in case a future non-gradient background treatment
   (a subtle pattern, etc.) wants this same fixed full-bleed layer again.
   `position: absolute`, not `fixed` — real bug, found live: `.auth-page-bg`
   is a DOM descendant of `.auth-page-right` (the right column only), but
   `position: fixed` ignores DOM nesting entirely and sizes `inset: 0`
   against the full viewport, not its parent. On a wide two-column sign-in
   page this painted the light `--color-bg` fill across the *entire* page —
   including over the always-dark `.auth-marketing` panel on the left, whose
   own fixed-white text (`.auth-marketing-headline` etc.) then read as
   barely-visible pale text on light cream instead of white-on-near-black.
   `pointer-events: none` hid this from click/hit-testing (`elementFromPoint`)
   the whole time, which is why it wasn't caught visually in normal use —
   nothing was ever clickable through it, only visually broken. `position:
   absolute` correctly scopes `inset: 0` to `.auth-page-right` (already
   `position: relative`, immediately above), matching what this layer was
   always meant to cover. */
.auth-page-bg {
  position: absolute;
  inset: 0;
  background: var(--color-bg);
  pointer-events: none;
  z-index: 0;
}

/* Left marketing panel (issue #6 Phase 5, redesigned in a follow-up after
   user feedback: the first cut looked sparse/empty on wide monitors —
   content pinned to the top-left of a huge flat-gradient column, plain
   emoji icons read as placeholder rather than finished). Fixed near-black
   background regardless of the site's own light/dark theme — this panel is
   intentionally always-dark, matching the ZeBeyond direction's near-black
   marketing surface (issue #155) rather than the previous solid brand-teal
   gradient fill. issue #206 §5 follow-up removed the two radial teal/cyan
   glows this comment used to describe (a leftover from the pre-Alpenglow
   ZeBeyond/lime pass, never retuned to gold/rose) — solid near-black now,
   same "no ambient background gradient anywhere in the app" decision as
   `body`/`.auth-page-bg` above; the diagonal-line texture below is
   unchanged. */
/* intentional: fixed literal, not a token, since this panel never reads the
   site's own light/dark theme tokens — same reasoning as the rest of this
   always-dark panel, issue #116. */
.auth-marketing {
  background: #0a0a0a;
  display: flex;
  flex-direction: column;
  align-items: center;
  padding: max(40px, env(safe-area-inset-top)) 56px max(40px, env(safe-area-inset-bottom)) max(56px, env(safe-area-inset-left));
  position: relative;
  overflow: hidden;
}
/* intentional: fixed accent color on this panel's own fixed-dark background
   regardless of site theme (issue #116) — mirrors .eyebrow's layout (imported via the
   shared class) but can't read the theme-flipping --color-accent token here, same
   reasoning as every other fixed color on this panel. v3 (issue #416 Phase 3) —
   retuned from the v2 red/orange Modernist accent (#FF563C) to the dark-theme
   --v3-accent value (#23f67b, matching :root[data-theme='dark']'s --v3-accent
   at 145 92% 55%): this panel is always-dark regardless of site theme, so it
   always uses the dark-theme v3 accent value directly, same as before. */
.auth-marketing-eyebrow { color: #23f67b; margin: 0 0 var(--space-4); }
/* intentional: flat, low-opacity fixed-white fill on this panel's own always-dark
   background regardless of site theme (issue #116) — flattened from a
   repeating-linear-gradient texture per design-system.md §2's "no gradients
   anywhere" rule (issue #406). */
.auth-marketing-bg-pattern {
  position: absolute;
  inset: 0;
  background-color: rgba(255, 255, 255, 0.03);
  pointer-events: none;
}
/* v3 (issue #416 Phase 5 — #430) — same decorative floating-orb treatment
   as `.landing-orb` (design-system.md §7), reused verbatim on this
   always-dark panel rather than reading the theme-flipping --v3-accent
   token, since the panel never changes with site theme (same reasoning as
   every other fixed color on it, see the comment above
   .auth-marketing-eyebrow). Disabled under prefers-reduced-motion by the
   same global `float`/`floatSlow` keyframe rule `.landing-orb` already
   relies on — no separate override needed here. */
.auth-marketing-orb {
  position: absolute;
  border-radius: 50%;
  filter: blur(60px);
  pointer-events: none;
  z-index: 0;
}
.auth-marketing-orb-1 {
  width: 260px;
  height: 260px;
  top: -50px;
  right: -60px;
  background: rgba(35, 246, 123, 0.22); /* intentional: fixed accent-green glow on this panel's always-dark background regardless of site theme (issue #116) */
  animation: float 6s ease-in-out infinite;
}
.auth-marketing-orb-2 {
  width: 200px;
  height: 200px;
  bottom: 60px;
  left: -50px;
  background: rgba(38, 217, 113, 0.18); /* intentional: same always-dark-panel reasoning as -orb-1 above */
  animation: floatSlow 10s ease-in-out infinite;
}
.auth-marketing-inner {
  position: relative;
  z-index: 1;
  flex: 1;
  display: flex;
  flex-direction: column;
  max-width: 480px;
}
/* intentional: scoped override — .brand-name/.brand-tagline normally inherit --color-text (dark
   text), unreadable on this always-dark panel regardless of site theme (issue #116). */
.auth-marketing .brand-name, .auth-marketing .brand-tagline { color: #fff; }
.auth-marketing .brand-tagline { opacity: 0.75; }
.auth-marketing-brand { margin-bottom: 56px; }

/* Vertically centers the headline/subhead/features block in whatever room
   is left between the brand mark (top) and the stat line (bottom), instead
   of the whole panel just center-aligning one small flex item — this is
   what actually fixes the "too much empty space" complaint on tall/wide
   viewports, not a smaller panel. */
.auth-marketing-content { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 28px; }

/* intentional: every color below is a fixed white/white-alpha value against .auth-marketing's
   always-dark panel, not a theme token — the panel itself never changes with site theme
   (issue #116). */
.auth-marketing-headline {
  margin: 0;
  color: #fff;
  font-family: var(--font-display);
  font-size: clamp(32px, 3.2vw, 46px);
  font-weight: 800;
  line-height: 1.08;
  letter-spacing: -0.02em;
}
.auth-marketing-subhead {
  margin: 0;
  color: rgba(255, 255, 255, 0.8);
  font-size: var(--text-base);
  line-height: var(--leading-loose);
  max-width: 40ch;
}

.auth-marketing-values { display: grid; gap: 22px; list-style: none; padding: 0; margin: 8px 0 0; }
.auth-marketing-value { display: flex; gap: 16px; align-items: flex-start; }
/* intentional: fixed white-alpha/accent-alpha, same always-dark panel as
   above (issue #116) — v3 (issue #416 Phase 5 — #430): icon-tile glass
   treatment (rounded, accent-tinted, subtle glow) replacing the flat
   white-alpha square, the same "still looks like the old UI" gap flagged
   live on this panel. */
.auth-marketing-value-icon {
  display: grid;
  place-items: center;
  width: 40px;
  height: 40px;
  flex-shrink: 0;
  border-radius: var(--v3-radius-md);
  background: rgba(35, 246, 123, 0.14);
  border: 1px solid rgba(35, 246, 123, 0.3);
  box-shadow: 0 8px 20px -12px rgba(35, 246, 123, 0.6);
  color: #fff;
}
/* intentional: fixed white/white-alpha, same always-dark panel as above (issue #116) */
.auth-marketing-value-title { display: block; color: #fff; font-weight: 800; font-size: var(--text-base); margin-bottom: 2px; }
.auth-marketing-value-text { display: block; color: rgba(255, 255, 255, 0.72); font-size: var(--text-base); line-height: var(--leading-normal); }

/* intentional: fixed white-alpha divider/text, same always-dark panel as above (issue #116) */
.auth-marketing-stat {
  position: relative;
  z-index: 1;
  margin: 32px 0 0;
  padding-top: 20px;
  border-top: 1px solid rgba(255, 255, 255, 0.16);
  color: rgba(255, 255, 255, 0.72);
  font-size: var(--text-base);
  font-weight: 600;
}

.auth-page-inner {
  width: min(440px, 100%);
  position: relative;
  z-index: 1;
  display: grid;
  gap: 20px;
}

.auth-top-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}

/* Sign-out button added to the onboarding page's top row alongside the theme
   toggle — this page had no sign-out affordance anywhere before. */
.onboarding-top-actions { display: flex; align-items: center; gap: 8px; }

.auth-brand { justify-self: start; }

/* v3 (issue #416 Phase 3) — design-system.md §4 glass-surface treatment,
   matching .card's own recipe (surface/85 + blur-md + hairline border +
   accent-tinted shadow). Kept as its own class rather than switching to
   .card directly since this card also needs overflow: hidden for its
   head/body/foot padding structure, which .card's ::before hairline-border
   pseudo-element assumes isn't clipped. */
.auth-card-lg {
  position: relative;
  /* issue #455 — stay close to --color-bg brightness; see .phase-card. */
  background: hsl(var(--v3-surface));
  border: 1px solid var(--color-divider);
  border-radius: var(--v3-radius-lg);
  overflow: hidden;
}

.auth-card-head { padding: 28px 28px 0; }
/* Issue #496 (C4) — full-width Sign in/Sign up switch, reusing the shared
   .seg/.seg-item classes (design-system.md §5) with a block layout override
   so both segments split the card's width evenly instead of the default
   inline-content sizing every other .seg use (priority filter, theme
   picker) relies on. */
.auth-mode-switch { display: flex; width: 100%; margin-bottom: 20px; }
.auth-mode-switch .seg-item { flex: 1; text-align: center; }
.auth-mode-switch-hidden { display: none; }
.auth-title { font-family: var(--font-heading-v3, var(--font-display)); font-size: clamp(26px, 4vw, 32px); font-weight: 700; line-height: 1.1; letter-spacing: -0.02em; }
.auth-subtitle { margin-top: 8px; color: var(--color-text-muted); font-size: var(--text-base); font-family: var(--font-body-v3, var(--font-body)); }
.auth-card-body { padding: 24px 28px; display: grid; gap: 16px; }
.auth-card-foot { padding: 0 28px 24px; font-size: var(--text-base); color: var(--color-text-muted); text-align: center; }
.auth-form { display: grid; gap: 14px; }
.remember-row { display: flex; align-items: center; gap: 8px; cursor: pointer; }
.remember-checkbox { width: 15px; height: 15px; accent-color: var(--color-accent); cursor: pointer; flex-shrink: 0; }
/* issue #155 v2 Phase D3 (full completion pass) — native checkbox accent-color, no
   text-contrast implication. */
:root[data-theme='dark'] .remember-checkbox { accent-color: var(--color-accent); }
.remember-label { font-size: var(--text-base); color: var(--color-text-muted); font-weight: 600; user-select: none; }
.auth-divider {
  display: grid;
  grid-template-columns: 1fr auto 1fr;
  align-items: center;
  gap: 12px;
  color: var(--color-text-muted);
  font-size: 11px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.08em;
}
.auth-divider::before, .auth-divider::after { content: ''; height: 1px; background: var(--color-border); }
.auth-footnote { text-align: center; color: var(--color-text-muted); font-size: 12px; font-weight: 600; }

/* Issue #496 (C4) — auth-page inputs are taller than the app-wide
   .field-input default (16px text already comes from --text-base, the
   type-scale floor, but a bare padding-driven height reads cramped at this
   card's larger scale). Scoped to .auth-form specifically, not a global
   .field-input change — every other form in the app keeps its existing
   height. */
.auth-form .field-input { min-height: 52px; }

/* Onboarding template picker */
.onboarding-page {
  min-height: 100vh;
  min-height: 100dvh;
  display: grid;
  place-items: center;
  padding: max(24px, env(safe-area-inset-top)) max(16px, env(safe-area-inset-right)) max(40px, env(safe-area-inset-bottom)) max(16px, env(safe-area-inset-left));
  position: relative;
}

/* Issue #493 — widened from 880px. `.template-grid`'s card minmax grew
   220px -> 290px to match the issue's design reference, and 880px was tuned
   for the old, narrower card: 3 columns of 290px + 2x16px gaps need >=902px,
   which 880px doesn't clear, so the grid silently dropped to 2-up at 1280px
   (real, caught via a live screenshot comparison — the issue's own testing
   requirement calls for 3-up at 1280px). 960px clears it with headroom
   while staying under --content-max (1080px, A1's own page-width cap). */
.onboarding-inner {
  width: min(960px, 100%);
  position: relative;
  z-index: 1;
  display: grid;
  gap: 28px;
}

.onboarding-head { text-align: center; display: grid; gap: 8px; justify-items: center; }
.onboarding-back { justify-self: start; }

.template-grid-heading {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
  flex-wrap: wrap;
}
.template-grid-title { font-size: var(--text-lg); font-weight: 800; margin: 0; }
.template-grid-meta { font-size: var(--text-base); color: var(--color-text-muted); }

.template-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
  align-items: stretch;
  gap: 16px;
}

/* Issue #493 — "Create your own roadmap" is a full-width band above the
   card grid, not a grid cell. Rather than moving it out of the DOM (which
   would break every existing test/click-flow keyed on it being the grid's
   first `.template-card`), it stays the grid's first `[role="listitem"]`
   and this modifier just makes its own grid cell span every column. */
.template-grid > .template-full-band { grid-column: 1 / -1; }

/* issue #206 §3.2 — clickable card (the whole card picks a template via the
   nested .template-card-pick button), so it gets the hover-lift treatment.
   Base surface swapped from --surface-glass to --color-surface per the
   spec's explicit "Base: var(--color-surface) bg" instruction — this drops
   the backdrop-filter glass effect this card family previously had. */
/* v3 (issue #416 Phase 3) — design-system.md §4 glass card grid ("cards are
   visually separate objects now, not cells in one ruled grid"). */
.template-card {
  height: 100%;
  /* issue #455 — stay close to --color-bg brightness; see .phase-card. */
  background: hsl(var(--v3-surface));
  border: 1px solid var(--color-divider);
  border-radius: var(--v3-radius-lg);
  padding: var(--space-6);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  text-align: left;
  cursor: pointer;
  transition: transform var(--duration-slow, 300ms) var(--ease-out), box-shadow var(--duration-slow, 300ms) var(--ease-out), border-color var(--duration-slow, 300ms) var(--ease-out);
  font-family: inherit;
  color: var(--color-text);
  width: 100%;
  position: relative;
}
.template-card:hover, .template-card:has(.template-card-pick:focus-visible) { transform: translateY(-4px); border-color: hsl(var(--v3-accent) / 0.4); }
.template-card.is-disabled { cursor: not-allowed; opacity: 0.6; transform: none; pointer-events: none; }
.template-card.picking { opacity: 0.85; }
/* `store.switchRoadmap()` (onClick, buildCard()/buildCustomCard(),
   onboarding.js) is a real Firebase round-trip whenever the template isn't
   already cached this session — the faint opacity dim above was the only
   feedback during that wait, which read as unresponsive lag rather than
   "loading" on anything slower than an instant local network (real,
   reported bug). This overlay (buildPickingOverlay(), onboarding.js) covers
   the whole card with a spinner + "Opening…" for the duration of the click. */
.template-card-picking-overlay {
  display: none;
  position: absolute;
  inset: 0;
  align-items: center;
  justify-content: center;
  gap: 8px;
  background: var(--surface-glass);
  backdrop-filter: blur(2px);
  border-radius: var(--radius-lg);
  font-size: var(--text-base);
  font-weight: 700;
  color: var(--color-accent-700);
}
/* issue #155 v2 Phase D3 (full completion pass) — plain text on a glass overlay. */
:root[data-theme='dark'] .template-card-picking-overlay { color: var(--color-accent-700); }
.template-card.picking .template-card-picking-overlay { display: flex; }
.template-card-picking-overlay .btn-spinner { width: 16px; height: 16px; border-width: 2px; }
/* issue #493 — the "pick this roadmap" primary control is a real <button>
   wrapping just the <h3> title (not the whole card's content the way it
   used to) — its `::after` (absolute, `inset: 0`) is what actually stretches
   the click/tap target over the entire card, resolving against `.template-card`
   itself (the nearest `position: relative` ancestor), since this button is
   left `position: static` on purpose. Never `all: unset` here — that would
   also reset `outline`, which silently defeats the `:focus-visible` ring
   below (WCAG 2.4.7); only the specific properties a plain button needs are
   reset. `.template-card-overflow`'s `z-index: 2` (below) sits above this
   `::after`, so the ⋯ trigger stays clickable despite the stretched overlay. */
.template-card-pick {
  position: static;
  display: block;
  width: 100%;
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
}
.template-card-pick::after {
  content: '';
  position: absolute;
  inset: 0;
  border-radius: inherit;
  z-index: 1;
}
.template-card-pick:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.template-card-header {
  position: relative;
  display: flex;
  align-items: flex-start;
  gap: var(--space-3);
}
.template-card-icon-wrap { position: relative; display: inline-flex; flex-shrink: 0; }
/* issue #206 §3.2 — 3px TOP border only; right/bottom/left stay the card's
   normal 1px solid --color-border, untouched by this rule (border-top only
   sets the top edge). Was a border-image gradient; reverted to a plain solid
   color per the app-wide "no gradients, solid only" decision — simpler now
   that there's no gradient to scope to one edge via border-image-slice. */
.template-card-current {
  border-top: 3px solid var(--color-accent);
}
/* Issue #493 — a 46px icon tile (was a bare, un-contained icon glyph) so a
   favorited card's star badge has a real corner to ride (see
   .template-card-favorite-indicator below). */
.template-card-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 46px;
  height: 46px;
  flex-shrink: 0;
  border-radius: var(--radius-md);
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  color: var(--color-accent-700);
}
:root[data-theme='dark'] .template-card-icon { color: var(--color-accent-700); }
.template-card-name { font-size: var(--text-lg); font-weight: 800; margin: 0; }
/* Two-line clamp (issue #493) — a longer AI-generated custom-roadmap
   description no longer pushes same-row cards to mismatched heights. */
.template-card-desc {
  font-size: var(--text-base);
  color: var(--color-text-muted);
  line-height: 1.5;
  margin: 0;
  display: -webkit-box;
  -webkit-line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}
.template-card-footer { display: flex; flex-direction: column; gap: 6px; margin-top: auto; padding-top: 8px; }
.template-card-count {
  font-size: 12px;
  font-weight: 700;
  color: var(--color-accent-700);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
/* Thin per-card progress bar (issue #493). A native <progress> was tried
   first — its `.value`/`.max` are plain DOM properties, which would have
   avoided an inline `style` cleanly — but real-browser testing found its
   `::-webkit-progress-bar`/`::-webkit-progress-value` pseudo-styling doesn't
   reliably apply (rendered as a solid dark bar regardless of actual
   percentage, confirmed visually, not just a computed-style artifact).
   Replaced with a plain track/fill div pair — the same "capped set of
   discrete CSS classes instead of an inline style" pattern this file already
   uses for animation-delay (see `.check-item.entering-delay-N` above) — the
   fill's width is set via one of `.template-card-progress-fill-{0,5,…,100}`,
   never a `style="width:…"` (index.html's CSP silently drops inline style
   anyway — see .claude/rules/ui-styling.md's "Never set an inline style
   attribute" rule). `--color-border-strong` (this app's fully-opaque "ink"
   token — near-black in light theme) was also wrong for the track's "empty"
   state in an earlier version of this rule; `--color-border`, the same color
   at its already-alpha-blended, actually-light divider strength, is correct. */
.template-card-progress-track {
  width: 100%;
  height: 4px;
  border-radius: var(--radius-full);
  background: var(--color-border);
  overflow: hidden;
}
.template-card-progress-fill {
  display: block;
  height: 100%;
  border-radius: inherit;
  background: var(--color-accent);
  width: 0%;
}
.template-card-progress-fill-5 { width: 5%; }
.template-card-progress-fill-10 { width: 10%; }
.template-card-progress-fill-15 { width: 15%; }
.template-card-progress-fill-20 { width: 20%; }
.template-card-progress-fill-25 { width: 25%; }
.template-card-progress-fill-30 { width: 30%; }
.template-card-progress-fill-35 { width: 35%; }
.template-card-progress-fill-40 { width: 40%; }
.template-card-progress-fill-45 { width: 45%; }
.template-card-progress-fill-50 { width: 50%; }
.template-card-progress-fill-55 { width: 55%; }
.template-card-progress-fill-60 { width: 60%; }
.template-card-progress-fill-65 { width: 65%; }
.template-card-progress-fill-70 { width: 70%; }
.template-card-progress-fill-75 { width: 75%; }
.template-card-progress-fill-80 { width: 80%; }
.template-card-progress-fill-85 { width: 85%; }
.template-card-progress-fill-90 { width: 90%; }
.template-card-progress-fill-95 { width: 95%; }
.template-card-progress-fill-100 { width: 100%; }
/* issue #155 v2 Phase D3 (full completion pass) — plain text, straight swap. */
:root[data-theme='dark'] .template-card-count { color: var(--color-accent-700); }
/* issue #206 §3.6 — "Current" gets a small solid-filled dot (::before) next
   to the label, no markup change needed (onboarding.js renders this as a
   single `el('span', { text: 'Current' })` with no child node). Was a
   gradient dot; reverted to solid gold per the app-wide "no gradients"
   decision. */
.template-card-current-badge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  height: var(--chip-height);
  padding: 0 var(--chip-padding-x);
  border-radius: var(--radius-full);
  font-size: 10px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-accent-700);
  background: var(--color-warning-bg);
  border: 1px solid var(--color-border-strong);
}
.template-card-current-badge::before {
  content: '';
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--color-accent);
  flex-shrink: 0;
}
.template-card-started-badge {
  display: inline-flex;
  align-items: center;
  height: var(--chip-height);
  padding: 0 var(--chip-padding-x);
  border-radius: var(--radius-full);
  font-size: 10px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-text-muted);
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border-strong);
}
.template-card-started { border-color: var(--color-border-strong); }

/* Corner info button on "Create your own roadmap" (issue #4 follow-up) —
   same 24px circular-corner-button slot/sizing the old standalone
   .template-card-hide/-delete/-favorite buttons used (all three retired in
   issue #206 §4.1, collapsed into .template-card-overflow-btn below —
   "Create your own roadmap" is the one card that never had 2+ secondary
   actions, so it keeps its own single always-visible corner button rather
   than a menu), opening buildYourOwnGuide instead of hiding/deleting. Used
   to live only on the now-retired "blank" template as an inline text link;
   moved here since blank was its sole home. */
.template-card-info-corner {
  position: absolute;
  top: 12px;
  right: 12px;
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 1px solid var(--border-glass);
  background: var(--color-surface);
  color: var(--color-text-muted);
  font-size: 13px;
  line-height: 1;
  cursor: pointer;
  /* Above .template-card-pick's own stretched ::after overlay (z-index: 1,
     issue #493) — same reasoning as .dropdown.template-card-overflow. */
  z-index: 2;
}
.template-card-info-corner:hover { color: var(--color-accent-700); border-color: var(--color-border-strong); }
/* issue #155 v2 Phase D3 (full completion pass) — straight text/border swap. */
:root[data-theme='dark'] .template-card-info-corner:hover { color: var(--color-accent-700); border-color: var(--color-border-strong); }

/* Card-action overflow menu (issue #206 §4.1) — collapses the favorite +
   hide/delete corner buttons (formerly `.template-card-favorite`/`-hide`/
   `-delete`, issue #177/#61, removed here) behind one ⋯ trigger.
   `.template-card-overflow` is `createDropdown()`'s own `.dropdown` wrapper
   (app.css's existing `.dropdown`/`.dropdown-menu`/`.dropdown-item` rules
   style the portaled menu itself) positioned absolutely in the same
   top-right corner slot the old standalone hide/delete button used to
   occupy — only one button now, so no `right: 44px` favorite-offset variant
   is needed. */
/* `.dropdown.template-card-overflow` (two classes), not `.template-card-overflow`
   alone — `.dropdown`'s own `position: relative` (below) is a single-class selector
   at equal specificity, declared later in this file, so a single-class override here
   would lose the cascade regardless of source order and silently fall back to
   `position: relative`. That's a real bug this was found from: it knocked the ⋯
   trigger out of its absolute corner slot into the card's normal flex flow, which fed
   a wrong trigger rect into `positionMenu()` (dropdown.js) and rendered the portaled
   menu partly off-screen — reported live as a blank white box overlapping the cards. */
/* z-index: 2 (issue #493) — sits above .template-card-pick's own stretched
   `::after` overlay (z-index: 1), so the ⋯ trigger stays independently
   clickable/focusable instead of the pick button's overlay swallowing the
   click. */
.dropdown.template-card-overflow { position: absolute; top: 10px; right: 10px; z-index: 2; }
/* 44x44 (issue #493 — "rather than an icon button's default 36px"), not the
   old 24px circular corner button; the @media (pointer: coarse) override
   below still grows it to 46px for the same 44px-boundary rounding reason
   documented on that block. */
.template-card-overflow-btn {
  width: 44px;
  height: 44px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 1px solid var(--border-glass);
  background: var(--color-surface);
  color: var(--color-text-muted);
  cursor: pointer;
}
.template-card-overflow-btn:hover,
.template-card-overflow-btn[aria-expanded='true'] { color: var(--color-accent-700); border-color: var(--color-border-strong); }
/* issue #155 v2 Phase D3 (full completion pass) — straight text/border swap, same as .template-card-info-corner:hover above. */
:root[data-theme='dark'] .template-card-overflow-btn:hover,
:root[data-theme='dark'] .template-card-overflow-btn[aria-expanded='true'] { color: var(--color-accent-700); border-color: var(--color-border-strong); }

/* Favorited-roadmap on-card indicator (issue #395, repositioned onto the icon
   tile's own corner in issue #493) — a plain, non-interactive cue, never a
   click target (favoriting stays a ⋯-menu-only action, see
   buildFavoriteMenuAction()/buildFavoriteIndicator() in onboarding.js).
   `.template-card-icon` is now a real 46px tile (see above), which gives this
   badge a real corner to ride — a small 20px circle at -7px/-7px with a 2px
   ring in the card's own surface color, so it reads as a badge sitting on
   top of the tile rather than clipping the icon glyph underneath. Positioned
   relative to `.template-card-icon-wrap` (not `.template-card` itself), so it
   never has to coordinate with the ⋯ trigger's own top-right corner slot —
   the two occupy entirely different corners of the card now. */
.template-card-favorite-indicator {
  position: absolute;
  top: -7px;
  left: -7px;
  width: 20px;
  height: 20px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  background: var(--color-warning-bg);
  color: var(--color-accent);
  border: 2px solid hsl(var(--v3-surface));
  pointer-events: none;
}
.template-card-favorite-indicator svg { width: 12px; height: 12px; }
.template-card-favorite-indicator svg path { fill: currentColor; }

.hidden-templates-section { display: grid; gap: 12px; justify-items: center; }
.hidden-templates-toggle { justify-self: center; }
.hidden-grid { width: 100%; }
.template-card-hidden {
  background: var(--color-surface-raised);
  border: 1px dashed var(--color-border-strong);
  border-radius: var(--radius-lg);
  padding: var(--space-6);
  opacity: 0.85;
}
.template-card-hidden .btn { margin-top: auto; align-self: flex-start; }

/* Issue #100 revamp — the plain dashed-border box read as an inert
   placeholder rather than the primary "start something new" action it is
   (real user feedback: "I don't think it is good we need to remake this").
   A glowing brand-colored border + a small "AI-powered" pill make it the
   visually loudest card in the grid — deliberately keeping the same
   .template-card `--color-surface` base every other card uses (never
   overriding `background` to a solid fill) so it reads as a highlighted
   variant of the existing card language, not a mismatched flat block. */
/* issue #297 — --color-brand-rose (violet "AI-assisted" hue) is deleted, not
   remapped to a second accent: design-system.md §1/§2 is explicit that this
   app has "one accent per theme," with no reserved second hue for AI-related
   affordances the way issue #136's "accent-2" concept had. Judgment call:
   this card/badge now reads on the neutral ramp instead — --color-neutral-700
   for the border/icon/name (a clearly darker-than-divider tone that still
   reads as a deliberate outline, not the plain --color-border every other
   card uses) and --color-neutral-800 for the badge's higher-contrast text
   role (--color-brand-rose-ink's old job). The card is still visually
   distinguished from `.template-card-current` (accent-colored) by simply
   not using the accent at all — "the AI card is the one that's NOT accent,
   everything else uses the scarce accent" reads as a valid distinction on
   its own, and doesn't need a second hue to do it. The old violet glow
   box-shadow (a decorative colored shadow) is also dropped outright — no
   glows on flat surfaces, per design-system.md §2/§4/§9's review checklist —
   in favor of the same plain --shadow-sm/--shadow-lg elevation every other
   card in this file already uses. */
.template-card-create {
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: 6px;
  border: 1.5px solid var(--color-neutral-700);
  box-shadow: var(--shadow-sm);
}
.template-card-create:hover, .template-card-create:has(.template-card-pick:focus-visible) { box-shadow: var(--shadow-lg); }
.template-card-create .template-card-icon { color: var(--color-neutral-700); }
.template-card-create .template-card-name { color: var(--color-neutral-700); }
/* Issue #493 — as a full-width band, the create card reads better as a
   horizontal row (icon left, copy right) than the centered column the
   original grid-cell card used. Scoped to `.template-full-band` so this
   never affects the card if it's ever rendered somewhere narrower again. */
.template-full-band .template-card-create {
  flex-direction: row;
  align-items: center;
  text-align: left;
  gap: var(--space-4);
}
.template-full-band .template-card-create .template-card-pick {
  flex: 1;
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--space-4);
}
/* Issue #493 follow-up — badge+name+desc as one column beside the icon, not
   four flat flex-row siblings (see buildCreateCard()'s own comment for the
   real, reported wrapping bug this replaces). */
.template-card-create-copy {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
  flex: 1;
}
.template-card-create-heading {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
}
.template-full-band .template-card-create .template-card-name { margin: 0; }
.template-full-band .template-card-create .template-card-desc { -webkit-line-clamp: 1; }
.template-card-ai-badge {
  align-self: center;
  display: inline-flex;
  align-items: center;
  flex-shrink: 0;
  white-space: nowrap;
  height: var(--chip-height);
  padding: 0 var(--chip-padding-x);
  border-radius: var(--radius-full);
  font-size: 10px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-neutral-800);
  background: var(--color-neutral-200);
  border: 1px solid var(--color-border-strong);
}

/* Dashboard */
.dashboard { min-height: 100vh; min-height: 100dvh; display: flex; flex-direction: column; }

/* App shell — sidebar + topbar (issue #6 Phase 2). `.app-shell-2` composes
   with `.dashboard` (kept on the same element — existing e2e/unit tests
   assert `.dashboard` as the "dashboard is rendered" marker) rather than
   replacing it. First grid column is `auto`, sized to `.app-sidebar`'s own
   `width` — collapsing the sidebar is then just changing that one width,
   no `:has()` or duplicated column tracks needed. */
/* minmax(0, 1fr), not a bare 1fr (issue #8 follow-up) — a bare `1fr` track's
   automatic minimum size is its content's min-content width, so any page
   with wide-enough content (the Progress page's ~800px heatmap/charts) could
   force this whole grid column, and therefore the page, wider than the
   viewport instead of letting that content scroll within its own container.
   dashboard.js/settings.js never had content wide enough to expose this —
   `.app-shell-main`'s own `min-width: 0` alone wasn't sufficient; the track
   itself needs the explicit minimum too. Same fix applied to both
   `@media`-overridden column definitions below. */
.app-shell-2 { display: grid; grid-template-columns: auto minmax(0, 1fr); align-items: start; min-height: 100vh; min-height: 100dvh; }

/* `position: sticky; top: 0` + a viewport-height cap, not the grid row's
   (`align-items: start` above already stops the row-stretch half of this) —
   without both, a long roadmap's `.dashboard-content` column can grow far
   taller than the viewport, and a plain-stretched sidebar would grow right
   along with it (seen at ~5700px tall against 484 seeded items) instead of
   staying pinned while the content scrolls past it. `overflow-y: auto` is a
   safety net for a future sidebar tall enough to exceed the viewport itself
   (more nav items than fit), not something today's short nav list needs. */
/* v3 (issue #416 Phase 2 — #424): design-system.md §5 "recreate the
   portfolio's floating glass nav pill." Scoped to a glass-surface treatment
   on the existing full-height, edge-to-edge sticky sidebar rather than a
   fully floating margin-inset pill — the portfolio's pill nav is a single
   top-of-page bar, not a full-height side rail, and reshaping this into an
   inset/floating panel would touch the sticky/height-cap layout this file's
   own conventions document at length (see .claude/rules/ui-styling.md's
   card/grid + sticky-context sections) for a mostly-cosmetic gain. Glass
   blur + rounded active-item treatment ships now; a genuinely floating
   sidebar shape is a separate future call, not assumed here. */
.app-sidebar {
  width: 240px;
  position: sticky;
  top: 0;
  height: 100vh;
  height: 100dvh;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-6) var(--space-4);
  /* issue #440 — nav chrome (sidebar/topbar) reads --color-bg, not the
     "elevated" --color-surface-raised every card-shaped surface uses: real,
     repeated user report that the sidebar/topbar visibly lighter than the
     page (even just slightly) reads as inconsistent, not as an intentional
     "this is chrome" cue. */
  background: var(--color-bg);
  border-right: 1px solid var(--color-divider);
  transition: width var(--duration-base) var(--ease-out);
}

/* issue #298 — design-system.md §4's 2px major-section rule, under the brand block. */
.app-sidebar-brand { padding: 0 var(--space-2) var(--space-4); margin-bottom: var(--space-2); border-bottom: 2px solid var(--color-divider); }

.app-sidebar-nav { display: flex; flex-direction: column; gap: var(--space-1); }

.nav-item {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-md);
  color: var(--color-text-muted);
  text-decoration: none;
  font-weight: 700;
  font-size: var(--text-base);
  transition: background var(--duration-fast) ease, color var(--duration-fast) ease;
}
.nav-item:hover { background: hsl(var(--v3-accent) / 0.08); color: var(--color-text); }
/* issue #206 §3.7 — left-edge 3px accent bar + raised bg, replacing the old
   flat gold-tinted fill. Used `box-shadow: inset 3px 0 0 ...` rather than a
   `border-left`/pseudo-element, per the issue's own stated preference — an
   inset shadow doesn't add to the box's rendered width/padding, so it can't
   shift `.nav-item-icon`/label layout the way a real border would.
   Switched border-radius from the old pill shape (--chip-radius) to
   --radius-md: a left-edge straight bar reads as clipped/curved against a
   fully rounded pill's left corner — a rect radius is the correct shape for
   an edge-indicator pattern. Flagging this shape change from the pre-#206
   pill treatment. */
/* v3 (issue #416 Phase 2 — #424): design-system.md §5 "active nav item:
   accent text + hover fill on each pill segment." Kept the existing
   left-edge accent bar shape (this row is still a full-width edge-to-edge
   item, not a floating pill segment, per .app-sidebar's own scoping note
   above) — only the color source and radius moved to the v3 accent/radius
   tokens. */
.nav-item.active {
  background: hsl(var(--v3-accent) / 0.1);
  color: var(--v3-accent-ink, var(--color-accent-700));
  border-radius: var(--v3-radius-md);
  box-shadow: inset 3px 0 0 hsl(var(--v3-accent));
}
.nav-item-icon { display: inline-flex; flex-shrink: 0; }

.app-sidebar-spacer { flex: 1; }

.app-sidebar-collapse-btn {
  align-self: flex-start;
  border: none;
  background: none;
  color: var(--color-text-muted);
  cursor: pointer;
  padding: var(--space-1) var(--space-2);
  font-size: var(--text-base);
  border-radius: var(--radius-md);
}
.app-sidebar-collapse-btn:hover { background: var(--color-surface-raised); color: var(--color-text); }

.app-sidebar-footer {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding-top: var(--space-3);
  border-top: 1px solid var(--color-border);
}

.app-sidebar-identity {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex: 1;
  min-width: 0;
  border: none;
  background: none;
  /* No text node of its own — the avatar and .app-sidebar-user-email child
     each set their own color — but a button never inherits color from its
     ancestors by default, so this stays explicit for defensiveness (issue #116). */
  color: inherit;
  cursor: pointer;
  padding: var(--space-1);
  border-radius: var(--radius-md);
  text-align: left;
}
.app-sidebar-identity:hover { background: var(--color-surface-raised); }
.app-sidebar-user-email {
  font-size: var(--text-base);
  color: var(--color-text-muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Issue #123 — persistent local-only-data risk indicator next to "Guest
   session". Colored with --color-warning (not --color-danger) since it's informational,
   not an active error state. */
.app-sidebar-guest-risk {
  display: inline-flex;
  flex-shrink: 0;
  color: var(--color-warning);
}

/* Collapsed — manual desktop icon-rail toggle, persisted to localStorage
   (sidebar.js). Independent of the automatic tablet-width rail below. */
.app-sidebar.collapsed { width: 64px; }
.app-sidebar.collapsed .nav-item-label,
.app-sidebar.collapsed .app-sidebar-user-email,
.app-sidebar.collapsed .app-sidebar-guest-risk,
.app-sidebar.collapsed .brand-name,
.app-sidebar.collapsed .brand-tagline { display: none; }
.app-sidebar.collapsed .nav-item { justify-content: center; }
.app-sidebar.collapsed .app-sidebar-collapse-btn { transform: scaleX(-1); }

/* Issue #102 — an icon-only rail (this 64px desktop-collapsed state, or the
   automatic 640-1023px tablet rail below) is too narrow for the identity
   avatar and sign-out button to sit side by side (footer's default row
   layout): 64px width minus this rule's own left/right padding leaves ~32px
   of content width, less than the avatar (28px) + gap + icon button (36px)
   combined, so the two visually overlapped instead of wrapping. Stack them
   vertically and shrink the sign-out button to match the avatar's footprint
   whenever the rail is icon-only — never let this rule apply to the full
   (expanded) sidebar width, where the row layout is correct. */
.app-sidebar.collapsed .app-sidebar-footer {
  flex-direction: column;
  align-items: center;
  gap: var(--space-1);
}
.app-sidebar.collapsed .app-sidebar-identity { justify-content: center; padding: var(--space-1) 0; }
.app-sidebar.collapsed .app-sidebar-signout,
.app-sidebar.collapsed .app-sidebar-footer .theme-toggle.btn-icon { width: 32px; height: 32px; font-size: var(--text-base); }

/* issue #299 — design-system.md §8: "Avatars: square, ink fill, ground-colored
   initial." Was a circular, accent-filled, fixed-white-text avatar left over
   from the pre-#297 direction (both fill and shape). */
/* v3 (issue #416 Phase 5 — #430) — design-system.md §8: "avatars: --radius-lg
   rounded square (not v2's hard square)." Real, reported gap: this class had
   no border-radius at all, so every avatar app-wide (sidebar footer
   identity, account dropdown trigger, comment/report authorship) stayed a
   hard square even after every other card/chip/input in the app moved to
   the v3 radius scale. */
.avatar {
  display: grid;
  place-items: center;
  background: var(--color-text);
  color: var(--color-bg);
  font-weight: 800;
  flex-shrink: 0;
  border-radius: var(--v3-radius-lg);
}
.avatar-sm { width: 28px; height: 28px; font-size: var(--text-base); }
.avatar-md { width: 36px; height: 36px; font-size: var(--text-base); }
.avatar-lg { width: 56px; height: 56px; font-size: var(--text-lg); }

/* Issue #102 follow-up — `.dropdown-menu` used to be `position: absolute`
   relative to `.dropdown` (this wrapper), with its left/right/top/bottom set
   by the static `.dropdown-start`/`.dropdown-end` rules below. That broke the
   moment the wrapper's containing block was `.app-sidebar`: the sidebar's
   `overflow-y: auto` computes `overflow-x` to `auto` too per the CSS overflow
   spec (an unspecified axis next to a non-`visible` one is never left
   `visible`), so the sidebar silently clips anything absolutely-positioned
   inside it to its own narrow content box — invisible in the normal 240px
   sidebar (the 180px menu mostly fit inside it by coincidence) but a hard
   clip in the 56-64px icon-only rail, cutting the "Delete account" menu down
   to a sliver (reported live). `position: fixed` escapes that ancestor clip
   entirely (fixed elements are clipped by the viewport, not by a scrolling
   ancestor, as long as nothing between here and the viewport sets a
   transform/filter/perspective — `.app-sidebar` never does outside its own
   mobile-drawer transform, which isn't active in the collapsed/rail states
   this bug hit) — but a fixed element ignores `.dropdown`'s
   `position: relative` entirely, so `dropdown.js` now computes the menu's
   `left`/`right`/`top`/`bottom` from the trigger's `getBoundingClientRect()`
   at open time instead of leaning on static CSS offsets. z-index raised to
   sit above the mobile sidebar drawer (200) and its backdrop (199), since a
   fixed-position menu can now be triggered from inside that drawer too.
   Issue #121 follow-up: `position: fixed` is *positioned* relative to the
   viewport only as long as no ancestor between it and the root has a
   transform/filter/etc. — but it also *stacks* (z-index paint order) within
   whatever stacking context its containing block belongs to, so even a
   correctly-positioned fixed menu can render behind/in front of unrelated
   page content inconsistently if such an ancestor exists (reported live: the
   sidebar's avatar menu, nested inside the animated dashboard shell,
   intermittently rendered under phase-card content). `dropdown.js` now
   portals `.dropdown-menu` to a direct child of `document.body` on open (the
   same fix `.custom-select-listbox` below already uses for the identical bug
   class) — it is no longer a DOM descendant of `.dropdown`, so its
   open/closed visibility is driven by `.dropdown-menu.open` directly, not a
   `.dropdown.open .dropdown-menu` descendant selector. */
/* A portaled floating menu/listbox has no page-flow relationship to what it
   visually sits on top of — clicking a trigger near the top of a short card
   (the Daily Todos duration select is the reported case, issue #228 follow-up)
   can open a menu tall enough to cover that same card's own sibling controls
   (the "Add" button, 8px below the trigger) and spill well past the card into
   unrelated content beneath it. That's expected positioning (see the
   position: fixed comments below), but with no visual layer marking it as a
   floating overlay, it reads as corrupted layout rather than an open menu —
   a real, reported bug, not a hypothetical. `.floating-scrim` is a shared,
   fixed, full-viewport dim layer inserted just behind the listbox/menu (a
   sibling body child, not a wrapper — the listbox/menu keep their own
   independent position: fixed coordinates) whenever one is open, giving every
   portaled floating element the same "this is a layer above the page, not
   part of it" visual cue a native <select>'s OS-rendered dropdown gets for
   free. Purely visual — clicking it closes the menu via the existing
   click-outside-`wrap`-and-`listbox`/`menu` handler already in
   select.js/dropdown.js, since the scrim is neither. */
.floating-scrim {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.45); /* intentional: a fixed dim overlay, not a themed surface — same reasoning as .modal-overlay's own scrim */
}
.custom-select-scrim { z-index: 1009; }
.dropdown-scrim { z-index: 209; }

.dropdown { position: relative; display: inline-flex; min-width: 0; }
/* v3 (issue #416 Phase 5 — #430): glass surface per design-system.md §5's
   nav/overlay treatment — was a flat --color-surface fill + --shadow-lg. */
.dropdown-menu {
  position: fixed;
  min-width: 180px;
  background: hsl(var(--v3-surface) / 0.9);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  border: 1px solid hsl(var(--v3-divider) / 0.6);
  border-radius: var(--v3-radius-lg);
  box-shadow: var(--v3-shadow-glow);
  padding: var(--space-1);
  display: none;
  flex-direction: column;
  /* Issue #507 follow-up — the account menu grew a header block + a "Sign
     out" row + 48px-tall items (this same issue), tall enough on a real
     Firebase account (10+ items) to exceed the viewport height entirely on
     a short/laptop screen — `positionMenu()` (dropdown.js) can flip the
     menu above vs. below the trigger, but has nothing to fall back to once
     the content itself is taller than the whole viewport. Capping height
     and scrolling internally, the same way `.custom-select-listbox` and
     every other tall floating list in this app already does, guarantees
     the menu box itself is always fully on-screen regardless of item count
     — verified against tests/e2e/responsive.test.js's "not clipped by the
     sidebar" boundingBox assertion, which caught the regression live. */
  max-height: min(480px, calc(100vh - 24px));
  overflow-y: auto;
  z-index: 210;
}
@supports not (backdrop-filter: blur(1px)) {
  .dropdown-menu { background: var(--color-surface-raised); }
}
.dropdown-menu.open { display: flex; }
/* Issue #507 — account-menu identity block (email + "Signed in · synced"),
   shown above the item list only when a header is passed. Not a menu item
   itself (no role="menuitem", never clickable). */
.dropdown-header {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--space-2) var(--space-3) var(--space-3);
  margin-bottom: var(--space-1);
  border-bottom: 1px solid hsl(var(--v3-divider) / 0.6);
}
.dropdown-header-title {
  font-weight: 600;
  font-size: var(--text-base);
  color: var(--color-text);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.dropdown-header-subtitle { font-size: var(--text-base); color: var(--color-text-muted); }
/* Issue #507 — 48px rows per the account-menu design reference, matching
   this app's own --tap-min touch-target floor rather than inventing a
   separate row height. */
.dropdown-item {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  width: 100%;
  min-height: var(--tap-min, 48px);
  text-align: left;
  padding: var(--space-2) var(--space-3);
  border: none;
  background: none;
  border-radius: var(--v3-radius-md);
  font: inherit;
  font-size: var(--text-base);
  color: var(--color-text);
  cursor: pointer;
}
.dropdown-item:hover, .dropdown-item:focus-visible { background: hsl(var(--v3-accent) / 0.1); outline: none; }
.dropdown-item-danger { color: var(--color-danger); }
.dropdown-item-icon { display: inline-flex; flex-shrink: 0; color: var(--color-text-muted); }
.dropdown-item-danger .dropdown-item-icon { color: var(--color-danger); }
.dropdown-item-text { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dropdown-item-badge { flex-shrink: 0; }

/* Custom-styled listbox replacing a bare <select> (issue #136 Phase 3,
   src/ui/components/select.js) — same position: fixed + getBoundingClientRect()
   approach as .dropdown-menu above, for the same reason (escapes a clipping
   ancestor like .app-sidebar). .custom-select-trigger reuses .field-input's
   border/padding/background so it matches every other field in the same
   form exactly, with only the layout (flex row, space-between) added. */
.custom-select { position: relative; display: inline-block; width: 100%; }
.custom-select-trigger {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  width: 100%;
  text-align: left;
  cursor: pointer;
  color: var(--color-text);
  font: inherit;
}
.custom-select-trigger:focus-visible { outline: none; border-color: var(--color-accent); box-shadow: 0 0 0 3px color-mix(in srgb, var(--color-accent) 20%, transparent); }
.custom-select-value { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.custom-select-caret { display: inline-flex; flex-shrink: 0; color: var(--color-text-muted); transform: rotate(90deg); transition: transform var(--duration-base) var(--ease-in-out); }
.custom-select.open .custom-select-caret { transform: rotate(-90deg); }
.custom-select-listbox {
  position: fixed;
  max-height: 260px;
  overflow-y: auto;
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);
  padding: var(--space-1);
  margin: 0;
  list-style: none;
  display: none;
  flex-direction: column;
  /* Unlike .dropdown-menu above (only ever triggered from topbar/sidebar
     chrome, never nested inside a modal), select.js is explicitly meant to
     be droppable into any form — including importRoadmapModal.js's Goal/
     context field, inside a `.modal-overlay` at z-index 1000. A portaled
     listbox at the same z-index: 210 as .dropdown-menu rendered visually
     underneath the modal card: geometrically correct (per the transformed-
     ancestor portal fix above) but click-intercepted by the modal's own
     content on top of it in the stacking order — found via a real E2E
     failure (issue #136 Phase 3 follow-up), not a visual review. Must stay
     above every modal in the app; .modal-overlay's z-index: 1000 is the
     highest of any component this can be nested inside. */
  z-index: 1010;
}
/* Not a descendant selector — select.js appends .custom-select-listbox
   straight to <body> while open (a portal), specifically so its
   `position: fixed` coordinates are never hijacked by a transformed
   ancestor like .item-panel's slide-in (see select.js's own comment). */
.custom-select-listbox.open { display: flex; }
.custom-select-option {
  padding: var(--space-2) var(--space-3);
  border-radius: var(--radius-md);
  font-size: var(--text-base);
  color: var(--color-text);
  cursor: pointer;
}
.custom-select-option:hover, .custom-select-option:focus-visible { background: var(--color-surface-raised); outline: none; }
.custom-select-option.active { color: var(--color-accent-700); font-weight: 700; background: var(--color-warning-bg); }
/* issue #155 v2 Phase D3 (full completion pass) — text-on-light-fill pattern. */
:root[data-theme='dark'] .custom-select-option.active { color: var(--color-accent-700); background: var(--color-warning-bg); }
/* Issue #36 — no focusable field renders under 16px on touch, but a listbox
   option isn't a text-zoom-triggering field; matches .field-input's own
   compact variant instead so density stays consistent with the rest of a
   dense form. */
.custom-select.compact .custom-select-trigger { padding: 8px 10px; font-size: var(--text-base); }

/* issue #298 — design-system.md §1/§4: flat surfaces, 2px rules for major
   section boundaries (was a translucent/blurred "glass" surface + 1px rule,
   a decorative effect the "alignment and rules do the organising — never
   decoration" identity rule doesn't allow). */
/* v3 (issue #416 Phase 2 — #424): design-system.md §4/§5's blur-xl glass
   nav pill treatment, superseding #298's own flat/2px-rule comment above
   (that comment documents the *v2* decision this phase reverses — kept in
   place as history, not deleted). */
.app-topbar {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-3) var(--space-6);
  /* issue #440 — see .app-sidebar's matching note above for why nav chrome
     reads --color-bg here, not --color-surface-raised. */
  background: var(--color-bg);
  border-bottom: 1px solid var(--color-divider);
  position: sticky;
  top: 0;
  z-index: 20;
  /* issue #6 Phase 4.2 — a single source of truth for the topbar's own
     height, reused by .section-label's sticky offset below so a sticky
     section header never sits half-hidden under the topbar. Keep these two
     in lockstep rather than hardcoding the same pixel value twice. */
  min-height: var(--topbar-h);
}
.app-topbar-breadcrumb {
  font-weight: 700;
  font-size: var(--text-base);
  color: var(--color-text-muted);
  flex: 1;
  min-width: 0;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
/* Issue #488 — `.app-topbar-status` (review-due/daily-todo badges, sync
   pill, "Create account") is retired along with the `.icon-btn-group`
   height-matching hack it needed (issue #309/#435/#450) — the topbar is now
   just breadcrumb + `.icon-btn-group` (search + bell + avatar), same-size
   36px icon-only controls with no differently-sized row to stay in sync
   with. Issue #503 (E3) added the bell back as its own button — see that
   selector's own comment. */
.icon-btn-group { height: 36px; box-sizing: border-box; }
.icon-btn-group .app-topbar-command-btn,
.icon-btn-group .app-topbar-bell,
.icon-btn-group .app-topbar-avatar-btn {
  width: 28px;
  height: 28px;
  border-radius: var(--radius-full);
}
/* Command palette trigger (issue #125) — a bare, unstyled-until-hover 36px
   button since it sits among icon-only topbar actions, not a filled .btn. */
.app-topbar-command-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: none;
  background: none;
  color: var(--color-text);
  cursor: pointer;
  border-radius: var(--radius-full);
}
.app-topbar-command-btn:hover { background: var(--color-surface-raised); }
/* "What's New" bell trigger (`notificationBell.js`, issue #503/E3) — same
   unstyled-until-hover 36px treatment as `.app-topbar-command-btn`. Briefly
   folded into the avatar as `.avatar-unread-dot` during issue #488's interim
   call; the dot is back on the bell itself as `.app-topbar-bell-dot`. */
.app-topbar-bell {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: none;
  background: none;
  color: var(--color-text);
  cursor: pointer;
  border-radius: var(--radius-full);
}
.app-topbar-bell:hover { background: var(--color-surface-raised); }
.app-topbar-bell-dot { position: absolute; top: 6px; right: 6px; }
/* Avatar/account-menu trigger (issue #488) — same unstyled-until-hover 36px
   treatment as `.app-topbar-command-btn`, wrapping the shared `.avatar`
   component. */
.app-topbar-avatar-btn {
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 36px;
  height: 36px;
  border: none;
  background: none;
  color: var(--color-text);
  cursor: pointer;
  border-radius: var(--radius-full);
}
.app-topbar-avatar-btn:hover { background: var(--color-surface-raised); }

/* `grid-column: 2` is required, not decorative — without an explicit
   placement, CSS Grid's auto-placement algorithm places the next in-flow
   child into the first *available* cell. `.app-sidebar` going `display:
   none` below the 900px breakpoint (below) removes it from grid flow
   entirely, and without this rule `.app-shell-main` (the
   next in-flow child) would auto-place into column 1 instead of column 2 —
   shrinking to the sidebar's own column width and jumping to the left edge
   the instant the drawer opened, a real layout-shift bug distinct from (and
   found after fixing) the backdrop-visibility issue above (reported live,
   issue #18 follow-up). This selector has no visible effect while the
   sidebar stays in flow (desktop, or the closed tablet rail) — it only
   matters once something upstream can go out of flow. */
.app-shell-main { display: flex; flex-direction: column; min-width: 0; grid-column: 2; }
.app-content {
  flex: 1;
  width: 100%;
  max-width: var(--content-max);
  margin: 0 auto;
  padding: clamp(14px, 3vw, 32px);
}

/* Tap-target floor (issue #482 A1) — every interactive control listed below
   gets at least a 48px hit area regardless of viewport/pointer type. This is
   the *base*, unconditional floor; the existing `(pointer: coarse)` 46px
   overrides elsewhere in this file (issue #36/#233) predate this token and
   stay as-is — both apply, and 48px is the larger of the two, so this rule is
   never weakened by them.
   `.btn-icon` is deliberately excluded from this unconditional floor (via
   `.btn:not(.btn-icon)`, since e.g. `.theme-toggle` carries both `.btn` and
   `.btn-icon` together) — issue #36/#233 established (and
   `tests/e2e/responsive.test.js` asserts) that an icon-only button stays a
   compact 36x36px on a non-touch desktop viewport and only grows for a real
   touch/coarse pointer; see the `@media (pointer: coarse)` block below,
   where it's raised to `var(--tap-min)` instead of the old fixed 46px. */
.btn:not(.btn-icon),
.filter-chip,
.nav-item,
.check-box,
.seg-opt {
  min-height: var(--tap-min);
  min-width: var(--tap-min);
}

/* Below 900px (issue #484) — the sidebar is retired entirely in favor of
   bottomNav.js's fixed bottom tab bar; no icon-only rail, no off-canvas
   drawer. `.app-shell-main` is pinned back to the grid's only explicit
   column, matching the same "grid-column must match however many explicit
   columns actually exist at this breakpoint" reasoning previous versions of
   this rule already established for the old <640px tier. */
@media (max-width: 899px) {
  .app-shell-main { grid-column: 1; }
  .app-shell-2 { grid-template-columns: minmax(0, 1fr); }
  .app-sidebar { display: none; }
  /* Issue #488 retired the #463 status-group wrap-order rework entirely —
     with `.app-topbar-status` gone, `.app-topbar` is just breadcrumb +
     `.icon-btn-group` and never needs to wrap onto a second row at any
     width, so no `order`/`flex-wrap` override is needed here at all. */
  /* Room for the fixed bottom nav (below) so page content never renders
     underneath it. */
  .app-content { padding-bottom: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom) + var(--space-4)); }
}

.scroll-locked { overflow: hidden; }

/* Bottom tab bar (issue #484) — replaces the hamburger drawer as the app's
   mobile/tablet navigation below the sidebar's 900px breakpoint. Fixed,
   full-width, always-visible labels — reachable one-handed, no discovery
   needed, matching the platform pattern every phone user already knows.
   Hidden entirely at/above 900px, where the sidebar (above) is the nav. */
.bottom-nav {
  display: none;
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 200;
  height: var(--bottom-nav-h);
  padding-bottom: env(safe-area-inset-bottom);
  background: var(--color-bg);
  border-top: 1px solid var(--color-divider);
}
@media (max-width: 899px) {
  .bottom-nav { display: flex; }
}
.bottom-nav-item {
  flex: 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 2px;
  min-height: var(--tap-min);
  color: var(--color-text-muted);
  text-decoration: none;
  font-size: var(--text-base);
  font-weight: 600;
}
.bottom-nav-item.active {
  background: var(--color-accent-100);
  /* --color-accent-700 is this app's WCAG-safe "accent as paragraph-size
     text" token (see its own :root definition) — used here as the spec's
     --color-accent-ink, which isn't a real token in this codebase. */
  color: var(--color-accent-700);
}
.bottom-nav-item-icon { display: inline-flex; }
/* `white-space: nowrap` + ellipsis, not a wrap — this bar's height is fixed
   (`--bottom-nav-h`), so a label that wraps to two lines overflows it
   instead of growing it (issue #495 follow-up, real reported bug: "My
   roadmap" wrapped in its quarter-width column on a narrow phone while its
   single-word siblings stayed on one line). The label itself was shortened
   to a single word so this never triggers in practice; this is the
   defensive floor for any future label that's still too long for its column
   at the narrowest supported width, degrading to a truncated ellipsis
   instead of a broken layout. */
.bottom-nav-item-label { line-height: 1; max-width: 100%; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }

.offline-banner {
  display: none;
  padding: 10px 16px;
  background: var(--color-warning-bg);
  border-bottom: 1px solid var(--color-warning);
  color: var(--color-warning);
  font-size: var(--text-base);
  font-weight: 600;
  text-align: center;
}
.offline-banner.show { display: flex; align-items: center; justify-content: center; gap: 8px; }

.verification-banner {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 10px 16px;
  background: var(--color-warning-bg);
  border-bottom: 1px solid var(--color-border-strong);
  color: var(--color-accent-700);
  font-size: var(--text-base);
  font-weight: 600;
}
/* issue #155 v2 Phase D3 (full completion pass) — text-on-light-fill pattern, app-wide
   banner shown across every page for an unverified account. */
:root[data-theme='dark'] .verification-banner {
  background: var(--color-warning-bg);
  border-bottom-color: var(--color-border-strong);
  color: var(--color-accent-700);
}
/* issue #503 follow-up — same fix as .guest-banner-msg below (identical
   shape, identical shrink-to-nothing bug on a narrow viewport). */
.verification-msg { flex: 1; min-width: min(220px, 100%); }
.verification-dismiss { font-size: 16px; line-height: 1; padding: 2px 8px; }

/* Issue #488 — replaces the old topbar-status "Local only" sync pill +
   "Create account" button pair, same dismissible-banner shape as
   .verification-banner above but living in .app-content, not topbar chrome. */
.guest-banner {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 10px 16px;
  margin-bottom: var(--space-4);
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  font-size: var(--text-base);
}
/* issue #503 follow-up — real, reported bug: `min-width: 0` let this flex
   item shrink all the way toward zero on a narrow viewport instead of ever
   reaching the point flex-wrap is meant to kick in for — with "Create
   account" + the dismiss button also competing for the same row and
   neither eager to shrink below their own text/icon's min-content, nearly
   all the deficit landed on this item, squeezing its text into a
   several-words-tall single narrow column instead of wrapping the row onto
   a new line. A real (not zero) min-width means the buttons wrap onto
   their own line, below full-width message text, the moment there's
   genuinely not enough room — matching `.verification-msg`'s identical fix
   below (same shape, same bug). */
.guest-banner-msg { flex: 1; min-width: min(220px, 100%); color: var(--color-text-muted); }
.guest-banner-dismiss { font-size: 16px; line-height: 1; padding: 2px 8px; }

/* Issue #505 follow-up (live visual review) — on the dashboard specifically,
   `.guest-banner` is a direct child of `.app-content`, flush with *its*
   padding — but `.dashboard-header` (the very next sibling) adds its own
   further `--dashboard-gutter-x` padding before `.roadmap-summary-card` and
   friends, so the banner reads measurably wider than every card below it
   (confirmed live: 1016px vs. 976px). Scoped to `.guest-banner` specifically
   (not `.verification-banner`/`.backup-reminder-banner`/
   `.progress-digest-banner`, its siblings in the same `.app-content` slot) —
   those three are deliberately flush, full-bleed strips with a bottom border
   only and no side border/radius, unlike `.guest-banner`'s bordered-card
   look, which is what makes the width mismatch read as a bug here and not
   there. Also scoped to `.dashboard` — settings.js/progress.js have no
   equivalent second gutter layer for this to correct against. */
.dashboard .app-content > .guest-banner,
.dashboard .app-content > .guest-risk-nudge {
  margin-left: var(--dashboard-gutter-x);
  margin-right: var(--dashboard-gutter-x);
  /* Issue #505 follow-up (live visual review) — `.guest-banner`'s own
     `margin-bottom: var(--space-4)` (16px) used to stack on top of
     `.dashboard-header`'s `padding-top` (18px, safe-area-aware — see
     `.claude/rules/ui-styling.md`'s safe-area note, not something to retune
     here), reading as a visibly larger 34px gap before the first card versus
     the flat 16px rhythm between the header's own cards. Zeroed here so the
     header's own padding is the sole source of that one gap, matching the
     rest of the stack far more closely (18px vs. 16px) without touching the
     safe-area-critical padding value itself. */
  margin-bottom: 0;
}

/* Issue #505 follow-up (live visual review) — the same double-stack shape
   as `.guest-banner` above, at the opposite end of `.dashboard-header`:
   `.roadmap-filters-card` is normally the header's last visible child
   (`.review-tag-group-banner`, the real last DOM child, only ever renders
   visible content while the REVIEW filter is active — it's still present,
   just empty/hidden otherwise, which is why a `:last-child` selector can't
   be used to scope this to "only when nothing follows"), so its own
   margin-bottom stacks with the header's `padding-bottom` (20px) into a
   36px gap before `.daily-todo-panel` — visibly larger than the 16px rhythm
   between the header's own cards. Zeroed unconditionally: in the rare state
   where `.review-tag-group-banner` *is* visible, its own `margin-top: 8px`
   still provides a real (if now smaller) gap above it — a reasonable
   trade-off for a rarely-visible state, not a broken one. */
.dashboard-header > .roadmap-filters-card {
  margin-bottom: 0;
}

/* Issue #507 — the "at-risk" nudge in a warning about local-only data being
   lost, not good news, so it deliberately does NOT share the accent-tint
   background below (.backup-reminder-banner/.progress-digest-banner) — a
   neutral surface + border reads as "pay attention," never as a success
   banner. Same dismissible-banner shape otherwise (icon, message, actions). */
.guest-risk-nudge {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 10px 16px;
  background: var(--color-surface);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--v3-radius-md);
  color: var(--color-text);
  font-size: var(--text-base);
  font-weight: 600;
  margin-bottom: var(--space-4);
}
.guest-risk-nudge-icon { display: inline-flex; color: var(--color-danger); }
.guest-risk-nudge-msg { flex: 1; min-width: min(220px, 100%); font-weight: 500; }

.backup-reminder-banner {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 10px 16px;
  background: var(--color-warning-bg);
  border-bottom: 1px solid var(--color-border-strong);
  color: var(--color-accent-700);
  font-size: var(--text-base);
  font-weight: 600;
}
/* issue #155 v2 Phase D3 (full completion pass) — same pattern as .verification-banner
   above. */
:root[data-theme='dark'] .backup-reminder-banner {
  background: var(--color-warning-bg);
  border-bottom-color: var(--color-border-strong);
  color: var(--color-accent-700);
}
.backup-reminder-icon { font-size: var(--text-base); }
.backup-reminder-msg { flex: 1; min-width: 220px; font-weight: 500; }

/* Weekly progress digest banner (issue #284) — same shape as
   .backup-reminder-banner immediately above, reusing the identical
   warning-tint/border/text-token trio so every dismissible top-of-page
   banner in this app reads as one visual family. */
.progress-digest-banner {
  display: flex;
  align-items: center;
  gap: 10px;
  flex-wrap: wrap;
  padding: 10px 16px;
  background: var(--color-warning-bg);
  border-bottom: 1px solid var(--color-border-strong);
  color: var(--color-accent-700);
  font-size: var(--text-base);
  font-weight: 600;
}
:root[data-theme='dark'] .progress-digest-banner {
  background: var(--color-warning-bg);
  border-bottom-color: var(--color-border-strong);
  color: var(--color-accent-700);
}
.progress-digest-icon { display: inline-flex; }
.progress-digest-msg { flex: 1; min-width: 220px; font-weight: 500; }

/* intentional: theme-agnostic dark scrim behind an opaque, bright .modal-card
   (issue #116; issue #206 §3.5 — retuned to a neutral rgba(0,0,0,0.5), the
   spec's exact value, replacing the old slate-tinted rgba(15,23,42,0.55)). */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.5);
  display: flex;
  /* `safe center` (not plain `center`) so a modal taller than the viewport
     stays scrollable to its very top and bottom — plain `center` on an
     overflowing flex container clips both ends equally and makes them
     unreachable no matter how far you scroll, a well-known flexbox+overflow
     trap. Found via the Daily Todos guide modal growing past the viewport
     on a short window (issue #56 follow-up) — applies to every modal in
     the app, not just that one. */
  align-items: safe center;
  justify-content: center;
  z-index: 1000;
  padding: 24px;
  overflow-y: auto;
}
/* v3 (issue #416 Phase 5 — #430): glass surface + shadow-glow per
   design-system.md §5's overlay rule — was a flat --color-surface fill +
   --shadow-lg. Applies to every hand-rolled overlay built on `.modal-card`
   (confirmDialog, deleteAccountModal, itemPanel, newRoadmapModal,
   importRoadmapModal, share/comparison/feedback modals, guide modals).
   Issue #444 — the 0.9 opacity let enough of whatever's blurred behind the
   modal (page content varies by which modal/route it's opened from) bleed
   through to occasionally drop `--color-text-muted`'s contrast on top of it
   below the 4.5:1 AA floor (measured 4.48:1, reproduced independent of any
   other change) — real, not an axe sampler false positive, since the
   composited colors axe reported were mathematically consistent with the
   actual token math. 0.97 keeps the glass look while leaving negligible
   backdrop bleed-through, so text contrast no longer depends on what
   happens to be rendered behind the modal at open time. */
.modal-card {
  background: hsl(var(--v3-surface) / 0.97);
  -webkit-backdrop-filter: blur(16px);
  backdrop-filter: blur(16px);
  border: 1px solid hsl(var(--v3-divider) / 0.6);
  border-radius: var(--v3-radius-lg);
  box-shadow: var(--v3-shadow-glow);
  padding: 32px 28px 28px;
  width: 100%;
  max-width: 420px;
}
@supports not (backdrop-filter: blur(1px)) {
  .modal-card { background: var(--color-surface-raised); }
}
/* Issue #444 — the flat 2px border-bottom rule (added in #301, back when the
   design system was still the flat v2 "Modernist" system) reads as a bare
   grey line floating under the title on today's v3 glass `.modal-card`,
   which already carries its own border/glow/blur — live-reported as
   unpolished ("one grey line") next to the dialog's actual content. Dropped
   the rule entirely in favor of spacing alone (matches how every other v3
   surface — cards, panels — separates a heading from its body: whitespace,
   not a ruled line). */
.modal-title {
  font-size: 20px;
  font-weight: 800;
  color: var(--color-text);
  padding-bottom: 16px;
  margin-bottom: 20px;
}
.delete-modal-body {
  font-size: var(--text-base);
  color: var(--color-text-muted);
  line-height: 1.6;
}

.build-guide-card { max-width: 520px; display: grid; gap: 4px; }
.build-guide-intro { font-size: var(--text-base); color: var(--color-text-muted); line-height: 1.6; margin-bottom: 8px; }
.build-guide-heading { font-size: var(--text-base); font-weight: 800; color: var(--color-text); margin-top: 12px; }
/* Issue #507 — the numbered 1/2/3 step badge from the "How it works"-style
   guide cards (dailyTodoGuide.js/buildYourOwnGuide.js) in the design
   reference. A plain flex row, not a grid, so a step whose body wraps to
   multiple lines still keeps the badge aligned to the first line. */
.build-guide-step { display: flex; gap: 12px; margin-top: 12px; align-items: flex-start; }
.build-guide-step-number {
  flex-shrink: 0;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: hsl(var(--v3-accent) / 0.15);
  color: var(--color-accent-700);
  font-weight: 700;
  font-size: var(--text-base);
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.build-guide-step-content { flex: 1; min-width: 0; }
.build-guide-step .build-guide-heading { margin-top: 0; }
.build-guide-body { font-size: var(--text-base); color: var(--color-text); line-height: 1.6; }
.build-guide-note { font-size: 12px; color: var(--color-text-muted); font-style: italic; margin-top: 12px; }

/* Issue #507 — custom-roadmap icon picker: a 56px-cell grid (design
   reference), one cell per decorativeIcon.js name in the caller's list. */
.icon-picker-card { max-width: 420px; }
.icon-picker-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, 56px);
  gap: var(--space-2);
  margin: var(--space-4) 0;
}
.icon-picker-cell {
  width: 56px;
  height: 56px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--color-border);
  border-radius: var(--v3-radius-md);
  background: var(--color-surface);
  color: var(--color-text-muted);
  cursor: pointer;
}
.icon-picker-cell:hover, .icon-picker-cell:focus-visible { border-color: hsl(var(--v3-accent) / 0.5); color: var(--color-text); outline: none; }
.icon-picker-cell.active { border-color: hsl(var(--v3-accent)); background: hsl(var(--v3-accent) / 0.12); color: var(--color-accent-700); }

.confirm-dialog-card { max-width: 440px; }
.confirm-dialog-body { font-size: var(--text-base); color: var(--color-text-muted); line-height: 1.6; }
.confirm-dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 24px; }

/* Share card preview modal — rebuilt as a full generator (issue #501,
   superseding the original issue #8 Part C fixed single-card modal).
   Widened from the old 600px single-column layout to a two-column
   preview/controls grid, matching the design reference's 09-share-progress
   layout — the preview stays a fixed-max-width column while the controls
   column scrolls independently within the shared modal-card scroll bound. */
.share-modal-card { max-width: 980px; display: grid; gap: 14px; position: relative; max-height: 90vh; overflow-y: auto; }
.share-modal-card .modal-close { position: absolute; top: 8px; right: 8px; }
.share-modal-subtitle { color: var(--color-text-muted); font-size: var(--text-base); margin: -6px 0 4px; }
.share-modal-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; }
@media (min-width: 800px) {
  .share-modal-grid { grid-template-columns: minmax(0, 1.1fr) minmax(0, 1fr); align-items: start; }
}
.share-modal-preview-col { display: grid; gap: 14px; position: sticky; top: 0; }
@media (max-width: 799px) {
  .share-modal-preview-col { position: static; }
}
.share-modal-controls-col { display: grid; gap: 20px; min-width: 0; }
.share-preview-heading { display: flex; justify-content: space-between; align-items: baseline; }
.share-preview-label { font-size: var(--text-base); font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: var(--tracking-caps); }
/* The preview canvas is the true 1200x630 element, CSS-scaled down via a
   plain width:100% — never a separately-styled approximation — so the
   downloaded PNG is guaranteed pixel-identical to what was previewed. */
.share-preview-wrap { position: relative; border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--shadow-sm); transition: opacity var(--duration-fast) var(--ease-out); }
.share-preview-wrap.share-preview-loading { opacity: 0.5; }
.share-card-preview { width: 100%; height: auto; display: block; }
.share-style-row { display: flex; align-items: center; gap: 12px; }
.share-control-block { display: grid; gap: 12px; }
.share-control-heading { font-size: var(--text-base); font-weight: 700; color: var(--color-text); margin: 0; }
.share-caption-heading-row { display: flex; justify-content: space-between; align-items: center; }

.share-scope-group { display: grid; gap: 8px; }
.share-scope-option {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 12px 14px;
  border: 1px solid var(--color-divider);
  border-radius: var(--radius-md);
  cursor: pointer;
  background: hsl(var(--v3-surface));
}
.share-scope-option:has(.share-scope-input:checked) { border-color: var(--color-accent); background: hsl(var(--v3-accent) / 0.08); }
.share-scope-input { flex-shrink: 0; accent-color: var(--color-accent); width: 18px; height: 18px; }
.share-scope-option-body { display: grid; gap: 2px; min-width: 0; }
.share-scope-option-label { font-weight: 700; color: var(--color-text); }
.share-scope-option-desc { font-size: var(--text-base); color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.share-phase-select-slot:empty { display: none; }

.share-toggles { display: grid; gap: 4px; }
.share-toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 6px 0; }
.share-toggle-label { color: var(--color-text); font-size: var(--text-base); }

.share-caption-label { display: grid; gap: 6px; font-size: var(--text-base); font-weight: 600; color: var(--color-text); }
.share-caption-input {
  font: inherit;
  font-size: var(--text-base);
  padding: 10px 12px;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-surface-raised);
  color: var(--color-text);
  resize: vertical;
}
.share-hashtag-row { display: flex; flex-wrap: wrap; gap: 8px; }
.share-hashtag-row .tag-chip { cursor: pointer; border: 1px solid var(--color-divider); background: hsl(var(--v3-surface)); font: inherit; text-transform: none; letter-spacing: normal; font-weight: 600; }

.share-targets-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
.share-target-btn { justify-content: flex-start; gap: 8px; }

.share-modal-actions { display: flex; flex-wrap: wrap; gap: 10px; }

/* Roadmap sharing — published snapshot link (issue #131, close button +
   internal scroll bound added issue #396) */
.share-roadmap-modal-card { max-height: 90vh; overflow-y: auto; }
.share-roadmap-modal { display: grid; gap: 14px; max-width: 520px; position: relative; }
.share-roadmap-modal .modal-close { position: absolute; top: 0; right: 0; }
.share-roadmap-modal-copy { color: var(--color-text-muted); font-size: var(--text-base); }
.share-roadmap-modal-status { font-size: var(--text-base); color: var(--color-text); word-break: break-all; }
.share-link-list { display: grid; gap: 10px; list-style: none; padding: 0; margin: 0; max-height: 240px; overflow-y: auto; }
.share-link-row {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 12px;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-surface-raised);
}
.share-link-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--color-text); }

/* issue #502 — public shared-roadmap view (E2), rebuilt to match the design
   reference (docs/screenshots/responsive-redesign/10-shared-roadmap.png)
   onto the current v3 flat-content component set (.card/.badge/--chip-*
   tokens, design-system.md §4). No app navigation on this page (the issue's
   own scope note) — every link is a marketing entry point (home, sign up),
   never a link into the app shell; the theme toggle is the one interactive
   control an unauthenticated visitor gets besides those. */
.shared-view-container { min-height: 100vh; min-height: 100dvh; padding: 32px 20px calc(32px + env(safe-area-inset-bottom)); max-width: var(--content-max, 1080px); margin: 0 auto; }
.shared-view-chrome { display: flex; align-items: center; justify-content: space-between; margin-bottom: 28px; padding-bottom: 20px; border-bottom: 1px solid var(--color-divider); }
.shared-view-brand { display: inline-flex; align-items: center; }
.shared-view-chrome-actions { display: flex; align-items: center; gap: 12px; }
.shared-view-content { display: grid; gap: 20px; }
/* .shared-view-content's own gap only ever separates the loading-state's
   skeleton children — renderSnapshot()/renderRevoked() each return a single
   node, so the real inter-section spacing (header → stats → phases → CTA →
   footer) has to live on .shared-view itself, not its parent. Missing this
   rule was a real bug: every section rendered flush against the next with
   zero gap, found live via a screenshot report. */
.shared-view { display: grid; gap: 20px; }

.shared-view-loading-state { display: grid; gap: 16px; }
.shared-view-stats-skeleton { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 16px; }
.shared-view-loading-state .skeleton-card { height: 88px; }

.shared-view-state { display: grid; gap: 12px; justify-items: center; text-align: center; padding: 80px 20px; }
.shared-view-state-icon { color: var(--color-text-muted); margin-bottom: 4px; }
.shared-view-state-actions { display: flex; flex-wrap: wrap; gap: 12px; justify-content: center; margin-top: 8px; }

.shared-view-header { display: grid; gap: 10px; }
.shared-view-readonly-badge { display: inline-flex; align-items: center; gap: 6px; width: fit-content; height: auto; padding: 6px var(--chip-padding-x); background: hsl(var(--v3-surface)); border: 1px solid var(--color-divider); border-radius: var(--radius-full); color: var(--color-text-muted); font-family: var(--font-body); font-size: var(--text-base); font-weight: 600; text-transform: none; }
.shared-view-header h1 { margin: 0; }
.shared-view-subtitle { color: var(--color-text-muted); margin: 0; }

.shared-view-stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; }
.shared-stat-tile { display: flex; flex-direction: column; gap: 8px; padding: 18px 20px; }
.shared-stat-tile-label { font-size: var(--text-base); font-weight: 700; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: var(--tracking-caps, 0.08em); }
.shared-stat-tile-headline { display: flex; align-items: baseline; gap: 8px; }
.shared-stat-tile-number { font-size: var(--text-3xl); font-weight: 800; color: var(--color-text); }
.shared-stat-tile-sub { font-size: var(--text-base); color: var(--color-text-muted); }
.shared-stat-tile-bar { width: 100%; height: 6px; display: block; }
.shared-stat-tile-bar-track { fill: var(--color-border-strong, var(--color-divider)); }
.shared-stat-tile-bar-fill { fill: var(--color-accent); }

.shared-view-phases { display: grid; gap: 20px; }
.shared-phase-card { padding: 18px 20px; }
.shared-phase-card-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
.shared-phase-card-head h2 { margin: 0; }
.shared-phase-card-counts { font-size: var(--text-base); color: var(--color-text-muted); }
.shared-phase-card-percent { font-size: var(--text-xl); font-weight: 800; color: var(--color-text); flex-shrink: 0; }
.shared-section { margin-top: 14px; }
.shared-section h3 { font-size: var(--text-base); font-weight: 700; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: var(--tracking-caps, 0.08em); margin: 0 0 8px; }
.shared-item-list { display: grid; gap: 8px; list-style: none; padding: 0; margin: 0; }
.shared-item {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 12px;
  border-radius: var(--radius-md);
  background: hsl(var(--v3-surface));
  border: 1px solid var(--color-divider);
}
.shared-item-check {
  width: 24px;
  height: 24px;
  flex-shrink: 0;
  border-radius: var(--radius-full);
  border: 2px solid var(--color-border-strong, var(--color-divider));
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--color-bg);
}
.shared-item-check-done { background: var(--color-accent); border-color: var(--color-accent); }
.shared-item-body { flex: 1; min-width: 0; display: flex; flex-direction: column; }
.shared-item-title { color: var(--color-text); font-weight: 600; }
.shared-item-done .shared-item-title { color: var(--color-text-muted); text-decoration: line-through; }
.shared-item-priority { font-size: var(--text-base); color: var(--color-text-muted); }
.shared-item-resource-count { display: inline-flex; align-items: center; gap: 4px; color: var(--color-text-muted); font-size: var(--text-base); flex-shrink: 0; }

/* --color-accent-600 flips brightness direction between themes (a darkened
   step in light theme, a *lighter* step in dark theme — see its own comment
   above --color-accent-600's declaration), so it needs its matched
   --color-ink-on-accent text token, not a fixed white — a fixed white read
   near-invisible against dark theme's light-mint accent-600 fill, a real
   contrast bug found via a live screenshot. Same pairing .btn-primary/
   .seg-item/.reset-success-icon already use for exactly this reason. */
.shared-view-cta-banner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 16px;
  padding: var(--space-8) 2.5rem;
  background: var(--color-accent-600);
  color: var(--color-ink-on-accent);
  border: none;
}
.shared-view-cta-banner::before { display: none; }
.shared-view-cta-text h2 { margin: 0 0 4px; color: inherit; }
.shared-view-cta-text p { margin: 0; color: inherit; opacity: 0.85; }
.shared-view-cta-banner .btn-cta-inverse {
  flex-shrink: 0;
  background: hsl(0 0% 100%);
  /* intentional: fixed dark green on a fixed white pill, same both themes —
     the pill itself never changes color with theme (a "cutout" look against
     the accent-600 field above), so its text can't read a theme-flipping
     ink token either; verified >=8:1 against white in both themes. */
  color: #034E24;
  border-color: transparent;
  box-shadow: none;
}
.shared-view-cta-banner .btn-cta-inverse:hover { background: hsl(0 0% 96%); transform: translateY(-1px); }

.shared-view-footer { margin-top: 12px; display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; gap: 8px; padding-top: 20px; border-top: 1px solid var(--color-divider); }
/* A plain small icon + "Made with Ascent" line — deliberately not
   createBrandMark()'s full icon+wordmark pair (.brand-mark/.brand-name are
   bold/uppercase/text-xl, sized for real page headers), which used to sit
   awkwardly inline next to this footer's own plain-text label at a visibly
   different size/weight. Text-decoration is set explicitly (not inherited
   default-underline) so the whole line reads as one calm, muted caption. */
.shared-view-attribution { display: inline-flex; align-items: center; gap: 6px; color: var(--color-text-muted); font-size: var(--text-base); text-decoration: none; }
.shared-view-attribution:hover, .shared-view-attribution:focus-visible { text-decoration: underline; }
/* .brand-mark's inner <svg> carries its own fixed width="20"/height="20"
   HTML attributes (brandGlyph(), brand.js) — shrinking only the outer box
   here left the glyph at its native 20px size inside an 18px box, poking
   out asymmetrically instead of scaling down with it. The child selector
   forces the svg to fill its (smaller) box so place-items: center centers
   a correctly-sized glyph, not an oversized one. */
.shared-view-attribution .brand-mark { width: 18px; height: 18px; }
.shared-view-attribution .brand-mark svg { width: 100%; height: 100%; }
.shared-view-footer-note { color: var(--color-text-muted); font-size: var(--text-base); }

@media (max-width: 480px) {
  .shared-view-cta-banner { flex-direction: column; align-items: stretch; text-align: center; }
  .shared-view-cta-banner .btn { width: 100%; }
  .shared-view-footer { flex-direction: column; text-align: center; }
}

@media (max-width: 480px) {
  .share-modal-actions { flex-direction: column; }
  .share-modal-actions .btn { width: 100%; }
}

/* "Create your own roadmap" AI-creation modal (issues #4/#64, redesigned as a
   two-column build/paste layout in #100). Below the ≥1024px tier (see the
   breakpoint scale at the bottom of this file) this collapses to the single
   top-to-bottom column the flow always used — only the grid/footer split
   below is gated on that tier, not the content itself. */
.import-modal-card { max-width: 1040px; display: grid; gap: 16px; max-height: 88vh; overflow-y: auto; }
.import-modal-grid { display: grid; grid-template-columns: minmax(0, 1fr); gap: 24px; }
.import-column { min-width: 0; }
.import-column-paste { display: grid; gap: 12px; align-content: start; }
/* The build column is a flex column, not a plain scrolling block — the
   "Copy it" step (`.import-copy-sticky`) is a sibling of the scrollable
   `.import-column-scroll`, not a descendant of it, so it always renders in
   its own natural space below whatever height the scroll area is given,
   instead of requiring a scroll to reach it. This is what fixes "the prompt
   and Copy button are under the scrollable, not visible on first open"
   without removing the scroll itself — the topic/filters area still scrolls
   independently, the copy step never does. */
.import-column-build { display: flex; flex-direction: column; min-width: 0; }
.import-column-scroll { display: grid; gap: 14px; align-content: start; }
.import-step-heading { display: flex; align-items: center; gap: 10px; font-size: var(--text-base); font-weight: 800; color: var(--color-text); margin: 6px 0 0; }
.import-step-heading:first-child { margin-top: 0; }
/* v3 (issue #416 Phase 5 — #430) — light theme now reads --color-accent-600
   (not the base accent) for the same text-hosting-fill reason as
   .reset-success-icon above; white text on it clears 5.90:1. Also fixes a
   real, pre-existing bug found auditing this rule during the compliance
   sweep: the code comment below claimed dark theme already overrode the
   text color to a dark ink (`var(--color-bg)`), but the actual dark-theme
   rule only ever overrode `background`, never `color` — so dark theme was
   silently rendering fixed white text on a bright accent fill the whole
   time (1.44:1 on the new green base, and not much better on the old v2 red
   either). Fixed for real this time: the dark override below now also sets
   `color`. */
.import-step-badge {
  flex-shrink: 0;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--color-accent-600);
  color: #fff; /* intentional: fixed white on --color-accent-600, dark theme overrides below */
  font-size: 12px;
  font-weight: 800;
}
:root[data-theme='dark'] .import-step-badge { background: var(--color-accent); color: var(--color-bg); }
.field-hint { font-size: 12px; color: var(--color-text-muted); margin-top: -4px; }
.import-options { display: grid; gap: 14px; padding: 14px; background: var(--color-surface-raised); border: 1px solid var(--color-border); border-radius: var(--radius-md); }
.import-option-chips { display: flex; flex-wrap: wrap; gap: 6px; }
/* An implicit CSS Grid row's "auto" sizing (.import-column-scroll, the
   parent) gives a scroll-container item (overflow-y: auto here) an
   automatic minimum content size of 0 — per spec, a non-"visible" overflow
   value zeroes out an item's content-based minimum size, which collapsed
   this block to ~34px (just its padding/border) regardless of max-height,
   the real cause of issue #121 item 5's "renders very short" report, not
   the 260px cap alone. A definite `height` sidesteps the ambiguous
   "auto" track-sizing algorithm entirely — the box is always exactly this
   tall, scrolling its own overflow, rather than being derived from content. */
.import-prompt-block {
  font-family: var(--font-mono);
  font-size: var(--text-base);
  line-height: 1.7;
  white-space: pre-wrap;
  word-break: break-word;
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: 16px;
  height: 420px;
  overflow-y: auto;
  color: var(--color-text);
}
/* Always visible the instant the modal opens — see the .import-column-build
   comment above. A top border + panel background give it a subtle "this is
   a fixed footer, not just more scrolling content" cue even though it isn't
   position: fixed/sticky. */
.import-copy-sticky {
  display: grid;
  gap: 8px;
  padding-top: 14px;
  margin-top: 10px;
  border-top: 1px solid var(--color-border);
  background: var(--color-surface);
}
.import-copy-hint { font-size: 12px; color: var(--color-text-muted); margin: 0; }
.import-copy-instructions { font-size: 12px; color: var(--color-text-muted); margin: 0; }
.import-paste-area { font-family: var(--font-mono); font-size: 12px; resize: vertical; }
.import-technical-toggle { justify-self: start; }
.import-errors {
  list-style: none;
  display: grid;
  gap: 4px;
  padding: 10px 12px;
  margin: 0;
  background: var(--color-danger-bg);
  border: 1px solid var(--color-danger);
  border-radius: var(--radius-md);
  color: var(--color-danger);
  font-size: 12px;
}
.import-errors:empty { display: none; }
.import-errors[hidden] { display: none; }
.import-modal-footer { display: grid; gap: 10px; }

/* ≥1024px (tablet landscape / small laptop and up) — the two-column
   "build your prompt" / "paste the AI's answer" split. Each column scrolls
   independently (not the whole .modal-card) so the prompt-building column
   can stay in view while scrolling the paste column, or vice versa — the
   exact disorientation a single shared scroll caused pre-#100. The paste
   textarea is set to fill its column so it reads as the visually dominant
   element there, per the issue's "attention should land on the paste box"
   requirement. */
@media (min-width: 1025px) {
  .import-modal-grid { grid-template-columns: 45% minmax(0, 1fr); gap: 32px; }
  .import-column-build { max-height: 60vh; }
  .import-column-scroll { flex: 1; min-height: 0; overflow-y: auto; padding-right: 4px; }
  .import-prompt-block { height: min(420px, 40vh); }
  .import-column-paste { max-height: 60vh; overflow-y: auto; padding-right: 4px; }
  .import-column-paste .import-paste-area { min-height: 260px; flex: 1; }
}

/* issue #298 originally gave this header its own flat background + a 2px
   border-bottom "major section boundary" rule, matching .app-topbar's
   treatment at the time. issue #503 follow-up — real, reported bug: every
   section inside this header (.roadmap-summary-card/.next-up-card/
   .roadmap-filters-card) is now its own individually-rounded `.card`, each
   with its own background/border/radius — this outer header's own flat
   background + square-cornered border-bottom is a leftover from before
   that per-section `.card` system existed. With it still here, it painted
   a flat, unrounded rule flush against `.daily-todo-panel` immediately
   below (zero gap, since the border sits at this header's own padding-box
   edge) — reading as "one rectangular seam" cutting across two rounded
   cards, and effectively boxing an already-carded region in a second,
   redundant box (the "box inside a box" pattern this app's own design
   system otherwise avoids — see ui-styling.md's card-consolidation note).
   Removed; only the safe-area-aware padding remains, since layout/spacing
   is still this element's job even though visual chrome no longer is. */
.dashboard-header {
  padding: max(18px, env(safe-area-inset-top)) max(var(--dashboard-gutter-x), env(safe-area-inset-right)) 20px max(var(--dashboard-gutter-x), env(safe-area-inset-left));
}

.header-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }

.sync-pill {
  font-size: 12px;
  font-weight: 800;
  padding: 6px 10px;
  border-radius: var(--radius-full);
  border: 1px solid var(--color-success);
  background: var(--color-success-bg);
  color: var(--color-success);
}
.sync-pill.guest { background: var(--color-warning-bg); color: var(--color-warning); border-color: var(--color-warning); }
.sync-pill.error { background: var(--color-danger-bg); color: var(--color-danger); border-color: var(--color-danger); }

/* issue #297 — design-system.md §2 collapses success into the neutral-ink
   scheme (no dedicated green/olive hue survives this pass); --color-success
   already resolves to --color-neutral-800 in both themes, so this dot no
   longer needs its own fixed literal to stay theme-consistent. */
.sync-dot {
  width: 8px; height: 8px; border-radius: 50%; background: var(--color-success); display: inline-block;
}
.sync-dot.error { background: var(--color-danger); }

/* Small header notification for the soonest active Daily Todo (issue #56
   follow-up) — visible on every roadmap's dashboard, not just the
   onboarding page the todo list itself lives on, since todos are global.
   Reuses the same ok/warn/danger status-color families as
   daily-todo-remaining's countdown bands (src/ui/utils/dailyTodo.js
   remainingBand) rather than inventing new colors, so the urgency reads
   consistently wherever a todo countdown appears. Hidden entirely (not just
   emptied) when there is no active todo — see updateDailyTodoBadge in
   dashboard.js. */
.daily-todo-nav-badge[hidden] { display: none; }
.daily-todo-nav-badge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-size: 12px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  padding: 6px 10px;
  border-radius: var(--v3-radius-md);
  text-decoration: none;
  border: 1px solid var(--color-success);
  background: var(--color-success-bg);
  color: var(--color-success);
  transition: transform 0.15s ease;
}
.daily-todo-nav-badge:hover, .daily-todo-nav-badge:focus-visible { transform: translateY(-1px); }
.daily-todo-nav-badge:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.daily-todo-nav-badge.warn { background: var(--color-warning-bg); color: var(--color-warning); border-color: var(--color-warning); }
.daily-todo-nav-badge.danger { background: var(--color-danger-bg); color: var(--color-danger); border-color: var(--color-danger); }
.daily-todo-nav-icon { display: inline-flex; flex-shrink: 0; }

/* Review-due nav badge (issue #134) — header pill showing topics due for
   spaced-repetition review, matching the .daily-todo-nav-badge pattern above.
   Hidden entirely (not just emptied) when nothing is due — see
   updateReviewDueBadge in dashboard.js. */
.review-due-nav-badge[hidden] { display: none; }
.review-due-nav-badge {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font: inherit;
  font-size: 12px;
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  padding: 6px 10px;
  border-radius: var(--v3-radius-md);
  cursor: pointer;
  border: 1px solid var(--color-warning);
  background: var(--color-warning-bg);
  color: var(--color-warning);
  transition: transform 0.15s ease;
}
.review-due-nav-badge:hover, .review-due-nav-badge:focus-visible { transform: translateY(-1px); }
.review-due-nav-badge:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.review-due-nav-icon { display: inline-flex; flex-shrink: 0; }

/* Roadmap summary card (issue #460, replacing issue #6 Phase 4.4's separate
   `.roadmap-header`/`.stat-strip` blocks) — identity badge + freshness meta
   row on the left, the two stat tiles on the right, inside one glass `.card`
   instead of three independently-bordered floating blocks. Real feedback:
   the old layout (a badge pill, a plain meta line, then two separately
   bordered stat boxes, each with its own margin) read as cluttered/
   scattered rather than a cohesive header — a returning user's daily-driver
   dashboard should read as one panel at a glance, not several. `flex-wrap`
   lets the stats drop below the identity block on a narrow viewport instead
   of squeezing both halves into too little width. */
/* issue #489 — was a horizontal flex (identity left, two stat tiles right);
   now a single vertical column, since the whole card is one stacked block
   (name/save-state row, then the count+percent+bar progress block). */
.roadmap-summary-card {
  max-width: 1160px;
  margin: 0 auto 16px;
  display: flex;
  flex-direction: column;
  padding: 20px 24px;
}

.current-roadmap-badge {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  width: fit-content;
  font-size: 12px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--color-accent-700);
  background: var(--color-warning-bg);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--v3-radius-md);
  padding: 6px 12px;
}
/* issue #155 v2 Phase D3 (full completion pass) — dashboard.js's header badge;
   text-on-light-fill pattern. */
:root[data-theme='dark'] .current-roadmap-badge {
  color: var(--color-accent-700);
  background: var(--color-warning-bg);
  border-color: var(--color-border-strong);
}

.roadmap-meta-row {
  margin: 0;
  color: var(--color-text-muted);
  font-size: var(--text-base);
  font-weight: 600;
  font-variant-numeric: tabular-nums;
}

/* issue #489 — replaces #460's two-`.stat-tile` layout (an icon+count tile
   plus a 64px `.stat-tile-ring`) with a single block: identity name + save
   state on top (`.roadmap-summary-top`, wraps the existing
   `.current-roadmap-badge` + `.roadmap-meta-row`), then a large count +
   right-aligned percentage row, then a 12px full-width bar
   (`.roadmap-summary-progress`). A ring reads harder to parse at a glance
   than a bar for the older end of this app's audience — `progressRing.js`
   remains in use at phase-head call sites (a much smaller scale, where a
   ring still suits), just not here. */
.roadmap-summary-top {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 8px 16px;
}
/* Issue #522 audit follow-up — groups the "Saved …" meta text with the new
   "Switch roadmap" button so `.roadmap-summary-top`'s `space-between` still
   only has two real items (the identity badge, and this group) rather than
   three fighting over the row. */
.roadmap-summary-top-right {
  display: flex;
  align-items: center;
  gap: 12px;
  flex-wrap: wrap;
}

.roadmap-summary-progress {
  display: flex;
  flex-direction: column;
  gap: 8px;
  margin-top: 14px;
}

.roadmap-summary-progress-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 12px;
}

.roadmap-summary-count { display: flex; align-items: baseline; gap: 4px; flex-wrap: wrap; min-width: 0; }
.roadmap-summary-count-number { font-size: var(--text-3xl); font-weight: 900; color: var(--color-accent-700); font-variant-numeric: tabular-nums; }
:root[data-theme='dark'] .roadmap-summary-count-number { color: var(--color-accent-700); }
.roadmap-summary-count-total { font-size: var(--text-lg); color: var(--color-text-muted); font-weight: 700; font-variant-numeric: tabular-nums; }
.roadmap-summary-count-label { font-size: var(--text-base); color: var(--color-text-muted); font-weight: 600; }

.roadmap-summary-percent {
  flex-shrink: 0;
  font-size: var(--text-xl);
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  color: var(--color-accent-700);
}
:root[data-theme='dark'] .roadmap-summary-percent { color: var(--color-accent-700); }

/* 12px full-width bar, per issue #489's spec — flat `--color-accent` fill,
   matching `.mini-bar-fill`'s own solid-color treatment (issue #206's
   Alpenglow "no gradients anywhere in the app, solid colors only" decision,
   see design-system.md) rather than a gradient. Width is set via
   roadmapSummaryBarFill.style.width in dashboard.js (a direct CSSOM
   mutation, not the HTML `style` attribute the CSP blocks — same
   `ring.style.width`/`listbox.style.width` precedent already used by
   featureTour.js/select.js), since a continuous 0-100% value has no
   sensible discrete class-bucket scheme. */
.roadmap-summary-bar-track {
  height: 12px;
  border-radius: var(--radius-sm);
  background: var(--color-border);
  overflow: hidden;
}
.roadmap-summary-bar-fill {
  height: 100%;
  width: 0%;
  border-radius: var(--radius-sm);
  background: var(--color-accent);
  transition: width var(--duration-enter) var(--ease-spring);
}

/* Real, reported bug (live screenshot): `.stat-strip`'s base grid declaration
   was missing entirely — the only rule anywhere in this file mentioning
   `.stat-strip` was the `≤480px` override below forcing a single column,
   with nothing above it establishing what that override was narrowing
   *from*. With no `display: grid` (or any explicit `display` at all) on the
   container, every `.kpi-tile` child rendered as a plain full-width
   block-level div, stacked one per row, instead of the "4-equal-width KPI
   card" grid this component was actually built for (see `.kpi-tile`'s own
   comment below) — reported live as "square boxes" turning into full-width
   rectangular bars on the Progress page. `repeat(auto-fit, minmax(220px,
   1fr))` matches this app's own established card-grid convention (see
   `.claude/rules/ui-styling.md`'s "Card/grid layout" entry) so it reflows
   responsively across tile counts/widths without a bespoke media query per
   breakpoint — the `≤480px` override further down still forces a guaranteed
   single column once even one tile can't comfortably fit at 220px, since
   `auto-fit` alone doesn't guarantee that on its own. */
.stat-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: var(--space-4); align-items: stretch; }

/* C2 (issue #494) scope item 2 — the Progress page's asymmetric KPI layout:
   one large hero tile beside a 2x2 grid of four supporting tiles, rather
   than .stat-strip's five equal-width tiles ("five identical tiles read as
   a report; this reads as designed," per the issue). Collapses to one
   column below 760px. */
.kpi-layout { display: grid; grid-template-columns: minmax(240px, 1.1fr) 2fr; gap: var(--space-4); align-items: stretch; }
.kpi-layout .kpi-tile-hero { height: 100%; }
.kpi-grid-secondary { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--space-4); }
@media (max-width: 760px) {
  .kpi-layout { grid-template-columns: 1fr; }
  .kpi-grid-secondary { grid-template-columns: repeat(2, 1fr); }
}
@media (max-width: 480px) {
  .kpi-grid-secondary { grid-template-columns: 1fr; }
}

/* issue #155 v2 Phase B — a distinct new component, `.kpi-tile`, not a `.stat-tile`
   variant despite the issue's own bullet naming it "`.stat-tile`": `.stat-tile` above
   is already shipped (issue #6 Phase 4.1) as a horizontal icon-left/number-right row
   used by the dashboard's stat strip and progress ring — reusing that class name for
   the reference's structurally different vertical KPI card (label row with a corner
   badge, then a large number, then a delta caption) would collide with an existing,
   in-production component rather than extend it. Built and visually verified in
   isolation this phase — no page wires it in yet, that's Phase C/D's job. Composes
   with the shared `.card-arrow-badge` corner affordance below (also used by Phase D's
   planned person/customer cards, per the issue's spec). */
/* issue #300 — design-system.md §4: "Elevation only for overlays... Flat
   surfaces otherwise" (dropped the --shadow-sm this flat stat cell had). */
/* v3 (issue #416 Phase 4 — #428): wires the .card glass primitive (#420)
   into the Progress page's stat strip — same surface-recolor pattern
   .phase-card/.progress-card above use. */
.kpi-tile {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: 10px;
  padding: var(--space-6);
  /* issue #455 — stay close to --color-bg brightness; see .phase-card. */
  background: hsl(var(--v3-surface));
  border: 1px solid var(--color-divider);
  border-radius: var(--v3-radius-lg);
}
.kpi-tile-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
/* issue #300 — spec's "uppercase kickers" for the progress stat row. */
.kpi-tile-label { font-size: var(--text-base); font-weight: 700; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: var(--tracking-caps, 0.08em); }
.kpi-tile-number { display: flex; align-items: baseline; gap: 4px; font-size: var(--text-3xl); font-weight: 900; color: var(--color-text); font-variant-numeric: tabular-nums; }
/* issue #206 §6 — de-emphasized "still at zero" state: smaller, muted, less bold than
   a real earned number, so a fresh roadmap's stat strip doesn't read as a wall of loud
   "0"s. Paired with an action-oriented .kpi-tile-delta caption at each call site. */
.kpi-tile-number-zero { font-size: var(--text-xl); font-weight: var(--font-weight-semibold); color: var(--color-text-muted); }
.kpi-tile-delta { font-size: var(--text-base); color: var(--color-text-muted); font-weight: 600; }
/* issue #155 v2 Phase D1 — progress.js's stat strip: a smaller inline caption after
   the number (e.g. "/ 200", "days"), reusing .kpi-tile-number's flex row via a
   trailing span rather than adding a second wrapper element. .kpi-tile-bar positions
   the optional mini progress bar (Items complete tile) below the number, mirroring
   the spacing .stat-tile-bar already used for the same visual role. */
.kpi-tile-total { font-size: var(--text-base); color: var(--color-text-muted); font-weight: 700; font-variant-numeric: tabular-nums; }
.kpi-tile-bar { margin-top: 2px; }

/* The reference's "exactly one hero-highlighted tile per screen" pattern (issue #155 v2
   redefinition comment) — a single solid-filled tile among otherwise-dark siblings. The
   old fallback pattern (var(--color-brand-gold, var(--color-brand-gold))) was already
   dead code by the time #297 touched this file — both arguments resolved identically,
   a leftover from the lime/gold token collapse during #206 — so it's simplified to a
   single --color-accent read rather than carrying the redundant fallback forward. */
/* issue #300 — real, live contrast bug found auditing this component post-#297:
   --color-text on the raw --color-accent fill measures 3.95:1 light / 2.75:1
   dark (dark theme fails even the large-text 3:1 floor) — the old lime/mint
   accent this was tuned against passed at 5-17:1 in the same role, and the
   axe CONTRAST_FALSE_POSITIVE_SELECTORS exemption for .kpi-tile-label/
   .kpi-tile-hero (ui-styling.md) predates the #297 token swap, so CI's a11y
   suite was never going to re-flag this. Fixed the same way .btn-primary was
   in Phase 1: fill reads the darkened --color-accent-600, text reads
   --color-ink-on-accent (4.94:1 light / 6.69:1 dark, verified) — the
   .kpi-tile-delta opacity dim is also dropped (0.75 opacity dropped it to
   3.33:1 light, still failing) rather than layering a second contrast risk
   on top of the fill/text fix. */
/* v3 (issue #416 Phase 4 — #428): design-system.md §5's ".btn-primary —
   gradient fill" pattern, applied to the single hero-highlighted stat tile.
   Fixed black text in both themes, same reasoning as .btn-primary's own
   comment above (the gradient is always light green regardless of theme). */
.kpi-tile-hero {
  background: linear-gradient(135deg, hsl(var(--v3-gradient-start)), hsl(var(--v3-gradient-end)));
  border-color: transparent;
  box-shadow: var(--v3-shadow-glow);
}
.kpi-tile-hero .kpi-tile-label,
.kpi-tile-hero .kpi-tile-number,
.kpi-tile-hero .kpi-tile-delta,
.kpi-tile-hero .kpi-tile-total {
  /* intentional: fixed black, same in both themes — matches .btn-primary's
     own gradient-text comment (issue #416 Phase 4). */
  color: #000;
}

/* Shared circular corner-badge affordance (issue #155 v2 Phase B) — used by `.kpi-tile`
   above and, per the issue's Phase D scope, planned for person/customer-style cards
   too. Parent needs `position: relative` (`.kpi-tile` already has it). */
.card-arrow-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 28px;
  height: 28px;
  border-radius: 999px;
  border: 1px solid var(--color-border);
  background: var(--color-surface-raised);
  color: var(--color-text);
  flex-shrink: 0;
}
/* intentional: a fixed black-tint overlay reads as a subtle darkening on top of
   whichever solid accent color fills `.kpi-tile-hero` (lime in dark theme, brand teal
   in light theme via the fallback above) — using a token here would fight whichever
   accent is actually active, since neither --color-surface-raised nor --color-border is designed to sit on
   top of a bright solid fill (issue #155 v2 Phase B). */
.kpi-tile-hero .card-arrow-badge {
  background: rgba(0, 0, 0, 0.14);
  border-color: rgba(0, 0, 0, 0.14);
  /* intentional: fixed black, matching .kpi-tile-hero's own fixed-black-text
     convention (issue #416 Phase 4) — var(--color-text) would render white
     in dark theme, unreadable against a light-green gradient fill. */
  color: #000;
}

/* issue #491 — "Next up" card, directly under the summary card. Flat content
   surface (`.card`, §4's "flat content" rule), not a glass overlay — same
   family as `.roadmap-summary-card`/`.roadmap-filters-card` above. Rows reuse
   `.check-box`/`.check-mark`/`.check-body`/`.check-title`/`.check-meta`
   verbatim (dashboard.js's checklist rows) so a "Next up" row is visually
   identical to its real row in the phase list below, just without the ⋮
   overflow menu or priority left-edge stripe. */
.next-up-card {
  max-width: 1160px;
  margin: 0 auto 16px;
  padding: 18px 24px;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.next-up-card-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.next-up-rows { display: flex; flex-direction: column; }
.next-up-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 10px 0;
  border-bottom: 1px solid var(--color-border);
  cursor: pointer;
}
.next-up-row:last-child { border-bottom: none; }
.next-up-row .check-meta { text-transform: none; }
.next-up-complete {
  display: flex;
  align-items: center;
  gap: 10px;
  color: var(--color-text-muted);
  font-size: var(--text-base);
}

/* issue #460 — wraps the filter toolbar in one glass `.card`, same reasoning
   as `.roadmap-summary-card` above. */
.roadmap-filters-card {
  max-width: 1160px;
  /* Issue #505 follow-up (live visual review) — was `18px`, an outlier next
     to `.roadmap-summary-card`/`.next-up-card`'s identical `16px`
     margin-bottom despite all three being equal-weight siblings inside
     `.dashboard-header` — confirmed live via getBoundingClientRect() as a
     genuinely uneven 16/16/18px vertical rhythm down the card stack. */
  margin: 0 auto 16px;
  padding: 18px 24px;
}

/* Issue #487 — the whole priority-select/chip/tag toolbar collapsed to one
   row of three controls: search (flex), Filter (opens the .item-panel-based
   panel below), Expand all. */
.filter-toolbar { display: flex; align-items: center; gap: 10px; }
.filter-toolbar .search-input { flex: 1; min-width: 0; }
.filter-toggle-btn { position: relative; flex-shrink: 0; }
.filter-btn-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 18px;
  height: 18px;
  padding: 0 5px;
  margin-left: 6px;
  border-radius: var(--radius-full);
  background: var(--color-accent);
  color: var(--color-bg);
  font-size: 11px;
  font-weight: 800;
  line-height: 1;
}
.filter-btn-badge[hidden] { display: none; }

/* The panel itself reuses itemPanel.js's own `.panel-overlay`/`.item-panel`/
   `.panel-*` classes (side-slide on desktop, bottom sheet at ≤480px per that
   component's own media query — `.claude/rules/ui-styling.md`) rather than a
   parallel overlay implementation. `.filter-panel-section` groups a labeled
   group of controls with the same vertical rhythm `.panel-body`'s own `gap`
   already provides between top-level children. */
.filter-panel-section { display: grid; gap: 8px; }
.filter-panel-summary { font-size: var(--text-base); color: var(--color-text-muted); flex: 1; min-width: 0; }
.filter-panel-footer[hidden] { display: none; }

.toolbar-label { font-size: var(--text-base); font-weight: 800; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: 0.08em; }
.priority-legend { font-size: var(--text-base); color: var(--color-text-muted); }

.filter-row { display: flex; flex-wrap: wrap; gap: 8px; }

/* issue #477 — the priority row used to be five `.filter-chip` pills (All/
   P0-P3) sitting inline before "Resources"/"Review due"; the five collapsed
   into one createSelect() dropdown (prioritySelectContainer, dashboard.js)
   sitting beside the two remaining toggle chips (filterContainer) in this
   shared row. */
.priority-filter-row { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.priority-select-wrap { flex-shrink: 0; }
.priority-filter-select { width: 180px; }
@media (max-width: 480px) {
  .priority-filter-select { width: 100%; }
}

/* Issue #182 — pattern/concept tag filter chips + the review-due tag-grouping
   summary line; reuses .filter-chip/.filter-row as-is (see above), just two
   small container-level rules for the pieces that are new to this issue. */
.tag-filter-row[hidden] { display: none; }
.review-tag-group-banner[hidden] { display: none; }
/* issue #460 — matches .roadmap-summary-card/.roadmap-filters-card's own
   max-width/centered-margin so this banner lines up with the rest of the
   header instead of spanning the full (wider) .dashboard-header padding box. */
.review-tag-group-banner { display: grid; gap: 4px; max-width: 1160px; margin: 8px auto 0; }
.review-tag-group-line {
  font-size: var(--text-base);
  color: var(--color-text-muted);
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  padding: 6px 10px;
}

.filter-chip {
  position: relative;
  min-height: 36px;
  padding: 7px 12px;
  border-radius: var(--radius-full);
  border: 1px solid var(--color-border);
  background: var(--color-surface);
  color: var(--color-text);
  font: inherit;
  font-size: var(--text-base);
  font-weight: 800;
  cursor: pointer;
  overflow: hidden;
  transition: transform var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard);
}
.filter-chip:hover { transform: translateY(-1px); border-color: var(--color-border-strong); }
.filter-chip:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
/* Real, reported bug (issue #435): the generic chip (`All`, `Resources`,
   `Review due` — anything with no `data-p` attribute) used a completely
   different active-state recipe (solid `--color-text` fill) from the
   priority-specific chips right below (tint + colored border + colored ink),
   so the row read as three or four unrelated button styles instead of one
   "this filter is active" language. Same tint/border/ink shape as the
   priority variants, using the accent tokens — matches `P0`'s treatment
   exactly, since a generic filter has no priority hue of its own to borrow. */
/* Real, reported bug (issue #440 follow-up): every filter chip's active
   state — `All`/`Resources`/`Review due` and every `P0`-`P3` priority chip
   alike — now reads this exact same accent tint/border/ink. Previously
   `P0`/`P1` used the accent (green) here but `P2`/`P3` had their own
   `.filter-chip[data-p="P2"|"P3"].active` override reading `--color-p2`/
   `--color-p3` (neutral grays, design-system.md §2's original "P2 = neutral
   tint" mapping) — so the row still showed two different active colors
   after the earlier #435 fix unified the generic chips onto the accent.
   Priority hue differentiation stays on the always-visible `.badge.P0`-`P3`
   tags elsewhere in each row, which aren't toggle buttons and don't need
   this "one active-state language" consistency requirement. */
.filter-chip.active { background: color-mix(in srgb, var(--color-accent) 15%, transparent); border-color: var(--color-accent); color: var(--color-accent-800); }
.chip-count { opacity: 0.75; font-size: var(--text-base); font-variant-numeric: tabular-nums; }

/* Issue #6 Phase 4.3 — subtle hover highlight, flattened from a swept
   linear-gradient shimmer to a solid fixed-white overlay per design-system.md
   §2's "no gradients anywhere" rule (issue #406); still its own ::before layer
   so it inherits the chip's own border-radius/overflow-hidden for free. */
.filter-chip::before {
  content: '';
  position: absolute;
  inset: 0;
  /* intentional: fixed white highlight, same in both themes (issue #116) */
  background: rgba(255, 255, 255, 0.12);
  opacity: 0;
  transition: opacity var(--duration-slow) var(--ease-out);
  pointer-events: none;
}
.filter-chip:hover::before { opacity: 1; }

.filter-chip-clear {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  margin-left: 4px;
  opacity: 0.75;
  cursor: pointer;
}
.filter-chip-clear:hover { opacity: 1; }

/* issue #155 v2 Phase B — `.filter-chip-counted`, an embedded count sub-badge riding on
   top of the existing `.filter-chip` base (the reference's "Pending 20"/"Responded
   22"/"Assigned 16" status-chip row) — a genuinely new compound component per the
   issue's spec, not a retune: no existing chip class supported a second, visually
   distinct count badge inside itself before this. Apply alongside the base class
   (`class="filter-chip filter-chip-counted"`), not as a replacement — `.filter-chip`
   still owns the pill shape/hover/active-state mechanics, this only adds the badge
   slot. Built and visually verified in isolation this phase; no page wires it in yet. */
.filter-chip-counted { display: inline-flex; align-items: center; gap: 8px; }
.filter-chip-count-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 22px;
  height: 22px;
  padding: 0 6px;
  border-radius: var(--radius-full);
  background: var(--color-surface-raised);
  color: var(--color-text-muted);
  font-size: var(--text-base);
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  flex-shrink: 0;
}
/* Inverts against whichever fill `.filter-chip.active` (or a future accent-colored
   variant of it) ends up using, the same way the base chip's own active text/bg swap
   works — so the badge reads as "a dark circle on top of a bright fill" regardless of
   which specific accent colors the active chip, without hardcoding a color here. */
.filter-chip-counted.active .filter-chip-count-badge {
  background: var(--color-bg);
  color: var(--color-text);
}

.clear-filters-btn[hidden] { display: none; }

.search-input {
  width: min(340px, 100%);
  min-width: 200px;
  padding: 10px 14px;
  border-radius: var(--radius-md);
  border: 1px solid var(--color-border-strong);
  background: var(--color-surface);
  color: var(--color-text);
  font: inherit;
  font-size: var(--text-base);
  transition: width var(--duration-base) var(--ease-out), border-color var(--duration-fast) ease;
}
/* Issue #6 Phase 4.3 — grows toward (not literally to) a wider width on
   focus; issue #487's `.filter-toolbar` gives it `flex: 1` (flex-basis 0%),
   which overrides `width` for sizing purposes anyway, so this has no visible
   effect there but still matters for any other context this class is used
   in outside that flex row. */
.search-input:focus { outline: none; border-color: var(--color-accent); width: min(520px, 100%); }
.search-input:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
/* issue #155 v2 Phase D3 (full completion pass) — dashboard.js's search field. */
:root[data-theme='dark'] .search-input:focus { border-color: var(--color-accent); }

/* Daily Todos (issue #56) — a self-contained card, separate from the phase
   list, sourced from its own store (dailyTodoStore.js), not roadmapStore.
   Same --color-surface/--radius-lg/--shadow-sm base every other card in this
   pass uses (issue #206 §3.2 — glass chrome dropped, see .template-card's
   comment above for the same change), but with an --color-warning (not
   --color-brand-gold) border/icon color — --color-warning is already used
   app-wide as the "not the roadmap-teal" token (progress bar highlight), so
   reusing it here (rather than inventing a new color) is what makes it read
   as "a different kind of card" in both themes without a special case.
   Distinct from --color-brand-rose, reserved specifically for AI-related
   affordances. No hover-lift: this panel isn't itself clickable, only the
   todo rows inside it are. */
/* issue #298 — design-system.md §4: "Elevation only for overlays... Flat
   surfaces otherwise" (dropped the --shadow-sm this flat card had) and a 2px
   rule for a major section boundary (was a 1px colored border). */
/* issue #483's "flat content, elevated overlays" sweep (see that rule's own
   comment on .phase-card/.roadmap-filters-card above) missed this card —
   the #416 Phase 2 glass treatment below predates #483 and was never
   revisited. Real, reported bug: the translucent `backdrop-filter` blur
   here visibly softens this card's corner/edge rendering compared to the
   flat `.roadmap-filters-card` directly above it in the dashboard's own
   layout, so the seam between the two reads as one rounded edge meeting one
   sharp edge instead of two identical --v3-radius-lg corners. `.daily-todo-panel`
   is genuine page content (not an overlay/dialog/dropdown), so it gets the
   same flat, opaque treatment every other content-surface card already has. */
/* issue #503 follow-up — real, reported inconsistency: this card's rounded
   corners read visibly flatter than `.roadmap-filters-card`/`.next-up-card`
   right above it, even though all three share the exact same 16px
   `--v3-radius-lg`. The actual difference wasn't radius — it's that those
   other cards compose the shared `.card` class (`app.css`, above), which
   draws a subtle gradient-tinted hairline ring via `.card::before` on top of
   the plain divider border; that extra ring is what makes an identical
   curve read as crisply rounded. This card never had the `.card` class
   (`dailyTodoPanel.js` now adds it) — background/border/radius below are
   now redundant with `.card`'s own (kept only for the properties `.card`
   doesn't set: layout/sizing), and `.card:hover`'s hover-lift transform is
   cancelled right below, since this panel deliberately has no hover-lift
   (only the todo rows inside it are clickable, see the comment above). */
.daily-todo-panel {
  max-width: 1160px;
  margin: 0 var(--dashboard-gutter-x) 18px;
  width: auto;
  box-sizing: border-box;
  padding: var(--space-6);
}
.daily-todo-panel:hover { transform: none; border-color: var(--color-divider); }
.daily-todo-heading-row {
  display: flex;
  align-items: center;
  gap: 8px;
  margin: 0 0 10px;
}
.daily-todo-icon { display: inline-flex; flex-shrink: 0; color: var(--color-warning); }
.daily-todo-heading {
  margin: 0 auto 0 0;
  font-size: var(--text-base);
  font-weight: 800;
}
/* issue #136 Phase 2 follow-up — was 20x20 with an 'xs' (16px) icon, sitting
   directly next to .daily-todo-collapse-btn's 24x24 box with an 'sm' (20px)
   icon; two different button diameters and two different icon sizes side by
   side read as visibly inconsistent/misaligned (reported live, screenshot).
   Matches .daily-todo-collapse-btn's box + icon size exactly now.
   A second live report (screenshot) flagged the reminder/info/collapse trio
   as still visibly misaligned — a real `getBoundingClientRect()` check found
   all three already pixel-identical (same top/height, same 24px box, same
   'sm' icon size) in this codebase, so the box-model dimensions above were
   not the cause here. `line-height: 0` was added to all three (below) as a
   defensive hardening against the sub-pixel/baseline drift an inline-flex
   button with no text content can pick up from an inherited non-zero
   line-height at some zoom levels/OS display-scaling factors — harmless if
   that wasn't the actual cause, and removes one more variable if a fresh
   report ever reproduces this again. */
.daily-todo-info-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  line-height: 0;
  border-radius: 50%;
  border: 1px solid var(--color-border-strong);
  background: transparent;
  color: var(--color-text-muted);
  cursor: pointer;
  padding: 0;
  flex-shrink: 0;
}
.daily-todo-info-btn:hover { color: var(--color-warning); border-color: var(--color-warning); }
.daily-todo-info-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }

/* Local reminder opt-in toggle (issue #132) — same 24x24/'sm'-icon box as
   .daily-todo-info-btn/.daily-todo-collapse-btn above (see the "Two same-row
   circular icon-buttons" rule). .active marks reminders as currently on. */
.daily-todo-reminder-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  line-height: 0;
  border-radius: 50%;
  border: 1px solid var(--color-border-strong);
  background: transparent;
  color: var(--color-text-muted);
  cursor: pointer;
  padding: 0;
  flex-shrink: 0;
}
.daily-todo-reminder-btn:hover { color: var(--color-warning); border-color: var(--color-warning); }
.daily-todo-reminder-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.daily-todo-reminder-btn.active { color: var(--color-warning); border-color: var(--color-warning); }

/* Collapse/expand toggle (issue #83) — reuses the exact same chevron-rotate
   language as .phase-card/.chevron (dashboard.js's phase cards) so the two
   "expandable section" affordances in the app read consistently: pointing
   right (0deg) when collapsed, rotated to point down (90deg) when expanded.
   Unlike .phase-card, the *expanded* state is the rotated one here since
   that's this panel's default/common state. */
.daily-todo-collapse-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 24px;
  height: 24px;
  line-height: 0;
  border-radius: 50%;
  border: 1px solid var(--color-border-strong);
  background: transparent;
  color: var(--color-text-muted);
  cursor: pointer;
  padding: 0;
  flex-shrink: 0;
}
.daily-todo-collapse-btn:hover { color: var(--color-warning); border-color: var(--color-warning); }
.daily-todo-collapse-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.daily-todo-panel:not(.collapsed) .daily-todo-collapse-btn .chevron { transform: rotate(90deg); }

/* Only shown while collapsed (below) — a small "N active" reminder so
   shrinking the panel never fully hides whether there's anything to come
   back for. */
.daily-todo-count-badge {
  display: none;
  flex-shrink: 0;
  font-size: var(--text-base);
  font-weight: 700;
  color: var(--color-text-muted);
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border-strong);
  border-radius: var(--v3-radius-md);
  padding: 3px 9px;
  white-space: nowrap;
}

/* Inner rows no longer stack as their own bordered boxes — they sit flush
   inside .daily-todo-panel's own card padding, divided by hairlines, so the
   whole thing reads as one card rather than a heading floating above a
   separate stack of boxes. */
.daily-todo-panel > .daily-todo-add-row,
.daily-todo-panel > .daily-todo-list,
.daily-todo-panel > .daily-todo-missed-toggle,
.daily-todo-panel > .daily-todo-missed-list {
  background: transparent;
  border: none;
}
/* Collapsed state (issue #83) — shrinks the panel down to just its heading
   row so a growing todo list doesn't push the roadmap card grid further
   down the page each time one is added. Never the first-run default — see
   the KEYS.DAILY_TODOS_COLLAPSED comment in localStorageKeys.js and the
   `collapsed` initialization in dailyTodoPanel.js. */
.daily-todo-panel.collapsed > .daily-todo-add-row,
.daily-todo-panel.collapsed > .daily-todo-list,
.daily-todo-panel.collapsed > .daily-todo-missed-toggle,
.daily-todo-panel.collapsed > .daily-todo-missed-list {
  display: none;
}
.daily-todo-panel.collapsed .daily-todo-heading-row { margin-bottom: 0; }
.daily-todo-panel.collapsed .daily-todo-count-badge { display: inline-flex; }
/* Real, reported mismatch (issue #309, same shape as .app-topbar-status's
   fix above): the "Add a todo" `.field-input.compact` (39px) and its "Add"
   `.btn-sm` (35px) sat 4px apart in the same row. */
.daily-todo-add-row > .field-input,
.daily-todo-add-row > .btn {
  height: 39px;
  box-sizing: border-box;
}
.daily-todo-add-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  padding: 0 0 14px;
  border-radius: 0;
  border-bottom: 1px solid var(--color-border);
}
.daily-todo-add-row .inline-add { flex: 1; min-width: 160px; }
.todo-duration-select { min-width: 110px; }
.todo-custom-hours { width: 90px; }

.daily-todo-list {
  display: grid;
  border-top: none;
}

/* Issue #490 — recomposed onto #486 B1's two-line row rule: checkbox ·
   title + one meta line · timer button · overflow. An urgent (danger-band,
   not-done) todo gets a 3px accent left edge — `box-shadow: inset` rather
   than a real `border-left`, same "avoid the layout shift a real border
   would cause" reasoning as `.nav-item.active`'s own inset accent bar
   (`.claude/rules/design-system.md`). */
.daily-todo-item {
  display: flex;
  align-items: flex-start;
  gap: 10px;
  padding: 10px 8px;
  border-top: 1px solid var(--color-border);
}
.daily-todo-item.urgent { box-shadow: inset 3px 0 0 var(--color-danger); }
.daily-todo-item.done .daily-todo-title { text-decoration: line-through; color: var(--color-text-muted); }

.daily-todo-checkbox {
  display: flex;
  align-items: center;
  padding-top: 2px;
  cursor: pointer;
  flex-shrink: 0;
}
.daily-todo-checkbox input[type='checkbox'] { width: 18px; height: 18px; accent-color: var(--color-warning); flex-shrink: 0; }

.daily-todo-content { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.daily-todo-title { font-size: var(--text-base); font-weight: 600; overflow-wrap: anywhere; }
/* Deliberately NOT display: flex — a flex item is block-level, which trims
   the leading/trailing space inside each child span (each segment's own
   " · "/" tracked" text), collapsing "0s tracked" down to "0stracked" with
   no space at all. Plain inline flow (the default) doesn't have this
   problem and still wraps normally at the container's width. */
.daily-todo-meta {
  font-size: var(--text-base);
  color: var(--color-text-muted);
}
.daily-todo-meta-sep { color: var(--color-text-muted); }
.daily-todo-meta-label { color: var(--color-text-muted); }

.daily-todo-linked-badge {
  flex-shrink: 0;
  font-size: 10px;
  font-weight: 700;
  color: var(--color-accent-700);
  background: var(--color-warning-bg);
  border-radius: var(--v3-radius-md);
  padding: 2px 8px;
  white-space: nowrap;
}
/* issue #155 v2 Phase D3 (full completion pass) — dailyTodoPanel.js; text-on-light-fill
   pattern. */
:root[data-theme='dark'] .daily-todo-linked-badge { color: var(--color-accent-700); background: var(--color-warning-bg); }

.daily-todo-remaining {
  font-weight: 800;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.daily-todo-remaining.ok { color: var(--color-accent-700); }
:root[data-theme='dark'] .daily-todo-remaining.ok { color: var(--color-accent-700); }
.daily-todo-remaining.warn { color: var(--color-p1-ink); }
.daily-todo-remaining.danger { color: var(--color-p0); }
.daily-todo-remaining.done { color: var(--color-text-muted); font-weight: 700; }

/* The ⋮ overflow menu (Delete) reuses onboarding.js's
   `.template-card-overflow-btn` treatment exactly — same circular
   24x24 icon-button box, same "Two same-row circular icon-buttons must
   share the same box size" rule (`.claude/rules/ui-styling.md`) applied
   against the adjacent `.daily-todo-timer-btn`. `createDropdown()`'s own
   `.dropdown` wrapper is a normal inline-flex row child here (unlike the
   template card's `position: absolute` corner slot), since this button
   already sits in its own flex row position, not a corner overlay. */
.daily-todo-overflow-btn {
  flex-shrink: 0;
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 1px solid var(--border-glass);
  background: var(--color-surface);
  color: var(--color-text-muted);
  cursor: pointer;
}
.daily-todo-overflow-btn:hover,
.daily-todo-overflow-btn[aria-expanded='true'] { color: var(--color-accent-700); border-color: var(--color-border-strong); }
:root[data-theme='dark'] .daily-todo-overflow-btn:hover,
:root[data-theme='dark'] .daily-todo-overflow-btn[aria-expanded='true'] { color: var(--color-accent-700); border-color: var(--color-border-strong); }

.daily-todo-missed-toggle {
  display: block;
  width: 100%;
  text-align: left;
  padding: 10px 0;
  border-top: 1px solid var(--color-border);
  border-radius: 0;
  background: transparent;
  color: var(--color-text-muted);
  font: inherit;
  font-size: var(--text-base);
  font-weight: 700;
  cursor: pointer;
}
.daily-todo-missed-list {
  border-top: none;
  border-radius: 0 0 var(--radius-md) var(--radius-md);
}
.daily-todo-missed-list .daily-todo-item { opacity: 0.75; }
.daily-todo-missed-list .daily-todo-title { text-decoration: line-through; }

.dashboard-content {
  flex: 1;
  max-width: 1160px;
  margin: 0 auto;
  width: 100%;
  /* issue #492 — the extra left inset (--phase-spine-gutter, defined under
     :root) reserves room for the progress spine so it never overlaps a
     phase-card's own left edge; this file's other three .dashboard-content
     padding overrides (below and the two responsive breakpoints) add the
     same gutter to their own left value. Right/top/bottom read
     --dashboard-gutter-x (issue #503 follow-up) so this card's right edge
     always lines up with .dashboard-header's/.daily-todo-panel's own. */
  padding: 22px var(--dashboard-gutter-x) 80px calc(var(--dashboard-gutter-x) + var(--phase-spine-gutter));
  display: grid;
  gap: 12px;
  align-content: start;
  /* .phase-spine (issue #492) is position: absolute against this box. */
  position: relative;
}

/* Progress spine (issue #492) — a 2px vertical rule with one dot per phase,
   filled from the top down to the last phase the user has started. Hidden
   from assistive tech (aria-hidden, dashboard.js) since the same progress is
   already in each phase-head's own progress ring/count; positioned entirely
   in pixels from real `.phase-head` rects (updatePhaseSpine(), dashboard.js),
   never a percentage of container height, which would land mid-card and
   read arbitrary once phases vary in size. Dot/fill top offsets are set via
   direct `el.style.top`/`el.style.height` JS property writes, not an inline
   HTML `style` attribute — see `.claude/rules/ui-styling.md`'s "Never set an
   inline style attribute" rule for why that distinction matters under this
   app's CSP. */
.phase-spine {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: var(--phase-spine-gutter);
  pointer-events: none;
}

/* issue #492 follow-up — real feedback: `bottom: 0` stretched this line the
   full height of `.dashboard-content` regardless of how many phases were
   actually rendered, leaving a long bare tail below the last phase's dot on
   any roadmap with trailing whitespace. Height is now set from JS
   (updatePhaseSpine(), dashboard.js) to the real pixel position of the last
   rendered phase's dot — the same "measure the real content, don't stretch
   to the container" fix this file's own .dashboard-content `align-content:
   start` entry documents for the analogous row-stretch bug. */
.phase-spine-track {
  position: absolute;
  top: 0;
  left: 50%;
  width: 2px;
  height: 0;
  transform: translateX(-50%);
  background: var(--color-border);
  border-radius: 1px;
}

.phase-spine-fill {
  position: absolute;
  top: 0;
  left: 50%;
  width: 2px;
  height: 0;
  transform: translateX(-50%);
  background: var(--color-accent);
  border-radius: 1px;
}

.phase-spine-dots {
  position: absolute;
  inset: 0;
}

.phase-spine-dot {
  position: absolute;
  left: 50%;
  width: 10px;
  height: 10px;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: var(--color-bg);
  border: 2px solid var(--color-border-strong);
}

.phase-spine-dot-in-progress {
  border-color: var(--color-accent);
  background: var(--color-accent-100);
}

.phase-spine-dot-complete {
  background: var(--color-accent);
  border-color: var(--color-accent);
}

/* Phase cards. issue #206 §3.2 — .phase-card itself has no click handler
   (only its nested .phase-head button toggles the accordion, dashboard.js);
   removed the old whole-card hover-lift accordingly — a card that isn't
   itself clickable shouldn't imply it is by lifting under the cursor
   anywhere over its body content. Flagging: this changes existing behavior
   (the card used to lift on hover regardless of where inside it the cursor
   was), unlike .template-card above, which keeps its whole-card hover-lift
   because clicking anywhere on that card *does* trigger the pick action via
   its nested button covering the full card. */
/* v3 (issue #416 Phase 2 — #424): wires the .card glass primitive (#420)
   into the dashboard's checklist cards — the first real adoption of that
   primitive, per §4/§5. The left priority-color accent border (below) is
   layout-critical and kept as a real `border`, not folded into .card's own
   hairline-gradient ::before, since that pseudo-element has no way to carry
   a data-driven per-priority color. */
/* v3 (issue #416 Phase 5 — #430) — real, reported bug: a dashboard with
   many phase cards (the Java Backend template ships 19) each combining
   `overflow: hidden` (for the rounded-corner clip below) with
   `backdrop-filter: blur(12px)` forces the browser to keep a full
   blurred+clipped composite layer resident for the card — cheap while
   collapsed (~64px tall), but for a fully-expanded card (Core Java's 70
   topics measures ~4500px tall) that composite cost is large enough that
   ordinary scrolling visibly outran paint, leaving large blank regions
   where topic rows should be. Verified as a genuine *paint*, not layout,
   issue — `getBoundingClientRect()` on "missing" rows during the bug
   always returned correct, fully-packed positions.
   `content-visibility: auto` was tried first and made things *worse* —
   combined with this many DOM nodes it produced whole-page blank-paint
   states that didn't recover even after scrolling back to the top and
   waiting, confirmed via DOM/computed-style inspection showing fully
   correct layout (`.dashboard-content` sized/positioned/visible/opacity:1)
   underneath nothing but unpainted pixels. Reverted — not used here.
   The fix that actually held up under repeated testing: `.phase-card.open`
   (below) drops the translucency/blur once a card is expanded, since
   that's exactly when it can grow to thousands of pixels tall. A flat,
   fully opaque fill costs the same to paint regardless of height; the
   collapsed (default, most common) state keeps the full glass treatment. */
/* issue #483 — content surfaces are flat and solid; the backdrop-filter/glow
   glass treatment (and the whole #430/#432/#433/#450/#455 paint-cost history
   documented above) no longer applies to this card at all, collapsed/open or
   scrolling, since there's no blur left to drop for the scroll window. */
.phase-card {
  position: relative;
  /* issue #455 (real, reported regression via #483's flat-surfaces refactor
     accidentally re-pointing this at --color-surface-raised) — flat content
     surfaces must stay close to --color-bg's own brightness, not
     --color-surface-raised, which is meaningfully lighter in dark theme and
     reads as the whole page flashing grey while scrolling a long, mostly-
     open roadmap. See tests/e2e/scrollBackgroundFlash.test.js. */
  background: hsl(var(--v3-surface));
  border: 1px solid var(--color-divider);
  border-radius: var(--v3-radius-lg);
  overflow: hidden;
}

/* Scroll-perf mode (issue #432) — generalizes the `.phase-card.open` fix
   directly above to every glass surface, for the duration of any scroll, not
   just one element's expanded state. Real, reported bug: fast/sustained
   scrolling visibly dropped frames and briefly showed blank/unpainted
   regions, the same paint-lag symptom `.phase-card.open`'s own comment
   documents at length — but not limited to expanded phase cards. Two other
   sources: `.app-topbar`/`.app-sidebar` are sticky and permanently blurred,
   so they must re-sample and re-blur the page content flowing underneath
   them on *every* scroll frame, guaranteed, for as long as either is
   on-screen (effectively the whole time you're scrolling the dashboard); and
   a template/phase-card grid can have a dozen-plus `.card`/`.phase-card`/
   `.template-card` glass surfaces simultaneously visible, each its own
   independent blur compositing cost, all paid again every frame.
   `src/services/scrollPerfMode.js` toggles `[data-scrolling]` on `<html>`
   for as long as a scroll is actively happening (rAF-throttled, cleared
   ~150ms after the last scroll event) — these rules drop backdrop-filter for
   exactly that window and fall back to the same flat, opaque background
   these surfaces already use for browsers with no backdrop-filter support at
   all (the adjacent `@supports not (...)` rules above), so nothing new is
   introduced here, just reused. The glass look is only ever missing while
   motion itself has the user's attention, not while reading static content —
   restored automatically the moment scrolling settles. Any future glass
   surface (a new `.card`-based component, a new sticky/pinned element) needs
   adding to this selector list too, or it'll keep paying this cost on every
   scroll frame indefinitely. */
/* issue #483 — `.app-topbar`/`.app-sidebar` and every card-shaped surface
   this rule used to list here (`.card`/`.phase-card`/`.template-card`/
   `.tag-chip`/`.kpi-tile`/`.progress-card`/`.auth-card-lg`) are now flat,
   solid content surfaces with no backdrop-filter/glow at any time — see each
   selector's own rule above — so there is no glass treatment left for this
   scroll-perf mitigation to drop for them. Only the surfaces this issue
   deliberately left glass (genuine overlays, plus the handful of
   `.card`-duplicating components not in this issue's scope) still need it. */
html[data-scrolling] .settings-section,
html[data-scrolling] .settings-guest-card,
html[data-scrolling] .feature-card,
html[data-scrolling] .step-card,
html[data-scrolling] .command-palette-card,
html[data-scrolling] .landing-mock-card,
/* issue #470 — `.modal-card` (confirmDialog and every other hand-rolled
   modal) and `.dropdown-menu` (the sidebar account menu) are the two glass
   surfaces most likely to be freshly *created* — appended to `document.body`
   for the first time — right in the middle of, or shortly after, a fast
   scroll: a real, reported bug showed both intermittently painting as an
   empty unstyled box for a brief window before resolving to their real
   content a moment later, with the live report's own screenshots showing
   fast-scroll blanking followed shortly after by opening the account
   dropdown / a confirm dialog. Neither was in this selector list before —
   both compose their own background/backdrop-filter/box-shadow glass
   treatment independently (`.modal-card`, `.dropdown-menu`, this file), the
   exact "duplicates `.card`'s treatment instead of composing it" gap this
   rule's own history above already fixed for `.kpi-tile`/`.progress-card`/
   etc. If either is inserted while `[data-scrolling]` is already set on
   `<html>` (a fast scroll settles ~150ms after the last scroll event, per
   `scrollPerfMode.js` — well within reach of a reflexive click right after
   scrolling), this rule now applies from its very first paint, skipping the
   expensive blur/glow compositing during the exact window a backlog of
   still-settling scroll-driven layout work (see the pruneMountedRowsFromTop/
   Bottom batching fix, `dashboard.js`) is most likely competing for the same
   frame budget. */
html[data-scrolling] .modal-card,
html[data-scrolling] .dropdown-menu {
  background: hsl(var(--v3-surface));
  -webkit-backdrop-filter: none;
  backdrop-filter: none;
  box-shadow: none;
}

/* Issue #6 Phase 4.2's left-border priority accent (`.phase-card[data-priority]`
   border-left-color, at a wider 3px border-left-width) is removed — issue
   #503 follow-up, real reported bug: since issue #486 gave every checklist
   row its own priority-coloured left accent (`.check-item::before`, per
   topic, more precise since a row's priority can differ from its phase's),
   this card-level accent sat immediately next to that row-level one at a
   slightly different x-offset, reading as a doubled/misaligned line rather
   than one clean accent. The phase's own priority is still shown via its
   `.badge` in `.phase-head`; the row-level accent is the single remaining
   left-edge line.
*/

.phase-head {
  width: 100%;
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 15px 16px;
  border: 0;
  /* v3 (#424) — transparent so .phase-card's glass background shows through
     instead of an opaque flat fill sitting on top of it. */
  background: transparent;
  color: var(--color-text);
  font: inherit;
  text-align: left;
  cursor: pointer;
  transition: background 140ms ease;
}
.phase-head:hover { background: var(--color-surface-raised); }
.phase-head:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }

.phase-index { font-family: var(--font-mono); font-size: 12px; font-weight: 700; color: var(--color-text-muted); min-width: 28px; font-variant-numeric: tabular-nums; }
/* issue #6 Phase 4.2 — display font for the "cleaner head layout" polish. */
.phase-name { flex: 1; font-family: var(--font-display); font-weight: 700; font-size: var(--text-base); }
/* Ring (createProgressRing, src/ui/components/progressRing.js) is now the
   visible progress affordance in the phase head; this stays in the DOM as
   an sr-only label so assistive tech (and the existing unit test asserting
   on its text) keep working unchanged. */
.phase-progress.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.progress-ring { flex-shrink: 0; }
.chevron { color: var(--color-text-muted); transition: transform var(--duration-base) var(--ease-in-out); }
.phase-card.open .chevron { transform: rotate(90deg); }

.phase-body { display: none; border-top: 1px solid var(--color-border); }
/* issue #6 Phase 7 — the "body-in" fade used to run here on every open, via
   `display: none -> block`, which can't itself be transitioned. A full
   re-render (rebuilding every phase-card, not just the toggled one) used to
   trigger it unintentionally on structural changes too. Expand/collapse now
   plays a true FLIP height animation instead, driven by animatePhaseBody()
   (dashboard.js) via Element.animate() — this rule only sets the resting
   `display: block` state for an already-open card on a full re-render (no
   animation replay), the same way it always has. */
.phase-card.open .phase-body { display: block; }

/* issue #6 Phase 4.2 — sticky while scrolling through a long open phase.
   --topbar-h keeps the offset in lockstep with .app-topbar's own height
   (defined near .app-topbar) so a stuck section header never sits half
   hidden underneath it. */
.section-label {
  position: sticky;
  top: var(--topbar-h);
  z-index: 5;
  font-family: var(--font-mono);
  font-size: 10px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--color-text-muted);
  padding: 12px 16px 6px;
  background: var(--color-surface-raised);
}
/* `.phase-body-animating` (animatePhaseBody(), dashboard.js) is applied only
   while `.phase-body` carries an inline `overflow: hidden` for its FLIP
   height animation. That `overflow: hidden` makes `.phase-body` a scroll
   container, which per the Position spec becomes the sticky positioning
   context for every `.section-label` inside it (normally the page is) —
   causing labels to jump/overlap against the still-animating 0→target
   height for the animation's duration, a real reported bug. Dropping to
   `position: static` for that window removes the sticky recalculation
   entirely; the class is removed (and sticky restored) the instant the
   animation's `onfinish` clears the inline `overflow`. */
.phase-body-animating .section-label { position: static; }

/* Custom roadmap phase/section CRUD (issue #4). Column layout with the
   .field-hint caption on its own line (issue #121 item 4) — without it, the
   rename input directly under the phase/section header reads as an
   unlabeled duplicate title rather than an obviously-a-rename-field
   control. */
.phase-manage-row, .section-manage-row {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: 6px;
  padding: 10px 16px;
  background: var(--color-surface-raised);
  border-bottom: 1px dashed var(--color-border);
}
.phase-manage-row .field-hint, .section-manage-row .field-hint { margin-top: 0; }
.phase-manage-row-controls, .section-manage-row-controls {
  display: flex;
  align-items: center;
  gap: 8px;
}
.phase-manage-row .field-input, .section-manage-row .field-input { flex: 1; }

/* issue #206 §3.6 — pill badge, tinted (not flat-filled): background at ~15%
   opacity of the matching --color-p0..--color-p3 token via color-mix()
   (this file's established opacity pattern), text at that token's full
   opacity. No separate dark-theme text-color override needed any more —
   unlike the old flat-fill-plus-white-text version, a token-tinted
   background is contrast-safe against its own full-opacity text by
   construction in both themes. */
.badge {
  display: inline-flex;
  align-items: center;
  height: var(--chip-height);
  padding: 0 var(--chip-padding-x);
  border-radius: var(--radius-full);
  font-family: var(--font-mono);
  font-size: 9px;
  font-weight: 800;
}
.badge.P0 { background: color-mix(in srgb, var(--color-p0) 15%, transparent); color: var(--color-p0-ink); }
.badge.P1 { background: color-mix(in srgb, var(--color-p1) 15%, transparent); color: var(--color-p1-ink); }
.badge.P2 { background: color-mix(in srgb, var(--color-p2) 15%, transparent); color: var(--color-p2-ink); }
.badge.P3 { background: color-mix(in srgb, var(--color-p3) 15%, transparent); color: var(--color-p3-ink); }

/* issue #433 — `.section-rows` wraps a section's `.check-item` rows (plus a
   top/bottom `.row-spacer`) so dashboard.js's row-level windowed rendering
   can prune far-off-screen rows without disturbing the section's total
   flowed height. Both are plain block boxes by default (matching
   `.phase-body`'s own plain block layout, unaffected by this new wrapper
   level) — the only thing either ever needs is `.row-spacer`'s height, set
   directly by dashboard.js's `syncSectionRowsWindow()` via
   `.style.height` (direct DOM property mutation, not the CSP-blocked inline
   `style` attribute — see that function's own comment). No visual styling of
   either class is needed; `:last-of-type`'s `border-bottom: none` rule below
   still resolves correctly since `.row-spacer` isn't a `.check-item` and
   doesn't participate in that selector. */
.section-rows { display: block; }
.row-spacer { display: block; height: 0; }

/* Checklist items — issue #486 (B1): fixed two-line row (checkbox, title,
   one grey meta line) at a 66px minimum height, replacing the old
   up-to-nine-control/up-to-four-line row. Priority is a 3px coloured left
   edge instead of a separate `.priority-tag` pill; every secondary control
   (Open, Add to today, Mark reviewed, Add a link, Delete) lives in the
   `.check-item-overflow-btn` ⋮ menu at the row's trailing edge instead. */
.check-item {
  position: relative;
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 66px;
  padding: 12px 16px;
  border-bottom: 1px solid var(--color-border);
  cursor: pointer;
  transition: background-color var(--duration-fast) var(--ease-standard);
}
.check-item:last-of-type { border-bottom: none; }
.check-item:hover { background: var(--color-surface-raised); }
.check-item:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }

/* issue #503 follow-up — real, reported bug: the priority accent used to be
   an `inset 3px 0 0` box-shadow, which paints *inside* the row's own border
   layer at the same stacking step as `border-bottom` above. Where two
   same-priority rows sit back to back, the 1px grey divider still crosses
   the 3px accent strip at every row boundary, notching a visible grey tick
   into what should read as one continuous coloured line down a run of
   same-priority rows — worse the more rows are open, since it repeats at
   every ~66px. A `::before` overlay paints as generated content *after* the
   element's own background/border in the paint order, so it sits on top of
   that notch instead of getting cut by it, giving a genuinely unbroken
   strip across consecutive rows with no box-model/padding side effects
   (same reason the original comment gave for using an inset shadow instead
   of a real `border-left` in the first place). */
.check-item::before {
  content: '';
  position: absolute;
  left: 0;
  /* 1px overlap top/bottom, not a flush 0/0 — a same-height adjacent row's
     own `border-bottom` sits exactly on this row's bottom pixel, and
     sub-pixel rounding between the two independently-laid-out boxes can
     still leave a hairline of that grey border visible even though this
     layer paints after it (see the ::before comment above `.check-item` for
     why paint order alone isn't quite enough). Overlapping by 1px in both
     directions guarantees full coverage regardless of that rounding. */
  top: -1px;
  bottom: -1px;
  width: 3px;
}
.check-item-p-P0::before { background: var(--color-p0); }
.check-item-p-P1::before { background: var(--color-p1); }
.check-item-p-P2::before { background: var(--color-p2); }
.check-item-p-P3::before { background: var(--color-p3); }

/* Issue #379 — `.check-item-focused` is the j/k keyboard-navigation focus
   ring: a purely visual state (dashboard.js tracks it via a plain id, not
   real DOM focus, since .check-item rows aren't themselves focusable) and
   deliberately distinct from :focus-visible above, which still marks real
   keyboard focus on .check-box. Outline instead of a background tint so it
   reads clearly even on a done (already-tinted) row. */
.check-item.check-item-focused { outline: 2px solid var(--color-accent); outline-offset: -2px; }

.shortcuts-modal-card { max-width: 420px; width: 100%; padding: 24px; display: flex; flex-direction: column; gap: 16px; }
.shortcuts-modal-title { font: 800 20px/1.2 var(--font-display); }
.shortcuts-modal-list { display: flex; flex-direction: column; }
.shortcuts-modal-row {
  display: flex;
  align-items: center;
  gap: 12px;
  padding: 8px 0;
  border-bottom: 1px solid var(--color-border);
}
.shortcuts-modal-row:last-child { border-bottom: none; }
.shortcuts-modal-key {
  flex-shrink: 0;
  min-width: 32px;
  text-align: center;
  padding: 2px 8px;
  border: 1px solid var(--color-border-strong);
  background: var(--color-surface-raised);
  font: 600 13px/1 var(--font-mono);
  color: var(--color-text);
}
.shortcuts-modal-desc { color: var(--color-text-muted); font-size: var(--text-base); }

/* Issue #6 Phase 4.2 — stagger-in entrance for a newly-added topic row (see
   dashboard.js's knownItemIds id-diff). A capped set of discrete
   entering-delay-N classes, not an inline `style="animation-delay:…"` —
   index.html's CSP has no 'unsafe-inline' in style-src, so the browser
   silently drops any inline style attribute app-wide (confirmed live: it
   produced a CSP console violation per row and no visible stagger at all).
   Any future per-row animation delay must follow this same discrete-class
   pattern instead of reaching for an inline style. */
@keyframes item-entering {
  from { opacity: 0; transform: translateY(4px); }
  to { opacity: 1; transform: translateY(0); }
}
.check-item.entering { animation: item-entering var(--duration-base) var(--ease-out) both; }
.check-item.entering-delay-0 { animation-delay: 0ms; }
.check-item.entering-delay-1 { animation-delay: calc(var(--stagger-base) * 1); }
.check-item.entering-delay-2 { animation-delay: calc(var(--stagger-base) * 2); }
.check-item.entering-delay-3 { animation-delay: calc(var(--stagger-base) * 3); }
.check-item.entering-delay-4 { animation-delay: calc(var(--stagger-base) * 4); }
.check-item.entering-delay-5 { animation-delay: calc(var(--stagger-base) * 5); }
.check-item.entering-delay-6 { animation-delay: calc(var(--stagger-base) * 6); }
@media (prefers-reduced-motion: reduce) {
  .check-item.entering { animation: none; }
}

/* Same item-entering keyframes/stagger token reused for the "Create your own
   roadmap" modal's numbered step headings (issue #100 revamp) — a short,
   staggered fade-in so the six steps read as a guided sequence instead of
   all snapping in at once. */
.import-step-heading.entering { animation: item-entering var(--duration-base) var(--ease-out) both; }
.import-step-heading.entering-delay-0 { animation-delay: 0ms; }
.import-step-heading.entering-delay-1 { animation-delay: calc(var(--stagger-base) * 1); }
.import-step-heading.entering-delay-2 { animation-delay: calc(var(--stagger-base) * 2); }
.import-step-heading.entering-delay-3 { animation-delay: calc(var(--stagger-base) * 3); }
.import-step-heading.entering-delay-4 { animation-delay: calc(var(--stagger-base) * 4); }
.import-step-heading.entering-delay-5 { animation-delay: calc(var(--stagger-base) * 5); }
.import-step-heading.entering-delay-6 { animation-delay: calc(var(--stagger-base) * 6); }
@media (prefers-reduced-motion: reduce) {
  .import-step-heading.entering { animation: none; }
}

/* Phase/roadmap completion confetti flourish (issue #181, confetti.js's
   triggerConfetti()) — fixed full-viewport overlay, pointer-events: none so
   it never blocks the underlying UI, self-removed by confetti.js after the
   animation ends. issue #297 — design-system.md §7 calls for "squares in
   the accent ramp colors (no circles, no gold)": border-radius zeroed
   (was 2px, a holdover from the old rounded-corner scale) and every piece
   now reads directly from the --color-accent-* ramp (was a mix of
   --color-accent/--color-warning/--color-brand-rose — the last of those a
   dangling reference to a deleted v3 token that would have resolved to
   nothing) so the 6-piece burst reads as one accent family at different
   steps, not an arbitrary color grab-bag. */
.confetti-burst {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 300;
  overflow: hidden;
}
.confetti-piece {
  position: absolute;
  top: -12px;
  left: calc(var(--confetti-x, 50%));
  width: 8px;
  height: 14px;
  border-radius: 3px;
  animation: confetti-fall 1.4s var(--ease-out) both;
}
.confetti-piece-0 { background: var(--color-accent); left: 8%; }
.confetti-piece-1 { background: var(--color-accent-600); left: 24%; }
.confetti-piece-2 { background: var(--color-accent-700); left: 40%; }
.confetti-piece-3 { background: var(--color-accent); left: 56%; }
.confetti-piece-4 { background: var(--color-accent-800); left: 72%; }
.confetti-piece-5 { background: var(--color-accent-600); left: 88%; }
.confetti-piece-delay-0 { animation-delay: 0ms; }
.confetti-piece-delay-1 { animation-delay: calc(var(--stagger-base) * 1); }
.confetti-piece-delay-2 { animation-delay: calc(var(--stagger-base) * 2); }
.confetti-piece-delay-3 { animation-delay: calc(var(--stagger-base) * 3); }
.confetti-piece-delay-4 { animation-delay: calc(var(--stagger-base) * 4); }
.confetti-piece-delay-5 { animation-delay: calc(var(--stagger-base) * 5); }
.confetti-piece-delay-6 { animation-delay: calc(var(--stagger-base) * 6); }
@keyframes confetti-fall {
  from { opacity: 1; transform: translateY(0) rotate(0deg); }
  to { opacity: 0; transform: translateY(100vh) rotate(360deg); }
}
@media (prefers-reduced-motion: reduce) {
  .confetti-piece { animation: none; display: none; }
}

/* issue #206 §3.3 — checkbox radius standardized to --radius-sm; checked
   state now fills --color-success (was --color-brand-gold), which reads as
   dark-enough in both themes to safely host a fixed white checkmark with no
   per-theme text-color override needed (unlike the old lime-based dark
   theme, which needed near-black text — that override is removed below).
   The scale micro-animation itself (§5) shipped separately, below, as
   `.check-pop` — kept apart from `.check-box`'s own base rule since it's a
   one-shot class `toggleDone()` (dashboard.js) adds/removes around the
   click, not a permanent style. */
.check-box {
  width: 20px;
  height: 20px;
  border: 1.5px solid var(--color-border-strong);
  border-radius: var(--radius-sm);
  flex-shrink: 0;
  display: grid;
  place-items: center;
  background: var(--color-surface);
  transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard);
}
/* A real, reported gap (issue #309): `.check-box` is a genuinely focusable
   `role="checkbox"` element (`tabindex="0"`, `dashboard.js`) — every checklist
   row's checkbox had no visible keyboard-focus indicator at all. Same 2px
   accent outline every other interactive element in the app already uses. */
.check-box:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
/* issue #206 §5 follow-up — real feedback (screenshots, twice): the checked
   fill kept reading as an out-of-family green no matter how much the
   `--color-success` token itself was warmed, since it's still a genuinely
   different hue from the app's gold/rose palette. Dropped the semantic
   "success ≠ brand" distinction for this one highest-frequency surface and
   switched the checked fill to solid `--color-brand-gold` instead — the
   same accent every other "this is selected/complete/active" indicator in
   the app already uses (the active-roadmap badge, the sidebar's active-nav
   bar, `.progress-ring-fill`). `--color-success`/`-bg` tokens are
   unchanged and still used elsewhere (e.g. toast-success's accent bar) —
   only `.check-box.done`'s fill moved off them. `--color-ink-on-gold`
   (not fixed white) for the checkmark, since white-on-gold is the exact
   "fails badly in dark theme" bug `--color-ink-on-gold`'s own token
   comment already documents. */
.check-mark {
  display: inline-flex;
  color: var(--color-ink-on-accent);
  transform: scale(0);
  opacity: 0;
  /* issue #6 Phase 4.2 — spring bounce on toggle; pre-existing, kept as-is
     (not a new §5/PR2 motion addition). */
  transition: transform var(--duration-base) var(--ease-bounce), opacity 120ms ease;
}
/* v3 (issue #416 Phase 5 — #430) — reads --color-accent-600 in light theme,
   not the base accent, same text-hosting-fill fix as .reset-success-icon
   above (the checkmark glyph reads --color-ink-on-accent, which fails
   against the base green in light theme but clears 5.90:1 on -600; dark
   theme's own --color-ink-on-accent is already dark ink and reads fine
   against the bright base accent, so this is scoped to light theme only —
   see the `:root[data-theme='dark']` override immediately below). */
.check-item.done .check-box {
  background: var(--color-accent-600);
  border-color: transparent;
}
:root[data-theme='dark'] .check-item.done .check-box { background: var(--color-accent); }
.check-item.done .check-mark { transform: scale(1); opacity: 1; }
/* issue #298 — design-system.md §5: "done row text = line-through at 50% opacity".
   Was only a color-mute (--color-text-muted, a 64% mix per issue #297's contrast
   fix) with no line-through at all. */
.check-item.done .check-title { text-decoration: line-through; opacity: 0.5; }

/* issue #206 §5 — checkbox check micro-animation: a scale(1)->scale(1.15)->
   scale(1) pop on the checkbox itself, layered on top of (not replacing) the
   check-mark's own existing spring-bounce fade-in above. `--duration-fast`
   keeps the whole pop comfortably under the spec's 200ms ceiling — the
   app's highest-frequency interaction must never read as laggy under rapid
   repeated clicking. Class lifecycle (add, forced reflow, remove-on-
   `animationend`) lives in `toggleDone()`, dashboard.js — see that
   function's own comment for why a one-shot JS-toggled class was needed
   instead of a plain CSS transition here. */
@keyframes check-pop {
  0% { transform: scale(1); }
  50% { transform: scale(1.15); }
  100% { transform: scale(1); }
}
/* issue #499 (D3) — "row tick spring: 160ms" reuses --duration-tick, not
   --duration-fast directly, so this animation's name-to-token mapping is
   self-documenting even though the two tokens' values happen to differ
   (120ms vs. 160ms) — --duration-fast is still used for the general
   120-200ms hover/press rule elsewhere in this file (design-system.md §7). */
.check-box.check-pop { animation: check-pop var(--duration-tick) var(--ease-standard); }
@media (prefers-reduced-motion: reduce) {
  .check-box.check-pop { animation: none; }
}

/* issue #486 (B1) — a fixed two-line body: title, then one grey meta line
   ("Must do · 2 links · 25 min tracked", dashboard.js's buildRowMetaText()).
   `flex-direction: column` replaces the old wrapping single-line-of-chips
   row — the meta line is always exactly one line, never a wrapped badge
   cluster. */
.check-body { flex: 1; display: flex; flex-direction: column; justify-content: center; gap: 2px; min-width: 0; }
/* issue #6 Phase 7 — strike-through animates in via `::after` width 0% -> 100%
   instead of an instant `text-decoration: line-through`, per the animation
   inventory. `position: relative` + `display: inline-block` so the pseudo-element
   sizes itself to the title's own rendered width, not the flex row's.
   issue #499 follow-up — `.check-body` is a flex column with no `align-items`,
   which defaults to `stretch`: that stretched `.check-title` (an inline-block)
   to the row's full width, so the done-state strike-through line spanned the
   entire row instead of just the title text (real, reported: "cutting the
   whole row" instead of just the text) — `align-self: flex-start` keeps the
   title (and its `::after` line) sized to its own content, matching this
   file's existing "a flex column's default `stretch` needs an explicit
   `align-self: flex-start`" precedent (see the card-grid layout section above). */
.check-title { position: relative; display: inline-block; align-self: flex-start; max-width: 100%; font-size: var(--text-base); font-weight: 600; }
.check-title::after {
  content: '';
  position: absolute;
  left: 0;
  top: 50%;
  height: 1px;
  width: 0%;
  background: currentColor;
  transition: width var(--duration-base) var(--ease-out);
}
.check-item.done .check-title::after { width: 100%; }

/* The row's single grey meta line — priority label plus optional link-count/
   time-tracked segments, joined with " · " in dashboard.js. Replaces the old
   .priority-tag/.resource-count/.notes-indicator/.completed-via-todo-indicator
   chip cluster entirely; those signals either fold into this line (priority,
   link count, time tracked) or moved into the ⋮ overflow menu (notes,
   completed-via-todo — reachable via "Open"). */
.check-meta { font-size: var(--text-base); color: var(--color-text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }

/* Issue #100 follow-up — expanded, always-visible resource links shown on a
   checklist row when the "Resources" filter chip is active (dashboard.js's
   renderInlineResources()). */
.check-resources-inline { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 2px; }
.resource-inline-link { text-decoration: none; cursor: pointer; transition: transform 140ms ease, border-color 140ms ease; }
.resource-inline-link:hover { transform: translateY(-1px); }
.resource-inline-link:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }

/* issue #381 — a topic blocked on an unmet prerequisite: dimmed row, disabled
   checkbox (see dashboard.js's toggleDone()/isBlocked() guard), and a chip
   naming the prerequisite. */
.check-item.locked { opacity: 0.55; }
.check-item.locked .check-box { cursor: not-allowed; }
.prerequisite-lock-chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  height: var(--chip-height);
  padding: 0 var(--chip-padding-x);
  align-self: flex-start;
  border-radius: var(--radius-full);
  border: 1px solid var(--color-border);
  color: var(--color-text-muted);
  cursor: default;
}

/* issue #486 (B1) — the single ⋮ overflow trigger replacing the old
   always-hover-visible .check-actions button cluster (Edit, Add to today,
   Mark reviewed). Same 24px/`.template-card-overflow-btn`-matching treatment
   as onboarding.js's card overflow menu, so both "collapse secondary actions
   behind ⋮" spots in the app read as one visual family. Always visible (not
   hover-revealed) — a menu trigger, unlike the old inline buttons, has
   nothing to gain from hiding at rest, and staying visible means a touch/
   coarse-pointer device needs no separate always-on override the way
   `.check-actions` used to. */
.check-item-overflow-btn {
  flex-shrink: 0;
  width: 32px;
  height: 32px;
  display: grid;
  place-items: center;
  border-radius: var(--radius-sm);
  border: 1px solid transparent;
  background: transparent;
  color: var(--color-text-muted);
  cursor: pointer;
}
.check-item-overflow-btn:hover,
.check-item-overflow-btn[aria-expanded='true'] { color: var(--color-text); border-color: var(--color-border); background: var(--color-surface-raised); }
.check-item-overflow-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }

.add-row {
  display: flex;
  gap: 8px;
  padding: 10px 16px 14px;
  background: var(--color-surface-raised);
  border-top: 1px dashed var(--color-border);
}
.inline-add { flex: 1; }

/* "+ Add phase" (issue #65 follow-up) renders as a direct sibling of
   .phase-card in .dashboard-content, not nested inside one — unlike
   "+ Add section"/"Add a custom topic…", which already sit inside a fully
   boxed .phase-body. Without its own framing it read as a bare flat strip
   next to the bordered/shadowed phase-cards below it. Matches .phase-card's
   box but keeps a dashed border throughout (rather than just the top edge)
   as the same "this creates something new" cue used by
   .template-card-create/.template-card-hidden. */
.add-row-standalone {
  border: 1px dashed var(--color-border-strong);
  border-radius: var(--radius-md);
  padding: 12px 16px;
}

/* Item panel */
/* intentional: theme-agnostic dark scrim, same pattern as .modal-overlay above
   (issue #116; issue #206 §3.5 — matched to .modal-overlay's rgba(0,0,0,0.5),
   just kept at a lower alpha since this one also has a backdrop-filter blur
   doing part of the visual separation work). */
.panel-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.45);
  backdrop-filter: blur(4px);
  z-index: 200;
  opacity: 0;
  transition: opacity 220ms ease;
}
.panel-overlay.show { opacity: 1; }

.item-panel {
  position: fixed;
  top: 0;
  right: 0;
  width: min(480px, 100%);
  height: 100%;
  background: var(--color-surface);
  box-shadow: var(--shadow-lg);
  z-index: 201;
  display: flex;
  flex-direction: column;
  padding-top: env(safe-area-inset-top);
  padding-right: env(safe-area-inset-right);
  padding-bottom: env(safe-area-inset-bottom);
  transform: translateX(100%);
  /* issue #499 (D3) — "sheet slide (from #D1): 220ms" reads --duration-sheet
     instead of a literal 280ms; --ease-spring is the same curve
     (cubic-bezier(.2,.9,.3,1)) this rule already hardcoded, just named. This
     transition drives both the desktop translateX slide-in-from-right and
     (via the ≤720px override's translateY values above) the mobile
     bottom-sheet slide-up — one shared timing for both shapes. */
  transition: transform var(--duration-sheet) var(--ease-spring);
}
.item-panel.show { transform: translateX(0); }

.panel-header {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  padding: 20px 20px 12px;
  border-bottom: 1px solid var(--color-border);
}
.panel-kicker { font-size: 12px; color: var(--color-text-muted); font-weight: 700; }
.panel-title { font-size: 22px; font-weight: 900; margin-top: 4px; }
.panel-body { flex: 1; overflow: auto; padding: 20px; display: grid; gap: 16px; align-content: start; }
.panel-footer {
  padding: 16px 20px;
  border-top: 1px solid var(--color-border);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  flex-wrap: wrap;
}
.panel-footer-right { display: flex; gap: 8px; margin-left: auto; }

/* What's New changelog drawer (issue #20) — reuses .item-panel/.panel-* for
   the slide-in shell; everything below is specific to its content. */
.changelog-drawer-body { gap: 28px; }
.changelog-group + .changelog-group { padding-top: 20px; border-top: 1px solid var(--color-border); }
.changelog-group-header { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; margin-bottom: 12px; }
.changelog-group-version { font-size: var(--text-base); font-weight: 800; }
.changelog-group-date { font-size: 12px; color: var(--color-text-muted); }
.changelog-item-list { display: grid; gap: 14px; }
.changelog-item { display: flex; gap: 10px; align-items: flex-start; }
.changelog-item-body { min-width: 0; }
.changelog-item-title { font-weight: 700; font-size: var(--text-base); }
.changelog-item-desc { color: var(--color-text-muted); font-size: var(--text-base); margin-top: 2px; }
.changelog-type-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: 999px; margin-top: 6px; }
.changelog-type-dot.changelog-type-feat { background: var(--color-accent); }
:root[data-theme='dark'] .changelog-type-dot.changelog-type-feat { background: var(--color-accent); }
.changelog-type-dot.changelog-type-fix { background: var(--color-warning); }
.changelog-type-dot.changelog-type-improvement { background: var(--color-text-muted); }
.changelog-type-label { display: inline-block; margin-top: 4px; font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.03em; color: var(--color-text-muted); }

/* "New" feature badge (issue #20 Phase C) — a small pill next to a feature
   introduced by a changelog entry the user has already opened the drawer
   for; same rounded-pill treatment as .sync-pill, brand-colored since this
   is always positive/informational, never a warning/error state. */
/* v3 (issue #416 Phase 5 — #430) — same fixes as .import-step-badge above:
   light theme's fill moves to --color-accent-600 (white text clears 5.90:1,
   vs. 2.68:1 on the base accent); the dark-theme override's `color` was
   missing entirely before this pass (a real, pre-existing bug — dark theme
   was rendering fixed white text straight on the bright base accent, now
   fixed to dark ink via `var(--color-bg)`, same as .import-step-badge). */
.feature-new-badge {
  display: inline-block;
  font-size: 10px;
  font-weight: 800;
  text-transform: uppercase;
  letter-spacing: 0.03em;
  padding: 2px 8px;
  border-radius: var(--v3-radius-sm);
  background: var(--color-accent-600);
  color: #fff; /* intentional: fixed white on --color-accent-600, dark theme overrides below */
}
:root[data-theme='dark'] .feature-new-badge { background: var(--color-accent); color: var(--color-bg); }

.notes-textarea {
  resize: vertical;
  min-height: 88px;
  field-sizing: content;
  max-height: 320px;
  font-family: inherit;
  line-height: 1.5;
}
.notes-counter { font-size: var(--text-base); }
.notes-status {
  font-size: var(--text-base);
  font-weight: 700;
  color: var(--color-accent-700);
  opacity: 0;
  transition: opacity 200ms ease;
}
:root[data-theme='dark'] .notes-status { color: var(--color-accent-700); }
.notes-status.show { opacity: 1; }
.notes-status.error { color: var(--color-warning); }

/* Issue #180 — timer start/pause control + running total, itemPanel.js and
   dailyTodoPanel.js. */
.timer-row { display: flex; align-items: center; gap: 8px; }
.timer-toggle-btn.active { color: var(--color-accent-700); }
:root[data-theme='dark'] .timer-toggle-btn.active { color: var(--color-accent-700); }
.timer-display { font-size: var(--text-base); font-variant-numeric: tabular-nums; color: var(--color-text-muted); }
.daily-todo-timer-btn {
  border: none;
  background: none;
  padding: 2px;
  color: var(--color-text-muted);
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  font: inherit;
}
.daily-todo-timer-btn:hover, .daily-todo-timer-btn.active { color: var(--color-accent-700); }
:root[data-theme='dark'] .daily-todo-timer-btn:hover,
:root[data-theme='dark'] .daily-todo-timer-btn.active { color: var(--color-accent-700); }
.daily-todo-time-spent {
  font-size: var(--text-base);
  font-variant-numeric: tabular-nums;
  color: var(--color-text-muted);
}

.resource-list { display: grid; gap: 10px; }
/* issue #206 §6 — small solid gold dot instead of a plain muted-gray message.
   Was a gradient dot; reverted to solid per the app-wide "no gradients"
   decision. */
.resource-list-empty { display: flex; align-items: center; gap: var(--space-2); font-size: var(--text-base); color: var(--color-text-muted); }
.resource-list-empty-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: var(--color-accent); }
.resource-card {
  display: grid;
  gap: 8px;
  padding: 10px;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-surface-raised);
}
.resource-card-header { display: flex; align-items: center; }
.resource-meta { display: grid; gap: 6px; }
.resource-actions { display: flex; gap: 8px; }
.resource-add { display: grid; gap: 8px; margin-top: 10px; }
.resource-url-warning { margin: 0; }

/* Issue #329 — best-effort "Check link" result line. `.unverified` (the
   common, expected result — see itemPanel.js's own comment on why this is
   not an error) stays neutral/muted, never the danger color: a CORS-blocked
   response is not evidence the link is broken. `.reachable` uses the success
   token; `.error` (an invalid URL, caught before ever attempting a fetch)
   is the only state that uses the danger token. */
.resource-check-status { margin: 0; display: flex; align-items: center; gap: 4px; }
.resource-check-status.reachable { color: var(--color-success); }
.resource-check-status.error { color: var(--color-danger); }

/* Issue #12B — link-type badge, one per detectLinkType() result
   (src/ui/utils/linkDetector.js). Issue #353: per-type hex colors (predating the v2
   "Modernist" mono-scheme rule, design-system.md §2) collapsed to the shared mono
   scheme — visual distinction between link types now comes from each badge's own
   icon (linkDetector.js's LINK_TYPE_META), not color. `.link-badge-github` was
   already this shape and is the template every other type now matches. */
.link-badge {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  font-size: var(--text-base);
  font-weight: 700;
  padding: 3px 9px;
  border-radius: var(--v3-radius-md);
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  color: var(--color-text-muted);
}
.link-badge-github { color: var(--color-text); background: var(--color-border); border-color: var(--color-border); }

.muted { color: var(--color-text-muted); }
.small { font-size: 12px; }

/* Empty state */
.empty-state {
  text-align: center;
  padding: 64px 20px;
  color: var(--color-text-muted);
  border: 1px dashed var(--color-border);
  border-radius: var(--radius-md);
  background: var(--color-surface-raised);
}
/* Issue #505 — `.dashboard-content`'s left padding includes
   `--phase-spine-gutter` (issue #492) so real phase cards clear the spine's
   dot column it draws down their left edge. When there are zero visible
   phase cards (the "no filter matches"/"nothing found" empty states replace
   the whole phase list), that gutter has nothing next to it to justify —
   the card just reads as inset relative to `.roadmap-filters-card`/
   `.daily-todo-panel` directly above it (confirmed live: 512px vs. 487px
   left edge at one tested viewport). Cancel it here so the empty state's
   left edge lines up with its sibling cards above; the right edge already
   matches without any change (both read the same `--dashboard-gutter-x`). */
.dashboard-content > .empty-state {
  margin-left: calc(-1 * var(--phase-spine-gutter));
}
/* issue #206 §6 — small solid gold icon badge, not a generic muted-gray
   glyph. Was a gradient fill; reverted to solid per the app-wide
   "no gradients" decision. */
/* v3 (issue #416 Phase 5 — #430) — light theme reads --color-accent-600, not
   the base accent, same text/icon-hosting-fill fix as the other badges in
   this pass ( --color-ink-on-accent clears only 2.68:1/~3:1-graphics-floor
   on the base green; 5.90:1 on -600). Uses the token, not a literal color,
   so dark theme (already dark ink, already passes against the bright base
   accent) needs no override. */
.empty-icon {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 48px;
  height: 48px;
  margin: 0 auto var(--space-3);
  border-radius: 50%;
  background: var(--color-accent-600);
  color: var(--color-ink-on-accent);
}
:root[data-theme='dark'] .empty-icon { background: var(--color-accent); }
.empty-title { font-weight: 800; color: var(--color-text); margin-top: var(--space-2); }
.empty-message { margin-top: var(--space-1); }
.empty-state .btn { margin-top: var(--space-4); }

/* Icon system (issue #107) — base class for every createIcon()-produced
   <svg>; size comes from one of the four modifier classes below, which read
   the --icon-size-* tokens defined in :root. Every icon-wrapper class in
   this file (.nav-item-icon, .btn-icon, .stat-tile-icon, etc.) sets an
   explicit size this way — never let an icon glyph inherit surrounding
   body-text font-size (that's exactly what undersized the sidebar's
   Settings gear before this issue). */
.icon { flex-shrink: 0; }
.icon-xs { width: var(--icon-size-xs); height: var(--icon-size-xs); }
.icon-sm { width: var(--icon-size-sm); height: var(--icon-size-sm); }
.icon-md { width: var(--icon-size-md); height: var(--icon-size-md); }
.icon-lg { width: var(--icon-size-lg); height: var(--icon-size-lg); }

/* Component library (issue #6 Phase 3) */

/* 3.1 Skeleton loaders — design-system.md §7: "flat neutral-200 blocks, no
   shimmer gradients — a simple opacity pulse" (issue #309; this used to be a
   `background-position`-animated `linear-gradient`, both a §2 "no gradients
   anywhere" violation and, now moot, a redundant one — its three color stops
   had all converged on the same `--color-surface-raised` value during an
   earlier token migration, so it hadn't even been visibly shimmering). */
/* Issue #507 — a real shimmer sweeping between --color-surface and
   --color-divider only (design reference), replacing the old flat
   --color-neutral-200 fill + opacity-pulse — a loading placeholder is never
   a tinted/accent step, only these two neutral tokens. */
@keyframes skeleton-shimmer {
  0% { background-position: -150% 0; }
  100% { background-position: 150% 0; }
}
.skeleton {
  background: linear-gradient(90deg, var(--color-surface) 25%, var(--color-divider) 50%, var(--color-surface) 75%);
  background-size: 200% 100%;
  animation: skeleton-shimmer 1.6s ease-in-out infinite;
  border-radius: var(--radius-md);
}
.skeleton-text { height: 14px; }
.skeleton-card { height: 72px; }
@media (prefers-reduced-motion: reduce) {
  .skeleton { animation: none; }
}

/* 3.4 Tooltip — portaled to document.body (issue #180 follow-up, see
   tooltip.js's own comment), so `position: fixed` + JS-computed left/top
   are the only positioning mechanism now; there is no longer a
   `.tooltip-trigger` descendant relationship to key CSS off of. */
.tooltip-trigger { position: relative; }
/* intentional: fixed white text on a fixed dark --neutral-900 tooltip background, same in both themes (issue #116) */
.tooltip-bubble {
  position: fixed;
  transform: translate(-50%, -100%);
  background: var(--neutral-900);
  color: #fff;
  padding: 4px 8px;
  border-radius: var(--v3-radius-sm);
  font-size: var(--text-base);
  white-space: nowrap;
  pointer-events: none;
  z-index: 60;
}
.tooltip-bubble.tooltip-below { transform: translate(-50%, 0); }

/* 3.5 Modal — spring entry on top of the existing .modal-overlay/.modal-card */
@keyframes modal-card-enter {
  from { opacity: 0; transform: scale(0.96); }
  to { opacity: 1; transform: scale(1); }
}
.modal-card-enter { animation: modal-card-enter var(--duration-enter) var(--ease-spring) both; }
@media (prefers-reduced-motion: reduce) {
  .modal-card-enter { animation: none; }
}

/* 3.5b Feature tour (issue #17) — a one-time spotlight walkthrough, portaled
   to document.body like every other floating element (see this file's
   "every floating/positioned element is a portal" convention). z-index 1100+
   deliberately sits above .modal-overlay (1000) and .custom-select-listbox
   (1010, the highest existing value in this file) — the tour can be
   re-triggered mid-session via "Take a tour" and must render above
   absolutely everything, including an already-open modal. */
.tour-scrim {
  position: fixed;
  inset: 0;
  z-index: 1100;
  background: transparent;
}
.tour-ring {
  position: fixed;
  z-index: 1101;
  /* v3 (issue #416 Phase 5 — #430) — design-system.md §7's cutout radius
     follows --v3-radius-lg now, replacing the old hard --radius-md corner. */
  border-radius: var(--v3-radius-lg);
  border: 2px solid hsl(var(--v3-accent));
  /* The dimming layer itself — a huge-spread box-shadow fills the rest of
     the viewport, leaving this element's own box as a "cutout" over the
     highlighted target. Not clip-path'ing a separate overlay, per the
     issue's own spec — cheaper to keep in sync on resize/scroll, since
     repositioning is just two style writes rather than a recomputed path.
     Issue #507 — a 4px accent-100 halo (design reference) sits between the
     ring border and the dim layer, in the same box-shadow stack rather than
     a second element, so it tracks the ring's position/resize for free. */
  box-shadow: 0 0 0 4px hsl(var(--v3-accent) / 0.2), 0 0 0 9999px rgba(0, 0, 0, 0.5); /* intentional: theme-agnostic dim layer, same reasoning as .modal-overlay's own scrim; design-system.md §7 specifies "ink scrim at 50%" exactly (issue #301 follow-up — was 0.6) */
  pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
  .tour-ring {
    transition: top var(--duration-base) var(--ease-standard), left var(--duration-base) var(--ease-standard),
      width var(--duration-base) var(--ease-standard), height var(--duration-base) var(--ease-standard);
  }
}
/* v3 (issue #416 Phase 5 — #430) — glass card + shadow-glow per
   design-system.md §7, replacing the old flat --color-surface/--shadow-lg
   card. */
.tour-popover {
  position: fixed;
  z-index: 1102;
  background: hsl(var(--v3-surface) / 0.9);
  -webkit-backdrop-filter: blur(16px);
  backdrop-filter: blur(16px);
  border: 1px solid hsl(var(--v3-divider) / 0.6);
  border-radius: var(--v3-radius-lg);
  box-shadow: var(--v3-shadow-glow);
  padding: 20px;
  max-width: 320px;
}
@supports not (backdrop-filter: blur(1px)) {
  .tour-popover { background: var(--color-surface-raised); }
}
/* Issue #507 — the design reference shows "STEP N OF M" as a visible
   eyebrow-style kicker above the step title, not a screen-reader-only
   label; the popover's own aria-live="polite" already announces step
   changes to assistive tech, so making this visible loses nothing. */
.tour-popover-progress { margin: 0 0 6px; }
.tour-popover-title { font-size: var(--text-base); font-weight: 700; margin: 0 0 6px; color: var(--color-text); }
.tour-popover-body { font-size: var(--text-base); color: var(--color-text-muted); margin: 0 0 16px; }
.tour-popover-actions { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.tour-popover-actions-right { display: flex; gap: 8px; }

.tour-welcome-overlay {
  position: fixed;
  inset: 0;
  z-index: 1102;
  background: rgba(0, 0, 0, 0.5); /* intentional: same theme-agnostic scrim as .modal-overlay */
  display: flex;
  align-items: safe center;
  justify-content: center;
  padding: 24px;
  overflow-y: auto;
}
.tour-welcome-card {
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  box-shadow: var(--shadow-lg);
  padding: 32px 28px 28px;
  width: 100%;
  max-width: 420px;
}
/* Flush-left, not centered — design-system.md §4's "flush-left everything...
   never center hero copy or button labels" rule (issue #301 follow-up). This
   card was the one remaining centered modal in the app. */
/* Issue #507 follow-up (live visual review) — createBrandMark() returns a
   bare [icon, wordmark] array with no gap of its own; every other call site
   supplies it via a wrapping `.brand` class (10px gap), but this one used a
   bespoke `.tour-welcome-brand` div that never set `align-items`/`gap`, so
   the glyph and "ASCENT" wordmark rendered touching with no space between
   them. */
.tour-welcome-brand { display: flex; align-items: center; gap: 10px; justify-content: flex-start; margin-bottom: 12px; }
.tour-welcome-title { font-size: var(--text-lg); margin: 0 0 8px; color: var(--color-text); }
.tour-welcome-body { color: var(--color-text-muted); margin: 0 0 20px; }
.tour-welcome-actions { display: flex; justify-content: flex-end; align-items: center; gap: 12px; }

/* 3.6 Tabs — filled pill style (issue #495's settings.js, this component's
   first real call site, restyled from the original underline treatment to
   match that issue's design reference). Wraps rather than scrolling on a
   narrow viewport (the 360px requirement) since each pill is independently
   sized, not sharing one continuous border. */
.tabs { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.tab {
  font: inherit;
  font-size: var(--text-base);
  font-weight: 700;
  color: var(--color-text);
  background: var(--color-surface);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-lg);
  padding: var(--space-2) var(--space-4);
  cursor: pointer;
  transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard);
}
/* --color-accent-600, not the base --color-accent — same axe-caught
   contrast fix .btn-primary/.seg-item already document. */
.tab[aria-selected='true'] { background: var(--color-accent-600); color: var(--color-ink-on-accent); border-color: var(--color-accent-600); }
.tab:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
/* :not(.active), not a bare `.tab-panel` — a panel's own content class (e.g.
   settings.js's `.settings-section`, which sets `display: grid`) is equal
   specificity to a single bare class selector, so a same-specificity rule
   declared later in this file (as `.settings-section` is) would otherwise
   win the cascade and leave every inactive panel visible regardless of this
   rule. `:not(.active)` bumps this to two classes' worth of specificity so
   it always wins, independent of source order (issue #495 follow-up, found
   live — see the Build Log). The active rule below deliberately sets no
   `display` of its own (only the entrance animation) — a `.tab-panel.active`
   selector is two classes, which would itself outrank and silently collapse
   `.settings-section`'s own `display: grid` (and its `gap`) the same way;
   letting the panel's own content class decide its display keeps this
   primitive content-agnostic, the way `.tabs`' first call site (this issue)
   needs it to be. */
.tab-panel:not(.active) { display: none; }
.tab-panel.active { animation: fade-in var(--duration-base) var(--ease-out); }
.settings-tabs .tab-panel.active { margin-top: var(--space-6); }

/* 3.7 Progress ring (issue #206 §7) — flat solid stroke. Was a
   var(--gradient-alpenglow) <linearGradient> (progressRing.js built a real
   <defs>/<linearGradient>/<stop> tree, since CSS custom properties can't be
   read from an SVG `stop-color` presentation attribute); reverted to a
   plain CSS `stroke` declaration per the app-wide "no gradients" decision —
   simpler now that there's no gradient def to keep out of this rule's way. */
.progress-ring-track { stroke: var(--color-border-strong); }
.progress-ring-fill {
  stroke: var(--color-accent);
  transition: stroke-dashoffset var(--duration-enter) var(--ease-spring);
}

/* 3.8 Notification badge */
/* v3 (issue #416 Phase 5 — #430) — design-system.md §8: "small glowing
   accent dot", replacing the v2 flat square dot. The numbered-count variant
   (`.notification-badge` without `-dot`) keeps its own count-pill shape —
   §8's "dot" language only applies to the dot variant a caller opts into via
   `.notification-badge-dot`, since a count needs legible digits a bare glow
   dot can't render. */
.notification-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 16px;
  height: 16px;
  padding: 0 4px;
  background: hsl(var(--v3-accent));
  color: var(--color-ink-on-accent);
  font-size: 10px;
  font-weight: 800;
  line-height: 1;
  border-radius: var(--v3-radius-sm);
}
.notification-badge-dot {
  min-width: 8px;
  width: 8px;
  height: 8px;
  padding: 0;
  border-radius: 50%;
  box-shadow: 0 0 8px hsl(var(--v3-accent) / 0.8);
}

/* 3.3 Command palette */
/* v3 (issue #416 Phase 5 — #430) — glass surface matching the nav pill's
   blur-xl per design-system.md §5, replacing the flat `.modal-card`-derived
   surface this component used to inherit implicitly. */
.command-palette-card {
  max-width: 560px;
  width: 100%;
  padding: 0;
  display: grid;
  grid-template-rows: auto 1fr auto;
  max-height: 70vh;
  background: hsl(var(--v3-surface) / 0.85);
  -webkit-backdrop-filter: blur(20px);
  backdrop-filter: blur(20px);
  border: 1px solid hsl(var(--v3-divider) / 0.6);
  border-radius: var(--v3-radius-lg);
  box-shadow: var(--v3-shadow-glow-lg);
}
@supports not (backdrop-filter: blur(1px)) {
  .command-palette-card { background: var(--color-surface-raised); }
}
.command-palette-input {
  font: inherit;
  font-size: var(--text-base);
  border: none;
  border-bottom: 1px solid hsl(var(--v3-divider) / 0.6);
  padding: var(--space-4) var(--space-6);
  background: transparent;
  color: var(--color-text);
}
.command-palette-input:focus { outline: none; }
.command-palette-list { overflow-y: auto; padding: var(--space-2); display: grid; gap: 2px; }
.command-palette-item {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 2px;
  width: 100%;
  text-align: left;
  font: inherit;
  /* No text node of its own — .command-palette-item-title/-subtitle each set
     their own color — kept explicit for defensiveness (issue #116). */
  color: inherit;
  border: none;
  background: none;
  padding: var(--space-2) var(--space-3);
  border-radius: var(--v3-radius-md);
  cursor: pointer;
}
/* v3 (issue #416 Phase 5 — #430) — result rows get the standard v3
   hover/active accent tint per design-system.md §6, replacing the old
   solid-fill + inset-bar treatment. */
.command-palette-item.active { background: hsl(var(--v3-accent) / 0.1); box-shadow: none; }
.command-palette-item.active .command-palette-item-title { color: var(--v3-accent-ink, hsl(var(--v3-accent))); }
.command-palette-item-title { font-size: var(--text-base); font-weight: 700; color: var(--color-text); }
.command-palette-item-subtitle { font-size: var(--text-base); color: var(--color-text-muted); }
/* issue #283 — "Navigation"/"Topics" group headers, only rendered once the palette
   has more than one result group (cross-roadmap topic search wired in). Kicker
   styling per design-system.md §3 (uppercase, letter-spaced, accent color). */
.command-palette-group-label {
  padding: var(--space-2) var(--space-3) 4px;
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.14em;
  color: var(--color-accent-700);
}
.command-palette-footer {
  padding: var(--space-2) var(--space-4);
  border-top: 1px solid var(--color-border);
  font-size: var(--text-base);
  color: var(--color-text-muted);
}

/* Save badge — bottom-right corner. Used to be stacked above the floating
   feedback widget trigger to avoid overlapping it (issue #9); that trigger
   was retired in issue #498 (feedback moved into the account menu/Settings),
   so this reverts to a plain fixed bottom-right offset. If a new fixed
   bottom-right corner element is ever added, re-check .claude/rules/
   ui-styling.md's "two fixed corner elements" rule before assuming they can
   coexist. */
.save-badge {
  position: fixed;
  bottom: calc(20px + env(safe-area-inset-bottom));
  right: calc(20px + env(safe-area-inset-right));
  background: var(--color-surface);
  color: var(--color-text);
  border: 1px solid var(--color-border);
  padding: 10px 14px;
  border-radius: var(--v3-radius-md);
  font-size: var(--text-base);
  font-weight: 700;
  box-shadow: var(--shadow-md);
  opacity: 0;
  transform: translateY(8px);
  transition: opacity var(--duration-base) ease, transform var(--duration-base) ease;
  pointer-events: none;
  z-index: 100;
  display: flex;
  align-items: center;
  gap: 8px;
}
.save-badge.show { opacity: 1; transform: translateY(0); }
/* issue #153 — an error-state badge now carries a real "Retry now" button
   (roadmapStore.js's retrySaveNow()), so pointer-events flips to auto only in
   this state; every other state stays click-through (pointer-events: none
   above) since it's purely informational. */
.save-badge.error { background: var(--color-danger-bg); color: var(--color-danger); border-color: var(--color-danger); pointer-events: auto; }
.save-badge-retry {
  color: inherit;
  border-color: currentColor;
  min-height: 0;
  padding: 3px 10px;
  font-size: var(--text-base);
}
/* intentional: neutral gray hover wash reads correctly against either theme's --color-danger-bg fill, same reasoning as .btn-spinner's own neutral track just below (issue #116) */
.save-badge-retry:hover:not(:disabled) { background: rgba(127, 127, 127, 0.15); }
/* intentional: neutral gray track works against any --color-surface value in either theme, same as .btn-spinner above (issue #116) */
.save-badge .spin {
  width: 12px; height: 12px; border-radius: 50%;
  border: 2px solid rgba(127,127,127,0.2);
  border-top-color: var(--color-accent);
  animation: spin 1s linear infinite;
}
/* issue #155 v2 Phase D3 (full completion pass) — app-wide save-status spinner. */
:root[data-theme='dark'] .save-badge .spin { border-top-color: var(--color-accent); }

/* Toasts */
.toast-stack {
  position: fixed;
  bottom: calc(20px + env(safe-area-inset-bottom));
  left: 50%;
  transform: translateX(-50%);
  z-index: 300;
  display: grid;
  gap: 8px;
  pointer-events: none;
}
/* issue #206 §3.8 — was a flat solid-fill pill (999px radius, white text on a
   saturated background); now a --color-surface card with a left-edge 3px
   accent bar colored by type, matching the modal/sidebar language elsewhere
   in this pass. Used a real `border-left` rather than an inset box-shadow —
   the toast has no other border to conflict with (unlike .nav-item.active,
   which already had layout-sensitive padding), and a border naturally
   respects border-radius clipping with no extra care needed. Switched
   border-radius from the old full pill (999px) to --radius-md: a sharp
   left-edge color bar reads as a cut corner against a fully rounded pill,
   the same reasoning as the sidebar active-item shape change in this pass —
   flagging this shape change. */
/* issue #206 §5 — entrance/exit timing switched from a hardcoded 220ms/ease
   to the shared duration/ease tokens (the spec's "only duration/ease custom
   property tokens" hard limit), matching the modal entrance's own duration-enter/
   ease-spring pairing in spirit: slide+fade in on show, a quicker fade+slide
   back on dismiss. `toast.js`'s own setTimeout delays (260ms before the DOM
   remove) stay in sync with `--duration-base`, not restated here since JS
   can't read a CSS custom property without an extra getComputedStyle call
   for a one-line constant — see that file's own comment. */
/* issue #206 §5 follow-up — real feedback (screenshot): `--color-surface`
   (near-white in light theme) has almost no contrast against `--color-bg`
   (also near-white) with only a `box-shadow` for separation — the toast
   read as barely-there plain text floating on the page rather than a
   card. A full 1px `--color-border` outline gives it a crisp edge
   regardless of how close the two background tokens happen to be, on top
   of (not instead of) the existing shadow and left accent bar. */
.toast {
  background: var(--color-surface);
  color: var(--color-text);
  border: 1px solid var(--color-border);
  border-left: 3px solid var(--color-accent);
  padding: 12px 18px;
  border-radius: var(--radius-md);
  font-size: var(--text-base);
  font-weight: 700;
  box-shadow: var(--shadow-lg);
  opacity: 0;
  transform: translateY(10px);
  transition: opacity var(--duration-base) var(--ease-decelerate), transform var(--duration-base) var(--ease-decelerate);
}
.toast.show { opacity: 1; transform: translateY(0); }
.toast:not(.show) { transition-timing-function: var(--ease-accelerate); transition-duration: var(--duration-fast); }
.toast-success { border-left-color: var(--color-success); }
.toast-info { border-left-color: var(--color-accent); }
.toast-error { border-left-color: var(--color-danger); }

/* ==========================================================================
   Settings page (issue #16) — reuses the app-shell-2/sidebar/topbar chrome
   dashboard.js already established (same `.app-shell-2` outer class); only
   the content region below the topbar is new here.
   ========================================================================== */

.settings-content { padding: var(--space-6); display: grid; gap: var(--space-6); max-width: 720px; }
.settings-header h1 { margin: 0; font-size: var(--text-xl); }

.settings-sections { display: grid; gap: var(--space-6); }

/* v3 (issue #416 Phase 4 — #428): wires the .card glass primitive (#420)
   into the settings ledger's per-section cards — same surface-recolor
   pattern .phase-card/.progress-card/.kpi-tile above use. Supersedes the
   v2 flat-surface rule this comment used to document. */
.settings-section {
  position: relative;
  background: hsl(var(--v3-surface) / 0.85);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  border: 1px solid hsl(var(--v3-divider) / 0.6);
  border-radius: var(--v3-radius-lg);
  box-shadow: 0 16px 36px -26px hsl(var(--v3-accent) / 0.25);
  padding: var(--space-6);
  display: grid;
  gap: var(--space-4);
}
@supports not (backdrop-filter: blur(1px)) {
  .settings-section { background: var(--color-surface-raised); }
}
.settings-section-title { margin: 0; font-size: var(--text-lg); }
.settings-section-subtitle { margin: -8px 0 0; color: var(--color-text-muted); }

/* A checkbox-driven toggle switch (issue #495) — this app's first, matching
   the design reference's "Animations"/"Daily reminders" style row instead of
   a plain checkbox. A real `input[type=checkbox]` (keyboard/screen-reader
   operable) sits visually hidden inside `.switch`; `.switch-track` is a
   sibling `<span>` carrying the visible track/thumb via `::after` — never a
   pseudo-element directly on the input itself, which several browsers don't
   reliably render on a form control with `appearance: none`. */
.switch { position: relative; display: inline-flex; width: 44px; height: 26px; flex-shrink: 0; }
.switch-input {
  position: absolute;
  inset: 0;
  margin: 0;
  opacity: 0;
  cursor: pointer;
}
.switch-track {
  position: absolute;
  inset: 0;
  border-radius: var(--radius-lg);
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border);
  pointer-events: none;
  transition: background-color var(--duration-fast) var(--ease-standard), border-color var(--duration-fast) var(--ease-standard);
}
.switch-track::after {
  content: '';
  position: absolute;
  top: 2px;
  left: 2px;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--color-text-muted);
  transition: transform var(--duration-fast) var(--ease-standard), background-color var(--duration-fast) var(--ease-standard);
}
.switch-input:checked + .switch-track { background: var(--color-accent-600); border-color: var(--color-accent-600); }
.switch-input:checked + .switch-track::after { transform: translateX(18px); background: var(--color-ink-on-accent); }
.switch-input:focus-visible + .switch-track { outline: 2px solid var(--color-accent); outline-offset: 2px; }

/* A single row's label + control sit on one line on wide viewports and stack
   on narrow ones — the same "don't rely on equal source lengths" caution as
   .card-grid layouts elsewhere (ui-styling.md), just for a horizontal row
   instead of a vertical stack. */
.settings-row { display: grid; gap: var(--space-2); }
.settings-row-main { display: flex; align-items: center; justify-content: space-between; gap: var(--space-3); flex-wrap: wrap; }
.settings-row-label { font-weight: 700; color: var(--color-text); }
.settings-row-label-group { display: inline-flex; align-items: center; gap: var(--space-2); }
.settings-row-value { color: var(--color-text-muted); font-size: var(--text-base); margin-right: auto; }
.settings-row-hint { color: var(--color-text-muted); font-size: 12px; margin: 0; }
/* v3 (issue #416 Phase 4 — #428): design-system.md §2's paragraph-size
   accent-text rule — --v3-accent-ink in light mode (--v3-accent itself
   already clears AA in dark mode, no separate ink token needed there). */
.settings-verified { color: hsl(var(--v3-accent-ink)); font-weight: 700; }
:root[data-theme='dark'] .settings-verified { color: hsl(var(--v3-accent)); }
.settings-unverified { color: var(--color-p1-ink); font-weight: 700; }

.settings-select { max-width: 200px; }

.settings-inline-form {
  border-top: 1px solid var(--color-border);
  padding-top: var(--space-4);
}
.settings-inline-form-body { display: grid; gap: var(--space-3); max-width: 380px; }

.danger-zone { border-color: var(--color-danger); }
.danger-zone .settings-section-title { color: var(--color-danger); }

/* issue #206 §3.2 — base card treatment; kept --space-8 padding (bigger than
   the --space-6 default) since this is a deliberately emphasized guest CTA
   card, not a standard content card. */
.settings-guest-card {
  position: relative;
  background: hsl(var(--v3-surface) / 0.85);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  border: 1px solid hsl(var(--v3-divider) / 0.6);
  border-radius: var(--v3-radius-lg);
  box-shadow: 0 16px 36px -26px hsl(var(--v3-accent) / 0.25);
  padding: var(--space-8);
  display: grid;
  gap: var(--space-3);
  justify-items: start;
  max-width: 480px;
}
@supports not (backdrop-filter: blur(1px)) {
  .settings-guest-card { background: var(--color-surface-raised); }
}
.settings-guest-card h2 { margin: 0; }
.settings-guest-card p { margin: 0; color: var(--color-text-muted); }

@media (max-width: 480px) {
  .settings-row-main { flex-direction: column; align-items: flex-start; }
  .settings-select { max-width: 100%; width: 100%; }
}

/* ==========================================================================
   Progress page (issue #8) — reuses the app-shell-2/sidebar/topbar chrome
   dashboard.js/settings.js already established; only the content region
   below the topbar is new here.
   ========================================================================== */

/* grid-template-columns: minmax(0, 1fr) explicitly, not left implicit
   (issue #8 follow-up) — an implicit single-column grid track's automatic
   size is content-based (max-content), so this card list was rendering at
   its widest child's content width (~840px, the heatmap) regardless of the
   container's own real width, forcing the whole page wider than the
   viewport on a narrow screen. Found live via a phone-width screenshot;
   .progress-card's own `min-width: 0` alone wasn't enough — the track
   itself needs the explicit minimum. */
.progress-content { padding: var(--space-6); display: grid; grid-template-columns: minmax(0, 1fr); gap: var(--space-6); max-width: 1160px; margin: 0 auto; width: 100%; align-content: start; }

.progress-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; }
/* C2 (issue #494) scope item 1 — one derived display line replaces the old
   static "Progress" h1 + subtitle pair; see buildHeroStatement() (progress.js). */
.progress-hero-statement { margin: 0; font-size: var(--text-2xl); font-weight: 700; max-width: 640px; }
.progress-header-actions { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
/* Real, reported mismatch (issue #309, same shape as .app-topbar-status's
   fix above): the "This Week/This Month/All Time" `.filter-chip`s (36px)
   and the "Share progress" `.btn-sm` (34px) sat 2px apart in the same row. */
.progress-header-actions .filter-chip,
.progress-header-actions > .btn {
  height: 36px;
  box-sizing: border-box;
}
.progress-range-toggle { display: flex; gap: var(--space-2); }

/* v3 (issue #416 Phase 4 — #428): wires the .card glass primitive (#420)
   into the Progress page's stat/heatmap/chart/table cards — same
   surface-recolor pattern .phase-card (Phase 2) used, not the shared .card
   class itself, since this card has no need for .card's hairline-gradient
   ::before. */
.progress-card {
  position: relative;
  /* issue #455 — stay close to --color-bg brightness; see .phase-card. */
  background: hsl(var(--v3-surface));
  border: 1px solid var(--color-divider);
  border-radius: var(--v3-radius-lg);
  padding: var(--space-6);
  /* .progress-content has no explicit grid-template-columns, so its single
     implicit column defaults to `auto` sizing — a grid item's own default
     min-width is its content's min-content size, so the ~800px-wide
     heatmap/charts inside this card would otherwise force the whole grid
     track (and the page) wider than the viewport instead of letting the
     heatmap's own .heatmap-scroll (and the chart/table wrap containers)
     scroll internally. Same fix as .app-shell-main's own min-width: 0. */
  min-width: 0;
}
.progress-card-title { margin: 0 0 var(--space-4); font-size: var(--text-lg); }

/* Activity heatmap (B3) — see src/ui/components/heatmap.js for why this is
   plain HTML/CSS Grid, not literal SVG. Cell size/gap match the issue's own
   spec (12x12px, 3px gap). */
.heatmap { display: flex; flex-direction: column; gap: var(--space-3); }
.heatmap-body { display: flex; gap: 6px; }
/* Right padding so the final month label (e.g. "Jul") has room to render
   past its 12px column instead of being clipped by the scroll container's
   own edge — found live, the label reads fine everywhere except the very
   last column. `min-width: 0` overrides a flex item's default `auto` min
   width (its own content size) — without it, this ~800px-wide grid forced
   `.heatmap-body` (and the whole page) wider than the viewport instead of
   scrolling within its own box, found live on a phone-width screenshot. */
.heatmap-scroll { overflow-x: auto; padding-bottom: 2px; padding-right: 28px; min-width: 0; }
.heatmap-months { display: grid; grid-auto-flow: column; grid-auto-columns: 12px; gap: 3px; margin-bottom: 4px; }
.heatmap-month-label { font-size: var(--text-base); color: var(--color-text-muted); white-space: nowrap; }
.heatmap-day-labels { display: grid; grid-template-rows: repeat(7, 12px); gap: 3px; width: 26px; flex-shrink: 0; margin-top: 18px; }
.heatmap-day-label { font-size: var(--text-base); color: var(--color-text-muted); line-height: 12px; }
.heatmap-grid { display: grid; grid-auto-flow: column; grid-template-rows: repeat(7, 12px); grid-auto-columns: 12px; gap: 3px; }
/* v3 (issue #416 Phase 4 — #428): design-system.md §5's green heat ramp,
   replacing v2's hard-edged-square red ramp above — --v3-radius-sm swaps
   back in for the small-control corner (v2 zeroed radius app-wide; v3 §4's
   3-step radius scale applies to every control, this one included). */
.heatmap-cell { width: 12px; height: 12px; border-radius: var(--v3-radius-sm); background: hsl(var(--v3-heat-0)); padding: 0; border: none; cursor: default; }
.heatmap-cell[data-level="1"] { background: hsl(var(--v3-heat-1)); }
.heatmap-cell[data-level="2"] { background: hsl(var(--v3-heat-2)); }
.heatmap-cell[data-level="3"] { background: hsl(var(--v3-heat-3)); }
.heatmap-cell[data-level="4"] { background: hsl(var(--v3-heat-4)); }
.heatmap-cell[data-today="true"] { outline: 1.5px solid var(--color-text); outline-offset: -1.5px; }
.heatmap-legend { display: flex; align-items: center; gap: 6px; font-size: var(--text-base); color: var(--color-text-muted); }
.heatmap-legend-swatch { width: 10px; height: 10px; border-radius: var(--v3-radius-sm); background: hsl(var(--v3-heat-0)); }
.heatmap-legend-swatch[data-level="1"] { background: hsl(var(--v3-heat-1)); }
.heatmap-legend-swatch[data-level="2"] { background: hsl(var(--v3-heat-2)); }
.heatmap-legend-swatch[data-level="3"] { background: hsl(var(--v3-heat-3)); }
.heatmap-legend-swatch[data-level="4"] { background: hsl(var(--v3-heat-4)); }
/* Row-only grid placement (see heatmap.js) — column is left to
   `grid-auto-flow: column`'s own auto-placement in document order. */
.gr-1 { grid-row: 1; }
.gr-2 { grid-row: 2; }
.gr-3 { grid-row: 3; }
.gr-4 { grid-row: 4; }
.gr-5 { grid-row: 5; }
.gr-6 { grid-row: 6; }
.gr-7 { grid-row: 7; }

/* issue #155 v2 Phase B — chartWrapper.js's `createBucketedBarChart()`/
   `createChartLegend()` support (the reference's multi-color value-bucketed bar chart
   + floating tooltip + legend row). Built and visually verified in isolation this
   phase; no page calls the new chartWrapper.js exports yet — that's Phase C/D's job.
   `.chart-tooltip` is deliberately a fixed white-bg/dark-text card in both themes, not
   theme-tokened — the reference shows a small white rounded-rect tooltip with dark
   text regardless of page theme, the same "fixed regardless of site theme" reasoning
   already used for `.auth-marketing`'s always-dark panel (issue #116). */
.chart-tooltip {
  position: absolute;
  pointer-events: none;
  z-index: 5;
  padding: 8px 12px;
  border-radius: var(--radius-md);
  /* intentional: fixed white card + dark text, same in both themes — mirrors the
     reference's tooltip exactly rather than following site theme (issue #155 v2). */
  background: #ffffff;
  color: #0f172a;
  font-size: var(--text-base);
  font-weight: 700;
  box-shadow: var(--shadow-md);
  opacity: 0;
  top: 0;
  left: 0;
  transform: translate(-50%, calc(-100% - 10px));
  transition: opacity var(--duration-fast) ease;
  white-space: nowrap;
}
.chart-tooltip.visible { opacity: 1; }
.chart-tooltip-date { display: block; font-weight: 800; margin-bottom: 2px; }
/* intentional: paired with the fixed white tooltip background above — a muted gray
   that reads correctly only against white, not a theme token (issue #155 v2). */
.chart-tooltip-value { display: block; color: #475569; }
.chart-tooltip::after {
  content: '';
  position: absolute;
  left: 50%;
  bottom: -5px;
  width: 10px;
  height: 10px;
  transform: translateX(-50%) rotate(45deg);
  /* intentional: matches the fixed white tooltip body above (issue #155 v2). */
  background: #ffffff;
}

.chart-legend { display: flex; flex-wrap: wrap; gap: 16px; margin-top: 8px; }
.chart-legend-item { display: inline-flex; align-items: center; gap: 6px; font-size: var(--text-base); color: var(--color-text-muted); font-weight: 600; }
.chart-legend-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
/* Per-bucket dot colors as discrete classes, not an inline `style` (index.html's CSP
   has no `unsafe-inline` for style-src — see the "Never set an inline style" rule
   above) — mirrors chartWrapper.js's own BUCKET_TOKENS mapping so the legend dots and
   the bars they describe always agree. */
/* issue #206 — --color-brand-gold is now defined in both themes (the
   Alpenglow token layer replaced the old dark-theme-only lime token), so the
   var() fallback this rule used to need is dead; dropped. */
/* v3 (issue #416 Phase 4 — #428): repointed to the v3 accent token, matching
   chartWrapper.js's own BUCKET_TOKENS.high change above. */
.chart-legend-dot-high { background: hsl(var(--v3-accent)); }
.chart-legend-dot-medium { background: var(--color-text-faint); }
.chart-legend-dot-low { background: var(--color-border-strong); }

/* Chart containers (B4/B5) — Chart.js is given a fixed-height box with
   `maintainAspectRatio: false` (see chartWrapper.js) since a canvas has no
   intrinsic height of its own to size a CSS Grid/flex row from. */
.chart-container { position: relative; height: 260px; }
.chart-container canvas { width: 100% !important; height: 100% !important; }
/* issue #206 §6 — flat-zero chart empty-state note (progress.js's velocityEmptySlot).
   A small solid gold dot, not a full illustration. Was a gradient dot;
   reverted to solid per the app-wide "no gradients" decision. */
.chart-empty-note { display: flex; align-items: center; gap: var(--space-2); margin: 0 0 var(--space-3); font-size: var(--text-base); color: var(--color-text-muted); }
/* v3 (issue #416 Phase 4 — #428): repointed to the v3 accent token. */
.chart-empty-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; background: hsl(var(--v3-accent)); }
/* Issue #125 — a skeleton placeholder for the first Chart.js CDN load only
   (chartWrapper.js's dynamic import), shown/hidden by progress.js rather
   than CSS alone since it must disappear the instant the real chart mounts,
   not on a fixed timer. */
.chart-container .skeleton-card { position: absolute; inset: 0; height: 100%; }
.chart-canvas-loading { display: none; }

/* C2 (issue #494) scope item 4 — "+N this week" headline figure, next to the
   "Cumulative progress" card title (progress.js's lineHeadlineSlot). */
.progress-card-title-row { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-3); margin-bottom: var(--space-4); flex-wrap: wrap; }
.progress-card-title-row .progress-card-title { margin-bottom: 0; }
.chart-headline-figure { margin: 0; font-size: var(--text-lg); font-weight: 700; color: var(--color-accent-700); }
:root[data-theme='dark'] .chart-headline-figure { color: var(--color-accent-700); }
.progress-card-title-hint { margin: 0; font-size: var(--text-base); color: var(--color-text-muted); }

/* Phase breakdown (B6, merged with the old priority table in issue #494's
   C2) — a flat list of horizontal bars, deliberately not .phase-card (that
   class carries expand/collapse chrome this list doesn't need). The bar
   itself is an inline SVG rect with a `width` *attribute* (not inline
   `style`, per the CSP rule) driven by setAttribute, same pattern
   progressRing.js already uses for its own dynamic stroke-dashoffset.
   `.phase-breakdown-list` carries a left-hand spine rule (`::before`) that
   every row's `.phase-breakdown-spine-dot` sits on top of — a flow-positioned
   cousin of the dashboard's own JS-pixel-measured `.phase-spine` (issue
   #492, `.claude/rules/ui-styling.md`); these rows are uniform-height flex
   items, so no per-row pixel measurement is needed to thread them together. */
.phase-breakdown-list { display: grid; gap: var(--space-3); align-content: start; position: relative; padding-left: 4px; }
.phase-breakdown-list::before {
  content: '';
  position: absolute;
  top: 10px;
  bottom: 10px;
  left: 9px;
  width: 2px;
  background: var(--color-border);
  border-radius: 1px;
}
.phase-breakdown-row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--space-3);
  width: 100%;
  background: none;
  border: none;
  padding: var(--space-2) 0;
  text-align: left;
  font: inherit;
  color: inherit;
  cursor: pointer;
  border-radius: 6px;
  position: relative;
}
.phase-breakdown-row:hover, .phase-breakdown-row:focus-visible { background: var(--color-surface-raised); outline: none; }
.phase-breakdown-spine-dot { position: relative; z-index: 1; width: 10px; height: 10px; border-radius: 999px; flex-shrink: 0; background: var(--color-bg); border: 2px solid var(--color-border-strong); }
.phase-breakdown-spine-dot[data-priority="P0"] { border-color: var(--color-p0); }
.phase-breakdown-spine-dot[data-priority="P1"] { border-color: var(--color-p1); }
.phase-breakdown-spine-dot[data-priority="P2"] { border-color: var(--color-p2); }
.phase-breakdown-spine-dot[data-priority="P3"] { border-color: var(--color-p3); }
.phase-breakdown-spine-dot[data-complete="true"] { background: var(--color-accent); border-color: var(--color-accent); }
.phase-breakdown-main { display: grid; gap: 4px; min-width: 0; }
.phase-breakdown-name { font-weight: 600; font-size: var(--text-base); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.phase-breakdown-count { font-size: var(--text-base); color: var(--color-text-muted); font-variant-numeric: tabular-nums; white-space: nowrap; }

/* Generic linear progress bar (issue #8) — reused by the phase-breakdown
   rows above and the "Items complete" stat tile below. An inline SVG rect
   with its `width` set via `setAttribute` (never inline `style`), same
   pattern progressRing.js already uses for its own dynamic stroke-dashoffset. */
.mini-bar { width: 100%; height: 8px; display: block; }
.mini-bar-track { fill: var(--color-border-strong); }
.mini-bar-fill { fill: var(--color-accent); }
/* issue #155 v2 Phase D3 (full completion pass) — base rule, used by every mini-bar
   outside progress.js's already-recolored .kpi-tile-hero context (Phase D1). */
:root[data-theme='dark'] .mini-bar-fill { fill: var(--color-accent); }
/* intentional: v3 (issue #416 Phase 4 — #428) — .kpi-tile-hero's fill is now
   a light-green gradient with fixed black text (see .kpi-tile-hero's own
   comment above); a plain --color-bg (near-white) mini-bar-fill would
   nearly disappear against it, so this reads a fixed dark tint instead,
   matching the black-text treatment rather than the old solid-accent-fill
   era's white-on-dark logic. */
.kpi-tile-hero .mini-bar-fill { fill: rgba(0, 0, 0, 0.55); }
/* intentional: matches .kpi-tile-hero .card-arrow-badge's black-tint-on-solid-accent
   treatment above (issue #416 Phase 4). */
.kpi-tile-hero .mini-bar-track { fill: rgba(0, 0, 0, 0.14); }
.stat-tile-bar { margin-top: 6px; }

/* Projected completion (B8) — C2 (issue #494) scope item 6 rebuilt this into
   the page's closing statement: a large date first, a supporting pace line,
   and the "speed up" note in an accent-*tinted* strip rather than a full
   accent block (the hero KPI tile already carries this page's one
   full-accent surface — see §9's "exactly one full-accent surface per
   viewport" review rule). */
.projection-card-body { display: grid; gap: var(--space-2); justify-items: center; text-align: center; }
.projection-headline { font-size: var(--text-3xl); font-weight: 800; color: var(--color-text); }
.projection-pace { color: var(--color-text-muted); font-size: var(--text-base); }
.projection-boost-strip {
  margin-top: var(--space-2);
  width: 100%;
  padding: var(--space-3) var(--space-4);
  border-radius: var(--radius-md);
  background: var(--color-accent-100);
  color: var(--color-accent-700);
  font-size: var(--text-base);
  font-weight: 600;
}
:root[data-theme='dark'] .projection-boost-strip { color: var(--color-accent-700); }
.projection-empty { color: var(--color-text-muted); text-align: center; }

/* Roadmap comparison modal (issue #285, restyled per design-system.md v3 in
   issue #504 — E4 of the responsive-redesign batch, matching the "Compare
   roadmaps" panel in that issue's design reference). Reuses .modal-card
   (glass overlay, unchanged) — everything below this comment is new/replaced
   for #504; the flush-left/radius-0 v2 shape it used to have is gone. */
.comparison-modal-card { width: min(640px, 92vw); max-height: 85vh; display: flex; flex-direction: column; gap: var(--space-4); }
.comparison-modal-subtitle { color: var(--color-text-muted); font-size: var(--text-base); }
.comparison-modal-subtitle strong { color: var(--color-text); }

/* Full-width stacked pill buttons (design reference: "Its starter template" /
   "Another roadmap"), not a side-by-side chip row — reuses .filter-chip's own
   pill radius/hover-highlight precedent (`--radius-full`, unchanged by the
   §4 button-radius move since a chip/toggle pill is a different category from
   a form-adjacent button) but full width and taller, matching the reference. */
.comparison-mode-toggle { display: flex; flex-direction: column; gap: 8px; }
.comparison-mode-btn {
  position: relative;
  width: 100%;
  min-height: var(--tap-min, 48px);
  padding: 10px 18px;
  border-radius: var(--radius-full);
  border: 1px solid var(--color-divider);
  background: hsl(var(--v3-surface));
  color: var(--color-text);
  font: inherit;
  font-size: var(--text-base);
  font-weight: 700;
  cursor: pointer;
  overflow: hidden;
  transition: transform var(--duration-fast) var(--ease-out), border-color var(--duration-fast) var(--ease-out), background-color var(--duration-fast) var(--ease-out);
}
.comparison-mode-btn:hover:not(:disabled) { transform: translateY(-1px); border-color: hsl(var(--v3-accent) / 0.4); }
.comparison-mode-btn:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.comparison-mode-btn.active {
  background: hsl(var(--v3-accent));
  border-color: hsl(var(--v3-accent));
  color: var(--v3-accent-ink, var(--color-accent-700));
}
:root[data-theme='dark'] .comparison-mode-btn.active { color: var(--color-bg); }
.comparison-mode-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.comparison-mode-reason { margin: -2px 0 0; font-size: var(--text-base); color: var(--color-text-muted); }

.comparison-other-select { display: flex; flex-direction: column; gap: 6px; }
.comparison-result-slot { overflow-y: auto; border-top: 1px solid var(--color-divider); padding-top: var(--space-4); }
.comparison-loading, .comparison-empty { color: var(--color-text-muted); text-align: left; }

/* Four-cell summary strip (done in both / only in yours / only in theirs /
   total) — a flat 2x2 grid with hairline dividers, matching the reference. */
.comparison-summary {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  border: 1px solid var(--color-divider);
  border-radius: var(--radius-md);
  overflow: hidden;
  margin-bottom: var(--space-4);
}
.comparison-summary-item {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 12px 14px;
  border-left: 1px solid var(--color-divider);
  border-top: 1px solid var(--color-divider);
  text-align: left;
}
.comparison-summary-item:nth-child(odd) { border-left: none; }
.comparison-summary-item:nth-child(-n+2) { border-top: none; }
.comparison-summary-value { font-size: var(--text-2xl); font-weight: 800; color: var(--color-text); font-variant-numeric: tabular-nums; }
.comparison-summary-item:first-child .comparison-summary-value { color: var(--v3-accent-ink, var(--color-accent-700)); }
.comparison-summary-label { font-size: var(--text-base); color: var(--color-text-muted); }

.comparison-phase-groups { display: flex; flex-direction: column; gap: var(--space-4); }
.comparison-phase-title {
  margin: 0 0 4px;
  font-size: var(--text-base);
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: var(--tracking-caps);
  color: var(--color-text-muted);
  text-align: left;
}
.comparison-row-list { list-style: none; margin: 0; padding: 0; border-top: 1px solid var(--color-divider); }

/* 48px rows: a status dot from the accent ramp, the topic title (truncated,
   never wraps the row taller than 48px), and a plain-language status. */
.comparison-row {
  display: flex;
  align-items: center;
  gap: 10px;
  min-height: 48px;
  padding: 0;
  border-bottom: 1px solid var(--color-divider);
  text-align: left;
}
.comparison-dot { flex-shrink: 0; width: 8px; height: 8px; border-radius: 50%; }
.comparison-dot-done { background: hsl(var(--v3-accent)); }
.comparison-dot-partial { background: hsl(var(--v3-accent) / 0.5); }
.comparison-dot-added { background: transparent; border: 1.5px solid hsl(var(--v3-accent) / 0.55); }
.comparison-dot-neutral { background: var(--color-text-muted); opacity: 0.55; }
.comparison-row-title {
  flex: 1;
  min-width: 0;
  color: var(--color-text);
  font-size: var(--text-base);
  font-weight: 600;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.comparison-row-status { font-size: var(--text-base); color: var(--color-text-muted); white-space: nowrap; flex-shrink: 0; }
.comparison-row-status-both-done { color: var(--v3-accent-ink, var(--color-accent-700)); font-weight: 600; }

@media (max-width: 768px) {
  .progress-content { padding: var(--space-4); }
  .progress-header { flex-direction: column; align-items: stretch; }
  .chart-container { height: 220px; }
}

@media (max-width: 480px) {
  .progress-card { padding: var(--space-4); }
}

/* ==========================================================================
   Landing page (issue #6 Phase 6) — marketing page for signed-out visitors
   at '#/'. Self-contained section; nothing here is reused by any other page.
   ========================================================================== */

/* v3 (issue #416 Phase 3) — design-system.md §4's ambient background wash,
   scoped to this marketing page only ("wash on marketing pages,
   flat-with-glass-cards on app pages" — the dashboard/settings/progress
   shell stays flat). Three soft radial accent washes over the base
   --color-bg, per the portfolio's own `body` rule. */
.landing-page {
  min-height: 100vh;
  min-height: 100dvh;
  overflow-x: hidden;
  position: relative;
  background:
    radial-gradient(circle at 10% -10%, hsl(var(--v3-accent) / 0.1), transparent 45%),
    radial-gradient(circle at 90% -10%, hsl(var(--v3-gradient-end) / 0.1), transparent 45%),
    radial-gradient(circle at 50% 120%, hsl(var(--v3-accent) / 0.08), transparent 50%),
    var(--color-bg);
}

/* Decorative floating blurred orbs behind the hero (design-system.md §7) —
   aria-hidden, absolutely positioned, purely ambient. Fully disabled (not
   just paused) under prefers-reduced-motion via the app-wide catch-all at
   the bottom of this file. */
.landing-orb {
  position: absolute;
  border-radius: 50%;
  filter: blur(60px);
  pointer-events: none;
  z-index: 0;
  background: hsl(var(--v3-accent) / 0.25);
}
.landing-orb-1 {
  width: 280px;
  height: 280px;
  top: -60px;
  left: -40px;
  animation: float 6s ease-in-out infinite;
}
.landing-orb-2 {
  width: 220px;
  height: 220px;
  bottom: -40px;
  right: 4%;
  background: hsl(var(--v3-gradient-end) / 0.22);
  animation: floatSlow 10s ease-in-out infinite;
}
@keyframes float {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-8px); }
}
@keyframes floatSlow {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-14px); }
}

/* v3 (issue #416 Phase 3) — scroll-reveal at-rest/revealed states
   (design-system.md §7, driven by src/ui/utils/scrollReveal.js). */
.reveal {
  opacity: 0;
  transform: translateY(28px);
}
.reveal.is-revealed {
  opacity: 1;
  transform: translateY(0);
  transition: opacity 450ms, transform 450ms;
  transition-timing-function: cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
.reveal-delay-1 { transition-delay: 0.05s; }
.reveal-delay-2 { transition-delay: 0.15s; }
.reveal-delay-3 { transition-delay: 0.25s; }
.reveal-delay-4 { transition-delay: 0.35s; }

/* issue #299 — design-system.md's flat "2px-ruled header bar" nav, replacing
   the pre-#297 floating/bordered/blurred pill-shaped bar (chip-radius, glass
   surface, backdrop-filter — none of which fit the flat Modernist identity). */
/* v3 (issue #416 Phase 3 follow-up) — was a centered 1160px-max-width bar
   with an opaque flat --color-bg fill, which read as a hard-edged rectangle
   floating against .landing-page's ambient gradient wash bleeding around it
   on either side (a real, reported visual bug, not a stylistic choice).
   .landing-nav is now a full-bleed glass bar (matching the app-shell
   topbar's own blur-xl treatment, design-system.md §5); .landing-nav-inner
   carries the old centered max-width layout so the nav's *content* still
   aligns with the hero/sections below it. */
.landing-nav {
  position: sticky;
  top: 0;
  z-index: 20;
  background: hsl(var(--v3-surface) / 0.7);
  -webkit-backdrop-filter: blur(20px);
  backdrop-filter: blur(20px);
  border-bottom: 1px solid hsl(var(--v3-divider) / 0.6);
}
@supports not (backdrop-filter: blur(1px)) {
  .landing-nav { background: var(--color-surface-raised); }
}
.landing-nav-inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-4);
  max-width: 1160px;
  margin: 0 auto;
  padding: var(--space-4) var(--space-6);
}
.landing-nav-links { display: flex; gap: var(--space-6); }
.landing-nav-link {
  background: none;
  border: none;
  color: var(--color-text-muted);
  font: inherit;
  font-weight: 600;
  font-size: var(--text-base);
  cursor: pointer;
  padding: 0;
}
.landing-nav-link:hover { color: var(--color-text); }
.landing-nav-link:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.landing-nav-actions { display: flex; align-items: center; gap: var(--space-3); }
/* issue #500 (D4) — theme toggle lives in the nav now, matching the design
   reference; reuses the shared .btn-icon 36px sizing, no new size rule
   needed. */
.landing-nav-theme-toggle { flex-shrink: 0; }

/* issue #155 (ZeBeyond direction) — position:relative so .landing-hero-copy/
   .landing-hero-mock's z-index: 1 has a containing block to stack above the
   orbs/wash behind them. v3 (issue #416 Phase 3 follow-up) — dropped the
   overflow: hidden this rule used to carry for the (now-unused) .bg-grid-glow
   layer: with .landing-orb-1/-2 (blurred, 60px) as children of this section,
   that clip hard-cropped their blur at the section's own rectangular edge,
   reading as a visible rectangular "box" seam against .landing-page's ambient
   wash behind it — a real, reported visual bug, not a stylistic choice.
   .landing-page's own overflow-x: hidden is still the horizontal-scroll
   safety net; letting this section's overflow stay visible is what lets the
   orbs' blur bleed smoothly into the page wash instead of clipping. */
.landing-hero {
  display: grid;
  grid-template-columns: 1.05fr 0.95fr;
  align-items: center;
  gap: var(--space-12);
  max-width: 1160px;
  margin: 0 auto;
  padding: var(--space-12) var(--space-6) var(--space-16);
  position: relative;
}
.landing-hero-copy { position: relative; z-index: 1; }
.landing-hero-eyebrow { margin: 0 0 var(--space-4); }
.landing-hero-title {
  font-family: var(--font-heading-v3, var(--font-display));
  font-weight: 700;
  font-size: clamp(40px, 5vw, 72px);
  line-height: 1.1;
  letter-spacing: -0.02em;
  margin: 0 0 var(--space-4);
  color: var(--color-text);
}
.landing-hero-subtitle {
  font-family: var(--font-body-v3, var(--font-body));
  font-size: var(--text-lg);
  line-height: var(--leading-loose);
  color: var(--color-text-muted);
  margin: 0 0 var(--space-8);
  max-width: 46ch;
}
.landing-hero-actions { display: flex; flex-wrap: wrap; gap: var(--space-3); margin-bottom: var(--space-6); }
.landing-hero-stat { font-size: var(--text-base); color: var(--color-text-muted); margin: 0; }

.landing-hero-mock { display: flex; justify-content: center; position: relative; z-index: 1; }
.landing-mock-card {
  width: 100%;
  max-width: 380px;
  background: hsl(var(--v3-surface) / 0.85);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  border: 1px solid hsl(var(--v3-divider) / 0.6);
  border-radius: var(--v3-radius-lg);
  box-shadow: var(--v3-shadow-glow);
  overflow: hidden;
}
@supports not (backdrop-filter: blur(1px)) {
  .landing-mock-card { background: var(--color-surface-raised); }
}
/* issue #500 (D4) — the hero mock became a real checklist card (identity
   row + progress ring + priority-labeled rows), replacing the earlier
   generic grey-bar mock. Still a pure el()-built decorative mockup, aria-hidden
   and not a real store-driven render — see landing.js's MOCK_ROWS comment. */
.landing-mock-head { display: flex; align-items: center; gap: var(--space-3); padding: var(--space-4) var(--space-6); border-bottom: 1px solid var(--color-border); }
.landing-mock-avatar {
  width: 32px;
  height: 32px;
  flex-shrink: 0;
  display: grid;
  place-items: center;
  border-radius: var(--v3-radius-md);
  background: hsl(var(--v3-accent) / 0.14);
  color: var(--v3-accent-ink, hsl(var(--v3-accent)));
  font-weight: 700;
  font-size: var(--text-base);
}
.landing-mock-head-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; }
.landing-mock-head-title { font-weight: 700; font-size: var(--text-base); color: var(--color-text); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.landing-mock-head-sub { font-size: var(--text-base); color: var(--color-text-muted); }
.landing-mock-ring {
  flex-shrink: 0;
  width: 34px;
  height: 34px;
  display: grid;
  place-items: center;
  border-radius: 50%;
  border: 2px solid hsl(var(--v3-accent));
  color: hsl(var(--v3-accent));
  font-weight: 700;
  font-size: 10px;
}
.landing-mock-body { display: grid; gap: var(--space-3); padding: var(--space-4) var(--space-6); }
.landing-mock-row { display: flex; align-items: center; gap: var(--space-3); }
/* v3 (issue #416 Phase 3) — design-system.md §5 "Checkboxes: rounded
   squares (--radius-sm)... done = accent fill", superseding v2's zero-radius
   square rule (the comment this replaces). Recolored from the v2 red/orange
   --color-accent/--color-warning-bg to the v3 green accent, both themes
   (a single v3-token rule now covers both, replacing the old light/dark
   conditional pair). */
.landing-mock-check {
  width: 18px;
  height: 18px;
  flex-shrink: 0;
  display: grid;
  place-items: center;
  border-radius: var(--v3-radius-sm);
  border: 2px solid hsl(var(--v3-accent) / 0.5);
  color: var(--v3-accent-ink, hsl(var(--v3-accent)));
}
/* intentional: fixed black checkmark on the always-bright green fill, same
   both themes — matches .btn-primary's own "fixed black on bright accent"
   reasoning (design-system.md §5). */
.landing-mock-row.is-done .landing-mock-check { border-color: hsl(var(--v3-accent)); background: hsl(var(--v3-accent)); color: #000; }
.landing-mock-row-text { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; }
.landing-mock-row-title { font-size: var(--text-base); font-weight: 600; color: var(--color-text); }
.landing-mock-row.is-done .landing-mock-row-title { text-decoration: line-through; opacity: 0.55; }
.landing-mock-row-sub { font-size: var(--text-base); color: var(--color-text-muted); }
.landing-mock-foot { padding: var(--space-3) var(--space-6) var(--space-4); font-size: var(--text-base); color: var(--color-text-muted); border-top: 1px solid var(--color-border); }

.landing-section-eyebrow { display: flex; justify-content: center; margin-bottom: var(--space-3); }
.landing-section-title {
  font-family: var(--font-heading-v3, var(--font-display));
  font-weight: 600;
  font-size: clamp(28px, 3vw, 36px);
  letter-spacing: -0.015em;
  line-height: 1.15;
  text-align: center;
  margin: 0 0 var(--space-8);
  color: var(--color-text);
}

.landing-features, .landing-steps { max-width: 1160px; margin: 0 auto; padding: var(--space-12) var(--space-6); }

.landing-feature-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
  align-items: stretch;
  gap: var(--space-6);
}
.feature-card {
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  background: hsl(var(--v3-surface) / 0.85);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  border: 1px solid hsl(var(--v3-divider) / 0.6);
  border-radius: var(--v3-radius-lg);
  box-shadow: 0 16px 36px -26px hsl(var(--v3-accent) / 0.25);
  padding: var(--space-8);
  transition: transform var(--duration-slow, 300ms) var(--ease-out), box-shadow var(--duration-slow, 300ms) var(--ease-out);
}
@supports not (backdrop-filter: blur(1px)) {
  .feature-card { background: var(--color-surface-raised); }
}
.feature-card:hover { transform: translateY(-4px); box-shadow: var(--v3-shadow-glow-lg); }
/* v3 (issue #416 Phase 3) — recolored from the v2 red/orange
   --color-warning-bg/--color-accent-700 tint to a v3 accent-tinted glass
   chip, both themes (was a light-vs-dark-theme conditional pair; a single
   v3-token rule now covers both). */
.feature-card-icon {
  width: 48px;
  height: 48px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: var(--v3-radius-md);
  background: hsl(var(--v3-accent) / 0.12);
  color: var(--v3-accent-ink, hsl(var(--v3-accent)));
  margin-bottom: var(--space-6);
}
.feature-card-title { font-family: var(--font-heading-v3, var(--font-display)); font-weight: 600; font-size: var(--text-lg); margin: 0 0 var(--space-2); color: var(--color-text); }
.feature-card-text { font-size: var(--text-base); line-height: var(--leading-loose); color: var(--color-text-muted); margin: 0; }

.landing-steps-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  align-items: stretch;
  gap: var(--space-6);
  list-style: none;
  padding: 0;
  margin: 0;
}
.step-card {
  height: 100%;
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  position: relative;
  background: hsl(var(--v3-surface) / 0.85);
  -webkit-backdrop-filter: blur(12px);
  backdrop-filter: blur(12px);
  border: 1px solid hsl(var(--v3-divider) / 0.6);
  border-radius: var(--v3-radius-lg);
  box-shadow: 0 16px 36px -26px hsl(var(--v3-accent) / 0.25);
  padding: var(--space-8) var(--space-6) var(--space-6);
  transition: transform var(--duration-slow, 300ms) var(--ease-out), box-shadow var(--duration-slow, 300ms) var(--ease-out);
}
@supports not (backdrop-filter: blur(1px)) {
  .step-card { background: var(--color-surface-raised); }
}
.step-card:hover { transform: translateY(-4px); box-shadow: var(--v3-shadow-glow-lg); }
.step-card-number {
  position: absolute;
  top: var(--space-4);
  right: var(--space-6);
  font-family: var(--font-display);
  font-weight: 800;
  font-size: var(--text-2xl);
  color: var(--color-border-strong);
}
/* v3 (issue #416 Phase 3) — same recolor as .feature-card-icon above. */
.step-card-icon {
  width: 40px;
  height: 40px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 50%;
  background: hsl(var(--v3-accent) / 0.12);
  color: var(--v3-accent-ink, hsl(var(--v3-accent)));
  margin-bottom: var(--space-4);
}
.step-card-title { font-family: var(--font-heading-v3, var(--font-display)); font-weight: 600; font-size: var(--text-base); margin: 0 0 var(--space-2); color: var(--color-text); }
.step-card-text { font-size: var(--text-base); line-height: var(--leading-loose); color: var(--color-text-muted); margin: 0; }

/* issue #500 (D4) — the template strip: a horizontal chip row derived from
   the real TEMPLATES registry, sitting in its own subtly-tinted band between
   the hero and the feature cards, matching the design reference. */
.landing-template-strip {
  background: hsl(var(--v3-surface) / 0.5);
  border-top: 1px solid hsl(var(--v3-divider) / 0.6);
  border-bottom: 1px solid hsl(var(--v3-divider) / 0.6);
  padding: var(--space-8) var(--space-6);
}
.landing-strip-eyebrow { display: flex; justify-content: center; margin-bottom: var(--space-4); }
.landing-template-chips {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: var(--space-3);
  max-width: 960px;
  margin: 0 auto;
}
.landing-template-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) var(--space-4);
  border-radius: 999px;
  background: hsl(var(--v3-surface-elevated));
  border: 1px solid hsl(var(--v3-divider) / 0.6);
  color: var(--color-text);
  font-weight: 600;
  font-size: var(--text-base);
  text-decoration: none;
  transition: transform var(--duration-fast, 150ms) var(--ease-out), border-color var(--duration-fast, 150ms) var(--ease-out);
}
.landing-template-chip:hover { transform: translateY(-2px); border-color: hsl(var(--v3-accent) / 0.5); }
.landing-template-chip:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.landing-template-chip-ai {
  background: hsl(var(--v3-accent) / 0.12);
  border-color: hsl(var(--v3-accent) / 0.4);
  color: var(--v3-accent-ink, hsl(var(--v3-accent)));
}

/* issue #500 (D4) — the progress split: copy + a fact list on the left, a
   decorative mock of the Progress page's stat trio + heatmap on the right,
   the same "illustrative UI chrome" convention buildHeroMock() established. */
.landing-progress {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  align-items: center;
  gap: var(--space-12);
  max-width: 1160px;
  margin: 0 auto;
  padding: var(--space-16) var(--space-6);
}
.landing-progress-copy { position: relative; z-index: 1; }
.landing-progress-title { text-align: left; margin: 0 0 var(--space-4); }
.landing-progress-subtitle { font-size: var(--text-base); line-height: var(--leading-loose); color: var(--color-text-muted); margin: 0 0 var(--space-6); max-width: 46ch; }
.landing-progress-facts { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-3); }
.landing-progress-facts li { display: flex; align-items: flex-start; gap: var(--space-3); font-size: var(--text-base); color: var(--color-text); }
/* intentional: fixed black checkmark on the always-bright green fill, same
   reasoning as .landing-mock-row.is-done .landing-mock-check above. */
.landing-fact-check {
  flex-shrink: 0;
  width: 22px;
  height: 22px;
  display: grid;
  place-items: center;
  margin-top: 1px;
  border-radius: 50%;
  background: hsl(var(--v3-accent));
  color: #000;
}
/* Flat, not glass — a page content surface, not an overlay (design-system.md
   §4's "flat content, elevated overlays" rule, issue #483). */
.landing-progress-mock {
  background: hsl(var(--v3-surface));
  border: 1px solid hsl(var(--v3-divider) / 0.6);
  border-radius: var(--v3-radius-lg);
  padding: var(--space-6);
}
.landing-progress-stats { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-3); margin-bottom: var(--space-4); }
.landing-progress-stat {
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: var(--space-3);
  border-radius: var(--v3-radius-md);
  background: hsl(var(--v3-surface-elevated));
  border: 1px solid hsl(var(--v3-divider) / 0.6);
}
.landing-progress-stat-label { font-size: var(--text-base); color: var(--color-text-muted); font-weight: 600; text-transform: uppercase; letter-spacing: var(--tracking-caps, 0.08em); }
.landing-progress-stat-value { font-family: var(--font-heading-v3, var(--font-display)); font-weight: 700; font-size: var(--text-xl, var(--text-lg)); color: var(--color-text); }
.landing-heat-caption { font-size: var(--text-base); font-weight: 600; color: var(--color-text-muted); text-transform: uppercase; letter-spacing: var(--tracking-caps, 0.08em); margin: 0 0 var(--space-3); }
/* 84 cells = 12 weeks x 7 days (HEAT_PATTERN's own length, landing.js). A
   real calendar heatmap reads top-to-bottom as days-of-week, left-to-right
   as weeks — that's rows fixed at 7 with auto-flow:column adding as many
   implicit *columns* as needed, not the reverse. The previous version fixed
   the *column* count (14) with auto-flow:column, which has no bound on row
   count and unbounded row-count with auto-flow:column means the browser
   keeps adding columns instead of wrapping — every cell rendered in one
   single 84-wide row that overflowed the card (found live, screenshotted).
   minmax(0, 1fr) on the auto column track (not a bare 1fr) so it can still
   shrink below its content size — same "1fr has no width ceiling of its
   own" rule this file's ui-styling.md documents elsewhere. */
.landing-heat-grid {
  display: grid;
  grid-template-rows: repeat(7, 1fr);
  grid-auto-flow: column;
  grid-auto-columns: minmax(0, 1fr);
  gap: 4px;
  overflow: hidden;
}
.landing-heat-cell { aspect-ratio: 1; border-radius: 3px; }
.landing-heat-0 { background: hsl(var(--v3-heat-0)); }
.landing-heat-1 { background: hsl(var(--v3-heat-1)); }
.landing-heat-2 { background: hsl(var(--v3-heat-2)); }
.landing-heat-3 { background: hsl(var(--v3-heat-3)); }
.landing-heat-4 { background: hsl(var(--v3-heat-4)); }

/* issue #500 (D4) — the four-fact privacy strip. `.sr-only-heading` gives
   the section a real accessible heading (WCAG landmark navigation) without
   showing one visually, matching the design reference's headingless layout —
   same visually-hidden-but-present clip technique `.phase-progress.sr-only`
   already uses elsewhere in this file. */
.sr-only-heading { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
.landing-privacy { max-width: 1160px; margin: 0 auto; padding: var(--space-12) var(--space-6); }
.landing-privacy-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
  gap: var(--space-8);
}
.landing-privacy-fact-title { font-family: var(--font-heading-v3, var(--font-display)); font-weight: 600; font-size: var(--text-lg); margin: 0 0 var(--space-2); color: var(--color-text); }
.landing-privacy-fact-text { font-size: var(--text-base); line-height: var(--leading-loose); color: var(--color-text-muted); margin: 0; }

/* issue #299 — design-system.md §2: "the accent runs as a field in exactly
   one pattern: poster statements (landing closing banner). Nowhere else."
   Was a bordered, page-background card (a leftover pre-#297 treatment) —
   now a genuine full-bleed solid-accent poster. Heading text is large
   (--text-3xl, ~39px, weight 800 — comfortably WCAG "large text," only
   needs 3:1) so --color-ink-on-accent on the raw --color-accent field is
   safe at 3.76:1 light / 5.88:1 dark (verified via relative-luminance
   calculation); the button inside inverts to a ground-colored fill so it
   reads as a cutout against the field rather than a same-hue-on-same-hue
   button (accent-700 text on ink-on-accent bg measures 6.41:1 light /
   12.23:1 dark). */
/* v3 (issue #416 Phase 3) — recolored from the v2 flat red/orange
   --color-accent fill to the v3 brand gradient (design-system.md §2's
   "gradients are back and encouraged... primary buttons" — this band is
   effectively one large CTA surface). */
/* issue #500 (D4) — the banner became an inset rounded card (design
   reference: left-aligned copy + subtitle, a white/ground-colored button on
   the right) rather than the previous full-bleed edge-to-edge poster strip;
   still the one "solid accent field" surface on the page per design-system.md
   §2's "poster statements only" rule. */
.landing-cta {
  max-width: 1160px;
  margin: var(--space-8) auto var(--space-16);
  padding: var(--space-8) var(--space-12);
  border-radius: var(--v3-radius-lg);
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: var(--space-6);
  position: relative;
  overflow: hidden;
  background: linear-gradient(135deg, hsl(var(--v3-gradient-start)), hsl(var(--v3-gradient-end)));
}
.landing-cta-copy { position: relative; z-index: 1; display: flex; flex-direction: column; gap: var(--space-2); }
/* intentional: fixed black text on the always-bright gradient fill above,
   same both themes — matches .btn-primary's own "fixed black, same in both
   themes" reasoning (design-system.md §5, issue #416 Phase 1). */
.landing-cta-title { font-family: var(--font-heading-v3, var(--font-display)); font-weight: 700; font-size: var(--text-2xl); letter-spacing: -0.015em; margin: 0; color: #000; max-width: 24ch; }
/* intentional: fixed black, not a diluted/opacity version of it — an alpha
   mix here would blend toward the gradient's own bright green and drop well
   below the black title's already-tight 3.76:1 (light theme) contrast
   margin against this field; a plain, undiluted color at a smaller
   size/weight is what carries the visual de-emphasis instead. */
.landing-cta-subtitle { margin: 0; font-size: var(--text-base); font-weight: 500; color: #000; max-width: 40ch; }
.landing-cta .btn-primary {
  position: relative;
  z-index: 1;
  flex-shrink: 0;
  /* intentional: fixed black fill so the button reads as a solid "inverse"
     CTA against the gradient band behind it, same both themes. */
  background: #000;
  color: hsl(var(--v3-gradient-start));
  border-color: transparent;
  box-shadow: none;
}
/* intentional: fixed black, same reasoning as the base rule above. */
.landing-cta .btn-primary:hover:not(:disabled) { background: #000; opacity: 0.85; box-shadow: none; transform: translateY(-2px); }

.landing-footer {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-8) var(--space-6);
  border-top: 1px solid var(--color-border);
}
.landing-footer-copy { font-size: var(--text-base); color: var(--color-text-muted); margin: 0; }
/* issue #414 — "About the developer" footer link, the signed-out
   discoverability surface for the new /creator page. */
.landing-footer-link { font-size: var(--text-base); color: var(--color-text-muted); text-decoration: none; }
.landing-footer-link:hover { color: var(--color-text); text-decoration: underline; }
.landing-footer-link:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }

/* Developer/creator profile page (issue #414) — a single, owner-authored,
   public contact surface, built on .landing-page's page shell/orbs/footer
   and the shared .card/.icon-tile/.eyebrow primitives (design-system.md §4/§5)
   rather than inventing a parallel set of glass-surface rules. */
/* issue #506 redesign — left-aligned column matching the design reference
   exactly (avatar/eyebrow/name/tagline/bio and the link-card grid all sit
   flush against the same left edge of a page-centered column), replacing
   the earlier centered-hero treatment other landing-shell pages use. */
.developer-profile-hero {
  max-width: 700px;
  margin: 0 auto;
  padding: var(--space-16) var(--space-6) var(--space-8);
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  text-align: left;
  position: relative;
  z-index: 1;
}
.developer-profile-avatar {
  width: 96px;
  height: 96px;
  display: grid;
  place-items: center;
  background: hsl(var(--v3-accent) / 0.14);
  color: var(--color-accent-700);
  font-weight: 800;
  font-size: var(--text-2xl);
  border-radius: 50%;
  margin-bottom: var(--space-8);
}
:root[data-theme='dark'] .developer-profile-avatar { color: hsl(var(--v3-accent)); }
/* The source photo (public/developer-avatar.webp) is a tight square headshot
   crop, matching this frame's own 1:1 aspect ratio exactly — object-fit:
   cover therefore never needs to crop into the head/hair at any size, it
   only scales. object-position: center top is kept as a safety margin
   anyway, so any future replacement photo that isn't a perfect square still
   keeps the top of the head in frame rather than centering vertically (which
   would bias toward cropping the top on a taller-than-square source). */
.developer-profile-avatar-photo {
  object-fit: cover;
  object-position: center top;
  background: var(--color-surface-raised);
  border: 1px solid var(--color-border);
}
.developer-profile-back {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  color: var(--color-text-muted);
  font-weight: 600;
  font-size: var(--text-base);
  text-decoration: none;
}
.developer-profile-back:hover { color: var(--color-text); }
.developer-profile-back:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.developer-profile-eyebrow { margin: 0 0 var(--space-3); }
.developer-profile-name {
  font-family: var(--font-heading-v3, var(--font-display));
  font-weight: 700;
  font-size: clamp(28px, 4vw, 40px);
  line-height: 1.15;
  letter-spacing: -0.02em;
  margin: 0 0 var(--space-3);
  color: var(--color-text);
}
.developer-profile-tagline {
  font-family: var(--font-body-v3, var(--font-body));
  font-size: var(--text-lg);
  color: var(--color-accent-700);
  font-weight: 600;
  margin: 0 0 var(--space-6);
}
:root[data-theme='dark'] .developer-profile-tagline { color: hsl(var(--v3-accent)); }
.developer-profile-bio {
  font-size: var(--text-base);
  line-height: var(--leading-loose);
  color: var(--color-text-muted);
  max-width: 62ch;
  margin: 0;
}
.developer-profile-links-section {
  max-width: 700px;
  margin: 0 auto;
  /* was `var(--space-10)` — not a token in this file's scale (see the
     .developer-profile-link-card comment above for the identical bug), which
     invalidated this whole shorthand and collapsed padding to 0, running the
     last card row flush against the footer divider (issue #506 follow-up,
     reported live). */
  padding: var(--space-8) var(--space-6) var(--space-16);
}
.developer-profile-links-eyebrow { margin: 0 0 var(--space-4); }
.developer-profile-links {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  align-items: stretch;
  gap: var(--space-4);
}
/* Explicit column counts (not auto-fit) so the grid lands on exactly two
   columns at 768px, matching this page's design reference and its own
   "one column at 360px, two at 768px" testing requirement (issue #506) —
   auto-fit's content-driven wrapping put three columns at 768px instead. */
@media (min-width: 768px) {
  .developer-profile-links { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (min-width: 1024px) {
  .developer-profile-links { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
.developer-profile-link-card {
  height: 100%;
  /* Every card must stay the same height in every row, even one whose label
     happens to wrap onto a second line (e.g. a long link label at the
     narrower 3-column tier) — a fixed min-height keeps every row in the
     grid visually in sync instead of only the cards within one row
     stretching to match each other (issue #506 follow-up, reported live). */
  min-height: 92px;
  display: flex;
  /* flex-start, not center — the #542 follow-up above lets a long
     description wrap onto 2-3 lines instead of being hidden behind an
     ellipsis, so centering the icon against the card's full (now taller,
     variable) height left it drifting away from the label it's meant to
     sit beside. Top-aligning keeps the icon anchored to the label on every
     card regardless of how many lines its own description wraps to. */
  align-items: flex-start;
  gap: var(--space-4);
  /* was `padding: var(--space-5)` — that token doesn't exist in this file's
     scale (--space-1/2/3/4/6/8/12/16/24 only), so the whole declaration was
     invalid and every link card silently rendered with 0 padding (found via
     a live getComputedStyle() check, issue #506 follow-up). */
  padding: var(--space-4) var(--space-5, 1.25rem);
  color: var(--color-text);
  text-decoration: none;
}
.developer-profile-link-arrow { color: var(--color-text-muted); flex-shrink: 0; align-self: flex-start; margin-top: 2px; }
.developer-profile-link-body {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  flex: 1;
}
.developer-profile-link-label { font-weight: 600; font-size: var(--text-base); }
/* Issue #542 follow-up, found live — `white-space: nowrap` truncated every
   description to 3-6 visible characters ("The …", "Work …") the moment the
   card narrowed to its 2/3-column grid width, hiding real, meaningful copy
   (src/data/developerProfile.js) behind an ellipsis. Cards already reserve
   room for a wrapped second line (`.developer-profile-link-card`'s own
   `min-height: 92px` comment), so letting this wrap is a plain fix, not a
   size increase. */
.developer-profile-link-description {
  font-size: var(--text-base);
  color: var(--color-text-muted);
}

/* ==========================================================================
   Responsive breakpoint scale (issue #36)

   Six tiers, replacing the old ad-hoc 920/640 pair. Written mobile-up in this
   comment for readability, but implemented as `max-width` overrides on top of
   a desktop-first base (matching every other rule in this file), plus one
   `min-width` tier for ultra-wide displays:

     ≤375px   small phone   (iPhone SE, older/small Android)
     ≤480px   phone         (most modern phones, portrait)
     ≤768px   tablet portrait (iPad portrait)
     ≤1024px  tablet landscape / small laptop (iPad landscape)
     1025–1599px  laptop / desktop — the existing base styles, untuned
     ≥1600px  large / ultra-wide desktop
     ≥2200px  true ultra-wide / 4K (issue #340)

   Touch-capable vs. hover-capable is handled separately via the `(hover: …)`
   / `(pointer: …)` media features above and below — never inferred from
   viewport width. A touchscreen laptop or iPad landscape session is exactly
   as capable of triggering the `pointer: coarse` tier as a phone is, and a
   plain-mouse desktop at a narrow window should not get touch-sized targets.

   issue #340 — the ≥1600px tier originally only reached 5 dashboard-related
   selectors, so every other top-level page content container (onboarding,
   settings, progress) stayed at its base-tier width forever, no matter how
   wide the monitor got — on a real 27"+ display this read as a small,
   centered, laptop-sized page floating in a sea of unused background.
   `.onboarding-inner` (880px base) and `.progress-content` (1160px base) now
   get the same ≥1600px treatment `.dashboard-content` already had, plus a
   second ≥2200px tier for true ultra-wide/4K so the column keeps growing
   rather than capping out at a still-modest width on a 3440px+ display.
   `.settings-content` is a form/reading-width column (720px) — verified
   against a real 27"+ screenshot and deliberately left unchanged; growing a
   form's line length past a comfortable reading width doesn't improve
   usability the way more chart/grid room does for the other two pages.
   Modal `max-width` values (`.import-modal-card`, `.command-palette-card`,
   etc.) were also checked and left as-is — a modal filling most of a 27"+
   screen would be its own usability problem, not a fix. */

@media (min-width: 1600px) {
  .header-top, .roadmap-summary-card, .roadmap-filters-card, .review-tag-group-banner, .stat-strip, .dashboard-content { max-width: 1320px; }
  .onboarding-inner { width: min(1200px, 100%); }
  .progress-content { max-width: 1320px; }
}

@media (min-width: 2200px) {
  .header-top, .roadmap-summary-card, .roadmap-filters-card, .review-tag-group-banner, .stat-strip, .dashboard-content { max-width: 1600px; }
  .onboarding-inner { width: min(1500px, 100%); }
  .progress-content { max-width: 1600px; }
}

@media (max-width: 1024px) {
  /* issue #6 Phase 6/8 — hero collapses to a single column; mock preview
     drops below the copy instead of sitting beside it. Flush-left, not
     centered (issue #309) — design-system.md §4's "never center hero copy
     or button labels" applies at every width, not just desktop. */
  .landing-hero { grid-template-columns: 1fr; }
  .landing-hero-actions { justify-content: flex-start; }
  .landing-hero-mock { order: -1; }
  /* issue #500 (D4) — the progress split collapses to a single column at
     the same tier the hero does, copy first then the stat/heatmap mock. */
  .landing-progress { grid-template-columns: 1fr; gap: var(--space-8); }
  .landing-progress-title, .landing-progress-subtitle { text-align: left; }
}

/* Issue #496 (C4) — the auth marketing panel now hides at its own 960px
   tier rather than sharing the 1024px tier above with the landing hero:
   the merged sign-in/sign-up shell is narrower content than the landing
   hero, and hiding the panel too early (at 1024px) squashed the card's
   own 440px column against a 960-1024px viewport for no reason — the
   panel had room to stay. This was previously grouped with the 1024px
   block above (issue #6 Phase 5.1); split out as its own tier by direct
   design request, not a generalizable breakpoint. */
@media (max-width: 960px) {
  .auth-page { grid-template-columns: 1fr; }
  .auth-marketing { display: none; }
}

@media (max-width: 768px) {
  /* --dashboard-gutter-x set once here (issue #503 follow-up) — .dashboard-header's
     padding, .daily-todo-panel's margin, and .dashboard-content's padding
     all read it, so this single override keeps every dashboard section's
     left/right edge in sync instead of three separately-tuned literals. */
  :root { --dashboard-gutter-x: 16px; }
  .dashboard-header { padding: max(14px, env(safe-area-inset-top)) max(var(--dashboard-gutter-x), env(safe-area-inset-right)) 16px max(var(--dashboard-gutter-x), env(safe-area-inset-left)); }
  .dashboard-content { padding: 18px var(--dashboard-gutter-x) calc(var(--bottom-nav-h) + env(safe-area-inset-bottom) + var(--space-4)) calc(var(--dashboard-gutter-x) + var(--phase-spine-gutter)); }
  .landing-nav-links { display: none; }
  /* issue #500 (D4) — nav sheds the section links first (above), then the
     Sign in link at a narrower tier below — Start free + the brand mark +
     theme toggle are the only actions guaranteed to survive down to the
     smallest supported width. */
  .landing-template-chips { gap: var(--space-2); }
  /* issue #460 — the two glass cards' desktop padding (20-24px) reads too
     spacious once the viewport itself is this narrow; match `.dashboard-header`'s
     own tightened padding at this tier instead of the two cards ballooning
     the header taller than it needs to be. */
  .roadmap-summary-card { padding: 16px 18px; gap: 16px; }
  .roadmap-filters-card { padding: 16px 18px; }
}

/* Issue #497 (D1) — every modal becomes a bottom sheet below 720px. Implemented
   once in the shared shell every listed modal builds on (`.modal-overlay`/
   `.modal-card`, `modal.js`) plus `.panel-overlay`/`.item-panel`
   (`itemPanel.js`, which predates and doesn't reuse `modal.js`'s classes) —
   confirmDialog, commandPalette, importRoadmapModal, shareRoadmapModal,
   deleteAccountModal, and feedbackModal all get the sheet treatment for free
   with zero per-component CSS, since they only ever add an extra class
   alongside `modal-card`, never replace it. The drag handle is a `::before`
   pseudo-element, not a DOM node — no component needed a JS change for this
   issue. Threshold widened from the item panel's previous 480px (issue #6
   Phase 8) to 720px so every modal in this batch switches to a sheet at the
   same viewport width, matching the design reference's single "below 720px"
   rule rather than each modal picking its own breakpoint. */
@media (max-width: 720px) {
  .modal-overlay { align-items: flex-end; padding: 0; }
  .modal-card {
    width: 100%;
    max-width: 100%;
    margin: 0;
    border-radius: 22px 22px 0 0;
    max-height: 92vh;
    max-height: 92dvh;
    overflow-y: auto;
    padding-top: 36px;
    position: relative;
  }
  .modal-card::before {
    content: '';
    position: absolute;
    top: 10px;
    left: 50%;
    transform: translateX(-50%);
    width: 40px;
    height: 4px;
    border-radius: 999px;
    background: hsl(var(--v3-divider) / 0.8);
  }
  /* Close buttons absolutely positioned in a card's top-right corner need to
     clear the drag handle's reserved top padding, and grow to the 44px+
     tap-min floor regardless of pointer type — a phone-viewport rule, not a
     touch-capability one (the design reference ties this to width, not
     pointer type), so it's intentionally not gated on `(pointer: coarse)`
     the way most touch-target rules in this file are. */
  .modal-close, .feedback-modal-close {
    top: 16px;
    width: var(--tap-min);
    height: var(--tap-min);
  }
  /* Primary action goes full width and the action row stops right-aligning
     — matches the design reference's "primary action is full width" rule,
     whichever footer-row class a given modal happens to use. */
  .confirm-dialog-actions,
  .feedback-form-actions {
    flex-direction: column;
    align-items: stretch;
    margin-top: 16px;
  }
  .confirm-dialog-actions .btn,
  .feedback-form-actions .btn {
    width: 100%;
  }

  /* `.item-panel` gets the identical sheet shape (radius/max-height/drag
     handle) as `.modal-card` above, just applied to its own
     `.panel-overlay`/`.panel-header` shell instead. */
  .item-panel {
    top: auto;
    bottom: 0;
    left: 0;
    right: 0;
    width: 100%;
    max-width: 100%;
    height: 92vh;
    height: 92dvh;
    border-radius: 22px 22px 0 0;
    padding-top: 0;
    padding-bottom: env(safe-area-inset-bottom);
    transform: translateY(100%);
  }
  .item-panel.show { transform: translateY(0); }
  .item-panel .panel-header { position: relative; padding-top: 28px; }
  .item-panel .panel-header::before {
    content: '';
    position: absolute;
    top: 10px;
    left: 50%;
    transform: translateX(-50%);
    width: 40px;
    height: 4px;
    border-radius: 999px;
    background: hsl(var(--v3-divider) / 0.8);
  }
  .panel-footer { flex-direction: column; align-items: stretch; }
  .panel-footer-right { margin-left: 0; width: 100%; }
  .panel-footer-right .btn { flex: 1; }
}

@media (max-width: 480px) {
  /* --dashboard-gutter-x, see the ≤768px tier's own comment above. */
  :root { --dashboard-gutter-x: 12px; }
  .dashboard-header { padding: max(14px, env(safe-area-inset-top)) max(var(--dashboard-gutter-x), env(safe-area-inset-right)) 16px max(var(--dashboard-gutter-x), env(safe-area-inset-left)); }
  .dashboard-content { padding: 16px var(--dashboard-gutter-x) calc(var(--bottom-nav-h) + env(safe-area-inset-bottom) + var(--space-4)) calc(var(--dashboard-gutter-x) + var(--phase-spine-gutter)); }
  /* issue #489 — the identity row (`.roadmap-summary-top`) already wraps
     name/save-state onto their own lines at this width via its own
     `flex-wrap`, and the progress block below it is a single full-width
     column at every tier, so — unlike #460's two-side-by-side-tile layout
     this replaces — there's no separate narrow-phone stacking rule needed
     here. */
  .roadmap-summary-card { padding: 14px 16px; gap: 12px; }
  .roadmap-filters-card { padding: 14px 16px; }
  /* Issue #444 — live-reported, screenshotted: `.stat-strip`'s
     `repeat(auto-fit, minmax(200px, 1fr))` let two `.stat-tile`s keep
     sitting side by side at a 200px column width even though 200px isn't
     wide enough for one tile's own content (icon + "0 / 484" + "Items
     done") on one line — the grid track itself satisfied the 200px floor,
     so it never wrapped to a single column; the wrapping happened *inside*
     each tile's text instead ("0" / "/ 484" / "Items" / "done" each on
     their own line), reading as broken layout rather than a normal
     responsive stack. Forcing a single column below this tier gives each
     tile its full width back, which is comfortably enough for the content
     to stay on one line. */
  .stat-strip { grid-template-columns: 1fr; }
  /* `.item-panel`'s bottom-sheet treatment (was issue #6 Phase 8, gated at
     this same ≤480px tier) moved to the shared ≤720px bottom-sheet block
     above (issue #497) — see that block's own comment for why the threshold
     widened. Don't re-add it here. */
  .landing-hero-title { font-size: var(--text-3xl); }
  .landing-hero-actions { flex-direction: column; align-items: stretch; }
  .landing-hero-actions .btn { width: 100%; }
  .landing-cta-title { font-size: var(--text-2xl); }
  /* issue #500 (D4) — the last nav tier: Sign in is shed here, after the
     section links (already hidden at 768px above), leaving only the brand
     mark, theme toggle, and the primary "Start free" button. */
  .landing-nav-signin { display: none; }
  .landing-cta { flex-direction: column; align-items: stretch; text-align: center; padding: var(--space-8); }
  .landing-cta-copy { align-items: center; }
  .landing-cta .btn-primary { width: 100%; }
  /* issue #6 Phase 8 — a horizontal-scroll row instead of wrapping, so the
     priority filter chips stay a single tappable row on a phone width
     instead of eating vertical space with a second/third wrapped line.
     `scrollbar-width: none`/the `::-webkit-scrollbar` rule hide the
     scrollbar chrome — the chips themselves are the scroll affordance. */
  .filter-row { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none; padding-bottom: 2px; }
  .filter-row::-webkit-scrollbar { display: none; }
  .filter-chip { flex-shrink: 0; }

}

/* In-app feedback widget (issue #9) — floating trigger + modal + form
   primitives. z-index 150 sits above ordinary page content but below the
   sidebar mobile backdrop (199)/item panel (210)/modal-overlay (1000)/toast
   (300), per the issue's "above content but below modals and item panels"
   spec. Fixed to the viewport corner, so it needs the same safe-area-inset
   treatment every other fixed edge element in this app gets
   (.claude/rules/ui-styling.md). */
/* issue #498 — the floating feedback trigger (.feedback-widget-trigger/
   -emoji/-label) was retired here; "Send feedback" now lives in the account
   menu (sidebar.js) and a Settings row (settings.js), both opening this same
   feedbackModal.js. If you find a stray reference to those classes, it's a
   regression, not something still in use. */
.feedback-modal-card { max-width: 480px; width: 100%; position: relative; }
.feedback-modal-close { position: absolute; top: 16px; right: 16px; }
.feedback-modal-body { display: flex; flex-direction: column; gap: 16px; }
.feedback-type-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding-right: 36px; }
.feedback-privacy-note { font-size: var(--text-base); color: var(--color-text-muted); margin: 0; }

/* Issue #505 — the old type-select-grid "3 large cards" screen and its
   per-type multi-field form are replaced by one screen: a row of small
   single-select kind chips (reusing `.filter-chip`'s existing look, same as
   the priority/tag filter rows on the dashboard) plus one textarea. */
.feedback-kind-chips { display: flex; flex-wrap: wrap; gap: 8px; }
/* `.feedback-kind-chip` is always paired with `.filter-chip` at the call
   site (feedbackModal.js), which already sets `color`/`background`/etc. —
   but scripts/lint-theme.mjs's "every custom button-like class needs its
   own explicit color" check (issue #116) scans each class token
   independently, so this needs its own (identical) declaration to satisfy
   it rather than relying on the sibling class silently covering it. */
.feedback-kind-chip { color: inherit; }
.feedback-my-reports-link { align-self: center; }

.feedback-form { display: flex; flex-direction: column; gap: 16px; }
.feedback-field { display: flex; flex-direction: column; gap: 6px; }
.feedback-field-input { min-height: 42px; }
textarea.feedback-field-input { min-height: 88px; resize: vertical; }
.feedback-char-counter { align-self: flex-end; font-size: var(--text-base); color: var(--color-text-muted); }
.feedback-field-input.field-input-invalid { border-color: var(--color-danger); }

.feedback-system-info-checkbox { display: flex; align-items: flex-start; gap: 8px; font-size: var(--text-base); color: var(--color-text); cursor: pointer; }
/* issue #206 §5 follow-up — was an unstyled native checkbox (OS-default blue
   check in the screenshots); `accent-color` recolors the native control to
   match the theme with no custom widget needed, same convention
   `.remember-checkbox`/`.daily-todo-checkbox input[type='checkbox']`
   already established elsewhere in this file. */
.feedback-system-info-checkbox input[type='checkbox'] { width: 16px; height: 16px; margin-top: 2px; accent-color: var(--color-accent); flex-shrink: 0; cursor: pointer; }
.feedback-system-info-summary { color: var(--color-text-muted); font-size: var(--text-base); }

.feedback-form-actions { display: flex; gap: 12px; justify-content: flex-end; }
.feedback-cooldown-message { text-align: right; }

.feedback-success { display: flex; flex-direction: column; align-items: center; text-align: center; gap: 10px; padding: 12px 0; }
.feedback-success-icon { font-size: var(--text-4xl); }
.feedback-reference { font-family: var(--font-mono); font-weight: 700; color: var(--color-accent-700); }
/* issue #155 v2 Phase D3 — modal-scoped recolor; falls back to mint in light theme. */
:root[data-theme='dark'] .feedback-reference { color: var(--color-accent-700); }

.my-reports-list { display: flex; flex-direction: column; gap: 8px; max-height: 60vh; overflow-y: auto; }
.my-report-row { border: 1px solid var(--color-border); border-radius: var(--radius-md); overflow: hidden; }
.my-report-summary { width: 100%; text-align: left; padding: 12px 14px; background: var(--color-surface-raised); color: var(--color-text); font: inherit; border: none; cursor: pointer; display: flex; flex-direction: column; gap: 4px; }
.my-report-title-row { display: flex; align-items: center; gap: 8px; font-weight: 700; }
.my-report-title { flex: 1; }
.my-report-meta { font-size: var(--text-base); color: var(--color-text-muted); }
.my-report-severity { font-size: var(--text-base); font-weight: 800; padding: 2px 8px; border-radius: var(--v3-radius-sm); background: var(--color-danger-bg); color: var(--color-danger); }
.my-report-severity-low, .my-report-severity-medium { background: var(--color-warning-bg); color: var(--color-warning); }
.my-report-details { padding: 0 14px 14px; font-size: var(--text-base); color: var(--color-text); display: flex; flex-direction: column; gap: 6px; }

@media (max-width: 375px) {
  /* --dashboard-gutter-x, see the ≤768px tier's own comment above. This
     tier previously had no matching .dashboard-header override at all —
     .dashboard-content alone dropped to 10px here while .dashboard-header
     (and therefore .roadmap-summary-card/.next-up-card/.roadmap-filters-card)
     stayed at the ≤480px tier's 12px, a second small version of the same
     misalignment bug. Setting the shared token here fixes both at once. */
  :root { --dashboard-gutter-x: 10px; }
  .dashboard-header { padding: max(14px, env(safe-area-inset-top)) max(var(--dashboard-gutter-x), env(safe-area-inset-right)) 16px max(var(--dashboard-gutter-x), env(safe-area-inset-left)); }
  .dashboard-content { padding: 14px var(--dashboard-gutter-x) calc(var(--bottom-nav-h) + env(safe-area-inset-bottom) + var(--space-4)) calc(var(--dashboard-gutter-x) + var(--phase-spine-gutter)); }
  .modal-card { padding: 26px 20px 22px; }
}

/* iOS Safari auto-zooms the page when a focused input's computed font size
   is under 16px. Scoped to phone/tablet widths (not a hover/pointer feature —
   there's no reliable way to detect "is this WebKit's iOS text-zoom
   behavior" — 1024px comfortably covers phones and tablets in either
   orientation without changing the denser desktop field styling). */
@media (max-width: 1024px) {
  .field-input,
  .field-input.compact,
  .search-input,
  .import-paste-area,
  .feedback-field-input {
    font-size: 16px;
  }
}

/* Touch targets — WCAG 2.5.5 / platform guidance recommend ~44x44px minimum.
   Gated on actual pointer capability, not viewport width (see the note atop
   the breakpoint scale above). Sized to 46px, not exactly 44px (issue #233):
   sub-pixel/DPI layout rounding in real browsers (and headless Chromium under
   CI) can measure an element sitting exactly on the 44px boundary as
   fractionally under it (e.g. 43.999996px), failing a strict `>= 44`
   assertion — reproduced live in PR #231's CI run, which passed only on
   Playwright retry. 2px of headroom absorbs that rounding without changing
   the visual intent of "~44px minimum". */
@media (pointer: coarse) {
  /* Raised to the tap-min token (48px, issue #482 A1) — was a fixed 46px
     before this. Still coarse-pointer-only, matching every other rule in
     this block; see the code comment above the base `.btn`/`.filter-chip`/
     `.nav-item`/`.check-box`/`.seg-opt` rule for why `.btn-icon` isn't in
     that unconditional list. */
  .btn-icon { width: var(--tap-min); height: var(--tap-min); }
  .btn-sm { min-height: 46px; }
  .filter-chip { min-height: 46px; }
  .check-item { min-height: 46px; }
  .daily-todo-overflow-btn, .daily-todo-info-btn, .daily-todo-collapse-btn, .daily-todo-reminder-btn { width: 46px; height: 46px; }
  .template-card-info-corner, .template-card-overflow-btn { width: 46px; height: 46px; }
  /* issue #435 — same border-clip bug as the base rule above, at the touch
     tier: the group must grow to actually fit a real touch target (var(--tap-min)
     child + 2*1px border + 2*3px padding = 56px, issue #482 A1 — was a fixed
     46px/54px pair before this), not stay pinned to 36px like the
     mouse/desktop row. */
  .app-topbar .icon-btn-group { height: 56px; }
  .icon-btn-group .app-topbar-command-btn,
  .icon-btn-group .app-topbar-bell,
  .icon-btn-group .app-topbar-avatar-btn {
    width: var(--tap-min);
    height: var(--tap-min);
  }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}
/* issue #499 (D3) — the Animations-off preference added in #495/#C3 sets a
   plain data-animations-off attribute on <html> (uiPreferences.js), but
   until this pass nothing in app.css ever read it. Same shape as the
   prefers-reduced-motion catch-all immediately above — a global !important
   floor rather than a per-component opt-out, so every animation/transition
   in this file (including any added after this pass) is covered without
   needing its own explicit override. */
[data-animations-off], [data-animations-off] *, [data-animations-off] *::before, [data-animations-off] *::after {
  animation-duration: 0.01ms !important;
  transition-duration: 0.01ms !important;
  scroll-behavior: auto !important;
}

/* Print/PDF export of a roadmap (issue #133 Part 2, restyled/branded in issue
   #160, restructured onto a real <table>/<thead>/<tfoot> and given a page
   watermark + browser-chrome suppression in a follow-up — see the block
   comments inside @media print below) — a dedicated print stylesheet, not a
   separate route: printRoadmap.js appends a `.print-roadmap` <table> snapshot
   (with a repeating `.print-page-header`/`.print-page-footer` brand pair
   inside its <thead>/<tfoot>) plus a sibling `.print-watermark` layer to
   document.body, and toggles `.print-mode` on it just for the duration of
   window.print(). `.print-include-notes-row` is the small on-screen checkbox
   in the pre-print modal; everything else here only applies inside an actual
   print/PDF context. */
.print-include-notes-row { display: flex; align-items: center; gap: 8px; margin: 16px 0; }
.print-roadmap,
.print-watermark { display: none; }

/* intentional: every literal color in this @media print block is fixed,
   theme-independent print output — a printed page always renders on
   assumed-white paper/PDF viewer background regardless of the app's active
   on-screen theme, so none of these should ever read from a --token that
   could resolve to a dark-theme value. The phase/priority hues below are the
   same P0-P3 hues the on-screen light theme uses (--color-p0..--color-p3,
   :root), copied as literals rather than tokens since a print stylesheet
   must never resolve to the dark-theme values of those same custom
   properties.
   Retuned in issue #301 (Phase 5) — the previous literals here (#dc2626
   red/#b45309 amber/#2563eb blue/#15803d green) were a leftover four-hue
   rainbow palette from well before the v2 "Modernist" single-accent+neutral
   redesign; the comment claiming they "matched --color-p0..--color-p3" had
   gone stale the moment Phase 1 (#297) repointed those tokens to
   accent/accent-400/neutral-500/neutral-300, and nothing print-specific was
   touched in any phase since. A solid-fill-plus-white-text badge (the old
   approach) cannot work with the new palette at all. Print badges follow
   this app's own on-screen `.badge` pattern instead — a light tint
   background with full-opacity ink-variant text, not a solid fill — which is
   what makes the on-screen badges contrast-safe.
   **Issue #435**: this comment's own literal hex values had gone stale a
   second time — v3's redesign (issue #416) repointed `--color-p0`/`-p1` from
   the v2 red/orange-pink family to the current green accent family
   (`--color-accent`/`--color-accent-400`), but this print stylesheet was
   never updated to match, so a printed roadmap's P0/P1 accents still showed
   the old red/pink even after the on-screen app fully moved to green.
   Current values: phase border-left accents (no text-contrast concern, just
   a colored rule) use the base hue directly — #0CB656/#70DB9F/#9B9797/#D7D3D3
   (P0-P3). Priority badges use a light tint background with full-opacity ink
   text — #ECFEF4/#05612D (P0), #ECFEF4/#034E24 (P1), #f0efef/#444141 (P2),
   #f9f8f8/#605D5D (P3) — each pairing re-verified against the current
   `--color-p*-bg`/`--color-p*-ink` (light theme) token values documented at
   their `:root` definitions. */
@media print {
  /* `@page { margin: 0 }` removes the blank margin band Chrome/Firefox
     otherwise reserve for their own browser-injected print chrome — the
     page URL and date/time in the top corners, the URL and page-number in
     the bottom corners (visible in a real export as "localhost:4173/#/app"
     during local dev — real feedback, screenshot). With no margin space to
     paint into, that chrome no longer renders at all, in both engines. This
     also means our own header/footer/content no longer get any "free"
     side/edge padding from the browser's default page margin — see the
     `padding: 0 40px`/`36px` additions below the table rules, which replace
     it. A user's own browser-level "Headers and footers" print-dialog
     toggle is a separate setting this can't reach either way — this fix
     just makes sure there's no default browser margin left for that chrome
     to use even if a user does have it enabled. */
  @page {
    margin: 0;
  }

  /* `.print-roadmap` is a real <table>, with the branded header/footer
     inside a genuine <thead>/<tfoot> (printRoadmap.js) — not a `position:
     fixed` div pair. Two fixed-positioning approaches were tried and
     rejected first, both confirmed broken via a real multi-page `page.pdf()`
     render, not just visual inspection: (1) `position: fixed` header/footer
     with `.print-roadmap`'s own padding reserving clearance — that padding
     only applies once, at the very top/bottom of the whole flowed document,
     so pages after the first flowed content flush against the page edge,
     underneath the fixed header (reported live via a 47-page multi-phase
     roadmap export, "Core Java (P0)" overlapping the brand header on every
     page after the first). (2) `position: fixed` + an `@page` margin to
     reserve that same space on every page — this looked like the correct
     fix, but Chromium's print pagination positions `fixed` elements
     relative to the *content* box (inside the page margin), not the page
     edge, so a fixed header at `top: 0` still landed at the same coordinate
     the flowing content starts at, reproducing the identical overlap
     regardless of margin size. `<thead>`/`<tfoot>` is the browser-native,
     spec-backed mechanism for exactly this ("repeat this row and reserve
     space for it on every printed page") and was the only one of the three
     that a real multi-page PDF render confirmed clean on every page.
     `.print-roadmap` itself deliberately carries no `background` (unlike an
     earlier version) — a printed page is white paper regardless, and an
     opaque table background would paint over `.print-watermark` (below),
     which needs to show through from underneath the flowing content. */
  body.print-mode > *:not(.print-roadmap):not(.print-watermark) { display: none !important; }

  /* Fallback for a native browser/OS print invoked directly (mobile Chrome's
     own three-dot "Print…", Ctrl/Cmd+P, a print button outside this app's
     own "Print roadmap…" flow) — i.e. `body.print-mode` was never toggled.
     Without this, `.app-sidebar` (`position: sticky; height: 100dvh;
     overflow-y: auto`) and `.app-topbar` (`position: sticky`) get paginated
     as their own single viewport-height slice by the print engine, while
     `.app-shell-main`'s actual content — a sibling grid column, not a
     descendant of the scroll-clipped sidebar — renders empty on every page:
     real bug, reported live from a phone, printing the onboarding page
     showed two near-blank pages containing only the sidebar nav labels.
     Hide the app chrome and reset every height/overflow constraint so
     whatever page is on screen flows and paginates normally instead. */
  body:not(.print-mode) .app-sidebar,
  body:not(.print-mode) .bottom-nav,
  body:not(.print-mode) .command-palette,
  body:not(.print-mode) .save-badge,
  body:not(.print-mode) .toast-stack,
  body:not(.print-mode) .tour-scrim,
  body:not(.print-mode) .tour-ring,
  body:not(.print-mode) .tour-popover {
    display: none !important;
  }

  /* Issue #444 — the raw-reflow fallback above used to also apply to
     Settings/Progress/onboarding's roadmap-picker grid, none of which have
     any print layout of their own: a native print triggered from any of
     those pages just reflowed their raw, un-print-styled DOM across
     arbitrary page breaks (live report/screenshot — printing Settings
     produced two broken, unthemed pages). Printing must only ever produce
     the roadmap checklist itself. `.dashboard-content` is the one real
     exception, and only as a last-resort safety net — dashboard.js's own
     beforeprint/matchMedia listener (issue #292/#254) already swaps in the
     branded `.print-roadmap` snapshot (toggling `.print-mode`) before a
     dashboard print ever reaches this fallback in practice. Every other
     page shows a plain "nothing to print" message instead of its raw
     content. */
  body:not(.print-mode) .app-shell-2,
  body:not(.print-mode) .app-shell-main,
  body:not(.print-mode) .app-topbar,
  body:not(.print-mode) .dashboard-content {
    display: block !important;
    position: static !important;
    height: auto !important;
    max-height: none !important;
    overflow: visible !important;
  }
  body:not(.print-mode):not(:has(.dashboard-content)) .app-shell-2,
  body:not(.print-mode):not(:has(.dashboard-content)) .app-shell-main,
  body:not(.print-mode):not(:has(.dashboard-content)) .app-topbar,
  body:not(.print-mode):not(:has(.dashboard-content)) .onboarding-page {
    display: none !important;
  }
  body:not(.print-mode):not(:has(.dashboard-content))::before {
    content: 'Nothing to print here — open a roadmap to print it.';
    display: block;
    padding: 80px 40px;
    font-family: var(--font-body);
    font-size: 16px;
    color: #111111; /* intentional: fixed print text color, see block comment above this @media print block */
  }

  body.print-mode .print-roadmap {
    display: table;
    width: 100%;
    border-collapse: collapse;
    color: #111111; /* intentional: fixed print text color, see block comment above */
    position: relative;
    z-index: 1;
  }
  .print-roadmap thead td,
  .print-roadmap tfoot td { padding: 0 40px; }
  .print-roadmap tbody td { padding: 0 40px 24px; }

  /* A faint, blurred repeating watermark behind the content on every page —
     `position: fixed` repeats correctly per page on its own (confirmed
     during the header/footer investigation above); it doesn't need the
     `<thead>`/`<tfoot>` space-reservation trick since overlapping the
     content is the intent here, not a bug to avoid. Sized and centered
     against a Letter-ish page (816×1056px at 96dpi) so it reads as one
     centered mark per page rather than drifting off-page on a different
     paper size — `width`/`height: 100vw`/`100vh` plus flex-centering keeps
     it centered regardless of actual page dimensions. Low opacity + blur
     keeps the underlying checklist text legible on top of it, per the
     explicit "both should be visible" requirement. `print-color-adjust`
     isn't needed here (opacity/color always print by default, unlike
     `background-color`/`box-shadow`, which need the browser's "background
     graphics" print option enabled) — this deliberately uses `color`, not a
     `background`, so it always renders, that option or not. */
  .print-watermark {
    display: flex;
    position: fixed;
    inset: 0;
    align-items: center;
    justify-content: center;
    pointer-events: none;
    z-index: 0;
    filter: blur(2px);
  }
  .print-watermark-mark {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    opacity: 0.07;
    color: #111111; /* intentional: fixed print watermark color, see block comment above */
  }
  .print-watermark-mark .brand-mark {
    width: 160px;
    height: 160px;
    box-shadow: none;
  }
  .print-watermark-mark .brand-mark svg { width: 100%; height: 100%; }
  .print-watermark-mark .brand-name {
    font-size: 64px;
    color: #111111; /* intentional: fixed print watermark color, see block comment above */
  }

  .print-page-header {
    display: flex;
    align-items: center;
    padding: 20px 0 12px;
    border-bottom: 1px solid #ccc; /* intentional: fixed print divider color */
  }
  .print-brand .brand-name,
  .print-brand .brand-tagline { color: #111111; } /* intentional: fixed print text color */
  .print-brand .brand-mark { box-shadow: none; }

  .print-page-footer {
    padding: 8px 0 20px;
    border-top: 1px solid #ccc; /* intentional: fixed print divider color */
    font-size: 11px;
    color: #666; /* intentional: fixed print text color */
    text-align: center;
  }

  .print-roadmap-title { font-size: 22px; margin: 0 0 16px; }
  .print-phase {
    margin: 0 0 16px;
    padding-left: 12px;
    border-left: 4px solid #94a3b8; /* intentional: fixed neutral print accent, phase priority unmapped */
    page-break-inside: avoid;
  }
  .print-phase-P0 { border-left-color: #0CB656; } /* intentional: fixed print accent, matches --color-p0 (light theme) — issue #435, was still #EC3013 from before v3's green accent */
  .print-phase-P1 { border-left-color: #70DB9F; } /* intentional: fixed print accent, matches --color-p1 (light theme) — issue #435, was still #FF9783 */
  .print-phase-P2 { border-left-color: #ABEDC8; } /* intentional: fixed print accent, matches --color-p2 (light theme) — issue #462, P2/P3 moved from neutral grey to accent-green shades */
  .print-phase-P3 { border-left-color: #D4F7E3; } /* intentional: fixed print accent, matches --color-p3 (light theme) — issue #462 */
  .print-phase-title { font-size: 18px; margin: 20px 0 8px; }
  .print-section-title {
    font-size: 12px;
    margin: 14px 0 6px;
    color: #444; /* intentional: fixed print text color */
    text-transform: uppercase;
    letter-spacing: 0.06em;
    border-bottom: 1px solid #ddd; /* intentional: fixed print divider color */
    padding-bottom: 4px;
  }
  .print-item-list { list-style: none; margin: 0; padding: 0; }
  .print-item { margin: 6px 0; page-break-inside: avoid; }
  .print-priority-badge {
    display: inline-block;
    font-size: 11px;
    font-weight: 700;
    padding: 1px 8px;
    margin-left: 6px;
    /* radius 0 — design-system.md §4, matches every on-screen chip/badge */
  }
  .print-priority-badge.print-priority-P0 { background: #ECFEF4; color: #05612D; } /* intentional: fixed print tint+ink, matches --color-p0-bg/-ink (light theme) — issue #435, was still the pre-v3 red/pink pairing */
  .print-priority-badge.print-priority-P1 { background: #ECFEF4; color: #034E24; } /* intentional: fixed print tint+ink, matches --color-p1-bg/-ink (light theme) — issue #435, was still the pre-v3 red/pink pairing */
  .print-priority-badge.print-priority-P2 { background: #ECFEF4; color: #034E24; } /* intentional: fixed print tint+ink, matches --color-p2-bg/-ink (light theme) — issue #462 */
  .print-priority-badge.print-priority-P3 { background: #ECFEF4; color: #067436; } /* intentional: fixed print tint+ink, matches --color-p3-bg/-ink (light theme) — issue #462 */
  .print-item-notes { font-size: 12px; color: #333; margin: 2px 0 2px 24px; white-space: pre-wrap; /* intentional: fixed print text color */ }
  .print-resource { font-size: 12px; color: #333; margin: 2px 0 2px 24px; font-style: italic; /* intentional: fixed print text color */ }
}

/* Real-device report, issue #542 follow-up — the last row of the Progress
   page (and the bottom of Settings) rendered underneath the fixed bottom
   nav bar on a real phone, cutting off content no matter how far the user
   scrolled. `.app-content`'s own `@media (max-width: 899px)` rule above
   reserves `--bottom-nav-h` + the safe-area inset for exactly this, but
   `.progress-content`/`.settings-content` each carry their own `padding`
   shorthand (several breakpoints, all pre-existing and unrelated to the
   bottom nav) that resets all four sides — including bottom — back to
   their own tuned value. Both classes sit on the same element as
   `.app-content` at equal specificity (one class each), so whichever rule
   is later in the file wins outright for any property they both set; every
   one of those shorthand rules is physically earlier than `.app-content`'s
   own 899px rule expects, but still loses because CSS doesn't care about a
   media query's own position, only the plain source order of the matching
   rules. Reasserted last, after every other rule touching these two
   classes' padding, and scoped to padding-bottom only so it never touches
   whatever top/left/right padding those earlier rules already set
   correctly. Verified live via a real getBoundingClientRect() check (the
   page's last content element measured directly against the bottom nav's
   own top edge), not just eyeballed. */
@media (max-width: 899px) {
  .progress-content, .settings-content {
    padding-bottom: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom) + var(--space-4));
  }
}
