:root {
  color-scheme: dark;
  /* Arcade (Night City) values — CSS-only fallback for no-JS clients.
     The real default is Ember, set by the preload script above via
     data-theme="ember"; these :root values only apply if that script
     can't run (JS disabled). */
  --page:       #0a0713;
  --surface:    #17101f;
  --surface-2:  #1e1629;
  --ink:        #f5f0ff;
  --ink-2:      #cbb8e8;
  --ink-muted:  #8874a8;
  --gridline:   #2c2038;
  --border:     rgba(243, 255, 0, 0.14);
  --good:       #00f0ff;
  --danger:     #ff3b3b;

  --series-1: #00f0ff; /* electric cyan */
  --series-2: #f3ff00; /* hot yellow */
  --series-3: #ff2ee6; /* magenta */
  --series-4: #ff5e3a; /* orange-red */
  --series-5: #9d4dff; /* violet */
  --series-6: #00ff85; /* acid green */
  --series-7: #ff8a00; /* amber-orange */
  --series-8: #4d7dff; /* electric blue */

  --accent-glow: #f3ff00;
  --accent-glow-2: #00f0ff;
  --scanline-opacity: 0.06;
  --scanline-size: 4px;
  --bg-motif-opacity: 0.35;
  --glitch-amp: 2px;

  --radius-sm: 2px;
  --radius-md: 2px;
  --radius-lg: 0px;

  --font-display: "Chakra Petch", "Segoe UI", system-ui, sans-serif;
  --font-mono: "JetBrains Mono", "SFMono-Regular", "Consolas", "Menlo", monospace;

  /* typography */
  --text-2xs: 0.6875rem;
  --text-xs:  0.8125rem;
  --text-sm:  0.9375rem;
  --text-md:  1.0625rem;
  --text-lg:  1.5rem;
  --weight-regular: 400;
  --weight-medium: 500;
  --weight-semibold: 600;

  /* spacing (4/8-based) */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-5: 1.25rem;
  --space-6: 1.5rem;
  --space-8: 2rem;
}

html[data-theme="hacker"] {
  --page:       #050805;
  --surface:    #0b120c;
  --surface-2:  #0e1a10;
  --ink:        #d8ffe4;
  --ink-2:      #8fdba3;
  --ink-muted:  #4f7a5c;
  --gridline:   #163019;
  --border:     rgba(0, 255, 159, 0.16);
  --good:       #00ff9f;
  --danger:     #ff3b3b;

  --series-1: #00ff9f;
  --series-2: #35d67c;
  --series-3: #7fffb0;
  --series-4: #00c2ff;
  --series-5: #4dffd6;
  --series-6: #a8ff60;
  --series-7: #00ffe1;
  --series-8: #6fffa0;

  --accent-glow: #00ff9f;
  --accent-glow-2: #00c2ff;
  --scanline-opacity: 0.10;
  --scanline-size: 3px;
  --bg-motif-opacity: 0.5;
  --glitch-amp: 1px;

  --font-display: var(--font-mono);
}

html[data-theme="noir"] {
  --page:       #07090a;
  --surface:    #101516;
  --surface-2:  #151c1e;
  --ink:        #eef4f2;
  --ink-2:      #a9bdb9;
  --ink-muted:  #63817c;
  --gridline:   #1c2527;
  --border:     rgba(94, 234, 212, 0.12);
  --good:       #ffb347;
  --danger:     #e0574a;

  --series-1: #2dd4bf;
  --series-2: #ffb347;
  --series-3: #7fe3d6;
  --series-4: #ffd08a;
  --series-5: #0e9c8f;
  --series-6: #d98a2e;
  --series-7: #9fece1;
  --series-8: #f2c179;

  --accent-glow: #2dd4bf;
  --accent-glow-2: #ffb347;
  --scanline-opacity: 0.045;
  --scanline-size: 5px;
  --bg-motif-opacity: 0.6;
  --glitch-amp: 1px;
}

html[data-theme="synthwave"] {
  --page:       #100b23;
  --surface:    #1c1440;
  --surface-2:  #241a52;
  --ink:        #fdf2ff;
  --ink-2:      #d9b8ff;
  --ink-muted:  #9482c2;
  --gridline:   #2f2166;
  --border:     rgba(255, 46, 230, 0.16);
  --good:       #05e0d8;
  --danger:     #ff3b3b;

  --series-1: #ff2ee6;
  --series-2: #05e0d8;
  --series-3: #ffde59;
  --series-4: #7d5fff;
  --series-5: #ff6ec7;
  --series-6: #00b8ff;
  --series-7: #ff9142;
  --series-8: #b06bff;

  --accent-glow: #ff2ee6;
  --accent-glow-2: #05e0d8;
  --scanline-opacity: 0.05;
  --scanline-size: 4px;
  --bg-motif-opacity: 0.55;
  --glitch-amp: 1.5px;
}

html[data-theme="void"] {
  --page:       #000000;
  --surface:    #0a0a0a;
  --surface-2:  #121212;
  --ink:        #e4e4e4;
  --ink-2:      #9a9a9a;
  --ink-muted:  #5c5c5c;
  --gridline:   #1a1a1a;
  --border:     rgba(255, 255, 255, 0.08);
  --good:       #00e5a0;
  --danger:     #ff4d4d;

  --series-1: #00e5a0;
  --series-2: #4da6ff;
  --series-3: #b0b0b0;
  --series-4: #ff4d4d;
  --series-5: #7d8fff;
  --series-6: #ffffff;
  --series-7: #6ee7ff;
  --series-8: #8a8a8a;

  --accent-glow: #ffffff;
  --accent-glow-2: #00e5a0;
  --scanline-opacity: 0.02;
  --scanline-size: 6px;
  --bg-motif-opacity: 0.12;
  --glitch-amp: 1px;
}

/* Three light/white themes (added 2026-07-27) — same cyberpunk
   component system (scanlines, glitch title, series palette), just
   reversed onto light surfaces instead of dark ones. --page is a
   hair off pure white (never #ffffff) on all three: body::before's
   scanline effect uses mix-blend-mode:overlay, which mathematically
   leaves a pure-white base pixel completely unchanged regardless of
   blend color — a true #ffffff page would make the scanline layer
   invisible outright, not just faint. */
html[data-theme="prism"] {
  color-scheme: light;
  --page:       #f4f5fa;
  --surface:    #ffffff;
  --surface-2:  #eceffa;
  --ink:        #13151c;
  --ink-2:      #454958;
  --ink-muted:  #868ba0;
  --gridline:   #e1e4f0;
  --border:     rgba(255, 46, 166, 0.16);
  --good:       #0090c8;
  --danger:     #e0273f;

  --series-1: #0077ff; /* electric blue */
  --series-2: #ff2ea6; /* hot magenta */
  --series-3: #00b8a9; /* teal */
  --series-4: #7c3aed; /* violet */
  --series-5: #ff5e3a; /* orange-red */
  --series-6: #00a86b; /* acid green, darkened for contrast */
  --series-7: #d97706; /* amber-orange, darkened */
  --series-8: #2563eb; /* electric blue 2 */

  --accent-glow: #ff2ea6;
  --accent-glow-2: #0077ff;
  --scanline-opacity: 0.035;
  --scanline-size: 4px;
  --bg-motif-opacity: 0.25;
  --glitch-amp: 1.5px;
}

html[data-theme="frost"] {
  color-scheme: light;
  --page:       #fafafa;
  --surface:    #ffffff;
  --surface-2:  #f1f2f5;
  --ink:        #1a1d21;
  --ink-2:      #4b5058;
  /* #8a8f98 -> #6b7280 (2026-08-29, ux-ui-reviewer finding): the
     original measured ~3.1:1 against --page/--surface, below WCAG
     AA's 4.5:1 floor for text — it's used directly for small
     captions (e.g. landing's "Alpha capped at..." and the
     day-cycle's "on the loop"), not just as a decorative tint.
     #6b7280 keeps the same blue-gray hue, ~4.6-4.8:1 against both
     --page and --surface (computed via the WCAG relative-luminance
     formula, not eyeballed). */
  --ink-muted:  #6b7280;
  --gridline:   #e6e8eb;
  --border:     rgba(30, 41, 59, 0.12);
  --good:       #16a34a;
  --danger:     #dc2626;

  --series-1: #4f46e5; /* indigo */
  --series-2: #0d9488; /* teal */
  --series-3: #d97706; /* amber */
  --series-4: #dc2626; /* red */
  --series-5: #7c3aed; /* violet */
  --series-6: #059669; /* green */
  --series-7: #2563eb; /* blue */
  --series-8: #db2777; /* pink */

  --accent-glow: #4f46e5;
  --accent-glow-2: #0d9488;
  --scanline-opacity: 0.02;
  --scanline-size: 6px;
  --bg-motif-opacity: 0;
  --glitch-amp: 1px;
}

html[data-theme="paper"] {
  color-scheme: light;
  --page:       #f6f1e6;
  --surface:    #fbf8f0;
  --surface-2:  #efe7d6;
  --ink:        #2b2418;
  --ink-2:      #5c5236;
  --ink-muted:  #93876a;
  --gridline:   #e2d8c0;
  --border:     rgba(120, 72, 30, 0.16);
  --good:       #4d7c3a;
  --danger:     #a83232;

  --series-1: #9a3324; /* rust */
  --series-2: #5c6b2e; /* olive */
  --series-3: #2f4858; /* ink-blue */
  --series-4: #b8860b; /* ochre */
  --series-5: #6b4226; /* umber */
  --series-6: #3c6e47; /* forest green */
  --series-7: #7a4b8a; /* muted plum */
  --series-8: #8c5b3f; /* terracotta */

  --accent-glow: #9a3324;
  --accent-glow-2: #5c6b2e;
  --scanline-opacity: 0.015;
  --scanline-size: 6px;
  --bg-motif-opacity: 0;
  --glitch-amp: 0.75px;
}

/* Four more themes (added 2026-07-27), each picked for a mood a
   self-improvement/habit-tracking user actually cycles through
   rather than another visual variation on the same energy: Tide
   (calm reflection, e.g. a night check-in), Ember (morning
   motivation/momentum), Forge (discipline/deep-work focus), Bloom
   (the "growth" metaphor itself, taken literally). See docs.md's
   "Design system" section for the full rationale. */
html[data-theme="tide"] {
  --page:       #060f14;
  --surface:    #0b1a22;
  --surface-2:  #10232d;
  --ink:        #dff3f2;
  --ink-2:      #96c2c2;
  --ink-muted:  #567878;
  --gridline:   #172b33;
  --border:     rgba(94, 211, 199, 0.14);
  --good:       #4dd0c4;
  --danger:     #ff6b5e;

  --series-1: #4dd0c4; /* soft teal */
  --series-2: #5b9bd5; /* calm blue */
  --series-3: #8fd3c9; /* seafoam */
  --series-4: #7ea6c4; /* slate blue */
  --series-5: #b0e0d8; /* pale aqua */
  --series-6: #3f7d8c; /* deep teal */
  --series-7: #6fb8d1; /* sky blue */
  --series-8: #9fc9c2; /* muted mint */

  --accent-glow: #4dd0c4;
  --accent-glow-2: #5b9bd5;
  --scanline-opacity: 0.03;
  --scanline-size: 6px;
  --bg-motif-opacity: 0.2;
  --glitch-amp: 0.5px;
}

html[data-theme="ember"] {
  --page:       #0f0704;
  --surface:    #1c0f08;
  --surface-2:  #26140a;
  --ink:        #fff1e0;
  --ink-2:      #e8b98a;
  --ink-muted:  #8a6248;
  --gridline:   #331d0f;
  --border:     rgba(255, 140, 40, 0.18);
  --good:       #ffb020;
  --danger:     #ff3b2f;

  --series-1: #ff8c28; /* ember orange */
  --series-2: #ffcf4d; /* gold */
  --series-3: #ff5e3a; /* fire red-orange */
  --series-4: #ffa94d; /* amber */
  --series-5: #e8433a; /* deep red */
  --series-6: #ffe08a; /* pale gold */
  --series-7: #ff7043; /* coral-orange */
  --series-8: #c9432e; /* rust-red */

  --accent-glow: #ff8c28;
  --accent-glow-2: #ffcf4d;
  --scanline-opacity: 0.05;
  --scanline-size: 4px;
  --bg-motif-opacity: 0.4;
  --glitch-amp: 1.5px;
}

html[data-theme="forge"] {
  --page:       #0a0a0a;
  --surface:    #141414;
  --surface-2:  #1c1c1c;
  --ink:        #f0ede6;
  --ink-2:      #a8a39a;
  --ink-muted:  #6b675f;
  --gridline:   #262626;
  --border:     rgba(255, 176, 32, 0.16);
  --good:       #ffb020;
  --danger:     #ff4433;

  --series-1: #ffb020; /* tactical amber */
  --series-2: #d94f3c; /* rust red */
  --series-3: #8a8578; /* steel gray */
  --series-4: #c9c2b3; /* bone/light steel */
  --series-5: #ff7a1a; /* burnt orange */
  --series-6: #4a4640; /* dark steel */
  --series-7: #e0a83f; /* dull gold */
  --series-8: #6e6a60; /* gunmetal */

  --accent-glow: #ffb020;
  --accent-glow-2: #d94f3c;
  --scanline-opacity: 0.08;
  --scanline-size: 3px;
  --bg-motif-opacity: 0.15;
  --glitch-amp: 1px;
}

html[data-theme="bloom"] {
  color-scheme: light;
  --page:       #f5faf2;
  --surface:    #ffffff;
  --surface-2:  #eaf3e3;
  --ink:        #16261a;
  --ink-2:      #47614c;
  --ink-muted:  #839485;
  --gridline:   #dbe8d6;
  --border:     rgba(56, 142, 60, 0.16);
  --good:       #2e9e44;
  --danger:     #d1453b;

  --series-1: #2e9e44; /* fresh green */
  --series-2: #6fae2f; /* leaf green */
  --series-3: #1f7a5c; /* deep teal-green */
  --series-4: #c98a1f; /* sunflower gold */
  --series-5: #4a8f3c; /* moss green */
  --series-6: #3aa66d; /* mint green */
  --series-7: #8a5a2b; /* soil brown */
  --series-8: #5f8f6b; /* sage */

  --accent-glow: #2e9e44;
  --accent-glow-2: #6fae2f;
  --scanline-opacity: 0.02;
  --scanline-size: 6px;
  --bg-motif-opacity: 0;
  --glitch-amp: 0.75px;
}

/* Obsidian/Vellum (added 2026-08-27 as the app's only 2 modes,
   reversed 2026-08-28 back to 2 new themes alongside the original
   12 — direct request: "let's keep all the themes, later after
   launch we see which ones our users use more"). An Enlightenment
   cabinet-of-instruments identity (engraved brass plates, oxidized
   copper, ledger numerals) rather than literal ancient-Rome marble —
   see docs.md's "Design system" section for the full rationale.
   Each sets its own --font-display/--font-body so only these 2
   themes pick up Bodoni Moda/Instrument Sans (`body`'s font-family
   falls back to plain system-ui otherwise, unchanged for the
   original 12 — see that rule below). */
html[data-theme="obsidian"] {
  --page:       #100e12; /* obsidian */
  --surface:    #1b1820; /* basalt */
  --surface-2:  #23202a; /* basalt, elevated */
  --ink:        #ede7dd; /* bone */
  --ink-2:      #c7bfb0;
  --ink-muted:  #948b7c; /* ash */
  --gridline:   #241f29;
  --border:     rgba(176, 141, 62, 0.18);
  --good:       #5b8776; /* verdigris */
  --danger:     #c9604f; /* oxide */

  --series-1: #b08d3e; /* brass */
  --series-2: #5b8776; /* verdigris */
  --series-3: #c9604f; /* oxide/rust */
  --series-4: #6e8dae; /* steel blue */
  --series-5: #93789c; /* plum */
  --series-6: #7c8f5e; /* moss */
  --series-7: #8792a0; /* slate */
  --series-8: #b37569; /* rosewood */

  --accent-glow: #b08d3e;   /* brass — rank/honor accent */
  --accent-glow-2: #5b8776; /* verdigris — growth/completeness accent */
  --scanline-opacity: 0;
  --scanline-size: 4px;
  --bg-motif-opacity: 0;
  --glitch-amp: 0;

  --font-display: "Bodoni Moda", Georgia, "Times New Roman", serif;
  --font-body: "Instrument Sans", "Segoe UI", system-ui, sans-serif;
}

html[data-theme="vellum"] {
  color-scheme: light;
  --page:       #efe8da; /* vellum */
  --surface:    #f7f2e7; /* paper */
  --surface-2:  #fbf8f1;
  --ink:        #241f19;
  --ink-2:      #4a4335;
  --ink-muted:  #6b6154; /* umber */
  --gridline:   #e4dbc9;
  --border:     rgba(122, 90, 34, 0.2);
  --good:       #3f6656; /* verdigris, deepened */
  --danger:     #7a2e27; /* oxide, deepened */

  --series-1: #7a5a22; /* brass, deepened */
  --series-2: #3f6656; /* verdigris, deepened */
  --series-3: #7a2e27; /* oxide, deepened */
  --series-4: #3e5872; /* steel blue, deepened */
  --series-5: #5c4162; /* plum, deepened */
  --series-6: #48562f; /* moss, deepened */
  --series-7: #4c5560; /* slate, deepened */
  --series-8: #733f35; /* rosewood, deepened */

  --accent-glow: #7a5a22;   /* brass — rank/honor accent */
  --accent-glow-2: #3f6656; /* verdigris — growth/completeness accent */
  --scanline-opacity: 0;
  --scanline-size: 4px;
  --bg-motif-opacity: 0;
  --glitch-amp: 0;

  --font-display: "Bodoni Moda", Georgia, "Times New Roman", serif;
  --font-body: "Instrument Sans", "Segoe UI", system-ui, sans-serif;
}

/* Gasp (added 2026-09-11, direct request: "a pink theme with a pink
   background", creative direction left open). The 6th light theme, and
   the only one whose ground is a saturated hue rather than a neutral —
   prism/frost/paper/bloom/vellum all sit on near-white or warm off-white
   and carry their color in the accents. Gasp inverts that: the page IS
   the color (a blush ground), and the panels lift *toward* white, so the
   hierarchy reads as petals on skin rather than ink on paper.
   The name is the reaction, so the accents are the sharp intake — hot
   fuchsia and orchid violet at full saturation against a deep plum-black
   ink, no pastel-on-pastel mush. --good is jade and --danger a deep
   crimson: on a pink page a pink "danger" would be invisible as a
   signal, so both status colors are deliberately pushed out of the
   theme's own hue family (same reasoning as forge's amber-on-steel).
   No scanline (a HUD readout line reads as dirt on a blush ground) but
   the highest --bg-motif-opacity of any light theme: a flush rising from
   the lower edge, which is the one effect the name asks for. */
html[data-theme="gasp"] {
  color-scheme: light;
  --page:       #ffe7ef; /* blush — the ground is the color */
  --surface:    #fff6f9; /* petal, lifted toward white */
  --surface-2:  #ffdde9;
  --ink:        #2e0d1c; /* plum-black */
  --ink-2:      #6b2942;
  --ink-muted:  #8f5a70;
  --gridline:   #f6cddb;
  --border:     rgba(214, 51, 122, 0.20);
  --good:       #0e8a74; /* jade — out of the pink family on purpose */
  --danger:     #b3122f; /* deep crimson, not a bright pink-red */

  --series-1: #d6337a; /* hot fuchsia */
  --series-2: #a01f6b; /* raspberry */
  --series-3: #e05590; /* candy pink */
  --series-4: #7b3fa0; /* orchid violet */
  --series-5: #c2456f; /* rose */
  --series-6: #0e8a74; /* jade */
  --series-7: #e07b39; /* coral-amber */
  --series-8: #5b4bc4; /* periwinkle */

  --accent-glow: #d6337a;
  --accent-glow-2: #7b3fa0;
  --scanline-opacity: 0;
  --scanline-size: 6px;
  --bg-motif-opacity: 0.55;
  --glitch-amp: 0.5px;
}

* { box-sizing: border-box; }
/* Same guard as `body`'s own `overflow-x: clip` below, on `html`
   too, mobile-only (2026-09-01, direct report: the landing page's
   mobile hero was horizontally draggable on real Safari — dragging
   desyncs its `position: sticky` pin from the viewport and reads as
   a gap at one screen edge). `body`'s clip alone wasn't enough
   there: the hero's `.sc-stage` needs `overflow: visible` (both
   axes) so its rays can bleed past the fold into
   `.landing-lines-continue` below (that's the whole mechanism —
   see landing.html's own markup comment), and splitting that into
   `overflow-x: clip; overflow-y: visible;` on the sticky element
   itself — tried first, seemed to fix the drag — silently broke
   the vertical bleed instead on real Safari (direct follow-up
   report: "the lines dont growth bellow anymore"). Moved to `html`
   globally next — direct follow-up report, with a screenshot: the
   *desktop* hero's right hand/rays were now cut off well short of
   the screen edge, reproducible in headless Chromium this time.
   `body`'s own `overflow-x: clip` never caused that (desktop
   worked fine before any of this), only `html`'s did — some
   interaction between having both `html` and `body` clip the same
   axis at once and the hero's full-bleed `100vw` breakout, not
   fully diagnosed. Since the drag only ever happens on mobile
   (`.sc-stage` only goes `overflow: visible` there — see the
   `@media (max-width: 640px)` block below), scoping this to the
   same breakpoint fixes the drag without touching desktop at all.
   Mobile drag fix not yet confirmed on real hardware. */
@media (max-width: 640px) {
  html { overflow-x: clip; }
}
body {
  font-family: var(--font-body, system-ui), -apple-system, "Segoe UI", sans-serif;
  background: var(--page);
  color: var(--ink);
  max-width: 640px;
  margin: 0 auto;
  padding: 1.5rem 1rem 4rem;
  /* Guards the `.landing-topbar` full-bleed (`width: 100vw`) against
     producing a horizontal scrollbar on desktops with classic
     scrollbars. `clip`, not `hidden`: `hidden` would make `body` a
     scroll container and break `position: sticky` descendants
     (`.theme-switcher-bar`). Inner `overflow-x: auto` panes are
     unaffected — they scroll within their own box. */
  overflow-x: clip;
}
/* Large screens: let the page use real screen space instead of
   staying pinned to a phone-width column. Each step only widens the
   outer container — grids/lists inside just naturally get roomier;
   .form-grid below keeps forms from stretching into giant inputs.

   The steps are /habits' steps (2026-09-02, direct instruction:
   "lets make the habits margin (space from the elements and the
   screen), the standard for all other pages"). /habits looked wider
   than every other page because its card grid ignores this container
   entirely — it breaks out to 100vw and pads by
   `max(1rem, calc(50vw - 800px))`, so its content caps at 1600px and
   packs as many fixed 20rem tracks as fit. Rather than pick a new
   round number and land near it, each step below is exactly the card
   row width at that width — n * 20rem + (n-1) * --space-5 gap — so a
   page's content column and the habit cards sit on the same two
   vertical lines at any viewport, and the old 640/800/980/1180
   ladder's arbitrary numbers go away:
     2 tracks =  660px content, from  692px
     3 tracks = 1000px content, from 1032px
     4 tracks = 1340px content, from 1372px
   (Below 692px /habits drops the breakout entirely and runs one
   column at the normal content width — see the `max-width: 691px`
   reset further down — so there is nothing to match there.)

   Each `max-width` is the row width PLUS body's own `1rem` side
   padding, which under this sheet's border-box sizing lives inside
   max-width; that is why every number below equals its own
   breakpoint, and why they look 32px larger than the row widths
   listed above. Without that, every page sat exactly 16px inboard of
   the cards — measured, not assumed.

   There is no 5-track step: the grid's 1600px content cap can only
   ever fit four 20rem tracks (5 would need 1680px), so 1340px is
   where /habits itself tops out and therefore where the page does. */
@media (min-width: 692px)  { body { max-width: 692px; } }
@media (min-width: 1032px) { body { max-width: 1032px; } }
@media (min-width: 1372px) { body { max-width: 1372px; } }

/* Atmospheric layers — position:fixed always creates its own stacking
   context, and z-index:-1 places that context below body's normal,
   non-positioned content (per CSS painting order: negative z-index
   positioned descendants paint before in-flow static descendants),
   so real content always renders above these with no extra markup. */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: repeating-linear-gradient(
    to bottom,
    var(--ink) 0,
    var(--ink) 1px,
    transparent 1px,
    transparent var(--scanline-size)
  );
  opacity: var(--scanline-opacity);
  mix-blend-mode: overlay;
  animation: scanline-drift 9s linear infinite;
}
@keyframes scanline-drift {
  from { background-position: 0 0; }
  to   { background-position: 0 calc(var(--scanline-size) * 4); }
}
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  opacity: var(--bg-motif-opacity);
}
html[data-theme="hacker"] body::after { opacity: 0; }
html[data-theme="arcade"] body::after {
  background:
    radial-gradient(ellipse at 20% -10%, color-mix(in srgb, var(--series-3) 18%, transparent), transparent 55%),
    radial-gradient(ellipse at 90% 110%, color-mix(in srgb, var(--series-1) 14%, transparent), transparent 55%);
}
html[data-theme="noir"] body::after {
  background: radial-gradient(ellipse at 50% 20%, transparent 0%, transparent 40%, color-mix(in srgb, #000 70%, transparent) 100%);
}
html[data-theme="synthwave"] body::after {
  background-image: linear-gradient(180deg, transparent 55%, color-mix(in srgb, var(--series-2) 30%, transparent) 100%);
  mask-image: linear-gradient(180deg, transparent 0%, transparent 55%, black 100%);
}
html[data-theme="void"] body::after {
  background: radial-gradient(ellipse at 50% 0%, color-mix(in srgb, #ffffff 6%, transparent), transparent 60%);
}
html[data-theme="prism"] body::after {
  background:
    radial-gradient(ellipse at 15% -10%, color-mix(in srgb, var(--series-2) 16%, transparent), transparent 55%),
    radial-gradient(ellipse at 90% 110%, color-mix(in srgb, var(--series-1) 14%, transparent), transparent 55%);
}
html[data-theme="frost"] body::after { opacity: 0; }
html[data-theme="paper"] body::after { opacity: 0; }
html[data-theme="tide"] body::after {
  background: radial-gradient(ellipse at 50% 100%, color-mix(in srgb, var(--series-1) 14%, transparent), transparent 60%);
}
html[data-theme="ember"] body::after {
  background: radial-gradient(ellipse at 50% 110%, color-mix(in srgb, var(--series-1) 22%, transparent), transparent 60%);
}
html[data-theme="forge"] body::after {
  background: radial-gradient(ellipse at 50% 50%, transparent 0%, transparent 45%, color-mix(in srgb, #000 60%, transparent) 100%);
}
html[data-theme="bloom"] body::after { opacity: 0; }
/* Gasp is the one light theme with a visible motif (the others suppress
   theirs): a fuchsia flush rising from the bottom edge and an orchid
   wash at the top, so the blush ground deepens at both ends of the page
   instead of sitting flat. Same two-radial technique as arcade/prism,
   tuned warm. */
html[data-theme="gasp"] body::after {
  background:
    radial-gradient(ellipse at 50% 115%, color-mix(in srgb, var(--series-1) 30%, transparent), transparent 60%),
    radial-gradient(ellipse at 12% -12%, color-mix(in srgb, var(--series-4) 16%, transparent), transparent 55%);
}

h1 {
  font-family: var(--font-display);
  font-size: var(--text-lg);
  font-weight: var(--weight-semibold);
  margin: 0 0 var(--space-2);
}
h4 {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin: var(--space-4) 0 var(--space-2);
}

a { color: var(--series-1); }

a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible,
.check-btn:focus-visible,
.numeric-pill:focus-visible,
summary:focus-visible {
  outline: 2px solid var(--series-1);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}
/* The seal is square (2026-09-13), so the shared rounded-square ring
   above is already the right shape — it only wants a little more air. */
.seal-btn:focus-visible { outline-offset: 3px; }

form.inline { display: inline; }
.day-checkboxes { display: flex; gap: var(--space-2); flex-wrap: wrap; margin-top: var(--space-1); }
.day-checkbox { display: flex; align-items: center; gap: var(--space-1); font-size: var(--text-sm); font-weight: var(--weight-regular); }

input[type="checkbox"] {
  width: 1.15rem;
  height: 1.15rem;
  appearance: none;
  -webkit-appearance: none;
  border: 1.5px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--page);
  display: inline-block;
  position: relative;
  cursor: pointer;
  margin: 0;
  vertical-align: middle;
  transition: background-color 120ms ease, border-color 120ms ease;
}
input[type="checkbox"]:checked {
  background: var(--series-1);
  border-color: var(--series-1);
}
input[type="checkbox"]:checked::after {
  content: "";
  position: absolute;
  left: 0.32rem;
  top: 0.1rem;
  width: 0.3rem;
  height: 0.55rem;
  border: solid var(--surface);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}
input[type="checkbox"]:focus-visible {
  outline: 2px solid var(--series-1);
  outline-offset: 2px;
}
/* align-items: flex-start, not center (UX audit, 2026-08-13): center
   only looked right because every checkbox label in the app so far
   fit on one line. Once a label wraps (e.g. the Ad hoc box's live-mode
   checkbox on mobile), centering against the label's full block
   height strands the checkbox beside a middle line instead of the
   first word of the sentence it belongs to. flex-start plus a small
   top nudge on the checkbox itself keeps it next to line one
   regardless of how many lines the label wraps to. */
label:has(> input[type="checkbox"]) {
  display: inline-flex;
  align-items: flex-start;
  gap: var(--space-2);
  font-weight: var(--weight-regular);
  width: auto;
}
label:has(> input[type="checkbox"]) input[type="checkbox"] { margin-top: 0.15rem; }

/* input[type="radio"] has no custom appearance (unlike checkbox
   above) — added 2026-08-29, UX audit finding: the generic
   `input, select, textarea { width: 100%; }` rule above had no
   carved-out exception for it, so its invisible click target
   stretched to the row's full width, shoving the label text into a
   seemingly-random right-aligned column. Onboarding step 7's
   habit-picker is this app's first native radio-button UI, which
   is why this went unnoticed until now. */
input[type="radio"] { width: auto; }
.form-grid {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  margin-top: var(--space-3);
  max-width: 34rem;
}
/* Flex items default to an "automatic minimum size" of their
   content's min-content width, not 0 — same mechanism already
   worked around at .tasks-week-day/.task-details (search
   min-width: 0 above). A <label> wrapping a native
   input[type="time"]/"date" inherits that input's own min-content
   width (the browser's internal hour/minute/AM-PM/calendar
   rendering, which reports a fairly wide floor), so on narrow
   mobile widths the label — and the input stretched to 100% of
   it — got held at that floor and overflowed the .habit-card
   instead of shrinking to the actual available column width.
   Zeroing it here lets the label (and anything else in a
   .form-grid) shrink to fit; width: 100% below still makes it
   fill the space normally at every width that isn't cramped. */
.form-grid > * { min-width: 0; }
/* Compact row layout for a ProgramExerciseSet's edit form (added
   2026-08-14) — 3-4 short numeric fields repeated once per set row
   read far better side by side than stacked, the way the default
   .form-grid lays out a program/exercise's own handful of longer
   fields. Each label gets a fixed-ish column (wraps on narrow
   viewports via the parent's own flex-wrap, not a media query) so a
   page of several sets stays scannable as a loose table rather than
   several screens of vertical stacking. */
.form-grid-inline { flex-direction: row; flex-wrap: wrap; align-items: flex-end; max-width: none; }
/* label:has(> input[type="checkbox"]) (above) already sets width:
   auto for a checkbox label globally, so it naturally sizes to its
   own content here without needing its own selector — only the
   text-input labels need the fixed-ish column width. */
.form-grid-inline > label:not(:has(> input[type="checkbox"])) { width: auto; flex: 0 1 8rem; }
label {
  display: block;
  color: var(--ink-2);
  font-size: var(--text-sm);
  font-weight: var(--weight-medium);
}
input, select, textarea {
  font: inherit;
  font-size: var(--text-sm);
  font-weight: var(--weight-regular);
  color: var(--ink);
  background: var(--page);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: var(--space-2) var(--space-3);
  margin-top: var(--space-1);
  width: 100%;
}
textarea {
  font-family: var(--font-mono);
  resize: vertical;
}
input:focus, select:focus, textarea:focus {
  border-color: var(--series-1);
  outline: none;
}
/* [readonly], not :read-only (2026-09-04). `:read-only` matches any
   input that isn't text-editable, and checkbox/radio/file/range/
   submit/button inputs can't take the readonly attribute at all — so
   they matched permanently, and this rule (later, equal specificity)
   beat the `cursor: pointer` set on checkboxes ~120 lines above. Every
   one of them rendered with a not-allowed cursor and the greyed-out
   fill. The attribute selector only matches a real readonly, and the
   readOnly IDL property reflects to it, so JS-set readonly still hits. */
input[readonly], textarea[readonly] {
  background: var(--surface-2);
  color: var(--ink-muted);
  cursor: not-allowed;
}
input[readonly]:focus, textarea[readonly]:focus {
  border-color: var(--border);
}
input[type="time"], input[type="number"], input[type="date"] {
  font-family: var(--font-mono);
  /* Belt-and-suspenders alongside .form-grid > * min-width: 0
     above: even once the label can shrink, cap the input itself
     so it can never render past its own box on a narrow phone. */
  max-width: 100%;
}

button, .btn {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  background: transparent;
  color: var(--ink-2);
  border: 1px solid var(--ink-muted);
  border-radius: var(--radius-sm);
  padding: var(--space-2) var(--space-4);
  cursor: pointer;
  transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 80ms ease;
}
button:hover { border-color: var(--ink); color: var(--ink); }
button:active { transform: scale(0.97); }
.btn-primary {
  border-color: var(--series-1);
  color: var(--series-1);
}
.btn-primary:hover {
  background: var(--series-1);
  color: var(--page);
}
.btn-danger {
  border-color: var(--danger);
  color: var(--danger);
}
.btn-danger:hover { background: var(--danger); color: var(--page); }
.btn-sm { padding: var(--space-1) var(--space-2); font-size: 0.66rem; }

.form-actions {
  display: flex;
  gap: var(--space-2);
  margin-top: var(--space-4);
  align-items: center;
}
/* Habit edit form's Save + Delete row (added 2026-07-30, UX audit's
   #1 punch-list item — Save/Delete sat button-adjacent with only
   var(--space-2) between them, an easy mis-click). Pushes Delete
   to the opposite end of the row instead of relocating it
   elsewhere in the panel — a native confirm() already guards the
   actual delete (see the form's onsubmit in _widget_habits.html),
   so this is purely about making a careless click land on Save,
   not about adding another safety step. */
.habit-edit-actions { justify-content: space-between; }

details > summary {
  cursor: pointer;
  color: var(--ink-2);
  font-size: var(--text-sm);
}
details[open] > summary { color: var(--ink); }
.details-content > form, .details-content > h4, .details-content > .history-list { margin-top: var(--space-3); }

.habit-card {
  position: relative;
  background: var(--surface);
  /* The Instrument Plate (added 2026-08-27) — a hairline metal frame
     on every card, paired with the corner brackets below, so the
     card itself reads as a plate screwed to the page rather than a
     generic rounded panel. */
  border: 1px solid var(--border);
  border-radius: 0;
  padding: var(--space-4) var(--space-5);
  margin-bottom: var(--space-5);
}
/* Now that the heatmap is a small fixed size (not stretched to card
   width), a habit-card no longer needs the full page width — lay
   cards out side by side once there's room for more than one.
   Fixed 20rem tracks (not minmax(20rem, 1fr)) so a column never
   stretches past what the card actually uses — a flexible 1fr track
   left a wide gray gap to the right of each card whenever fewer than
   a full row's worth of cards were present (e.g. 2 cards splitting a
   3-card-wide row 1fr/1fr each, each far wider than the 20rem the
   card itself is capped at). Fixed tracks pack as many real
   20rem-wide cards per row as actually fit, with only the normal
   container-edge remainder left over, not a per-card gap; auto-fit
   still collapses to a single column on mobile for free.
   Full-bleed breakout (added 2026-07-22): body's own max-width step
   (up to 1180px) was still capping how many fixed 20rem columns
   could fit even on much wider real desktop viewports — reported by
   the user as habit cards "not fitting the screen, even when
   clearly there is space there." Same width:100vw/negative-margin
   technique the Tasks week grid used before it was reverted to
   match its sibling panels' width — but here the whole point is the
   opposite: let the grid actually use the full viewport instead of
   matching anything else on the page. Only `.habit-cards` itself
   breaks out; the widget header, legend, and "+ New habit" form
   stay at the normal content width.
   justify-content: center (added 2026-07-22, same session): fixed
   20rem tracks mean auto-fit's column count is floor(available /
   (20rem + gap)) — whenever that division isn't exact (the normal
   case), the leftover fractional-track width doesn't distribute
   anywhere; with the default start-aligned packing it all landed as
   one lump of blank space after the last card (up to just under a
   full card's width, confirmed via measurement — e.g. ~260px of
   pure leftover at a 1920px viewport, on top of the side padding,
   reported by the user as "big blank space on the right"). Centering
   splits that same leftover evenly between both edges instead —
   doesn't reclaim the space (a habit's worth of empty width will
   always be possible whenever the card count doesn't exactly fill
   the row), but it no longer reads as lopsided/broken.

   Owner moved from `.habit-cards` to `.habit-group` (2026-09-02,
   direct user report: "this un alignement is un harmonic. make the
   title on the same line as the habits"). The group heading sat at
   the normal content width while this grid broke out to 100vw and
   centred its tracks, so the title's accent bar and the first card's
   left edge landed on two different vertical lines, drifting apart
   by half the grid's leftover as the viewport changed. Now the
   *group* is the grid, `.habit-cards` is display:contents (a pure
   DOM handle for the drag-reorder / instant-action JS, which only
   ever uses events and insertBefore — never the container's box),
   and the heading block is one grid item spanning the full row, so
   its left edge IS track 1's left edge by construction rather than
   by two formulas agreeing. Spanning 1/-1 also stops auto-fit from
   collapsing empty tracks, so a user with fewer habits than fit in
   one row now gets them packed under the heading instead of centred
   away from it — the fractional-track leftover is still split
   evenly by justify-content, which is what the 2026-07-22 note
   above is actually about. */
.habit-group {
  display: grid;
  grid-template-columns: repeat(auto-fit, 20rem);
  justify-content: center;
  gap: var(--space-5);
  /* The DEFAULT stays `start`, and only the cards opt into stretching
     (below). The group's grid also carries tips and the enroll/new-habit
     toggles; stretching those to a card's height blows a dismissible
     one-line hint up into an empty panel. */
  align-items: start;
  width: 100vw;
  margin-left: calc(50% - 50vw);
  box-sizing: border-box;
  padding: 0 max(1rem, calc(50vw - 800px));
}
.habit-group > .habit-cards { display: contents; }
/* Heading + explainer + that group's action button, as one item on
   the card grid's own first line. Its own internal spacing is
   untouched; only the trailing margin goes, because the grid's
   row-gap (--space-5) now provides exactly the gap the action's
   margin-bottom used to. */
.habit-group-lead { grid-column: 1 / -1; }
.habit-group-lead > :last-child { margin-bottom: 0; }
.habit-group > .empty-note { grid-column: 1 / -1; }
.habit-cards > .habit-card { margin-bottom: 0; }
/* Every card in a row is as tall as the tallest one in it (2026-09-14,
   direct request: "I want all the ranked habits to have the same size").
   Measured on the preview beforehand, one row of ranked plates ran
   162.8 / 175.4 / 192.3px — a habit with a cue line, one with a claim
   that wrapped, one with neither — so the group started on a shared line
   and finished ragged. `align-self` on the cards rather than
   `align-items` on the group, so the tips and toggles sharing that grid
   keep their own content height. Where a ranked plate's extra height
   goes is decided by its own rule further down: the progress line is
   pushed to the foot, so a row's rails land on one baseline too. */
.habit-cards > .habit-card { align-self: stretch; }
/* Below 692px only one 20rem track can fit (2*320 + 1.25rem gap +
   2*1rem padding = 692), so the full-bleed breakout buys nothing and
   actively hurts: the fixed 320px track centred inside a 100vw row
   put the card at left:35 while the widget heading, legend and
   "+ New habit" all sit at left:16 — visibly out of line on a phone.
   Below that width, drop the breakout and let one column fill the
   normal content box so every element in the widget shares an edge. */
@media (max-width: 691px) {
  .habit-group {
    width: auto;
    margin-left: 0;
    padding: 0;
    grid-template-columns: 1fr;
  }
}
.habit-card::before, .habit-card::after {
  content: "";
  position: absolute;
  width: 11px;
  height: 11px;
  pointer-events: none;
}
.habit-card::before {
  top: 0; left: 0;
  border-top: 2px solid var(--habit-color, var(--accent-glow));
  border-left: 2px solid var(--habit-color, var(--accent-glow));
}
.habit-card::after {
  bottom: 0; right: 0;
  border-bottom: 2px solid var(--habit-color, var(--accent-glow));
  border-right: 2px solid var(--habit-color, var(--accent-glow));
}
/* ...except on habit cards themselves (added 2026-09-02, direct
   user ask: "remove those color edges from the habits"). There the
   brackets pick up each habit's own --habit-color, so a grid of
   cards read as a set of mismatched colored corners rather than one
   instrument plate — the habit's color is already carried by the
   monogram, check button, heatmap and chart. Scoped to the three
   `.habit-cards` containers (/habits' ranked + normal grids, home's
   .home-habit-rows), so task cards, train-hub cards and every other
   .habit-card in the app keep the corner-bracket motif. */
.habit-cards > .habit-card::before,
.habit-cards > .habit-card::after { content: none; }
.habit-card summary {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}
.habit-card summary:hover { color: var(--ink); }
.habit-card header {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-3);
}
.monogram {
  flex: none;
  width: 2.4rem;
  height: 2.4rem;
  border: 1px solid var(--habit-color, var(--series-1));
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: 1rem;
  font-weight: var(--weight-semibold);
  color: var(--habit-color, var(--series-1));
  background: transparent;
}
/* Icon square (2026-09-09, direct request: "make it so he is able to
   choose emojis to be on the square instead of letter", then "make
   custom emojis that resonate more with our aesthetic").

   The presets are text-presentation glyphs, not colour emoji, exactly
   so this rule can be almost nothing: they keep .monogram's mono face
   and its --habit-color ink, so a habit wearing ◆ reads as the same
   instrument mark as one wearing its letter, in the same colour. Only
   the size moves — a glyph like ▲ or ☀ is drawn to a smaller optical
   size than a capital, so at the letter's 1rem it floats in the box.

   A colour emoji pasted into the picker's free-text box still works:
   colour-font glyphs carry their own palette and ignore `color`
   entirely, so they land at this size and simply keep their colours. */
.monogram.has-icon {
  font-size: 1.3rem;
  line-height: 1;
}
.habit-title { flex: 1; min-width: 8rem; display: flex; flex-direction: column; }
.habit-header-actions {
  flex: none;
  display: flex;
  align-items: center;
  gap: var(--space-3);
}
.habit-title .name {
  display: block;
  font-family: var(--font-mono);
  font-size: var(--text-md);
  font-weight: var(--weight-semibold);
}
.habit-title .reminders {
  display: block;
  order: -1;
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: var(--weight-medium);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
/* The off state is dimmer than the on one, so the two read apart at a
   glance instead of only by reading the word "NOT" (2026-09-09). It
   stays text rather than becoming a colour-only signal — the word is
   still what carries the meaning, per the contrast rules the rest of
   these eyebrows follow. */
.habit-title .reminders.is-off { opacity: 0.55; }
/* The cue is a full-width row of the header, not a line inside
   `.habit-title` (2026-09-14) — same place on screen, under the name, but
   its height is now the card's own rather than the title column's. See
   habit_card() for why that is what lines the claim sentences up.

   `flex: 1 1 100%` takes a whole line of the wrapping header, which also
   hands the cue 280px instead of the 177px it had between the two marks —
   enough for a real cue to stay on ONE line.

   `min-height` is the reservation: the span is rendered on every ranked
   card, empty when the habit has no cue yet, and an empty block would
   otherwise collapse to nothing and put that card's claim back above its
   neighbours'. `1lh` is exactly one line box of this element; the `em`
   value before it is the fallback for anything without the unit. */
.habit-cue {
  flex: 1 1 100%;
  display: block;
  min-height: 1.2em;
  min-height: 1lh;
  font-size: 0.78rem;
  font-style: italic;
  color: var(--ink-muted);
}
.completeness {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-muted);
  white-space: nowrap;
  text-align: right;
  line-height: 1.5;
}
.completeness-dot {
  display: inline-block;
  width: 0.4rem;
  height: 0.4rem;
  margin-right: 0.3rem;
  background: var(--ink-muted);
}
.completeness .completeness-value { color: var(--ink); }
{#- The Gauge (added 2026-08-27) — a build habit's completeness lights up
    verdigris, not an arbitrary series color: verdigris is patina,
    formed by accumulation over time, so it's the one metal that
    means "consistency" everywhere it appears, independent of
    whichever --series-N the habit itself was colored. -#}
.completeness.active .completeness-dot {
  background: var(--accent-glow-2);
  animation: blink-dot 1.8s ease-in-out infinite;
}
.completeness.active .completeness-value { color: var(--accent-glow-2); }

{#- The Seal (added 2026-08-27) — the same instrument-plate system's
    other reading: a "bad"/break ranked habit gets a struck brass
    medallion instead of a rising gauge, since resisting something
    is a rank/honor, not an accumulation. Since 2026-09-12 it holds the
    completeness fraction (hits in the ring, "of 14" under it) in place
    of the streak it used to show — same markup, same treatment, a
    different number. -#}
.completeness-seal {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  white-space: nowrap;
}
.seal-ring {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.75rem;
  height: 1.75rem;
  border-radius: 50%;
  border: 1px solid var(--accent-glow);
  color: var(--accent-glow);
  font-family: var(--font-display);
  font-size: 0.8rem;
  font-weight: var(--weight-semibold);
  line-height: 1;
}
.seal-label {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-muted);
  text-align: right;
  line-height: 1.2;
}
.seal-label .seal-days { display: block; color: var(--ink); }

.quick-action { flex: none; }

/* THE ACTION CLUSTER — claim, badge, die on one baseline (2026-09-13,
   direct markup: the badge/die/rung corner circled as "not pretty and
   harmonic"; direct pick: "one line: claim, then die").

   The claim leads and the instruments follow it, pushed right. When a
   claim is present the cluster takes the full width of its wrapping
   header, so it gets a row of its own under the habit's name instead of
   competing with the title for the same line. `:has()` is what decides,
   so no template has to pass a flag and no habit without a rung wording
   changes at all — the selector simply never matches for them.

   Scoped to the two containers that opt in (the card's header actions and
   the habit page's), NOT to `.quick-action` itself: /home's condensed rows
   still draw the claim inside the form, where a phone row has no width for
   a sentence beside the die. */
/* THE CARD keeps a row of its own: a 20rem card track has no room for
   badge + sentence beside the title.

   UNCONDITIONAL since 2026-09-14 (was `:has(.habit-claim)`). The die left
   this cluster for the header line that day — see _macros.html's
   habit_card() — and the only thing that can push it off the card's right
   edge is another item sharing that line. The badge is data-width ("0 / 4"
   vs "10 / 12"), so if the cluster were allowed back up beside the title
   whenever a card had no claim, the die's x would go back to being set by
   the badge's contents on exactly those cards. Sending the cluster down
   every time is what makes the die's position a constant. */
.habit-header-actions {
  flex: 1 1 100%;
  min-width: 0;
  /* Neither actions container sets wrap by default; the claim needs it to
     be able to take a line of its own when the row runs out of width. */
  flex-wrap: wrap;
}
/* Claim left, badge right, kebab after it — the reading order the position
   study rendered. `margin-left: auto` rather than `justify-content`,
   because a card with no claim would otherwise push its badge to the LEFT
   edge of the row instead of the right. */
.habit-header-actions .completeness { margin-left: auto; }
/* The die, now a direct child of the header. `.habit-title` beside it is
   `flex: 1`, so this already sits hard right; the auto margin is what
   holds it there on a card with no title block to absorb the slack. */
.habit-card > header > .quick-action { margin-left: auto; }
/* Room for the kebab, on the cards that have one (2026-09-14).
   `details.habit-details` is absolutely positioned at the card's
   top-right, in the padding gutter — which was free until the die moved
   onto the header line and claimed the same corner (measured overlap:
   12px across, 16px down). A ranked card carries no trigger at all
   (`.habit-details-triggerless`, 2026-09-09), so it is the only card that
   can keep the die hard against its own edge, and it does: this reserves
   the gutter on every OTHER card instead of insetting all of them, which
   would have pulled the bookend off the plate Michel actually approved.
   20px clears the kebab's left edge (card right − 32) by 8. */
.habit-card:not(.habit-card-ranked) > header { padding-right: var(--space-5); }
/* THE HABIT PAGE keeps the cluster UP on the header line, beside the name
   (2026-09-13, direct request: "I want the mark button to be higher on the
   screen. Right now, it is a bit on the bottom"). It had been dropping to
   a full-width row under the title, which pushed the one control on the
   plate down past the eyebrow. It shares the header's free space with the
   title block instead, and only wraps below when the viewport is too
   narrow to hold both. */
.habit-page-actions:has(.habit-claim) {
  /* `0 1 auto`, not `1 1 auto`: the title block beside it is `flex: 1`, so
     letting it take the slack carries the cluster to the plate's right
     edge. Sharing the slack instead left it floating mid-plate with a
     wide dead gap after the kebab, which had always sat on the edge. */
  flex: 0 1 auto;
  min-width: 0;
  flex-wrap: wrap;
}
.habit-claim {
  flex: 0 1 auto;
  min-width: 0;
  /* The body face, sentence case. The badge beside it is mono uppercase;
     before this they were the same voice at nearly the same size and the
     header carried four such labels with nothing outranking anything. */
  font-size: var(--text-xs);
  line-height: 1.35;
  color: var(--ink-2);
  text-align: left;
}
/* On a ranked plate the claim is the only sentence on the card, and the
   rung is what the whole plate is about — so it takes full ink. */
.habit-card-ranked .habit-claim,
.habit-page-plate.is-ranked .habit-claim { color: var(--ink); }
/* The night an overnight habit's mark will land on, stamped in front of the
   claim (2026-09-14 — see _macros.html's habit_card()). Mono and one step
   back in ink so the claim still reads as the sentence and this reads as
   the date on it; `nowrap` because "Last night · Sun 14" breaking across
   two lines is the one way this could be read as part of the claim. It
   keeps --ink-2 on a ranked plate, where the claim itself goes to --ink —
   that contrast is the whole job. */
.habit-claim-when {
  /* THE STAMP IS PART OF THE SENTENCE, so it takes the sentence's face and
     size and is set apart by ink alone (2026-09-14, direct report: "I'm
     finding the last night's Sun 13 — it's not harmonic with the rest of
     the phrase").

     It was mono at 0.9em, and measuring the two runs is what found the
     fault: cap heights 9px vs the claim's 10px, so the stamp sat visibly
     shorter than the words it introduces, and at that size the mono also
     renders thin and wide-tracked against a denser body face — three
     mismatches reading as one wrong thing. At equal size the two faces'
     caps agree exactly (10/10) and their x-heights already did (7/7).

     The deeper reason it was wrong is the rule the `.habit-claim` block
     above already states: mono is this header's LABEL voice (the badge,
     the tier, "ON YOUR CALLS"), the body face is its sentence voice. The
     stamp belongs to the sentence, so a mono run inside it was a third
     voice on a line that had deliberately been reduced to one. */
  color: var(--ink-2);
  white-space: nowrap;
  /* The gap is CSS, not a space character in the markup: the stamp and the
     claim sit in one inline run, and the literal space between them was
     being eaten — "Sun 13Stayed in my room" (caught by looking, on the
     8012 preview, before this rule existed). */
  margin-right: 0.45em;
}
/* The body face sets `·` tighter than the mono did, closing "night · Sun"
   to "night·Sun". Explicit side padding rather than word-spacing, which
   would also stretch the space inside "Last night", and rather than literal
   spaces in the markup, which is what the stamp/claim gap already proved
   unreliable here. 0.32em reads as slightly more than a word space (a space
   is ~0.25em in this face), which is what a separator wants. Deliberately
   NOT aria-hidden: hiding it would run "Last night" and "Sun 13" together
   with nothing between them for a screen reader. */
.habit-claim-sep {
  padding: 0 0.32em;
}
/* Narrow: the sentence keeps the row, the instruments drop under it
   rather than squeezing the words into a two-character column. */
/* Narrow: the sentence drops onto its own line UNDER the instruments
   rather than being squeezed between them. `order: 1` carries it past the
   kebab, which keeps its place on the instrument line — without the
   `flex-wrap` above, that same order only shoved the sentence to the far
   side of the kebab on one crowded line, which is how it first shipped. */
@media (max-width: 560px) {
  .habit-claim { flex: 1 1 100%; order: 1; margin-top: var(--space-1); }
}

.check-btn, .numeric-pill {
  height: 2.25rem;
  border: 1px solid var(--habit-color, var(--series-1));
  background: transparent;
  color: var(--habit-color, var(--series-1));
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease, transform 80ms ease;
}
.check-btn {
  padding: 0 var(--space-3);
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.check-btn.done {
  background: var(--good);
  border-color: var(--good);
  color: var(--page);
}
.check-btn:hover { background: color-mix(in srgb, var(--habit-color, var(--series-1)) 15%, transparent); }
.check-btn:active { transform: scale(0.96); }

.numeric-pill {
  min-width: 2.4rem;
  padding: 0 var(--space-3);
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-1);
  text-decoration: none;
  white-space: nowrap;
}
.numeric-pill.logged {
  background: var(--good);
  border-color: var(--good);
  color: var(--page);
}
.numeric-pill:hover { background: color-mix(in srgb, var(--habit-color, var(--series-1)) 15%, transparent); }
.numeric-pill:active { transform: scale(0.96); }

/* ── The Strike ────────────────────────────────────────────────────
   The mark button, everywhere it appears (2026-09-13) — /home's rows,
   /habits' cards and the habit's own page all render hold_seal() out of
   quick_action(). A press-and-hold seal: .seal-sweep-fill is a ring that
   app.js closes over 600ms, and only a closed ring writes the entry.

   The ring is driven by stroke-dashoffset set inline per frame, so there
   is NO transition on it — a transition would lag the finger. The two
   things that DO animate are the face sinking under the press and the
   strike itself. See _macros.html's hold_seal() for why this doesn't
   carry .check-btn. */
.seal-btn {
  position: relative;
  /* ONE square, the same 2.4rem box the rank/letter monogram draws
     (2026-09-13). Was 2.6rem holding a 2.05rem face — two concentric
     squares where every other mark in the app is one. */
  width: 2.4rem;
  height: 2.4rem;
  flex: none;
  padding: 0;
  border: 0;
  background: transparent;
  display: grid;
  place-items: center;
  cursor: pointer;
  /* The browser must not claim the drag: a hold on a touch screen would
     otherwise scroll the page out from under the seal. */
  touch-action: none;
  -webkit-tap-highlight-color: transparent;
}
.seal-sweep {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  pointer-events: none;
}
.seal-sweep-track { fill: none; stroke: var(--border); stroke-width: 1; }
.seal-sweep-fill {
  fill: none;
  stroke: var(--habit-color, var(--series-1));
  stroke-width: 2;
  stroke-linecap: butt;
  /* No rotation: a rect's path already starts at its top-left corner. */
}
.seal-face {
  position: relative;
  /* The face IS the square now — it fills the button and the sweep runs
     on its edge, rather than being a smaller box inside a ring. */
  width: 100%;
  height: 100%;
  border: 1px solid var(--habit-color, var(--series-1));
  color: var(--habit-color, var(--series-1));
  display: grid;
  place-items: center;
  font-family: var(--font-mono);
  /* A GLYPH at rest, not a word (2026-09-13): the label used to be sized
     for the longest word any language puts here — "Marcar", "Segure" —
     which drove it to 0.46rem, ~7px, the smallest type anywhere in uful.
     A tick needs no translating and no shrinking. The hold hint still
     swaps a word in (app.js), which is why this is not smaller still. */
  /* 0.95rem since 2026-09-14, matching the rank numeral beside it
     ("one is bigger than the other"). Both boxes had always measured
     38.4px, but the numeral's ink filled its frame while the tick drew
     about 7px of ink in the same box, so the die read as the smaller,
     emptier twin. Only the GLYPH is this size — the hold hint swaps a
     WORD in ("Hold", "Segure"), which would not fit a 38.4px box at this
     size and is dropped back down by the [data-hinting] rule below. */
  font-size: 0.95rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.02em;
  text-transform: uppercase;
  white-space: nowrap;
  transition: transform 140ms cubic-bezier(0.2, 0.8, 0.3, 1),
              background-color 200ms ease, color 200ms ease;
}
.seal-btn:hover .seal-face {
  background: color-mix(in srgb, var(--habit-color, var(--series-1)) 12%, transparent);
}
/* Under the finger the die sinks. */
.seal-btn[data-holding="true"] .seal-face { transform: scale(0.92); }

/* Struck: filled in the app's own done colour, so a marked habit reads
   exactly as it did when this was a filled pill. The inset highlight and
   shadow are the impression — light catching a struck face. */
.seal-btn.is-struck .seal-face,
.seal-btn[data-struck="true"] .seal-face {
  background: var(--good);
  border-color: var(--good);
  color: var(--page);
  box-shadow: inset 0 1px 0 color-mix(in srgb, #fff 30%, transparent),
              inset 0 -1px 0 rgba(0, 0, 0, 0.28);
}
.seal-btn[data-struck="true"] .seal-face {
  animation: seal-struck 380ms cubic-bezier(0.16, 1.4, 0.3, 1) 1;
}
@keyframes seal-struck {
  0%   { transform: scale(0.86); }
  55%  { transform: scale(1.06); }
  100% { transform: scale(1); }
}
/* The closed ring leaves WITH the strike rather than disappearing with
   the fragment swap (2026-09-13, "the animation ends abruptly"). The
   struck seal the server sends back carries no ring at all — hold_seal()
   renders the sweep only for a holdable one — so without this the line
   the user spent 600ms closing simply blinked out of existence on the
   swap. `forwards` holds it at zero until then. */
.seal-btn[data-struck="true"] .seal-sweep-fill {
  animation: seal-ring-out 340ms ease-out forwards;
}
@keyframes seal-ring-out {
  0%   { opacity: 1; }
  100% { opacity: 0; }
}
/* One ring of shock leaving the die. */
.seal-halo {
  position: absolute;
  inset: 0;
  border: 1px solid var(--habit-color, var(--series-1));
  opacity: 0;
  pointer-events: none;
}
.seal-btn[data-struck="true"] .seal-halo { animation: seal-halo 520ms ease-out 1; }
@keyframes seal-halo {
  0%   { opacity: 0.75; transform: scale(1); }
  100% { opacity: 0; transform: scale(1.5); }
}
/* Released early: the face says what it wanted, then goes back to
   itself. Set by app.js, which also restores the label. */
.seal-btn[data-hinting="true"] .seal-face {
  color: var(--ink-muted);
  border-color: var(--ink-muted);
  /* A word, not a glyph: back to the size the face was sized for before
     the tick grew (2026-09-14). */
  font-size: 0.58rem;
}

/* The numeric habit's confirm, beside its input — same seal, smaller. */
.seal-btn-sm { width: 2.1rem; height: 2.1rem; }
.seal-btn-sm .seal-face { font-size: 0.85rem; }
.seal-btn-sm[data-hinting="true"] .seal-face { font-size: 0.54rem; }

/* Inert until there is a number to strike (2026-09-13). The input
   always carries a placeholder (the habit's unit, else "amount"), so
   :placeholder-shown IS "nothing typed yet" — which means this needs no
   JS state of its own and cannot drift out of sync with the field. The
   matching behaviour is readyToStrike() in app.js; before both, holding
   an empty seal posted nothing and got a 422 page back. */
.numeric-pill-form:has(input[name="value_numeric"]:placeholder-shown) .seal-face {
  color: var(--ink-muted);
  border-color: var(--ink-muted);
}
.numeric-pill-form:has(input[name="value_numeric"]:placeholder-shown) .seal-btn:hover .seal-face {
  background: transparent;
}

@media (prefers-reduced-motion: reduce) {
  .seal-btn[data-struck="true"] .seal-face,
  .seal-btn[data-struck="true"] .seal-halo { animation: none; }
  /* Still ends at zero — it is the ring going away, not a flourish. */
  .seal-btn[data-struck="true"] .seal-sweep-fill { animation: none; opacity: 0; }
}

/* Numeric quick-log: visible by default, hidden only once JS is known
   to be running (added 2026-09-08, review finding). The form used to
   ship with a literal `hidden` attribute from _macros.html and was
   only ever un-hidden by initQuickNumericLogContainer — so with JS
   off there was no way to reach it and no way to submit it, making
   numeric habits impossible to log on /home, whose widget offers no
   other path. That inverted this app's own rule, stated at
   initProgramSetsAutoSave below: hide at runtime, never in the
   server-rendered markup.

   Driven by the `js` class on <html> (set by the theme-preload
   script in <head>, before first paint, so JS users see no flash)
   rather than by a JS-set marker on each row — the habit cards are
   swapped in as server-rendered fragments by
   initInstantHabitActionsContainer, and a per-node marker would be
   lost on every swap. `.is-open` replaced `form.hidden` for the same
   reason: a fresh fragment must come back closed on its own. */
.numeric-pill-form { display: inline-flex; }
html.js .numeric-pill-form { display: none; }
html.js .numeric-pill-form.is-open { display: inline-flex; }
/* With JS off the pill is inert (type="button", submits nothing), so
   it would just sit there next to a working input doing nothing. */
html:not(.js) [data-quick-log-toggle] { display: none; }
/* initQuickNumericLogContainer sets `trigger.hidden` to put the pill away
   while its input is open, but `.numeric-pill { display: inline-flex }`
   above beats the UA's [hidden] rule, so it never actually went away.
   Cosmetic and long-standing (it was already an open item in TODO.md);
   fixed 2026-09-13 because the Strike put a THIRD control in that row —
   the row read LOG · [24] · seal, with the LOG button dead. Same pattern
   as `.acct-wwp-state[hidden]` and `.password-toggle-btn svg[hidden]`. */
.numeric-pill[hidden] { display: none; }
.numeric-pill-form input {
  height: 2.25rem;
  width: 4.5rem;
  margin-top: 0;
  padding: 0 var(--space-2);
  border: 1px solid var(--habit-color, var(--series-1));
  background: var(--page);
  color: var(--ink);
  font-family: var(--font-mono);
  font-size: 0.75rem;
  text-align: center;
}

/* `margin: auto` centres the whole box, but the box isn't just the
   7 day-columns — it also carries the month-label gutter on the
   left with nothing matching it on the right. Centre *that* box and
   the actual squares end up off-centre, shifted right by half the
   gutter's width, which reads as more background to the left of the
   squares than to the right. Fixed to a fixed 1.6rem (not
   minmax(1.6rem, auto), so long month names like "Setembro" can't
   resize it — see `.month-label`'s `overflow: visible`) and mirrored
   with an equal-width empty `.spacer` column at the far right (one
   per row, see `heatmap_grid()` in _macros.html). With matching
   gutters on both sides, centring the box now centres the squares. */
/* One type size per grid, picked from the longest value in it
   (added 2026-09-02, direct request: "the numbers inside the square
   are not harmonic"). A 1.4rem square fits ~3 mono glyphs
   comfortably; 4 need a step down, and anything longer than that
   never reaches the DOM because _compact_value() (progress.py)
   collapses it to "9.6k"/"12k"/"1.2M" first. Set on the grid, not
   the cell, so a 5 and a 12k in the same habit are never different
   sizes. */
.heatmap { --cell-value-size: 0.62rem; }
.heatmap[data-value-width="3"] { --cell-value-size: 0.52rem; }
.heatmap[data-value-width="4"] { --cell-value-size: 0.44rem; }
.heatmap[data-value-width="5"] { --cell-value-size: 0.42rem; }
/* 6 is the last step because build_heatmap() clamps max_value_len
   there — a habit logging 7+ digit values has bigger problems than
   type size, and every step past this is unreadable anyway. A
   5-6 digit habit is genuinely cramped at 1.4rem; the real fix is
   wider cells, which needs the graph box to follow the heatmap's
   width (see `.habit-chart .line-graph`), so it's a deliberate
   separate decision rather than something to sneak in here. */
.heatmap[data-value-width="6"] { --cell-value-size: 0.36rem; }
.heatmap {
  margin: var(--space-3) auto 0;
  display: grid;
  grid-template-columns: 1.6rem repeat(7, 1.4rem) 1.6rem;
  grid-auto-flow: row;
  grid-auto-rows: 1.4rem;
  gap: 3px;
  width: max-content;
  max-width: 100%;
}
.heatmap .weekday-label {
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: 0.55rem;
  font-weight: var(--weight-semibold);
  color: var(--ink-muted);
  text-transform: uppercase;
}
.heatmap .month-label {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  padding-right: 2px;
  font-family: var(--font-mono);
  font-size: 0.55rem;
  color: var(--ink-muted);
  white-space: nowrap;
  min-width: 0;
  overflow: visible;
}
.heatmap .cell {
  width: 100%;
  height: 100%;
  border-radius: 3px;
  background: color-mix(in srgb, var(--habit-color, var(--series-1)) 10%, transparent);
  display: flex;
  align-items: center;
  justify-content: center;
  overflow: hidden;
  font-family: var(--font-mono);
  font-size: var(--cell-value-size, 0.5rem);
  font-weight: var(--weight-semibold);
  line-height: 1;
  color: color-mix(in srgb, var(--ink) 55%, transparent);
}
/* A logged day's square is filled with the habit's colour in
   proportion to that day's value, normalised against the habit's own
   best and worst day in the window (added 2026-09-02, direct
   request: "we take the numeric values we have, and normalize them,
   then use this to set the color of each square"). `--cell-fill` is
   set per cell by heatmap_grid() from `HeatmapCell.fill_pct`
   (30-100%); it's absent for boolean habits, whose squares keep the
   flat full-colour "done" fill via the 100% fallback. */
.heatmap .cell.hit {
  background: color-mix(in srgb, var(--habit-color, var(--series-1)) var(--cell-fill, 100%), var(--surface));
  /* Knockout number: the digits wear the card's own background
     colour at every step of the ramp (direct request 2026-09-02,
     "make the number inside the square text color, the bg color"),
     so they read as punched out of the colour rather than printed
     on it. This replaced an ink/surface switch at a fill threshold,
     which meant the same habit's numbers changed colour partway up
     its own scale. `--surface` is exactly what the fill is mixed
     into, so the knockout is the same colour the square would be
     with no value at all. */
  color: var(--surface);
}
/* No distinct .miss color (dropped 2026-07-30, on request) — a
   missed day renders identically to a never-logged one (the base
   .cell rule above, a faint 10% tint), rather than its own 32%
   shade. The .miss class itself still exists in the markup
   (heatmap_grid() in _macros.html still distinguishes cell.logged
   from cell.hit) since completeness/tooltip logic still needs it — this
   only removes the extra color, it doesn't stop tracking the
   distinction server-side. */
.heatmap .cell.future {
  background: transparent;
  border: 1px dashed color-mix(in srgb, var(--habit-color, var(--series-1)) 14%, transparent);
}
/* Off-routine days: this weekday isn't in the habit's linked routine
   schedule, so skipping it isn't a miss. Diagonal hatch marks it as
   "doesn't count" regardless of hit/miss underneath; hitting anyway
   still shows the hit color through the hatch, reading as a bonus. */
.heatmap .cell.unscheduled {
  background-image: repeating-linear-gradient(
    135deg,
    color-mix(in srgb, var(--ink) 16%, transparent) 0,
    color-mix(in srgb, var(--ink) 16%, transparent) 1px,
    transparent 1px,
    transparent 4px
  );
}
.heatmap .cell.unscheduled:not(.hit):not(.miss) { background-color: transparent; }
.heatmap .cell.unscheduled.hit {
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--habit-color, var(--series-1)) 70%, var(--surface));
}

/* flex-wrap (added 2026-07-29) kept every item on-screen by dropping
   to a second row once translated labels pushed the nav past the
   viewport — but /train's new icon (2026-08-14) pushed a regular
   logged-in user's row to 7 items (8 for admins), and direct user
   ask 2026-08-15 ("the navbar should be only 1 row") ruled that back
   out. overflow-x replaces it: nowrap + horizontal scroll keeps
   every item on one line at any item count or label length — the
   same "nothing clips off-screen" guarantee wrap gave, without a
   second row. Its scrollbar used to be hidden by a rule right here,
   for the reason the app-wide rule below now gives for every
   scroller — a tab strip reads cleaner without one and is still
   reachable by touch/trackpad/shift+wheel — so the local rule went
   when that one landed (2026-09-03). */
.main-nav {
  display: flex;
  flex-wrap: nowrap;
  /* Rows short of the full width (fewer items — non-admin has no
     Panel link) would otherwise clump left and leave a bare gap on
     the right; rows that overflow have no free space left to
     distribute, so this is a no-op for the scrollable case. */
  justify-content: space-between;
  overflow-x: auto;
  gap: var(--space-2);
  margin: var(--space-2) 0 var(--space-4);
}
/* No visible scrollbar anywhere in the app (2026-09-03, direct
   request — "remove the sidebars", reported on /habits and on the
   habit-detail popup, which grows its own bar next to the page's).
   This generalises what the landing page and the /preview/* pages
   (rendered inside the landing's iframe modal) already did on their
   own since 2026-08-30 — they read as chromeless, and every page now
   does. Nothing stops scrolling: wheel, trackpad, touch, keyboard,
   shift+wheel for horizontal and the scrollbar's own drag-free
   keyboard equivalents all still work, exactly as they already did
   for `.main-nav` above, which hid its bar for this same reason.
   Both halves are needed, and both are element-scoped rather than
   page-scoped: `scrollbar-width` covers Firefox and
   `::-webkit-scrollbar` covers Chrome/Safari, and the universal
   selector is what reaches the *inner* scroll containers (the habit
   detail popup's `.details-content`, `.chat-messages`, the week
   grid) — hiding it on `html` alone would only take the page-level
   bar. `html` is still named explicitly: `*` doesn't match the root
   element's scrollbar in every engine.
   Trade-off accepted with the request: a scrollable region no longer
   advertises itself with a bar, so anything that scrolls has to stay
   discoverable some other way (cut-off content at the fold, the
   popup's own framing). */
html, * {
  scrollbar-width: none; /* Firefox */
  -ms-overflow-style: none; /* legacy Edge */
}
html::-webkit-scrollbar, *::-webkit-scrollbar { display: none; }
/* `body`'s own top padding (see the `body` rule) still pushed the
   hero's sticky stage down from true y=0 even after the topbar
   above was taken out of flow (2026-08-30) — the topbar was the
   bigger offset, not the only one. Landing-only: every other page
   still wants that padding, this is the one page whose first
   section needs to start at the literal top of the viewport. */
html.is-landing body { padding-top: 0; }
.nav-link {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  flex-shrink: 0; /* never squeezed/wrapped mid-label by the scroll container */
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--ink-muted);
  text-decoration: none;
  padding: var(--space-1) var(--space-2);
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  white-space: nowrap;
}
/* currentColor on the SVG means the icon follows the link's muted /
   hover / is-active colour with no separate rules. */
.nav-icon { width: 0.95rem; height: 0.95rem; flex-shrink: 0; }

/* Phones: stack icon above a smaller label to trade horizontal space
   for a little vertical height, so fewer items actually need the
   scroll above to reach "Conta"/"Painel" at the end of the row. */
@media (max-width: 640px) {
  .main-nav { gap: var(--space-1); }
  .nav-link {
    flex-direction: column;
    gap: 2px;
    padding: var(--space-1);
    font-size: 0.55rem;
    letter-spacing: 0.02em;
  }
  .nav-icon { width: 1.15rem; height: 1.15rem; }
}
.nav-link:hover { color: var(--ink); }
.nav-link.is-active { color: var(--series-1); border-color: var(--series-1); }

.chat-page { display: flex; flex-direction: column; gap: var(--space-4); margin-top: var(--space-3); }
.chat-messages { display: flex; flex-direction: column; gap: var(--space-3); max-height: 60vh; overflow-y: auto; padding: var(--space-2) 0; }
.chat-bubble { position: relative; max-width: 32rem; padding: var(--space-3) var(--space-4); background: var(--surface); }
.chat-bubble::before, .chat-bubble::after { content: ""; position: absolute; width: 9px; height: 9px; pointer-events: none; }
.chat-bubble-user { align-self: flex-end; }
.chat-bubble-user::before { top: 0; left: 0; border-top: 2px solid var(--series-1); border-left: 2px solid var(--series-1); }
.chat-bubble-user::after { bottom: 0; right: 0; border-bottom: 2px solid var(--series-1); border-right: 2px solid var(--series-1); }
.chat-bubble-agent { align-self: flex-start; }
.chat-bubble-agent::before { top: 0; left: 0; border-top: 2px solid var(--ink-muted); border-left: 2px solid var(--ink-muted); }
.chat-bubble-agent::after { bottom: 0; right: 0; border-bottom: 2px solid var(--ink-muted); border-right: 2px solid var(--ink-muted); }
.chat-bubble-sender {
  display: block;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin-bottom: var(--space-1);
}
.chat-bubble-user .chat-bubble-sender { color: var(--series-1); }
.chat-bubble-text { margin: 0; color: var(--ink); font-size: var(--text-sm); white-space: pre-wrap; }
.chat-bubble-audio { display: block; width: 16rem; max-width: 100%; height: 2.25rem; }
.chat-bubble-photo { display: block; max-width: 100%; max-height: 20rem; border-radius: var(--radius-sm); }
.chat-bubble-time { display: block; margin-top: var(--space-1); font-family: var(--font-mono); font-size: 0.58rem; color: var(--ink-muted); text-align: right; }
/* The user's own bubble, rendered client-side the instant they hit send
   (added 2026-08-07 — see initChatSend()) — before the server has
   confirmed anything, let alone the agent's reply. Faded, not
   "loading," since the send itself isn't in doubt, only the reply. */
.chat-bubble.is-pending { opacity: 0.55; }
.chat-input-form { display: flex; flex-direction: column; gap: var(--space-3); align-items: stretch; border-top: 1px solid var(--gridline); padding-top: var(--space-3); }
.chat-input-form textarea { margin-top: 0; }
.chat-attach-row { display: flex; flex-wrap: wrap; gap: var(--space-4); }
/* Wind Down Routine setup (added 2026-09-01; finished-state styling
   2026-09-02). The conversation ends in a real completion panel
   instead of the same grey link it started with — direct user
   report: "after the chat ends is confusing". `.wind-down-saved`
   only ever renders once a routine exists, so its accent border is
   itself the signal that the setup is done. */
/* The Wind Down Routine explainer (added 2026-09-02) — step 4 of 5
   of enrolling in Sleep. Prose, not a form-first screen, so the
   points get room to breathe; the phone-off rule is the one the user
   actually has to absorb, so it alone gets the danger-accented
   treatment rather than reading as one bullet among three. */
.wind-down-intro { display: flex; flex-direction: column; gap: var(--space-5); }
.wind-down-intro-lede { margin: 0; font-size: var(--text-md); }
.wind-down-intro-point { border-left: 2px solid var(--border); padding-left: var(--space-4); }
.wind-down-intro-point h2 { margin: 0 0 var(--space-2); font-size: var(--text-md); }
.wind-down-intro-point p { margin: 0 0 var(--space-2); }
.wind-down-intro-point p:last-child { margin-bottom: 0; }
.wind-down-intro-rule { border-left-color: var(--danger); }
.wind-down-intro-rule h2 { color: var(--danger); }
.wind-down-intro-error { color: var(--danger); font-weight: var(--weight-semibold); margin: 0; }
.wind-down-progress { font-family: var(--font-mono); font-size: var(--text-xs); letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-muted); margin: 0 0 var(--space-2); }
.wind-down-saved { border: 1px solid var(--border); border-left: 3px solid var(--good); border-radius: var(--radius-md); padding: var(--space-3) var(--space-4); margin-top: var(--space-4); background: var(--surface-2); }
.wind-down-saved-title { font-weight: 600; margin: 0 0 var(--space-2); }
.wind-down-steps { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: var(--space-1); }
.wind-down-steps li { display: flex; gap: var(--space-3); align-items: baseline; }
/* Monospace + fixed width so the times form a readable column
   rather than a ragged edge that shifts with each step's label. */
.wind-down-step-time { font-family: var(--font-mono); font-size: var(--text-sm); color: var(--good); flex: 0 0 auto; min-width: 7.5rem; }
.wind-down-step-text { flex: 1 1 auto; }
.wind-down-footer { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-2); margin-top: var(--space-4); }
.wind-down-footer .empty-note { margin: 0; }
.chat-attach-field { display: flex; flex-direction: column; gap: var(--space-1); flex: 1 1 12rem; }
.chat-attach-field input[type="file"] { padding: var(--space-1) 0; border: none; background: none; }
/* The wrapper rule above strips the file input's own border/
   background, but its inner "Choose File"/"Browse" control is a
   separate UA-styled sub-element (`::file-selector-button`, or
   `::-webkit-file-upload-button` pre-Chrome 121/pre-Safari 17) that
   a plain `input[type="file"]` rule can't reach — left in raw OS
   button chrome while every other button in the app is this same
   mono/uppercase style. Restyled to match `button, .btn` exactly. */
.chat-attach-field input[type="file"]::file-selector-button,
.chat-attach-field input[type="file"]::-webkit-file-upload-button {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  background: transparent;
  color: var(--ink-2);
  border: 1px solid var(--ink-muted);
  border-radius: var(--radius-sm);
  padding: var(--space-2) var(--space-4);
  margin-right: var(--space-3);
  cursor: pointer;
  transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}
.chat-attach-field input[type="file"]::file-selector-button:hover,
.chat-attach-field input[type="file"]::-webkit-file-upload-button:hover {
  border-color: var(--ink);
  color: var(--ink);
}
.chat-voice-field.is-recorder-mode { flex-direction: row; align-items: center; gap: var(--space-3); flex: 0 0 auto; }
.chat-voice-field.is-recorder-mode > label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); }
.chat-mic-btn[hidden] { display: none; }
.chat-mic-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  border-radius: 50%;
  border: 1px solid var(--series-1);
  color: var(--series-1);
  background: transparent;
  touch-action: none;
  user-select: none;
  -webkit-user-select: none;
  flex-shrink: 0;
}
.chat-mic-btn:hover { border-color: var(--series-1); color: var(--series-1); background: transparent; }
.chat-mic-btn.is-recording {
  background: var(--danger);
  border-color: var(--danger);
  color: var(--page);
  animation: chat-mic-pulse 1.1s ease-in-out infinite;
}
@keyframes chat-mic-pulse {
  0%, 100% { transform: scale(1); opacity: 1; }
  50% { transform: scale(1.08); opacity: 0.85; }
}
.chat-record-status { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--series-1); }
.chat-input-form > .btn-primary { align-self: flex-end; }

.habit-chart { margin-top: var(--space-3); }
.habit-card details { margin-top: var(--space-2); }
.chart-toggle {
  display: flex;
  gap: 2px;
  width: max-content;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: 2px;
  margin-bottom: var(--space-2);
}
/* Real (non-preview) toggle lives in .log-entry-actions now, next to
   the Log button (added 2026-09-01, direct request — see
   chart_toggle_control()'s comment in _macros.html) — the
   margin-bottom above was for sitting above the chart, wrong once
   it's a flex sibling of a button; pushed to the row's far end
   instead of sitting flush after Log, matching "next to" without
   crowding it. Preview's copy (habit_chart(), above the chart)
   keeps the plain margin-bottom, unaffected by this override. */
.log-entry-actions .chart-toggle { margin-bottom: 0; margin-left: auto; }
/* The heatmap display pair (added 2026-09-02) sits on its own line
   right-aligned under the Log row, so it reads as a sub-setting of
   the chart above it rather than as a third peer in that row. */
.heatmap-display-toggle {
  display: flex;
  width: fit-content;
  /* Right-aligned under the Log row's own Heatmap/Graph toggle, so
     the two chart controls stack in one column instead of the
     display switches drifting to the opposite edge. `margin-left:
     auto` needs the fit-content width above — `.chart-toggle`'s
     inline-flex would otherwise ignore it. */
  margin-left: auto;
  margin-top: var(--space-2);
  margin-bottom: 0;
}
.chart-toggle-btn {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: var(--space-1) var(--space-2);
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  color: var(--ink-muted);
  cursor: pointer;
}
.chart-toggle-btn:hover { color: var(--ink); }
.chart-toggle-btn:disabled {
  opacity: 0.4;
  cursor: not-allowed;
}
.chart-toggle-btn:disabled:hover { color: var(--ink-muted); }
.chart-toggle-btn.is-active {
  color: var(--surface);
  background: var(--habit-color, var(--series-1));
  font-weight: var(--weight-semibold);
}

/* ---- Habit square icon picker (2026-09-09) ---------------------
   Direct request: "for the user's normal habits, not the ranked
   ones, make it so he is able to choose emojis to be on the square
   instead of letter". Sits in the habit's edit form, so it inherits
   .form-grid's column and spans it.

   Radios styled as tiles: the <input> is visually hidden but stays
   focusable and keyboard-operable (clip, not display:none — a
   display:none radio is dropped from tab order and from some
   screen readers), and :checked/:focus-visible on it paint the
   sibling mark. That's what keeps the whole picker working with JS
   off, which this form requires. Only shared tokens are used, so
   all fourteen themes follow with no per-theme block. */
.icon-picker {
  grid-column: 1 / -1;
  margin: 0;
  padding: var(--space-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: transparent;
}
.icon-picker legend {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
  padding: 0 var(--space-2);
}
/* auto-fill, not fixed columns: the popup is narrow on a phone and
   wide on a desktop, and the tiles are a uniform 2.2rem either way —
   so the grid reflows from ~5 across to ~13 without a breakpoint. */
.icon-choices {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(2.2rem, 1fr));
  gap: var(--space-2);
}
.icon-choice { display: block; margin: 0; cursor: pointer; }
.icon-choice input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}
.icon-choice-mark {
  display: flex;
  align-items: center;
  justify-content: center;
  aspect-ratio: 1;
  width: 100%;
  /* The tile previews the square, so it wears the square's face and
     ink — mono, the habit's colour — rather than showing a glyph here
     that would look like something else once picked. */
  font-family: var(--font-mono);
  font-size: 1.1rem;
  line-height: 1;
  color: var(--habit-color, var(--series-1));
  border: 1px solid var(--gridline, var(--border));
  border-radius: var(--radius-sm);
  background: transparent;
  transition: border-color 0.12s ease, background 0.12s ease;
}
.icon-choice:hover .icon-choice-mark { border-color: var(--habit-color, var(--series-1)); }
.icon-choice input:checked + .icon-choice-mark {
  border-color: var(--habit-color, var(--series-1));
  box-shadow: inset 0 0 0 1px var(--habit-color, var(--series-1));
}
.icon-choice input:focus-visible + .icon-choice-mark {
  outline: 2px solid var(--accent-glow, var(--series-1));
  outline-offset: 2px;
}
/* The two tiles that aren't one of the presets: the letter (what the
   square shows with no icon set) and the "+" that arms the free-text
   field under the grid. Both are type rather than a mark, so they sit
   a size down from the glyph tiles around them. */
.icon-choice-letter,
.icon-choice-other {
  font-size: 0.85rem;
  font-weight: var(--weight-semibold);
}
.icon-other-input {
  margin-top: var(--space-2);
  width: 100%;
  font-size: var(--text-sm);
}

/* The habit edit form's call-reminder switch (2026-09-09), replacing
   the three-way importance <select> that sat in this slot. A plain
   checkbox, so the form still works as a POST with JS off; the label
   is the hit target and the row reads left-to-right like the rest of
   the form-grid rather than as a stray box. */
.remind-toggle {
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--space-2);
  cursor: pointer;
}
.remind-toggle input { width: auto; margin: 0; flex: none; }
.field-hint {
  display: block;
  margin-top: calc(var(--space-1) * -1);
  color: var(--ink-muted);
  font-size: var(--text-xs);
  line-height: 1.5;
}

.line-graph {
  display: block;
  width: 100%;
  height: auto;
}
/* On a habit card the graph stands in for the heatmap, so it gets
   the heatmap's exact box (added 2026-09-02, direct request: "make
   the habits with graph the same size as the others" — a graph card
   was 222px shorter than a heatmap one, so flipping chart style
   resized the card and left the grid ragged). Width is pinned to
   `.heatmap`'s own max-content width (9 columns + 8 gaps = 13rem +
   24px), NOT 100% of the card: the heatmap is rem/px-sized and so
   viewport-independent, and only a fixed width makes the graph
   match it on every screen. Height then follows for free from
   height:auto, because build_line_graph() now sizes the viewBox to
   this box's aspect ratio per habit (chart_geometry
   .graph_height_for_weeks()) — no forced CSS height, so nothing is
   squashed. Same `margin: var(--space-3) auto 0` as `.heatmap` so
   the collapsed top margin matches too. Train/exercise charts keep
   the full-width `.line-graph` rule above — this is scoped to
   `.habit-chart`. */
.habit-chart .line-graph {
  width: calc(13rem + 24px);
  max-width: 100%;
  margin: var(--space-3) auto 0;
}
/* Axis + endpoint labels, sized for reading rather than for
   matching the old render (2026-09-02, direct request: "the scale is
   a bit hard to read (the numbers are small)"). These are viewBox
   units, and `.habit-chart .line-graph` renders the 364-wide box at
   232px — a 0.637 scale — so 13.5/15 here land at ~8.6/9.6 real px,
   in line with the heatmap's own month/weekday labels (0.55rem)
   instead of the ~5px they were before. */
.habit-chart .graph-tick-label { font-size: 13.5px; }
.habit-chart .graph-end-label { font-size: 15px; }
/* Numeric habit with nothing logged in the window: the graph is a
   one-line note instead of an SVG, which would collapse the card the
   same way. Reserve the heatmap's height for it — same formula as
   `.heatmap`'s grid ((weeks + 1) rows + weeks gaps), reading the
   habit's own week count from `--heatmap-weeks` (set inline by
   habit_chart(), _macros.html). */
.habit-chart .empty-note {
  display: flex;
  align-items: center;
  justify-content: center;
  text-align: center;
  margin-top: var(--space-3);
  min-height: calc(
    (var(--heatmap-weeks, 13) + 1) * 1.4rem + var(--heatmap-weeks, 13) * 3px
  );
}
.graph-gridline { stroke: var(--gridline); stroke-width: 1; }
.graph-tick-label {
  font-family: var(--font-mono);
  font-size: 7px;
  fill: var(--ink-muted);
  /* Same surface-color halo the end label carries: these sit at the
     left edge *inside* the plot, so any line or wash passing behind
     one used to cut straight through the digits ("10000" reading as
     "1|000"). Spotted once the labels were enlarged 2026-09-02. */
  paint-order: stroke;
  stroke: var(--surface);
  stroke-width: 3px;
  stroke-linejoin: round;
}
.graph-area {
  fill: var(--habit-color, var(--series-1));
  opacity: 0.1;
  stroke: none;
}
.graph-line {
  fill: none;
  stroke: var(--habit-color, var(--series-1));
  stroke-width: 2;
  stroke-linejoin: round;
  stroke-linecap: round;
}
/* Raw daily values behind a rolling-mean trend (added 2026-09-02):
   thinner and knocked back so ~90 days of real data read as texture
   around the trend rather than as the chart itself. Same hue — it's
   the same series, not a second one, so no legend is owed. */
.graph-line-raw {
  stroke-width: 1;
  opacity: 0.22;
}
.graph-line-trend { stroke-width: 2; }
.graph-hit { fill: transparent; }
.graph-end-dot {
  fill: var(--habit-color, var(--series-1));
  stroke: var(--surface);
  stroke-width: 2;
}
.graph-end-label {
  font-family: var(--font-mono);
  font-size: 8px;
  font-weight: var(--weight-semibold);
  fill: var(--ink-2);
  /* Surface-color halo (added 2026-07-31) so the label stays legible
     on the rare habit whose last point's incoming line segment
     crosses right through it — cheaper and more robust than trying
     to flip the label's side based on the incoming slope. */
  paint-order: stroke;
  stroke: var(--surface);
  stroke-width: 3px;
  stroke-linejoin: round;
}

.widget-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  flex-wrap: wrap;
}
.widget-header h1 { margin-bottom: 0; }
/* /habits' header lost its <h1> (2026-09-02, user ask to remove the
   "Habit tracker" title) and now holds only the reorder controls.
   `space-between` on a single child would park them on the LEFT,
   under the nav, so this page's header pushes them back to the
   right edge where every other widget's controls sit. Scoped to
   .habits so the shared .widget-header rule above is untouched. */
.habits > .widget-header { justify-content: flex-end; }
/* First group sits closer to the page title than the gap BETWEEN
   groups (.habit-group's own margin-top) — replaced the old
   `.widget-header + details` rule when the "+ New habit" <details>
   stopped being the first thing after the header (2026-09-02). */
.habits > .widget-header + .habit-group { margin-top: var(--space-5); }
/* Home's widget titles sat flush against the box right below them —
   no equivalent of the .habits rule above existed for any
   of Home's 5 widgets, so e.g. "Status" touched its own readout box
   with zero gap (direct user request, 2026-07-31: "add a little
   space between the titles and the element below them"). Scoped to
   .home-widgets, NOT the shared #widgets id — that id turned out to
   be reused as-is by habits.html/calendar.html/calls.html too (each
   page has exactly one, so it's valid per-page, but an id selector
   still matches all of them at once), which would have silently
   widened this beyond the "on the home page" ask; .home-widgets is
   a second class added only on home.html's container for this
   reason. One rule on .widget-header itself (not `+ next-element`)
   so it works regardless of what markup immediately follows across
   the 5 different widget partials. */
.home-widgets .widget-header { margin-bottom: var(--space-2); }

/* ---- /account, rebuilt 2026-09-06 ----------------------------
   The page was five stacked full-width `.widget` sections, each
   opened by its own <h1> — five per page, no hierarchy — holding a
   single 34rem form column on a 1,340px viewport, with the identity
   of the account (the email) drawn as a greyed-out readonly input
   and the auto-save readout parked ~1,800px below the field it
   reports on.

   It is an identity plate over a two-column instrument board now.
   The plates are `.habit-card` — this app's Instrument Plate, hairline
   frame and corner brackets included — rather than a second card
   primitive, and the monogram is `.monogram`, the same mark a habit
   card wears. Reads only the shared tokens — the --surface,
   --border, --gridline and --ink families plus --accent-glow and
   --series-1 — so all fourteen themes follow with no per-theme
   block. */

/* Identity plate. `.acct-id-who` is the only growing item, so the
   fact strip is pushed to the plate's right edge without an auto
   margin — which would have right-aligned it once flex-wrap put it
   on its own line on a phone. */
.acct-id {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-4) var(--space-5);
  /* Bottom margin is the grid's own row gap, so the plate sits in the
     same vertical rhythm as the cards under it rather than at some
     second, page-only spacing. */
  margin: var(--space-3) 0 var(--space-5);
}
.acct-id-mark { width: 3rem; height: 3rem; font-size: 1.25rem; color: var(--accent-glow); border-color: var(--accent-glow); }
.acct-id-who { flex: 1 1 12rem; min-width: 0; }
.acct-id-name {
  margin: 0;
  font-size: var(--text-md);
  font-weight: var(--weight-semibold);
  color: var(--ink);
  overflow-wrap: anywhere;
}
.acct-id-email {
  margin: 2px 0 0;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--ink-muted);
  overflow-wrap: anywhere;
}
/* The spec strip. Flex, not a grid of `repeat(auto-fit, ...)` tracks:
   auto-fit resolves to exactly ONE repetition whenever the grid
   container's own width is indefinite, which it is while this sits
   as a flex item being intrinsically sized — the four cells came out
   stacked in a single tall column against the plate's right edge.
   Flex wrapping needs no such resolution and gives the same result
   the grid was meant to: one row where there's room, wrapping to two
   and then one on a phone, with no media query of its own.
   No `justify-content` here: on a wide plate `.acct-id-who` is the
   only growing item, so it is what pushes this strip to the right
   edge, and once the strip wraps to its own line on a phone the
   cells should pack left like everything else on the card. */
.acct-facts {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3) var(--space-5);
  margin: 0;
}
.acct-fact { min-width: 0; }
.acct-fact dt {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.acct-fact dd {
  margin: 2px 0 0;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--ink);
  overflow-wrap: anywhere;
}

/* Two columns from 1032px — body's own second max-width step, so the
   split happens exactly where the page stops being a phone-width
   column rather than at a number picked for this page alone.
   `align-items: start` so a short right column doesn't stretch its
   plates to match the tall left one. */
.acct-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-5);
  align-items: start;
}
.acct-col { display: flex; flex-direction: column; gap: var(--space-5); min-width: 0; }
@media (min-width: 1032px) {
  .acct-grid { grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr); gap: var(--space-5) var(--space-6); }
}
/* `.habit-card`'s margin-bottom exists for the stacked habit list;
   inside a column the column's own gap is what separates plates.
   Same specificity as `.habit-card` (one class), so this wins on
   source order — it is deliberately below that rule, not above it.
   `.acct-id` is deliberately NOT in this selector: it sits above the
   grid, not in a column, so nothing else provides its bottom gap and
   it keeps the margin set on it further up. It was listed here at
   first, which zeroed that margin and left the identity plate flush
   against the two cards below it. */
.acct-card { margin-bottom: 0; }
.acct-card { padding: var(--space-5); }

/* Card header: a mark, a mono micro-title, and whatever readout that
   card owns, over a hairline. Replaces five page-level <h1>s with one
   real <h1> ("Account") and five section headers that read as labels
   on an instrument rather than as five competing page titles. */
.acct-card-head {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  padding-bottom: var(--space-3);
  margin-bottom: var(--space-4);
  border-bottom: 1px solid var(--gridline);
}
.acct-card-head h2 {
  flex: 1;
  min-width: 0;
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink);
}
.acct-card-icon { width: 15px; height: 15px; flex: none; color: var(--accent-glow); }

/* The auto-save readout, in the header of the card whose fields it
   reports on. `:not(:empty)` so it costs no box at all until
   initAccountAutoSave() writes "Saved" into it. */
.acct-saved { flex: none; }
.acct-saved:not(:empty) {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--accent-glow);
  border: 1px solid color-mix(in srgb, var(--accent-glow) 45%, transparent);
  border-radius: var(--radius-sm);
  padding: 2px var(--space-2);
}

/* A field is label + control + that control's own hint, as one block.
   The hints used to be bare `.empty-note` siblings in the form's flex
   column, so each sat --space-3 below its own control and --space-3
   above the next label — equidistant between two fields, visibly
   belonging to neither. */
.acct-form { max-width: none; margin-top: 0; }
.acct-field { display: flex; flex-direction: column; min-width: 0; }
.acct-hint {
  margin: var(--space-1) 0 0;
  padding: 0;
  color: var(--ink-muted);
  font-size: var(--text-xs);
  line-height: 1.5;
}
.acct-card > .acct-hint { margin-top: 0; }
.acct-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); }
@media (max-width: 30rem) { .acct-pair { grid-template-columns: 1fr; } }
/* `.account-check` carries margin-top: --space-4 to space itself off
   whatever preceded it; inside an .acct-field the form's own gap
   already does that. */
.acct-form .account-check { margin-top: 0; }
.acct-save-btn { align-self: flex-start; }

/* Status line: a dot and a sentence, so "am I connected?" is answered
   before the paragraph explaining what connecting is for. */
.acct-state {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--ink);
  overflow-wrap: anywhere;
}
.acct-dot { flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--ink-muted); }
.acct-state.is-on > .acct-dot { background: var(--series-1); box-shadow: 0 0 0 3px color-mix(in srgb, var(--series-1) 18%, transparent); }
/* Pending reads --accent-glow-2, not --accent-glow: several themes
   (forge among them) define --accent-glow and --series-1 as the same
   hex, which would have made the connected and pending dots
   identical. --accent-glow-2 is the secondary accent and is defined,
   distinctly, in all fourteen. */
.acct-state.is-pending > .acct-dot { background: var(--accent-glow-2); box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent-glow-2) 18%, transparent); }
.acct-state.is-off { color: var(--ink-2); }
.acct-alert { margin: 0; font-family: var(--font-mono); font-size: var(--text-xs); color: var(--danger); }

.acct-wwp-state { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-3); }
/* An author `display` beats the UA's own `[hidden] { display: none }`,
   and initWhatsAppLink() toggles exactly these three blocks by
   .hidden — so it has to be restated. Same reason as
   `.password-toggle-btn svg[hidden]` further down. */
.acct-wwp-state[hidden] { display: none; }
.acct-qr {
  width: 220px;
  height: 220px;
  max-width: 100%;
  padding: var(--space-2);
  border: 1px solid var(--border);
  /* Hardcoded white, in a fourteen-theme app, on purpose: a QR code
     is only scannable as dark-on-light, and the padding is its quiet
     zone. The one element on the page that must not follow the
     theme. */
  background: #fff;
}

/* The theme picker keeps `.theme-switcher-inline`'s own button
   sizing — it just sits on a plate now instead of a bare page
   section. */
#theme .theme-switcher-inline { margin-top: var(--space-3); }
#theme .tip { margin-top: 0; }

/* Log out at the far end of the row — the `.habit-edit-actions`
   treatment. The two used to sit var(--space-2) apart, and the
   destructive one is the one you can hit by accident. */
.acct-actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  margin-top: var(--space-4);
}

/* Spacing between stacked widgets (moved off the headings 2026-07-29).
   It used to be an inline `margin-top` on every widget <h1>, which had
   two problems: it pushed the *first* widget's heading 48px below the
   nav while pages whose first element is a plain page title sat at
   16px — so the nav-to-content gap differed per page — and inside
   `.widget-header` (a flex row holding the h1 and the reorder buttons)
   a margin on the h1 alone dropped the title below its own buttons.
   As a sibling rule it does neither: the first widget starts flush,
   every later one gets the gap, and the header row stays aligned. */
.widget + .widget { margin-top: var(--space-8); }

/* One-column stack is the base everywhere, including Home below the
   board breakpoint below. Scoped to #widgets (both a class and an id
   on the same div) via the id selector so its higher specificity
   wins over ".widget + .widget" above regardless of source order. */
#widgets > .widget { margin-top: 0; }
#widgets { display: flex; flex-direction: column; gap: var(--space-8); }

/* ---- Home as an instrument board (rebuilt 2026-09-06) ----------
   Home was five full-width stacked sections with one exception:
   "Habits Today" and "Today's Schedule" split 50/50 from 640px up
   (direct user request, 2026-07-31). On a 1,440px screen that
   exception had become the page's worst fault — the schedule
   usually holds a row or two, so ~450px of empty black sat beside a
   tall habit list, and the three widgets below it then stretched
   the full ~1,860px, which left "This Week at a Glance" drawing a
   habit name at the far left and that habit's own 7-cell strip at
   the far right with a dead gutter in between.

   It is a two-column board now: the widgets flow, in widget_order,
   down a CSS multi-column container, so a tall widget takes a
   column and the short ones stack beside it with no dead space.
   Multi-column specifically — NOT a grid or a flex row — because
   every widget has to stay a DIRECT child of #widgets: the up/down
   forms post widget_order, and initWidgetDragReorder() both reads
   the new order back off `container.querySelectorAll('.widget')`
   and ignores any drop target whose `parentElement !== container`.
   Columns are a rendering concept that adds no wrapper elements, so
   the DOM stays flat and both reorder paths keep working untouched.
   `break-inside: avoid` keeps a widget whole rather than splitting
   one across the fold; `column-span: all` lets the day plate run
   the full width from wherever it has been reordered to.

   The 50/50 pairing is kept where it was asked for — on a desktop
   the habit list is column one and the schedule heads column two —
   but below 1,000px everything is a single column now, which is
   what that pair wanted at 640px and never had the room for.

   Scoped to `#widgets.home-widgets`, not `#widgets` alone: that id
   is reused verbatim by habits/calendar/calls (see the note above
   `.home-widgets .widget-header`), and only the extra class makes
   the selector specific enough to beat the plain-id rules above. */
@media (min-width: 1000px) {
  #widgets.home-widgets {
    display: block;
    column-count: 2;
    column-gap: var(--space-8);
  }
  #widgets.home-widgets > .widget {
    break-inside: avoid;
    margin: 0 0 var(--space-8);
  }
  #widgets.home-widgets > #status { column-span: all; }
}

/* A plate that ends a widget carries .habit-card's own bottom
   margin, which on the board stacked on top of the column gap. */
.home-widgets .widget > .habit-card:last-child { margin-bottom: 0; }

/* Panel labels, not page titles (2026-09-06). Five <h1>s at the same
   size meant nothing on Home led. On the board they are the small
   mono caps a labelled instrument panel wears, which leaves the day
   plate as the only large type on the page. The tags are unchanged —
   this is type, not document structure. */
.home-widgets .widget-header h1 {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-muted);
}

.reorder-controls {
  display: flex;
  align-items: center;
  gap: 2px;
}
.reorder-controls form.inline { display: inline; }
.reorder-btn {
  font-family: var(--font-mono);
  font-size: 0.7rem;
  padding: var(--space-1) var(--space-2);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--ink-muted);
  cursor: pointer;
}
.reorder-btn:hover:not(:disabled) { color: var(--ink); }
.reorder-btn:disabled { opacity: 0.35; cursor: default; }

.drag-handle {
  cursor: grab;
  color: var(--ink-muted);
  padding: var(--space-1);
  user-select: none;
}
.drag-handle:active { cursor: grabbing; }

/* Reorder toggle (added 2026-07-31, direct user request: "add a
   small button on the edge, that toggle the reorder buttons on
   the widgets... by default we don't see them"). Pure CSS, no JS —
   a checkbox-hack toggle so it works even with JS disabled,
   matching the app's progressive-enhancement rule.
   **Two follow-up requests, same day, both changed the mechanism:**
   1. "make the reorder badge at the bottom of the page - rn he is
      on our screen all time" — was `position: fixed` (floating,
      always on screen regardless of scroll); now a plain in-flow
      element, placed last in each page's content (after #widgets
      closes in home.html/habits.html/calendar.html/calls.html), so
      it only appears once you've scrolled to the actual bottom.
   2. "on the habit inside order reorder, should be affect by him
      ... the reorder controls should also follow the reorder" —
      was scoped to `.widget-header .reorder-controls` only (the
      page-level up/down/drag-handle), deliberately excluding
      `habit_reorder_buttons()`/`task_reorder_buttons()`'s per-item
      controls. Now covers all of them — every `.reorder-controls`
      inside `#widgets`, regardless of which of the 3 macros
      produced it.
   Both changes together broke the original `~` general-sibling
   approach: `A:checked ~ B` can only select a B that comes AFTER A
   in the DOM, but the toggle now needs to affect #widgets' contents
   while sitting AFTER #widgets visually. Switched to `body:has(...)`
   instead, which doesn't care about sibling order — only that the
   checked checkbox exists anywhere in the document. */
/* input.reorder-toggle-input (element+class), not just .reorder-
   toggle-input alone — needs to out-specificity the earlier global
   `input[type="checkbox"] { display: inline-block; ... }` rule
   (also (0,1,1) specificity), which otherwise wins and renders
   this as a small styled checkbox box instead of hiding it. */
input.reorder-toggle-input { display: none; }
.reorder-toggle-bar {
  display: flex;
  justify-content: flex-end;
  margin-top: var(--space-8);
}
.reorder-toggle-btn {
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-muted);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: var(--space-1) var(--space-2);
}
.reorder-toggle-btn:hover { color: var(--ink); }
body:has(.reorder-toggle-input:checked) .reorder-toggle-btn {
  color: var(--series-1);
  border-color: var(--series-1);
}
/* The actual show/hide — every reorder control inside #widgets,
   widget-level and item-level alike (see point 2 above).
   `.reorder-scope` (added 2026-08-02) extends the same behaviour to
   reorderable lists on pages that have no #widgets at all — the
   program list on /train/lifting itself and the exercise list on
   a program's own detail page (/train/lifting/programs/{id}),
   which aren't dashboard widgets but still call reorder_toggle(). */
#widgets .reorder-controls,
.reorder-scope .reorder-controls { display: none; }
body:has(.reorder-toggle-input:checked) #widgets .reorder-controls,
body:has(.reorder-toggle-input:checked) .reorder-scope .reorder-controls {
  display: flex;
}
/* The morning-briefing order list is the exception (added
   2026-09-05): its arrows are the feature itself, sitting inside a
   settings card, not a "rearrange my dashboard" affordance. Behind
   the page's Reorder toggle they'd read as a numbered list with no
   way to change it — the one thing this control exists to offer. Out-
   specifies the #widgets hide above (one id + two classes). */
#widgets .briefing-order-list .reorder-controls { display: flex; }

.dragging { opacity: 0.4; }

.history-list {
  display: flex;
  flex-direction: column;
}
.history-row {
  display: grid;
  grid-template-columns: 5.5rem 1fr auto auto;
  align-items: center;
  gap: var(--space-2);
  padding: var(--space-2) 0;
  border-bottom: 1px solid var(--gridline);
  font-size: var(--text-sm);
}
.history-row:nth-child(even) { background: color-mix(in srgb, var(--ink) 3%, transparent); }
.history-date { font-family: var(--font-mono); color: var(--ink-muted); font-size: var(--text-xs); }
.history-value { font-family: var(--font-mono); color: var(--ink); font-weight: var(--weight-medium); }
.history-note {
  color: var(--ink-2);
  font-size: var(--text-xs);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.empty-note { color: var(--ink-muted); font-size: var(--text-sm); padding: var(--space-2) 0; }
/* .empty-note reads --ink-muted, which measures 3.15:1 in paper and
   3.52:1 in forge. That's defensible for the "nothing here yet" text
   it was named for, but the call screens were also using it for real
   instructions — "Tap anything you did today", "Tonight's weather
   appears from the next day's update onward" — and an instruction
   set in the placeholder colour is a legibility bug, not a style.
   .call-hint is that text: same shape, --ink-2, which clears 4.5:1 on
   all fourteen themes. Scoped to the calls surface deliberately
   rather than recolouring .empty-note app-wide, which would restyle
   every page in one go (2026-09-06). */
.call-hint { color: var(--ink-2); font-size: var(--text-sm); padding: var(--space-2) 0; margin: 0; }
/* initMuscleTagValidation()'s failed-submit state (added 2026-08-13,
   UX audit punch-list #3) — same hint paragraph, just recolored
   instead of a separate error element. */
.muscle-picker-hint.is-error { color: var(--danger); font-weight: var(--weight-semibold); }
.tip {
  display: flex;
  align-items: flex-start;
  gap: var(--space-2);
  border-left: 2px solid var(--accent-glow);
  background: var(--surface-2);
  color: var(--ink-muted);
  font-size: var(--text-xs);
  padding: var(--space-2) var(--space-3);
  margin: var(--space-2) 0;
  border-radius: var(--radius-sm);
}
.tip p { margin: 0; flex: 1; }
.preview-banner {
  align-items: center;
  border-left-color: var(--accent-glow-2);
  font-size: var(--text-sm);
  margin: 0 0 var(--space-4);
}
.preview-banner .btn { flex-shrink: 0; }
.view-as-banner {
  align-items: center;
  border-left-color: var(--danger);
  font-size: var(--text-sm);
  margin: 0 0 var(--space-4);
  position: sticky;
  top: 0;
  z-index: 10;
}
.view-as-banner .btn { flex-shrink: 0; }
/* Error variant of .tip (added 2026-08-29, UX audit finding) — same
   left-accent-border treatment, just recolored, instead of bare
   `style="color: var(--danger)"` text with no background/border. */
.tip-error { border-left-color: var(--danger); }
.tip button {
  appearance: none;
  border: none;
  background: none;
  color: var(--ink-muted);
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  line-height: 1;
  cursor: pointer;
  padding: 0;
}
.tip button:hover { color: var(--ink); }
.history-more { margin-top: var(--space-2); }
.history-more summary { font-size: var(--text-xs); }

/* Sticky wrapper, not fixed (changed 2026-07-28) — confirmed via an
   actual iPhone Safari screenshot from the user that `position:
   fixed` visibly lags/detaches from the top edge mid-scroll while
   Safari's address bar animates away (a well-known WebKit quirk:
   fixed elements are laid out against the viewport size *before*
   the toolbar-hide resize, so they briefly float over content
   until the resize settles). `position: sticky` stays in normal
   document flow instead of being computed against the viewport, so
   it isn't subject to that toolbar-driven resize lag. Full-width +
   pointer-events:none so it doesn't block clicks on content
   beneath the empty part of the bar; the actual switcher re-enables
   pointer-events on itself. */
.theme-switcher-bar {
  position: sticky;
  top: 0;
  z-index: 10;
  display: flex;
  justify-content: flex-end;
  pointer-events: none;
}
.theme-switcher {
  pointer-events: auto;
  max-width: min(19rem, calc(100vw - 2 * var(--space-3)));
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: 0 0 0 var(--radius-md);
  padding: 2px var(--space-2);
}
/* Collapsed by default (added 2026-07-27, was an always-visible
   button row) — same <details>/.details-content disclosure pattern
   used throughout the app (habit-card add/edit forms, task-details),
   animated for free by the existing generic initSmoothDetails(). */
.theme-switcher-toggle {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-muted);
  padding: var(--space-1) 0;
  /* min-height floor for a real tap target (was 63x21px — the only
     entry point to all 14 themes on pages with no /account link
     handy, e.g. the landing page; ux-ui-reviewer finding,
     2026-08-29). App-wide, not landing-only — this <summary> is the
     same shared settings_panel() control everywhere. Text size
     unchanged, just vertically centered in a taller box. */
  min-height: 2.5rem;
  display: flex;
  align-items: center;
}
.theme-switcher-toggle:hover { color: var(--ink); }
.theme-switcher-buttons {
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 2px;
  padding-top: 2px;
}
/* /account's dedicated Theme section (added 2026-07-28) — a normal,
   always-visible in-page widget, not the floating corner bar
   (.theme-switcher-bar) used everywhere else. Overrides the corner-
   badge sizing .theme-switcher carries by default (tiny buttons,
   tight max-width, asymmetric corner radius — all tuned for a
   compact fixed/sticky widget, wrong for a full-width page section). */
.theme-switcher.theme-switcher-inline {
  max-width: none;
  background: none;
  border: none;
  border-radius: 0;
  padding: 0;
  display: flex;
  flex-wrap: wrap;
  justify-content: flex-start;
  gap: var(--space-2);
}
.theme-switcher-inline .theme-btn {
  font-size: var(--text-xs);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--border);
}
.theme-btn {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: var(--space-1) var(--space-2);
  background: transparent;
  border: 1px solid transparent;
  border-radius: var(--radius-sm);
  color: var(--ink-muted);
  cursor: pointer;
}
.theme-btn:hover { color: var(--ink); }
.theme-btn.is-active {
  color: var(--page);
  background: var(--accent-glow);
  font-weight: var(--weight-semibold);
}
/* Landing page top bar — log-in link left, theme switcher right.
   Deliberately in NORMAL DOCUMENT FLOW: it sits at the top of the
   page and scrolls away with the content, rather than following the
   viewport.
   Do not "fix" this by adding `position: fixed` or `sticky`. That
   was tried six times (changes_log.md (18)-(24)) against a report of
   the buttons "moving when I scroll", which turned out to mean the
   opposite of the CSS sense: the user wanted them anchored to the
   *page*, and every fixed/sticky variant made them follow the
   screen more tightly. In-flow is the intended behaviour.
   `align-items: flex-start` so opening the Themes panel grows it
   downward without dragging the log-in link out of line;
   `white-space: nowrap` + `flex-shrink: 0` on `.landing-login-link`
   keep "Log in" on one line when that panel claims the row's width
   on narrow screens. Body padding supplies the outer inset, so the
   bar lines up with the rest of the page's content edges. */
.landing-topbar {
  display: flex;
  justify-content: space-between;
  align-items: flex-start;
  gap: var(--space-3);
  /* Taken out of flow (2026-08-30, direct request: the hero's title
     and hands should be vertically centered from the very first
     frame, not only once scrolling engages the pin's sticky stage
     — before this, the topbar's own in-flow height pushed the
     sticky stage's start point down from true viewport y=0, so
     centering only looked right after scrolling past that offset).
     `position: absolute`, NOT fixed/sticky — this is not the thing
     the comment above (now historical, the bar itself has changed
     shape since) warns against. An absolutely-positioned element
     with no positioned ancestor is anchored to a point in the
     DOCUMENT and scrolls away with the page exactly like an
     in-flow element would; fixed/sticky are what glue an element
     to the viewport so it keeps following the screen, which is
     the behaviour that was rejected. Verified by screenshotting a
     scroll pass after this change: the bar still disappears
     upward once you scroll past it, same as before. */
  position: absolute;
  top: 0; left: 0; right: 0;
  z-index: 5;
  /* Full-bleed to the true screen edges at every width, not just
     phones — was the `width:100vw; margin-left/right:calc(50% -
     50vw)` breakout `.habit-cards` also uses, but that trick is for
     escaping a centred *flow* ancestor; now that this is absolute
     with no positioned ancestor, its containing block is already
     the full viewport, so plain `left/right: 0` above does the same
     job directly. */
  /* Small breathing room so the buttons don't touch the screen
     edge. Inside the 100vw box (`box-sizing: border-box` is global),
     so it insets the buttons without re-narrowing the bar. */
  padding-left: var(--space-3);
  padding-right: var(--space-3);
}
/* Landing only — the Themes panel overlays the page when it opens
   instead of growing the bar and pushing the hero down. The
   `<details>` box stays summary-sized; the button grid is lifted out
   of flow and floated beneath it, so nothing below ever reflows.
   Needs its own background/border/radius because it no longer sits
   inside `.theme-switcher`'s painted box. Animation is untouched:
   `initSmoothDetails()` animates `max-height` on `.details-content`,
   which works the same absolutely positioned. */
/* Boxed/bordered button chrome reads as generic app UI fighting the
   delicate hand line art behind it (direct feedback, 2026-08-30:
   "the login and setting buttons are kinda fucking the site").
   Text-only at rest on the hero specifically — .theme-switcher-
   buttons below (the dropdown menu, once opened) keeps its own
   box, since a floating panel needs that separation to stay
   legible against the art. */
.landing-topbar .theme-switcher {
  position: relative;
  background: none;
  border: none;
  padding: 0;
}
.landing-topbar .theme-switcher-buttons {
  position: absolute;
  top: calc(100% + var(--space-1));
  right: 0;
  z-index: 20;
  width: max-content;
  max-width: min(19rem, calc(100vw - 2 * var(--space-3)));
  padding: var(--space-2);
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
}
/* Settings panel (added 2026-07-29) — the landing bar's menu,
   originally theme + language, now just theme (language removed
   2026-08-15). Stacked labelled groups rather than one flat button
   pile. Overrides .theme-switcher-buttons' flex-wrap row, which it
   reuses purely for the landing overlay positioning. */
.settings-panel .details-content {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--space-3);
  justify-content: flex-start;
}
.settings-group {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
}
.settings-group-label {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-subtle, var(--ink-muted));
}
.settings-group-items {
  display: flex;
  flex-wrap: wrap;
  gap: 2px;
  margin: 0;
}
/* Wraps the two auth links (added 2026-08-15 alongside /signup) so
   .landing-topbar's `justify-content: space-between` still sees just
   two children — this group and the settings panel — instead of
   splitting three items across the row. */
.landing-auth-links {
  display: flex;
  flex-shrink: 0;
  gap: var(--space-2);
}
.landing-login-link {
  flex-shrink: 0;
  white-space: nowrap;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-muted);
  text-decoration: none;
  padding: var(--space-2) var(--space-3);
  /* No box — see .landing-topbar .theme-switcher's comment above,
     same fix for the same reason. Text-only, credit-line style. */
  background: none;
  border: none;
  /* min-height, not just more padding — a fixed floor guarantees a
     real 44px tap target regardless of this text's small font
     metrics (was 66x31px; ux-ui-reviewer finding, 2026-08-29). The
     text itself stays the same small size, just vertically centered
     in a taller invisible box. */
  min-height: 2.75rem;
  display: inline-flex;
  align-items: center;
}
.landing-login-link:hover { color: var(--ink); }
/* At body's widest step the four tracks stretch to ~330px and the tiles
   go hollow, so the row doubles instead — 8 tracks, which is the other
   count that divides a yes/no habit's eight cells exactly (7 figures
   plus the lead's second track), landing them on one line. Tied to
   body's own 1372px step, not a number of this section's own. */
@media (min-width: 1372px) {
  .stat-grid { grid-template-columns: repeat(8, minmax(0, 1fr)); }
}

@media (max-width: 560px) {
  .theme-btn { padding: var(--space-1); font-size: 0.6rem; }
}

.empty-state {
  text-align: center;
  padding: var(--space-8) var(--space-4);
  color: var(--ink-muted);
}
.empty-state p:first-child {
  font-size: var(--text-md);
  color: var(--ink-2);
  margin: 0 0 var(--space-1);
}
.empty-state-hint { font-size: var(--text-xs); margin: 0; }

/* 5-segment progress bar (added 2026-07-30, from the UX audit's
   "Step X of 5 is plain text, easy to miss" finding) — discrete
   segments, not a continuous fill bar, since the wizard genuinely
   has 5 fixed, equally-weighted steps; a segmented bar reads that
   literally instead of implying continuous progress within a step.
   Kept .onboarding-step-label alongside it (demoted to a caption)
   rather than replacing it — screen readers get the same "step 2
   of 5" text either way, and the numeric label is still useful
   alongside the visual bar, not redundant with it. */
.onboarding-progress {
  display: flex;
  gap: var(--space-1);
  width: 100%;
  max-width: 32rem;
  margin: 0 0 var(--space-2);
}
.onboarding-progress-seg {
  flex: 1;
  height: 4px;
  border-radius: var(--radius-sm);
  background: var(--surface-2);
}
.onboarding-progress-seg.is-done { background: var(--series-1); }
.onboarding-step-label {
  color: var(--ink-muted);
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin: 0 0 var(--space-4);
}
/* "Previous step" link (added 2026-09-13) — sits between the step
   caption and the step's own card, left-aligned to the same 32rem
   column as the progress bar above it, since .centered-page centres
   everything by default. Muted like the caption it follows: it's an
   escape hatch, not a second action competing with Continue. The
   caption drops its bottom margin when this link follows it so the
   two read as one small header block, rather than floating apart
   across .centered-page's gap. */
.onboarding-step-label:has(+ .onboarding-back) { margin-bottom: 0; }
.onboarding-back {
  width: 100%;
  max-width: 32rem;
  margin: 0 0 var(--space-2);
  text-align: left;
}
.onboarding-back a {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--ink-muted);
  text-decoration: none;
}
.onboarding-back a:hover { color: var(--ink); }
/* Step 8's card is the one wider block in the wizard
   (.onboarding-enroll-step, 44rem) — the link tracks whatever step
   actually follows it rather than hardcoding "step 8" in the template,
   so it keeps hugging the card's left edge if another step ever goes
   wide. */
.onboarding-back:has(+ .onboarding-enroll-step) { max-width: 44rem; }
.onboarding-block { max-width: 32rem; width: 100%; text-align: left; }
.onboarding-block ul { margin: var(--space-2) 0 var(--space-3); padding-left: var(--space-5); }
.onboarding-block li { margin-bottom: var(--space-1); }
/* Step 7's build/break choice (added 2026-08-28, was step 6 until
   the calls step split in two on 2026-09-06) — two big equal-width
   buttons side by side. */
.onboarding-focus-choice {
  display: flex;
  gap: var(--space-3);
  margin-top: var(--space-3);
}
.onboarding-focus-choice form { flex: 1; }
.onboarding-focus-btn { width: 100%; padding: var(--space-4); font-size: var(--text-md); }
/* ---- The two call steps, 5 (morning) and 6 (night) (added
   2026-09-06, direct request: the old single calls step was "a bit
   messy, and it doesn't have a structured order").

   The structure IS the fix: a titled head that says which of the two
   calls this is, then numbered sections in the order a user thinks
   in — who calls, when, what it covers, extras. `--tone` follows the
   .call-panel.is-morning/.is-night convention already used on
   /calls, so the same call wears the same accent in both places. */
.onboarding-call-step.is-morning { --tone: var(--accent-glow); }
.onboarding-call-step.is-night { --tone: var(--accent-glow-2); }
.onboarding-call-head {
  display: flex;
  align-items: center;
  gap: var(--space-3);
}
.onboarding-call-head .call-card-icon {
  width: 2rem;
  height: 2rem;
  flex: none;
  color: var(--tone);
}
.onboarding-call-eyebrow {
  display: block;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--tone);
}
.onboarding-call-heading h2 { margin: var(--space-1) 0 0; }
.onboarding-call-sub {
  margin: var(--space-2) 0 0;
  font-size: var(--text-sm);
  line-height: 1.55;
  color: var(--ink-muted);
}
.onboarding-sections { margin: 0; }
.onboarding-section {
  margin-top: var(--space-5);
  padding-top: var(--space-4);
  border-top: 1px solid var(--gridline);
}
/* The section head reads like the enrollment popup's
   .popup-section-title — mono label, then a rule running to the
   right edge — with the step's number in place of that one's dot,
   since here the sections are a sequence, not a set. */
.onboarding-section-title {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin: 0 0 var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-2);
}
.onboarding-section-title::after {
  content: "";
  flex: 1;
  height: 1px;
  background: linear-gradient(90deg, var(--gridline), transparent);
}
.onboarding-section-num {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.35rem;
  height: 1.35rem;
  border: 1px solid color-mix(in srgb, var(--tone) 55%, transparent);
  border-radius: var(--radius-sm);
  color: var(--tone);
  font-variant-numeric: tabular-nums;
}
.onboarding-section-label { flex: none; }
.onboarding-section-optional {
  flex: none;
  order: 3;
  color: var(--ink-muted);
  font-weight: var(--weight-regular);
  letter-spacing: 0.08em;
}
/* Step 5's Continue sits outside the form it submits (see that
   template's comment), so it needs the separation the form's own
   .form-actions margin gave it. */
.onboarding-step-actions { margin-top: var(--space-5); }
/* The custom-topic add row is one text field + Add, laid out inline
   so it reads as a single control rather than two stacked ones. The
   shared .form-grid-inline column (8rem, sized for a program's short
   numeric set fields) cut the placeholder in half here, so this one
   field gets the room its own copy needs. */
.onboarding-call-step .form-grid-inline > label:not(:has(> input[type="checkbox"])) {
  flex: 1 1 18rem;
}

/* ---- Step 8's ranked-habit picker (rebuilt 2026-09-06, direct
   request: the old radio list "is an ugly list. I want it to be like
   the list when you're on the habits page and click on Enroll").
   It IS that list — same enroll_* macros, same .enroll-grid/.enroll-
   card rules further down this file — with each card a submit button
   instead of a link, so it needs the app's mono/uppercase button
   skin taken back off to look identical to the popup's version. The
   wider block is what lets the grid hold two card columns, the way
   the popup's own 48rem panel does. */
.onboarding-enroll-step { max-width: 44rem; }
button.enroll-card {
  width: 100%;
  font-family: inherit;
  font-size: inherit;
  font-weight: inherit;
  letter-spacing: normal;
  text-transform: none;
  text-align: left;
}
/* .popup-section-title is `position: sticky` inside the popup's
   scrolling body; nothing scrolls here, and sticky on a page-level
   heading would peel it off the section on the way past. Its opaque
   --page fill exists only to hide the cards passing under it, and
   this block is --surface-2, so it has to go too or the title reads
   as a stripe. */
.onboarding-enroll-step .popup-section-title {
  position: static;
  background: transparent;
}
.onboarding-enroll-step .popup-section { margin-top: var(--space-4); }

.routine-grid {
  --routine-row-h: 0.85rem;
  position: relative;
  margin-top: var(--space-3);
  display: grid;
  grid-template-columns: minmax(2.75rem, auto) repeat(7, minmax(6rem, 1fr));
  /* row 1: day headers. row 2: reserved for the all-day tray
     (untimed routine events, added 2026-08-09 — see
     RoutineGrid.all_day/build_routine_grid's row_offset=1 call in
     habits_page.py). Hourly slot rows start at row 3, same
     row_offset=1 convention Calendar's own .calendar-grid already
     established for its own all-day tray. */
  grid-template-rows: auto auto repeat(var(--total-rows), var(--routine-row-h));
  column-gap: var(--space-2);
  row-gap: 0;
  overflow-x: auto;
  padding-bottom: var(--space-2);
}
.routine-day-header {
  grid-row: 1;
  text-align: center;
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-2);
  padding-bottom: var(--space-2);
}
.routine-track {
  border-radius: 0;
  background-image: repeating-linear-gradient(
    to bottom,
    transparent,
    transparent calc(4 * var(--routine-row-h) - 1px),
    var(--gridline) calc(4 * var(--routine-row-h) - 1px),
    var(--gridline) calc(4 * var(--routine-row-h))
  );
}
.routine-hour-label {
  grid-column: 1;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  color: var(--ink-muted);
  align-self: start;
  transform: translateY(-0.4em);
  white-space: nowrap;
}
.routine-event {
  /* min-height: 0 overrides the grid item's default min-height: auto —
     without it, over-height content (a name+time pair taller than a
     short block's own row span) forces the item itself to grow taller
     than its grid-row span to fit, so the bordered box grew past the
     event's real end time and stopped lining up with the hour
     gridlines/labels underneath. With it, the box always stays exactly
     the size the grid track gives it. */
  min-height: 0;
  margin: 1px 2px;
  padding: 2px var(--space-1);
  border-radius: 0;
  background: color-mix(in srgb, var(--block-color, var(--series-1)) 14%, transparent);
  border: 1px solid var(--block-color, var(--series-1));
  font-family: var(--font-mono);
  font-size: 0.6rem;
  letter-spacing: 0.01em;
  text-transform: uppercase;
  line-height: 1.2;
  display: flex;
  flex-direction: column;
  gap: 1px;
  transition: background-color 150ms ease;
}
.routine-event:hover {
  background: color-mix(in srgb, var(--block-color, var(--series-1)) 30%, transparent);
  z-index: 2;
}
.routine-event-name {
  flex-shrink: 0;
  font-weight: var(--weight-medium);
  color: var(--ink);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.routine-event-time { flex-shrink: 0; color: var(--ink-muted); }
.routine-event.clipped { border-style: dashed; opacity: 0.75; }
/* .compact (<=30min block, see RoutineEventBlock.compact): dropping
   the time line (still available via the block's `title` hover)
   leaves just one name line to fit — tight but workable at every
   duration down to 15min, unlike the name+time pair this replaces.
   Keeps the normal bordered box; just centers the single line and
   trims padding slightly for a bit more headroom. */
.routine-event.compact {
  justify-content: center;
  padding: 1px var(--space-1);
}

.routine-event-preview {
  background: color-mix(in srgb, var(--series-1) 16%, transparent);
  border: 1px dashed var(--series-1);
  color: var(--series-1);
  pointer-events: none;
}
.routine-event-preview[hidden] { display: none; }

.task-card { padding: var(--space-2) var(--space-3); margin-bottom: var(--space-2); }
.task-row-head { display: flex; align-items: center; gap: var(--space-2); }
.task-check-form { flex: none; }
.task-row-head .reorder-controls { flex: none; }
.check-btn.task-check { width: 1.6rem; height: 1.6rem; padding: 0; position: relative; }
.check-btn.task-check.done::after {
  content: "";
  position: absolute;
  left: 0.5rem;
  top: 0.28rem;
  width: 0.3rem;
  height: 0.55rem;
  border: solid var(--page);
  border-width: 0 2px 2px 0;
  transform: rotate(45deg);
}
.task-details { flex: 1; min-width: 0; }
.task-details summary { display: flex; align-items: center; gap: var(--space-2); }
/* Calendar-anchor tasks (no <details>/edit popover, see task_row() in
   _macros.html) still need the same flex:1;min-width:0 + row layout
   .task-details + its summary gave title/time together, or .task-
   title's ellipsis/.calendar-allday-cell's column-stack fix below
   have nothing to shrink/stack against. */
.task-title-group { display: flex; align-items: center; gap: var(--space-2); flex: 1; min-width: 0; }
.task-title {
  font-family: var(--font-mono);
  font-size: var(--text-md);
  font-weight: var(--weight-semibold);
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* --good, not --ink-muted (changed 2026-07-30, UX audit's #3
   punch-list item) — --ink-muted is the same token used for
   de-emphasized/inactive text everywhere else in the app, so a
   completed task and a disabled control read identically. --good
   is the app's existing success/completion color (already used by
   the habit tracker's .numeric-pill.logged), reused here rather
   than introducing a new token. */
.task-title.done { color: var(--good); text-decoration: line-through; }
/* Multi-line body text (added 2026-07-26 for /todo's TODO.md items —
   unlike .task-title above, these are full sentences/paragraphs, not
   short one-line titles, so no nowrap/ellipsis clipping and a
   regular-weight, smaller size that actually reads as prose. */
.todo-item-text {
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  font-weight: var(--weight-regular);
  line-height: 1.5;
  flex: 1;
  min-width: 0;
  white-space: normal;
  overflow-wrap: break-word;
}
.todo-item-text.done { color: var(--ink-muted); text-decoration: line-through; }
.todo-item-text code { font-size: var(--text-xs); }
.task-time {
  flex: none;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--ink-muted);
  white-space: nowrap;
}
.task-time.all-day { color: var(--series-1); }

/* Tasks widget: Today / This week / Long term read as three distinct
   bordered panels of the same width, instead of loose headings
   bleeding into each other (This week previously broke out to
   100vw for extra room — dropped per user request so all three
   panels line up; the 7-column grid below now scrolls horizontally
   within the panel on narrow containers instead). */
.tasks-block {
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: var(--space-4) var(--space-5);
  margin-bottom: var(--space-6);
}
.tasks-daily { margin-top: var(--space-2); }
.tasks-block:last-child { margin-bottom: 0; }

/* Shared by Login and Onboarding (added 2026-07-30, from the UX
   audit's "pinned top-left of an otherwise empty canvas" finding —
   originally `.login-page`, generalized here the same day once
   Onboarding needed the identical treatment) — a focused,
   single-task page, so it gets its own centered layout rather than
   flowing top-left like the multi-widget dashboard pages do.
   min-height subtracts body's own top+bottom padding (1.5rem +
   4rem, see the `body` rule) so this centers within the actual
   visible viewport, not overshoot it and force a scrollbar. */
.centered-page {
  min-height: calc(100vh - 5.5rem);
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: var(--space-3);
}
.centered-page h1 { margin: 0; }
.login-card {
  width: 100%;
  max-width: 24rem;
  text-align: left;
}
.login-back-link {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.05em;
  color: var(--ink-muted);
  text-decoration: none;
}
.login-back-link:hover { color: var(--ink); }

/* Show/hide toggle for the password field — eye/eye-off pair,
   same inline-SVG icon-button pattern the Chat page's mic button
   uses (base.html search: chat-mic-btn). Two pre-rendered SVGs
   toggled via `hidden` rather than swapping path data at runtime,
   same reasoning as everywhere else in this app that keeps
   translated/derived strings server-rendered: data-label-show/
   data-label-hide hold both `t()`-translated aria-label strings up
   front so initPasswordToggle() only ever swaps between two known
   values, never constructs UI text in JS. */
.password-field { position: relative; }
.password-field input { padding-right: 2.25rem; }
.password-toggle-btn {
  position: absolute;
  top: 50%;
  right: var(--space-1);
  transform: translateY(-50%);
  display: flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: none;
  padding: var(--space-1);
  margin: 0;
  color: var(--ink-muted);
  cursor: pointer;
}
.password-toggle-btn:hover { color: var(--ink); border-color: transparent; }
.password-toggle-btn:active { transform: translateY(-50%) scale(0.94); }
/* The browser's default `[hidden] { display: none }` UA rule
   doesn't reliably apply to inline <svg> elements (verified: both
   icons rendered simultaneously with `hidden` present but computed
   display staying `block`) — declared explicitly instead of
   relying on that. */
.password-toggle-btn svg[hidden] { display: none; }
/* The panel header now carries that panel's own control on its right
   (2026-09-06) — the "+ Add task" chip, or This week's date nav. It
   used to be title + count only, with the control below as a
   page-wide slab. align-items: center (not baseline) because a 34px
   chip and a 11px label have no shared baseline worth aligning to. */
.tasks-group-title {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-3);
  min-height: 34px;
  margin-top: 0;
  margin-bottom: var(--space-3);
  padding-bottom: var(--space-2);
  border-bottom: 1px solid var(--gridline);
}
/* The count rides with the title, and its auto right margin is what
   pushes the control to the far edge. Scoped here so
   .task-category-header's own space-between row is untouched. */
/* A boxed count, not a loose numeral: at 0.66rem mono, "4" set plain
   next to a date reads as part of the date. */
.tasks-group-title .tasks-count {
  margin-right: auto;
  min-width: 1.4rem;
  padding: 1px 5px;
  text-align: center;
  border: 1px solid color-mix(in srgb, var(--series-1) 40%, transparent);
  border-radius: var(--radius-sm);
}
.tasks-group-sub { color: var(--ink-muted); font-weight: var(--weight-regular); }
.tasks-count {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: var(--weight-semibold);
  color: var(--series-1);
}
/* /habits' two groups — Ranked and Your habits (rebuilt 2026-09-02,
   direct user report: "on the ranked session there is a button for
   the normal habits, and the ranked button is almost on the normal
   habits ... the title is small and unimportant ... I dont like the
   style of the buttons they dont feel harmonic").

   Three things this fixes at once:
   1. Each group is one block in the markup (see
      _widget_habits.html), so a group's action button sits under its
      OWN heading instead of hard against the next group's.
   2. The heading is a real section divider — display font at
      --text-md, an accent bar, and a rule across the content width
      — a full tier above the mono .tasks-group-title/h4 label it
      used to be, which read as a caption on the group below it.
   3. The action is an ordinary button, not a plate: both toggles
      were `<details class="habit-card">`, i.e. the exact frame,
      corner brackets and full width of a habit card, plus a
      disclosure triangle. The summary now reuses the app's own
      button language (mono/uppercase, hairline border, the same
      padding and fill-on-hover as `button, .btn` above), so it
      belongs to the same family as LOG / MARK / ADD HABIT.

   `--group-accent` is the single knob that separates the two
   groups: Ranked is uful-curated, so it carries the app accent; a
   user's own habits stay neutral. Both titles keep the same size
   and weight — the color is the distinction, not a demotion. */
.habit-group { --group-accent: var(--ink-2); margin-top: var(--space-8); }
.habit-group-ranked { --group-accent: var(--series-1); }
/* .habit-group's box is the full-bleed card grid itself — geometry
   and the reasoning behind it live with the `.habit-cards` history
   near the top of this stylesheet, not here. Deliberately no border
   of its own: its `margin-left: calc(50% - 50vw)` breakout resolves
   `50%` against the widget's content box, so nothing may change how
   wide that box is or every card grid shifts off-centre. */
.habit-group-head {
  display: flex;
  align-items: baseline;
  gap: var(--space-3);
  border-bottom: 1px solid var(--gridline);
  padding-bottom: var(--space-2);
}
.habit-group-title {
  font-family: var(--font-display);
  font-size: var(--text-md);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink);
  margin: 0;
  padding-left: var(--space-3);
  border-left: 3px solid var(--group-accent);
}
.habit-group-count {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: var(--weight-semibold);
  color: var(--group-accent);
}
.habit-group-sub {
  margin: var(--space-2) 0 0;
  font-size: var(--text-2xs);
  line-height: 1.5;
  color: var(--ink-muted);
  /* No measure cap (2026-09-02, direct request "I want all in a single
     line") — both group subtitles are one sentence and fit on one line
     at card width; they still wrap on narrow screens. */
}
.habit-group-action { margin: var(--space-4) 0 var(--space-5); }
.habit-group-action > summary {
  /* Marker dropped in both engines (list-style for Firefox/Chromium,
     ::-webkit-details-marker for Safari), same pair the .habit-details
     trigger uses — a triangle reads as "outline", not "button". */
  list-style: none;
  display: inline-flex;
  align-items: center;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--group-accent);
  border: 1px solid var(--group-accent);
  border-radius: var(--radius-sm);
  padding: var(--space-2) var(--space-4);
  cursor: pointer;
  transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}
.habit-group-action > summary::-webkit-details-marker { display: none; }
.habit-group-action > summary:hover { background: var(--group-accent); color: var(--page); }
.habit-group-action[open] > summary { background: var(--group-accent); color: var(--page); }
.habit-group-ranked > .empty-note { margin: var(--space-3) 0 0; }
/* One tier below .tasks-group-title (panel-level) — an accent bar
   instead of a full bottom border keeps the hierarchy readable:
   panel > category > task. */
.task-category-header {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2);
  margin-top: var(--space-4);
  margin-bottom: var(--space-2);
  padding-left: var(--space-2);
  border-left: 2px solid var(--series-1);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--ink-2);
}
.task-category-header:first-of-type { margin-top: 0; }
.task-category-header .tasks-count { font-size: 0.6rem; }

/* Long term's per-category "Completed" disclosure (added 2026-09-12).
   Daily and weekly tasks age out of view on their own when the day or
   week moves on; long-term ones have no date to age out with, so ticked
   ones used to pile up struck-through in the live list forever. They now
   drop into this, closed on every load.

   Styled to recede: a dashed hairline rather than the category header's
   solid accent bar, muted mono at the count's own size, and the rows
   inside dimmed — it's an archive you open on purpose, not a second list
   competing with the live one. The marker is hand-drawn (::before, with
   the native one suppressed both ways) like every other <details> in the
   app, so it can rotate and inherit color. */
.tasks-untimed-category + .tasks-untimed-category { margin-top: var(--space-5); }
.tasks-completed { margin-top: var(--space-2); }
/* Open, the archive ends right where the next category header begins;
   without this the two read as one run of rows. */
.tasks-completed[open] { padding-bottom: var(--space-2); }
.tasks-completed > summary {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  list-style: none;
  cursor: pointer;
  padding: var(--space-2) 0 0;
  border-top: 1px dashed var(--border);
  font-family: var(--font-mono);
  font-size: 0.6rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--ink-muted);
  transition: color 120ms ease;
}
.tasks-completed > summary::-webkit-details-marker { display: none; }
.tasks-completed > summary::before {
  content: "\25B8";
  display: inline-block;
  transition: transform 120ms ease;
}
.tasks-completed[open] > summary::before { transform: rotate(90deg); }
.tasks-completed > summary:hover { color: var(--ink-2); }
.tasks-completed-list { margin-top: var(--space-3); opacity: 0.7; }
.tasks-completed-list .task-card:last-child { margin-bottom: 0; }

/* This week's Prev/label/Next became a .cal-seg (2026-09-06) — the
   same segmented instrument the calendar bar steps its range with,
   instead of three underlined anchor-buttons. Only the label's fixed
   min-width is overridden: inside a panel header it has less room
   than it does on the calendar bar, and the range string is shorter. */
.tasks-week-nav .cal-datenav-label { min-width: 0; }
/* Daily Report's date input (added 2026-07-30, UX audit finding —
   the one native OS control left on an otherwise fully re-skinned
   page). Unlike the Chat page's file-input browse button
   (::file-selector-button, a real styleable button), the calendar
   icon is ::-webkit-calendar-picker-indicator — a bitmap-like
   pseudo-element that only accepts filter/opacity, never `color`,
   so it can't be recolored to an exact theme token the way a real
   button can. Hidden instead (opacity: 0) with a hand-drawn SVG
   layered on top — same 24x24 stroke/currentColor icon language
   as the Chat mic button and _macros.html's nav_icon(), so it's
   an actual app icon in the exact right color per theme, not a
   filter approximation of one.
   First attempt positioned the invisible indicator at its normal
   small native size/spot and placed the SVG directly over it —
   verified broken: a click exactly on the drawn icon's coordinates
   moved focus to <body>, not the input, meaning the guessed
   position didn't match the real (browser-internal, not fully
   specified) hit-box. Fixed the reliable way: stretch the
   invisible indicator to cover the ENTIRE input
   (position:absolute; inset:0; width/height:100%) so any click
   anywhere in the field — including on the decorative icon, which
   sits inside the input's bounds — lands on real native picker
   chrome. Re-verified the same way: document.activeElement is now
   the date input after a click on the icon's exact coordinates.
   Third bug, reported back as "there are two icons rn" — the hide
   rule targets ::-webkit-calendar-picker-indicator, a Chromium/
   WebKit-only pseudo-element with no Firefox equivalent (Firefox
   doesn't expose any hook to hide/style its native date icon at
   all). In Firefox the whole hide rule silently matches nothing,
   so its native icon stayed fully visible right next to the new
   SVG — not a hot-reload/stale-process issue, a real engine gap.
   Scoped the entire hide+overlay treatment behind `@supports
   selector(::-webkit-calendar-picker-indicator)` so it only
   activates where it can actually work: Chromium/WebKit gets the
   custom themed icon exactly as before, Firefox falls back to
   its own plain native icon alone (not restyled, but not doubled
   either) via .date-field-icon's default `display: none`. */
.date-field { position: relative; display: inline-flex; align-items: center; }
.date-field-icon { display: none; }
@supports selector(::-webkit-calendar-picker-indicator) {
  .date-field input[type="date"] { padding-right: 1.9rem; }
  .date-field input[type="date"]::-webkit-calendar-picker-indicator {
    opacity: 0;
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    margin: 0;
    cursor: pointer;
  }
  .date-field-icon {
    display: block;
    position: absolute;
    right: var(--space-2);
    color: var(--ink-2);
    pointer-events: none;
  }
}
/* Days-as-rows unconditionally (changed 2026-07-30, on request —
   "I want the mobile version to be on desktop"). Used to be a
   7-column grid down to ~tablet width, then this row layout below
   a `@media (max-width: 48rem)` breakpoint (rationale: 7 real
   columns can't fit readable text no matter how they're sized,
   even with horizontal scroll available). Collapsed into one
   unconditional layout — the values below are exactly what the
   media query used to apply only under 48rem, now applying at
   every width. */
.tasks-week-grid {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  margin-top: var(--space-3);
  overflow-x: visible;
  padding-bottom: 0;
}
.tasks-week-day { display: flex; flex-direction: column; gap: var(--space-2); min-width: 0; }
.tasks-week-day .task-card { padding: var(--space-2) var(--space-3); }
.tasks-week-day .check-btn.task-check { width: 1.6rem; height: 1.6rem; }
.tasks-week-day .check-btn.task-check.done::after {
  left: 0.5rem; top: 0.28rem; width: 0.3rem; height: 0.55rem;
}
.tasks-week-day .task-details summary {
  flex-direction: row;
  align-items: center;
}
.tasks-week-day .task-title { font-size: var(--text-md); font-weight: var(--weight-semibold); width: 100%; }
.tasks-week-day .task-time { font-size: var(--text-2xs); }
/* z-index only now — grid-column (used to span "the full grid")
   and the title/time font-size bumps are dead now there's no grid
   and the base sizes already match what these used to bump up to. */
.tasks-week-day:has(.task-details[open]) { z-index: 2; }
.tasks-week-day:has(.task-details[open]) .form-grid { max-width: none; }
/* Today's row, marked the same way today's column is on the calendar
   grid above — accented label plus a leading edge, so the current day
   is findable in a stack of seven without reading any dates. */
.tasks-week-day.is-today .tasks-week-day-header { color: var(--accent-glow); }
.tasks-week-day.is-today .tasks-week-day-header { border-bottom-color: color-mix(in srgb, var(--accent-glow) 45%, transparent); }
.tasks-week-day.is-today { border-left: 2px solid var(--accent-glow); padding-left: var(--space-3); margin-left: calc(-1 * var(--space-3) - 2px); }
.tasks-week-day-header {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-2);
  min-height: 30px;
  padding-bottom: var(--space-1);
  border-bottom: 1px solid var(--gridline);
  display: flex;
  flex-direction: row;
  justify-content: space-between;
  align-items: center;
  gap: var(--space-3);
  text-align: left;
}
/* Day letter + date read as one label on the left; the far right is
   the day's add chip. They used to sit at opposite ends of the row
   with nothing between them, which made a 1,340px rule out of what is
   one short caption. */
.tasks-week-day-name { display: inline-flex; align-items: baseline; gap: var(--space-2); }
.tasks-week-day-date { color: var(--ink-muted); font-weight: var(--weight-regular); }
/* Shared .empty-note's var(--space-2) top+bottom padding, on top of
   .tasks-week-day's own flex gap before/after it, made an empty day
   ("Nothing yet.") take up noticeably more vertical room than a day
   with actual tasks — shrunk here rather than touching the shared
   rule other empty states (Long-term tasks, Daily list) still use
   as-is. */
.tasks-week-day .empty-note { padding: 0; font-size: var(--text-xs); }
/* Further compaction (still on request) — the parent's own flex
   `gap` (var(--space-2) = 8px) can't be reduced per-pair-of-
   children in flexbox, only for the whole row, so scope it to
   days that ARE just the empty state via :has() — a day with real
   task cards keeps the roomier var(--space-2) gap between them. */
.tasks-week-day:has(.empty-note) { gap: 2px; }

.calendar-legend {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  color: var(--ink-muted);
  margin: var(--space-2) 0 var(--space-3);
}
.legend-chip {
  width: 0.7rem;
  height: 0.7rem;
  display: inline-block;
  margin-left: var(--space-3);
}
.calendar-legend .legend-chip:first-child { margin-left: 0; }
.legend-chip-routine {
  border-radius: 0;
  background: color-mix(in srgb, var(--series-1) 14%, transparent);
  border: 1px solid var(--series-1);
}
.legend-chip-task {
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  border: 1px solid var(--ink-muted);
}
.calendar-grid {
  --routine-row-h: 0.85rem;
  position: relative;
  margin-top: var(--space-3);
  display: grid;
  grid-template-columns: minmax(2.75rem, auto) repeat(7, minmax(6rem, 1fr));
  grid-template-rows: auto auto repeat(var(--total-rows), var(--routine-row-h));
  column-gap: var(--space-2);
  row-gap: 0;
  overflow-x: auto;
  padding-bottom: var(--space-2);
}
/* Desktop/laptop widths (added 2026-07-26): the 6rem-per-day floor
   (needed on phone widths to keep columns legible, see the mobile
   column-floor fix below) needs slightly more room than body's own
   900-1199px width tier provides, forcing a horizontal scrollbar
   even on a normal laptop window. A smaller floor here still leaves
   each column wide enough to read while comfortably fitting every
   body width tier from 900px up — only viewports narrower than that
   (real phone widths) still fall back to the wider floor + scroll. */
@media (min-width: 900px) {
  .calendar-grid { grid-template-columns: minmax(2.75rem, auto) repeat(7, minmax(5.5rem, 1fr)); }
}
/* The weekday initial and the date are two adjacent nodes with no
   separator between them in the markup ("M" + "AUG 31"), which is
   fine in .tasks-week-day-header — that one is a flex row with
   `justify-content: space-between`, so it pushes them to opposite
   ends of the column. This header reuses the same pair from
   _widget_calendar.html but was a plain centred block, so they
   rendered welded together as "MAUG 31" (spotted 2026-09-02, direct
   instruction: "fix the MAUG thing"). Centred flex row with a real
   gap: the separation is the layout's job here too, rather than a
   whitespace character in the template that both call sites would
   then have to agree about. */
.calendar-day-header {
  grid-row: 1;
  display: flex;
  justify-content: center;
  align-items: baseline;
  gap: var(--space-2);
  text-align: center;
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-2);
  padding-bottom: var(--space-2);
}
.calendar-allday-label {
  grid-row: 2;
  grid-column: 1;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  color: var(--ink-muted);
  white-space: nowrap;
  align-self: start;
}
.calendar-allday-cell {
  grid-row: 2;
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-height: 1.4rem;
  padding-bottom: var(--space-1);
}
.calendar-allday-cell .task-card { padding: 2px var(--space-2); }
.calendar-allday-cell .check-btn.task-check { width: 1.1rem; height: 1.1rem; margin-top: 1px; }
/* Side-by-side title+badge (the base .task-details summary layout)
   starves the title in this narrow a column — the "all day"/time
   badge is flex:none (never shrinks), so title's flex:1 shrinks to
   almost nothing and ellipsizes after 2-3 characters, leaving only
   the badge legible. Stack instead, same fix already used for the
   Tasks weekly table's own narrow columns (.tasks-week-day
   .task-details summary): title gets the cell's full width on its
   own line. align-items: flex-start (row-head + summary) keeps the
   checkbox pinned to the title's first line instead of vertically
   centering against the now-much-taller wrapped title. */
.calendar-allday-cell .task-row-head { align-items: flex-start; }
.calendar-allday-cell .task-title-group {
  flex-direction: column;
  align-items: flex-start;
  gap: 0.1rem;
}
.calendar-allday-cell .task-title {
  font-size: var(--text-2xs);
  width: 100%;
  white-space: normal;
  overflow-wrap: break-word;
}
.calendar-track {
  border-radius: 0;
  background-image: repeating-linear-gradient(
    to bottom,
    transparent,
    transparent calc(4 * var(--routine-row-h) - 1px),
    var(--gridline) calc(4 * var(--routine-row-h) - 1px),
    var(--gridline) calc(4 * var(--routine-row-h))
  );
}

.calendar-task-marker {
  position: relative;
  margin: 1px 4px;
  z-index: 3;
}
.calendar-task-marker .task-card {
  padding: 1px 4px;
  border-radius: var(--radius-sm);
  overflow: hidden;
}
.calendar-task-marker .task-row-head { gap: 3px; }
.calendar-task-marker .check-btn.task-check { width: 0.85rem; height: 0.85rem; }
.calendar-task-marker .check-btn.task-check.done::after {
  left: 0.24rem; top: 0.1rem; width: 0.16rem; height: 0.3rem;
}
.calendar-task-marker .task-title { font-size: 0.55rem; }
.calendar-task-marker .task-time { display: none; }

/* ============ /calendar: the bar, the month, the block list ========
   (added 2026-09-06). The page had three visual languages on one
   widget — a dense instrument grid, two page-wide `<details
   class="habit-card">` slabs holding forms a quarter that width, and
   an auto-fit card grid whose last row stretched one block across the
   page. This is the single vocabulary they now share: hairline mono
   chips on a bar, panels that drop from the chip that owns them, and
   a colour key carried from the grid into the list.

   Reads only --border, --gridline, the --surface and --ink tokens
   and --accent-glow, plus the --series-N a block already carries,
   so all fourteen themes
   follow with no per-theme block, same as the Day Dial cluster. */
.cal-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  margin: var(--space-3) 0 var(--space-4);
  padding-bottom: var(--space-3);
  border-bottom: 1px solid var(--gridline);
}

/* The shared chip. One rule for every control on the bar — a segment,
   a standalone button, a <summary> — so they are the same object at
   the same height regardless of which element the semantics needed. */
.cal-seg > a,
.cal-seg > span,
.cal-chip-btn,
.cal-tool > summary {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--space-2);
  min-height: 34px;
  padding: 0 var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--ink-2);
  text-decoration: none;
  white-space: nowrap;
  cursor: pointer;
  transition: border-color 120ms ease, color 120ms ease, background-color 120ms ease;
}
.cal-seg > a:hover,
.cal-chip-btn:hover,
.cal-tool > summary:hover { border-color: var(--accent-glow); color: var(--ink); }

/* Segmented pairs/triples: neighbours share one edge (the
   .call-screen-nav treatment) so each group reads as one instrument
   rather than two loose buttons. */
.cal-seg { display: inline-flex; }
.cal-seg > * + * { border-left-color: transparent; margin-left: -1px; }
.cal-seg > a:hover { z-index: 1; }
/* The active view is filled, not merely bolder — at this size a
   weight change alone is not a reliable "you are here". */
.cal-viewswitch > a.is-active {
  background: var(--accent-glow);
  border-color: var(--accent-glow);
  color: var(--page);
  z-index: 1;
}
.cal-datenav-arrow { font-size: var(--text-xs); padding: 0 var(--space-3); }
.cal-datenav-label {
  color: var(--ink);
  font-weight: var(--weight-medium);
  letter-spacing: 0.06em;
  cursor: default;
  min-width: 11rem;
}

/* A form chip and the panel it owns. `position: relative` + an
   absolutely-positioned panel keeps the bar's own height fixed
   whether a form is open or shut — an in-flow panel would push the
   entire grid down by the height of a form every time one is
   opened. Plain <details>, so it still opens with JS off. */
.cal-tool { position: relative; }
.cal-tool > summary { list-style: none; }
.cal-tool > summary::-webkit-details-marker { display: none; }
.cal-tool > summary::after {
  content: "";
  width: 0; height: 0;
  border-left: 3.5px solid transparent;
  border-right: 3.5px solid transparent;
  border-top: 4px solid currentColor;
  transition: transform 120ms ease;
}
.cal-tool[open] > summary { border-color: var(--accent-glow); color: var(--ink); }
.cal-tool[open] > summary::after { transform: rotate(180deg); }
.cal-tool-primary > summary { color: var(--accent-glow); border-color: color-mix(in srgb, var(--accent-glow) 45%, transparent); }
/* The compact variant, for a chip that sits inside a row it must not
   set the height of — a day's own "+ Add" in the Tasks week list. */
.cal-tool-sm > summary { min-height: 26px; padding: 0 var(--space-2); font-size: 0.6rem; letter-spacing: 0.06em; }
.cal-tool-sm > summary::after { border-top-width: 3px; border-left-width: 3px; border-right-width: 3px; }
.cal-tool-panel {
  position: absolute;
  top: calc(100% + var(--space-2));
  right: 0;
  z-index: 40;
  width: max-content;
  max-width: min(26rem, calc(100vw - 2rem));
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: var(--space-4);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.45);
}
.cal-tool-panel .form-grid { max-width: none; }
.cal-tool-panel .form-actions { margin-top: var(--space-3); }
/* Below ~30rem a 26rem popover has nowhere to go but off-screen, so
   drop out of the overlay entirely and let the panel flow in the bar
   at full width — the height cost the absolute positioning exists to
   avoid is not worth an unreachable form. */
@media (max-width: 30rem) {
  .cal-tool { position: static; }
  .cal-tool-panel { position: static; max-width: none; width: auto; box-shadow: none; margin-top: var(--space-2); }
  .cal-bar .calendar-legend { margin-left: 0; }
}

/* Legend chips sit on the bar now, so they lose their old standalone
   top margin. The auto left margin is the bar's hinge: the legend and
   the two form chips after it ride the right edge, where controls live
   on every other surface, and the whole tail drops to a second line
   together once the bar runs out of room. */
.cal-bar .calendar-legend { margin: 0 0 0 auto; }

/* ---- The now-line -------------------------------------------------
   Two pieces: a hairline across the whole week (where the day is) and
   a dot on today's column (which day it is). Both are positioned at
   their grid row and then nudged by --now-frac of one row height, so
   the mark lands on the real minute rather than the row boundary. */
.cal-now {
  grid-column: 2 / -1;
  align-self: start;
  height: 1px;
  margin-top: calc(var(--now-frac, 0) * var(--routine-row-h));
  background: var(--danger);
  opacity: 0.75;
  pointer-events: none;
  z-index: 4;
}
.cal-now-dot {
  align-self: start;
  justify-self: start;
  width: 7px;
  height: 7px;
  margin-top: calc(var(--now-frac, 0) * var(--routine-row-h) - 3px);
  margin-left: -3px;
  border-radius: 50%;
  background: var(--danger);
  pointer-events: none;
  z-index: 5;
}
/* Today's column, marked on the week grid (the data was already in
   CalendarDayColumn.is_today and simply wasn't drawn). A wash on the
   track plus an accented header — nothing that competes with the
   blocks sitting on top of it. */
.calendar-day-header.is-today { color: var(--accent-glow); }
.calendar-day-header.is-today .tasks-week-day-date { color: var(--accent-glow); opacity: 0.8; }
.calendar-track.is-today { background-color: color-mix(in srgb, var(--accent-glow) 5%, transparent); }

/* ---- Month view ---------------------------------------------------
   A 7-wide grid of day cells, 4-6 rows. Cells are min-height'd rather
   than fixed so a busy week grows and a quiet one stays compact, and
   every cell shows at most MONTH_CELL_LIMIT chips plus a "+N more"
   link into that week — an uncapped cell would let one crowded day
   set the height of its whole row. */
.cal-month {
  display: grid;
  grid-template-columns: repeat(7, minmax(0, 1fr));
  gap: 1px;
  background: var(--gridline);
  border: 1px solid var(--gridline);
  border-radius: var(--radius-md);
  overflow: hidden;
}
.cal-month-dow {
  background: var(--surface-2);
  padding: var(--space-2) var(--space-2);
  text-align: center;
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink-2);
}
.cal-month-day {
  background: var(--surface);
  min-height: 7.25rem;
  padding: var(--space-2);
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}
/* Padding days belong to the neighbouring months — present, so the
   weeks stay whole, but recessed so the month itself is the figure. */
.cal-month-day.is-out { background: var(--page); }
.cal-month-day.is-out .cal-month-daynum { color: var(--ink-muted); }
.cal-month-day.is-out .cal-month-chip { opacity: 0.45; }
.cal-month-day.is-today { background: color-mix(in srgb, var(--accent-glow) 7%, var(--surface)); }
.cal-month-daynum {
  align-self: flex-start;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  color: var(--ink-2);
  text-decoration: none;
  padding: 1px 5px;
  border-radius: var(--radius-sm);
  margin-bottom: 2px;
}
.cal-month-daynum:hover { background: var(--surface-2); color: var(--ink); }
.cal-month-day.is-today .cal-month-daynum {
  background: var(--accent-glow);
  color: var(--page);
}
/* The chip carries its block's own --series colour as a leading edge
   — the same colour that block is drawn in on the week grid, so the
   two views are visibly the same objects. */
.cal-month-chip {
  display: flex;
  align-items: baseline;
  gap: 4px;
  min-width: 0;
  padding: 1px var(--space-1);
  border-left: 2px solid var(--chip-color, var(--series-1));
  background: color-mix(in srgb, var(--chip-color, var(--series-1)) 12%, transparent);
  font-family: var(--font-mono);
  font-size: 0.6rem;
  line-height: 1.45;
  text-transform: uppercase;
  letter-spacing: 0.02em;
}
.cal-month-chip .cal-chip-time { flex: none; color: var(--ink-muted); }
.cal-month-chip .cal-chip-label {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--ink);
}
.cal-month-chip.is-done .cal-chip-label { text-decoration: line-through; color: var(--ink-muted); }
.cal-month-more {
  margin-top: auto;
  font-family: var(--font-mono);
  font-size: 0.6rem;
  color: var(--ink-muted);
  text-decoration: none;
}
.cal-month-more:hover { color: var(--accent-glow); }
/* Seven readable columns don't exist below ~640px. The grid keeps its
   shape and scrolls sideways inside the widget rather than crushing
   each day to 40px of unreadable text. */
@media (max-width: 40rem) {
  .cal-month { display: grid; grid-template-columns: repeat(7, 7.5rem); overflow-x: auto; }
  .cal-month-day { min-height: 5.5rem; }
}

/* ---- The routine block list --------------------------------------
   Was an auto-fit grid of `<details class="habit-card">`; five blocks
   in a four-wide row left the fifth stretched alone across the page,
   and none of them carried the colour they're drawn in above. Now a
   wrapped row of colour-keyed chips that each expand to a full-width
   editing panel IN PLACE (flex-basis: 100% on [open]), so the list
   never reflows into a ragged column of half-empty cards. */
.routine-blocks { margin-top: var(--space-5); }
.cal-subhead {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  margin: 0 0 var(--space-3);
  padding-left: var(--space-2);
  border-left: 2px solid var(--accent-glow);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink-2);
}
.routine-chip-list { display: flex; flex-wrap: wrap; gap: var(--space-2); align-items: flex-start; }
.routine-chip { flex: 0 1 auto; max-width: 100%; }
.routine-chip[open] { flex: 1 1 100%; }
.routine-chip > summary {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 34px;
  padding: 0 var(--space-3);
  border: 1px solid var(--border);
  border-left: 3px solid var(--block-color, var(--series-1));
  border-radius: var(--radius-sm);
  background: var(--surface);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  list-style: none;
  cursor: pointer;
}
.routine-chip > summary::-webkit-details-marker { display: none; }
.routine-chip > summary:hover { border-color: var(--accent-glow); border-left-color: var(--block-color, var(--series-1)); }
.routine-chip[open] > summary { border-color: var(--accent-glow); border-left-color: var(--block-color, var(--series-1)); }
.routine-chip-dot {
  flex: none;
  width: 7px; height: 7px;
  background: var(--block-color, var(--series-1));
}
.routine-chip-name {
  color: var(--ink);
  font-weight: var(--weight-semibold);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.routine-chip-meta { flex: none; color: var(--ink-muted); }
/* Open, the chip is the full width of the list, so its meta goes to
   the far end rather than trailing the name across a 1,300px bar. */
.routine-chip[open] > summary .routine-chip-meta { margin-left: auto; }
.routine-chip-panel {
  margin-top: var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface);
  padding: var(--space-4);
}
/* Occurrence forms inside an open chip: side by side while they fit,
   so a block that happens three times a week is one panel rather than
   three stacked full-width forms. Capped tracks (not minmax(_, 1fr))
   for the reason .habit-group documents — a flexible track makes a
   single occurrence stretch its form across the whole open panel with
   its inputs stranded at the left edge. auto-fill, not auto-fit, so
   the leftover tracks stay collapsed at their real size. */
.routine-chip-panel {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(14rem, 17rem));
  justify-content: start;
  gap: var(--space-4);
  align-items: start;
}
.routine-occurrence-card { min-width: 0; }
.routine-chip-panel .form-grid { max-width: none; }
.routine-add-time { grid-column: 1 / -1; border-top: 1px solid var(--gridline); padding-top: var(--space-3); }
/* Same chip as everything else on the surface, minus the disclosure
   triangle — a second nested ▶ inside an already-open panel was the
   last one left on the page. */
.routine-add-time > summary {
  display: inline-flex;
  align-items: center;
  min-height: 26px;
  padding: 0 var(--space-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  font-family: var(--font-mono);
  font-size: 0.6rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  list-style: none;
}
.routine-add-time > summary::-webkit-details-marker { display: none; }
.routine-add-time > summary:hover { border-color: var(--accent-glow); color: var(--ink); }
.routine-add-time[open] > summary { border-color: var(--accent-glow); color: var(--ink); }
.routine-add-time > .form-grid { margin-top: var(--space-3); }

/* One column since 2026-09-05: the preview panel that used to sit in
   the second column is gone, so each grid holds a single prefs card.
   Kept as the wrapper (rather than unwrapped) because the iOS
   time-input fixes further down are scoped to .briefing-grid. */
/* ================= /calls: the Day Dial (added 2026-09-06) =========
   The page used to be two near-identical preference forms with the
   calls themselves as a 13px mono link at the bottom of each: 58% of
   a 3,633px page was settings, and nothing about a call was above the
   fold on the page named Calls. This is the inversion — the two calls
   are the objects, the preferences fold inside them.

   Note this whole cluster reads --accent-glow/--gridline/--ink-* and
   nothing else, so all fourteen themes follow with no per-theme block,
   exactly as the day-ring picker does. */
.day-dial {
  display: flex;
  align-items: center;
  gap: var(--space-6);
  flex-wrap: wrap;
  margin-bottom: var(--space-5);
}
/* The ring is the SAME construction as .time-chip/.tr-arc — the
   --sun-ring custom property is declared once, up at the day-ring
   picker, and reused here at real size so the 14px affordance and
   this cannot drift apart. */
.day-dial-ring {
  position: relative;
  width: 116px;
  height: 116px;
  flex: none;
  border-radius: 50%;
  background: var(--sun-ring);
}
.day-dial-ring::after {
  content: "";
  position: absolute;
  inset: 14px;
  border-radius: 50%;
  background: var(--page);
  box-shadow: inset 0 0 0 1px var(--border);
}
/* A marker for hour h sits at 180deg + h*15deg because the gradient
   starts `from 180deg` — midnight at the bottom, dawn on the left,
   midday overhead. So the morning call really does land on the ring's
   own dawn tick and the night call really is past dusk. */
.dial-mark, .dial-hand {
  position: absolute;
  inset: 0;
  transform: rotate(calc(180deg + var(--h) * 15deg));
  pointer-events: none;
}
.dial-mark i {
  position: absolute;
  left: 50%;
  top: 50%;
  width: 9px;
  height: 9px;
  margin: -4.5px;
  border-radius: 50%;
  transform: translateY(-51px);
  box-shadow: 0 0 0 2.5px var(--page);
}
.dial-mark.is-morning i { background: var(--accent-glow); }
.dial-mark.is-night i { background: var(--accent-glow-2); }
/* Now, as a hairline reaching the inner edge — the dial reads as a
   clock you are somewhere on, which is what makes "night in 7h" land
   without spelling it out. */
.dial-hand::before {
  content: "";
  position: absolute;
  left: 50%;
  top: 50%;
  width: 1px;
  height: 44px;
  margin-left: -0.5px;
  transform-origin: top center;
  transform: translateY(-44px);
  background: var(--ink-2);
  opacity: 0.55;
}
.day-dial-now {
  position: absolute;
  inset: 0;
  z-index: 2;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}
.day-dial-copy h2 { font-size: var(--text-lg); margin: var(--space-1) 0 var(--space-2); }
.day-dial-date {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin: 0;
}
.day-dial-times { display: flex; gap: var(--space-4); margin: 0; }
.dial-key {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-variant-numeric: tabular-nums;
  color: var(--ink-2);
}
.dial-key i { width: 8px; height: 8px; border-radius: 50%; }
.dial-key.is-morning i { background: var(--accent-glow); }
.dial-key.is-night i { background: var(--accent-glow-2); }

/* ---- the two call panels ----
   .status-cell's own treatment (accent-topped rule, mono label, big
   display value) — the same instrument cells _widget_home_status.html
   already renders "next call" into. /home said more about the user's
   calls than /calls did; this is that component moved onto the page
   it is actually about. */
.call-pair {
  display: grid;
  gap: var(--space-4);
  grid-template-columns: repeat(auto-fit, minmax(19rem, 1fr));
}
.call-panel {
  background: var(--surface);
  border: 1px solid var(--border);
  border-top: 2px solid var(--cell-accent, var(--border));
  padding: var(--space-4) var(--space-5) var(--space-4);
}
.call-panel.is-morning { --cell-accent: var(--accent-glow); }
.call-panel.is-night { --cell-accent: var(--accent-glow-2); }
.call-panel-top { display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-4); }
.call-panel-top h2 { font-size: var(--text-md); margin: 0; }
.call-panel-when {
  font-family: var(--font-mono);
  font-size: 1.4rem;
  font-variant-numeric: tabular-nums;
  color: var(--cell-accent);
}
.call-panel-state {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin: var(--space-1) 0 0;
}
.call-panel-state b { color: var(--good); font-weight: var(--weight-medium); }

/* The ticked topics as their real card icons, in the user's own saved
   order — the preference rendered as content. Reading fourteen
   checkboxes to work out which were on was the only way to see this. */
.topic-run { list-style: none; display: flex; flex-wrap: wrap; gap: var(--space-2); margin: var(--space-3) 0 0; padding: 0; }
.topic-run-item {
  width: 1.9rem;
  height: 1.9rem;
  display: flex;
  align-items: center;
  justify-content: center;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  color: var(--ink-2);
}
.topic-run-item svg { width: 15px; height: 15px; }
/* The night has options rather than content blocks, so its run is
   short labels on the same chip frame instead of icons. */
.topic-run-item.is-text {
  width: auto;
  padding: 0 var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  color: var(--ink-muted);
}

/* flex since 2026-09-09: "Call now" sits beside "Open the call". */
.call-panel-actions {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  margin-top: var(--space-4);
}
.call-panel-open {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  padding: var(--space-2) var(--space-4);
  min-height: 44px;
  line-height: 28px;
  border: 1px solid var(--cell-accent);
  color: var(--cell-accent);
  text-decoration: none;
}
.call-panel-open:hover { background: var(--surface-2); }
/* The preferences, folded. Same forms, same routes — this is only a
   <details>, so initSmoothDetails() animates it for free and it stays
   a working disclosure with JS off. */
.call-panel-edit { margin-top: var(--space-4); border-top: 1px solid var(--gridline); padding-top: var(--space-3); }
.call-panel-edit > summary {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-muted);
  cursor: pointer;
  min-height: 44px;
  display: flex;
  align-items: center;
}
.call-panel-edit > summary:hover { color: var(--ink-2); }

/* ---- How your calls sound (added 2026-09-11, restyled same day) ----
   The shared voice/speed/pauses panel. It IS a .call-panel — same
   surface, border and padding as the morning and night cards — because
   the first cut was a bare <details> sitting on the page background
   between two framed cards, which read as something that had fallen out
   of the layout rather than a third object in it.

   What marks it as the shared one is the top rule: every other panel
   sets a single --cell-accent, this one runs a gradient from the morning
   accent to the night accent. It is the only two-toned rule in the app,
   and it is saying the one true thing about this control — it belongs to
   both calls at once. Built as a ::before strip rather than a
   border-image so it inherits the card's own 1px border on the other
   three sides untouched. */
.call-voice {
  margin-top: var(--space-5);
  border-top: 1px solid var(--border);
  position: relative;
}
.call-voice::before {
  content: "";
  position: absolute;
  top: -1px;
  left: -1px;
  right: -1px;
  height: 2px;
  background: linear-gradient(90deg, var(--accent-glow), var(--accent-glow-2));
}
/* Marker removed so the summary can lay itself out as a real header row;
   the open/closed state is carried by the caret below. */
.call-voice > details > summary { list-style: none; cursor: pointer; }
.call-voice > details > summary::-webkit-details-marker { display: none; }
.call-voice-summary {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
  min-height: 44px;
}
.call-voice-head { display: flex; flex-direction: column; gap: 2px; }
.call-voice-title { font-size: var(--text-md); font-weight: var(--weight-medium); color: var(--ink); }
.call-voice-sub {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
/* The live setting, reported in the collapsed state — the same
   "preference rendered as content" move .topic-run makes for the
   morning's topics, so the fold says something even shut. Sits where
   .call-panel-when's clock sits on the other two cards, and wears the
   same tabular mono. */
.call-voice-now {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  font-variant-numeric: tabular-nums;
  color: var(--ink-2);
  text-align: right;
}
.call-voice > details[open] .call-voice-now { color: var(--ink-muted); }

.call-voice-field { border: 0; padding: 0; margin: var(--space-4) 0 0; }
.call-voice-label {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin-bottom: var(--space-2);
  padding: 0;
}
.call-voice-value { font-variant-numeric: tabular-nums; color: var(--ink-2); }
.call-voice-hint {
  font-size: var(--text-xs);
  color: var(--ink-muted);
  margin: var(--space-2) 0 0;
}

/* Voice + Preview on one row: the two are one gesture (pick, hear, pick
   again), and a Preview button parked down beside Save would read as a
   second way to submit the form. */
.call-voice-row { display: flex; gap: var(--space-2); align-items: stretch; }
.call-voice-row > select { flex: 1 1 auto; min-width: 0; }
/* Same mono-uppercase outlined shape as .call-panel-open, so the page's
   two kinds of "do something to a call" button match. */
.call-voice-play {
  flex: 0 0 auto;
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  padding: 0 var(--space-4);
  min-height: 44px;
  background: transparent;
  border: 1px solid var(--accent-glow);
  border-radius: var(--radius-sm);
  color: var(--accent-glow);
  cursor: pointer;
}
.call-voice-play:hover { background: var(--surface-2); }
.call-voice-play-glyph { font-size: 0.7em; line-height: 1; }

.call-voice input[type="range"] {
  width: 100%;
  /* Without this the native control renders browser-blue — the one
     colour no theme here has. */
  accent-color: var(--accent-glow);
}
/* A bare slider says nothing about which end is which, and this one's
   band is narrow enough (0.75-1.15) that the difference is easy to
   under-read. */
.call-voice-ends {
  display: flex;
  justify-content: space-between;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin-top: var(--space-1);
}

/* Pauses as a segmented instrument rather than a <select>: four fixed,
   ordered steps are a scale, and a dropdown hides both the count and the
   order. Radios underneath, so it submits with JS off and keeps native
   arrow-key navigation. Treatment borrowed from .chart-toggle, the
   segmented control the app already uses. */
.call-voice-seg {
  display: flex;
  gap: 2px;
  background: var(--surface-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: 2px;
}
.call-voice-seg-opt { flex: 1; margin: 0; }
/* Visually hidden, never display:none — the radio has to stay focusable
   and reachable by arrow key. */
.call-voice-seg-opt input {
  position: absolute;
  opacity: 0;
  width: 1px;
  height: 1px;
  pointer-events: none;
}
.call-voice-seg-opt span {
  display: block;
  text-align: center;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  padding: var(--space-2) var(--space-1);
  border-radius: var(--radius-sm);
  color: var(--ink-muted);
  cursor: pointer;
}
.call-voice-seg-opt:hover span { color: var(--ink); }
.call-voice-seg-opt input:checked + span { color: var(--surface); background: var(--accent-glow); }
.call-voice-seg-opt input:focus-visible + span { outline: 2px solid var(--accent-glow); outline-offset: 2px; }

.call-voice-actions {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: var(--space-3);
  margin-top: var(--space-5);
}
.call-voice-status {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin-right: auto;
}

/* ---- Past calls, as a strip (added 2026-09-06) ----
   Thirty rows of "Thu · Sep 04 · Morning · Night · 4 Focused" took
   thirty reads to answer the question anyone brings here. Bar height
   and colour are the day's rating on the SAME ramp
   .day-rating-dot[data-score] uses, so the archive and the input
   agree by construction. */
.trail-head { display: flex; justify-content: space-between; gap: var(--space-4); flex-wrap: wrap; margin-bottom: var(--space-2); }
.trail-legend {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.trail-swatch { width: 6px; height: 6px; border-radius: 50%; background: var(--gridline); }
.trail-swatch.on-morning { background: var(--accent-glow); }
.trail-swatch.on-night { background: var(--accent-glow-2); }

.trail { display: flex; align-items: flex-end; gap: 3px; }
.trail-day {
  flex: 1;
  min-width: 0;
  display: flex;
  flex-direction: column;
  align-items: stretch;
  justify-content: flex-end;
  gap: 4px;
  padding-bottom: 2px;
  text-decoration: none;
  border-radius: var(--radius-sm);
}
a.trail-day:hover { background: var(--surface-2); }
a.trail-day:focus-visible { outline: 2px solid var(--accent-glow); outline-offset: 1px; }
.trail-bar { display: block; background: var(--gridline); border-radius: var(--radius-sm) var(--radius-sm) 0 0; }
.trail-bar[data-score="1"], .trail-bar[data-score="2"] { background: var(--danger); }
.trail-bar[data-score="3"] { background: var(--accent-glow); }
.trail-bar[data-score="4"], .trail-bar[data-score="5"] { background: var(--good); }
.trail-dots { display: flex; gap: 2px; justify-content: center; }
.trail-dots i { width: 3px; height: 3px; border-radius: 50%; background: var(--gridline); }
.trail-dots i.on-morning { background: var(--accent-glow); }
.trail-dots i.on-night { background: var(--accent-glow-2); }
.trail-scale {
  display: flex;
  justify-content: space-between;
  margin-top: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  color: var(--ink-muted);
}

/* Mobile hit targets (2026-09-06). At 390px every control on this hub
   was under 44px — sixteen 26x24 reorder arrows and the 69x29
   .briefing-chip that is the page's primary control — while the night
   screen passed at the same width. Folding the order ladder behind
   Edit takes most of those arrows off the first screen; these two
   rules fix what remains. */
@media (max-width: 480px) {
  .briefing-chip { min-height: 44px; }
  .briefing-order-list .reorder-btn { min-width: 44px; min-height: 44px; }
}

.briefing-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-5);
  margin-top: var(--space-3);
  align-items: start;
}
.briefing-grid .form-grid { max-width: none; }
/* The morning-briefing category picker (added 2026-08-04, replacing a
   freeform notes textarea; restyled the same day from a flat
   checkbox list into whole-box-clickable chips, grouped, matching
   the feel of the pre-existing onboarding suggestion chips). A
   <fieldset> because eleven checkboxes are one labelled group, but
   stripped of the UA's border/padding and its `min-width: min-content`
   default — that default is a fieldset-only quirk that
   .form-grid > * { min-width: 0 } cannot override, and it would hold
   the card open at the widest label on a narrow phone. */
.briefing-categories { border: 0; padding: 0; margin: 0; min-width: 0; }
.briefing-categories legend {
  padding: 0;
  margin-bottom: var(--space-2);
  color: var(--ink-2);
  font-size: var(--text-sm);
  font-weight: var(--weight-medium);
}
.briefing-category-group { margin-bottom: var(--space-3); }
.briefing-category-group:last-child { margin-bottom: 0; }
.briefing-category-group-label {
  display: block;
  color: var(--ink-muted);
  font-size: var(--text-xs);
  text-transform: uppercase;
  letter-spacing: 0.05em;
  margin-bottom: var(--space-1);
}
.briefing-chip-row { display: flex; flex-wrap: wrap; gap: var(--space-2); }
/* The chip IS the <label> — clicking anywhere in it toggles the
   checkbox, which is visually hidden but still focusable/keyboard-
   operable (opacity: 0, not display: none). Checked state is read via
   :has() rather than a class, so plain POST/redirect (JS off) still
   renders the right state on reload — nothing here depends on JS. */
.briefing-chip {
  position: relative;
  display: inline-flex;
  align-items: center;
  width: auto;
  padding: var(--space-1) var(--space-2);
  border: 1.5px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--page);
  font-size: var(--text-sm);
  font-weight: var(--weight-regular);
  cursor: pointer;
  transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
}
.briefing-chip input[type="checkbox"] {
  position: absolute;
  opacity: 0;
  width: 100%;
  height: 100%;
  top: 0;
  left: 0;
  margin: 0;
  cursor: pointer;
}
.briefing-chip:has(input:focus-visible) {
  outline: 2px solid var(--series-1);
  outline-offset: 2px;
}
.briefing-chip:has(input:checked) {
  background: var(--series-1);
  border-color: var(--series-1);
  color: var(--surface);
  animation: briefing-chip-pick 300ms ease;
}
@keyframes briefing-chip-pick {
  0%, 100% { transform: translateX(0); }
  20% { transform: translateX(-3px); }
  40% { transform: translateX(3px); }
  60% { transform: translateX(-2px); }
  80% { transform: translateX(2px); }
}
/* Custom free-text briefing categories (added 2026-08-12) */
.briefing-custom-categories { margin-top: var(--space-4); }
.briefing-custom-categories h5 { margin: 0 0 var(--space-1); font-size: 0.8rem; }
.briefing-custom-category-list {
  list-style: none; margin: var(--space-2) 0; padding: 0;
  display: flex; flex-direction: column; gap: var(--space-2);
}
.briefing-custom-category-list li {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-2); padding: var(--space-2); border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}
.briefing-custom-category-list .inline { margin: 0; }
/* The morning-briefing running order (added 2026-09-05). Same row
   shape as .briefing-custom-category-list above, plus the ordinal —
   the number is the whole point of this list ("1. AI news, 2. crypto,
   3. weather"), so it's a real <ol> marker rendered inside the row
   via a counter rather than the default outside marker, which would
   sit outside the bordered box. */
.briefing-order-list {
  list-style: none; margin: var(--space-2) 0 0; padding: 0; counter-reset: briefing-order;
  display: flex; flex-direction: column; gap: var(--space-2);
}
.briefing-order-list li {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-2); padding: var(--space-2); border: 1px solid var(--border);
  border-radius: var(--radius-sm); counter-increment: briefing-order;
}
.briefing-order-list .briefing-order-label::before {
  content: counter(briefing-order) ". "; color: var(--ink-muted); font-variant-numeric: tabular-nums;
}
.briefing-order-list .inline { margin: 0; }
.briefing-order-list .reorder-controls { flex: 0 0 auto; }
/* The pinned habits row: present so the list matches the briefing
   the user actually hears, but not orderable — hence no arrows and a
   dimmed, dashed frame that reads as "fixed" rather than "draggable". */
.briefing-order-list .briefing-order-pinned { border-style: dashed; opacity: 0.75; }
.briefing-order-pinned-note {
  font-size: var(--text-2xs); color: var(--ink-muted); text-transform: uppercase; letter-spacing: 0.05em;
}
/* Real iOS Safari's native input[type="time"] ignores max-width
   below its own internal minimum layout width (hour/minute/colon
   plus reserved spinner space) — confirmed by pixel-measuring a
   user-supplied screenshot: the control's right border sat ~5.5px
   past its .habit-card's own right edge at a 390px-wide viewport,
   even with .form-grid > * { min-width: 0 } and max-width: 100%
   already applied (both verified working in headless Chromium,
   which *does* fully honor max-width here — this is a WebKit-only
   gap Chromium testing alone can't catch). Rather than chase an
   undocumented, version/locale-dependent native floor, clip it at
   the card boundary: .habit-card's own corner-bracket ::before/
   ::after sit flush at 0/0 and bottom/right:0 (not negative
   offsets), so they're unaffected. Scoped to .briefing-grid's own
   cards, not every .habit-card in the app, since this is the one
   place a type="time" input lives inside one. */
.briefing-grid .habit-card { overflow: hidden; }
/* Reset, not another patch (2026-07-31) — three rounds of capping
   this field from the outside (.form-grid > * min-width:0,
   input max-width:100%, .habit-card overflow:hidden above) all
   still left it at the shared `width: 100%` every input/select/
   textarea gets, and none of them fixed it on the reporting human's
   real iOS Safari: a screenshot showed the control's top/bottom
   border running straight off the physical screen edge with no
   closing right border anywhere — "the box don't end." That's not
   a too-wide-by-a-few-px overflow (which is all Chromium/the CSS
   box model would produce); it's the signature of a percentage
   width resolving against the wrong containing block, a
   real-WebKit-only class of bug for native date/time controls that
   doesn't reproduce in Chromium (confirmed clean via exact
   getBoundingClientRect numbers, not just a screenshot) and
   couldn't be tested against real WebKit or even Firefox in this
   sandbox (both need system packages not installed here). Rather
   than keep guessing at how to cap a 100%-wide box, drop the 100%
   entirely: same fixed/intrinsic-width treatment already used for
   the Weekly Report's day-of-week <select> (search `#weekly-
   report-call-day` above) instead of another fluid-width patch. */
.briefing-grid input[type="time"] {
  width: auto;
  max-width: none;
  flex: none;
}
/* Same real-iOS-Safari bug as .briefing-grid above (added 2026-08-08,
   user report — onboarding steps 2/4's wake/sleep + call-time inputs
   overflow their card on a real phone), same proven fix: drop the
   fluid `width: 100%` rather than try to cap it. Scoped to
   .onboarding-block (shared by every onboarding step, not just 2/4)
   instead of a fresh confirmed-per-page allowlist like .briefing-grid
   got, since this is the identical control in the identical failure
   mode — no reason to wait for a report on every other step's own
   time input before applying the same fix there too. */
.onboarding-block input[type="time"] {
  width: auto;
  max-width: none;
  flex: none;
}
.briefing-screen h4 {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-2);
}
.briefing-screen-label {
  text-transform: none;
  letter-spacing: normal;
  font-weight: var(--weight-regular);
  color: var(--ink-muted);
  white-space: nowrap;
}
.briefing-screen-body {
  margin-top: var(--space-3);
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  color: var(--ink-2);
}
.briefing-screen-body p { margin: 0; }

/* Home dashboard (added 2026-07-31). Reuses .habit-card/.task-card/
   .briefing-screen wherever possible — these are the few genuinely
   new rules, all on existing --space, --text and --ink tokens so
   every theme (light/dark) inherits with no per-theme overrides.
   (Those three families were written here as `--space-`, `--text-`
   and `--ink-` with a star-slash between them until 2026-09-06.
   A star immediately followed by a slash closes a CSS comment, so
   this one ended four lines early and error recovery then swallowed
   the whole `.status-readout` rule below — Home's status row had
   been rendering as a plain block, no flex and no gap, ever since.
   Never let a star touch a slash inside a comment.) */
/* Status readout (redesigned 2026-07-31, direct user request for
   something more distinctive than a plain text strip) — an
   instrument-panel-style row of 3 stat cells, each with its own
   accent-colored top rule, echoing the "Mission Deck" HUD language
   (see docs.md's Design system section) rather than inventing a new
   visual idiom. Stacks to one column below 480px (dividers flip from
   vertical rules to horizontal ones) — same mobile-first pattern
   used everywhere else in this file. */
.status-readout {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}
@media (min-width: 480px) {
  .status-readout { flex-direction: row; align-items: stretch; }
}
.status-cell {
  flex: 1;
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  border-top: 2px solid var(--cell-accent, var(--border));
  padding-top: var(--space-2);
}
.status-cell-habits { --cell-accent: var(--good); }
.status-cell-tasks { --cell-accent: var(--series-4); }
.status-cell-tasks:has(.is-clear) { --cell-accent: var(--good); }
.status-cell-call { --cell-accent: var(--series-1); }
.status-cell-label {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.status-cell-value {
  font-family: var(--font-display);
  font-size: 1.9rem;
  font-weight: var(--weight-semibold);
  line-height: 1;
  color: var(--ink);
}
.status-cell-value.is-clear { color: var(--good); }
.status-cell-value-sep { color: var(--ink-muted); margin: 0 1px; }
.status-meter {
  height: 4px;
  background: var(--gridline);
  position: relative;
  overflow: hidden;
}
.status-meter-fill {
  position: absolute;
  inset: 0 auto 0 0;
  height: 100%;
  background: var(--good);
}
.status-cell-caption {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  color: var(--ink-muted);
}
.status-live-dot {
  display: inline-block;
  width: 0.4rem;
  height: 0.4rem;
  background: var(--series-1);
  animation: blink-dot 1.8s ease-in-out infinite;
}
/* The phone-alarm on/off (added 2026-09-07, _macros.html's
   alarm_toggle; two states since 2026-09-08 — see the macro).
   Rendered the same in the home status cell and in each /calls
   panel. Reads as a status line that happens to be pressable — a
   real <button> in a real form, so it works with JS off and keeps
   its focus ring. */
.alarm-toggle-form { margin-top: var(--space-1); }
.alarm-toggle {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: 0.15rem 0;
  background: none;
  border: 0;
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.04em;
  text-transform: lowercase;
  color: var(--ink-muted);
  transition: color 0.15s ease;
}
.alarm-toggle:hover, .alarm-toggle:focus-visible { color: var(--ink-2); }
.alarm-toggle-dot {
  width: 0.4rem;
  height: 0.4rem;
  flex: none;
  border: 1px solid currentColor;
  background: none;
}
/* On is the only state that spends colour: a silenced alarm should
   read as absence, not as an alert. */
.alarm-toggle.is-on { color: var(--ink-2); }
.alarm-toggle.is-on .alarm-toggle-dot {
  background: var(--accent-glow);
  border-color: var(--accent-glow);
}
.alarm-toggle.is-off .alarm-toggle-dot {
  background: none;
  opacity: 0.6;
}
.alarm-toggle.is-off .alarm-toggle-label { text-decoration: line-through; }
/* In a /calls panel it sits under the h2/time row, full width. */
.call-panel .alarm-toggle-form { margin: calc(var(--space-1) * -1) 0 0; }

/* The one-off call time (added 2026-09-08, _macros.html's
   call_time_override). Same mono status-line register as the alarm
   line it sits under — a row of small parts, not a form card, since
   it lives inside a status cell no wider than a phone. `is-set`
   is the only state that spends colour: an override is a temporary
   deviation and should be visible as one. */
.call-onceoff {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-1);
  margin-top: var(--space-1);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.04em;
  color: var(--ink-muted);
}
.call-onceoff-label { text-transform: lowercase; }
.call-onceoff-time {
  width: auto;
  min-width: 0;
  padding: 0.1rem 0.25rem;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  color: var(--ink-2);
  background: var(--surface-2);
  border: 1px solid var(--border);
}
.call-onceoff-set, .call-onceoff-clear {
  padding: 0.1rem 0.35rem;
  background: none;
  border: 1px solid var(--border);
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.04em;
  text-transform: lowercase;
  color: var(--ink-muted);
  transition: color 0.15s ease, border-color 0.15s ease;
}
.call-onceoff-set:hover, .call-onceoff-set:focus-visible,
.call-onceoff-clear:hover, .call-onceoff-clear:focus-visible {
  color: var(--ink-2);
  border-color: var(--ink-muted);
}
.call-onceoff.is-set { color: var(--accent-glow); }
.call-onceoff.is-set .call-onceoff-time {
  color: var(--accent-glow);
  border-color: var(--accent-glow);
}
/* In a /calls panel it closes the block the alarm line opened. */
.call-panel .call-onceoff { margin-bottom: var(--space-2); }

/* "Call now" (added 2026-09-09, _macros.html's call_now_button) —
   the catch-up ring. Rendered beside "Open the call" in a /calls
   panel and under the /home next-call cell; it only exists on a day
   whose call already ran, so it is an action, not a status line, and
   reads as one: a live dot plus a label, in the accent the app uses
   for anything currently happening. */
.call-now-form { display: inline-flex; }
.call-now {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: 0.2rem 0.5rem;
  background: none;
  border: 1px solid var(--accent-glow);
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--accent-glow);
  transition: background 0.15s ease, color 0.15s ease;
}
.call-now:hover, .call-now:focus-visible {
  background: var(--accent-glow);
  color: var(--page);
}
.call-now-dot {
  width: 0.4rem;
  height: 0.4rem;
  flex: none;
  background: currentColor;
  border-radius: 50%;
  animation: blink-dot 1.8s ease-in-out infinite;
}
/* On /home the button names its own call, since it is the one just
   gone rather than the one the cell counts down to. */
.call-now-row {
  display: flex;
  align-items: center;
  gap: var(--space-1);
  margin-top: var(--space-1);
}
.call-now-kind {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  text-transform: lowercase;
  color: var(--ink-muted);
}

.status-divider {
  background: var(--border);
  height: 1px;
  width: 100%;
}
@media (min-width: 480px) {
  .status-divider { width: 1px; height: auto; align-self: stretch; }
}

/* ---- The day plate (2026-09-06) -------------------------------
   Home's hero, and the fix for a page that opened cold on the word
   "Status" with nothing saying who or when. Identity on the left —
   the monogram a habit card and /account's identity plate both
   wear — the existing three-cell .status-readout on the right, one
   Instrument Plate around both. No new colour: --accent-glow and
   the --ink family only, so all fourteen themes follow with no
   per-theme block.

   Stacks below 760px, where .status-readout's own 480px breakpoint
   has already turned its cells into a row — so a phone gets
   identity over one row of three cells, not a six-deep column. */
.day-plate {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}
.day-plate-id {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  min-width: 0;
}
.day-plate-mark {
  width: 3rem;
  height: 3rem;
  font-size: 1.25rem;
  color: var(--accent-glow);
  border-color: var(--accent-glow);
}
.day-plate-who { min-width: 0; }
.day-plate-greeting {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--text-md);
  font-weight: var(--weight-semibold);
  color: var(--ink);
  overflow-wrap: anywhere;
}
.day-plate-date {
  margin: 2px 0 0;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
/* The child combinator matters: the plate's OWN divider is the one
   between identity and readout, and it only earns its keep once
   those two sit side by side. The readout's three internal
   dividers are not direct children and keep their own rules. */
.day-plate > .status-divider { display: none; }
@media (min-width: 760px) {
  .day-plate { flex-direction: row; align-items: center; gap: var(--space-6); }
  .day-plate-id { flex: 0 1 auto; }
  /* min-width: 0 so the readout shrinks under pressure instead of
     shoving the identity block's nowrap date out of the plate. */
  .day-plate-readout { flex: 1 1 0; min-width: 0; }
  .day-plate > .status-divider { display: block; }
}
/* .home-habit-rows overrides .habit-cards' fixed-20rem-track grid +
   full-bleed breakout (built for the full habit card on /habits) —
   Home's condensed rows are a plain single-column list instead. Same
   .habit-cards class kept on the container so
   initInstantHabitActions() (base.html JS) still binds; these rules
   just win the cascade by appearing after .habit-cards' own block. */
.home-habit-rows {
  display: flex;
  flex-direction: column;
  align-items: stretch;
  gap: var(--space-2);
  width: auto;
  margin-left: 0;
  padding: 0;
}
.home-habit-row {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) var(--space-3);
  margin-bottom: 0;
}
/* The LOG button is the SAME die as the check seal on these rows
   (2026-09-14, direct request: "the log button should be the same size
   and shape as the normal check button"). `.numeric-pill` is built as a
   pill — 2.25rem tall, min-width 2.4rem plus side padding — so a numeric
   habit's row measured 45.9 x 36 where a boolean one measured 38.4 x
   38.4, and the whole Pushups row came out 54px against every other
   row's 56.4px. Here it takes `.seal-face`'s exact box: the same 2.4rem
   square, the same 1px --habit-color hairline, and the same --good fill
   once logged (`.numeric-pill.logged`, already matching the struck
   face). Scoped to Home's rows — /habits' cards and the habit page still
   draw the pill, where a value and its unit have the width for it. */
.home-habit-row .numeric-pill {
  width: 2.4rem;
  height: 2.4rem;
  min-width: 0;
  flex: none;
  padding: 0;
}
.home-habit-row .numeric-pill-unit { display: none; }
.home-habit-row-name {
  flex: 1;
  min-width: 0;
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Habits Today's two groups (2026-09-13, direct request: "on the
   homepage, differentiate between the ranked habits and the normal
   habits"). Deliberately NOT /habits' .habit-group-* chrome: that
   title is display-face --text-md and its box is the full-bleed card
   grid itself, both wrong inside a dashboard rail. What carries over
   is the part that means something — the accent colour (series-1 for
   ranked, neutral ink for the user's own) and the 3px left rule the
   title hangs off — so the two pages label the same group the same
   way. Sizing follows .home-widgets .widget-header h1 one step down:
   the same mono uppercase voice, so a group heading reads as a
   subdivision of "HABITS TODAY" rather than competing with it. */
.home-habit-group { --group-accent: var(--ink-2); }
.home-habit-group + .home-habit-group { margin-top: var(--space-4); }
.home-habit-group-ranked { --group-accent: var(--series-1); }
.home-habit-group-head {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  border-bottom: 1px solid var(--gridline);
  padding-bottom: var(--space-1);
  margin-bottom: var(--space-2);
}
.home-habit-group-title {
  margin: 0;
  padding-left: var(--space-2);
  border-left: 3px solid var(--group-accent);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink);
}
.home-habit-group-count {
  font-family: var(--font-mono);
  font-size: 0.66rem;
  font-weight: var(--weight-semibold);
  color: var(--group-accent);
}
/* This Week at a Glance, redesigned (2026-07-31, direct user request
   for "a better design") — was a plain "habit name ... Nd" list plus
   a bare "N/M tasks done this week" line. Now each row draws a
   7-cell "week strip" (Mon-Sun of the current week) reusing
   `row.heatmap.rows[-1]` — the heatmap's own last row is already
   exactly this week's Monday-anchored 7 days, so no new query/
   computation, just a new render of already-built data. Cell state
   classes (hit/future/unscheduled) and their color logic mirror
   `.heatmap .cell` (see above) at a smaller, single-row scale, so a
   habit's week strip reads as "the same visual language, zoomed
   in" rather than a foreign component. */
/* Day legend (added 2026-09-06) — the strip drew seven anonymous
   cells and never said which day was which. Same flex shape as a
   habit row below (name / strip / figure) with the name and figure
   slots rendered empty, so each letter lands exactly over the cell
   it labels without a second set of measurements to keep in sync. */
.week-glance-legend {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding-bottom: var(--space-2);
  margin-bottom: var(--space-2);
  border-bottom: 1px solid var(--gridline);
}
.week-strip-label {
  width: 0.85rem;
  flex: none;
  text-align: center;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  line-height: 1;
  color: var(--ink-muted);
}
.week-glance-strips {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
}
/* Hairline rules instead of a gap (2026-09-06): a habit's name sits
   at the plate's left edge and its strip at the right, so on a wide
   column the two were separated by empty space with nothing tying
   them together. Ruled rows under the weekday legend make the same
   markup read as the table it always was. */
.week-glance-strips li {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  padding: var(--space-2) 0;
}
.week-glance-strips li + li { border-top: 1px solid var(--gridline); }
.week-glance-habit-name {
  flex: 1;
  min-width: 0;
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.week-strip { display: flex; gap: 3px; flex: none; }
.week-strip-cell {
  width: 0.85rem;
  height: 0.85rem;
  border-radius: var(--radius-sm);
  /* 26%, up from 10%, and .future's dashed edge is the --border
     token rather than 14% of the habit colour (2026-09-06): at the
     old values an unhit or still-to-come cell was invisible on a
     dark theme, so the widget read as empty even on a week that had
     real data in it. */
  background: color-mix(in srgb, var(--habit-color, var(--series-1)) 26%, transparent);
}
.week-strip-cell.hit { background: var(--habit-color, var(--series-1)); }
.week-strip-cell.future {
  background: transparent;
  border: 1px dashed var(--border);
}
.week-strip-cell.unscheduled {
  background-image: repeating-linear-gradient(
    135deg,
    color-mix(in srgb, var(--ink) 16%, transparent) 0,
    color-mix(in srgb, var(--ink) 16%, transparent) 1px,
    transparent 1px,
    transparent 4px
  );
}
.week-strip-cell.unscheduled:not(.hit) { background-color: transparent; }
.week-strip-cell.unscheduled.hit {
  box-shadow: inset 0 0 0 1.5px color-mix(in srgb, var(--habit-color, var(--series-1)) 70%, var(--surface));
}
.week-glance-completeness-value {
  flex: none;
  min-width: 2.2rem;
  text-align: right;
  font-family: var(--font-mono);
  font-weight: var(--weight-semibold);
  color: var(--ink-muted);
  white-space: nowrap;
}
.week-glance-completeness-value.active { color: var(--series-6); }
/* Label and value over the meter, not label-meter-value in one row
   (2026-09-06): in the board's narrow rail the single-row form
   squeezed the meter down to a stub between two mono strings. */
.week-glance-tasks {
  margin-top: var(--space-4);
  padding-top: var(--space-3);
  border-top: 1px solid var(--gridline);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}
.week-glance-tasks-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-3);
}
.week-glance-tasks-label {
  flex: none;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.week-glance-tasks-value {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  color: var(--ink-muted);
  text-align: right;
}

/* Generic `select { width: 100% }` stretched this to the whole
   .form-grid column (34rem/544px) for a single weekday name —
   way oversized (added 2026-07-30, on request). Capped rather
   than touching the shared rule every other select in the app
   relies on. 12rem comfortably fits the longest option in either
   language ("segunda-feira", 13 chars) plus the dropdown arrow. */
#weekly-report-call-day { max-width: 12rem; }

.daily-report-list {
  margin-top: var(--space-4);
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(20rem, 1fr));
  gap: var(--space-4);
  align-items: start;
}

.details-content {
  overflow: hidden;
  transition: max-height 0.25s ease;
}

/* "Details" trigger moved into the card's empty top-right corner as a
   small icon-only symbol (added 2026-09-01; first pass was a bordered
   text button — "kinda ugly," and a follow-up direct request: "move
   the details button to the right up corner, and make it as a small
   symbol that represents it"). `.habit-card`'s corner brackets
   (`::before`/`::after` above) already mark the top-left and
   bottom-right corners — this fills the top-right one the same way a
   real (not pseudo-element) absolutely-positioned child can:
   `.habit-card` is `position: relative`, so `top`/`right` here are
   relative to ITS padding box, landing the icon in the padding
   gutter above/right of `header`'s own content (which starts further
   inset, at the padding box's inner edge) — no overlap with the
   completeness badge/reorder controls already living in that header row.
   `details.habit-details` (not just `.habit-details`) to outrank the
   generic `.habit-card details { margin-top: ... }` rule above,
   which would otherwise nudge this off its `top` position — same
   specificity (one class + one type selector each), source order
   after it settles the tie.
   Kebab-dot icon (see `summary_label()`, _macros.html) intentionally
   has no border/background of its own, matching "small symbol" —
   just a glyph that recolors on hover/open. The marker is dropped
   outright (two rules: `list-style` for Firefox/Chromium,
   `::-webkit-details-marker` for Safari) since a disclosure triangle
   implied "expands right here," which stopped being true once this
   opens as a popup instead. Scoped to `.habit-details` specifically
   — NOT the broader `.habit-card summary` rule above, which also
   matches `_widget_habits.html`'s "+ New habit" toggle (itself a
   `.habit-card`-classed `<details>`) and would have restyled/moved
   that too. */
details.habit-details {
  position: absolute;
  top: var(--space-2);
  right: var(--space-2);
  margin-top: 0;
}
.habit-details > summary {
  list-style: none;
  display: flex;
  align-items: center;
  justify-content: center;
  width: 1.5rem;
  height: 1.5rem;
  color: var(--ink-muted);
}
.habit-details > summary::-webkit-details-marker { display: none; }
.habit-details > summary .details-trigger-icon { display: flex; }
.habit-details > summary svg { width: 1.15rem; height: 1.15rem; }
.habit-details > summary:hover { color: var(--ink); }
.habit-details[open] > summary { color: var(--habit-color, var(--accent-glow)); }
/* A ranked card carries no trigger (2026-09-09): its popup is only
   ever rendered already-open, arrived at from the habit page's
   "Edit" link, so the corner icon would only appear after the popup
   was closed — exactly the kebab that was asked to go. The <summary>
   itself stays in the markup (a <details> without one gets a UA
   "Details" legend) and is hidden here. */
.habit-details-triggerless > summary { display: none; }

/* Habit card "Details" panel as a popup (added 2026-09-01, direct
   request — the old inline accordion pushed the whole card open into
   "a huge menu"). `.habit-details[open]` alone (pure CSS, [open] is
   set with JS off too) already centers .details-content as a framed
   popup over a dimmed page, so it degrades correctly without JS.
   The backdrop is a real element (`.details-popup-backdrop`, first
   child of `.habit-details` in the macro, right after <summary>) —
   NOT a `::before` pseudo-element (the first version of this used
   one; direct follow-up report that click-to-close didn't work,
   and this codebase has hit real-Safari-only pseudo-element/hit-
   testing quirks before, see changes_log.md — so switched to a real
   node, the same shape `.preview-modal-backdrop` below already uses
   and is known to work). `<details>`'s native behavior already
   hides any non-`<summary>` child while closed, so no `[open]`
   needed in the selector below — it only needs styling.
   The backdrop click-to-close, Escape key, and the × button all need
   initPopupDetails() (below) to actually do anything — the
   backdrop ships `pointer-events: none` and the × button `display:
   none` until JS adds `.is-enhanced`, so with JS off the backdrop is
   decorative only and clicks fall through to the real <summary> at
   its normal position, which still closes it same as before.
   Opt-in per <details> via the `popup-details` class (generalized
   from `.habit-details` on 2026-09-04, when the ranked-enrollment
   picker became the second popup — see _widget_habits.html): the
   habit card's Details panel and that picker carry it, every other
   <details> in the app (tasks, routines, train notes, the
   "+ New habit" form) keeps today's plain inline accordion. The
   trigger stays each popup's own business — `.habit-details`'s
   corner icon above, the picker's group-action button — this block
   only owns the backdrop, the centered panel and the × button. */
.popup-details > .details-popup-backdrop {
  position: fixed;
  inset: 0;
  z-index: 60;
  /* Dim toward the theme's own ground, not toward --ink (changed
     2026-09-04): --ink is near-white in the nine dark themes, so a
     55%-ink scrim LIGHTENED the page behind the popup into a milky
     wash instead of pushing it back. `--page` at 80% is the same
     scrim `dialog.time-ring-dialog::backdrop` below already uses,
     and it reads as "further away" in light themes too. */
  background: color-mix(in srgb, var(--page) 80%, transparent);
  backdrop-filter: blur(3px);
  pointer-events: none;
}
.popup-details.is-enhanced > .details-popup-backdrop { pointer-events: auto; }
.popup-details[open] > .details-content {
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  z-index: 61;
  width: min(34rem, calc(100vw - 2 * var(--space-5)));
  max-height: calc(100vh - 2 * var(--space-6));
  overflow-y: auto;
  background: var(--surface);
  border: 1px solid var(--border);
  box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.45);
  padding: var(--space-5);
}
.details-popup-close {
  position: absolute;
  top: var(--space-2);
  right: var(--space-2);
  z-index: 1;
  appearance: none;
  display: none;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--ink);
  font-size: var(--text-md);
  line-height: 1;
  cursor: pointer;
}
.popup-details.is-enhanced .details-popup-close { display: flex; }
.details-popup-close:hover { background: var(--surface); }
@media (max-width: 640px) {
  .popup-details[open] > .details-content {
    width: calc(100vw - 2 * var(--space-4));
  }
}

/* ---- The ranked-enrollment popup (added 2026-09-04, direct request:
   "after we click on enroll in a ranked habits I want the menu to
   appear as a pop up, and lets design a impressive menu").

   It reuses the whole `.popup-details` shell above — backdrop,
   centering, × button, Escape — and only reshapes what's inside it:
   a fixed header over a scrolling body, so the catalog can grow past
   one screen without the title scrolling away with it. That's why
   the panel drops the shell's padding and becomes a flex column;
   padding moves onto the header and the body, and the body owns the
   scrollbar (the shell's own `overflow-y: auto` is cancelled, or
   both would scroll).

   Everything below styles the same list that used to be a row of
   name + "Set up" buttons — the links are unchanged. Colour is the
   only thing separating build from break (--good / --danger via
   `--tone`), matching the .habit-group-ranked/-own convention that a
   group's accent, not its size, marks what it is. */
.popup-framed[open] > .details-content {
  padding: 0;
  overflow: hidden;
  display: flex;
  flex-direction: column;
  background: var(--page);
}
/* Per-popup width — the shell's own 34rem default suits a form; the
   enrollment catalog is a grid and wants the room. */
.enroll-popup[open] > .details-content {
  width: min(48rem, calc(100vw - 2 * var(--space-5)));
}
/* Corner brackets, the same mark .habit-card carries — placed on the
   panel itself so the popup reads as part of the app's frame
   language rather than a generic dialog. `inset` on a
   pointer-events: none overlay, so they never eat a click meant for
   a card underneath. Inset INSIDE the frame rather than hung off it
   at -1px: the panel clips its own overflow (that's what keeps the
   body's scroll inside the frame), so a negative offset would simply
   be cut off — and floating them a hair inside also keeps the
   top-left one off the header's spectrum rule, where an accent-on-
   accent bracket was invisible. They sit in the panel's padding
   gutter, the same place .habit-card's brackets sit. */
.popup-framed[open] > .details-content::before,
.popup-framed[open] > .details-content::after {
  content: "";
  position: absolute;
  width: 0.85rem;
  height: 0.85rem;
  pointer-events: none;
  z-index: 2;
}
.popup-framed[open] > .details-content::before {
  top: var(--space-2); left: var(--space-2);
  border-top: 2px solid var(--accent-glow);
  border-left: 2px solid var(--accent-glow);
}
.popup-framed[open] > .details-content::after {
  bottom: var(--space-2); right: var(--space-2);
  border-bottom: 2px solid var(--accent-glow);
  border-right: 2px solid var(--accent-glow);
}

.popup-head {
  position: relative;
  flex: none;
  padding: var(--space-5) var(--space-6) var(--space-4);
  border-bottom: 1px solid var(--gridline);
  background: var(--surface);
}
/* Hairline spectrum rule along the top edge — the one purely
   decorative element here. It uses the two ends of the catalog's own
   colour scale (build → break) rather than a new colour. */
.popup-head::after {
  content: "";
  position: absolute;
  left: 0; right: 0; top: 0;
  height: 2px;
  background: linear-gradient(90deg, var(--good), var(--accent-glow) 55%, var(--danger));
  opacity: 0.85;
}
.popup-eyebrow {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--series-1);
}
.popup-eyebrow::before {
  content: "";
  width: 0.4rem;
  height: 0.4rem;
  border-radius: 50%;
  background: currentColor;
  animation: blink-dot 1.8s ease-in-out infinite;
}
.popup-title {
  margin: var(--space-2) 0 0;
  font-family: var(--font-display);
  font-size: var(--text-lg);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.02em;
  color: var(--ink);
}
.popup-sub {
  margin: var(--space-2) 0 0;
  max-width: 34rem;
  font-size: var(--text-2xs);
  line-height: 1.55;
  color: var(--ink-muted);
}
.popup-body {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  padding: var(--space-5) var(--space-6) var(--space-6);
}
/* Forms inside a framed popup lay out two-up where there's room
   (2026-09-04, part of "update the details pop up ... better
   visual"): the habit edit form is seven single-column fields, which
   made the popup a tall scroll of near-identical boxes. `auto-fit` +
   a 13rem floor collapses back to one column on a narrow panel or
   phone with no media query. `.form-grid`'s own 34rem cap is lifted
   here — the panel is the width constraint now. Notes and button
   rows span the full width so they read as belonging to the fields
   above them rather than as another column. */
.popup-body .form-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
  align-items: start;
  gap: var(--space-3) var(--space-4);
  margin-top: var(--space-3);
  max-width: none;
}
.popup-body .form-grid > p,
.popup-body .form-grid > .form-actions,
.popup-body .form-grid > .field-hint,
.popup-body .form-grid > label:has(> input[type="checkbox"]) { grid-column: 1 / -1; }
.popup-body .form-actions { margin-top: var(--space-4); }
/* The habit Details popup accents its sections with that habit's own
   colour (the same `--habit-color` its card and chart use); every
   other framed popup falls back on the section-dot's own default. */
.habit-details[open] > .details-content {
  --tone: var(--habit-color, var(--ink-2));
  width: min(40rem, calc(100vw - 2 * var(--space-5)));
}
.habit-details[open] > .details-content .popup-title {
  font-size: var(--text-md);
  letter-spacing: 0.04em;
}

/* Sections inside any framed popup (the enrollment catalog's two
   categories, the Details panel's Edit/Log/History blocks). `--tone`
   is the section's accent: the enrollment catalog sets build/break
   colours, everything else falls back to the habit's own colour and
   then to --ink-2. */
.popup-section + .popup-section { margin-top: var(--space-6); }
.enroll-section-good { --tone: var(--good); }
.enroll-section-bad  { --tone: var(--danger); }
/* Sticky so the category stays named while a long list scrolls under
   it — the body is the scroll container, and the header is opaque on
   --page for exactly that reason. Sticky earns its keep only where a
   section is long enough to outlast its own title (the enrollment
   catalog's two lists); the Details popup's three short sections are
   static instead, so a pinned "EDIT" can't sit over the Log fields.
   */
.popup-section-title {
  position: sticky;
  top: calc(var(--space-5) * -1);
  z-index: 1;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin: 0 0 var(--space-3);
  padding: var(--space-2) 0;
  background: var(--page);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-2);
}
.habit-details .popup-section-title { position: static; }
.popup-section-dot {
  width: 0.45rem;
  height: 0.45rem;
  flex: none;
  background: var(--tone, var(--ink-2));
  box-shadow: 0 0 0.5rem var(--tone, transparent);
}
/* The label keeps its natural width; the rule fills whatever is left,
   so the count always sits hard right regardless of language. */
.popup-section-label { flex: none; }
.popup-section-title::after {
  content: "";
  flex: 1;
  height: 1px;
  background: linear-gradient(90deg, var(--gridline), transparent);
}
.popup-section-count {
  flex: none;
  color: var(--tone, var(--ink-2));
}

.enroll-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(13rem, 1fr));
  gap: var(--space-3);
}
.enroll-card {
  position: relative;
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  padding: var(--space-4);
  min-height: 7.5rem;
  background: var(--surface);
  border: 1px solid var(--gridline);
  border-radius: var(--radius-sm);
  text-decoration: none;
  color: inherit;
  overflow: hidden;
  transition: border-color 140ms ease, background-color 140ms ease,
              transform 140ms ease, box-shadow 140ms ease;
  /* Staggered reveal. `--i` is set inline per card (see
     _macros.html's enroll_section); `backwards` holds the card
     invisible through its own delay instead of flashing first. */
  animation: enroll-card-in 260ms ease backwards;
  animation-delay: calc(var(--i, 0) * 45ms);
}
/* A tone-coloured wash that grows out of the top-left corner on
   hover — the same "the frame reacts" idea as the habit cards'
   brackets, without adding a second border colour. */
.enroll-card::before {
  content: "";
  position: absolute;
  inset: 0;
  background: radial-gradient(circle at 0% 0%, var(--tone), transparent 62%);
  opacity: 0;
  transition: opacity 140ms ease;
  pointer-events: none;
}
.enroll-card:hover, .enroll-card:focus-visible {
  border-color: var(--tone);
  background: var(--surface-2);
  transform: translateY(-2px);
  box-shadow: 0 0.5rem 1.5rem color-mix(in srgb, var(--tone) 22%, transparent);
}
.enroll-card:hover::before, .enroll-card:focus-visible::before { opacity: 0.09; }
.enroll-card-name {
  font-family: var(--font-display);
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.01em;
  color: var(--ink);
  line-height: 1.25;
}
/* margin-top:auto pins the footer to the card's bottom edge, so a
   one-line and a two-line name still line their CTAs up across the
   row. */
.enroll-card-foot {
  margin-top: auto;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-2);
  /* Kept after the push chip was deleted with importance_level
     (2026-09-09): it holds every "SET UP →" in a row on the same
     line regardless of how the name above wrapped. */
  min-height: 1.4rem;
}
/* margin-left: auto, not the parent's space-between alone — the foot
   holds only the CTA now, and it must still sit hard right. */
.enroll-card-cta {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--tone);
  white-space: nowrap;
}
.enroll-card-cta span {
  display: inline-block;
  transition: transform 140ms ease;
}
.enroll-card:hover .enroll-card-cta span,
.enroll-card:focus-visible .enroll-card-cta span { transform: translateX(0.25rem); }

/* ---- A catalog entry whose enrollment flow doesn't exist yet
   (2026-09-11, direct request: "I want the other ones to be like,
   'You cannot enroll in them right now'... show that they are under
   construction, something like that, and a visual indication").

   The card stays in place at full size — the catalog is the roadmap, so
   hiding these would cost more than it saves — and is read as inert by
   three separate signals, because any one of them alone is missable:
   it is dimmed, it is hatched, and it says so in words. Two of the
   three survive a user who can't see colour. */
.enroll-card-locked {
  /* --tone drives the hover wash, the CTA colour and the section dot.
     Neutralising it here is what stops a locked card borrowing the
     build/break accent it hasn't earned. */
  --tone: var(--ink-muted);
  cursor: not-allowed;
  border-style: dashed;
  color: var(--ink-muted);
  /* Not `opacity`, which would also fade the hatch below and leave the
     name too faint to read against it. The name dims on its own. */
  background: var(--surface);
}
/* Construction hatching. A repeating-linear-gradient rather than an
   image for the usual reason (no second external asset), sized in rem so
   it scales with the card and not the viewport. Sits under the content
   (the ::before wash is unused here — a locked card has no hover) and is
   deliberately faint: it should read as texture, not as a barricade. */
.enroll-card-locked::before {
  opacity: 1;
  background: repeating-linear-gradient(
    -45deg,
    color-mix(in srgb, var(--ink) 5%, transparent) 0 0.35rem,
    transparent 0.35rem 0.9rem
  );
}
.enroll-card-locked .enroll-card-name { color: var(--ink-2); }
/* Every hover/focus reaction the live cards have, taken back off — a
   locked card must not lift, glow or recolour under the pointer. The
   picker's version is a real `disabled` <button>, so it never fires
   these anyway; the popup's is a <span>, which would. */
.enroll-card-locked:hover, .enroll-card-locked:focus-visible {
  border-color: var(--gridline);
  background: var(--surface);
  transform: none;
  box-shadow: none;
}
.enroll-card-locked:hover::before, .enroll-card-locked:focus-visible::before { opacity: 1; }
/* Same slot and typography as .enroll-card-cta, so a locked card's foot
   lines up with the "SET UP →" on the live ones beside it. */
.enroll-card-lock {
  margin-left: auto;
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-muted);
  text-align: right;
}
.enroll-card-lock-icon {
  width: 0.8rem;
  height: 0.8rem;
  flex: none;
}
/* The picker's cards are <button>s, and a disabled one is greyed by the
   UA on some engines. The card paints its own inert state above, so take
   that back off and let the two variants look identical. */
button.enroll-card-locked:disabled { opacity: 1; color: var(--ink-muted); }

@keyframes enroll-card-in {
  from { opacity: 0; transform: translateY(0.5rem); }
  to   { opacity: 1; transform: translateY(0); }
}
@media (prefers-reduced-motion: reduce) {
  .enroll-card { animation: none; }
}
@media (max-width: 640px) {
  .popup-head { padding: var(--space-4) var(--space-4) var(--space-3); }
  .popup-body { padding: var(--space-4); }
  .enroll-grid { grid-template-columns: 1fr; }
  .enroll-card { min-height: 0; }
}

@keyframes glitch-jitter {
  0%   { transform: translate(0, 0); }
  20%  { transform: translate(calc(var(--glitch-amp) * -1), var(--glitch-amp)); text-shadow: var(--glitch-amp) 0 var(--accent-glow-2), calc(var(--glitch-amp) * -1) 0 var(--accent-glow); }
  40%  { transform: translate(var(--glitch-amp), calc(var(--glitch-amp) * -1)); text-shadow: none; }
  60%  { transform: translate(calc(var(--glitch-amp) * -1), 0); text-shadow: calc(var(--glitch-amp) * -1) 0 var(--accent-glow), var(--glitch-amp) 0 var(--accent-glow-2); }
  80%  { transform: translate(0, var(--glitch-amp)); text-shadow: none; }
  100% { transform: translate(0, 0); }
}
.btn:hover, .habit-card summary:hover, .btn-primary:hover, .btn-danger:hover {
  animation: glitch-jitter 260ms steps(2, jump-none) 1;
}
@keyframes blink-dot {
  0%, 100% { opacity: 1; }
  50%      { opacity: 0.3; }
}


dialog.confirm-dialog {
  border: none;
  padding: 0;
  background: transparent;
  border-radius: 0;
}
dialog.confirm-dialog::backdrop {
  background: color-mix(in srgb, var(--ink) 45%, transparent);
}
.dialog-panel {
  position: relative;
  background: var(--surface);
  border: none;
  border-radius: 0;
  padding: var(--space-5);
  max-width: 22rem;
}
.dialog-panel::before, .dialog-panel::after {
  content: "";
  position: absolute;
  width: 11px;
  height: 11px;
  pointer-events: none;
}
.dialog-panel::before {
  top: 0; left: 0;
  border-top: 2px solid var(--accent-glow);
  border-left: 2px solid var(--accent-glow);
}
.dialog-panel::after {
  bottom: 0; right: 0;
  border-bottom: 2px solid var(--accent-glow);
  border-right: 2px solid var(--accent-glow);
}
.dialog-panel p {
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  margin: 0 0 var(--space-4);
  color: var(--ink-2);
}
.dialog-panel .form-actions { justify-content: flex-end; }

/* ---- Day ring time picker (added 2026-09-03) ---------------------
   Every input[type="time"] in the app is progressively enhanced into
   a compact mono chip that opens one shared 24-hour ring dialog
   (#time-ring-dialog, markup at the end of <body>, initTimeRing()
   below). The native input never leaves its form: it keeps its name,
   value, step and required flag and stays focusable so constraint
   validation still works, and with JS off it renders exactly as it
   always did — the chip is added by script, never by CSS.
   Every colour reads --accent-glow/--gridline/--ink-*, so all
   fourteen themes follow without a per-theme block. See docs.md "Day ring time
   picker". */
.time-field { position: relative; display: block; }
/* Clipped rather than display:none — a display:none/visibility:hidden
   control that is `required` and empty makes the browser refuse to
   submit with "not focusable" and no visible error. Opacity+1px keeps
   it focusable, so validation still points at a real element. */
.time-field > .time-native {
  position: absolute; left: 0; bottom: 0;
  width: 1px; height: 1px; min-width: 0; padding: 0; margin: 0;
  border: 0; opacity: 0; pointer-events: none;
}
.time-chip {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--space-3); width: 100%;
  font-family: var(--font-mono); font-variant-numeric: tabular-nums;
  font-size: var(--text-sm); font-weight: var(--weight-medium);
  text-transform: none; letter-spacing: 0;
  color: var(--ink); background: var(--surface-2);
  border: 1px solid var(--border); border-radius: var(--radius-sm);
  padding: var(--space-2) var(--space-3);
}
.time-chip:hover { border-color: var(--accent-glow); color: var(--ink); }
.time-chip.is-empty .tc-time { color: var(--ink-muted); }
/* The ring is a sky, and every theme already ships the two colours it
   needs: --accent-glow at the horizon (dawn and dusk) and
   --accent-glow-2 overhead (the middle of the day), over --gridline
   for night. In ember that is orange low, gold high, dark brown at
   night — a sunrise; hacker reads green horizon under a cyan midday;
   prism pink dawn under a blue noon.
   The stops sit on the ring's eight major ticks (every 3 hours), and
   night and midday are held FLAT across their bands, so only about a
   third of the circumference is ever in transition. That flatness is
   the part that matters: two earlier versions smeared a colour across
   the whole 360°, and long interpolations are where mud comes from.
   Version one interpolated --series-1/2/3/4/5/8 — the *categorical*
   chart palette, whose slots are chosen to be distinguishable from
   each other, which on a continuous ramp reads as random. Version two
   ramped --accent-glow over --gridline the whole way round: fine on
   the nine dark themes, mud on the five light ones, where the accent
   is a dark brass or indigo and every midpoint came out tan.
   Declared once here for both the 14px chip miniature and the dialog,
   so the affordance and the thing it opens cannot drift apart. */
/* .day-dial-ring joined this list 2026-09-06 — /calls' Day Dial is
   the same sky at real size, and declaring it here rather than
   copying the stops is what keeps the 14px affordance and the 116px
   dial from ever drifting apart. */
.time-chip, .tr-arc, .day-dial-ring {
  --sky-night: var(--gridline);
  --sky-horizon: var(--accent-glow);
  --sky-zenith: var(--accent-glow-2);
  --sun-ring: conic-gradient(from 180deg,
    var(--sky-night) 0%,       /* 00:00 */
    var(--sky-night) 12.5%,    /* 03:00 — night held flat to here */
    var(--sky-horizon) 25%,    /* 06:00 — dawn, on the tick */
    var(--sky-zenith) 37.5%,   /* 09:00 */
    var(--sky-zenith) 62.5%,   /* 15:00 — midday held flat */
    var(--sky-horizon) 75%,    /* 18:00 — dusk, on the tick */
    var(--sky-night) 87.5%,    /* 21:00 */
    var(--sky-night) 100%);
}
.tc-ring {
  width: 14px; height: 14px; border-radius: 50%; flex: none;
  background: var(--sun-ring);
  box-shadow: inset 0 0 0 3.5px var(--surface-2);
}

dialog.time-ring-dialog { border: none; padding: 0; background: transparent; border-radius: 0; }
dialog.time-ring-dialog::backdrop { background: color-mix(in srgb, var(--page) 82%, transparent); }
.time-ring-panel {
  position: relative; background: var(--surface);
  border: 1px solid var(--border); padding: var(--space-5);
}
.time-ring-panel::before, .time-ring-panel::after {
  content: ""; position: absolute; width: 11px; height: 11px; pointer-events: none;
}
.time-ring-panel::before {
  top: 0; left: 0;
  border-top: 2px solid var(--accent-glow); border-left: 2px solid var(--accent-glow);
}
.time-ring-panel::after {
  bottom: 0; right: 0;
  border-bottom: 2px solid var(--accent-glow); border-right: 2px solid var(--accent-glow);
}
.tr-label {
  font-family: var(--font-mono); font-size: var(--text-2xs);
  letter-spacing: 0.16em; text-transform: uppercase; color: var(--ink-muted);
  text-align: center; margin: 0 0 var(--space-4);
}
.tr-arc {
  --tr-size: 240px;
  position: relative; width: var(--tr-size); height: var(--tr-size);
  margin: 0 auto; touch-action: none; cursor: grab;
}
.tr-arc:active { cursor: grabbing; }
/* Midnight at the bottom (from 180deg), noon at the top; the ramp is
   civil daylight, not this user's real sunrise — see TODO.md. */
.tr-ring {
  position: absolute; inset: 0; border-radius: 50%;
  background: var(--sun-ring);
  -webkit-mask: radial-gradient(farthest-side, transparent 0 74%, #000 75%);
  mask: radial-gradient(farthest-side, transparent 0 74%, #000 75%);
}
.tr-marks { position: absolute; inset: 0; }
.tr-marks i {
  position: absolute; left: 50%; top: 0; width: 1px; height: 8px;
  margin-left: -0.5px; background: color-mix(in srgb, var(--ink) 25%, transparent);
  transform-origin: 50% calc(var(--tr-size) / 2);
}
.tr-marks i.maj { height: 14px; background: color-mix(in srgb, var(--ink) 55%, transparent); }
.tr-hand { position: absolute; inset: 0; }
.tr-hand span {
  position: absolute; left: 50%; top: 14px; width: 2px; height: 24px;
  margin-left: -1px; background: linear-gradient(180deg, var(--ink), transparent);
}
.tr-hand i {
  position: absolute; left: 50%; top: -8px; width: 16px; height: 16px;
  margin-left: -8px; border-radius: 50%; background: var(--ink);
  box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent-glow) 30%, transparent),
              0 0 16px var(--accent-glow);
}
.tr-center {
  position: absolute; inset: calc(var(--tr-size) * 0.21);
  display: flex; flex-direction: column; align-items: center; justify-content: center;
  gap: var(--space-1); text-align: center;
}
/* The readout is a real text field: the ring is for shaping a time,
   typing is for hitting one exactly (event times still step by 1). */
.time-ring-panel .tr-readout {
  width: 100%; padding: 0 0 2px; background: transparent;
  text-align: center; color: var(--ink); cursor: text;
  font-family: var(--font-mono); font-variant-numeric: tabular-nums;
  font-size: 2.15rem; font-weight: var(--weight-medium); letter-spacing: -0.02em;
  /* Dashed underline = "this is a field" (added 2026-09-10). It was a
     bare, borderless number before and read as a readout, so nobody
     tried clicking it. Only the bottom edge, so the numbers still sit
     in the ring rather than in a box. */
  border: none; border-bottom: 1px dashed color-mix(in srgb, var(--ink) 30%, transparent);
  transition: color 120ms ease, border-color 120ms ease;
}
.time-ring-panel .tr-readout:hover { border-bottom-color: var(--accent-glow); }
.time-ring-panel .tr-readout:focus {
  outline: none; color: var(--accent-glow);
  border-bottom: 1px solid var(--accent-glow);
}
/* The time being replaced, shown while the field is empty and waiting
   for digits — muted enough to read as "was", not as "is". */
.time-ring-panel .tr-readout::placeholder { color: var(--ink-muted); opacity: 1; }
.tr-hint {
  margin: var(--space-3) 0 0; text-align: center;
  font-family: var(--font-mono); font-size: var(--text-2xs);
  letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-muted);
}
.tr-note {
  font-family: var(--font-mono); font-size: var(--text-2xs);
  letter-spacing: 0.1em; text-transform: uppercase; color: var(--ink-muted);
}
.tr-actions { display: flex; gap: var(--space-2); justify-content: flex-end; margin-top: var(--space-5); }
.tr-actions .tr-spacer { margin-right: auto; }
@media (max-width: 400px) {
  .tr-arc { --tr-size: 204px; }
  .time-ring-panel .tr-readout { font-size: 1.8rem; }
}

/* Train hub + section sub-nav (added 2026-08-02, when /train became a
   hub over /train/cardio, /train/lifting and /train/mobility). No new
   visual language: each hub card is a .habit-card (corner brackets and
   all) turned into a link, setting --habit-color to a different
   --series-* token the same way .status-cell sets --cell-accent, so
   all 12 themes follow with no per-theme rules. The sub-nav pills
   borrow .chart-toggle-btn's active treatment (accent fill, --surface
   text) for the same reason. */
.train-hub {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: var(--space-5);
  margin-top: var(--space-4);
}
.train-hub-card {
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
  margin-bottom: 0;
  color: var(--ink-2);
  text-decoration: none;
}
.train-hub-card:hover { background: var(--surface-2); }
.train-hub-cardio { --habit-color: var(--series-1); }
.train-hub-lifting { --habit-color: var(--series-2); }
.train-hub-mobility { --habit-color: var(--series-3); }
/* Lifting/mobility sections exist in code (behind /train/lifting,
   /train/mobility) but aren't ready to expose pre-launch (added
   2026-08-18, direct user ask) — the hub cards drop their `href`
   entirely rather than just styling them dim, so they're actually
   unclickable and out of tab order with zero JS, not merely
   link-shaped. */
.train-hub-card.is-disabled { opacity: 0.45; cursor: not-allowed; }
.train-hub-card.is-disabled:hover { background: none; }
.train-hub-card-soon {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-muted);
  border: 1px solid var(--gridline);
  border-radius: var(--radius-sm);
  padding: 0.1em 0.4em;
  vertical-align: middle;
}
.train-hub-card-index {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.08em;
  color: var(--habit-color);
}
.train-hub-card-title {
  font-family: var(--font-display);
  font-size: var(--text-md);
  font-weight: var(--weight-semibold);
  color: var(--ink);
}
.train-hub-card-desc { font-size: var(--text-sm); color: var(--ink-muted); }

.train-subnav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-4);
  margin-bottom: var(--space-5);
  font-family: var(--font-mono);
  font-size: var(--text-xs);
}
.train-subnav-back { color: var(--ink-muted); text-decoration: none; }
.train-subnav-back:hover { color: var(--ink); }
.train-subnav-links { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.train-subnav-link {
  padding: var(--space-1) var(--space-3);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  color: var(--ink-2);
  text-decoration: none;
}
.train-subnav-link:hover { color: var(--ink); border-color: var(--ink-muted); }
.train-subnav-link.is-active {
  color: var(--surface);
  background: var(--series-1);
  border-color: var(--series-1);
}

/* Muscle volume map (Train page) — front/back body diagrams built as
   hand-authored inline SVG (templates/_muscle_body.html), not an
   external image asset. Redrawn 2026-07-23 (4th pass): the user
   supplied a clean line-art illustration (muscle_img/muscles.png)
   and asked to keep its exact lines, adding only the coloring.
   `.muscle-lines` is that illustration's linework, vectorized
   pixel-for-pixel via `potrace` and rendered unmodified as a filled
   overlay on top of everything else — it is not hand-drawn and
   never changes. `.muscle-region` shapes underneath it were
   produced by flood-filling each enclosed muscle area in the same
   source image and tracing the result, so their boundaries also
   come directly from the user's artwork, not from an approximated
   outline. Fill intensity (set per-region via inline style,
   color-mix() — same technique the habit heatmap uses) is the only
   thing that varies with volume; at 0% a region blends down toward
   --surface rather than disappearing, so it still reads as part of
   the drawing. See docs.md's "Train page" section for the
   trace/flood-fill pipeline. */
.muscle-map { display: flex; flex-wrap: wrap; justify-content: center; gap: var(--space-6); margin-top: var(--space-4); }
.muscle-body-col { display: flex; flex-direction: column; align-items: center; gap: var(--space-2); flex: 0 1 16rem; }
.muscle-body-label { font-family: var(--font-mono); font-size: var(--text-2xs); font-weight: var(--weight-semibold); letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-muted); }
.muscle-body { width: 100%; max-width: 16rem; height: auto; background: var(--surface); }
.muscle-region { stroke: var(--page); stroke-width: 1.5; stroke-linejoin: round; transition: fill 200ms ease, stroke 200ms ease, stroke-width 200ms ease; }
.muscle-lines { fill: var(--ink-2); }

.muscle-table { width: 100%; max-width: 34rem; margin: var(--space-6) auto 0; border-collapse: collapse; font-family: var(--font-mono); font-size: var(--text-sm); }
.muscle-table th { text-align: left; font-size: var(--text-2xs); font-weight: var(--weight-semibold); letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-muted); padding: 0 var(--space-3) var(--space-2); border-bottom: 1px solid var(--border); }
.muscle-table td { padding: var(--space-2) var(--space-3); border-bottom: 1px solid var(--border); color: var(--ink-2); vertical-align: middle; }
.muscle-table tr:last-child td { border-bottom: none; }
.muscle-table tr:hover td { background: var(--surface); }
.muscle-table td:first-child { color: var(--ink); }
.muscle-table td:nth-child(2) { text-align: right; }
.muscle-table td:nth-child(3) { display: flex; align-items: center; gap: var(--space-2); }
.muscle-table-bar { flex: 1; height: 6px; background: var(--surface-2); border-radius: var(--radius-sm); overflow: hidden; }
.muscle-table-bar span { display: block; height: 100%; background: var(--series-1); }
.muscle-table-pct { flex: 0 0 3ch; text-align: right; font-size: var(--text-2xs); color: var(--ink-muted); }

/* /train/lifting's section headings. The .balance-* rows and the
   set-logging list that shared this block were removed 2026-08-02
   with the Training Balance feature and TrainingSetLog. */
.train-section-heading {
  font-family: var(--font-display);
  font-size: var(--text-md);
  font-weight: var(--weight-semibold);
  margin: var(--space-6) 0 var(--space-2);
}
.muscle-breakdown { margin-top: var(--space-6); }
.muscle-breakdown summary { font-size: var(--text-xs); }

/* Lifting programs (added 2026-08-02, programs/sessions slice 2).
   The rows themselves are plain .habit-card.task-card, same as a
   task row — only the muscle tags needed a style of their own.
   Primary reads in the page accent, secondary in the muted ink, so
   the split survives all 12 themes without a per-theme rule. */
/* Trainings list on /train/lifting itself (added when the page's 3
   entry buttons — Programs/Log a workout/Start Live Training —
   collapsed into this inline list + one "+ New training" form). */
.program-list { margin-top: var(--space-4); }
/* Program picker boxes on /train/lifting were replaced by the
   Loadout Rail treatment (2026-08-14) — see the "Loadout Rail"
   comment block below (.rail/.rail-row/etc.), shared with
   train_lifting.html/train_lifting_program_detail.html. The rules
   that used to live here (.program-picker-grid/-box/-name/-meta/
   -adhoc*) are gone, not just unused — nothing references them any
   more. */
.program-list .task-title { text-decoration: none; }
.program-list a.task-row-head { text-decoration: none; }

/* "Loadout Rail" — the lifting-programs cluster's own visual
   identity: train_lifting_program_detail.html (added 2026-08-14,
   ux-ui-reviewer design concept, replacing the habit-card/task-card
   treatment it and the now-removed standalone programs-list page
   borrowed from Habits/Tasks), and the Programs picker on
   train_lifting.html itself (folded onto the same rail the same
   day — same rows, same data, so it got the same treatment rather
   than staying on the old card-grid; train_lifting.html later
   absorbed that removed list page's create/reorder controls too,
   2026-08-14). A program/exercise is a *sequence*
   (TrainingProgram.position / ProgramExercise.position are real,
   reorderable columns) — the rail gives that fact a body: one
   graduated spine every row hangs off of, numbered by loop.index.
   Row titles switch to --font-display (every other list row in the
   app titles itself in --font-mono) — that typographic break is
   what makes a row read as a heading you're browsing rather than a
   line you're scanning past. */
.rail-eyebrow {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--series-1);
  margin: 0 0 var(--space-1);
  display: flex;
  align-items: center;
  gap: var(--space-2);
}
.rail-eyebrow::before { content: ""; width: 1.1rem; height: 1px; background: var(--series-1); display: inline-block; }
.rail-eyebrow.is-section { margin-top: var(--space-6); }

/* Start-workout form's own frame — an accent rail on the left marks
   it as the page's primary action. Rename/delete doesn't get this
   treatment: it's a plain <details> below the rail (#program-settings,
   margin only, no panel) — one rarely-used disclosure doesn't earn a
   whole section frame. */
.briefing-panel { background: var(--surface); border-left: 3px solid var(--series-1); padding: var(--space-4) var(--space-5); margin-top: var(--space-4); }
.briefing-panel .rail-eyebrow { color: var(--ink-muted); }
.briefing-panel .rail-eyebrow::before { background: var(--ink-muted); }
#program-settings { margin-top: var(--space-6); }

.rail { position: relative; margin-top: var(--space-5); --rail-w: 2.6rem; }
.rail::before {
  content: "";
  position: absolute;
  left: calc(var(--rail-w) / 2 - 0.5px);
  top: 0.6rem;
  bottom: 0.6rem;
  width: 1px;
  background-image: repeating-linear-gradient(to bottom, var(--border) 0 3px, transparent 3px 9px);
}
.rail-row { position: relative; display: flex; gap: var(--space-3); padding: var(--space-2) 0; }
.rail-index { flex: 0 0 var(--rail-w); display: flex; flex-direction: column; align-items: center; gap: 3px; padding-top: 2px; }
.rail-index-num {
  position: relative;
  z-index: 1;
  width: 1.7rem;
  height: 1.7rem;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  color: var(--ink-muted);
  background: var(--page);
  border: 1px solid var(--border);
  flex: none;
}
.rail-row:hover .rail-index-num, .rail-row:focus-within .rail-index-num { color: var(--page); background: var(--series-1); border-color: var(--series-1); }
.rail-row .reorder-controls { transform: scale(0.85); transform-origin: top center; }

.rail-panel { flex: 1; min-width: 0; background: var(--surface); padding: var(--space-3) var(--space-4); border-left: 2px solid transparent; text-decoration: none; color: inherit; display: block; }
a.rail-panel:hover, .rail-row:focus-within .rail-panel { border-left-color: var(--series-1); background: var(--surface-2); }
/* Preview-mode program/session links (train_lifting.html) — no href,
   same "not actually clickable" affordance train-hub-card.is-disabled
   already uses for the Cardio/Mobility hub cards. */
.rail-panel.is-disabled, .task-row-head.is-disabled { opacity: 0.6; cursor: default; pointer-events: none; }
.rail-panel-head { display: flex; align-items: center; gap: var(--space-3); flex-wrap: wrap; }
.rail-title { font-family: var(--font-display); font-size: var(--text-md); font-weight: var(--weight-semibold); text-transform: uppercase; letter-spacing: 0.015em; color: var(--ink); margin: 0; flex: 1; min-width: 8ch; }
.rail-meta { display: flex; align-items: center; gap: var(--space-2); margin-left: auto; }

/* Segmented "load-bar" gauge: rail_gauge() macro (_macros.html)
   renders it, capped at 6 segments + a "+N" overflow badge past
   that. Program rows (.rail-count) gauge exercise count in
   --series-2, matching the muscle-tag pills' primary/secondary
   split so a lifter who already reads that convention on this
   page's own tags gets it for free here too; exercise rows gauge
   their own set count (len(ProgramExerciseSet), added 2026-08-14 —
   no longer the ProgramExercise.target_sets field, removed the
   same day) in the default --series-1. */
.rail-gauge { display: inline-flex; align-items: center; gap: 2px; --gauge-color: var(--series-1); }
.rail-gauge-seg { width: 0.5rem; height: 0.8rem; background: var(--surface-2); border: 1px solid var(--border); border-radius: var(--radius-sm); }
.rail-gauge-seg.is-filled { background: var(--gauge-color); border-color: var(--gauge-color); }
.rail-gauge-overflow { font-family: var(--font-mono); font-size: var(--text-2xs); color: var(--ink-muted); margin-left: 2px; }
.rail-gauge-num { font-family: var(--font-mono); font-size: var(--text-2xs); color: var(--ink-muted); letter-spacing: 0.03em; white-space: nowrap; }
.rail-count .rail-gauge { --gauge-color: var(--series-2); }

.rail-row.is-new .rail-index-num { border-style: dashed; color: var(--series-1); background: transparent; }
.rail-row.is-new .rail-panel { border: 1px dashed var(--border); background: transparent; }
.rail-row.is-new summary, .rail-row.is-new .rail-title { color: var(--series-1); }
.rail-prompt { display: flex; align-items: center; gap: var(--space-2); font-family: var(--font-mono); font-size: var(--text-sm); font-weight: var(--weight-semibold); text-transform: uppercase; letter-spacing: 0.03em; }

.rail-note { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--ink-muted); margin: var(--space-2) 0 0; }

@media (max-width: 420px) {
  .rail { --rail-w: 2rem; }
  .rail-index-num { width: 1.4rem; height: 1.4rem; font-size: 0.6rem; }
  .rail-title { font-size: var(--text-sm); min-width: 100%; order: -1; }
  .rail-panel-head { row-gap: var(--space-1); }
  .rail-meta { margin-left: 0; }
}

.muscle-tags { display: flex; flex-wrap: wrap; gap: var(--space-1); margin-top: var(--space-2); }
.muscle-tag {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.04em;
  padding: 0 var(--space-2);
  border: 1px solid var(--series-1);
  border-radius: var(--radius-sm);
  color: var(--series-1);
  background: color-mix(in srgb, var(--series-1) 12%, transparent);
}
.muscle-tag.is-secondary {
  border-color: var(--ink-muted);
  color: var(--ink-muted);
  background: var(--surface-2);
}
.muscle-picker { margin-bottom: var(--space-2); }

/* Live training (added 2026-08-02, programs/sessions slice 4) — the
   one screen in this app meant to be used one-handed, mid-set, at
   the gym: bigger tap targets than the rest of the app (44px+, the
   same floor .chat-mic-btn already uses), high-contrast mono
   numerals for the rest timer, steppers next to (not instead of) a
   real number input so JS-off still means "type the number". All of
   it is scoped under .live-training so nothing here touches the
   app's normal button/input sizing. */
.live-header { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.live-header-badge {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.live-exercise-nav { display: flex; align-items: center; justify-content: space-between; gap: var(--space-2); margin-top: var(--space-4); }
.live-exercise-nav .btn { text-decoration: none; min-height: 2.75rem; display: inline-flex; align-items: center; }
.live-exercise-nav .btn.is-disabled { opacity: 0.35; pointer-events: none; }
.live-exercise-index { font-family: var(--font-mono); font-size: var(--text-xs); color: var(--ink-muted); }
.live-exercise-name { font-family: var(--font-display); margin: var(--space-3) 0 0; }
.live-set-tracker { margin: var(--space-3) 0; display: flex; flex-direction: column; gap: var(--space-1); align-items: center; }
.live-set-dots { display: flex; gap: var(--space-1); }
.live-set-dot { width: 0.85rem; height: 0.85rem; border-radius: 50%; border: 1px solid var(--series-1); background: transparent; }
.live-set-dot.is-done { background: var(--series-1); }
.live-rest-timer { display: flex; flex-direction: column; align-items: center; gap: var(--space-1); margin: var(--space-5) 0; }
.live-rest-label { font-family: var(--font-mono); font-size: var(--text-2xs); letter-spacing: 0.08em; text-transform: uppercase; color: var(--ink-muted); }
.live-rest-value { font-family: var(--font-mono); font-size: 2.5rem; font-weight: var(--weight-semibold); color: var(--ink); line-height: 1; }
.live-rest-timer.is-past-target .live-rest-value { color: var(--good); animation: chat-mic-pulse 1.1s ease-in-out infinite; }
.live-rest-timer.is-paused .live-rest-value { color: var(--ink-muted); animation: none; }
.live-rest-controls { display: flex; align-items: center; gap: var(--space-2); }
.live-rest-target { font-family: var(--font-mono); font-size: var(--text-2xs); color: var(--ink-muted); min-width: 4ch; text-align: center; }
.live-rest-pause-btn { margin-top: var(--space-2); }
/* Touch-target fix (UX audit, 2026-08-13): this page's own comment
   above promises 44px+ tap targets for one-handed gym-floor use, but
   these three .btn-sm controls (rest ±15s, Pause/Resume, per-set
   Excluir) measured 20-24px tall — well under that floor. Scoped
   here rather than widening .btn-sm globally, since .btn-sm is used
   plenty of places elsewhere that were never meant to hit 44px. */
.live-rest-controls button,
.live-rest-pause-btn,
.live-undo-form button {
  min-height: 2.75rem;
  display: inline-flex;
  align-items: center;
  justify-content: center;
}
.live-rest-pause-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.live-log-form { display: flex; flex-direction: column; gap: var(--space-4); align-items: center; margin-top: var(--space-3); }
.live-field { display: flex; flex-direction: column; align-items: center; gap: var(--space-1); width: 100%; max-width: 20rem; }
.live-field label { font-size: var(--text-xs); color: var(--ink-muted); }
.live-stepper { display: flex; align-items: center; gap: var(--space-2); width: 100%; }
.live-stepper input[type="number"] {
  flex: 1;
  text-align: center;
  font-family: var(--font-mono);
  font-size: var(--text-lg);
  min-height: 2.75rem;
}
.live-stepper-btn {
  flex: 0 0 auto;
  width: 2.75rem;
  height: 2.75rem;
  border-radius: 50%;
  border: 1px solid var(--series-1);
  color: var(--series-1);
  background: transparent;
  font-family: var(--font-mono);
  font-size: var(--text-lg);
  line-height: 1;
}
.live-stepper-btn:hover { background: var(--series-1); color: var(--page); }
.live-note-details summary { font-size: var(--text-xs); color: var(--ink-muted); }
.live-note-details input { margin-top: var(--space-2); width: 100%; max-width: 20rem; }
.live-log-btn { width: 100%; max-width: 20rem; min-height: 3.25rem; font-size: var(--text-md); }
.live-undo-form { display: flex; justify-content: center; margin-top: var(--space-2); }
/* Cancel workout sat flush under "Switch to manual log view" with
   only the default form-actions gap between them — both full-width
   and in the one-handed thumb zone (UX audit, 2026-08-13). A rule +
   extra top space marks it as its own, more dangerous, zone rather
   than the next item in the same list. */
.live-cancel-row { margin-top: var(--space-8); padding-top: var(--space-4); border-top: 1px solid var(--border); }
.live-cancel-btn { min-height: 2.75rem; display: inline-flex; align-items: center; justify-content: center; }

/* /admin's agent-loop retention grid (added 2026-08-01) — a dense
   users x days matrix, so it scrolls sideways in its own wrapper
   rather than squeezing the cells below hover size. Reuses the
   habit heatmap's visual language (small rounded squares, --series-1
   for a hit) without reusing .heatmap itself, which is a CSS grid
   built for one habit's week columns, not a table. Dev-only page:
   no theme-specific tuning beyond the shared tokens. */
.retention-scroll { overflow-x: auto; padding-bottom: var(--space-2); }
.retention-grid { border-collapse: separate; border-spacing: 3px; font-family: var(--font-mono); }
.retention-grid th { font-size: 0.55rem; font-weight: var(--weight-semibold); color: var(--ink-muted); text-align: center; white-space: nowrap; padding: 0; }
.retention-grid th.retention-user, .retention-grid td.retention-user { text-align: left; padding-right: var(--space-3); white-space: nowrap; }
.retention-grid td.retention-user { font-size: var(--text-2xs); color: var(--ink-2); }
.retention-cell { width: 1rem; height: 1rem; padding: 0; border-radius: 3px; background: color-mix(in srgb, var(--series-1) 10%, transparent); }
/* Tri-state (added 2026-08-07, replacing the old binary sent/not-sent):
   not-sent stays the faint base fill above; sent-unanswered is a
   muted solid so it reads as "something happened" without implying
   success; sent-answered is the strong accent — the only state that
   means "this call actually got a reply". */
.retention-cell.sent.unanswered { background: var(--ink-muted); }
.retention-cell.sent.answered { background: var(--series-1); }
.retention-note { font-size: var(--text-2xs); color: var(--ink-muted); white-space: nowrap; padding-left: var(--space-2); text-align: left; }
.retention-note a { color: inherit; }

/* Commercial landing page (GET /, moved here from GET /home on
   2026-07-24) — public, informational-only pitch page, distinct
   from the dashboard at GET /home. Reuses the theme system (fonts,
   color tokens, theme switcher) but has no product nav — see
   docs.md. Redesigned same day for more visual presence: an idle
   glitch on the hero title, a CSS-only "waveform" motif (voice-
   first positioning), an animated day/night/agent cycle diagram
   replacing flat feature cards, and a quoted "who it's for"
   statement — all pure CSS (no new JS, no new fonts/assets),
   built from the same tokens/keyframes the rest of the app uses
   (--series-*, --accent-glow, blink-dot, glitch-jitter's amplitude
   var). See docs.md's "Commercial landing page" section. */
/* Top padding is deliberately paired with .landing-topbar's
   margin-bottom (1rem): 1rem + 2rem = 3rem, matching
   .landing-status's margin-top below — the same smaller "bookend"
   gap at both the very top (after the topbar) and very bottom
   (before the status line) of the page, vs. the 4.5rem rhythm used
   for every transition in between.
   The hero itself (title, tagline, hand-reach graphic) moved into
   landing.html's own scroll-craft pinned act (2026-08-29) — its
   typography lives in that template's extra_head block now,
   including the hacker-theme size correction that used to live
   here (JetBrains Mono's x-height runs ~11% taller than Chakra
   Petch's at the same font-size; ported verbatim). */

/* Pre-launch waitlist + alpha signup CTAs (added 2026-08-15), sitting
   right below the hero — the strongest above-the-fold spot for cold
   video traffic. */
.landing-cta {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-4);
  margin-top: var(--space-6);
  text-align: center;
  animation: fade-up 0.8s ease 0.3s both;
}
/* button/.btn (see the shared rule above) styles <button> already —
   .btn-primary supplies the color variant — this class only fixes up
   the two things an <a> needs that a <button> doesn't: no underline,
   and block-level display so padding applies like a real button.
   UX audit fix (added 2026-08-16, finding #1): .btn-primary only
   goes solid on :hover, a state a phone visitor from a video link
   never triggers — the one button whose job is converting a cold
   visitor read as identical to every other outline pill on the
   page. Solid fill moved to the resting state here instead, scoped
   to this one button (not the shared .btn-primary rule, so no
   other button on the app changes). Values are exactly
   .btn-primary:hover's own (background: var(--series-1); color:
   var(--page)) — already proven legible across all 12 themes since
   that's live today on hover everywhere else, just moved to rest
   here; hover now inverts to the outline look instead. Padding
   bumped from the shared button rule's default (audit finding #7:
   measured ~36px tall on mobile, under the 44px accessibility
   floor) — a targeted override on this button alone, not a
   site-wide button resize. */
.landing-cta-btn {
  text-decoration: none;
  display: inline-block;
  background: var(--series-1);
  color: var(--page);
  padding: 0.85rem 1.75rem;
}
.landing-cta-btn:hover { background: transparent; color: var(--series-1); }
.landing-waitlist-form {
  display: flex;
  gap: var(--space-2);
  flex-wrap: wrap;
  justify-content: center;
}
.landing-waitlist-form input[type="email"] {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  padding: var(--space-2) var(--space-3);
  border: 1px solid var(--ink-muted);
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--ink);
  /* min-width was 16rem, sized for the old, much longer placeholder
     ("Not ready yet? Get notified at launch") — now that the
     placeholder is just "Email" (2026-08-20, see landing.html's
     .landing-cta-repeat), that width read as oversized next to the
     short label and the button. 10rem still comfortably fits a
     typed email address. */
  min-width: 10rem;
  max-width: 100%;
}
.landing-waitlist-btn {
  /* Smaller than the default button, .btn sizing (direct request,
     2026-08-20) — this is the repeat CTA's secondary "not ready
     yet" ask (see .landing-cta-repeat above), not the primary
     "Try the alpha" action, so it shouldn't read as the same
     weight as either that or a real form's submit button. Kept
     deliberately small in font-size and horizontal padding for
     that reason; only vertical sizing changed below. */
  font-size: var(--text-2xs);
  padding: var(--space-1) var(--space-3);
  /* min-height floor for a real tap target (was 192x24px;
     ux-ui-reviewer finding, 2026-08-29) without growing the font or
     widening the button — same technique as .landing-login-link
     above. */
  min-height: 2.75rem;
  display: inline-flex;
  align-items: center;
}
.landing-waitlist-confirm {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: 0.03em;
  color: var(--ink-2);
  margin: 0;
}
.landing-cta-proof {
  font-family: var(--font-mono);
  /* Smaller than --text-2xs on purpose — the smallest token in the
     scale, but this line sits under the hero h1 -> tagline -> CTA
     button, and needs to keep shrinking to read as the tail of that
     sequence, not a same-size restatement of the button next to it. */
  font-size: 0.625rem;
  letter-spacing: 0.04em;
  color: var(--ink-muted);
  margin: 0;
}
/* Repeated CTA above .landing-status (added 2026-08-16, UX audit
   finding #8) — the page's one ask happened exactly once, right
   after the tagline, before any of the features/day-cycle/chips/
   roadmap made a case for the product; a convinced reader had to
   scroll back several thousand pixels to find it. Lighter weight
   than the hero version (smaller heading, no proof line) since
   this is a second chance, not the primary pitch. */
.landing-cta-repeat {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-3);
  margin-top: 4.5rem;
  text-align: center;
  animation: fade-up 0.8s ease both;
}
.landing-cta-repeat-lede {
  font-family: var(--font-display);
  font-size: var(--text-md);
  color: var(--ink-2);
  margin: 0;
}
/* The hero's old .landing-waveform (removed 2026-08-05) was
   replaced with a scroll-progress bar the same day, iterated
   through fixed -> inline -> sticky positioning, then removed
   again the same day on request — parked for a later session
   rather than solved further right now. Hero currently ends at
   the tagline, deliberately empty below it. See changes_log.md's
   2026-08-05 entries for the full back-and-forth if picking this
   up again. */

/* Call preview players (added 2026-08-05; moved inside their
   respective .tasks-block cards 2026-08-05, same day — on request,
   so the player reads as "here's what that explanation sounds
   like" rather than a separate showcase section detached from the
   copy describing it) — two mini audio players so a visitor can
   hear an actual morning/night call before signing up. Visual only
   for now: the <audio> elements point at
   /static/audio/{morning,night}-call-preview.mp3, which don't exist
   on disk yet (see backend/static/audio/README.md) — nothing here
   needs to change once they're dropped in.

   Progressive enhancement: the plain <audio controls> is the no-JS
   default (fully functional on its own). initCallPreviewPlayers()
   (base.html script block) adds .is-enhanced, which is what actually
   hides the native controls and reveals the custom play button/
   waveform/time below — so a JS-disabled visitor still gets a working
   player, just the browser's own UI instead of this one. */
.call-preview {
  margin-top: var(--space-3);
  padding-top: var(--space-3);
  border-top: 1px solid var(--border);
}
/* position:relative so .call-preview-time (below) can be pulled out
   of the flex flow entirely — see that rule's comment for why. */
.call-preview-player { position: relative; display: flex; align-items: center; }
.call-preview audio { width: 100%; height: 2rem; }
/* Hidden until .is-enhanced proves the custom UI is actually wired up. */
.call-preview-wave, .call-preview-time { display: none; }
.call-preview.is-enhanced audio { display: none; }
.call-preview.is-enhanced .call-preview-wave { display: flex; }
.call-preview.is-enhanced .call-preview-time { display: block; }
/* The wave IS the play/pause toggle (changed 2026-08-05 — a separate
   circular button removed on request; the animating-vs-static bars
   already communicate playing/paused, so no icon is needed either).
   `<button>` reset (border/background/padding/font) rather than a
   `<div>` — data-call-preview-play now lives on this element
   directly, so initCallPreviewPlayers() needs no change beyond what
   it already does.

   Widened further, and re-centered (changed again 2026-08-05, same
   day — "the number on the right are offsetting it"): the wave used
   to be `flex: 1` sharing the row with .call-preview-time, so its
   own box was actually `100% - time's width`, and centering the
   bars *within that narrower box* visually reads as off-center
   against the whole card, not truly centered. Fixed at the root
   instead of papering over it with a manual offset: .call-preview-
   time is pulled out of flow (`position: absolute`, see below), so
   .call-preview-wave is now the row's only true flex participant —
   `width: 100%` — and centers its bars against the FULL card width,
   genuinely centered rather than centered-minus-a-corner. Bar count
   bumped again too (9 -> 13) for a visibly bigger cluster on top of
   the extra box-width it now gets to center within (the two changes
   solve different problems: this one grows the bars themselves,
   the layout fix above grows/fixes the space they center in). */
.call-preview-wave {
  width: 100%;
  align-items: flex-end;
  justify-content: center;
  gap: 4px;
  /* height is the TOTAL box (this file's global `* { box-sizing:
     border-box }` — got this backwards on the first pass, see
     changes_log.md, which shrank the bars to a sliver: assumed
     content-box, where padding sits outside a fixed height, but
     border-box means padding eats INTO it instead). 2.75rem total
     minus 2×0.625rem padding leaves exactly 1.5rem of actual
     content box, which is what the bars' `height: 100%` (below)
     resolves against — same visual bar height as before the tap-
     target fix, sitting on top of a ~44px clickable area instead
     of being squeezed by it. */
  height: 2.75rem;
  border: none;
  background: transparent;
  /* Vertical padding — grows the clickable area beyond the bars'
     own 1.5rem visual height (fixed 2026-08-05: a real-device
     audit found the tap target was exactly 24px tall, thin on
     mobile despite being 100% wide) — see the height comment
     above for why this doesn't just shrink the bars instead. */
  padding: 0.625rem 0;
  cursor: pointer;
}
.call-preview-wave:disabled { opacity: 0.4; cursor: not-allowed; }
.call-preview-wave span {
  display: block;
  width: 4px;
  height: 100%;
  border-radius: 2px;
  background: linear-gradient(180deg, var(--series-1), var(--series-5));
  transform-origin: bottom;
  /* Rests at scaleY(0.6), not the more typical 0.25 a "waveform"
     bounce usually rests at — a shorter resting scale read as too
     thin to look clickable once this wave doubled as the play
     button itself (changed 2026-08-05, "make them a bit taller
     when not played... before was better"). */
  animation: call-preview-wave-bounce 1.1s ease-in-out infinite;
  animation-play-state: paused;
  /* Without this, only the delay:0 bar (nth-child(1)) shows the
     animation's 0% keyframe at rest — every other bar has a
     positive animation-delay and, while paused, sits stuck in its
     "before" phase forever (it never starts, so it never advances
     past the delay) where NO keyframe applies at all by default,
     i.e. full height/no scaleY, not scaleY(0.25) like bar 1. Only
     visible while paused; once actually playing every bar cycles
     through 0%/50%/100% normally regardless of this. */
  animation-fill-mode: backwards;
  opacity: 0.55;
  transition: opacity .15s ease;
}
.call-preview-wave:hover span { opacity: 0.85; }
.call-preview.is-playing .call-preview-wave span { animation-play-state: running; opacity: 1; }
.call-preview-wave span:nth-child(1)  { animation-delay: 0s; }
.call-preview-wave span:nth-child(2)  { animation-delay: 0.08s; }
.call-preview-wave span:nth-child(3)  { animation-delay: 0.16s; }
.call-preview-wave span:nth-child(4)  { animation-delay: 0.24s; }
.call-preview-wave span:nth-child(5)  { animation-delay: 0.32s; }
.call-preview-wave span:nth-child(6)  { animation-delay: 0.4s; }
.call-preview-wave span:nth-child(7)  { animation-delay: 0.48s; }
.call-preview-wave span:nth-child(8)  { animation-delay: 0.56s; }
.call-preview-wave span:nth-child(9)  { animation-delay: 0.64s; }
.call-preview-wave span:nth-child(10) { animation-delay: 0.72s; }
.call-preview-wave span:nth-child(11) { animation-delay: 0.8s; }
.call-preview-wave span:nth-child(12) { animation-delay: 0.88s; }
.call-preview-wave span:nth-child(13) { animation-delay: 0.96s; }
/* Pulled out of flex flow (see .call-preview-wave's comment above) —
   floats over the card's right edge instead of sharing the row, so
   it can never again narrow the wave's own centering box. The
   compact bar cluster (13 bars × 4px + 12 gaps × 4px = 100px ≈
   6.25rem) sits nowhere near that edge even in the narrowest card
   width the 2-up .landing-features grid produces, so there's no
   real overlap risk despite both now occupying the same row
   visually.

   `bottom: 0`, not `top: 50%` (changed 2026-08-05, "centralize the
   wave with the number... height wise") — the bars are
   transform-origin: bottom, so their bottom edge is the one fixed
   point regardless of animation state; centering the time label
   against the container's full (fixed) height instead put it
   noticeably above the *visible* bars whenever they're short (at
   rest, or between playing pulses), since the top ~40% of the
   container is empty air in that state. Anchoring both to the same
   bottom edge keeps them visually aligned no matter how tall the
   bars currently are. */
.call-preview-time {
  position: absolute;
  right: 0;
  /* Matches .call-preview-wave's own padding-bottom (fixed
     2026-08-05), not 0 — the wave's tap-target padding pushed its
     outer box edge 0.625rem below where the bars themselves
     actually end, so anchoring this to the row's true bottom (0)
     put it visibly lower than the bars instead of on the same
     line. */
  bottom: 0.625rem;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  /* --ink-muted, not --ink-2 (fixed 2026-08-05, real-browser audit)
     — failed WCAG AA contrast (2.9-4.3:1, needs 4.5:1) against the
     card background across every theme checked. --ink-2 is what
     the neighboring chip/quote text already uses, at 7-11:1. */
  color: var(--ink-2);
}

.landing-lede {
  max-width: 38rem;
  margin: var(--space-3) auto 0;
  text-align: center;
  color: var(--ink-2);
  font-size: var(--text-sm);
}

/* Feature-card typography — was scoped to .landing-features' 4-card
   grid; that grid became the scroll-craft pan rail in landing.html's
   2026-08-29 redo (.landing-rail, see there), so this now targets
   the cards' new wrapper instead. Same rules, same cards. */
.landing-rail .tasks-block h3 {
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--series-1);
  margin: 0 0 var(--space-2);
}
.landing-rail .tasks-block p { margin: 0; color: var(--ink-2); font-size: var(--text-sm); }

/* The day/night/agent dial that lived here — a dashed ring with three
   node dots, then two marks and a comet riding the rim — was removed on
   2026-09-13 ("just go and jump straight up to the Vitruvian Man"). The
   circle it became is drawn by `.canon-ring` in landing.html, with the
   act that owns it; nothing of the component is left to keep here. See
   changes_log.md 2026-09-13 (8) for what went, and docs.md's "The Canon"
   for what replaced it. */

.landing-chips { display: flex; flex-wrap: wrap; gap: var(--space-3); justify-content: center; margin-top: 2.5rem; }
.landing-chip {
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--ink-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: var(--space-2) var(--space-4);
  background: var(--surface-2);
}
.landing-chip-link {
  text-decoration: none;
  cursor: pointer;
  transition: color 0.15s ease, border-color 0.15s ease, background 0.15s ease;
}
.landing-chip-link:hover, .landing-chip-link:focus-visible {
  color: var(--ink);
  border-color: var(--accent-glow);
  background: var(--surface);
}

/* Roadmap section (added 2026-08-15, pre-launch) — "NOW" / "NEXT"
   two-column split, right after the chips above. `.landing-chip`'s
   pill shape is reused verbatim for each row; `.is-live`/
   `.is-secondary` are the only new modifiers, styled after the
   existing `.muscle-tag`/`.muscle-tag.is-secondary` accent-vs-dim
   pattern (search that class) rather than inventing a new one. */
/* Panel background + heading added 2026-08-16 (UX audit finding
   #4) — this section used to sit directly under .landing-chips
   with nothing but a margin between them, both built from the same
   .landing-chip component: 14 bordered pills in under 800px of
   scroll read as one repeated wall of tags, not two distinct
   ideas. Deliberately not a copy of .landing-manifesto's
   corner-bracket treatment two sections below — that specific
   motif already risks monotony that close to the real manifesto
   panel (flagged in this session's own manifesto design consult) —
   so this gets a plainer panel: same "distinct container" job, a
   different shape. */
.landing-roadmap {
  margin-top: 4.5rem;
  /* No longer its own fade-up (moved to the scroll-craft data-sc-in
     mechanism on landing.html's wrapper, 2026-08-29) — the two were
     fighting over opacity/transform on the same element. */
  background: var(--surface-2);
  border-radius: var(--radius-md);
  padding: var(--space-6) var(--space-5);
}
.landing-roadmap-title {
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--series-1);
  text-align: center;
  margin: 0;
}
.landing-roadmap-grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--space-6);
  margin-top: var(--space-4);
}
@media (max-width: 640px) {
  .landing-roadmap-grid { grid-template-columns: 1fr; }
}
.landing-roadmap-col { display: flex; flex-direction: column; gap: var(--space-2); }
.landing-roadmap-col .landing-chip { text-align: left; }
/* --good, not --series-1 (fixed 2026-08-16, UX audit finding #6) —
   --series-1 is an arbitrary chart-series color per theme, and on
   `paper` it resolves to a brick/rust red (#9a3324), so every
   "shipped" chip rendered in the one hue people reflexively read as
   stop/warning while the actually-secondary NEXT column sat in a
   calm neutral gray — the intended read was inverted on that theme
   specifically. --good is the dedicated status token, defined on
   every theme (paper's is #4d7c3a, a color that actually reads
   positive). */
.landing-chip.is-live {
  border-color: var(--good);
  color: var(--good);
  background: color-mix(in srgb, var(--good) 12%, transparent);
}
.landing-chip.is-secondary {
  border-style: dashed;
  border-color: var(--ink-muted);
  color: var(--ink-muted);
  background: var(--surface-2);
}

/* Landing-page preview modal (added 2026-08-10) — opens the real
   /preview/habits|calendar pages in an iframe, in-place over the
   landing page, instead of navigating away to them. `hidden` is the
   no-JS/pre-enhancement state; initLandingPreviewModal() below is
   what ever removes it, so with JS off the chip links (still real
   <a href> targets) just navigate normally instead. */
.preview-modal {
  position: fixed;
  inset: 0;
  z-index: 100;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-6);
}
.preview-modal[hidden] { display: none; }
.preview-modal-backdrop {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
}
.preview-modal-panel {
  position: relative;
  width: min(60rem, 100%);
  height: min(44rem, 100%);
  background: var(--page);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  overflow: hidden;
  box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.45);
}
.preview-modal-close {
  position: absolute;
  top: var(--space-2);
  right: var(--space-2);
  z-index: 1;
  appearance: none;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface-2);
  color: var(--ink);
  width: 2rem;
  height: 2rem;
  line-height: 1;
  font-size: var(--text-md);
  cursor: pointer;
}
.preview-modal-close:hover { background: var(--surface); }
.preview-modal-frame {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
}
@media (max-width: 640px) {
  .preview-modal { padding: 0; }
  .preview-modal-panel { width: 100%; height: 100%; border-radius: 0; }
}

/* Renamed from .landing-quote 2026-08-15 when a one-sentence pull-
   quote became the fuller Manifesto/Idea section — same panel,
   widened and left-aligned for a paragraph or more of copy instead
   of a single centered line. */
.landing-manifesto {
  position: relative;
  max-width: 54rem;
  margin: var(--space-5) auto 0;
  padding: var(--space-8);
  text-align: left;
  /* --surface-2, not --surface: on the light themes --surface is
     literally #ffffff (prism/frost/bloom) or near-white (paper), so
     the panel rendered as a hard white box on a tinted page.
     --surface-2 sits a step *away* from --page in both directions —
     a soft recessed tint on light themes, a raised panel on dark —
     so one token reads correctly across all twelve. */
  background: var(--surface-2);
}
.landing-manifesto::before, .landing-manifesto::after { content: ""; position: absolute; width: 14px; height: 14px; pointer-events: none; }
.landing-manifesto::before { top: 0; left: 0; border-top: 2px solid var(--accent-glow); border-left: 2px solid var(--accent-glow); }
.landing-manifesto::after { bottom: 0; right: 0; border-bottom: 2px solid var(--accent-glow); border-right: 2px solid var(--accent-glow); }
.landing-manifesto p {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--text-sm);
  font-weight: var(--weight-regular);
  line-height: 1.6;
  /* --ink-2, not --ink: --ink is the headline token, so this was
     rendering at exactly the weight of the "unfuckurlife" hero title
     and competing with it. A closing statement should sit below the
     page's loudest text, not level with it. */
  color: var(--ink-2);
}
.landing-manifesto p + p { margin-top: var(--space-4); }

/* margin-top: 3rem is the closing half of a top/bottom "bookend"
   pair with .landing-topbar's margin-bottom (1rem) — see that
   comment for the full rhythm. */
.landing-status {
  margin-top: 3rem;
  text-align: center;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-muted);
}

@keyframes fade-up {
  from { opacity: 0; transform: translateY(14px); }
  to   { opacity: 1; transform: translateY(0); }
}
/* Same shape as wave-bounce, taller resting floor (0.25 -> 0.6) —
   see .call-preview-wave span's own comment for why this needed to
   be a separate keyframe rather than tuning wave-bounce itself. */
@keyframes call-preview-wave-bounce {
  0%, 100% { transform: scaleY(0.6); }
  50%      { transform: scaleY(1); }
}
/* Reduced-motion overrides live LAST on purpose (moved 2026-07-29).
   They used to sit mid-stylesheet, above the rules they override —
   same specificity, so every later `animation:` declaration simply
   won and the whole landing page kept animating for users who asked
   it not to. Keep this block at the bottom.
   Landing page's own scroll-craft acts (hero, CTA, features, chips,
   roadmap, manifesto, closing CTA) handle reduced motion inside
   landing.html's extra_head block instead — that mechanism owns
   their opacity/transform, so overriding it here would just be a
   second, redundant author fighting the same properties. */
@media (prefers-reduced-motion: reduce) {
  .details-content { transition: none; }
  body::before { animation: none; }
  .btn:hover, .habit-card summary:hover, .btn-primary:hover, .btn-danger:hover { animation: none; }
  .completeness.active .completeness-dot { animation: none; }
  .status-live-dot { animation: none; }
  .live-rest-timer.is-past-target .live-rest-value { animation: none; }
  .briefing-chip:has(input:checked) { animation: none; }
}

/* ============================================================
   CALL SCREENS (added 2026-09-04)
   The morning/night calls, rendered as pages instead of read
   aloud — /calls/{day}/morning and /calls/{day}/night, plus the
   "Past calls" widget that lists them. See
   services/call_screen.py and docs.md's "Call screens" section.

   Every colour here is a token, so all fourteen themes get this
   for free; nothing below hardcodes a hex.
   ============================================================ */

/* Shared, used by the night screen's numeric habit inputs. There was
   no app-wide visually-hidden class before this (only one ad-hoc
   copy inside .chat-voice-field), so this is it. */
.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;
}

/* .call-screen-head and its kicker/today rules went with the shared
   sky-band header (2026-09-06) — the night screen was their last
   user; the morning had already moved. Deleted rather than left
   orphaned. */
/* A widget whose heading is redundant with the page it is on (the
   Calls widget, 2026-09-06): the row still exists to hold the reorder
   controls, it just has nothing to align against. */
.widget-header.is-untitled { justify-content: flex-end; min-height: 0; margin-bottom: var(--space-2); }
/* Browsing the archive meant bouncing back to /calls for every hop. */
/* margin-bottom added 2026-09-06: the nav used to sit inside
   .call-screen-head, which supplied the gap. On the morning screen it
   is now a sibling between the sky band and the deck grid, and with
   only a top margin it collided with the first instrument tile. */
/* Day navigation as a segmented chip pair (restyled 2026-09-06,
   direct report: the day nav and the screen switch read "out of
   harmony with the rest of the page"). They were bare links
   inheriting the global underlined anchor style, on screens where
   every other control is a bordered mono chip — .call-panel-open,
   .topic-run-item, .night-step-num, .day-mood-chip, .card-ord. Same
   vocabulary now: hairline border, mono, uppercase, no underline,
   hover moves the border to --accent-glow like every other control
   on the surface.

   The two halves share an edge (the second's left border is
   suppressed) so prev/next read as one instrument rather than two
   loose buttons, and the pair sits at the page's right edge where
   pagination is looked for. */
.call-screen-nav {
  display: flex;
  justify-content: flex-end;
  margin: var(--space-3) 0 var(--space-4);
}
.call-screen-nav > a, .call-screen-nav > span {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  min-height: 38px;
  padding: 0 var(--space-4);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--ink-2);
  text-decoration: none;
}
.call-screen-nav > * + * { border-left-color: transparent; margin-left: -1px; }
.call-screen-nav > a:hover { border-color: var(--accent-glow); color: var(--ink); z-index: 1; }
/* The dead end of the range — today, since a future call has no
   screen. Recessed rather than absent, so the pair keeps its shape
   and the edge of the archive is visible instead of implied. */
.call-screen-nav > span { color: var(--ink-muted); background: transparent; }
/* The arrows are inline glyphs inside their link, not segments of
   their own — without this they inherit the chip rules above and the
   pair renders as three boxes instead of two. */
.call-screen-nav a > span { all: unset; }

/* Inside the morning screen's sky band the pair sits on the header
   itself, which is where day navigation belongs and keeps it clear of
   the deck's right-hand rail. Transparent so it reads as part of the
   band rather than two panels floating on a gradient. */
.sky-band .call-screen-nav { margin: 0; }
.sky-band .call-screen-nav > a, .sky-band .call-screen-nav > span {
  background: color-mix(in srgb, var(--page) 55%, transparent);
  border-color: color-mix(in srgb, var(--ink) 22%, transparent);
  color: var(--ink);
}
.sky-band .call-screen-nav > span { color: var(--ink-2); background: transparent; }
/* The empty state names the exact fix and then didn't link it, in a
   bare sentence on an otherwise blank page — a new user's first
   morning (UX audit finding 11). */
.call-screen-empty-panel {
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  background: var(--surface);
  padding: var(--space-5);
  margin-bottom: var(--space-8);
  max-width: 30rem;
}
.call-screen-empty-panel p { color: var(--ink-2); margin-bottom: var(--space-4); }
/* "4 of 13 logged" — the night list never said how far along you were. */
.night-progress {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-2);
  margin-bottom: var(--space-3);
}
.night-progress b { color: var(--accent-glow); font-weight: var(--weight-medium); }

/* The player is the landing page's decorative waveform, which sat in
   a small card with copy around it there. Lifted to the top of a
   full-width screen it read as an animated divider with an orphan
   0:00 at the far right, and nothing said it was audio at all (UX
   audit 2026-09-04, finding 08). A label and a width make it a
   player again. */
.call-screen-audio { margin-bottom: var(--space-4); max-width: 30rem; }
.call-screen-audio-label {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-2);
  margin-bottom: var(--space-2);
}
/* The player's own rule adds a top border+padding meant for sitting
   under card text on /calls. At the top of a screen it's a stray
   line, so it's dropped here rather than changed at the source. */
.call-screen-audio .call-preview { margin-top: 0; padding-top: 0; border-top: none; }

/* Collapsed by design: on the morning screen the cards are the
   point and the prose is what the audio already said. */
.call-screen-text {
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: 0;
  margin-bottom: var(--space-6);
  background: var(--surface);
}
.call-screen-text[open] { padding-bottom: var(--space-3); }
.call-screen-text > summary {
  cursor: pointer;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-2);
  /* Finding 07: the padding used to sit on .call-screen-text, so the
     summary's own hit area measured 1306x17 inside a box that looks
     45px tall — clicking the bordered box did nothing. */
  padding: var(--space-3) var(--space-4);
}
.call-screen-text > summary:hover { color: var(--ink-2); }
.call-screen-text p { margin-top: var(--space-3); padding: 0 var(--space-4); color: var(--ink-2); }

/* The cards themselves. auto-fit rather than a fixed column count:
   the number of cards is the user's own pref list, so it changes per
   user and per day, and the grid has to hold one card and nine
   equally well. */
/* ================= morning deck (rebuilt 2026-09-06) =================
   Was one .call-cards grid of identically-weighted panels — weather, a
   philosophy quote and six paragraphs of AI news all in the same frame
   with the same 13px body, which is why the screen read as an
   undifferentiated news dashboard. Three weights now, routed off
   CallCard.weight (services/call_screen._GROUP_WEIGHTS). */

/* The sky band. One linear-gradient slice of the same sky --sun-ring
   draws, so it needs no new colour and follows all fourteen themes;
   the date and hour ride on it whether or not audio exists, which is
   the point — call_screen_audio() renders nothing without a clip, and
   on the common path a kicker was the screen's only claim to being a
   call at all. */
.sky-band {
  position: relative;
  margin: var(--space-5) 0 var(--space-4);
  padding: var(--space-6) var(--space-5) var(--space-4);
  overflow: hidden;
}
/* The two kinds run the same three tokens in opposite directions: the
   morning goes night -> dawn -> day, the night goes day -> dusk ->
   night. The two screens are the two ends of one day, which is the
   idea the whole surface is built on, and this is that idea in one
   gradient. No per-theme block — every theme ships all three. */
.sky-band.is-morning {
  background: linear-gradient(100deg,
              var(--gridline) 0%,
              var(--accent-glow) 46%,
              var(--accent-glow-2) 100%);
}
.sky-band.is-night {
  background: linear-gradient(100deg,
              var(--accent-glow-2) 0%,
              var(--accent-glow) 38%,
              var(--gridline) 88%);
}
/* The scrim is what makes the band readable on every theme without
   picking a text colour per theme: the type always sits on the dark
   end of a gradient it painted itself. */
.sky-band::after {
  content: "";
  position: absolute;
  inset: 0;
  background: linear-gradient(180deg,
              color-mix(in srgb, var(--page) 12%, transparent) 0%,
              color-mix(in srgb, var(--page) 72%, transparent) 58%,
              color-mix(in srgb, var(--page) 94%, transparent) 100%);
}
.sky-band-inner {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: var(--space-5);
  flex-wrap: wrap;
}
.sky-band h1 { font-size: var(--text-lg); margin: var(--space-1) 0 0; }
.sky-band-kicker {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-2);
  margin: 0;
}
.sky-band .call-screen-audio { margin-bottom: 0; }
.sky-band-controls { display: flex; flex-direction: column; align-items: flex-end; gap: var(--space-3); }
/* The player in the band (fixed 2026-09-06, direct report: "the audio
   wave thing is overlapping with the time of the audio").

   .call-preview-time is `position: absolute; right: 0` on purpose —
   pulling it out of flow is what lets .call-preview-wave be `width:
   100%` and centre its bars against the FULL card rather than against
   "card minus the readout", which is a deliberate fix from 2026-08-05.
   That reasoning holds for the ~30rem card it was written against. In
   the sky band the player is content-sized instead, so `width: 100%`
   is a narrow box, the centred bars reach the right edge, and they run
   straight under the absolutely-positioned readout.

   Scoped to the band rather than changed globally: the landing page's
   wide preview card still wants the centred-against-everything
   behaviour, and this is the context that breaks it. The time goes
   back into flow here, so the two can't occupy the same space at any
   width. */
.sky-band .call-preview-player { gap: var(--space-3); align-items: center; }
.sky-band .call-preview-wave { width: auto; flex: 1 1 auto; min-width: 8rem; }
.sky-band .call-preview-time { position: static; bottom: auto; flex: none; }
/* One row, not a stack: LISTEN | bars | 0:00, on the same translucent
   chip the day nav below it uses, so the band's two controls read as a
   set rather than as a floating label over a floating waveform. The
   min-width keeps the bars a waveform rather than a sliver now that
   the readout takes real width beside them. */
.sky-band .call-screen-audio {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  min-width: 15rem;
  padding: 0 var(--space-3);
  min-height: 38px;
  background: color-mix(in srgb, var(--page) 55%, transparent);
  border: 1px solid color-mix(in srgb, var(--ink) 22%, transparent);
  border-radius: var(--radius-sm);
}
.sky-band .call-screen-audio-label { margin: 0; flex: none; color: var(--ink); }
.sky-band .call-preview { flex: 1 1 auto; min-width: 0; margin: 0; }
.sky-band .call-preview-wave { height: 2rem; padding: 0.35rem 0; }
.sky-band .call-preview-time { color: var(--ink-2); }

/* Content and the user's own day are two kinds of thing; on desktop
   they sit side by side rather than stacked, so "your day" stops
   being the reward for scrolling past ten news cards. Single column
   below 60rem, rail last — which is the audio's own order. */
.morning-deck { display: grid; gap: var(--space-6); align-items: start; }
@media (min-width: 60rem) {
  .morning-deck { grid-template-columns: minmax(0, 1fr) 17rem; }
}
.morning-deck-main { min-width: 0; }

/* Environment: a temperature is a number. */
.instrument-strip {
  display: grid;
  gap: var(--space-2);
  grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
  margin-bottom: var(--space-4);
}
.instrument {
  position: relative;
  background: var(--surface);
  border: 1px solid var(--border);
  padding: var(--space-3) var(--space-4) var(--space-4);
}
.instrument-label {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin: 0 0 var(--space-2);
}
.instrument-label svg { width: 13px; height: 13px; flex: none; }
.instrument-body { font-size: var(--text-xs); color: var(--ink-2); line-height: 1.5; margin: 0; }

/* News and custom topics: the reading column, as before. */
.call-card {
  position: relative;
  background: var(--surface);
  border: 1px solid var(--border);
  padding: var(--space-4) var(--space-5);
  margin-bottom: var(--space-3);
}
.call-card-head { display: flex; align-items: center; gap: var(--space-3); }
.call-card-head h3 {
  font-size: var(--text-md);
  margin: 0;
  min-width: 0;
}
.call-card-head > svg { width: 17px; height: 17px; flex: none; color: var(--accent-glow); }
.call-card-group {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin-left: auto;
}
.call-card-body { color: var(--ink-2); font-size: var(--text-xs); line-height: 1.6; margin: var(--space-3) 0 0; }

/* Mindset / Fun: a rule, not a frame. An aphorism inside an
   instrument plate reads as a system message. */
.call-pullquote {
  position: relative;
  border-left: 2px solid var(--accent-glow);
  padding: var(--space-2) 0 var(--space-2) var(--space-5);
  margin: var(--space-5) 0;
}
.call-pullquote blockquote {
  font-family: var(--font-display);
  font-size: var(--text-md);
  line-height: 1.5;
  color: var(--ink);
  margin: 0;
  max-width: 46ch;
}
.call-pullquote figcaption {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin-top: var(--space-2);
}

/* The running order, carried onto the screen it orders. The ladder on
   /calls had no expression here at all before; seeing "7" on Markets
   at the top of the strip is also what tells you the ladder is worth
   reordering. */
.card-ord {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  color: var(--ink-muted);
  border: 1px solid var(--gridline);
  border-radius: var(--radius-sm);
  width: 1.25rem;
  height: 1.25rem;
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
}
.instrument .card-ord { position: absolute; top: var(--space-3); right: var(--space-3); }
.call-pullquote .card-ord {
  position: absolute;
  left: calc(var(--space-5) * -0.5 - 0.625rem);
  top: var(--space-2);
  background: var(--page);
}

/* The rail: the same three lists, beside the content instead of under
   it. Reuses .call-day-list/.call-day-mark below unchanged. */
.morning-rail { min-width: 0; }
.rail-group + .rail-group { margin-top: var(--space-5); }
.rail-group-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin: 0 0 var(--space-2);
  padding-bottom: var(--space-2);
  border-bottom: 1px solid var(--gridline);
}
.rail-group-head span { color: var(--ink-2); letter-spacing: 0.08em; }
.call-screen-empty { margin-bottom: var(--space-8); }

/* The personal half of the morning screen, and the read-only lists
   on the night one. */
/* .call-card and .call-day-block share surface/border/radius/padding,
   so "the world's news" and "your habits" carried identical weight
   and the heading had to do the separating on its own — at
   --text-md it was barely above body text. */
.call-day h2, .call-night-block h2 { font-size: var(--text-lg); margin-bottom: var(--space-4); }
.call-day-grid {
  display: grid;
  gap: var(--space-4);
  grid-template-columns: repeat(auto-fit, minmax(14rem, 1fr));
  /* Thirteen habits were forcing Routine and Tasks to match their
     height, so "No tasks scheduled today." sat in 400px of box. */
  align-items: start;
}
.call-day-block {
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  padding: var(--space-4);
}
.call-day-block h3 {
  font-size: var(--text-2xs);
  font-family: var(--font-mono);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin-bottom: var(--space-3);
}
.call-day-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-2); }
.call-day-list li { display: flex; align-items: baseline; gap: var(--space-2); font-size: var(--text-xs); }
/* Carries done-state, so it is information, not decoration. */
.call-day-mark { color: var(--ink-2); font-family: var(--font-mono); }
.call-day-list li.is-done .call-day-mark { color: var(--good); }
/* Struck through, not hidden: what you finished is part of the day's
   picture, same as on /home's Today list. */
.call-day-list li.is-done .call-day-name { color: var(--ink-muted); text-decoration: line-through; }
.call-day-name { flex: 1; color: var(--ink-2); }
.call-day-time, .call-day-meta {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  color: var(--ink-2);
}

/* The switch to this day's other call (restyled 2026-09-06, same
   report as .call-screen-nav above). This is the one link that moves
   between the two halves of a single day, so it borrows /calls' own
   two-call language rather than being a text link: the leading edge
   and the dot take the accent of the screen you are going TO —
   --accent-glow for the morning, --accent-glow-2 for the night —
   which is exactly how .dial-key and .call-panel mark the same two
   things on the hub. Arriving somewhere you recognise from the dial
   is the point.

   .call-screen-enter was deleted here: the /calls widget links it
   styled were removed in the 2026-09-06 rebuild, so it was dead. */
.call-screen-switch { margin: var(--space-8) 0 var(--space-6); }
/* inline-flex, not flex: sized to its label rather than run to the
   full 1,340px measure with the arrow stranded at the far edge —
   the same dead-width problem the night screen's habit rows had. */
.call-screen-switch a {
  display: inline-flex;
  align-items: center;
  gap: var(--space-3);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-2);
  text-decoration: none;
  background: var(--surface);
  border: 1px solid var(--border);
  border-left: 2px solid var(--switch-accent, var(--accent-glow));
  border-radius: var(--radius-sm);
  padding: var(--space-4) var(--space-5);
}
.call-screen-switch a::before {
  content: "";
  width: 8px;
  height: 8px;
  border-radius: 50%;
  flex: none;
  background: var(--switch-accent, var(--accent-glow));
}
.call-screen-switch a:hover { background: var(--surface-2); color: var(--ink); }
/* The arrow rides the right edge, so the row reads label-then-motion
   rather than the two colliding mid-line. */
.call-screen-switch a .switch-arrow { color: var(--switch-accent, var(--accent-glow)); }
.call-screen-switch.to-night a { --switch-accent: var(--accent-glow-2); }
.call-screen-switch.to-morning a { --switch-accent: var(--accent-glow); }

/* ---- night screen: the inputs ---- */
.call-night-block { margin-bottom: var(--space-8); }
/* The one block on the night screen that is prose rather than a
   form (added 2026-09-05 with the Daily Report move off /calls).
   Capped like .night-habit-list is: a resume paragraph run to a
   1,340px measure is unreadable, and the widget it came from was
   never full-bleed either — it sat in an auto-fit 20rem grid. */
.call-night-report .call-day-block { max-width: 46rem; }
/* The week the rollup covers, under the Weekly Report heading
   (2026-09-05). Mono + muted, the same treatment .call-day-time and
   the other date/metadata strings on this page already get, so it
   reads as the block's date stamp rather than as its first line of
   prose. */
.call-week-range {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.08em;
  color: var(--ink-muted);
  margin: calc(var(--space-4) * -1) 0 var(--space-4);
}
.night-subhead {
  font-size: var(--text-2xs);
  font-family: var(--font-mono);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin: var(--space-5) 0 var(--space-3);
}
/* ---- night screen: the ritual's step markers (added 2026-09-06) ----
   log -> rate -> plan -> read is a sequence with a real order, and it
   used to render as four <h2>s at one size in six identical
   .call-night-blocks. The index chip is .rail-index-num's treatment
   from the Loadout Rail (search: rail-index-num) rather than a new
   one. See _macros.html's night_step(). */
.night-step {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin: 0 0 var(--space-3);
}
.night-step-num {
  flex: 0 0 auto;
  width: 1.7rem;
  height: 1.7rem;
  display: flex;
  align-items: center;
  justify-content: center;
  font-family: var(--font-mono);
  font-size: var(--text-xs);
  color: var(--accent-glow);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
}
.night-step-title { font-size: var(--text-lg); margin: 0; }
/* The rule runs from the title to whatever readout sits on the right,
   so the two ends of the row are visibly one object. */
.night-step::after {
  content: "";
  flex: 1;
  height: 1px;
  background: var(--gridline);
}
.night-step-meta {
  flex: 0 0 auto;
  order: 3;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-2);
}
/* Step 4 is the one step the user doesn't perform — the report is
   written for them — so it shouldn't look like something waiting to
   be answered. */
.night-step.is-read-only .night-step-num {
  border-style: dashed;
  color: var(--ink-muted);
  background: transparent;
}

/* ---- night screen: the completion grid (rebuilt 2026-09-06) ----
   Was one 666px row per habit, name pinned left and a 95px button
   pinned right, with the right half of the 1,340px container empty
   top to bottom — you had to read nine rows to learn how the day
   went. The whole cell is the control now, which is the same
   whole-box-clickable idea .briefing-chip already uses.

   The 2026-09-04 finding still holds and is deliberately preserved:
   done RECEDES. A logged habit fills quietly; an unlogged one keeps
   the brighter surface and the live border, so the things still
   asking to be tapped stay loudest. The at-a-glance "seven of nine"
   read comes from fill-vs-hairline, not from making finished habits
   shout.

   What changed is HOW done recedes. It used to be `opacity: 0.72` on
   the row, which is also what put .night-habit-btn.done at 2.94:1 in
   frost and 3.46:1 in paper — the single most important state on the
   screen was its least legible thing on the five light themes. An
   opacity multiplies against whatever is behind it and can't be
   reasoned about per theme; explicit --ink-2-on---surface-2 can, and
   clears 4.5:1 everywhere. */
.night-habit-grid {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  gap: var(--space-2);
  grid-template-columns: repeat(auto-fit, minmax(13rem, 1fr));
}
.night-cell { display: flex; }
.night-cell > form { flex: 1; display: flex; min-width: 0; }
.night-cell-btn {
  flex: 1;
  min-height: 4.4rem;
  display: grid;
  grid-template-columns: 1.1rem minmax(0, 1fr);
  grid-template-rows: auto auto;
  align-content: center;
  gap: 2px var(--space-2);
  text-align: left;
  padding: var(--space-3);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  color: var(--ink);
  cursor: pointer;
  transition: background 120ms ease, border-color 120ms ease;
}
.night-cell-btn:hover { border-color: var(--accent-glow); background: var(--surface-2); }
.night-cell-mark {
  grid-row: 1 / 3;
  align-self: center;
  font-family: var(--font-mono);
  font-size: var(--text-sm);
  color: var(--good);
}
.night-cell-name { font-family: var(--font-display); font-size: var(--text-sm); color: var(--ink); }
.night-cell-meta { font-family: var(--font-mono); font-size: var(--text-2xs); color: var(--ink-muted); }
.night-cell.is-done .night-cell-btn { background: var(--surface-2); border-color: var(--gridline); }
.night-cell.is-done .night-cell-name { color: var(--ink-2); }
.night-cell.is-done .night-cell-meta { color: var(--ink-2); }

/* A numeric habit keeps its input — there's a value to type, not a
   state to toggle — so the cell is a field rather than a button, on
   the same frame so the grid stays one object. */
.night-cell-numeric {
  flex: 1;
  min-height: 4.4rem;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--space-2);
  padding: var(--space-3);
  background: var(--surface);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
}
.night-cell.is-done .night-cell-numeric { background: var(--surface-2); border-color: var(--gridline); }
.night-cell-fields { display: flex; gap: var(--space-2); }
.night-cell-fields input { flex: 1; min-width: 0; }
.night-cell-log {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  padding: 0 var(--space-3);
  min-height: 44px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--ink-2);
  cursor: pointer;
}
.night-cell-log:hover { background: var(--surface-2); border-color: var(--accent-glow); color: var(--ink); }

/* ---- night screen: how was today ---- */
/* .day-rating-scale and .day-rating-ends were both 1,340px while the
   dots inside clamped to 4.5rem each, so the five dots clumped in the
   left quarter and "Rough"/"Great" sat 1,340px apart, anchoring
   nothing (UX audit 2026-09-04, finding 03). Constraining the form
   itself lets the dots keep flex:1 and the labels land on the ends of
   the thing they actually label. Invisible on mobile, where the cap
   never bound — which is why it shipped. */
/* Sized to the surface, not to its container (2026-09-06, direct
   report: "there are some buttons that don't feel right"). They were
   `flex: 1; aspect-ratio: 1/1` inside a 26rem form, which made five
   88px display-font squares — by far the heaviest objects on a screen
   otherwise built from small mono chips, and reading as a different
   design language. Fixed 3.25rem now: still well over the 44px touch
   floor, mono and tabular like every other number on the surface, and
   the row is sized by its content so it no longer stretches. */
.day-rating-form { margin-bottom: var(--space-5); max-width: 26rem; }
.day-rating-scale { display: flex; gap: var(--space-2); }
.day-rating-dot {
  flex: 0 0 auto;
  width: 3.25rem;
  height: 3.25rem;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--ink-2);
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: var(--text-sm);
  cursor: pointer;
  transition: border-color 120ms ease, color 120ms ease, background 120ms ease;
}
.day-rating-dot:hover { border-color: var(--accent-glow); color: var(--ink); }
/* Five identical grey boxes is a clinical way to ask how someone's
   day went (UX audit 2026-09-04, finding 12). Every theme carries
   --danger alongside --good, so the scale ramps across them: the
   picked value takes its colour from its own position, and the
   archive's score badge uses the same ramp so a row of 1s and a row
   of 5s actually read as different at a glance — which is what the
   widget's "mood strip" comment always claimed. */
.day-rating-dot.is-picked { background: var(--surface-2); }
.day-rating-dot[data-score="1"].is-picked { border-color: var(--danger); color: var(--danger); }
.day-rating-dot[data-score="2"].is-picked { border-color: var(--danger); color: var(--danger); opacity: 0.85; }
.day-rating-dot[data-score="3"].is-picked { border-color: var(--accent-glow); color: var(--accent-glow); }
.day-rating-dot[data-score="4"].is-picked { border-color: var(--good); color: var(--good); opacity: 0.85; }
.day-rating-dot[data-score="5"].is-picked { border-color: var(--good); color: var(--good); }
.call-history-score[data-score="1"], .call-history-score[data-score="2"] { color: var(--danger); }
.call-history-score[data-score="3"] { color: var(--accent-glow); }
.call-history-score[data-score="4"], .call-history-score[data-score="5"] { color: var(--good); }
.day-rating-ends {
  display: flex;
  justify-content: space-between;
  max-width: 18.25rem;  /* five 3.25rem dots + four 0.5rem gaps */
  margin-top: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  color: var(--ink-2);
}
/* The scale and its own series, side by side (added 2026-09-06).
   Answering 1-5 every night and being shown nothing back was the
   biggest "why bother" gap on the surface — the scale is a series
   you're extending, so the series belongs next to it, not on another
   page. Wraps under on narrow viewports; the form keeps its 26rem
   cap either way. */
.rating-row {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: var(--space-5) var(--space-8);
  margin-bottom: var(--space-5);
}
.rating-row .day-rating-form { margin-bottom: 0; flex: 0 1 26rem; }
.rating-spark { margin: 0; flex: 0 0 auto; }
.rating-spark-cap {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin-bottom: var(--space-2);
}
.rating-spark-cap b { color: var(--ink); font-weight: var(--weight-medium); margin-left: var(--space-2); }
.rating-spark-bars { display: flex; align-items: flex-end; gap: 3px; height: 90px; }
/* Bar colour comes off the SAME ramp .day-rating-dot[data-score] uses
   below, so a 2 here and a picked 2 above are one colour by
   construction rather than by two lists that can drift. A night with
   no rating keeps its slot as a --gridline stub: the gaps are
   information, which is why day_rating_trail() returns a dense
   series instead of only the days that exist. */
.rating-spark-bar {
  width: 7px;
  display: block;
  background: var(--gridline);
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
}
.rating-spark-bar[data-score="1"], .rating-spark-bar[data-score="2"] { background: var(--danger); }
.rating-spark-bar[data-score="3"] { background: var(--accent-glow); }
.rating-spark-bar[data-score="4"], .rating-spark-bar[data-score="5"] { background: var(--good); }
.rating-spark-bar.is-today { outline: 1px solid var(--ink-2); outline-offset: 1px; }

.day-mood-label { font-size: var(--text-xs); color: var(--ink-2); margin-bottom: var(--space-3); }
/* Eight neutral chips in one wrap is a wall you read left to right;
   split by tone you find yours by direction first (2026-09-06). Same
   eight Mood entries, same keys, same single POST — the grouping is
   Mood.tone, which is presentation only and never stored. */
.day-mood-row { display: flex; align-items: center; gap: var(--space-3); margin-bottom: var(--space-2); }
.day-mood-tone {
  flex: 0 0 3rem;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.day-mood-chips { display: flex; flex-wrap: wrap; gap: var(--space-2); }
.day-mood-chip {
  padding: var(--space-2) var(--space-3);
  min-height: 44px;  /* was 61x35 — UX audit finding 10 */
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  background: var(--surface);
  color: var(--ink-2);
  font-size: var(--text-xs);
  cursor: pointer;
}
.day-mood-chip:hover { border-color: var(--accent-glow); color: var(--ink); }
/* A tint, not a colour: enough to tell the two rows apart at a
   glance without turning eight neutral options into a judgement. */
.day-mood-chip.is-up { border-color: color-mix(in srgb, var(--good) 30%, var(--border)); }
.day-mood-chip.is-down { border-color: color-mix(in srgb, var(--series-8) 26%, var(--border)); }

/* ---- night screen: Tomorrow, the one warm block (added 2026-09-06)
   Every other surface on a night screen reads in the theme's ordinary
   palette. This one takes --accent-glow as a wash and a left rule,
   because tomorrow is a sunrise and this is the only forward-looking
   section on a backward-looking page. It happens exactly once, which
   is what keeps it reading as an accent rather than as a theme bug —
   and it's the theme's own accent, so all fourteen follow for free. */
.call-night-tomorrow .night-step-num {
  color: var(--accent-glow);
  border-color: color-mix(in srgb, var(--accent-glow) 45%, var(--border));
  background: color-mix(in srgb, var(--accent-glow) 8%, var(--surface));
}
.tomorrow-panel {
  background: linear-gradient(180deg,
              color-mix(in srgb, var(--accent-glow) 9%, var(--surface)),
              color-mix(in srgb, var(--accent-glow) 2%, var(--surface)));
  border: 1px solid color-mix(in srgb, var(--accent-glow) 28%, var(--border));
  border-left: 2px solid var(--accent-glow);
  padding: var(--space-4) var(--space-5);
}
.tomorrow-panel .call-day-time { color: color-mix(in srgb, var(--accent-glow) 70%, var(--ink-muted)); }
.tomorrow-panel .night-task-form { margin-top: var(--space-4); }
.day-mood-chip.is-picked { border-color: var(--accent-glow); color: var(--accent-glow); background: var(--surface-2); }
/* Clearing an answer is a footnote to the rows above it, not a fifth
   option — it used to sit alone in a full-size chip under a gap,
   reading as a button of equal weight to the moods themselves. */
.day-mood-chip.is-clear {
  color: var(--ink-muted);
  border-color: transparent;
  background: transparent;
  min-height: 0;
  padding: var(--space-1) 0;
  text-decoration: underline;
  text-underline-offset: 3px;
}
.day-mood-chip.is-clear:hover { color: var(--ink-2); }
.day-mood-clear { margin-top: var(--space-2); margin-left: calc(3rem + var(--space-3)); }

.night-task-form { margin-top: var(--space-4); }
.night-task-form label {
  display: block;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-muted);
  margin-bottom: var(--space-2);
}
.night-task-fields { display: flex; gap: var(--space-2); flex-wrap: wrap; }
.night-task-fields input[type="text"] { flex: 1; min-width: 12rem; }
/* Was .btn-primary — a filled global button among mono chips, and the
   loudest thing in the Tomorrow panel. Same treatment as
   .night-cell-log, in the warm accent the panel already carries. */
.night-task-fields .night-task-add {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  padding: 0 var(--space-4);
  min-height: 44px;
  border: 1px solid color-mix(in srgb, var(--accent-glow) 45%, var(--border));
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--accent-glow) 10%, transparent);
  color: var(--accent-glow);
  cursor: pointer;
}
.night-task-fields .night-task-add:hover {
  background: color-mix(in srgb, var(--accent-glow) 18%, transparent);
}

/* ---- /calls: the Past calls widget ---- */
.call-history-list { list-style: none; margin: 0; padding: 0; display: grid; gap: var(--space-1); }
.call-history-row {
  display: flex;
  align-items: center;
  gap: var(--space-4);
  padding: var(--space-3) 0;
  border-bottom: 1px solid var(--border);
}
.call-history-row:last-child { border-bottom: none; }
.call-history-date {
  display: flex;
  flex-direction: column;
  min-width: 5rem;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  color: var(--ink-2);
}
.call-history-date strong { font-size: var(--text-xs); color: var(--ink-2); font-weight: var(--weight-medium); }
.call-history-links { display: flex; gap: var(--space-2); flex: 1; flex-wrap: wrap; }
.call-history-link {
  display: inline-flex;
  align-items: center;
  gap: var(--space-1);
  padding: var(--space-2) var(--space-3);
  /* The only route into either call screen, and it was the smallest
     control in the feature at 81x24 — UX audit finding 10. */
  min-height: 44px;
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.call-history-link:hover { border-color: var(--accent-glow); }
/* A call that never ran is shown, not hidden — an absent Monday
   morning is information. It just isn't a link. The first cut said
   that with opacity: 0.4, which computed to ~1.4:1 in every theme
   and deleted the information instead of muting it (UX audit
   2026-09-04, finding 05). Absence is now carried by SHAPE — no
   border, no pill — so it reads as nothing-there rather than as a
   broken button. */
.call-history-link.is-empty {
  color: var(--ink-muted);
  border-color: transparent;
  padding-left: 0;
  padding-right: 0;
}
.call-history-count { color: var(--accent-glow); }
.call-history-rating { display: flex; align-items: center; gap: var(--space-2); }
.call-history-score {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  color: var(--ink-2);
  border: 1px solid var(--border);
  border-radius: var(--radius-sm);
  padding: 1px var(--space-2);
}
.call-history-mood { font-size: var(--text-2xs); color: var(--ink-muted); }

.account-check { display: flex; align-items: center; gap: var(--space-2); margin-top: var(--space-4); }
.account-check input { width: auto; }

@media (max-width: 640px) {
  .night-habit { flex-wrap: wrap; }
  .call-history-row { flex-wrap: wrap; gap: var(--space-2); }
}

/* ------------------------------------------------------------------
   RANKED HABITS — tier ladder, ranked card, habit page
   (added 2026-09-09, direct request: "I want to give a different
   look to the ranked habits on the habit page. I want them to be
   important … later on, we have gamifications and ranks, actual
   tiers").

   The ladder's five rungs are defined ONCE, here, in terms of the
   theme's own tokens rather than as hex values — so the ramp
   re-reads correctly in all fourteen themes and no theme block
   needs a tier entry of its own. Cold-to-hot, which is what makes
   it uful's ladder rather than a generic bronze/silver/gold: rank
   is earned brightness. services/habit_tiers.py owns which rung a
   habit is on; it never knows a colour.
   ------------------------------------------------------------------ */
:root {
  --tier-1: var(--ink-muted);
  --tier-2: color-mix(in srgb, var(--accent-glow) 55%, var(--ink-muted));
  --tier-3: var(--accent-glow);
  --tier-4: var(--accent-glow-2);
  --tier-5: var(--ink);
}

/* The plate. A ranked card keeps the instrument-plate geometry every
   other card has and spends the difference on the tier: a frame in
   the tier's colour instead of the generic hairline, and the corner
   brackets back. Those brackets are switched off for cards inside
   `.habit-cards` (2026-09-02, "remove those color edges from the
   habits") precisely because a grid of per-habit colours read as
   mismatched corners — a tier colour is a *shared* scale, so the
   same mark reads as a grade here rather than as noise. */
.habit-card-ranked {
  border-color: color-mix(in srgb, var(--tier-color) 55%, transparent);
  background: linear-gradient(180deg, color-mix(in srgb, var(--tier-color) 7%, var(--surface)), var(--surface) 60%);
  /* A column, so the stretched height has somewhere deliberate to go
     (2026-09-14). Without this the plate simply grew a dead gap under
     its progress line; with it the line is pushed to the foot of the
     card and the rails of a whole row land on one baseline. The three
     children are the eyebrow, the header and the rail block — the popup
     draws itself `position: fixed` at z-index 60/61 and is not in flow. */
  display: flex;
  flex-direction: column;
}
/* ...but NOT Home's condensed rows, which also carry .habit-card-ranked
   (2026-09-14). `.home-habit-row` lays them out as a centred single line
   and is declared ~3000 lines earlier at the same one-class specificity,
   so the column rule above simply won the cascade: every ranked row on
   /home stacked its monogram, name, reading and button into a ~340px
   tall centred block while the user's own habits stayed one tight line.
   The stretch this column exists to absorb only happens in /habits'
   equal-height card grid; a Home row has no rail block and no slack. */
.habit-card-ranked.home-habit-row {
  flex-direction: row;
}
/* `padding-top` as well as the auto margin: `margin-top: auto` REPLACES
   .tier-rail-block's own `margin-top: var(--space-1)`, and resolves to
   zero wherever the card has no slack to push into — a single-column
   phone grid, where cards do not stretch. That left the rail drawn hard
   against the claim's bottom edge, reading as an underline on the
   sentence. The padding keeps the 4px whatever the margin resolves to. */
.habit-card-ranked > .tier-rail-block { margin-top: auto; padding-top: var(--space-1); }
.habit-cards > .habit-card-ranked::before,
.habit-cards > .habit-card-ranked::after {
  content: "";
  width: 10px;
  height: 10px;
}
.habit-cards > .habit-card-ranked::before {
  top: -1px; left: -1px;
  border-top: 2px solid var(--tier-color);
  border-left: 2px solid var(--tier-color);
}
.habit-cards > .habit-card-ranked::after {
  bottom: -1px; right: -1px;
  border-bottom: 2px solid var(--tier-color);
  border-right: 2px solid var(--tier-color);
}

/* Stretched link: the whole plate opens the habit's page. It sits
   BELOW the card's own controls in the stacking order (z-index 1 vs
   2) rather than above them, so the check button and the reorder
   arrows keep receiving their own clicks.

   The three raised wrappers are `pointer-events: none` and only the
   actual controls inside them take events back (2026-09-10, direct
   report: "the area where I can click on the habit feels a little
   strange"). Raising them without that is what made the whole
   INTERIOR of the plate dead — a wrapper sitting above the link
   swallows the click across its full width, not just where its
   controls are, so eyebrow, header band, square, badge, name and
   week line all did nothing and only the card's ~8px padding frame
   still opened the habit. Hit-tested point-by-point, not eyeballed:
   the frame was the only live region on the card.

   Cost of the fix, accepted: `pointer-events: none` also kills the
   `title` tooltips on .tier-monogram and .tier-rail-trend. The
   monogram's is redundant with the eyebrow text beside it
   ("TIER III · BUILDING"), and the trend's explanation lives on the
   habit's own page as a labelled stat — which is now one click away
   from anywhere on the plate. A dead 40px chip in the middle of the
   rail line would have reintroduced exactly the patchiness being
   fixed. Screen readers keep both: the sr-only spans stay. */
.habit-card-ranked { position: relative; }
.habit-card-enter { position: absolute; inset: 0; z-index: 1; }
.habit-card-ranked > .tier-eyebrow,
.habit-card-ranked > header,
.habit-card-ranked > .tier-rail-block {
  position: relative;
  z-index: 2;
  pointer-events: none;
}
/* The controls, and only the controls, take their clicks back. A new
   interactive element added to a ranked card's header needs a line
   here or it will be unclickable. */
.habit-card-ranked > header .quick-action,
.habit-card-ranked > header .reorder-controls { pointer-events: auto; }
/* The kebab used to sit in the plate's top-right corner and needed
   both a z-index of its own and a gutter kept clear in the eyebrow;
   both rules went with it (2026-09-09) — nothing of the popup is
   drawn on a ranked plate any more (its backdrop and panel are
   `position: fixed` at z-index 60/61, well clear of the stretched
   link), so the eyebrow runs the full width. */
/* With the emblem gone (2026-09-09, "the hexagon is kind of ugly.
   Do more like A1"), the monogram is the header's only mark again.
   On a ranked plate it takes the tier's colour rather than the
   habit's, so the card still reads as one graded object instead of
   a tier-framed plate with an unrelated coloured box in it — the
   habit's own colour keeps the check button and the page's heatmap. */
.habit-card-ranked .monogram,
.habit-page-ranked .monogram {
  border-color: var(--tier-color);
  color: var(--tier-color);
}
/* A roman numeral is not a letter: it needs the display face's
   proportions (the mono face sets "III" nearly twice the width of
   "I", so a grid of ranked squares would have visibly different
   optical weights) and a touch of tracking to stop "II"/"III"
   reading as one blurred glyph. */
.tier-monogram {
  font-family: var(--font-display);
  font-size: 0.95rem;
  letter-spacing: 0.06em;
}
.habit-card-ranked:hover {
  border-color: color-mix(in srgb, var(--tier-color) 85%, transparent);
}
.habit-card-enter:focus-visible {
  outline: 2px solid var(--tier-color);
  outline-offset: 2px;
}

.tier-eyebrow {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-mono);
  font-size: 0.58rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--tier-color);
  margin-bottom: var(--space-2);
}
/* The rule runs to the card's edge and stops short of the kebab. */
.tier-eyebrow::after {
  content: "";
  flex: 1;
  height: 1px;
  background: color-mix(in srgb, var(--tier-color) 35%, transparent);
}

/* The one line the ranked card carries in place of its heatmap. */
.tier-rail-block {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  margin-top: var(--space-1);
}
.tier-rail {
  position: relative;
  height: 3px;
  background: var(--gridline);
}
.tier-rail > span {
  position: absolute;
  top: 0; bottom: 0; left: 0;
  background: var(--tier-color);
}
.tier-rail-note {
  display: flex;
  justify-content: space-between;
  gap: var(--space-2);
  font-family: var(--font-mono);
  font-size: 0.58rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.tier-rail-enter { color: var(--tier-color); }
/* Four-week direction chip (added 2026-09-09, Phase 6.0). The note
   is space-between, so `margin-right:auto` keeps the chip beside
   the week count instead of letting three children spread across
   the row — and still parks "enter" on the right when it's there.
   A fall is drawn in the same muted ink as the rest of the note,
   deliberately not in an alert colour: the card reports a
   direction, it never scolds. */
.tier-rail-trend { margin-right: auto; letter-spacing: 0.04em; }
.tier-rail-trend.is-up { color: var(--tier-color); }
.tier-rail-trend.is-down { color: var(--ink-muted); opacity: 0.75; }

/* ---- the habit's own page (GET /habits/{id}) --------------------- */
/* No max-width of its own (2026-09-10, direct report: "make it the same
   width as the other pages of the app, like the habits page... Right now,
   the page width shrinks"). It used to cap at 46rem and centre itself,
   which inside body's 1032px step drew a 736px column that visibly
   narrowed when you entered a habit from /habits. body's own ladder
   (692 / 1032 / 1372, each one a whole number of 20rem habit-card tracks
   — see the note on those breakpoints) is what every other page uses, so
   the page now lands on the same two vertical lines as the cards it came
   from. */
.habit-page {
  display: flex;
  flex-direction: column;
  gap: var(--space-5);
}
.habit-page-back {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--ink-muted);
  text-decoration: none;
}
.habit-page-back:hover { color: var(--ink-2); }
.habit-page-head {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-3) var(--space-4);
}
.habit-page-head .monogram { width: 3rem; height: 3rem; font-size: 1.2rem; }
/* The page's square is 3rem to the card's 2.4rem, so the icon needs
   its own step up too — same glyph-to-box ratio as .monogram.has-icon,
   which this rule would otherwise out-order (equal specificity, later
   in the sheet) and leave the mark floating small in a bigger box. */
.habit-page-head .monogram.has-icon { font-size: 1.6rem; }
.habit-page-title { flex: 1; min-width: 12rem; display: flex; flex-direction: column; gap: var(--space-1); }
.habit-page-eyebrow {
  font-family: var(--font-mono);
  font-size: 0.58rem;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--tier-color, var(--accent-glow));
}
.habit-page-title h1 {
  margin: 0;
  font-family: var(--font-display);
  font-size: var(--text-lg);
  font-weight: var(--weight-semibold);
  color: var(--ink);
}
.habit-page-sub {
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.habit-page-actions { display: flex; align-items: center; gap: var(--space-3); }
.habit-page-section {
  display: flex;
  flex-direction: column;
  gap: var(--space-3);
  padding-top: var(--space-4);
  border-top: 1px solid var(--gridline);
}
.habit-page-section-title {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  margin: 0;
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  font-weight: var(--weight-semibold);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.habit-page-count { color: var(--ink-2); }
/* Specs are label/value pairs, not cards: a border or fill on each
   would read as nine separate objects when they're one block of
   facts about a single habit. Since 2026-09-10 that block has an
   outline of its own and hairline dividers between the pairs —
   still ONE object, now a legible one, instead of columns of text
   drifting in the page's whitespace. */
.spec-grid {
  display: flex;
  flex-wrap: wrap;
  border: 1px solid var(--gridline);
  /* The dividers are drawn as a gap the container's background shows
     through, so no cell needs to know whether it ends a row. Which
     makes growing cells load-bearing, not cosmetic: a short last row
     under `grid` left a cell-shaped patch of divider colour sitting in
     the strip like a missing tooth. */
  gap: 1px;
  background: var(--gridline);
}
.spec {
  display: flex;
  flex-direction: column;
  gap: 2px;
  flex: 1 1 8rem;
  min-width: 0;
  background: var(--surface);
  padding: var(--space-3) var(--space-4);
}
.spec-wide { flex-basis: 100%; }
.spec-label {
  font-family: var(--font-mono);
  font-size: 0.58rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.spec-value {
  font-family: var(--font-display);
  font-size: var(--text-sm);
  font-weight: var(--weight-semibold);
  color: var(--ink);
  overflow-wrap: anywhere;
}
/* Four fixed tracks, and the lead figure spans two — which is what makes
   a yes/no habit's seven figures fill two rows exactly, the orphaned
   "20 misses · 30d" on a line of its own being the thing this section
   was rebuilt to fix (2026-09-10). Auto-fit was tried first and is the
   wrong tool twice over: it picks its own column count, so the span-2
   lead can't be made to land, and growing tiles turned a short last row
   into two enormous ones. A numeric habit's ten figures leave one track
   empty at the end — a gap, not a stranded tile, since each tile draws
   its own border and the row simply stops. */
.stat-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: var(--space-2);
}
.stat {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
  border: 1px solid var(--gridline);
  padding: var(--space-3) var(--space-4);
}
/* The lead figure takes two tracks — see habit_detail.html. */
.stat-lead {
  grid-column: span 2;
  border-color: color-mix(in srgb, var(--tier-color, var(--accent-glow)) 45%, transparent);
  background: linear-gradient(180deg, color-mix(in srgb, var(--tier-color, var(--accent-glow)) 7%, transparent), transparent 70%);
}
.stat-value {
  font-family: var(--font-display);
  font-size: 1.5rem;
  font-weight: var(--weight-semibold);
  line-height: 1;
  color: var(--ink);
  font-variant-numeric: tabular-nums;
}
/* The denominator in a "23/28" stat (added 2026-09-09, Phase 6.0) —
   same tile, quieter ink, so the hit count still reads as the
   figure and the window it's measured over reads as context. */
.stat-of {
  font-size: 0.9rem;
  font-weight: var(--weight-regular, 400);
  color: var(--ink-muted);
}
.stat-label {
  font-family: var(--font-mono);
  font-size: 0.58rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
/* Left-aligned, not centred (2026-09-10, with the width cap gone): a
   ~200px grid centred in a 1340px column is adrift again, the exact
   thing framing it was meant to fix. On the page's own left edge it
   lines up with every section heading above and below it. */
.habit-page-chart { display: flex; flex-direction: column; align-items: flex-start; gap: var(--space-3); }
/* The frame is drawn around the chart, not around the column: a 13-week
   heatmap is ~200px wide inside a 46rem page, so a full-width box just
   outlined the empty two thirds. Shrink-wrapped and centred, the grid
   reads as a framed instrument instead of as something adrift.
   `max-width: 100%` keeps a wide numeric graph inside the page, where
   the frame then scrolls rather than the body. */
.habit-page-chart-frame {
  align-self: flex-start;
  max-width: 100%;
  border: 1px solid var(--gridline);
  padding: var(--space-4);
  overflow-x: auto;
}
.habit-page-chart-controls { display: flex; flex-wrap: wrap; gap: var(--space-2); }
/* The header's kebab (2026-09-11). `details.habit-details` is absolutely
   positioned for the CARD, whose corner it sits in; on this page it is a
   sibling of the completeness badge and the check button, so it goes back into
   the flow and lines up with them. The panel it opens is `position:
   fixed` and centred, so it is unaffected either way.
   (`.habit-page-edit-links` lived here until the same day, when the three
   flat buttons it laid out became this one icon.) */
.habit-page-actions .habit-details { position: static; }

/* ---- the rank plate (2026-09-10) --------------------------------------
   A ranked habit's header, wearing the instrument-plate geometry the
   ranked CARD wears on /habits: a frame in the tier's colour and the
   corner brackets. Same mark in both places, so entering a habit reads
   as opening the card rather than as arriving somewhere unrelated.
   `.habit-page` already sets --tier-color on the article, so nothing
   here has to be told which rung it's drawing. */
.habit-page-plate { display: flex; flex-direction: column; gap: var(--space-5); }
.habit-page-plate.is-ranked {
  position: relative;
  padding: var(--space-5);
  border: 1px solid color-mix(in srgb, var(--tier-color) 55%, transparent);
  background: linear-gradient(180deg, color-mix(in srgb, var(--tier-color) 9%, var(--surface)), var(--surface) 65%);
}
.habit-page-plate.is-ranked::before,
.habit-page-plate.is-ranked::after {
  content: "";
  position: absolute;
  width: 14px;
  height: 14px;
  pointer-events: none;
}
.habit-page-plate.is-ranked::before {
  top: -1px; left: -1px;
  border-top: 2px solid var(--tier-color);
  border-left: 2px solid var(--tier-color);
}
.habit-page-plate.is-ranked::after {
  bottom: -1px; right: -1px;
  border-bottom: 2px solid var(--tier-color);
  border-right: 2px solid var(--tier-color);
}
/* The numeral is the plate's subject, so it outgrows the 3rem square a
   normal habit's page draws. Outranks `.habit-page-head .monogram`
   above by one class — check that if either selector moves. */
.habit-page-plate.is-ranked .monogram {
  width: 4.4rem;
  height: 4.4rem;
  font-size: 1.85rem;
}
.habit-page-plate.is-ranked .habit-page-title h1 { font-size: 1.85rem; letter-spacing: -0.01em; }
.habit-page-plate.is-ranked .habit-page-eyebrow { color: var(--ink-muted); }

/* ---- the rank ladder ---------------------------------------------------
   See tier_ladder() in _macros.html for what each part means.

   The column count is the habit's own (2026-09-11): a habit with an
   authored progression has one rung per stage — Sleep has four — so both
   grids read `--rungs`, set inline on `.tier-ladder` from the length of
   the ladder the tier was read off. The 5 is only the fallback for a
   habit with no authored progression, which is still most of them. */
.tier-ladder { display: flex; flex-direction: column; gap: var(--space-2); }
.tier-ladder-track {
  display: grid;
  grid-template-columns: repeat(var(--rungs, 5), 1fr);
  /* Centred, so the standing rung below can grow taller than its
     neighbours without a negative margin to hang it back into line. */
  align-items: center;
  gap: 3px;
  list-style: none;
  margin: 0;
  padding: 0;
}
.tier-ladder-rung {
  height: 10px;
  position: relative;
  background: color-mix(in srgb, var(--ink) 4%, transparent);
  border: 1px solid var(--gridline);
}
/* COLOUR MARKS THE PRESENT, NOT THE PAST (2026-09-13, direct request:
   "the absence of color is not a good indicator that we are on that
   rank"). Until now a held rung was solid tier colour and the rung you
   stand on was a hairline outline filled to `--rung-fill` — which
   `tier_for` sets to 0 for every habit, so the rank you hold was drawn
   as a GAP between the ranks you had left behind. At rank 3 that reads
   as 2, and at rank 1 — where every real user stands — it left the whole
   instrument empty. So the emphasis is inverted: history steps back to a
   tint, and the rank being held is the loudest mark on the ladder. */
.tier-ladder-rung.is-held {
  background: color-mix(in srgb, var(--tier-color) 26%, transparent);
  border-color: color-mix(in srgb, var(--tier-color) 45%, transparent);
}
/* Solid AND taller: two channels, so the rank still reads where the tier
   colour is faint (`--tier-1` is `--ink-muted`) or the row is scanned at
   arm's length. */
.tier-ladder-rung.is-here {
  height: 16px;
  background: var(--tier-color);
  border-color: var(--tier-color);
}
/* How far across the current rung the habit has come — darkened OVER the
   solid fill, not drawn as the fill itself, so the rank's position never
   depends on a number that is still always 0. Invisible until a real
   progression rule lands and `--rung-fill` starts moving. */
.tier-ladder-rung.is-here::after {
  content: "";
  position: absolute;
  inset: 0 auto 0 0;
  width: var(--rung-fill, 0%);
  background: color-mix(in srgb, var(--ink) 22%, transparent);
}
.tier-ladder-ticks {
  display: grid;
  grid-template-columns: repeat(var(--rungs, 5), 1fr);
  gap: 3px;
  list-style: none;
  margin: 0;
  padding: 0;
}
.tier-ladder-ticks li {
  font-family: var(--font-mono);
  font-size: 0.54rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--ink-muted);
  text-align: center;
  overflow-wrap: anywhere;
}
.tier-ladder-ticks li.is-here { color: var(--tier-color); }
.tier-ladder-ticks li.is-next { color: var(--ink-2); }

/* ---- the admin's rank stepper (2026-09-12) -----------------------------
   Deliberately chrome, not a control: mono, small caps, hairline border,
   muted until you reach for it. It sits under a ladder whose whole job is
   to be read, and it must never look like something a user earns or
   operates — it is a gearshift for testing what each rank renders like
   while nothing promotes a habit automatically. Only an admin, on their
   own ranked habit, ever sees it (_macros.html's rank_stepper). */
.rank-stepper {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-2);
  padding-top: var(--space-2);
  border-top: 1px dashed var(--gridline);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.rank-stepper-tag {
  padding: 2px var(--space-1);
  border: 1px solid var(--gridline);
  border-radius: 2px;
  color: var(--ink-muted);
}
.rank-stepper-read { color: var(--ink-2); }
/* margin-left: auto on the second form would push the ladder's read-out
   apart on a narrow screen; the group stays left-aligned instead. */
.rank-stepper-form { display: contents; }
.rank-stepper-btn {
  min-width: 1.75rem;
  padding: 2px var(--space-1);
  border: 1px solid var(--gridline);
  border-radius: 2px;
  background: transparent;
  color: var(--ink-2);
  font-family: inherit;
  font-size: inherit;
  line-height: 1.4;
  cursor: pointer;
}
.rank-stepper-btn:hover:not(:disabled),
.rank-stepper-btn:focus-visible {
  border-color: var(--tier-color, var(--ink-2));
  color: var(--ink);
}
.rank-stepper-btn:disabled { opacity: 0.35; cursor: default; }
/* The stepper's third control is an <a>, not a <button> — it navigates
   rather than writing, so it needs the anchor's own resets to sit level
   with the two arrows beside it. `letter-spacing` is inherited from
   .rank-stepper, so the word tracks like the tag at the other end. */
.rank-stepper-link {
  display: inline-block;
  text-decoration: none;
}

/* ---- an unbuilt rank's setup page (2026-09-12) -------------------------
   Reached only from the stepper's Setup button, and only when the rank
   standing there has no configuration screen written yet
   (rank_setup_missing.html). It sits under the same rank plate the built
   screens wear, so what changes between a built rank and an unbuilt one
   is the answer, not the furniture. Quiet on purpose: this is a true and
   ordinary state of the app, not an error, and nothing here should read
   as a failure. */
.rank-setup-blank {
  max-width: 46rem;
  margin: var(--space-5) auto 0;
  padding: var(--space-4);
  border: 1px dashed var(--gridline);
  border-radius: var(--radius-md);
}
.rank-setup-blank-tag {
  margin: 0 0 var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--text-2xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-muted);
}
.rank-setup-blank h2 {
  margin: 0 0 var(--space-2);
  font-size: var(--text-lg);
}
.rank-setup-blank p {
  margin: 0;
  color: var(--ink-2);
  line-height: 1.6;
}
.rank-setup-blank .form-actions { margin-top: var(--space-4); }

/* ---- history, quieted (2026-09-10) ------------------------------------
   Page-scoped, not a change to the shared button: on a card's popup the
   delete is one action among a handful, but this page renders ten of
   them at once and a stack of ten red outlines was the loudest thing on
   a page whose subject is a rank. The word stays (a bare glyph reads as
   nothing to a screen reader); only its alarm is spent on hover/focus,
   where it belongs. */
.habit-page .history-row .btn-danger {
  border-color: transparent;
  color: var(--ink-muted);
}
.habit-page .history-row .btn-danger:hover,
.habit-page .history-row .btn-danger:focus-visible {
  border-color: var(--danger);
  color: var(--danger);
}

/* At body's widest step the four tracks stretch to ~330px and the tiles
   go hollow, so the row doubles instead — 8 tracks, which is the other
   count that divides a yes/no habit's eight cells exactly (7 figures
   plus the lead's second track), landing them on one line. Tied to
   body's own 1372px step, not a number of this section's own. */
@media (min-width: 1372px) {
  .stat-grid { grid-template-columns: repeat(8, minmax(0, 1fr)); }
}

@media (max-width: 560px) {
  /* Two full-width tiles beat four cramped ones on a phone; the lead
     figure stops being wide, since there is only one track. */
  .stat-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .habit-page-plate.is-ranked { padding: var(--space-4); }
  .tier-ladder-ticks li { font-size: 0.5rem; letter-spacing: 0.04em; }
}

/* ==========================================================================
   PHONE ERGONOMICS AND LOW-END PERFORMANCE (added 2026-09-10)
   --------------------------------------------------------------------------
   Everything above this line was written for a desktop browser first and
   patched for phones with `max-width` overrides. The Android app (mobile/,
   ARCHITECTURE.md §13) is that same site in a WebView, so every desktop
   assumption ships to the phone as-is. This section is the app-shaped layer:
   it changes ergonomics and paint cost, never the visual identity.
   Everything here is scoped to touch/narrow viewports, so the desktop site
   renders exactly as it did.
   ========================================================================== */

/* ---- 1. Input feel ------------------------------------------------------
   Four defaults a desktop browser never had to care about. */

html {
  /* A WebView inflates text in blocks it decides are "too small to read",
     which silently breaks any layout measured in the CSS pixels this sheet
     assumes — the numeric pills and the week rail especially. */
  -webkit-text-size-adjust: 100%;
  text-size-adjust: 100%;
}

/* The grey flash Android paints over the whole tapped box. It is the single
   loudest "this is a web page" tell in the app, and it fires on the element
   the browser picks, not the one that looks pressed. Replaced by the
   explicit :active states below, which follow the design system. */
a, button, summary, label, input, select, textarea,
[role="button"], .nav-link, .habit-card, .theme-btn {
  -webkit-tap-highlight-color: transparent;
}

/* Opts out of the ~300ms wait a touch engine holds every tap for, in case
   the tap turns into a double-tap-to-zoom. Applied only to controls, so
   pinch-zoom on the page itself is untouched. That delay is the difference
   between a button that feels native and one that feels like a web page. */
a, button, summary, label, [role="button"],
input[type="submit"], input[type="checkbox"], input[type="radio"] {
  touch-action: manipulation;
}

/* Press feedback, now that the platform's own flash is gone. Deliberately
   a transform rather than a colour change: it composites, so it lands on
   the very next frame even while the page is still busy — which is exactly
   when an old phone would otherwise feel unresponsive. */
@media (hover: none) {
  .btn:active, button:active, .nav-link:active,
  .theme-btn:active, summary:active, [role="button"]:active {
    transform: scale(0.97);
  }
}

/* Scroll chaining: dragging past the end of an inner scroller (the chat
   log, the habit popup, the week grid) hands the gesture to the page
   behind it, so a flick inside a panel scrolls the document instead. */
.chat-messages, .details-content, .popup-body, .main-nav,
.cal-grid-scroll, .table-scroll {
  overscroll-behavior: contain;
}

/* ---- 2. The scanline layer: repaint -> composite ------------------------
   body::before is a fullscreen fixed layer, blended with `mix-blend-mode`
   and animated forever on every page of the app. It animated
   `background-position`, which is a *paint* property: every frame repainted
   a viewport-sized repeating-gradient and re-blended it, at 60fps, for as
   long as the page was open. Twelve of the fourteen themes have a non-zero
   --scanline-opacity, so this was running essentially always.

   Animating `transform` instead moves the same work to the compositor: the
   layer is painted once and thereafter only re-positioned. The box is grown
   by one full pattern period at each end and travels exactly that period,
   so the pattern lands back on itself and the loop stays seamless — the
   gradient repeats every --scanline-size, and the old keyframes already
   moved it by 4x that. Pixel-for-pixel the same animation. */
body::before {
  top: calc(var(--scanline-size) * -4);
  bottom: calc(var(--scanline-size) * -4);
  inset-inline: 0;
}
@keyframes scanline-drift {
  from { transform: translate3d(0, 0, 0); }
  to   { transform: translate3d(0, calc(var(--scanline-size) * 4), 0); }
}

/* The two themes that set --scanline-opacity: 0 (obsidian, vellum) still
   paid for a fullscreen blended layer and its animation to render nothing
   at all. `display: none` is what actually stops the compositing work;
   `opacity: 0` alone does not. */
html[data-theme="obsidian"] body::before,
html[data-theme="vellum"] body::before,
html[data-theme="obsidian"] body::after,
html[data-theme="vellum"] body::after {
  display: none;
}

/* Gasp also sets --scanline-opacity: 0, so the same saving applies to its
   scanline layer — but not to body::after, which is the one light theme
   that actually paints a motif there. */
html[data-theme="gasp"] body::before {
  display: none;
}

/* ---- 3. Primary nav as a bottom tab bar (phones) ------------------------
   The nav was a strip at the top of the document that scrolled away with
   the content. On a desktop that is fine — the viewport is short and the
   mouse reaches anywhere. In the app it meant every page change began by
   scrolling back to the top: /account is 2,344px tall on a 390px-wide
   phone, /calendar 2,034px, so the primary navigation spent most of its
   life off-screen, and reaching it needed a scroll whose only purpose was
   to undo itself.

   Docked to the bottom rather than the top for reach: the top of a phone
   screen is the hardest place for a thumb to get to, and the bottom is
   where every native app puts its tabs.

   NOTE, because the repo has history here: two earlier elements were
   deliberately moved OUT of fixed positioning — the reorder badge
   ("make the reorder badge at the bottom of the page - rn he is on our
   screen all time") and the landing top bar (six attempts,
   changes_log.md (18)-(24)). Both were secondary chrome that followed the
   viewport when it was wanted anchored to the page. This is the opposite
   case: primary navigation, on a phone, where staying reachable is the
   whole point. If that reads wrong on real hardware, this block is the
   only thing to delete — the markup (_macros.html main_nav) is unchanged
   and the nav returns to its in-flow strip. */
@media (max-width: 640px) {
  html:not(.is-preview):not(.is-landing) .main-nav {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 50;
    margin: 0;
    /* Opaque: content scrolls underneath it. --surface rather than --page
       so the bar reads as a plane above the page in all fourteen themes. */
    background: var(--surface);
    border-top: 1px solid var(--border);
    /* The inset is 0 inside the app — mobile/App.tsx already wraps the
       WebView in a SafeAreaView with a bottom edge — but not in a phone
       browser, where the gesture bar overlaps the viewport. */
    padding: var(--space-1) var(--space-1) calc(var(--space-1) + env(safe-area-inset-bottom, 0px));
    gap: 0;
    justify-content: space-around;
    /* Five items (six for an admin) share the width evenly instead of
       scrolling, so the row never hides a destination off its right edge. */
    overflow-x: visible;
  }

  html:not(.is-preview):not(.is-landing) .main-nav .nav-link {
    flex: 1 1 0;
    min-width: 0;
    /* 48px: the smallest touch target that is reliably hit without
       looking. The old row was ~28px tall. */
    min-height: 3rem;
    justify-content: center;
    gap: 3px;
    padding: var(--space-1) 2px;
    border: 0;
    border-radius: 0;
    position: relative;
    font-size: 0.5rem;
  }

  /* The active item was marked with a 1px box around the link, which in a
     bar reads as a stray outline. A rule along the bar's own top edge is
     the tab-bar idiom, and it is the border the bar already has. */
  html:not(.is-preview):not(.is-landing) .main-nav .nav-link.is-active::before {
    content: "";
    position: absolute;
    top: -1px;
    left: 0;
    right: 0;
    height: 2px;
    background: var(--series-1);
  }

  html:not(.is-preview):not(.is-landing) .main-nav .nav-icon {
    width: 1.35rem;
    height: 1.35rem;
  }

  /* The bar is out of flow, so the page has to reserve its height or the
     last element of every page sits under it. 3rem link + 2 x --space-1
     padding + the border, plus the same safe-area inset the bar adds. */
  html:not(.is-preview):not(.is-landing) body {
    padding-bottom: calc(4.25rem + env(safe-area-inset-bottom, 0px));
  }
}

/* ---- 4. The week grid on a phone ---------------------------------------
   `.calendar-grid` is 2.75rem of hour gutter plus seven columns with a 6rem
   floor — about 716px, against ~358px of content width on a 390px phone. It
   has always been an `overflow-x: auto` scroller, which was the right call,
   but two things made it read as broken rather than scrollable:

   1. Scrollbars are hidden app-wide (2026-09-03, "remove the sidebars"), so
      the one affordance saying "there is more this way" was gone. A phone
      showed Monday to Wednesday and nothing suggested Thursday existed.
   2. Scrolling sideways took the hour gutter with it, so the moment you
      moved off Monday the times were gone and the rows meant nothing.

   Fixed here rather than by shrinking seven columns into 358px — that works
   out at 37px a day, too narrow for a date, let alone a block. */
@media (max-width: 640px) {
  .calendar-grid {
    /* Land on a day boundary rather than mid-column, so a flick always
       leaves a readable day rather than two half ones. */
    scroll-snap-type: x mandatory;
    /* Snapped column starts clear of the sticky gutter below, not under it. */
    scroll-padding-left: 3.25rem;
  }

  .calendar-day-header { scroll-snap-align: start; }

  /* Row 1 column 1 is the one cell of the gutter no element occupies —
     the day headers start at column 2 — so the first day scrolled up into
     the gutter's lane and showed through beside the sticky hours as a
     clipped "> 09". A generated grid item fills it. Safe as a grid item
     because every real child of this grid places itself explicitly, so
     there is no auto-placement flow for it to push out of order. */
  .calendar-grid::before {
    content: "";
    grid-row: 1;
    grid-column: 1;
    position: sticky;
    left: 0;
    z-index: 4;
    background: var(--page);
  }

  .calendar-allday-label {
    position: sticky;
    left: 0;
    z-index: 3;
    background: var(--page);
  }

  /* The hour gutter stays put while the days scroll under it — the whole
     reason the rows are readable away from Monday. Opaque, because the
     day tracks and blocks now pass beneath it. */
  .routine-hour-label {
    position: sticky;
    left: 0;
    z-index: 3;
    background: var(--page);
    padding-right: var(--space-1);
  }

  /* Deliberately NOT a 7-column squeeze: the floor stays, so roughly three
     and a bit days are visible and that partial fourth column is itself
     the "keep going" signal the hidden scrollbar used to give. */
}

/* ---- 5. The chat page on a phone ---------------------------------------
   `.chat-messages` is `max-height: 60vh; overflow-y: auto`, and the page
   scrolls too — two nested scrollers, which on touch is the worst case:
   a drag that starts inside the log scrolls the log until it hits its end
   and then, mid-gesture, starts scrolling the page instead. It also put
   the composer below the fold, so writing a message began with a scroll.

   The inner scroller has to stay — initChatSend() and the poll both keep
   the log pinned to the newest message with `scrollTop = scrollHeight`,
   which only does anything on an element that actually scrolls. So instead
   of removing it, the page becomes what a messaging screen should be: one
   viewport-height column, the log taking whatever is left, the composer
   always on screen underneath it, and the page itself not scrolling at all.

   `dvh`, not `vh`: `vh` is fixed to the *largest* viewport, so with a
   browser's address bar showing, a 100vh column runs off the bottom of the
   screen and takes the composer with it — exactly the bug this fixes. */
@media (max-width: 640px) {
  html.is-chat body {
    display: flex;
    flex-direction: column;
    /* `height`, not `min-height`: `min-height` sets a floor the column is
       free to grow past, which is exactly what it did — the log kept its
       full content height and pushed the composer off the bottom of the
       screen, the bug this block exists to fix. A hard height is what
       forces the log (the one `flex: 1 1 auto; min-height: 0` child) to be
       the thing that gives way. */
    height: 100dvh;
    /* The page itself no longer scrolls; the log does. Without this a
       rounding error between the fixed height and the padding leaves a
       few pixels of page scroll, which reads as a stuck rubber-band. */
    overflow: hidden;
  }

  html.is-chat .chat-page {
    flex: 1 1 auto;
    /* Without this a flex child refuses to shrink below its content's
       height, so the log would still push the composer off-screen. */
    min-height: 0;
  }

  html.is-chat .chat-messages {
    max-height: none;
    flex: 1 1 auto;
    min-height: 6rem;
    overflow-y: auto;
  }

  /* The composer keeps its natural height; only the log gives ground. */
  html.is-chat .chat-input-form { flex: 0 0 auto; }
}

/* ---- 6. Touch targets --------------------------------------------------
   Measured on a 390px viewport across the seven signed-in pages: the tip
   dismiss rendered at 10x15px, "set" on the call chip at 36x19, the alarm
   toggle at 70x19, and the call trail's day columns at 8x12. Both platform
   guidelines put the floor at 44-48px, and these are all controls that get
   tapped one-handed.

   Two different fixes, because these are two different kinds of control:

   - Text buttons get real height. They are laid out in normal flow with
     room around them, so growing them costs nothing and reads as
     deliberate on a phone.
   - Icon-sized and chart-sized controls get an INVISIBLE hit area instead,
     via a centred ::after larger than the element itself. Growing "x" or a
     sparkline column to 44px would wreck the thing it is drawn as; growing
     only what the finger has to land on changes nothing on screen. */
@media (max-width: 640px) {
  .btn, button.btn, .check-btn, .btn-primary, .btn-danger {
    min-height: 2.75rem;
  }

  /* The seal grows square rather than tall — a 44px-tall, 42px-wide one
     would be an egg. Both sizes go to 44, AND SO DOES THE MONOGRAM
     (2026-09-14, direct report: "one is bigger than the other").

     The two face rules that used to live here — 2.2rem and 2.05rem —
     are gone. They shrank the visible face to 35.2px inside a 44px
     button, which put a third concentric edge back on the die (ring 44 /
     face 35.2) and left it 3.2px SMALLER than the 38.4px rank square
     next to it: the "one square, not two" decision was quietly undone on
     every phone. `.seal-face` is width/height 100% by default, so
     deleting them is the whole fix — the face fills the button and the
     sweep runs on its own edge, exactly as it does on a desktop.

     The monogram has to grow with it or the mismatch simply flips sign.
     `.habit-page-head .monogram` and `.habit-page-plate.is-ranked
     .monogram` are more specific and keep their own larger sizes. */
  .seal-btn, .seal-btn-sm { width: 2.75rem; height: 2.75rem; }
  .monogram { width: 2.75rem; height: 2.75rem; }
  /* Home's square LOG die is the seal's twin (2026-09-14) and has to grow
     with it, or the same 5.6px mismatch this block exists to remove comes
     back on a numeric habit's row — and it would sit under the 44px touch
     minimum besides. Same selector as the sizing rule far above, declared
     later, so it wins without a specificity bump. */
  .home-habit-row .numeric-pill { width: 2.75rem; height: 2.75rem; }

  /* The centred overlay: at least 44x44, never smaller than the control
     it belongs to, and pointer-events pass through to the control itself
     because the pseudo-element is its child. */
  .tip button::after,
  .call-onceoff-set::after,
  .call-onceoff-clear::after,
  .alarm-toggle::after,
  .cal-datenav-arrow::after,
  .details-popup-close::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    width: 100%;
    height: 100%;
    min-width: 44px;
    min-height: 44px;
  }
  .tip button,
  .call-onceoff-set,
  .call-onceoff-clear,
  .alarm-toggle,
  .cal-datenav-arrow {
    position: relative;
  }

  /* The call trail is a sparkline — 30 day-columns a few pixels wide — so
     the width is the design and cannot change. The HEIGHT was the real
     problem: `align-items: flex-end` sized every column to its own bar, so
     a one-out-of-five day was a 12px-tall link. Stretching the columns to
     the strip's full height leaves the bars exactly where they were (they
     are still bottom-aligned by the column's own `justify-content:
     flex-end`) and makes the whole column above each bar tappable. */
  .trail {
    align-items: stretch;
    /* The strip's height comes from its tallest bar, and a bar's height is
       the day's 1-5 rating (3px when there is no rating at all). A week of
       unrated days therefore collapsed the whole strip — and every link in
       it — to 12px. The floor keeps the columns tappable no matter what the
       data says; the bars still sit on the baseline, so a low-rated week
       looks exactly as it did, just with room above it. */
    min-height: 2.75rem;
  }
}

/* ---- 7. Cross-document view transitions --------------------------------
   Every navigation in this app is a real page load — that is the whole
   architecture (server-rendered Jinja2, forms that work with JS off) and it
   is not changing. What it costs on a phone is the blank frame between the
   old page going away and the new one painting, which is the single thing
   that most makes the app read as a web page in a wrapper.

   `@view-transition` fixes exactly that, in CSS, with no JavaScript and no
   change to how navigation works: the browser holds the last frame of the
   old page, paints the new one, and cross-fades. A browser without support
   ignores the rule and navigates as before, so this stays inside the
   progressive-enhancement rule the stack is built on.

   The tab bar is named, which pulls it out of the cross-fade and carries it
   across the navigation as one persistent element — so tapping a tab
   re-paints the content under a bar that never blinks, the way a native
   tab bar behaves. */
@view-transition { navigation: auto; }

.main-nav { view-transition-name: uful-nav; }

/* 140ms, not the 250ms default: this is covering a page load, not
   performing. Long enough to remove the blank frame, short enough that it
   never becomes the reason a tap feels slow on a slow phone. */
::view-transition-old(root),
::view-transition-new(root) {
  animation-duration: 140ms;
}

@media (prefers-reduced-motion: reduce) {
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

/* The rank check's outcome rating on the habit page (added 2026-09-13).
   A row of short bars, one per answered day, oldest left — deliberately
   NOT the heatmap's grid: the heatmap answers "did you do it", and putting
   a second grid beside it would invite reading the two as the same kind of
   fact. A 1 still draws a fifth of a bar rather than nothing, because a 1
   is an answer (see rank_checks.rating_trail). */
.rating-trail {
  display: flex;
  align-items: flex-end;
  gap: 3px;
  height: 44px;
  margin-bottom: var(--space-xs, 0.5rem);
}
.rating-trail-bar {
  flex: 1 1 auto;
  min-width: 4px;
  max-width: 14px;
  height: var(--rating-pct);
  background: var(--accent, currentColor);
  opacity: 0.72;
  border-radius: 1px;
}
.rating-trail-meta {
  font-size: var(--text-sm, 0.85rem);
  color: var(--muted, inherit);
  margin: 0;
}

/* The rating's empty state (added 2026-09-13, direct report: "I don't see
   the sleep rating thing"). Full-height ghost bars rather than no bars:
   the scale has to be visible before there is anything on it, or a habit
   that has never been rated looks like a habit with no rating feature —
   which is every habit on its first day. */
.rating-trail.is-empty .rating-trail-bar {
  height: 100%;
  background: var(--muted, currentColor);
  opacity: 0.12;
}

/* Heatmap and outcome rating side by side (added 2026-09-13, direct
   request: "make the sleeping rate thing next to the heatmap"). The grid
   is narrow and left most of its row empty; the rating now fills it. Two
   columns where there is room, one where there isn't — the rating's bars
   are already fluid, so it takes whatever width it gets. */
.habit-page-pair {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  gap: var(--space-md, 1.5rem);
  align-items: start;
}
@media (max-width: 700px) {
  .habit-page-pair { grid-template-columns: 1fr; }
}
