/*
  brainboard styles

  Appearance is driven by two numbers and a palette, all set on <html> by
  brainboard.appearance:

    --lightness      lightness of the page background (OKLCH L, ~0.14 – 0.98)
    --saturation     0 – 1, scales the chroma of every colour below
    data-palette     which set of hues to use (see "Palettes")

  Every colour is derived from those, so nothing else in this file (or in the
  views) names a colour directly.
*/

:root {
  --lightness: 0.95;
  --saturation: 0.5;

  --hue-base: 85;
  --hue-accent: 35;
  --hue-alt: 255;

  /* Two faces: one for level names, one for everything else. */
  --font-body:    system-ui, -apple-system, "Segoe UI", sans-serif;
  --font-heading: var(--font-body);
  --font-heading-weight: 700;   /* some display faces only come in one weight */
}

/* ---- Palettes -------------------------------------------------------------
   A palette is three hues: the neutrals (background, ink, borders), a primary
   accent and a secondary accent. Anything carrying data-palette re-derives the
   tokens below from its own hues, which is how the swatches preview themselves.

   Base hues are spread roughly every 40-65° around the wheel (paper 85, moss
   135, sky 195, tide 235, violet 275, plum 320, ember 20/380) so the seven read
   as a deliberate spread rather than clustering in one part of the wheel.
   Checked numerically like the rest of the system (contrast.mjs in scratch
   notes): every one holds the same guarantees as the original four.
*/
[data-palette="paper"]  { --hue-base:  85; --hue-accent:  35; --hue-alt: 255; }
[data-palette="moss"]   { --hue-base: 135; --hue-accent: 150; --hue-alt:  75; }
[data-palette="sky"]    { --hue-base: 195; --hue-accent: 175; --hue-alt:  15; }
[data-palette="tide"]   { --hue-base: 235; --hue-accent: 195; --hue-alt:  25; }
[data-palette="violet"] { --hue-base: 275; --hue-accent: 300; --hue-alt:  95; }
[data-palette="plum"]   { --hue-base: 320; --hue-accent: 355; --hue-alt: 200; }
[data-palette="ember"]  { --hue-base:  20; --hue-accent:  45; --hue-alt: 230; }

/* ---- Derived tokens -------------------------------------------------------
   --polarity is +1 on a light background and -1 on a dark one. It flips at
   L = 0.565, the point where black and white ink have equal contrast (~4.5:1).
   brainboard.appearance/dark-below must match.

   --toward-ink is the direction ink lies in from the background. Every role is
   the background nudged toward the ink by some amount, so contrast holds at any
   slider position, and the lightness slider moves all of them at once.

   --sep fades the subtle tone steps (surface, hover, divider) to nothing as the
   background approaches the crossover. There ink has no contrast to spare, and
   a surface nudged toward it would eat into readability.
*/
:root,
[data-palette] {
  --polarity:   clamp(-1, calc((var(--lightness) - 0.565) * 1000), 1);
  --toward-ink: calc(var(--polarity) * -1);
  --sep:        clamp(0, calc(abs(var(--lightness) - 0.565) / 0.15), 1);

  --l-accent: calc(var(--lightness) + var(--toward-ink) * 0.50);
  --l-alt:    calc(var(--lightness) + var(--toward-ink) * 0.42);

  --bg:         oklch(var(--lightness)                                        calc(var(--saturation) * 0.045) var(--hue-base));
  --surface:    oklch(calc(var(--lightness) + var(--toward-ink) * var(--sep) * 0.04) calc(var(--saturation) * 0.055) var(--hue-base));
  --surface-2:  oklch(calc(var(--lightness) + var(--toward-ink) * var(--sep) * 0.10) calc(var(--saturation) * 0.065) var(--hue-base));
  --line:       oklch(calc(var(--lightness) + var(--toward-ink) * var(--sep) * 0.16) calc(var(--saturation) * 0.050) var(--hue-base));
  --edge:       oklch(calc(var(--lightness) + var(--toward-ink) * 0.40)       calc(var(--saturation) * 0.050) var(--hue-base));
  /* the page behind the app on wide screens; text never sits on it */
  --backdrop:   oklch(calc(var(--lightness) + var(--toward-ink) * 0.09)       calc(var(--saturation) * 0.050) var(--hue-base));

  --ink:        oklch(calc(0.55 + var(--toward-ink) * 0.45)                   calc(var(--saturation) * 0.030) var(--hue-base));
  --ink-muted:  oklch(calc(var(--lightness) + var(--toward-ink) * 0.50)       calc(var(--saturation) * 0.040) var(--hue-base));

  --accent:     oklch(var(--l-accent) calc(var(--saturation) * 0.19) var(--hue-accent));
  --alt:        oklch(var(--l-alt)    calc(var(--saturation) * 0.17) var(--hue-alt));
  /* text on an accent fill: black or white by the accent's own lightness */
  --on-accent:  oklch(clamp(0.1, calc((0.565 - var(--l-accent)) * 1000), 1) calc(var(--saturation) * 0.030) var(--hue-accent));
}

/* ---- Base ---------------------------------------------------------------- */

/* Neither had a height before, only #brainboard did (100dvh, below) — so
   nothing actually stopped the page itself from growing taller than the
   viewport if anything inside ever computed even slightly too tall (seen in
   practice right after a reload), leaving the page scrollable and the app
   bar scrolled out of view until manually scrolled back up. Constraining
   both here and clipping overflow closes that off outright: the app is
   meant to fit one screen, with #brainboard's own children (.boardLines)
   already handling whatever scrolling they individually need. */
html, body {
  height: 100dvh;
  overflow: hidden;
}

html { color-scheme: light; }
html[data-theme="dark"] { color-scheme: dark; }

/* The page behind the app. On a phone the app fills it; on a wider screen this
   is the backdrop the app column stands out from. */
body {
  background: var(--backdrop);
  color: var(--ink);
  font-family: var(--font-body);
}

button {
  color: inherit;
  background: transparent;
  border: 1px solid var(--edge);
  padding: 0.5em 1em;
  cursor: pointer;
}

/* :where() keeps these at zero specificity so component rules always win. */
:where(button:hover:not(:disabled)) { background: var(--surface-2); }
:where(button:disabled)             { opacity: 0.4; cursor: default; }
:where(button, summary):focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

/* ---- Layout -------------------------------------------------------------- */

/* One column, sized for a phone. The gap keeps the levels visibly apart. */
#brainboard {
  display: flex;
  flex-direction: column;
  gap: 0.375rem;
  width: 100%;
  max-width: 32rem;
  height: 100dvh;
  margin-inline: auto;
  background: var(--bg);
}

/* In focus mode the one open level fills the view right down to the footer. The
   footer has its own edge, so the level does not need a second one. */
#brainboard[data-focus="true"] { gap: 0; }
#brainboard[data-focus="true"] .boardSection.is-open { border-bottom: 0; }

/* On a wider screen the column is a panel on a backdrop, so give it an edge. */
@media (min-width: 34rem) {
  #brainboard {
    border-inline: 2px solid var(--edge);
    box-shadow: 0 0 2.5rem color-mix(in oklch, var(--ink) 18%, transparent);
  }
}

.icon {
  flex: none;
  width: 1.25rem;
  height: 1.25rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
}
.icon .solid { fill: currentColor; stroke: none; }

/* ---- Levels -------------------------------------------------------------- */

/* Every level is a strip. The open one grows to fill the screen and is the only
   one with a border; the rest stay collapsed to their header. */
.boardSection {
  display: flex;
  flex-direction: column;
  flex: 0 0 auto;
}

.boardSection.is-open {
  flex: 1 1 0;
  min-height: 0;
  border-bottom: 4px solid var(--edge);
  background: var(--surface);
}

/* A level's own progress bar already separates it from what follows, so the
   open level only needs a top border when the level right above it has no
   such bar (Now, which has no period to show progress for: see the timer idea
   in NOTES.md, which would give it one and make this rule moot there too). */
.boardSection:not(:has(.boardElapsed)) + .boardSection.is-open {
  border-top: 4px solid var(--edge);
}

/* Collapsed, this read as too visually loud for a strip that's just sitting
   there alongside every other collapsed level — kept only for the open
   state, where it's earned: Now is the one level with its own dedicated
   accent colour (--alt) elsewhere too (the icon, Send to Now's icon). */
.boardSection[data-board="now"].is-open { box-shadow: inset 0.25rem 0 0 var(--alt); }

.boardTitle {
  position: relative;
  display: flex;
  flex: none;
  align-items: center;
  gap: 0.75rem;
  width: 100%;
  min-height: 3rem;
  padding: 0.5rem 1rem;
  border: none;
  text-align: start;
  overflow: hidden;
}

.boardHeading {
  display: flex;
  flex-direction: column;
  gap: 0.25rem; /* text-underline-offset paints below the name's own box, with
                   nothing else reserving room for it; without a gap here it
                   can run into .boardProgress sitting right underneath */
  min-width: 0;
  margin-inline-end: auto;
}

/* Level names: the heading face, bold. Tasks are body face, regular. */
.boardName {
  font-family: var(--font-heading);
  font-size: 1rem;
  font-weight: var(--font-heading-weight);
  color: var(--ink-muted);
}

.boardProgress {
  font-size: 0.8rem;
  color: var(--ink-muted);
  font-variant-numeric: tabular-nums;
}

.boardSection.is-open .boardTitle {
  min-height: 4rem;
  padding-block: 0.75rem;
  cursor: default;
  background: transparent;
}
.boardSection.is-open .boardName {
  font-size: 1.5rem;
  line-height: 1.15;
  color: var(--ink);
  /* on the name only: text-decoration on the section would reach every task too */
  text-decoration: underline;
  text-decoration-color: var(--accent);
  text-decoration-thickness: 0.12em;
  text-underline-offset: 0.3em; /* clears descenders like the 'y' in "Today" */
}

/* One dot per task. Small and quiet on a collapsed level, larger and full
   strength on the open one. If there are more than fit, they wrap. */
/* Sits before the dots, so the dots stay flush against the right edge whether
   or not a level has a note. */
.boardNote {
  flex: none;
  font-size: 0.75rem;
  font-weight: 600;
}

.boardDots {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: 0.3rem;
  min-width: 0;
  max-width: 55%;
}
.boardDot {
  width: 0.5rem;
  height: 0.5rem;
  border-radius: 50%;
  background: var(--ink-muted);
}
.boardSection.is-open .boardDot {
  width: 0.75rem;
  height: 0.75rem;
  background: var(--ink);
}
/* A new dot pops in rather than just appearing. The back-out easing is what
   gives the "pop" — a plain ease would just read as a grow. (A `transition`
   was tried here too, to smooth the start of the pop — removed: transitions
   never fire on an element's first style computation, only on a change to
   one already on screen, and this class is present from the moment its dot
   is born, so it had nothing to smooth.)

   `backwards` matters for a dot arriving via a move: views/dots sets an
   inline `animation-delay` on those, so the entrance doesn't start until the
   source level's own slide-out has actually finished (see views/dots and the
   slide exit below). Without `backwards`, the delay would be a dead gap —
   the browser applies no animation-driven styling until the animation
   actually starts, so the dot would sit fully visible at its resting
   appearance for the whole delay, then jarringly snap to invisible right as
   the (now pointless) pop began. `backwards` holds the FROM keyframe's
   values (scale(0), invisible) for the entire delay instead, so a delayed
   dot is correctly invisible from the moment it exists, only appearing when
   its pop actually starts. Has no effect at all when there is no delay (the
   default, animation-delay: 0), so this is free for every ordinary pop. */
.boardDot.is-entering {
  animation: dot-pop-in 860ms cubic-bezier(0.34, 1.56, 0.64, 1) backwards;
}
@keyframes dot-pop-in {
  from { transform: scale(0); opacity: 0; }
  to   { transform: scale(1); opacity: 1; }
}

/* A dot leaving plays one of several ways, depending on why, deliberately not
   variations on the same motion:
   - Delete shrinks away — it gets smaller and is gone.
   - Complete grows, holds a beat at that size, then fades in place without
     shrinking back down — it doesn't withdraw, it lingers before going,
     which is the one thing worth feeling different about finishing
     something.
   - A move slides the dot up or down and fades it, matching the app's own
     motion grammar (see NOTES.md's Animation section): vertical motion means
     a task's level changed. This is the "standard"/default exit — plainer
     than Complete's, on purpose, since most level changes are routine, not
     an outcome worth marking.
   All four use `forwards` deliberately: the dot is unmounted for real
   shortly after (see `views/exit-hold-ms`), and without `forwards` the
   animation's effect would drop the instant it ends, snapping the dot back
   to full size/opacity right before it disappears. The daily slide still has
   no dot animation of its own — see NOTES.md. */
.boardDot.is-deleting {
  animation: dot-shrink-out 420ms ease-in forwards;
}
@keyframes dot-shrink-out {
  from { transform: scale(1); opacity: 1; }
  to   { transform: scale(0); opacity: 0; }
}
.boardDot.is-completing {
  animation: dot-grow-fade 600ms ease-out forwards;
}
@keyframes dot-grow-fade {
  0%   { transform: scale(1);   opacity: 1; }
  30%  { transform: scale(1.6); opacity: 1; }
  60%  { transform: scale(1.6); opacity: 1; }
  100% { transform: scale(1.6); opacity: 0; }
}
.boardDot.is-sliding-up {
  /* Kept in sync with the line's own slide (.lineItem.is-sliding-up/-down in
     this file, views/slide-duration-ms in views.cljs) deliberately: a move
     plays both at once, for the same task, and a mismatch between the two
     durations was worth fixing on its own, separate from just slowing down —
     two things meant to read as one motion, moving at different speeds. */
  animation: dot-slide-up 550ms ease-in forwards;
}
@keyframes dot-slide-up {
  from { transform: translateY(0);       opacity: 1; }
  to   { transform: translateY(-0.6rem); opacity: 0; }
}
.boardDot.is-sliding-down {
  animation: dot-slide-down 550ms ease-in forwards;
}
@keyframes dot-slide-down {
  from { transform: translateY(0);      opacity: 1; }
  to   { transform: translateY(0.6rem); opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
  .boardDot.is-entering,
  .boardDot.is-deleting,
  .boardDot.is-completing,
  .boardDot.is-sliding-up,
  .boardDot.is-sliding-down { animation: none; }
  .boardDot.is-deleting,
  .boardDot.is-completing,
  .boardDot.is-sliding-up,
  .boardDot.is-sliding-down { opacity: 0; }
}
.boardMore {
  font-size: 0.75rem;
  color: var(--ink-muted);
}

/* How far through its day, week or month a level is. Muted and thin until the
   level is open, then accent-coloured and thicker. */
.boardElapsed {
  position: absolute;
  inset-inline: 0;
  bottom: 0;
  height: 3px;
  opacity: 0.55;
  background: linear-gradient(90deg,
    var(--edge) calc(var(--elapsed) * 100%),
    var(--line) 0);
}
.boardSection.is-open .boardElapsed {
  height: 5px;
  opacity: 1;
  background: linear-gradient(90deg,
    var(--accent) calc(var(--elapsed) * 100%),
    var(--line) 0);
}
/* Now's own left accent border (see .boardSection[data-board="now"].is-open,
   below) is an inset box-shadow on the section itself, which paints BELOW a
   child element's own background by normal stacking order — .boardElapsed,
   being that child, was covering the border's own 0.25rem strip rather than
   sitting behind it. Insetting the bar's start edge by that same thickness
   leaves the border its own strip to render in, instead of fighting a child
   element for it. */
.boardSection[data-board="now"] .boardElapsed { inset-inline-start: 0.25rem; }

/* What a locked cold load shows above the (all closed) levels, in place of any
   one of them being open. Fills the leftover space, the same way an open level
   would, so the closed strips and footer still sit compactly at the bottom.
   A real border-bottom (matching .boardSection.is-open's own) gives this its
   own defined edge — a window, not text floating loose above whatever level
   happens to render below it. */
.introPanel {
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  padding: 2rem 1rem;
  border-bottom: 4px solid var(--edge);
  text-align: center;
}
.introTitle {
  margin: 0;
  font-family: var(--font-heading);
  font-weight: var(--font-heading-weight);
  font-style: italic;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  font-size: clamp(2rem, 9vw, 3rem);
  color: var(--ink);
}
.introClock {
  margin: 0;
  font-family: var(--font-body);
  font-size: 1.15rem;
  color: var(--ink-muted);
  font-variant-numeric: tabular-nums;
}

/* Shown once, only on an actual first run (views/welcome-panel, subs/welcome?).
   Plain, not italic: italics read fine for a short aside, but this runs a
   full sentence or more, and the whole point of this feature's wording (see
   NOTES.md's Animation section) has been ease of reading at a glance, which
   italic body text works against more than it earns here. */
.introWelcome {
  max-width: 26rem;
  margin-top: 0.5rem;
}
.introWelcomeText {
  margin: 0;
  font-size: 0.95rem;
  line-height: 1.4;
  color: var(--ink-muted);
}

/* What actually moved on the roll that just locked this view (see
   views/roll-report-panel, subs/roll-report). Inset under the clock, its own
   left-aligned block rather than centered text like the title/clock above
   it: a short list of sentences reads better ragged-left than each line
   individually centered. Lines fade in one at a time (views sets each one's
   own animation-delay), deliberately without any motion alongside the fade —
   a calmer opener for a screen that already has a title and a clock doing
   the "something is happening" work; not everything needs to travel to be
   noticed appearing. Plain, not italic, for the same reading-at-a-glance
   reason .introWelcomeText is above. */
.introReport {
  max-width: 26rem;
  margin-top: 0.5rem;
  text-align: left;
}
.introReportHeader {
  margin: 0 0 0.4rem;
  font-size: 0.8rem;
  font-weight: 600;
  color: var(--ink-muted);
}
/* The left border is what visually holds the lines together as one report,
   rather than a loose stack of unrelated sentences; the square bullets (a
   plain ::before square, not a list-style — this was never a <ul>, so no
   marker to override) repeat that same "these belong together" signal per
   line, at reading-eye height rather than centered on the line's full
   height. */
.introReportLines {
  border-left: 2px solid var(--edge);
  padding-left: 0.9rem;
}
.introReportLine {
  position: relative;
  padding-left: 1rem;
  margin: 0.3rem 0 0;
  font-size: 0.9rem;
  line-height: 1.4;
  color: var(--ink-muted);
  animation: intro-line-in 400ms ease-out backwards;
}
.introReportLine:first-child { margin-top: 0; }
.introReportLine::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.5em;
  width: 0.35rem;
  height: 0.35rem;
  background: var(--ink-muted);
}
@keyframes intro-line-in {
  from { opacity: 0; }
  to   { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) {
  .introReportLine { animation: none; }
}

/* ---- Tasks --------------------------------------------------------------- */

.boardLines {
  flex: 1 1 auto;
  min-height: 0;
  margin: 0;
  padding: 0.5rem 0.6rem;
  list-style: none;
  overflow-y: auto;
}

/* A line is plain text in a list. Its inline padding and transparent border are
   the same ones the selected card uses, so tapping a line only grows it
   downward: the text does not shift sideways. The transition covers every
   property .is-selected below actually changes, so the lift reads as one
   continuous motion together with .lineActionsWrap's height, rather than a
   smooth height change surrounded by everything else snapping around it. */
.lineItem {
  padding-inline: 0.6rem;
  border: 1px solid transparent;
  box-shadow: 0 1px 0 var(--line);
  transition: margin-block 260ms ease, border-color 260ms ease,
    border-radius 260ms ease, background-color 260ms ease, box-shadow 260ms ease;
}
.lineItem:hover:not(.is-selected) { background: var(--surface-2); }
/* No divider above a card (it sets itself apart) or below the last line
   (nothing to divide from), matching the first line's implicit lack of one. */
.lineItem:has(+ .is-selected),
.lineItem:has(+ .is-pending),
.lineItem:last-child { box-shadow: none; }

.lineButton {
  display: block;
  width: 100%;
  padding: 0.9rem 0;
  border: none;
  background: transparent;
  text-align: start;
  line-height: 1.4;
  overflow-wrap: anywhere;
}

/* A line departing on a move (see views/board-lines and views/leaving-line):
   collapses, slides a short distance up (a promotion) or down (a demotion,
   matching the dot's own direction — see subs/dot-exit), and fades, in
   place — never travelling, per NOTES.md's Animation section.

   `.is-sliding-up`/`.is-sliding-down` alone (`.is-departing` not yet added)
   is the priming step: a generous, no-op `max-height` establishes a real
   number to transition away from, since a plain line has no max-height
   ordinarily (its height depends on how its own text wraps, so nothing
   fixed could be picked ahead of time) and a transition cannot animate away
   from a property that was never a number to begin with. 8rem is an
   educated guess at "comfortably more than a task line should ever need,
   even wrapped a couple of lines" — same reasoning, and the same caveat
   about a too-generous guess distorting the timing, as `.lineActionsWrap`'s
   own max-height guess above. Adding `.is-departing` a frame later is what
   actually starts the collapse. */
.lineItem.is-sliding-up,
.lineItem.is-sliding-down {
  overflow: hidden;
  max-height: 8rem;
  opacity: 1;
  transform: translateY(0);
}
.lineItem.is-sliding-up.is-departing {
  max-height: 0;
  opacity: 0;
  transform: translateY(-0.6rem);
  /* Kept in sync with the dot's own slide (.boardDot.is-sliding-up/-down
     above, views/slide-duration-ms in views.cljs) and with
     views/line-slide-hold-ms — see both. */
  transition: max-height 550ms ease-in, opacity 550ms ease-in, transform 550ms ease-in;
}
.lineItem.is-sliding-down.is-departing {
  max-height: 0;
  opacity: 0;
  transform: translateY(0.6rem);
  transition: max-height 550ms ease-in, opacity 550ms ease-in, transform 550ms ease-in;
}
@media (prefers-reduced-motion: reduce) {
  .lineItem.is-sliding-up.is-departing,
  .lineItem.is-sliding-down.is-departing {
    transition: none;
    max-height: 0;
    opacity: 0;
  }
}

/* Tapped: it lifts, and its actions appear. */
.lineItem.is-selected {
  margin-block: 0.5rem;
  border-color: var(--edge);
  border-radius: 0.75rem;
  background: var(--surface-2);
  box-shadow: 0 0.25rem 0.75rem color-mix(in oklch, var(--ink) 12%, transparent);
}
.lineItem.is-selected .lineButton { font-weight: 600; }

/* The actions area is always mounted (see views/line-item), collapsed to
   nothing by default. Two approaches were tried here:
   - The 0fr/1fr grid-rows trick (an ordinary `height` transition can't
     animate to/from `auto`, and this content's real height depends on how
     the buttons happen to wrap, so a fixed pixel target was never an
     option). Abandoned after two real-use rounds of it still visibly not
     reaching zero: first a missing `overflow: hidden` on the wrapper itself
     was suspected (added — no change), then `.actionButton`'s own explicit
     `min-height: 2.75rem` floor was suspected and zeroed while `:disabled`
     (also no change). Both were reasonable, targeted fixes for real gaps in
     the technique as applied — neither actually moved the result, which
     means the technique's own core premise (that a grid item's rendered box
     gets forced down to its 0fr track regardless of its content's own
     sizing, with overflow:hidden clipping the rest) is not holding up in
     practice here, for a reason not pinned down through reasoning about the
     CSS alone, without a real browser to inspect computed values in.
   - What's built now instead: a `max-height` transition. Less elegant —
     `max-height` needs an explicit target for the expanded state (no
     animating to `auto`/`none`), so an educated guess (`6rem`) stands in for
     "comfortably more than this content should ever need, including a
     6-button Someday row wrapped to two lines at a narrow width" — a
     mismatch between that guess and the content's real height will make the
     transition look like it finishes early and then pauses, rather than
     running smoothly for its whole nominal duration; picking a closer guess
     is the fix if that turns out to be visible. In exchange, `max-height` on
     a plain block element is a hard, unconditional ceiling on that
     element's own rendered size — nothing about a descendant's own sizing
     (explicit min-height, padding, content) can push past it; whatever
     doesn't fit is what `overflow: hidden` clips. No grid-item "automatic
     minimum size" question to get right at all, which is exactly the class
     of subtlety the previous two attempts kept running into. */
.lineActionsWrap {
  max-height: 0;
  overflow: hidden;
  transition: max-height 260ms ease;
}
.lineItem.is-selected .lineActionsWrap {
  max-height: 6rem;
}
/* Collapsed buttons are `:disabled` (see action-button) purely so they take
   no tab stops and do nothing if somehow reached — not to look disabled,
   since they're about to be clipped to nothing anyway. Overrides the global
   disabled-button dimming so a card's buttons never visibly flicker to 40%
   opacity partway through the collapse, before the clip catches up to them.
   min-width/min-height were also zeroed here at first, left over from the
   old grid-rows technique (see above) where they mattered — found, in real
   use, to be actively harmful now: `:disabled` flips in the same instant
   `is-selected` does, not smoothly over the 260ms `max-height` runs, so the
   buttons instantly lost their size floor and visibly shrank/reflowed
   (buttons on a wrapped second row snapping onto one) well before the
   collapse had caught up. Since `max-height`'s overflow clip does not care
   what a child demands anyway, unlike the grid track it replaced, there was
   never a reason to zero them beyond "seemed harmless" — removed. */
.lineActionsWrap .actionButton:disabled {
  opacity: 1;
  cursor: default;
}
@media (prefers-reduced-motion: reduce) {
  .lineItem,
  .lineActionsWrap { transition: none; }
}

/* What Complete or Delete replace the line with, until the undo window ends.
   Deliberately its own look, not .is-selected's: a flatter background (--bg,
   not the --surface-2 a selected card uses) so the two are never mistaken for
   each other, plus a draining fill that empties out exactly as the undo
   window does, so the timeout itself is the thing you see, not just implied. */
.lineItem.is-pending {
  position: relative;
  overflow: hidden; /* clip the fill to the rounded corners */
  margin-block: 0.5rem;
  border-color: var(--edge);
  border-radius: 0.75rem;
  background: var(--bg);
  box-shadow: none; /* drop the plain divider, as .is-selected already does */
}
.lineItem.is-pending::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  transform-origin: bottom;
  animation: drain linear forwards;
  animation-duration: var(--undo-ms, 5000ms); /* set inline; kept in sync with events/undo-window-ms */
}
/* Complete fills up (empty to full, arriving whole exactly at completion);
   Delete drains away (full to empty, dissipating). Same keyframe, played in
   opposite directions, so the two read differently by motion alone, not just
   colour — including for anyone who can't tell the colours apart. */
.lineItem.complete::before {
  background: color-mix(in oklch, var(--accent) 20%, transparent);
  animation-direction: reverse;
}
.lineItem.delete::before { background: color-mix(in oklch, var(--ink) 12%, transparent); }
@keyframes drain {
  from { transform: scaleY(1); }
  to   { transform: scaleY(0); }
}
/* A static hint instead of a countdown, for anyone who asked for less motion. */
@media (prefers-reduced-motion: reduce) {
  .lineItem.is-pending::before { animation: none; transform: scaleY(0.5); }
}

.pendingRow {
  position: relative; /* stacks above the draining fill */
  z-index: 1;
  display: flex;
  align-items: center;
  gap: 0.6rem;
  padding: 0.75rem;
}
.pendingRow .icon { flex: none; }
.lineItem.complete .icon { color: var(--accent); }
.lineItem.delete .icon   { color: var(--ink-muted); }
.pendingText {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--ink-muted);
}
.pendingUndo {
  flex: none;
  min-height: 2.25rem;
  padding: 0.25rem 0.9rem;
  border-radius: 99px;
  font-weight: 600;
}

/* Complete and Delete at opposite ends; the moves in between. Every gap here
   (and .actionButton's own padding, below) is trimmed as tight as it can go
   without touching font size or icon size, purely to keep a line's row of
   buttons on one line at narrow phone widths instead of wrapping and pushing
   the selected card taller. Complete/Delete stay full-size regardless: their
   width is pinned by .actionButton's min-width, a tap-target floor, not by
   padding, so nothing here shrinks them. */
.lineActions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.4rem;
  padding-bottom: 0.6rem;
}
.lineMoves {
  display: flex;
  flex-wrap: wrap;
  justify-content: center;
  gap: 0.3rem;
}

.actionButton {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.3rem;
  min-width: 2.75rem;
  min-height: 2.75rem;
  padding: 0.25rem 0.55rem;
  border-radius: 0.6rem;
  font-size: 0.9rem;
  user-select: none; /* a tap-and-hold or a slightly dragged tap shouldn't be
                         able to select "Now" or "Week" as text */
}
/* The default button hover (--surface-2) is invisible here: a selected card
   already sits on --surface-2 itself. Needs its own, visibly different step. */
.actionButton:hover:not(:disabled) {
  background: color-mix(in oklch, var(--edge) 35%, var(--surface-2));
}
.actionButton:active:not(:disabled) {
  background: color-mix(in oklch, var(--edge) 60%, var(--surface-2));
}
/* Icons carry the colour; labels stay in ink so they keep their contrast. */
.actionComplete .icon { color: var(--accent); }
.actionNow .icon      { color: var(--alt); }
.actionDelete .icon   { color: var(--ink-muted); }

/* Shown in the add-box's own spot while a level that just emptied counts down
   to actually closing (views/closing-countdown, events/settle) — the one
   thing that makes an otherwise-ordinary-looking empty level read as "about
   to close" rather than just quiet. */
.closingCountdown {
  flex: none;
  margin: 0;
  padding: 0.75rem 1rem;
  text-align: center;
  color: var(--ink-muted);
  font-variant-numeric: tabular-nums;
}

/* ---- Adding -------------------------------------------------------------- */

.addLine {
  display: flex;
  flex: none;
  gap: 0.5rem;
  padding: 0.75rem 1rem;
}
.addLine input {
  flex: 1;
  min-width: 0;
  min-height: 2.75rem;
  padding: 0.25rem 1rem;
  border: 1px solid var(--edge);
  border-radius: 99px;
  background: var(--bg);
  color: inherit;
  font-size: 1rem; /* 16px keeps phones from zooming in on focus */
}
.addLine input::placeholder { color: var(--ink-muted); }
.addLine input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.addLine button {
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  border-radius: 50%;
}
/* Ready: something is typed. Otherwise it stays quiet and unresponsive. */
.addLine button:not(:disabled) {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}
.addLine button:not(:disabled):hover {
  background: color-mix(in oklch, var(--accent) 82%, var(--ink));
}
.addLine button:not(:disabled):active {
  background: color-mix(in oklch, var(--accent) 68%, var(--ink));
}
.addLine button:disabled { cursor: not-allowed; }

/* ---- Footer -------------------------------------------------------------- */

/* A separate band, not part of the levels: its own tone and a heavy top edge. */
.controlsPanel {
  display: flex;
  flex: none;
  align-items: center;
  gap: 0.5rem;
  padding: 0.75rem 1rem max(0.75rem, env(safe-area-inset-bottom));
  background: var(--surface-2);
  border-top: 3px solid var(--edge);
}
/* the band is the hover colour, so hover has to go the other way */
.controlsPanel button:hover:not(:disabled) { background: var(--bg); }

/* Dev only (see brainboard.config/debug?); never ships. Deliberately plainer
   than the real controls, so it doesn't read as one of them. */
/* Dev only (see brainboard.config/debug?; never ships — see debug-panel in
   views.cljs). One popover instead of loose buttons directly in the footer:
   two of them already crowded it, and every debug control added later would
   only have made that worse. Sits at the left, so its body opens from the
   left edge too, rather than floating away from the button that opens it. */
.debugPanel { position: relative; }

.debugToggle {
  min-height: 2.75rem;
  padding: 0.25rem 0.75rem;
  border-style: dashed;
  border-radius: 99px;
  font-size: 0.8rem;
  color: var(--ink-muted);
}

.debugBody {
  position: absolute;
  inset-inline-start: 0;
  bottom: calc(100% + 0.5rem);
  z-index: 10;
  display: grid;
  gap: 0.5rem;
  min-width: 11rem;
  padding: 0.75rem;
  border: 2px dashed var(--edge);
  border-radius: 0.75rem;
  background: var(--surface);
  box-shadow: 0 0.5rem 1.5rem color-mix(in oklch, var(--ink) 22%, transparent);
}
.debugBody button { width: 100%; }

.focusToggle {
  min-height: 2.75rem;
  padding: 0.25rem 1rem;
  border-radius: 99px;
  user-select: none;
}
.focusToggle[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}
.controlsPanel .focusToggle[aria-pressed="true"]:hover {
  background: color-mix(in oklch, var(--accent) 82%, var(--ink));
}

/* The only way into Someday: present only when there's something in it and
   it isn't already open, so it reads as an occasional door, not a fixture. */
.somedayToggle {
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  border: 2px solid var(--edge);
  border-radius: 50%;
}
.somedayToggle:hover {
  background: oklch(clamp(0, calc(var(--lightness) + 0.08), 1)
                     calc(var(--saturation) * 0.06) var(--hue-base));
}
.somedayToggle .icon {
  width: 1.5rem;
  height: 1.5rem;
}

/* ---- Appearance ---------------------------------------------------------- */

/* Settings and Appearance sit together at the far right of the footer; the
   auto margin lives on whichever of the pair comes first in the markup. */
.settingsPanel {
  position: relative;
  margin-inline-start: auto;
}

/* Appearance carries the same auto margin, so it still anchors itself to the
   right when Settings is hidden (Focus Mode) and it's on its own — but when
   Settings is present right before it, that would split the leftover space
   between the two instead of grouping them, pushing them apart rather than
   keeping them adjacent. Cancelled in that case, leaving Settings' own
   margin to push the pair together. */
.appearancePanel {
  position: relative;
  margin-inline-start: auto;
}
.settingsPanel + .appearancePanel {
  margin-inline-start: 0;
}

.appearanceToggle {
  display: grid;
  place-items: center;
  width: 3rem;
  height: 3rem;
  padding: 0;
  border: none;
  border-radius: 50%;
}
/* The icon is the whole button, and its own ring is its boundary, so hover and
   open just get a soft fill behind it rather than a second ring. Always
   lightened, regardless of the current theme: --surface-2 moves *toward ink*,
   which reads fine on a light background but darkens a dark one, weakening
   contrast right where the button most needs it. */
.appearanceToggle:hover,
.appearanceToggle[aria-expanded="true"] {
  background: oklch(clamp(0, calc(var(--lightness) + 0.08), 1)
                     calc(var(--saturation) * 0.06) var(--hue-base));
}
.appearanceToggle .icon {
  width: 2.75rem;
  height: 2.75rem;
  stroke-width: 1.5;
}

/* The palette icon's paint dots use the current palette, so it is a live swatch. */
.icon .dot-accent { fill: var(--accent); stroke: none; }
.icon .dot-alt    { fill: var(--alt);    stroke: none; }
.icon .dot-ink    { fill: var(--ink);    stroke: none; }

.appearanceBody {
  position: absolute;
  inset-inline-end: 0;
  bottom: calc(100% + 0.5rem);
  z-index: 10;
  display: grid;
  gap: 1rem;
  width: 18rem;
  padding: 1rem;
  border: 2px solid var(--edge);
  border-radius: 0.75rem;
  background: var(--surface);
  box-shadow: 0 0.5rem 1.5rem color-mix(in oklch, var(--ink) 22%, transparent);
}

/* ---- Settings -------------------------------------------------------------
   The same popover mechanics as Appearance above (toggle + anchored body,
   dismiss.cljs), a plain gear rather than a swatch, and form-shaped content.
*/

.settingsToggle {
  display: grid;
  place-items: center;
  width: 3rem;
  height: 3rem;
  padding: 0;
  border: 2px solid var(--edge);
  border-radius: 50%;
}
.settingsToggle:hover,
.settingsToggle[aria-expanded="true"] {
  background: oklch(clamp(0, calc(var(--lightness) + 0.08), 1)
                     calc(var(--saturation) * 0.06) var(--hue-base));
}
.settingsToggle .icon {
  width: 1.5rem;
  height: 1.5rem;
}

.settingsBody {
  position: absolute;
  inset-inline-end: 0;
  bottom: calc(100% + 0.5rem);
  z-index: 10;
  display: grid;
  gap: 1rem;
  width: 18rem;
  padding: 1rem;
  border: 2px solid var(--edge);
  border-radius: 0.75rem;
  background: var(--surface);
  box-shadow: 0 0.5rem 1.5rem color-mix(in oklch, var(--ink) 22%, transparent);
}

.settingsField {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  font-size: 0.9rem;
}
.settingsField select {
  padding: 0.4rem 0.6rem;
  border: 1px solid var(--edge);
  border-radius: 0.5rem;
  background: var(--bg);
  color: inherit;
  font: inherit;
}

.settingsActions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}
.settingsActions button { flex: 1 1 auto; }

/* Reachable only via the visible button beside it; kept in the layout (not
   display:none) so screen readers relying on layout still find it via the
   button's own click, and to avoid iOS Safari's habit of ignoring clicks on
   display:none file inputs entirely. */
.importInput {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
}

.settingsInstall { width: 100%; }

/* The build's own version (see subs/app-version) — placed last, an on-demand
   fact for the on-demand surface, not something anyone needs to see without
   asking. Absent entirely in development (no <meta> tag to read), not shown
   as a placeholder, same as every other conditional item in this panel. */
.settingsVersion {
  margin: 0.25rem 0 0;
  font-size: 0.75rem;
  color: var(--ink-muted);
  text-align: end;
  font-variant-numeric: tabular-nums;
}

/* ---- Focus timer ----------------------------------------------------------
   Same popover mechanics as Appearance/Settings above (views/focus-timer-panel).
   Anchored from the toggle's own start edge, like the debug panel, rather than
   the end edge Settings/Appearance use — this toggle sits mid-footer, not at
   its far right edge, so an end-anchored body would risk running off screen. */
.focusTimerPanel { position: relative; }

.focusTimerToggle {
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  border: 2px solid var(--edge);
  border-radius: 50%;
}
/* No hover rule of its own for the plain/is-running states: the footer's own
   `.controlsPanel button:hover:not(:disabled) { background: var(--bg); }`
   (see Footer, above) already covers every plain button here, settingsToggle
   and appearanceToggle included, and — found only now, while chasing this —
   actually OUTRANKS anything written as just `.focusTimerToggle:hover`
   on specificity (that generic rule carries a bare `button` type selector
   most single-purpose overrides don't bother matching against, which turned
   out to matter here): two different lightness-relative formulas were tried
   here across two earlier passes, and neither one ever actually applied —
   `var(--bg)` from that generic rule was quietly winning both times. Removed
   rather than left in as dead weight; is-finished, below, needs its own
   explicit override for a real reason (`--on-accent` isn't guaranteed to
   contrast against `--bg`, only against `--accent`), and follows the same
   `.controlsPanel <button-class>...` specificity-boosting shape
   `.controlsPanel .focusToggle[aria-pressed="true"]:hover` (Footer, above)
   already establishes, for exactly this same reason. */
/* A quiet accent outline while running — not a ticking readout of its own;
   see views/focus-timer-panel on why the countdown itself waits for the
   popover to actually be opened. */
.focusTimerToggle.is-running {
  border-color: var(--accent);
  color: var(--accent);
}
/* A stronger, filled treatment for a timer that finished while the popover
   was closed — deliberately more insistent than the outline above, since
   this is the one moment this app actually wants to catch the eye, and a
   gentle pulse to go with it (paused for reduced motion, same as every
   other animation in this app). Both clear the moment the popover is opened
   (views/focus-timer-panel's `seen?`), reverting to plain, not to
   `is-running` — a looked-at, still-unstopped timer isn't "running"
   anymore, it's just sitting there until Stop is pressed. */
.focusTimerToggle.is-finished {
  border-color: var(--accent);
  background: var(--accent);
  color: var(--on-accent);
  animation: focus-timer-pulse 1.6s ease-in-out infinite;
}
@keyframes focus-timer-pulse {
  0%, 100% { box-shadow: 0 0 0 0 color-mix(in oklch, var(--accent) 55%, transparent); }
  50%      { box-shadow: 0 0 0 0.4rem color-mix(in oklch, var(--accent) 0%, transparent); }
}
@media (prefers-reduced-motion: reduce) {
  .focusTimerToggle.is-finished { animation: none; }
}
/* Needs to actually outrank `.controlsPanel button:hover:not(:disabled)`
   (Footer, above) on specificity, not just look more specific by eye — that
   rule carries a bare `button` type selector that a plain
   `.focusTimerToggle.is-finished:hover` (two classes) does not out-rank, the
   exact mistake the first two attempts at this both made silently. Prefixing
   `.controlsPanel`, the same shape `.controlsPanel
   .focusToggle[aria-pressed="true"]:hover` already uses below for the same
   reason, gets there. on-accent-over-accent is what this state is FOR (the
   whole point is to catch the eye) — hovering it must never soften that
   toward whatever `var(--bg)` happens to be at the current appearance
   settings, since `--on-accent` was only ever computed to contrast against
   `--accent`, not against `--bg`. */
.controlsPanel .focusTimerToggle.is-finished:hover,
.controlsPanel .focusTimerToggle.is-finished[aria-expanded="true"] {
  background: var(--accent);
  color: var(--on-accent);
}
.focusTimerToggle .icon {
  width: 1.5rem;
  height: 1.5rem;
}

.focusTimerBody {
  position: absolute;
  inset-inline-start: 0;
  bottom: calc(100% + 0.5rem);
  z-index: 10;
  display: grid;
  gap: 0.75rem;
  /* A fixed width, not min-width: sized by its content, this popover used to
     snap a few pixels wider or narrower depending on what was inside it (a
     time input's intrinsic width differs from a number input's; a preset
     label can change length as presets learn — see views/focus-timer-picker).
     Fixed, with every input below allowed to shrink (min-width: 0) and every
     preset label bounded, nothing inside can push it either way. */
  width: 16.5rem;
  max-width: calc(100vw - 1rem);
  padding: 1rem;
  border: 2px solid var(--edge);
  border-radius: 0.75rem;
  background: var(--surface);
  box-shadow: 0 0.5rem 1.5rem color-mix(in oklch, var(--ink) 22%, transparent);
}

.focusTimerPicker {
  display: grid;
  gap: 0.6rem;
}
/* A fixed 3-column grid, not flex-wrap: the five minute presets plus the
   Until button wrap predictably into two even rows this way. flex-wrap left
   the last button alone on its own row, stretching to fill the full width
   while its neighbours sat three-to-a-row above it — looked like a mistake,
   not a layout. */
.focusTimerPresets {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 0.4rem;
}
/* The minmax(0, 1fr) columns above and a nowrap, clipped label: the grid can never be
   pushed wider by a long label (see .focusTimerBody's fixed width). The
   longest label a preset can ever show — "5h:59m", bounded by the manual cap —
   fits with room to spare at this padding. */
.focusTimerPresets button {
  min-height: 2.5rem;
  padding: 0.4rem 0.25rem;
  border-radius: 0.5rem;
  white-space: nowrap;
  overflow: hidden;
  font-variant-numeric: tabular-nums;
}
/* Until is a toggle, not an immediate action like its five neighbours. A
   filled accent when on — the same treatment .focusToggle uses for its own
   pressed state — since an outline-only version was nearly indistinguishable
   from the resting state. The hover override needs the .controlsPanel prefix
   for the same specificity reason as .focusTimerToggle.is-finished (see
   there): the footer's generic button hover would otherwise repaint this
   back to var(--bg) under a now-mismatched --on-accent label. */
.focusTimerPresets button[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--on-accent);
}
/* Hover for every button in the popover. Without this they had NONE worth
   the name: the footer's generic `.controlsPanel button:hover:not(:disabled)`
   sets var(--bg), which sits almost exactly on this popover's own --surface —
   technically applied, visually nothing. --surface-2 (what the global default
   hover would have used, had the footer rule not outranked it) is visibly
   different. Five class-level selectors here beat the footer rule's four. */
.controlsPanel .focusTimerBody button:hover:not(:disabled) {
  background: var(--surface-2);
}
/* Until has two distinct "primary" states, so its hover needs to work in
   both: unpressed just takes the neutral hover above; pressed gets the same
   darker-toward-ink accent .focusToggle's own pressed hover uses — mixing
   toward --ink only ever raises contrast against --on-accent, in either
   theme, so the label stays legible. */
.controlsPanel .focusTimerBody .focusTimerPresets button[aria-pressed="true"]:hover {
  background: color-mix(in oklch, var(--accent) 82%, var(--ink));
  border-color: var(--accent);
  color: var(--on-accent);
}
.focusTimerCustom {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}
.focusTimerCustom input {
  flex: 1;
  min-width: 0;
  min-height: 2.5rem;
  padding: 0.25rem 0.75rem;
  border: 1px solid var(--edge);
  border-radius: 0.5rem;
  background: var(--bg);
  color: inherit;
  font: inherit;
}
/* Real, specific feedback for going over the cap — not a standing caption,
   reusing --accent rather than inventing a "danger" colour this app's
   otherwise-restrained palette has never needed (Delete, for instance, uses
   --ink-muted, not red). The Start button's own label ("Too Long") carries
   the rest of the explanation; this just marks where the problem is. */
.focusTimerCustom input.is-over { border-color: var(--accent); }
/* Until mode: the time field picks up the same accent as the pressed Until
   button above it, so the two read as one thing at a glance — the field only
   exists because that button is on, which nothing else would tell anyone
   seeing it appear. The ring is a box-shadow, not a thicker border, so it
   can never change the field's size. */
.focusTimerCustom.is-until input {
  border-color: var(--accent);
  box-shadow: 0 0 0 1px var(--accent);
}
.focusTimerCustom button {
  flex: none;
  min-height: 2.5rem;
  padding: 0.4rem 0.9rem;
  border-radius: 0.5rem;
}
.focusTimerCustom button:disabled { cursor: not-allowed; }

.focusTimerReadout {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}
/* Same accent/line gradient-to-a-point trick as .boardSection.is-open
   .boardElapsed, just standalone and pill-shaped rather than sitting flush
   against a section's own bottom edge. Which fraction is passed in (time
   remaining or time elapsed) is decided in views/focus-timer-readout, from
   config/:timer-direction — the CSS itself doesn't know which. */
.focusTimerBar {
  flex: 1;
  height: 0.4rem;
  border-radius: 99px;
  background: linear-gradient(90deg,
    var(--accent) calc(var(--fraction) * 100%),
    var(--line) 0);
}
.focusTimerTime {
  flex: none;
  min-width: 3rem;
  text-align: center;
  font-variant-numeric: tabular-nums;
}
.focusTimerStop {
  flex: none;
  display: grid;
  place-items: center;
  width: 2rem;
  height: 2rem;
  padding: 0;
  border: 1px solid var(--edge);
  border-radius: 50%;
}
.focusTimerStop .icon {
  width: 1.1rem;
  height: 1.1rem;
}

/* ---- Popovers on narrow screens -------------------------------------------
   Anchored to their own toggle (start or end edge, per popover), a popover
   can run off the viewport once the screen is thin enough — the Focus timer's
   16.5rem body, anchored mid-footer, did on a 400px screen. Below this width
   every popover instead centres on the footer, which spans the viewport, and
   is capped at its width minus a small margin: full available size, no
   run-off, and every popover's bottom edge lines up at the same height.
   Placed after all the popover rules above so it wins on source order
   (equal specificity). The panels drop `position: relative` so the footer
   becomes the containing block; `left: 0; right: 0; margin: auto` centres a
   box with a definite width with no transform needed. The bottom offset
   drops the popover's lower edge slightly INTO the footer (above the toggles'
   own tops, which sit 0.75rem down), not just above it: sitting right atop
   the lowest level's progress bar, the two parallel lines fought each other;
   this way the bar passes behind the popover instead. */
@media (max-width: 30rem) {
  .controlsPanel { position: relative; }
  .debugPanel, .settingsPanel, .appearancePanel, .focusTimerPanel { position: static; }
  .debugBody, .settingsBody, .appearanceBody, .focusTimerBody {
    inset-inline: 0;
    margin-inline: auto;
    bottom: calc(100% - 0.25rem);
    max-width: calc(100% - 1rem);
  }
  .debugBody { width: fit-content; }
}

.slider {
  display: grid;
  grid-template-columns: 1fr auto;
  gap: 0.35rem 0.5rem;
  font-size: 0.85rem;
}
.slider input { grid-column: 1 / -1; }
.slider output { color: var(--ink-muted); font-variant-numeric: tabular-nums; }

/* The track previews what dragging does. */
.slider[data-control="lightness"] {
  --track: linear-gradient(90deg,
    oklch(0.14 calc(var(--saturation) * 0.045) var(--hue-base)),
    oklch(0.98 calc(var(--saturation) * 0.045) var(--hue-base)));
}
.slider[data-control="saturation"] {
  --track: linear-gradient(90deg,
    oklch(var(--l-accent) 0    var(--hue-accent)),
    oklch(var(--l-accent) 0.19 var(--hue-accent)));
}

.slider input[type="range"] {
  -webkit-appearance: none;
  appearance: none;
  width: 100%;
  height: 1.25rem;
  background: transparent;
  cursor: pointer;
}
.slider input[type="range"]::-webkit-slider-runnable-track {
  height: 0.75rem;
  border: 1px solid var(--edge);
  border-radius: 99px;
  background: var(--track);
}
.slider input[type="range"]::-moz-range-track {
  height: 0.75rem;
  border: 1px solid var(--edge);
  border-radius: 99px;
  background: var(--track);
}
.slider input[type="range"]::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 1.25rem;
  height: 1.25rem;
  margin-top: -0.3125rem;
  border: 2px solid var(--bg);
  border-radius: 50%;
  background: var(--ink);
  box-shadow: 0 0 0 1px var(--edge);
}
.slider input[type="range"]::-moz-range-thumb {
  width: 1.25rem;
  height: 1.25rem;
  border: 2px solid var(--bg);
  border-radius: 50%;
  background: var(--ink);
  box-shadow: 0 0 0 1px var(--edge);
}

.palettes {
  display: flex;
  flex-wrap: wrap;         /* seven swatches don't fit the popover on one row */
  justify-content: center;
  gap: 0.75rem;
}

.swatch {
  width: 2.25rem;
  height: 2.25rem;
  padding: 0;
  border: 2px solid var(--edge);
  border-radius: 50%;
  /* bg / accent / alt, each re-derived from this swatch's own data-palette */
  background: conic-gradient(var(--accent) 0 33.3%, var(--alt) 0 66.6%, var(--bg) 0);
}
.swatch:hover {
  outline: 2px solid var(--edge);
  outline-offset: 2px;
}
.swatch[aria-pressed="true"] {
  outline: 3px solid var(--ink);
  outline-offset: 2px;
}
