/* Streak Bot
 *
 * Direction: pre-dawn. The runs happen at an hour when nobody is awake, so the
 * ground is night indigo rather than the usual near-black dark theme, and the
 * only warm colour on the page is fire. Everything else is cool and quiet.
 *
 * The flame is the signature, because it is the subject: TikTok draws a streak
 * as a flame with a number, recoloured as the count climbs, and this app draws
 * the same object rather than inventing a chart for it. Colour is the reading —
 * see streakbot/flames.py for the bands and what is and is not known about them.
 *
 * The day strip is the second instrument, and it measures something different:
 * whether the bot ran. Kept days share a continuous rail along their base and a
 * missed day breaks it. The break is the information. It stays ember throughout,
 * because tinting it by streak length would quietly conflate our run history
 * with TikTok's count — two numbers that are not the same number.
 */

/* Fonts are linked from base.html, not @import-ed: an @import here would chain a
 * second render-blocking request behind this stylesheet. */

:root {
  color-scheme: dark;

  --night:    #101529;
  --night-2:  #171d36;
  --night-3:  #1f2745;
  --edge:     #2a3357;
  --ink:      #dde2f5;
  --ink-dim:  #7e88ad;

  --ember:      #ff8a3d;
  --ember-deep: #a8481a;
  --dawn:       #8fa7ff;
  --coral:      #ff5d6c;

  /* The heat ladder. A flame's colour is its streak length, so these six are a
   * scale rather than six unrelated accents — which is the only reason a night
   * palette can carry this many hues without turning into confetti. They appear
   * on flames and nowhere else.
   *
   * --h2 is --ember and --h5 is close to --dawn: the ladder absorbs the accents
   * the page already had instead of adding to them. All six clear 4.5:1 on
   * --night-2, which they have to, because each one is set on a number.
   */
  --h1: #ff5d6c;   /* day 3          */
  --h2: #ff8a3d;   /* 4–10           */
  --h3: #ffc93f;   /* 11–30          */
  --h4: #3ddc97;   /* 31–60          */
  --h5: #4fb0ff;   /* 61–100         */
  --h6: #b47cff;   /* 101 and up     */
  --ash: #7e88ad;  /* no badge yet   */

  --display: 'Bricolage Grotesque', ui-sans-serif, system-ui, sans-serif;
  --body:    'Instrument Sans', ui-sans-serif, system-ui, sans-serif;
  --data:    'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;

  --cell-w: 10px;
  --cell-h: 34px;
  --cell-gap: 7px;
  --r: 5px;

  /* Selection. Dawn, not ember, and that split is deliberate: ember is what the
   * bot does — a chat it sends to, a day it kept, a run in flight — while dawn is
   * already what the reader is pointing at, being the focus ring and the link
   * colour. Selecting rows is pointing, not sending, so it borrows the pointing
   * colour. It also means a row can be daily-off and selected at once without the
   * two states competing for the same hue, and the heat ladder stays on flames.
   *
   * The marker carries the selection and the wash only tints. Measured: --ink-dim
   * is the tightest text pair on the site at 4.75 on --night-2, so a wash above
   * 0.03 alpha drops the nickname and timestamp in a selected row under 4.5 (0.10
   * measured 3.97). The wash is therefore a whisper, and full --dawn on the
   * leading edge does the work at 6.87 against it.
   */
  --dawn-wash: rgba(143, 167, 255, 0.03);

  /* The chart bar fill. A deeper step of the same ember hue (49.9° against ember's
   * 50.5°), not ember itself, and the reason is measured: on a dark surface a filled
   * mark belongs in OKLCH L 0.48–0.67, and --ember sits at 0.75. Too light, so a field
   * of bars glares where a single flame reads as warm. This is L 0.635, C 0.152,
   * 4.58:1 on --night-2 — the only value here that passes every check the palette
   * validator applies to a one-series plot.
   *
   * The stylesheet already knew this, in a different place: the day strip spends full
   * ember on its 2px rail and fills its cells at 0.16 alpha, because saturated colour
   * belongs to small marks and thin lines rather than to blocks. Same rule, stated as
   * a token this time. */
  --plot: #d16a24;
}

*, *::before, *::after { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--night);
  color: var(--ink);
  font: 400 15px/1.6 var(--body);
  -webkit-font-smoothing: antialiased;
}

/* A single focus treatment everywhere, so keyboard users get the same signal. */
:focus-visible {
  outline: 2px solid var(--dawn);
  outline-offset: 2px;
  border-radius: 3px;
}

/* Prose links are underlined at rest. --dawn and --ink-dim share a hue (227deg)
 * and measure 1.52:1 against each other, so colour alone cannot mark a link —
 * the underline is the cue that carries it. */
a {
  color: var(--dawn);
  text-decoration: underline;
  text-decoration-thickness: from-font;
  text-underline-offset: 3px;
  text-decoration-skip-ink: auto;
}

/* Chrome and controls are identified by position or shape, not an underline. */
.brand, .topbar nav a, .btn { text-decoration: none; }
.topbar nav a:hover, .btn:hover { text-decoration: none; }

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 10;
  padding: 0.6rem 1rem;
  background: var(--night-3);
  color: var(--ink);
  border: 1px solid var(--edge);
  border-radius: 0 0 8px 0;
  text-decoration: none;
}
.skip-link:focus-visible { left: 0; }

/* ------------------------------------------------------------------ chrome */

/* The navigation, narrow-first.
 *
 * Below the rail's breakpoint this is a drawer, not a row of links — a scrolling
 * strip of five items was never a sidebar. The only chrome that stays on screen is
 * .railbar, holding the brand and the control that brings the drawer in.
 *
 * Closed, the rail is translated out AND visibility:hidden. The visibility is not
 * decoration: a rail that is only moved off-canvas keeps every link focusable, so a
 * keyboard reader would tab through five invisible destinations. It flips after the
 * slide finishes, hence the delayed transition, and back instantly on open. */

.railbar {
  display: flex; align-items: center; justify-content: space-between; gap: 1rem;
  padding: 0.8rem 1rem;
  border-block-end: 1px solid var(--edge);
}

/* Brand and control on one row, at both widths. Open on a narrow screen the drawer
 * covers this bar entirely — it is deliberately left below the scrim rather than
 * lifted above it, because the drawer carries its own close control. Lifting it while
 * it had no background is what painted two brands over each other. */
.rail-head {
  display: flex; align-items: center; justify-content: space-between; gap: 0.75rem;
  flex: none;
}

.nav-toggle {
  display: inline-grid; place-items: center;
  /* 44px, the touch figure for a primary control rather than the 24px floor. */
  inline-size: 44px; block-size: 44px;
  padding: 0;
  background: none;
  border: 1px solid var(--edge);
  border-radius: 9px;
  color: var(--ink-dim);
}
.nav-toggle:hover { color: var(--ink); background: var(--night-2); }
.nav-icon { inline-size: 20px; block-size: 20px; display: block; flex: none; }

/* The bars become a cross while the drawer is open, so the control says which way it
 * goes. One asset repositioned, not two SVGs swapped. */
.nav-bar-top, .nav-bar-mid, .nav-bar-bot { transform-origin: 12px 12px; }
[data-nav=open] .rail-toggle .nav-bar-top { transform: translateY(5px) rotate(45deg); }
[data-nav=open] .rail-toggle .nav-bar-bot { transform: translateY(-5px) rotate(-45deg); }
[data-nav=open] .rail-toggle .nav-bar-mid { opacity: 0; }

.topbar {
  position: fixed;
  inset-block: 0;
  inset-inline-start: 0;
  z-index: 9;
  inline-size: min(17rem, 82vw);
  display: flex; flex-direction: column; align-items: stretch;
  gap: 1.5rem;
  padding: 1.1rem 1rem;
  background: var(--night-2);
  border-inline-end: 1px solid var(--edge);
  overflow-y: auto;
  /* A drawer must not hand its scroll to the page behind it. */
  overscroll-behavior: contain;
  transform: translateX(-100%);
  visibility: hidden;
}
[data-nav=open] .topbar { transform: none; visibility: visible; }

/* At this width this is the drawer's close control rather than a collapse control,
 * which is what lets the drawer cover the bar above it and keep one brand on screen. */
.rail-toggle { display: inline-grid; }

.rail-scrim {
  position: fixed; inset: 0; z-index: 8;
  background: rgba(8, 10, 20, 0.72);
  opacity: 0;
}
[data-nav=open] .rail-scrim { opacity: 1; }
.rail-scrim[hidden] { display: none; }

@media (prefers-reduced-motion: no-preference) {
  .topbar { transition: transform 220ms cubic-bezier(0.2, 0, 0, 1), visibility 0s 220ms; }
  [data-nav=open] .topbar { transition-delay: 0s, 0s; }
  .rail-scrim { transition: opacity 220ms ease-out; }
  .nav-bar-top, .nav-bar-mid, .nav-bar-bot {
    transition: transform 200ms cubic-bezier(0.2, 0, 0, 1), opacity 140ms ease-out;
  }
}
/* Reduced motion gets a crossfade: a drawer sliding the width of the screen is
 * exactly the large-scale movement that preference asks us to drop. */
@media (prefers-reduced-motion: reduce) {
  .topbar { transform: none; opacity: 0; }
  [data-nav=open] .topbar { opacity: 1; }
}

.brand {
  font: 800 1.15rem/1 var(--display);
  letter-spacing: -0.03em;
  color: var(--ink);
}
/* The dot is the message the bot actually sends. */
.brand i { color: var(--ember); font-style: normal; }

/* The active marker is a leading edge, which is the device this file already uses
 * twice: a solid line carries the state and the surface stays quiet. */
.topbar nav { display: flex; flex-direction: column; gap: 0.15rem; flex: 1; }
.topbar nav a {
  display: flex; align-items: center; gap: 0.7rem;
  color: var(--ink-dim);
  font-size: 0.92rem;
  padding: 0.55rem 0.7rem;
  border-inline-start: 2px solid transparent;
  border-start-end-radius: 7px;
  border-end-end-radius: 7px;
}
.topbar nav a:hover { color: var(--ink); background: var(--night-3); text-decoration: none; }
.topbar nav a.on {
  color: var(--ink);
  border-inline-start-color: var(--ember);
  background: var(--night-3);
}
.nav-label { white-space: nowrap; }

.signout {
  display: flex; flex-direction: column; align-items: flex-start; gap: 0.4rem;
  margin: 0;
  padding-block-start: 1rem;
  border-block-start: 1px solid var(--edge);
}

.signed-in { color: var(--ink-dim); font: 400 0.85rem var(--data); }

main { max-width: 880px; margin: 0 auto; padding: 2.25rem 1.5rem 4rem; }

footer {
  max-width: 880px; margin: 0 auto; padding: 0 1.5rem 3rem;
  color: var(--ink-dim); font-size: 0.85rem;
}

/* ---------------------------------------------------------------- headings */

h1 {
  font: 700 1.75rem/1.15 var(--display);
  letter-spacing: -0.035em;
  margin: 0 0 0.4rem;
  /* Headings get balance: the landing h1 is two sentences capped at 22ch, which
   * without this wraps to three lines with a two-word last one. */
  text-wrap: balance;
}
h2 {
  font: 600 0.78rem/1 var(--body);
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--ink-dim);
  margin: 0 0 1.15rem;
  /* flex-wrap, because a heading with actions in it had no way to break. At 390px the
   * buttons kept their width and the heading text took what was left, so "Latest runs"
   * stacked into two lines beside a stacked Full history / Send now. Wrapping drops the
   * whole action group onto its own line and the heading stays one line, which is the
   * order they should give way in. */
  display: flex; align-items: center; justify-content: space-between;
  flex-wrap: wrap; gap: 0.5rem 1rem;
}
/* Controls sitting inside a section heading keep their own capitalization.
 * Without this an <a class="btn"> inherits the uppercase and a <button> does
 * not, so two adjacent peer actions read as two different policies. */
h2 .btn, h2 button { text-transform: none; letter-spacing: normal; }
/* pretty, not balance: these run to several lines, where balance evens the lines
 * and pretty is the one that stops a single word stranded on the last one. */
.sub { color: var(--ink-dim); margin: 0 0 2rem; max-width: 54ch; text-wrap: pretty; }

/* ------------------------------------------------------------------ panels */

.panel {
  background: var(--night-2);
  border: 1px solid var(--edge);
  border-radius: 12px;
  padding: 1.4rem;
  margin-bottom: 1.15rem;
}

.tiles { display: grid; gap: 1.15rem; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); }

.figure { font: 500 1.5rem/1.1 var(--display); letter-spacing: -0.02em; }
.figure-label {
  color: var(--ink-dim); font-size: 0.82rem; margin-top: 0.3rem;
  display: block;
}

/* ------------------------------------------------------- the flame: signature */

/* A count and its colour, drawn the way TikTok draws it, so a number needs no
 * legend to be read. Everything scales from font-size, which lets the same
 * markup serve a 96px hero and a 15px table cell. */

.flame {
  display: inline-flex; align-items: center; gap: 0.3em;
  color: var(--heat, var(--ash));
  line-height: 1;
}
/* Saving a count moves the flame between bands, and that recolouring is the whole
 * receipt for the save — so it is worth being able to see it happen rather than
 * finding a different colour already there. Colour only: a transition, so it is
 * interruptible, and the number beside it changes regardless. */
@media (prefers-reduced-motion: no-preference) {
  .flame { transition: color 260ms ease-out; }
}
.flame[data-tier="1"] { --heat: var(--h1); }
.flame[data-tier="2"] { --heat: var(--h2); }
.flame[data-tier="3"] { --heat: var(--h3); }
.flame[data-tier="4"] { --heat: var(--h4); }
.flame[data-tier="5"] { --heat: var(--h5); }
.flame[data-tier="6"] { --heat: var(--h6); }

.flame-glyph { width: 1em; height: 1em; flex: none; display: block; }
.flame-body { fill: currentColor; }
/* The hot centre. White rather than a lighter tint of the tier so one rule
 * serves all six bands. */
.flame-core { fill: #fff; opacity: 0.42; }

.flame-count {
  font: 500 0.82em var(--data);
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.01em;
}

/* No badge yet: hollow, the way a faded one reads in the inbox. */
.flame.is-unlit { color: var(--ash); }
.flame.is-unlit .flame-body { fill: none; stroke: currentColor; stroke-width: 1.7; }
.flame.is-unlit .flame-core { display: none; }
.flame.is-unlit .flame-count { font-size: 0.75em; }

/* ----------------------------------------------------------- the heat ladder */

/* The scale itself, as an instrument: discrete bands with their day ranges set
 * in the data face. Discrete because the real thing steps rather than blends,
 * and because a smooth gradient here would read as decoration. */

.ladder {
  list-style: none; margin: 2rem 0 0; padding: 0.9rem 0 0;
  border-top: 1px solid var(--edge);
  display: grid; gap: 0.55rem 0.4rem;
  grid-template-columns: repeat(auto-fit, minmax(76px, 1fr));
}
.ladder li {
  display: flex; flex-direction: column; align-items: center; gap: 0.4rem;
  padding: 0.45rem 0.2rem;
  border-radius: 8px;
  background: rgba(255, 255, 255, 0.02);
}
.ladder .flame { font-size: 1.35rem; }
.ladder-range {
  font: 500 0.72rem var(--data);
  color: var(--ink-dim);
  font-variant-numeric: tabular-nums;
}

/* --------------------------------------------------------- the hero's flame */

/* The page opens on the object it is about, at the size it deserves. */
.badge-demo {
  display: flex; align-items: center; gap: 1rem;
  flex-wrap: wrap;
  margin-bottom: 1.6rem;
}
.badge-demo .flame { font-size: clamp(3.4rem, 15vw, 5.4rem); }
.badge-demo .flame-count {
  font: 800 1em/0.82 var(--display);
  letter-spacing: -0.05em;
}
.badge-demo p {
  margin: 0; color: var(--ink-dim); font-size: 0.9rem; max-width: 20ch;
  /* Wraps below the flame instead of shrinking into a two-word column: at 360px
   * the flame and its count take most of the row on their own. */
  min-width: 16ch;
}

/* The same flame on the dashboard, where it shares a panel instead of opening a
 * page. Smaller, and the margin comes off — the panel already provides it. The
 * copy uncaps because there is room on a line here that the hero does not have. */
.badge-demo.is-panel { margin-bottom: 0; gap: 0.9rem; }
.badge-demo.is-panel .flame { font-size: clamp(2.4rem, 11vw, 3.4rem); }
.badge-demo.is-panel p { max-width: 34ch; }

/* One flicker, on the one flame that is meant to be looked at. Killed wholesale
 * by the reduced-motion block at the end of this file. */
.badge-demo .flame-core {
  transform-origin: 50% 70%;
  animation: flicker 3.6s ease-in-out infinite;
}
@keyframes flicker {
  0%, 100% { opacity: 0.42; transform: scaleY(1); }
  38%      { opacity: 0.58; transform: scaleY(1.06); }
  61%      { opacity: 0.34; transform: scaleY(0.95); }
}

/* --------------------------------------------- the day strip: did the bot run */

.streak-head {
  display: flex; align-items: baseline; gap: 0.6rem; flex-wrap: wrap;
  margin-bottom: 1.1rem;
}
.streak-count {
  font: 800 2.6rem/0.85 var(--display);
  letter-spacing: -0.05em;
  color: var(--ember);
  font-variant-numeric: tabular-nums;
}
.streak-unit { color: var(--ink-dim); font-size: 0.95rem; }

.strip {
  display: flex; gap: var(--cell-gap);
  align-items: flex-end;
  padding: 1rem 0 1rem;
}

/* The strip scrolls, so it has to be focusable — otherwise the days that are
 * off-screen are reachable by pointer only. It is one labelled region rather
 * than 35 focusable cells: individual 10px targets were both under the 24px
 * minimum and 35 tab stops between the streak and the rest of the page. */
.strip:focus-visible { outline: 2px solid var(--dawn); outline-offset: 4px; }

.cell {
  position: relative;
  flex: 0 0 var(--cell-w);
  height: var(--cell-h);
  border-radius: var(--r);
  background: rgba(255, 255, 255, 0.025);
  border: 1px solid var(--edge);
}

/* The rail is the streak. Kept days are quiet marks standing on a continuous
 * ember line; the line is what carries the meaning, so it gets the full-strength
 * colour and the cells stay recessive. Spending ember on 30 solid cells spread
 * the emphasis across everything and made the strip read as a progress bar —
 * which implies a destination a streak does not have. */
.cell.is-sent {
  background: rgba(255, 138, 61, 0.16);
  border-color: rgba(255, 138, 61, 0.45);
}
.cell.is-sent::after {
  content: '';
  position: absolute;
  left: 0; right: 0; bottom: -8px;
  height: 2px;
  background: var(--ember);
  border-radius: 2px;
}
/* Bridge into the gap only when the next day was kept too. Anything else —
 * a miss, a partial, an empty day — therefore leaves a visible break in the
 * line, which is the one thing a user needs to see. */
.cell.is-sent:has(+ .cell.is-sent)::after { right: calc(-1 * var(--cell-gap) - 1px); }

.cell.is-partial {
  background: rgba(255, 138, 61, 0.07);
  border-color: rgba(255, 138, 61, 0.55);
  border-style: dashed;
}

/* The break gets the emphasis: a hollow cell, a coral edge, and a tick sitting
 * where the rail should have continued. */
.cell.is-missed {
  background: transparent;
  border-color: var(--coral);
}
.cell.is-missed::after {
  content: '';
  position: absolute;
  left: 50%; bottom: -8px;
  width: 2px; height: 2px; margin-left: -1px;
  border-radius: 50%;
  background: var(--coral);
}

.cell.is-pending {
  border-color: var(--ember);
  border-style: dashed;
  animation: breathe 2.4s ease-in-out infinite;
}
.cell.is-today::before {
  content: '';
  position: absolute; left: 50%; top: -9px;
  width: 3px; height: 3px; margin-left: -1.5px;
  border-radius: 50%;
  background: var(--dawn);
}

.strip-legend {
  display: flex; gap: 1.25rem; flex-wrap: wrap;
  color: var(--ink-dim); font-size: 0.8rem;
  margin-top: 1.1rem; padding-top: 1rem;
  border-top: 1px solid var(--edge);
}
/* Room under each swatch for the mark that distinguishes it, which sits below the cell
 * in the strip and therefore below the swatch here. */
.strip-legend span { display: flex; align-items: center; gap: 0.45rem; padding-block-end: 5px; }

/* A legend has to be a miniature of the thing it explains, and this one was not.
 *
 * The strip tells days apart by the mark under the cell — a continuous ember rail for a
 * kept day, a coral dot for a missed one, nothing for a day with no run — and the swatches
 * carried none of it. What was left was a fill alpha of 0.16 against 0.07 against
 * transparent, and a 1px border switching between solid and dashed, inside an 8×15px box.
 * Rendered at 1440px and at 390px, "all reached", "some missed", "nothing sent" and "no
 * run" were four indistinguishable slivers.
 *
 * So the marks come across too. The fills stay exactly the cells' own values, because a
 * legend that brightens its swatches to be legible is no longer a legend. */
.swatch {
  position: relative;
  width: 10px; height: 15px; border-radius: 3px;
  background: rgba(255, 255, 255, 0.025); border: 1px solid var(--edge);
  flex: none;
}
.swatch.is-sent { background: rgba(255,138,61,0.16); border-color: rgba(255,138,61,0.45); }
/* The rail, which is what actually says "kept" in the strip. Drawn past both edges the
 * way a run of kept days bridges the gap between cells. */
.swatch.is-sent::after {
  content: '';
  position: absolute; left: -2px; right: -2px; bottom: -5px;
  height: 2px; border-radius: 2px;
  background: var(--ember);
}
.swatch.is-partial { background: rgba(255,138,61,0.07); border-color: rgba(255,138,61,0.55); border-style: dashed; }
.swatch.is-missed { background: transparent; border-color: var(--coral); }
.swatch.is-missed::after {
  content: '';
  position: absolute; left: 50%; bottom: -5px;
  width: 2px; height: 2px; margin-left: -1px;
  border-radius: 50%;
  background: var(--coral);
}
/* The rail on its own, at legend scale, so the caption naming it has something to point
 * at. No cell box: this row is about the line, not a day. */
.swatch.is-rail {
  width: 16px; height: 2px; border: none; background: var(--ember);
  border-radius: 2px;
}

@keyframes breathe {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.45; }
}

/* Landing hero: the strip ignites left to right once, then holds. */
.hero .cell { animation: ignite 460ms cubic-bezier(.2,.8,.3,1) backwards; animation-delay: calc(var(--i) * 32ms); }
@keyframes ignite {
  from { opacity: 0; transform: translateY(9px) scaleY(0.55); }
  to   { opacity: 1; transform: none; }
}

/* ------------------------------------------------------------------ flashes */

.flashes { list-style: none; padding: 0; margin: 0 0 1.75rem; }
.flash {
  padding: 0.75rem 0.95rem; border-radius: 9px; margin-bottom: 0.5rem;
  border: 1px solid var(--edge); background: var(--night-2);
  border-left-width: 3px;
}
.flash.success { border-left-color: var(--ember); }
.flash.error { border-left-color: var(--coral); }

.blockers { list-style: none; padding: 0; margin: 0; }
.blockers li {
  padding: 0.6rem 0.85rem; margin-bottom: 0.45rem;
  background: var(--night-3); border-radius: 0 8px 8px 0;
  border-left: 3px solid var(--coral);
}

/* ------------------------------------------------------------------ locked */

/* A page whose every control needs something that is not there yet. Same left-border
 * device as .caution and .blockers, because it is the same kind of statement: here is
 * what is stopping you, and here is the way out. Ember rather than coral — nothing is
 * broken, something is simply not set up. */
.panel.locked {
  border-inline-start: 3px solid var(--ember);
  border-start-start-radius: 0;
  border-end-start-radius: 0;
}
.panel.locked h2 { color: var(--ink); }
.panel.locked p { margin: 0 0 1.1rem; max-width: 62ch; }
.panel.locked p:last-child { margin-bottom: 0; }

/* ------------------------------------------------------------------- badges */

.tag {
  display: inline-block; padding: 0.15rem 0.5rem; border-radius: 5px;
  font: 500 0.74rem/1.4 var(--data);
  border: 1px solid var(--edge); color: var(--ink-dim); white-space: nowrap;
}
.tag.ok { color: var(--ember); border-color: var(--ember-deep); }
.tag.warn { color: #e8c07a; border-color: #6b5527; }
.tag.bad { color: var(--coral); border-color: #6f2a31; }
.tag.live { color: var(--dawn); border-color: #37427e; }

/* ------------------------------------------------------------------- tables */

/* Tables carry monospace timestamps and nowrap tags, so their minimum width can
 * exceed a 320px panel. Scrolling the table keeps the page itself free of
 * horizontal scroll; focusable because a scroll region that only responds to a
 * pointer is not operable by keyboard. Overflow and the edge fade come from
 * .scroll-x, which the wrappers also carry. */

/* Paint containment, because "scrolling the table keeps the page free of horizontal
 * scroll" was not actually true. The admin Accounts table is 687px inside a 226px
 * wrapper at a 320px viewport, and the wrapper scrolled it correctly — while the
 * overflow still reached documentElement, giving the whole page 227px of horizontal
 * scroll. Two-dimensional scrolling at 320px is a WCAG 1.4.10 failure, and it read as
 * a broken page rather than a wide table.
 *
 * Measured, not guessed: overflow-x: clip on main, body and html all left it at 227px,
 * and so did removing the widest control in the row. Containment on the scroller itself
 * is what took it to 0. It clips descendants to the padding box, which is what the
 * scroller already does, so nothing visible changes. */
.table-wrap { contain: paint; }
.table-wrap:focus-visible { outline: 2px solid var(--dawn); outline-offset: 3px; }

table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 0.6rem 0.55rem; border-bottom: 1px solid var(--edge); }
th {
  color: var(--ink-dim); font: 600 0.72rem/1 var(--body);
  letter-spacing: 0.09em; text-transform: uppercase;
}
tr:last-child td { border-bottom: none; }
.num { text-align: right; font-family: var(--data); font-variant-numeric: tabular-nums; }
/* Element-agnostic on purpose: this was td.handle, which silently matched none
 * of the <span class="handle"> usages, so a chat row's username inherited
 * --ink-dim from .who and read no louder than the nickname under it — while the
 * dashboard's handle lost the data face altogether. The colour is stated here
 * so the identifier leads wherever it appears. */
.handle { font: 400 0.88rem var(--data); color: var(--ink); }
td.when { color: var(--ink-dim); font: 400 0.84rem var(--data); }

/* -------------------------------------------------------------------- forms */

label { display: block; margin-bottom: 0.35rem; font-weight: 500; font-size: 0.92rem; }
/* Capped for the same reason .sub is: uncapped these ran to ~95 characters. */
.hint { color: var(--ink-dim); font-size: 0.84rem; margin: 0.35rem 0 0; max-width: 68ch; }

input[type=text], input[type=time], input[type=number], input[type=url],
input[type=file], select, textarea {
  width: 100%; padding: 0.6rem 0.7rem;
  background: var(--night); color: var(--ink);
  border: 1px solid var(--edge); border-radius: 8px;
  font: inherit;
}
textarea { min-height: 160px; font: 400 0.85rem/1.6 var(--data); resize: vertical; }
/* Reuses --ink-dim (5.18:1 here) rather than a dimmer one-off: the previous
 * #5a6488 measured 3.11:1, and these placeholders carry the expected format, so
 * they are meaningful text rather than decoration. */
input::placeholder, textarea::placeholder { color: var(--ink-dim); opacity: 1; }

/* The button inside a file input is drawn by the operating system, so styling the field
 * around it left a grey platform control sitting in a dark panel — on the one page whose
 * subject is how carefully this app handles a credential. Same tokens as a real button,
 * because that is what it is. */
input[type=file] { padding: 0.35rem 0.7rem; }
input[type=file]::file-selector-button {
  margin-inline-end: 0.7rem;
  padding: 0.3rem 0.7rem;
  border: 1px solid var(--edge); border-radius: 6px;
  background: var(--night-3); color: var(--ink);
  font: 500 0.86rem var(--body);
  cursor: pointer;
}
input[type=file]::file-selector-button:hover { background: #273054; }

.field { margin-bottom: 1.2rem; }
.field:last-child { margin-bottom: 0; }
.pair { display: flex; gap: 1.15rem; flex-wrap: wrap; }
.pair > .field { flex: 1; min-width: 170px; }

.check { display: flex; align-items: center; gap: 0.55rem; }
.check input { width: auto; accent-color: var(--ember); }
.check label { margin: 0; font-weight: 400; }

button, .btn {
  padding: 0.58rem 1rem; border-radius: 8px;
  border: 1px solid var(--edge); background: var(--night-3);
  color: var(--ink); font: 500 0.92rem var(--body);
  cursor: pointer;
}
button:hover, .btn:hover { background: #273054; text-decoration: none; }
/* Tactile confirmation that the press registered, on top of the state change. */
button:active, .btn:active { scale: 0.96; }
button, .btn { transition-property: background-color, border-color, scale; transition-duration: 120ms; }
button.go { background: var(--ember); border-color: var(--ember); color: #26150a; font-weight: 600; }
button.go:hover { background: #ffa05f; }
button.tiny { padding: 0.28rem 0.6rem; font-size: 0.82rem; }
button.bare {
  background: none; border: none; padding: 0; color: var(--ink-dim);
  font-size: 0.86rem; text-decoration: underline; text-underline-offset: 3px;
}
button.bare:hover { background: none; color: var(--ink); }
/* Destructive at rest, not only on hover. Touch has no hover state, and at rest
 * this was indistinguishable from "Sign out". Measures 5.56:1 on the panel. */
button.bare.risk { color: var(--coral); }
button.bare.risk:hover { color: #ff8b96; }
.acts { display: flex; gap: 0.6rem; flex-wrap: wrap; align-items: center; }
form.inline { margin: 0; display: inline-flex; }

/* The calibration field, in a table cell. Every other input on the site is
 * width:100%, which here would push the row past the viewport on its own — a
 * flame count is at most six digits, so this one is sized to its content. */
form.calibrate { gap: 0.35rem; align-items: center; }
form.calibrate input[type=number] {
  width: 7ch; padding: 0.28rem 0.4rem;
  font: 500 0.84rem var(--data);
  font-variant-numeric: tabular-nums;
  text-align: right;
}
/* The spinners cost more width at this size than the taps they save, and the
 * value is typed off a phone screen rather than nudged. */
form.calibrate input[type=number] { appearance: textfield; -moz-appearance: textfield; }
form.calibrate input[type=number]::-webkit-outer-spin-button,
form.calibrate input[type=number]::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }

/* How old the number is. The tally drifts above the real badge whenever a friend
 * stops replying, so the age is the reader's cue to check it again. */
.set-age { display: block; margin-top: 0.3rem; font-size: 0.74rem; }

/* --------------------------------------------------------------------- misc */

/* --ink, not --ink-dim: on code's own darker background --ink-dim measures
 * 4.19:1, and a lone "." — which is the whole payload this product sends — was
 * effectively invisible. */
code {
  background: var(--night-3); padding: 0.1rem 0.35rem; border-radius: 4px;
  color: var(--ink);
  font: 400 0.86em var(--data);
}
.dim { color: var(--ink-dim); }
.none-yet { color: var(--ink-dim); text-align: center; padding: 2rem 0; }

.caution {
  border: 1px solid var(--edge); border-left: 3px solid var(--coral);
  background: var(--night-3);
  padding: 1rem 1.1rem; border-radius: 0 9px 9px 0; margin-bottom: 1.5rem;
}
.caution p { margin: 0 0 0.6rem; }
.caution p:last-child { margin-bottom: 0; }

/* ------------------------------------------------------------------- hero */

.hero { padding: 3rem 0 1rem; }
.hero h1 {
  font-size: clamp(2rem, 6vw, 3.1rem);
  max-width: 22ch;
  margin-bottom: 1rem;
}
.hero .sub { font-size: 1.05rem; max-width: 46ch; }

/* The landing page runs three movements through one <section>: the pitch, the
 * scale, and the setup. h2 carries no top margin globally, because inside a panel
 * the panel's padding is the space — so out here the CTA button ended up ~0.5rem
 * from "READING A FLAME" and the three movements read as one wall. This is the
 * break between them, and it is the only place the page needs one. */
.hero h2 { margin-top: 3.6rem; }

/* The limit on what the product can do, sitting with the claim it qualifies
 * rather than 2rem below it and 0.5rem above the button. The rule marks it as a
 * boundary; it stays --edge rather than a warning colour because this is an
 * honest caveat, not a problem the reader has to solve. */
.hero .sub:has(+ .caveat) { margin-bottom: 0.9rem; }
.caveat {
  border-inline-start: 2px solid var(--edge);
  padding-inline-start: 0.9rem;
}

.hero .btn.enter {
  display: inline-block; margin-top: 2rem;
  background: var(--ember); border-color: var(--ember);
  color: #26150a; font-weight: 600; padding: 0.7rem 1.3rem;
}
.hero .btn.enter:hover { background: #ffa05f; }

/* No top margin: this list used to sit straight under the CTA and carry the
 * section break itself. It has a heading of its own now, so the break belongs to
 * .hero h2 and repeating it here put 3rem between a label and the thing it
 * labels. */
.how { list-style: none; padding: 0; margin: 0; display: grid; gap: 1.4rem; }
/* Inside a panel these steps are followed by the form they describe, and with no bottom
 * margin the last step sat 23px above the first field label where the steps themselves
 * are 46px apart — so "Paste it below" and "Cookie JSON" read as one block. Scoped to the
 * panel, because on the landing page this list ends the section and needs nothing. */
.panel .how { margin-block-end: 1.7rem; }
.how li { display: grid; grid-template-columns: auto 1fr; gap: 0.9rem; align-items: baseline; }
.how b { font: 500 0.8rem var(--data); color: var(--ember); }
.how span { color: var(--ink-dim); max-width: 62ch; }
.how strong { color: var(--ink); font-weight: 500; display: block; }

/* --------------------------------------------------------------- friend faces */

/* Two layers in one box: the monogram is the floor, the image is painted over it.
 * TikTok's avatar URLs are signed and expire, so app.js removes a failed image and
 * the letter is simply revealed — there is no broken state to design for.
 *
 * One quiet tint, not a colour per user. A second colour system here would compete
 * with the heat ladder, and the flame is what this app is remembered by. */

.face {
  position: relative;
  inline-size: 34px; block-size: 34px;
  flex: none;
  border-radius: 50%;
  overflow: hidden;
  background: var(--night-3);
  /* Against a panel these read as plates rather than floating cut-outs, and it
   * keeps a light avatar from bleeding into the surface behind it.
   *
   * Pure white at 10%, not --edge. --edge is a tinted neutral (#2a3357, a blue
   * slate), and a tinted outline picks up whatever surface sits under the image
   * and reads as dirt along its edge. On a dark ground the correct outline is
   * white at low alpha; kept in rgba to match the notation the rest of this file
   * uses rather than introducing a second colour syntax for one value. */
  box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.1);
}
.face-mono {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  font: 500 0.86rem var(--data);
  color: var(--ink-dim);
  /* Uppercase initials sit slightly high in this face at small sizes. */
  padding-block-end: 1px;
}
.face-img {
  position: absolute; inset: 0;
  inline-size: 100%; block-size: 100%;
  object-fit: cover;
  display: block;
}

/* A group is a rounded square, a person is a circle. Shape rather than a colour or a
 * badge: every messaging client already uses this convention, so it reads without a
 * legend, and colour here is reserved for the heat ladder. */
.face.is-group { border-radius: 9px; }

/* Username over nickname, with the face beside both. One cell, because these are
 * one fact — who this is — and three separate columns pushed the table off-screen
 * once Set count arrived. */
.who { display: flex; align-items: center; gap: 0.6rem; }
.who-name { display: flex; flex-direction: column; gap: 0.1rem; min-inline-size: 0; }
.who-nick {
  font-size: 0.8rem; color: var(--ink-dim);
  /* Real accounts here have nicknames of pure invisible characters, and some are
   * long enough to widen the column on their own. */
  max-inline-size: 22ch;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}

/* ------------------------------------------------------------ live run status */

/* What the dyno is doing, while it does it. The label carries the tone and the line
 * under it says literally what is happening — see streakbot/runphase.py, where every
 * phase is derived from a column rather than a timer. */

.runlive-head { display: flex; align-items: center; gap: 0.85rem; }
.runlive-head .flame { font-size: 1.9rem; }
.runlive-say { min-inline-size: 0; flex: 1; }
.runlive-label {
  display: block;
  font: 500 1rem/1.2 var(--display);
  letter-spacing: -0.01em;
  /* The roll needs a box to roll inside. Exactly one line tall and clipped, so a phase
   * arriving from below is cut off at the bottom edge and the one leaving is cut off at
   * the top — which is the whole difference between a block rolling and a word sliding
   * about. Without the clip the old animation moved the text over the detail line
   * underneath it.
   *
   * 1.2em is the line-height declared just above. Stated in em rather than lh so it
   * cannot drift from the font shorthand if that changes. */
  position: relative;
  overflow: hidden;
  block-size: 1.2em;
  /* Phase names are two words at most; wrapping one would push it out of a box that is
   * deliberately one line tall. */
  white-space: nowrap;
}
/* Both phases are taken out of flow so the incoming one does not push the outgoing one
 * down while they cross. With JS off the label holds bare text instead and this never
 * applies — the box is still one line, which is all that state needs. */
.runlive-label .phase {
  display: block;
  position: absolute;
  inset-inline: 0;
  inset-block-start: 0;
}
.runlive-detail { display: block; font-size: 0.86rem; color: var(--ink-dim); }
.runlive-clock {
  font: 500 0.82rem var(--data);
  font-variant-numeric: tabular-nums;
  color: var(--ink-dim);
  flex: none;
}

/* The same rail device the day strip uses, turned onto a different axis: there it
 * means consecutive days, here it means chats reached. */
.runlive-rail {
  margin-block-start: 1rem;
  block-size: 3px; border-radius: 2px;
  background: var(--night-3);
  overflow: hidden;
}
.runlive-fill {
  display: block; block-size: 100%; inline-size: 0;
  background: var(--ember);
  transition: inline-size 400ms ease-out;
}

.runlive-log {
  list-style: none; margin: 0.9rem 0 0; padding: 0;
  display: grid; gap: 0.3rem;
  font-size: 0.86rem;
}
.runlive-log:empty { display: none; }
.runlive-log li { display: flex; justify-content: space-between; gap: 1rem; }

/* A finished run stops claiming to be live. The border says the outcome before the
 * words do, and a failure never gets the ember. */
.runlive.is-over { border-color: var(--ember-deep); }
.runlive.is-over.is-bad { border-color: #6f2a31; }

/* The flame flickers only while something is actually running. Reuses the hero's
 * keyframes rather than a second copy, and the reduced-motion block at the end of
 * this file switches it off with everything else. */
.flame.is-live .flame-core {
  transform-origin: 50% 70%;
  animation: flicker 1.5s ease-in-out infinite;
}

/* ------------------------------------------------------- live panel motion */

/* Three animations, each tied to a change the panel can observe rather than to the
 * 2.5-second poll. Opt-in, so a reader who asked for less motion gets the same
 * panel with none of it — every one of these is decoration over a state that is
 * already legible from the text, the fill width and the flame.
 *
 * The clock is deliberately excluded. It changes on every tick, and animating a
 * value that moves once a second is how a progress panel becomes a distraction. */

/* The phase label rolls: the old text leaves through the top, the new arrives from
 * the bottom. Both are absolute inside a clipped one-line box, so neither one bleeds
 * over the detail line under it. Translation only — opacity comes along to ease the
 * cut-off at the clip edge, but a flat crossfade is explicitly avoided because it would
 * read as a fade, not a block moving.
 *
 * is-in and is-out are set by app.js. The old node is removed on animationend (with a
 * timer behind it in case the event never fires — under prefers-reduced-motion
 * durations are 0.01ms and the event still dispatches, but the fallback guards the edge
 * where it somehow does not). */
@keyframes phase-in {
  from { opacity: 0; transform: translateY(100%); }
  to   { opacity: 1; transform: none; }
}
@keyframes phase-out {
  from { opacity: 1; transform: none; }
  to   { opacity: 0; transform: translateY(-100%); }
}

@media (prefers-reduced-motion: no-preference) {
  .runlive-label .phase.is-in  { animation: phase-in  280ms cubic-bezier(0.2, 0, 0, 1); }
  .runlive-label .phase.is-out { animation: phase-out 240ms cubic-bezier(0.4, 0, 1, 1) forwards; }
}

/* One bump at the fill's leading edge as a chat lands. Inset so the glow rides the
 * end of the bar rather than washing the whole rail. */
@keyframes fill-bump {
  0%   { box-shadow: inset -6px 0 6px -3px rgba(255, 255, 255, 0); }
  35%  { box-shadow: inset -6px 0 6px -3px rgba(255, 255, 255, 0.85); }
  100% { box-shadow: inset -6px 0 6px -3px rgba(255, 255, 255, 0); }
}

@media (prefers-reduced-motion: no-preference) {
  .runlive-fill.is-bumped { animation: fill-bump 520ms ease-out; }

  /* Split into three chunks staggered ~100ms, so the sequence says which part of
     the panel to read first: what is happening, then how far along, then the log. */
  .runlive.is-arriving > * {
    animation: fade-in-up 380ms ease-out backwards;
  }
  .runlive.is-arriving > :nth-child(1) { animation-delay: 0ms; }
  .runlive.is-arriving > :nth-child(2) { animation-delay: 100ms; }
  .runlive.is-arriving > :nth-child(3) { animation-delay: 200ms; }
}

@keyframes fade-in-up {
  from { opacity: 0; transform: translateY(10px); filter: blur(4px); }
  to   { opacity: 1; transform: none; filter: blur(0); }
}

/* The count changes as chats land, so it must not reflow the line around it. */
.runlive-label { font-variant-numeric: tabular-nums; }

/* ------------------------------------------------------------------ dialog */

/* A styled <dialog>, not confirm(). The platform supplies focus trapping,
 * Escape, and an inert background; the appearance is entirely ours. */
dialog.sheet {
  width: min(28rem, calc(100vw - 2rem));
  padding: 1.4rem;
  border: 1px solid var(--edge);
  border-radius: 12px;
  background: var(--night-2);
  color: var(--ink);
  box-shadow: 0 24px 60px -12px rgba(0, 0, 0, 0.7);
}
dialog.sheet::backdrop { background: rgba(8, 10, 20, 0.72); }
/* A dialog's title is the loudest thing in it, not a section label. Inheriting the global
 * h2 gave "You're new?" the uppercase dim panel-label treatment, so the modal's title read
 * smaller and quieter than the sentence under it. */
dialog.sheet h2 {
  font: 700 1.15rem/1.2 var(--display);
  letter-spacing: -0.02em;
  text-transform: none;
  color: var(--ink);
  display: block;
  margin-bottom: 0.6rem;
}
dialog.sheet p { margin: 0 0 1.3rem; color: var(--ink-dim); }
dialog.sheet .acts { justify-content: flex-end; }
dialog.sheet .destroy { background: var(--coral); border-color: var(--coral); color: #2a0d10; font-weight: 600; }
dialog.sheet .destroy:hover { background: #ff7c88; }

@media (prefers-reduced-motion: no-preference) {
  dialog.sheet[open] { animation: sheet-in 160ms cubic-bezier(0.2, 0, 0, 1); }
}
@keyframes sheet-in {
  from { opacity: 0; transform: translateY(6px); }
  to { opacity: 1; transform: none; }
}

/* ------------------------------------------------------------------ tutorial */

/* Wider than the confirm sheet, because it holds two 16:9 screens rather than a
 * sentence. Everything else — surface, border, radius, backdrop, the sheet-in
 * animation — comes from dialog.sheet, so this is one dialog with two sizes rather
 * than two dialogs to keep in step. */
dialog.tutorial { width: min(42rem, calc(100vw - 2rem)); }
dialog.tutorial > p { color: var(--ink-dim); margin: 0 0 1.3rem; }
dialog.tutorial .hint { margin-block: 1.1rem 1.4rem; }

.screens {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
}
.screens figure { margin: 0; }
.screens figcaption {
  font: 600 0.72rem/1 var(--body);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--ink-dim);
  margin-block-end: 0.5rem;
}

/* 16:9 now so it is already the shape the video will be — the embed replaces the
 * contents of a box that does not move.
 *
 * --night, which is darker than the sheet it sits on, so it reads as a recessed screen
 * rather than a raised card. The desktop screen holds a YouTube embed that fills the
 * box; the mobile one is still a bare placeholder until its video lands. */
.screen {
  aspect-ratio: 16 / 9;
  display: grid;
  place-items: center;
  background: var(--night);
  border: 1px solid var(--edge);
  border-radius: 9px;
  color: var(--ink-dim);
  font: 500 0.82rem var(--data);
  letter-spacing: 0.08em;
  text-transform: uppercase;
}
.screen iframe {
  display: block;
  width: 100%;
  height: 100%;
  border: 0;
  border-radius: inherit;
}

/* The way back in, once it has been dismissed. Its own line above the summary, and
 * quiet — it is for the reader who is lost, not a call to action for everyone else. */
.sub.tutorial-open { margin-block-end: 0.35rem; }

/* ---------------------------------------------------------------- combobox */

/* Replaces a <select> holding 600+ IANA names: unsearchable on desktop and
 * close to unusable on a phone. ARIA APG combobox-with-listbox pattern. */
.combobox { position: relative; }
.combobox input[role=combobox] { padding-right: 2rem; }
.combobox::after {
  content: '';
  position: absolute; right: 0.75rem; top: 1.05rem;
  width: 0; height: 0;
  border-left: 4px solid transparent;
  border-right: 4px solid transparent;
  border-top: 5px solid var(--ink-dim);
  pointer-events: none;
}

.combobox-list {
  position: absolute;
  z-index: 5;
  left: 0; right: 0; top: calc(100% + 4px);
  max-height: 15rem;
  overflow-y: auto;
  margin: 0; padding: 0.3rem;
  list-style: none;
  background: var(--night-3);
  border: 1px solid var(--edge);
  border-radius: 8px;
  box-shadow: 0 18px 40px -14px rgba(0, 0, 0, 0.75);
  display: none;
}
.combobox.is-open .combobox-list { display: block; }

.combobox-list [role=option] {
  padding: 0.4rem 0.55rem;
  border-radius: 5px;
  font: 400 0.88rem var(--data);
  cursor: pointer;
}
.combobox-list [role=option].is-active { background: #2c3760; }
.combobox-list [role=option][aria-selected=true] { color: var(--ember); }
.combobox-list [role=option][aria-selected=true]::after { content: ' ✓'; }

/* ------------------------------------------------------------- select menu */

/* The same popup, opened by a button instead of a text field. A <select> is drawn by
 * the operating system and takes almost no styling, so the one in the admin row was the
 * only control in the app that did not look like the app. The popup, the tick and the
 * active row are all the combobox's, because this is one component with two triggers
 * rather than two components to keep in step.
 *
 * Its own trigger, though: the combobox is a field you type into, this is a button that
 * says what is chosen. Sized to its content like the select it replaces — the admin row
 * is already the widest thing on that page. */
.selectmenu { display: inline-block; }
.selectmenu-button {
  display: inline-flex; align-items: center;
  /* Room on the right for the shared ::after chevron. */
  padding: 0.28rem 1.5rem 0.28rem 0.5rem;
  font: 400 0.82rem var(--body);
  text-align: start;
  white-space: nowrap;
}
/* The combobox positions its chevron for a full-height field; this trigger is a small
 * button, so the arrow is centred on it instead. */
.selectmenu::after { top: 50%; margin-block-start: -2px; right: 0.55rem; }
/* Wide enough for the longest option so committing a shorter one does not resize the
 * row, which would shuffle every control beside it. */
.selectmenu .combobox-list { min-inline-size: max-content; }

/* The no-JS fallback. A native select cannot be restyled far, but it does not have to
 * arrive in the operating system's own grey either: appearance:none drops the platform
 * widget so the field tokens below actually apply, and the chevron is drawn here
 * because removing the appearance removes that too. */
select {
  appearance: none;
  -webkit-appearance: none;
  padding-right: 2rem;
  background-image: linear-gradient(45deg, transparent 50%, var(--ink-dim) 50%),
                    linear-gradient(135deg, var(--ink-dim) 50%, transparent 50%);
  background-position: calc(100% - 1rem) calc(50% + 1px), calc(100% - 0.7rem) calc(50% + 1px);
  background-size: 5px 5px, 5px 5px;
  background-repeat: no-repeat;
}

/* Announced to screen readers, not shown; the visible count would be noise. */
.sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* ---------------------------------------------------------- selecting rows */

/* Multi-select, for a list that is 29 rows long on a real account. Turning eight
 * chats on used to be eight full page loads.
 *
 * The checkbox is square and dawn; the daily control beside it stays a rounded
 * ember pill. Two controls in one row that both look like a boolean would be
 * unreadable, so they are told apart by shape as well as colour — a row can
 * legitimately be off and selected at the same time. */

th.pick, td.pick { width: 1px; padding: 0; }

/* The whole cell is the target, not just the 16px box. A bare checkbox is a
 * 16×16 target against WCAG 2.5.8's 24×24 baseline, with the daily pill as its
 * immediate neighbour — so the label stretches the hit area to the full cell and
 * the tick and its target become one thing, which is what a label is for. */
.pick label {
  display: grid;
  place-items: center;
  margin: 0;
  padding: 0.6rem 0.55rem;
  min-block-size: 24px;
  cursor: pointer;
}
.pick input[type=checkbox] {
  /* Square corners: the toggle beside it is the rounded one. */
  width: 16px; height: 16px;
  accent-color: var(--dawn);
  cursor: pointer;
  display: block;
}

/* The marker is the selection. A wash alone measured 1.20 against an unselected
 * row — nowhere near a cue — and pushing it far enough to be seen took --ink-dim
 * in the same row below 4.5. So this is the same device the day strip already
 * uses, where a solid line carries the state and the surface stays recessive:
 * full --dawn reads 6.87 against the row it marks.
 *
 * box-shadow rather than border-inline-start: a border would shift every cell in
 * the row sideways by its width as the box is ticked. */
tbody tr:has(.pick input:checked) { background: var(--dawn-wash); }
tbody tr:has(.pick input:checked) td.pick {
  box-shadow: inset 3px 0 0 var(--dawn);
}

/* -------------------------------------------------------------- the bulk bar */

/* In flow and always visible by default, which is what keeps multi-select working
 * with JS off: the buttons are plain submits on a form the checkboxes join by id.
 * app.js adds .is-floating, which lifts it to the foot of the viewport and shows it
 * only once something is ticked. */

.bulkbar {
  display: flex; align-items: center; gap: 1rem; flex-wrap: wrap;
  margin-block-start: 1.15rem;
  padding: 0.7rem 0.9rem;
  background: var(--night-3);
  border: 1px solid var(--edge);
  border-radius: 10px;
}
.bulk-count {
  font: 500 0.82rem var(--data);
  font-variant-numeric: tabular-nums;
  /* --ink, not --ink-dim: measured 4.19 on --night-3, under the 4.5 this text
   * needs. --ink reads 11.34 on the same surface, and the count is the one thing
   * in this bar that has to be unambiguous — so it does not get to be quiet. */
  color: var(--ink);
  flex: 1; min-inline-size: 12ch;
}

/* An author `display: flex` outranks the UA sheet's `[hidden] { display: none }`,
 * so without this the bar would ignore the hidden attribute entirely. */
.bulkbar[hidden] { display: none; }

.bulkbar.is-floating {
  position: fixed;
  inset-block-end: 1rem;
  inset-inline: 1rem;
  z-index: 6;
  margin-block-start: 0;
  max-inline-size: 34rem;
  margin-inline: auto;
  box-shadow: 0 18px 40px -14px rgba(0, 0, 0, 0.75);
}
.bulkbar.is-floating[hidden] { display: none; }

/* A fixed bar sits over whatever is at the foot of the page, and what is at the
 * foot of this page is the last few rows of the table it acts on. app.js sets this
 * while the bar is up so the page can be scrolled clear of it. */
body.has-bulkbar main { padding-block-end: 7.5rem; }

@media (prefers-reduced-motion: no-preference) {
  .bulkbar.is-floating:not([hidden]) { animation: sheet-in 140ms cubic-bezier(0.2, 0, 0, 1); }
}

/* Rows a bulk action just changed. Only bulk: when a single toggle is pressed the
 * pill under the pointer flips and the reader is already looking at it, so marking
 * it as well would be noise. After acting on eight rows from a bar at the foot of
 * the screen, nothing says which eight — which is the whole complaint about the old
 * reload, reappearing in a new place. Staggered down the table so the eye follows
 * the change rather than being hit by all of it at once. */
/* No fill mode: with `both` the final keyframe would stick, and a row that is still
 * ticked would keep `transparent` and lose its selection wash for good. */
tbody tr.just-changed { animation: just-changed 1s ease-out; animation-delay: calc(var(--i, 0) * 45ms); }
@keyframes just-changed {
  from { background: rgba(255, 138, 61, 0.24); }
  to   { background: transparent; }
}

/* ------------------------------------------------------------------- toasts */

/* Where an action reports back when the page did not reload.
 *
 * Only outcomes with no home in the layout land here: a queued run, a refused one,
 * an error. A state the reader can see — a pill flipping, a flame changing band, a
 * row leaving — is reported by the thing that changed, so it never becomes a toast.
 *
 * Deliberately the same object as .flash: same surface, same left border, same two
 * accents. A toast here is a flash that has moved, not a second notification
 * language to learn. */

.toasts {
  position: fixed;
  inset-block-end: 1rem;
  inset-inline: 1rem;
  z-index: 7;
  max-inline-size: 30rem;
  margin-inline: auto;
  list-style: none;
  padding: 0;
  margin-block: 0;
  display: grid;
  gap: 0.5rem;
  /* The container is always present so the live region exists before the first
     toast arrives, but it must never intercept clicks while it is empty. */
  pointer-events: none;
}
.toasts:empty { display: none; }

/* Lifted clear of the bulk bar when that is floating. Both want the foot of the
 * screen, and a bulk action that errors is exactly when they coincide. */
.toasts.is-lifted { inset-block-end: 4.6rem; }

.toast {
  pointer-events: auto;
  padding: 0.75rem 0.95rem;
  border-radius: 9px;
  border: 1px solid var(--edge);
  border-left-width: 3px;
  background: var(--night-2);
  box-shadow: 0 18px 40px -14px rgba(0, 0, 0, 0.75);
  display: flex; align-items: flex-start; gap: 0.9rem;
}
.toast.success { border-left-color: var(--ember); }
.toast.error { border-left-color: var(--coral); }
.toast p { margin: 0; flex: 1; font-size: 0.92rem; }
.toast button {
  flex: none;
  background: none; border: none;
  /* 24×24 is WCAG 2.5.8's baseline; a bare glyph gave this about 18. */
  inline-size: 24px; block-size: 24px;
  display: grid; place-items: center;
  padding: 0;
  color: var(--ink-dim); font-size: 1.1rem; line-height: 1;
  cursor: pointer;
}
.toast button:hover { background: none; color: var(--ink); }

@media (prefers-reduced-motion: no-preference) {
  .toast { animation: sheet-in 160ms cubic-bezier(0.2, 0, 0, 1); }
}

/* ------------------------------------------------------------ working state */

/* A control mid-request. No spinner: at these latencies a spinner appears and
 * vanishes before it can be read, and one per row would be a lot of noise for a
 * write that usually finishes in under 200ms. Dimming says "heard you" and the
 * pointer-events lock is what stops a second submit racing the first.
 *
 * Scoped to the controls that actually get it, rather than every [aria-busy]
 * element, so setting it anywhere else later cannot silently dim half a panel. */
button[aria-busy=true], form[aria-busy=true] { opacity: 0.55; pointer-events: none; }

/* -------------------------------------------------------------------- charts */

/* Hand-drawn SVG, because there is no bundler and the CSP admits no CDN. One series
 * per plot, one hue, and the geometry comes from streakbot/metrics.py so the numbers
 * are testable rather than only lookable-at.
 *
 * No legend anywhere here: a single series needs none, the heading names it. No value
 * printed on a bar either — a number on every mark goes unread. Each column carries a
 * native <title> and every figure is in the table twin, so the hover layer enhances
 * and never gates. */

/* 2.2rem above, because there was none. The chart title sat 20px below the paragraph
 * introducing the panel and 22px above its own note, so it read as that paragraph's last
 * line rather than as the label for the plot underneath it. A figure needs more air above
 * it than inside it. */
.chart { margin: 2.2rem 0 1.6rem; }
.chart:last-child { margin-bottom: 0; }

.chart figcaption {
  display: flex; flex-direction: column; gap: 0.15rem;
  margin-block-end: 0.9rem;
}
/* Below the h2 that owns it, not above. At 500/0.95rem in the display face this outranked
 * the uppercase section heading it sits under, so the page's own hierarchy ran backwards:
 * "Runs started" read as more important than "Usage". Same size as body text, weight
 * carrying the difference. */
.chart-title {
  font: 600 0.88rem/1.2 var(--body);
  letter-spacing: normal;
  color: var(--ink);
}
.chart-note { color: var(--ink-dim); font-size: 0.82rem; max-width: 60ch; }

/* Scale labels beside the plot rather than <text> inside it: the SVG stretches with
 * preserveAspectRatio="none", and text inside would stretch with it. */
.chart-plot { display: flex; gap: 0.6rem; align-items: stretch; }
.chart-scale {
  list-style: none; margin: 0; padding: 0;
  display: flex; flex-direction: column; justify-content: space-between;
  /* -0.5em pulls each label up so it sits centred on its own gridline. */
  margin-block: -0.5em;
  font: 400 0.7rem var(--data);
  font-variant-numeric: tabular-nums;
  color: var(--ink-dim);
  text-align: end;
  min-inline-size: 2.5ch;
  flex: none;
}

.chart-svg {
  flex: 1;
  min-inline-size: 0;
  block-size: 160px;
  display: block;
  overflow: visible;
}

/* Solid, one shade off the surface, and never dashed: dashing makes a ruler read as a
 * threshold. --edge measures 1.35 on --night-2, which is the point — a grid the eye can
 * find when it looks for it and ignores otherwise. */
.chart-grid { stroke: var(--edge); stroke-width: 1; vector-effect: non-scaling-stroke; }

.chart-bar { fill: var(--plot); }
/* The hit area is the whole column including the 2px gap, so a 20px bar is not a
 * pinpoint target. Invisible, and it must never eat the pointer for anything else. */
.chart-hit { fill: transparent; }
.chart-hit:hover { fill: rgba(255, 255, 255, 0.04); }

.chart-axis {
  list-style: none; margin: 0.45rem 0 0; padding: 0;
  /* Same track count as the bars, so a label sits under the column it names. */
  display: grid; grid-auto-flow: column; grid-auto-columns: 1fr;
  /* Offset by the scale gutter so the tracks line up with the plot, not the figure. */
  margin-inline-start: calc(2.5ch + 0.6rem);
  font: 400 0.68rem var(--data);
  color: var(--ink-dim);
  min-block-size: 1.2em;
}
/* Only the major ticks carry text. Twenty-four labels under twenty-four bars collide at
 * any width this panel will have; the rest are reachable in the table. */
.chart-axis li { text-align: center; white-space: nowrap; }
.chart-axis li.is-major { color: var(--ink); }

.chart-empty { padding: 1.5rem 0; }

.chart-table { margin-block-start: 0.7rem; }
.chart-table summary {
  color: var(--ink-dim); font-size: 0.82rem; cursor: pointer;
  inline-size: fit-content;
}
.chart-table summary:hover { color: var(--ink); }
.chart-table[open] summary { margin-block-end: 0.6rem; }

/* The window picker. One row, above everything it scopes — a control per chart would
 * let two plots disagree about what they are showing. */
.windowpick .btn.on {
  background: var(--night-3);
  border-color: var(--ember);
  color: var(--ink);
}

/* ------------------------------------------------------------------- KPI row */

/* Current values, so stat tiles rather than plots: a one-bar bar chart is a number with
 * extra steps. Proportional figures, not tabular — equal-width digits make a number at
 * this size read loose, and nothing here aligns vertically. */
.kpi .figure { font-variant-numeric: normal; }
.kpi .figure-of { color: var(--ink-dim); font-size: 0.7em; }
.kpi .panel.is-bad { border-color: #6f2a31; }

/* A refetch holds the previous numbers at reduced opacity instead of flashing a
 * skeleton, so the row never jumps and a dropped poll is not mistaken for zero. */
.kpi.is-stale { opacity: 0.55; }
@media (prefers-reduced-motion: no-preference) {
  .kpi { transition: opacity 160ms ease-out; }
}

/* ---------------------------------------------------------- admin account rows */

.rowacts { display: flex; flex-wrap: wrap; gap: 0.4rem; align-items: center; }
.rowrun { gap: 0.3rem; align-items: center; }
/* Sized to its content, unlike every other select here: the row is already the widest
 * thing on the page. */
.rowrun select {
  width: auto;
  padding: 0.28rem 0.4rem;
  font: 400 0.82rem var(--body);
}
/* A disabled account stays legible — it is still a row you act on — but reads as
 * switched off. The `disabled` tag carries the state in words. */
tbody tr.is-off .handle { color: var(--ink-dim); }

/* ------------------------------------------------- horizontal scroll affordance */

/* Shared by the day strip and the narrow-viewport nav: fade whichever edge still
 * has content behind it, so a scrollable row never looks complete. The thin
 * scrollbar stays as the no-JS fallback — the fade is driven by app.js, which
 * only adds the classes once it knows the element actually overflows. */
.scroll-x { overflow-x: auto; scrollbar-width: thin; }

.scroll-x.is-scrollable:not(.at-start):not(.at-end) {
  mask-image: linear-gradient(to right, transparent 0, #000 32px,
                              #000 calc(100% - 32px), transparent 100%);
}
.scroll-x.is-scrollable:not(.at-start).at-end {
  mask-image: linear-gradient(to right, transparent 0, #000 32px, #000 100%);
}
.scroll-x.is-scrollable.at-start:not(.at-end) {
  mask-image: linear-gradient(to right, #000 0, #000 calc(100% - 32px), transparent 100%);
}

/* --------------------------------------------------------------- responsive */

/* The side rail.
 *
 * 60rem is where the content stops fitting rather than a device preset — the rail is
 * 15rem and the tables inside main need roughly 600px before they scroll inside
 * themselves.
 *
 * In rem deliberately. At 200% text zoom the viewport measures half as many rem, so
 * this query drops out and the reader gets the drawer, which works at that width. A
 * px query would hold a 240px rail against a 640px effective viewport and squeeze
 * main to nothing. */
@media (min-width: 60rem) {
  body {
    display: grid;
    grid-template-columns: var(--rail-w, 15rem) minmax(0, 1fr);
    grid-template-areas: "rail main"
                         "rail foot";
    align-content: start;
    min-block-size: 100vh;
  }

  /* Collapsed to icons. The width is the only thing that changes, and the labels go
   * visually hidden rather than display:none — clipped text still counts toward a
   * link's accessible name, so each one keeps its name from its own words instead of
   * an aria-label that has to be kept in step with them. */
  html[data-nav=closed] body { --rail-w: 4.25rem; }

  /* A layout transition, so it reflows main — acceptable because collapsing is a
   * deliberate, occasional act rather than something on a hot path. Kept short. */
  @media (prefers-reduced-motion: no-preference) {
    body { transition: grid-template-columns 200ms cubic-bezier(0.2, 0, 0, 1); }
  }

  /* Back into the flow as a column, and none of the drawer's machinery applies. */
  .railbar, .rail-scrim { display: none; }
  .topbar {
    position: sticky;
    inset-block: 0 auto;
    z-index: auto;
    inline-size: auto;
    grid-area: rail;
    align-self: start;
    block-size: 100vh;
    padding: 1.5rem 1rem;
    background: none;
    transform: none;
    visibility: visible;
    opacity: 1;
    gap: 1.75rem;
  }

  /* Smaller than the touch figure, because a pointer is what presses it here. */
  .rail-toggle { inline-size: 34px; block-size: 34px; }

  html[data-nav=closed] .rail-brand,
  html[data-nav=closed] .nav-label,
  html[data-nav=closed] .signed-in {
    position: absolute;
    inline-size: 1px; block-size: 1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
  }
  html[data-nav=closed] .topbar { align-items: center; padding-inline: 0.7rem; }
  html[data-nav=closed] .topbar nav a { justify-content: center; padding-inline: 0.5rem; }
  /* Brand clipped away, so the row has only the control left to centre. */
  html[data-nav=closed] .rail-head { justify-content: center; }
  html[data-nav=closed] .signout { align-items: center; }
  /* The bars point the other way once the rail is shut. */
  html[data-nav=closed] .rail-toggle .nav-icon { transform: scaleX(-1); }

  main { grid-area: main; padding-block-start: 2.75rem; }
  footer { grid-area: foot; }
}

@media (max-width: 560px) {
  :root { --cell-w: 11px; --cell-h: 30px; --cell-gap: 3px; }
  main { padding: 1.5rem 1rem 3rem; }
  .streak-count { font-size: 2.1rem; }
  th, td { padding: 0.5rem 0.35rem; font-size: 0.9rem; }

  /* main already contributes 1.5rem here, and 4.5rem of nothing above the flame
   * is most of a small screen's first fold. The section breaks come in with it,
   * so the page keeps its proportions rather than just getting shorter. */
  .hero { padding-top: 1.25rem; }
  .hero h2 { margin-top: 2.6rem; }
  .hero .btn.enter { margin-top: 1.5rem; }

  /* The nav is a drawer at this width, so it needs none of the row-reflowing the
   * horizontal bar did. What it does need is a little more room per tap target. */
  .topbar { padding: 1rem 0.9rem; }
  .topbar nav a { padding-block: 0.7rem; }

  /* iOS Safari zooms the page whenever a focused field's text is under 16px,
   * which throws off every tap after it. The design keeps 15px above this width. */
  input[type=text], input[type=time], input[type=number], input[type=url],
  input[type=file], select, textarea { font-size: 16px; }

  /* The bar's verbs wrap onto their own line under the count rather than shrinking
   * the count to a couple of characters. */
  .bulkbar { gap: 0.6rem; padding: 0.6rem 0.7rem; }
  .bulk-count { flex: 1 0 100%; }
  .bulkbar.is-floating { inset-inline: 0.6rem; inset-block-end: 0.6rem; }
  .toasts { inset-inline: 0.6rem; inset-block-end: 0.6rem; }
  .toasts.is-lifted { inset-block-end: 6.2rem; }
  body.has-bulkbar main { padding-block-end: 9rem; }

  /* The tick is a 16px target inside a cell padded to 0.35rem here, which is under
   * the 24px minimum for a pointer target. The cell gives it the rest. */
  td.pick, th.pick { padding-inline: 0.45rem 0.25rem; }
}

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}
