/* gunnar console — design system
 *
 * The rule that shapes everything here: COLOUR MEANS STATUS. Nothing else on
 * the surface is chromatic. The dark theme's surfaces are a warm near-neutral,
 * cooled iron, never the blue-grey every forge UI reaches for; the light
 * theme's are NEUTRAL, because they are the login page's ground and that page
 * is the one the picture sets the colour of (see the surfaces block below).
 * The ink is warm in both. The only saturated pixels an operator ever sees
 * are pass / warn / bad / live. A green dot in this console is never
 * decoration, so it never has to be read twice.
 *
 * That leaves primary actions and the current selection without a hue. They
 * are expressed the way a terminal expresses them: contrast, surface and
 * weight. An INVERTED button is the primary — ink-on-surface turned over, so
 * it is dark on paper and bone on iron; a raised surface with stronger ink is
 * the current nav item.
 *
 * Light by default, dark by choice. The console used to be dark only, on the
 * argument that it sits next to a terminal; the owner asked for the opposite
 * default, so white is what a first visit paints and the dark design is kept
 * whole as a selectable theme rather than deleted. Two palettes, one design:
 * every rule below reads tokens, so a theme is a block of values and never a
 * second set of rules.
 *
 * The switch is `data-theme="dark"` on <html>, set by the header's toggle and
 * remembered in localStorage. `prefers-color-scheme` is deliberately NOT
 * consulted: a default the system can silently overturn is not a default, and
 * white was asked for by name.
 *
 * A third palette, `data-theme="surprise"` (owner, 2026-10-05), comes after
 * dark on the same switch (☀ → ☾ → ✦): see its block below the dark one, and
 * the short section at the end of the file that only it matches.
 *
 * The status hues are the same in both themes; their LIGHTNESS is not. 0.79
 * green reads on near-black and washes out on white, so the light palette
 * carries its own values — same hue, same meaning, enough contrast to be read
 * on paper.
 */

:root {
  color-scheme: light;

  /* ── Surfaces: ONE white with the front door, and it is the front door's ──
   *
   * The login page at `/login` and this console are two different documents —
   * a server-rendered page with its own inline stylesheet, and the wasm SPA —
   * and their grounds had drifted apart: #F8F6F4 here against #F4F4F4 there.
   * Four steps lighter and, worse, four steps WARMER, because this ramp was
   * warm paper (hue 75, chroma 0.004) and that one is neutral. A visitor who
   * logs in, or who opens a public repository from the catalogue, crosses from
   * one document to the other and the page changes colour underneath them.
   *
   * So the ground below is `#f4f4f4` WRITTEN OUT, not a re-derivation of it.
   * That number is measured — it is the mean of the backdrop webp's right edge,
   * read down the column where the picture meets the words, and the comment in
   * `login.html` explains why the corner was the wrong place to sample it. A
   * value like that must be copied, never matched by eye, or the two documents
   * drift again the first time either is touched.
   *
   * The SURFACE is that same `#f4f4f4`, written out a second time rather than
   * pointed at `--bg`, because it is the same measured value and not a choice
   * that happens to agree with it today. It used to be pure white, `oklch(1 0
   * 0)`, laid on every panel, metric cell and code block — and that was a
   * SECOND white. The login page declares a `--surface` of white too, but it
   * never paints one: all a visitor sees there is `#f4f4f4`. A console that
   * painted cards whiter than its ground had two whites where the door has one,
   * and the owner asked, twice, for one.
   *
   * So a panel is no longer told apart from the page by being whiter. It never
   * relied on that alone: `.panel`, `.metrics` and `.readme pre` each carry a
   * `--line` border, and that edge is now the whole of the separation — the way
   * the login page's catalogue sits on its ground with no fill of its own.
   *
   * What DID rely on the fill was everything laid on a panel: a table row's
   * hover, a badge, a diff's file header, a hunk heading, the skeleton shimmer.
   * They were `raised` and `hover` BELOW WHITE, 0.046 and 0.076 in OKLab
   * lightness; left where they were, on a `#f4f4f4` panel they would be 0.013
   * and 0.043 — a row hover nobody could see. So the two tokens move down by
   * exactly what the surface moved (1 → 0.9672), and every step they make
   * against the panel they sit on is the step it always was. `raised` lands on
   * #e5e5e5, which is the login page's own control fill (`--raised: #e5e5e3`)
   * less its trace of warmth: a button here and a button at the door are the
   * same grey.
   *
   * The sidebar keeps its place, a shade below the ground (0.023). The nav
   * link's hover is `--surface`, so it lifts to the ground's white; the current
   * item is `raised`, so it sinks below the sidebar — two directions, both
   * visible, neither a second white.
   *
   * The INK stays warm, and that is not an inconsistency: `--ink`, `--line` and
   * the status hues below are already token-for-token identical to the login
   * page's. Warm ink on a neutral ground is what that page has always been, so
   * matching the ground is the whole of what was needed to make them one. */
  --bg: #f4f4f4;
  --surface: #f4f4f4;
  --surface-raised: oklch(0.9214 0 0);
  --surface-hover: oklch(0.8914 0 0);
  --line: oklch(0.885 0.005 75);
  --line-strong: oklch(0.775 0.006 75);
  /* The sidebar is a shade off the page in DARK only. On paper it is the
   * ground itself: a sidebar greyer than the page was a second white by
   * subtraction — the chrome read as dimmer than the content beside it, and
   * crossing from the login page or a public repository listing into the
   * console changed the colour of the left edge. One ground, everywhere. */
  --sidebar-bg: #f4f4f4;

  /* Ink — warm near-black, never pure black. */
  --ink: oklch(0.225 0.008 70);
  --ink-dim: oklch(0.435 0.008 70);
  --ink-mute: oklch(0.565 0.007 70);
  /* The primary button inverts; its hover goes one step PAST the ink, which on
   * paper means darker and on iron meant brighter. */
  --ink-extreme: oklch(0 0 0);

  /* Status — the entire colour budget. Lightness tuned for a white ground. */
  --pass: oklch(0.52 0.15 155);
  --warn: oklch(0.55 0.13 85);
  --bad: oklch(0.53 0.20 25);
  --live: oklch(0.52 0.15 240);

  /* Status washes, for row and badge backgrounds. */
  --pass-wash: oklch(0.52 0.15 155 / 0.12);
  --warn-wash: oklch(0.55 0.13 85 / 0.13);
  --bad-wash: oklch(0.53 0.20 25 / 0.11);
  --live-wash: oklch(0.52 0.15 240 / 0.11);

  /* Source highlighting — two tinted families, dark enough to read on paper. */
  --hl-string-ink: oklch(0.47 0.09 150);
  --hl-constant-ink: oklch(0.47 0.10 250);

  --radius: 8px;
  --radius-sm: 5px;

  --sans: Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
  /* gunnar's whole vocabulary is machine values: oids, SHA256 fingerprints,
   * byte counts. They get a real mono face and tabular figures everywhere. */
  --mono: ui-monospace, "JetBrains Mono", SFMono-Regular, Menlo, Consolas, monospace;

  --sidebar-w: 236px;
  --ease: cubic-bezier(0.16, 1, 0.3, 1);
  --fast: 150ms;
  --slow: 220ms;
}

/* ── The dark theme ────────────────────────────────────────────────────────
 *
 * The console's original palette, unchanged in value and no longer the
 * default: warm-tinted near-black surfaces (cooled iron, not the blue-black
 * every forge UI reaches for) and warm bone ink. Selected, never inferred —
 * see the note at the top of this file about `prefers-color-scheme`. */
:root[data-theme="dark"] {
  color-scheme: dark;

  --bg: oklch(0.155 0.006 70);
  --surface: oklch(0.198 0.007 70);
  --surface-raised: oklch(0.238 0.008 70);
  --surface-hover: oklch(0.268 0.008 70);
  --line: oklch(0.30 0.008 70);
  --line-strong: oklch(0.40 0.009 70);
  --sidebar-bg: oklch(0.135 0.006 70);

  --ink: oklch(0.95 0.006 75);
  --ink-dim: oklch(0.775 0.007 75);
  --ink-mute: oklch(0.635 0.007 75);
  --ink-extreme: oklch(1 0 0);

  --pass: oklch(0.79 0.15 155);
  --warn: oklch(0.83 0.15 85);
  --bad: oklch(0.71 0.19 25);
  --live: oklch(0.80 0.11 240);

  --pass-wash: oklch(0.79 0.15 155 / 0.13);
  --warn-wash: oklch(0.83 0.15 85 / 0.13);
  --bad-wash: oklch(0.71 0.19 25 / 0.14);
  --live-wash: oklch(0.80 0.11 240 / 0.13);

  --hl-string-ink: oklch(0.82 0.055 150);
  --hl-constant-ink: oklch(0.83 0.05 250);
}

/* ── The surprise theme ────────────────────────────────────────────────────
 *
 * The third palette (owner, 2026-10-05), after ☀ light and ☾ dark on the
 * header's switch: dark Nordic — a near-black navy ground, white and soft-grey
 * ink, and two accents, purple and cyan. Same design, same rules: like dark,
 * it is a block of values over the tokens every rule reads, plus the short
 * section at the end of this file for what a palette cannot say (a face, a
 * gradient, a glow).
 *
 * The one thing it does differently is spend colour on something other than
 * status, and only where the owner's brief places it: the primary action is
 * purple, the focus ring and the current item lavender, and the header carries
 * the purple → blue → cyan gradient as a hairline. The status hues keep their
 * meaning and their dark-theme lightness, so a green dot is still a fact.
 *
 * The mix it is held to: 60–70 % ground (`--bg`, `--sidebar-bg`), 15–20 %
 * lifted surfaces (panels, cards, controls), 5–10 % purple, 5–10 % cyan.
 * Every text token is measured against the lightest surface it sits on and
 * passes WCAG AA (4.5:1) there: ink 13.8, ink-dim 8.3, ink-mute 5.2 on
 * `--surface-hover`; status words ≥ 4.8 on their washes; white on the primary
 * purple 5.7. The borders are thin and translucent (lavender at 12 % / 24 %),
 * except a form control's, which is held to the 3:1 a control boundary needs.
 *
 * The extra tokens here — the accents, the gradient, the glows — exist only in
 * this theme, and only the surprise rules at the end of this file read them. */
:root[data-theme="surprise"] {
  color-scheme: dark;

  --bg: #080b18;
  --surface: #11152a;
  --surface-raised: #181d36;
  --surface-hover: #1f2445;
  --line: rgba(185, 167, 255, 0.12);
  --line-strong: rgba(185, 167, 255, 0.24);
  --sidebar-bg: #0b0f20;

  --ink: #f4f5fa;
  --ink-dim: #b8c0d4;
  --ink-mute: #8f97b4;
  --ink-extreme: #ffffff;

  --pass: oklch(0.79 0.15 155);
  --warn: oklch(0.83 0.15 85);
  --bad: oklch(0.71 0.19 25);
  --live: #19c3d1;

  --pass-wash: oklch(0.79 0.15 155 / 0.13);
  --warn-wash: oklch(0.83 0.15 85 / 0.13);
  --bad-wash: oklch(0.71 0.19 25 / 0.14);
  --live-wash: rgba(25, 195, 209, 0.13);

  --hl-string-ink: #9de8ec;
  --hl-constant-ink: #b9a7ff;

  /* Rounded, not soft. */
  --radius: 10px;
  --radius-sm: 6px;

  /* Manrope, bundled (see the @font-face rules below); the system faces
   * behind it are the light and dark themes' own. */
  --sans: Manrope, -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;

  /* The accents, this theme's own. */
  --accent: #8b5cf6;
  --accent-deep: #6c3ff5;
  --accent-hover: #7a4ff7;
  --accent-wash: rgba(139, 92, 246, 0.16);
  --lavender: #b9a7ff;
  --cyan: #19c3d1;
  --cyan-line: rgba(25, 195, 209, 0.35);
  --pale-cyan: #9de8ec;
  --surface-accent: #1e1a45;
  --control-edge: rgba(185, 167, 255, 0.5);
  /* Sparingly: the header's hairline and the primary action's edge. */
  --gradient: linear-gradient(135deg, #6c3ff5 0%, #3b82f6 50%, #19c3d1 100%);
  --glow-purple: rgba(108, 63, 245, 0.45);
  --glow-cyan: rgba(25, 195, 209, 0.3);
}

/* Manrope (SIL Open Font License 1.1; `manrope-OFL.txt` beside the files),
 * three static weights cut from the variable font, Latin only, woff2 —
 * ~48 kB together. Each face claims a RANGE so every weight this stylesheet
 * asks for (400 … 800) lands on the nearest cut with no synthesised bold:
 * body text on 400, the 500–700 labels and buttons on 600, headlines on 800.
 * Only the surprise theme's `--sans` names the family, and a browser fetches
 * a face only when text is set in it, so light and dark never download it. */
@font-face {
  font-family: Manrope;
  src: url("/manrope-400.woff2") format("woff2");
  font-weight: 1 499;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: Manrope;
  src: url("/manrope-600.woff2") format("woff2");
  font-weight: 500 749;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: Manrope;
  src: url("/manrope-800.woff2") format("woff2");
  font-weight: 750 1000;
  font-style: normal;
  font-display: swap;
}

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

html,
body {
  margin: 0;
  height: 100%;
}

body {
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  font-size: 14.5px;
  line-height: 1.55;
  -webkit-font-smoothing: antialiased;
}

:where(h1, h2, h3, h4, p, figure, ul, ol, dl, dd) {
  margin: 0;
}

:where(ul, ol) {
  padding: 0;
  list-style: none;
}

a {
  color: var(--ink);
  text-decoration-color: var(--line-strong);
  text-underline-offset: 3px;
}

a:hover {
  text-decoration-color: var(--ink);
}

:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 2px;
  border-radius: var(--radius-sm);
}

code,
kbd,
samp,
.mono {
  font-family: var(--mono);
  font-size: 0.92em;
  font-variant-numeric: tabular-nums;
}

.num {
  font-variant-numeric: tabular-nums;
}

/* ── Shell ────────────────────────────────────────────────────────────── */

/* No sidebar since the final navigation (2026-10-02): the top bar IS the
   navigation, and the workspace takes the whole width. */
.app {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  height: 100dvh;
}

.sidebar {
  display: flex;
  flex-direction: column;
  gap: 1.5rem;
  padding: 1.25rem 0.75rem;
  background: var(--sidebar-bg);
  border-right: 1px solid var(--line);
  overflow-y: auto;
}

/* The brand heads the sidebar: the wordmark, the badger full-width under it,
 * STICKY so the pair holds the top while the nav scrolls beneath — the brand
 * carries the sidebar's own background so scrolling links slide under it
 * rather than through it. */
.brand {
  position: sticky;
  top: -1.25rem; /* cancels the sidebar's own top padding */
  z-index: 2;
  background: var(--sidebar-bg);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.5rem;
  padding: 1.25rem 0.25rem 0.75rem;
  margin-top: -1.25rem;
  font-size: 1.05rem;
  font-weight: 640;
  letter-spacing: -0.015em;
  text-decoration: none;
  text-align: center;
}

.brand small {
  color: var(--ink-mute);
  font-family: var(--mono);
  font-size: 0.68rem;
  font-weight: 400;
  letter-spacing: 0;
}

.nav-group {
  display: flex;
  flex-direction: column;
  gap: 1px;
}

.nav-group > h2 {
  padding: 0 0.6rem;
  margin-bottom: 0.4rem;
  color: var(--ink-mute);
  font-size: 0.68rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* ── Administration ──────────────────────────────────────────────────────────
 *
 * The one toned group in the sidebar and the one toned strip on a page. Added
 * 2026-09-18 with `/admin`, when every operator instrument was collected out of
 * the pages it had been hiding on.
 *
 * It borrows the `bad` tone, and that is a deliberate borrow rather than a new
 * colour: this console has four status colours and adding a fifth would make
 * every existing one mean less. Red here does NOT mean broken — the banner says
 * in words what it means, because a colour alone cannot carry a meaning it does
 * not already have, and somebody who cannot tell these hues apart must still be
 * able to tell the two halves apart.
 */

.nav-group.admin-group > h2 {
  color: var(--bad);
}

.nav-group.admin-group {
  padding-left: 0.5rem;
  border-left: 2px solid var(--bad);
}

.nav-group.admin-group .nav-link[aria-current="page"] {
  background: var(--bad-wash);
  color: var(--ink);
}

.admin-banner {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-bottom: 1rem;
  padding: 0.6rem 0.8rem;
  border: 1px solid var(--bad);
  border-left-width: 4px;
  border-radius: var(--radius);
  background: var(--bad-wash);
  color: var(--ink-dim);
  font-size: 0.85rem;
  line-height: 1.45;
}

.admin-banner a {
  color: var(--ink);
  font-weight: 600;
}

/* The strip's sentence is ONE inline run, its link inside it (owner
   2026-10-17): never three flex items broken over three lines. */
.admin-banner-line {
  flex: 1 1 auto;
  min-width: 0;
}

.admin-banner-tag {
  flex: none;
  color: var(--bad);
  font-size: 0.68rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

/* The three pages the group links to, side by side rather than stacked. */
.admin-links {
  gap: 0.5rem;
  flex-wrap: wrap;
}

/* ── Åtgärder ────────────────────────────────────────────────────────────────
 *
 * One line in the top bar, and the list behind it on the account page. Added
 * 2026-09-18, after the owner's shape: an account is told what it must attend
 * to continuously, in a queue it can come back to, and never as a required
 * step inside a flow that must not be interrupted.
 *
 * Two tones only. The loud one borrows `bad` and the quiet one `warn`, rather
 * than adding a colour: this console has four status colours and a fifth would
 * make each of them mean less. Neither carries meaning by colour alone — the
 * bar is a sentence, and the sentence is the message.
 */

.actions-bar {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  max-width: 46ch;
  padding: 0.3rem 0.65rem;
  border: 1px solid var(--warn);
  border-radius: 999px;
  background: var(--warn-wash);
  color: var(--ink);
  font-size: 0.82rem;
  text-decoration: none;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
  transition: background var(--fast) var(--ease);
}

.actions-bar:hover {
  background: var(--surface-hover);
}

.actions-bar.urgent {
  border-color: var(--bad);
  background: var(--bad-wash);
}

.actions-dot {
  flex: none;
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--warn);
}

.actions-bar.urgent .actions-dot {
  background: var(--bad);
}

.todo {
  display: flex;
  flex-direction: column;
  gap: 0.6rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.todo-item {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.7rem 0.8rem;
  border: 1px solid var(--line);
  border-left: 3px solid var(--warn);
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
}

.todo-item.urgent {
  border-left-color: var(--bad);
}

.todo-item strong {
  display: block;
  color: var(--ink);
  font-size: 0.92rem;
}

.todo-item .hint {
  margin: 0.25rem 0 0;
}

@media (max-width: 720px) {
  /* The bar is a sentence; on a narrow screen it would eat the whole bar and
   * push the theme toggle and the identity off it. The account page still has
   * the full list, so nothing is lost by dropping the headline here. */
  .actions-bar {
    display: none;
  }
}

.nav-link {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem;
  padding: 0.42rem 0.6rem;
  border-radius: var(--radius-sm);
  color: var(--ink-dim);
  font-size: 0.9rem;
  text-decoration: none;
  transition: background var(--fast) var(--ease), color var(--fast) var(--ease);
}

/* A page that lives UNDER the link before it (Account → Storage,
   Instruments → Storage): indented, a step quieter. */
.nav-link.nav-sub {
  padding-left: 1.6rem;
  font-size: 0.85rem;
}

/* The sidebar now IS the ground, so a hover can no longer lift to it — there
 * is nowhere lighter to go without minting the second white the door does not
 * have. Both states therefore sink, by the steps the panel tokens already
 * measure: hover to `raised`, and the current page one further to `hover`, so
 * "you are here" still outweighs "you are pointing at this". */
.nav-link:hover {
  background: var(--surface-raised);
  color: var(--ink);
}

/* The current page carries no hue: a raised surface, brighter ink and more
 * weight say "you are here" without spending the status budget. */
.nav-link[aria-current="page"] {
  background: var(--surface-hover);
  color: var(--ink);
  font-weight: 560;
}

.nav-link .count {
  color: var(--ink-mute);
  font-family: var(--mono);
  font-size: 0.78rem;
}

.workspace {
  display: flex;
  flex-direction: column;
  min-width: 0;
  overflow: hidden;
}

.topbar {
  display: flex;
  align-items: center;
  gap: 1rem;
  padding: 0 1.75rem;
  height: 52px;
  flex: none;
  border-bottom: 1px solid var(--line);
}

.topbar .spacer {
  flex: 1;
}

/* The theme switch. It sits beside the identity because both are facts about
 * THIS viewer rather than about the console, and it is quiet because changing
 * the colour of the page is never the errand anyone came here on. Fixed width
 * so the bar does not shift when the label swaps. */
.theme-toggle {
  flex: none;
  min-width: 6.2rem;
  font-variant-numeric: tabular-nums;
}

.page {
  flex: 1;
  overflow-y: auto;
  padding: 2rem 1.75rem 4rem;
}

/* Prose keeps a readable measure; tables and data below opt out. */
.page > p {
  max-width: 68ch;
  color: var(--ink-dim);
}

/* Present to a screen reader, absent to everyone else. Used by every table
 * caption in the console; without it those captions render as visible text
 * above their tables, which is how they shipped until a review caught it. */
.vh {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

.skip {
  position: absolute;
  left: -9999px;
}

.skip:focus {
  left: 0.5rem;
  top: 0.5rem;
  z-index: 10;
  padding: 0.5rem 0.75rem;
  background: var(--surface-raised);
  border-radius: var(--radius-sm);
}

/* ── Page header ──────────────────────────────────────────────────────── */

.page-header {
  display: flex;
  align-items: flex-end;
  gap: 1rem;
  padding-bottom: 0.9rem;
  margin-bottom: 1.75rem;
  border-bottom: 1px solid var(--line);
}

.page-header h1 {
  font-size: 1.4rem;
  font-weight: 640;
  letter-spacing: -0.02em;
}

.page-header p {
  margin-top: 0.15rem;
  color: var(--ink-mute);
  font-size: 0.88rem;
}

.page-header .actions {
  margin-left: auto;
  display: flex;
  gap: 0.5rem;
}

/* ── Panels ───────────────────────────────────────────────────────────── */

.panel {
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius);
}

.panel > header {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  padding: 0.8rem 1rem;
  border-bottom: 1px solid var(--line);
}

.panel > header h2 {
  font-size: 0.94rem;
  font-weight: 600;
}

.panel > header .actions {
  margin-left: auto;
  display: flex;
  gap: 0.4rem;
}

.panel-body {
  padding: 1rem;
}

/* Deliberately not a uniform card grid: the wide panel leads, the narrow ones
 * support it. */
.split {
  display: grid;
  grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr);
  gap: 1.25rem;
  align-items: start;
}

.stack {
  display: flex;
  flex-direction: column;
  gap: 1.25rem;
}

/* `display: flex` outranks the user agent's `[hidden]`: a stack the page hides
 * (the import forms where gunnar names nowhere to import into) stays hidden. */
.stack[hidden] {
  display: none;
}

/* A panel whose feature does not exist on the server yet. Honest, not hidden:
 * the operator learns the protocol declares it and gunnar refuses it. */
.panel.disabled {
  border-style: dashed;
  background: none;
}

.panel.disabled > header h2,
.panel.disabled .panel-body {
  color: var(--ink-mute);
}

/* ── Metrics ──────────────────────────────────────────────────────────── */

.metrics {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
  gap: 1px;
  background: var(--line);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  overflow: hidden;
}

.metric {
  padding: 0.85rem 1rem;
  background: var(--surface);
}

.metric dt {
  color: var(--ink-mute);
  font-size: 0.76rem;
  font-weight: 500;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

.metric dd {
  margin-top: 0.2rem;
  font-family: var(--mono);
  font-size: 1.32rem;
  font-variant-numeric: tabular-nums;
  letter-spacing: -0.02em;
}

.metric dd small {
  margin-left: 0.25rem;
  color: var(--ink-mute);
  font-size: 0.72rem;
}

/* ── Tables ───────────────────────────────────────────────────────────── */

.table-wrap {
  overflow-x: auto;
  max-width: 100%;
}

/* A wide table scrolls inside its panel; the panel itself never grows past
   the page (a grid or flex child is min-width:auto by default, which let a
   six-column table push a phone's page to 630 px — measured by the rig). */
.panel,
.stack > * {
  min-width: 0;
}

table {
  width: 100%;
  border-collapse: collapse;
  font-size: 0.88rem;
}

thead th {
  padding: 0.5rem 1rem;
  color: var(--ink-mute);
  font-size: 0.73rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-align: left;
  text-transform: uppercase;
  white-space: nowrap;
  border-bottom: 1px solid var(--line);
}

tbody td {
  padding: 0.55rem 1rem;
  border-bottom: 1px solid var(--line);
  vertical-align: baseline;
}

tbody tr:last-child td {
  border-bottom: none;
}

/* A row header is a cell. Without this it keeps the user agent's centred bold
 * and no padding at all, sitting flush against padded cells on the same row —
 * which is what the Room table's meter names and the Machine page's mount
 * paths were doing. Weight stays heavier than a `td`: it is the row's name. */
tbody th[scope="row"] {
  padding: 0.55rem 1rem;
  border-bottom: 1px solid var(--line);
  vertical-align: baseline;
  text-align: left;
  font-weight: 600;
}

tbody tr:last-child th[scope="row"] {
  border-bottom: none;
}

tbody tr:hover td {
  background: var(--surface-raised);
}

/* Except in a diff, where the row's wash is the meaning. A cell background
 * paints over a row background whatever the specificity, so the diff tables
 * opt out of the hover entirely. */
table.hunks tbody tr:hover td,
table.source tbody tr:hover td {
  background: none;
}

td.oid,
td.num,
td.fp {
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}

td.num {
  text-align: right;
}

/* One action per row, at the edge, and never wrapped onto two lines. */
td.row-actions {
  width: 1%;
  text-align: right;
  white-space: nowrap;
}

/* A value the server does not populate. Rendered, not blank, so nobody reads
 * an empty cell as "never used". */
.unset {
  color: var(--ink-mute);
  font-style: italic;
}

/* ── Badges ───────────────────────────────────────────────────────────── */

.badge {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  padding: 0.1rem 0.45rem;
  border-radius: 999px;
  background: var(--surface-raised);
  color: var(--ink-dim);
  font-size: 0.76rem;
  font-weight: 550;
  white-space: nowrap;
}

.badge::before {
  content: "";
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: currentColor;
}

.badge.plain::before {
  display: none;
}

/* Colour never travels alone: every badge carries its word. */
.badge.pass {
  background: var(--pass-wash);
  color: var(--pass);
}

.badge.warn {
  background: var(--warn-wash);
  color: var(--warn);
}

.badge.bad {
  background: var(--bad-wash);
  color: var(--bad);
}

.badge.live {
  background: var(--live-wash);
  color: var(--live);
}

.badge.live::before {
  animation: pulse 2s var(--ease) infinite;
}

@keyframes pulse {
  0%,
  100% {
    opacity: 1;
  }
  50% {
    opacity: 0.35;
  }
}

/* ── Buttons ──────────────────────────────────────────────────────────── */

.btn {
  /* An anchor wearing .btn is still a link to the browser: it keeps the
     underline the global `a` rule tints, and on .primary that draws a faint
     line across an inverted block. The Continue that leaves this console
     is the ONLY <a class="btn primary"> in this console — the one case
     nobody styled for — and it read as a stray link rather than the page's
     main action. Every other .btn is a <button>, which has no underline to
     lose, so this costs them nothing. */
  text-decoration: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.4rem;
  padding: 0.38rem 0.8rem;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
  color: var(--ink);
  font: inherit;
  font-size: 0.87rem;
  font-weight: 550;
  cursor: pointer;
  transition: background var(--fast) var(--ease), border-color var(--fast) var(--ease),
    opacity var(--fast) var(--ease);
}

.btn:hover:not(:disabled) {
  background: var(--surface-hover);
  border-color: var(--ink-mute);
}

.btn:active:not(:disabled) {
  background: var(--surface);
}

.btn:disabled {
  opacity: 0.45;
  cursor: not-allowed;
}

/* The primary action inverts instead of taking a hue. */
.btn.primary {
  background: var(--ink);
  border-color: var(--ink);
  color: var(--bg);
}

.btn.primary:hover:not(:disabled) {
  background: var(--ink-extreme);
  border-color: var(--ink-extreme);
}

/* ★ The Account page's tier panel (owner 2026-10-17): its name large,
   monetize's figures under it, and the page's ONE button — the console's
   primary (inverted, never a hue of its own), a size up. Full width on a
   phone. */
.tier .tier-name {
  margin: 0 0 0.6rem;
  font-size: 1.15rem;
}

/* A date or a size is never broken over two lines; on a phone the two
   figures stack instead. */
.tier .metric dd {
  white-space: nowrap;
}

@media (max-width: 440px) {
  .tier .metrics {
    grid-template-columns: 1fr;
  }
}

.tier .tier-ends {
  margin: 0.4rem 0 0;
}

.account-cta {
  display: flex;
  margin: 1.1rem 0 0.2rem;
}

.tier .tier-more {
  margin-top: 0.9rem;
}

.btn.primary.account-cta-btn {
  min-height: 3rem;
  min-width: 14rem;
  padding: 0.75rem 1.6rem;
  font-size: 1.02rem;
  font-weight: 640;
  letter-spacing: -0.005em;
  border-radius: var(--radius);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.12), 0 4px 14px rgba(0, 0, 0, 0.1);
}

@media (max-width: 720px) {
  .btn.primary.account-cta-btn {
    width: 100%;
  }
}

/* An offer in a More room panel: ONE button carrying everything the offer
   says, stacked, left-aligned, one width, so the offers read as a list of
   things to press (owner, 2026-10-02). */
.offers .btn.offer {
  justify-content: flex-start;
  width: 100%;
  max-width: 30rem;
  padding: 0.6rem 1rem;
  font-size: 0.95rem;
}

/* An offer that does not fit (owner, test2 2026-10-05): the offer's own
   words, where its button would be, as text — greyed, dashed, nothing to
   press — and monetize's sentence under it. */
.offers .offer-marked {
  width: 100%;
  max-width: 30rem;
  padding: 0.6rem 1rem;
  border: 1px dashed var(--ink-mute);
  border-radius: var(--radius, 6px);
  cursor: default;
}

.offers .offer-marked .offer-marked-words {
  margin: 0;
  font-size: 0.95rem;
  font-weight: 550;
  color: var(--ink-dim);
}

.offers .offer-marked .offer-why {
  margin: 0.25rem 0 0;
}

.btn.quiet {
  background: none;
  border-color: transparent;
  color: var(--ink-dim);
}

.btn.quiet:hover:not(:disabled) {
  background: var(--surface-raised);
  color: var(--ink);
}

/* Destructive is a status, so it may spend colour. */
.btn.danger {
  background: var(--bad-wash);
  border-color: color-mix(in oklch, var(--bad) 45%, transparent);
  color: var(--bad);
}

.btn.danger:hover:not(:disabled) {
  background: color-mix(in oklch, var(--bad) 22%, transparent);
}

.btn.busy {
  position: relative;
  color: transparent;
}

.btn.busy::after {
  content: "";
  position: absolute;
  width: 12px;
  height: 12px;
  border: 2px solid currentColor;
  border-top-color: transparent;
  border-radius: 50%;
  color: var(--ink);
  animation: spin 700ms linear infinite;
}

.btn.primary.busy::after {
  color: var(--bg);
}

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

/* A shop window still being read (test2, 2026-10-05): the server's line beside
   a small spinner, in the place the offers will take. */
.awaiting {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

.spinner {
  flex: none;
  width: 14px;
  height: 14px;
  border: 2px solid var(--ink-dim);
  border-top-color: transparent;
  border-radius: 50%;
  animation: spin 700ms linear infinite;
}

@media (prefers-reduced-motion: reduce) {
  .spinner {
    animation-duration: 2s;
  }
}

/* ── Forms ────────────────────────────────────────────────────────────── */

.field {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}

.field label {
  color: var(--ink-dim);
  font-size: 0.82rem;
  font-weight: 550;
}

.field .hint {
  color: var(--ink-mute);
  font-size: 0.78rem;
}

input[type="text"],
input[type="search"],
input[type="password"],
select,
textarea {
  padding: 0.4rem 0.6rem;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  background: var(--bg);
  color: var(--ink);
  font: inherit;
  font-size: 0.88rem;
}

input::placeholder {
  color: var(--ink-mute);
}

input:disabled,
select:disabled {
  opacity: 0.5;
}

.field.invalid input,
.field.invalid select {
  border-color: var(--bad);
}

.field .error {
  color: var(--bad);
  font-size: 0.8rem;
}

.row {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex-wrap: wrap;
}

/* ── States ───────────────────────────────────────────────────────────── */

/* Skeletons rather than a spinner in the middle of the content: the shape of
 * the answer is already known, so the page does not jump when it arrives. */
.skeleton {
  height: 0.85rem;
  border-radius: var(--radius-sm);
  background: linear-gradient(
    90deg,
    var(--surface-raised) 25%,
    var(--surface-hover) 50%,
    var(--surface-raised) 75%
  );
  background-size: 400% 100%;
  animation: shimmer 1.4s linear infinite;
}

.skeleton + .skeleton {
  margin-top: 0.55rem;
}

.skeleton.w-70 {
  width: 70%;
}

.skeleton.w-40 {
  width: 40%;
}

@keyframes shimmer {
  to {
    background-position: -400% 0;
  }
}

.empty {
  padding: 2.25rem 1rem;
  text-align: center;
  color: var(--ink-mute);
}

.empty h3 {
  margin-bottom: 0.3rem;
  color: var(--ink-dim);
  font-size: 0.95rem;
  font-weight: 600;
}

.empty p {
  max-width: 46ch;
  margin: 0 auto;
  font-size: 0.86rem;
}

/* An empty state's one line and its "?" sit centred together. */
.empty .hint-line {
  justify-content: center;
}

.empty .hint-line > p {
  margin: 0;
}

.empty .why > p {
  margin: 0.4rem auto 0;
  max-width: 46ch;
}

/* A refusal from gunnar, shown verbatim. The console never rewords what the
 * server said; a paraphrase is a second source of truth. */
.failure {
  padding: 0.85rem 1rem;
  border: 1px solid color-mix(in oklch, var(--bad) 40%, transparent);
  border-radius: var(--radius);
  background: var(--bad-wash);
}

.failure h3 {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  color: var(--bad);
  font-size: 0.88rem;
  font-weight: 600;
}

.failure .detail {
  margin-top: 0.35rem;
  font-family: var(--mono);
  font-size: 0.82rem;
  color: var(--ink-dim);
  overflow-wrap: anywhere;
}

.failure footer {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-top: 0.6rem;
  color: var(--ink-mute);
  font-size: 0.78rem;
}

/* A count the console had to truncate itself, because gunnar's list RPCs
 * ignore page_size and answer with everything. */
.truncation {
  padding: 0.5rem 1rem;
  border-top: 1px solid var(--line);
  color: var(--ink-mute);
  font-size: 0.8rem;
}

/* ── Event feed ───────────────────────────────────────────────────────── */

.feed {
  display: flex;
  flex-direction: column;
}

.feed li {
  display: grid;
  grid-template-columns: 5.5rem 8.5rem minmax(0, 1fr);
  gap: 0.75rem;
  padding: 0.45rem 1rem;
  border-bottom: 1px solid var(--line);
  font-size: 0.85rem;
}

.feed li:last-child {
  border-bottom: none;
}

.feed time {
  color: var(--ink-mute);
  font-family: var(--mono);
  font-size: 0.8rem;
}

.feed .what {
  color: var(--ink-dim);
  font-weight: 550;
}

.feed .where {
  font-family: var(--mono);
  overflow-wrap: anywhere;
}

/* A hole in the sequence. gunnar's event bus can drop under lag, and a stream
 * that reconnects starts at the live tail with no history, so the console
 * marks what it knows it missed rather than pretending the feed is complete. */
.feed li.gap {
  grid-template-columns: 1fr;
  justify-items: center;
  background: var(--warn-wash);
  color: var(--warn);
  font-size: 0.8rem;
  font-weight: 550;
}

/* ── Indexing ─────────────────────────────────────────────────────────────
 *
 * Post-ack index work: one row per repository whose pushed pack is still being
 * resolved. The panel is ABSENT when the box is idle rather than showing a zero
 * — a row that reads "0 indexing" every day is one an operator learns to skip,
 * and then misses the day it says something.
 *
 * The bar's transition is deliberately slow relative to the 2 s poll, so the
 * fill glides between samples instead of stepping; the global
 * prefers-reduced-motion rule below neutralises it for anyone who asked. */
.indexing {
  display: flex;
  flex-direction: column;
}

.indexing li {
  padding: 0.55rem 1rem;
  border-bottom: 1px solid var(--line);
  font-size: 0.85rem;
}

.indexing li:last-child {
  border-bottom: none;
}

.indexing .row {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.indexing .pct {
  margin-left: auto;
  font-family: var(--mono);
  color: var(--ink-dim);
}

.indexing .bar {
  height: 4px;
  margin: 0.45rem 0 0.35rem;
  background: var(--line);
  border-radius: var(--radius-sm);
  overflow: hidden;
}

.indexing .bar span {
  display: block;
  height: 100%;
  background: var(--live);
  transition: width 0.6s ease;
}

.indexing .meta {
  color: var(--ink-mute);
  font-family: var(--mono);
  font-size: 0.78rem;
  overflow-wrap: anywhere;
}

/* ── The room gauge (2026-10-05): days left as a bar, the end, the cap ── */

.room-gauge {
  display: grid;
  gap: 0.3rem;
  padding: 0.6rem 0.75rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  margin-bottom: 0.6rem;
}

.room-gauge-head,
.room-gauge-ends {
  margin: 0;
}

.room-gauge-bar {
  height: 6px;
  background: var(--line);
  border-radius: var(--radius-sm);
  overflow: hidden;
}

.room-gauge-bar span {
  display: block;
  height: 100%;
  background: var(--live);
  transition: width 0.6s ease;
}

.room-gauge[data-state="grace"] .room-gauge-bar span {
  background: var(--warn);
}

.room-gauge[data-state="read_only"] .room-gauge-bar span,
.room-gauge[data-state="retention"] .room-gauge-bar span {
  background: var(--bad);
}

.room-banner {
  display: grid;
  gap: 0.45rem;
  padding: 0.6rem 0.75rem;
  border-radius: var(--radius-sm);
  margin-bottom: 0.6rem;
}

.room-banner p {
  margin: 0;
}

.room-banner.warn {
  background: var(--warn-wash);
}

.room-banner.bad {
  background: var(--bad-wash);
}

.room-size {
  margin-top: 0.6rem;
  align-items: end;
}

.room-size input {
  width: 6rem;
}

/* ── Motion & responsive ──────────────────────────────────────────────── */

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

@media (max-width: 860px) {
  .app {
    grid-template-columns: 1fr;
    grid-template-rows: auto minmax(0, 1fr);
  }

  .sidebar {
    flex-direction: row;
    align-items: center;
    gap: 0.75rem;
    padding: 0.6rem 1rem;
    border-right: none;
    border-bottom: 1px solid var(--line);
    overflow-x: auto;
  }

  .nav-group {
    flex-direction: row;
    gap: 0.25rem;
  }

  .nav-group > h2 {
    display: none;
  }

  .nav-link {
    white-space: nowrap;
  }

  .split {
    grid-template-columns: minmax(0, 1fr);
  }

  .page {
    padding: 1.25rem 1rem 3rem;
  }
}

/* ── Browsing code ────────────────────────────────────────────────────── */

.crumbs {
  display: flex;
  align-items: center;
  gap: 0.35rem;
  flex-wrap: wrap;
  font-family: var(--mono);
  font-size: 0.86rem;
}

.crumbs .sep {
  color: var(--ink-mute);
}

.crumbs [aria-current="page"] {
  color: var(--ink-dim);
}

table.tree td.dir a::before,
table.tree td.file a::before,
table.tree td .up::before {
  display: inline-block;
  width: 1.1rem;
  color: var(--ink-mute);
  /* Shape, not colour: the status palette is spoken for. */
  content: "▸";
}

table.tree td.file a::before {
  content: "·";
}

table.tree td a {
  font-family: var(--mono);
  text-decoration: none;
}

table.tree td a:hover {
  text-decoration: underline;
}

table.tree td.dir a {
  font-weight: 550;
}

.source-wrap {
  overflow-x: auto;
  border-top: 1px solid var(--line);
}

table.source {
  width: 100%;
  border-collapse: collapse;
  font-family: var(--mono);
  font-size: 0.82rem;
  line-height: 1.6;
}

table.source td {
  padding: 0 0.75rem;
  border: none;
  vertical-align: top;
  white-space: pre;
}

table.source tr:hover td {
  background: var(--surface-raised);
}

td.ln {
  width: 1%;
  padding-right: 1rem !important;
  color: var(--ink-mute);
  text-align: right;
  user-select: none;
  font-variant-numeric: tabular-nums;
}

td.ln a {
  color: inherit;
  text-decoration: none;
}

td.ln a:hover {
  color: var(--ink);
}

td.src {
  width: 100%;
}

/* Six families, and most of them are not a hue.
 *
 * This console's rule is that colour carries status. Source text that spent
 * colour on grammar would leave a green dot on the same screen meaning less, so
 * the weight of the ink does most of the work here and only two families take a
 * tint at all. */
.hl-comment {
  color: var(--ink-mute);
  font-style: italic;
}

.hl-string {
  color: var(--hl-string-ink);
}

.hl-constant {
  color: var(--hl-constant-ink);
}

.hl-keyword {
  color: var(--ink);
  font-weight: 620;
}

.hl-entity {
  color: var(--ink);
}

.hl-punctuation {
  color: var(--ink-dim);
}

/* Broken syntax is a real fault, so it may spend a real status colour. */
.hl-invalid {
  color: var(--bad);
  text-decoration: underline wavy;
}

/* ── Diffs ────────────────────────────────────────────────────────────── */

.diff-stat {
  font-family: var(--mono);
  font-size: 0.82rem;
  font-variant-numeric: tabular-nums;
}

.diff-stat.add {
  color: var(--pass);
}

.diff-stat.del {
  color: var(--bad);
}

.diffs {
  display: flex;
  flex-direction: column;
}

.file-diff {
  border-top: 1px solid var(--line);
}

.file-diff > header {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  flex-wrap: wrap;
  padding: 0.6rem 1rem;
  background: var(--surface-raised);
}

.file-diff > header .path {
  font-size: 0.86rem;
  overflow-wrap: anywhere;
}

.diff-note {
  padding: 0.75rem 1rem;
  color: var(--ink-mute);
  font-size: 0.84rem;
}

table.hunks {
  width: 100%;
  border-collapse: collapse;
  font-family: var(--mono);
  font-size: 0.82rem;
  line-height: 1.55;
}

table.hunks td {
  padding: 0 0.75rem;
  border: none;
  vertical-align: top;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
}

table.hunks .hunk-head td {
  padding: 0.35rem 0.75rem;
  background: var(--surface-raised);
  color: var(--ink-mute);
  font-size: 0.78rem;
}

/* A washed background AND a leading marker: an added line must be readable as
 * added without relying on the colour at all. */
table.hunks tr.add {
  background: var(--pass-wash);
}

table.hunks tr.del {
  background: var(--bad-wash);
}

table.hunks tr.add .marker {
  color: var(--pass);
}

table.hunks tr.del .marker {
  color: var(--bad);
}

table.hunks .marker {
  display: inline-block;
  width: 1rem;
  color: var(--ink-mute);
  user-select: none;
}

/* ── Commit graph ──────────────────────────────────────────────────────────
 *
 * The ONE deliberate exception to "colour means status": in the graph, hue IS
 * the information — lane identity down the page. Per the console's own rule,
 * colour is still never the only signal: every row carries the words, every
 * node a tooltip, every stub its oid. Constant lightness/chroma against --bg,
 * eight hues, wrapped mod 8. */
:root {
  /* The light theme's lanes: skade's ice-blue family carried over by HUE and
   * darkened, because the dark theme's values are chosen to glow against
   * near-black and eight pale blues on white are eight invisible lanes. Same
   * order, same gold at lane 1, same wrap. */
  --lane-0: #0d7ea6;
  --lane-1: #8a6000; /* the gold one — it also glows, below */
  --lane-2: #15688f;
  --lane-3: #3f7f9e;
  --lane-4: #2c6f92;
  --lane-5: #4a7387;
  --lane-6: #0f7bad;
  --lane-7: #3f6879;
  --lane-1-glow: rgba(138, 96, 0, 0.35);
}

:root[data-theme="dark"] {
  /* skade's own hexes, verbatim from skade/.nornir/assets — the ice-blue
   * family, with skade's gold as lane 1: most walls are cold water, and every
   * eighth lane catches the light. */
  --lane-0: #6fd3f5;
  --lane-1: #ffcf7a; /* the gold one — it also glows, below */
  --lane-2: #2d9cd2;
  --lane-3: #bfe6ff;
  --lane-4: #4b9cc9;
  --lane-5: #9fc9e0;
  --lane-6: #7fd4ff;
  --lane-7: #86b3cc;
  --lane-1-glow: rgba(255, 207, 122, 0.55);
}

:root[data-theme="surprise"] {
  /* The surprise theme's lanes: the purple → cyan family at one lightness,
   * and lane 1 is the cyan that catches the light, as skade's gold does. */
  --lane-0: #9d7bff;
  --lane-1: #19c3d1; /* the cyan one — it also glows, below */
  --lane-2: #6f8cff;
  --lane-3: #b9a7ff;
  --lane-4: #5aa2f8;
  --lane-5: #9de8ec;
  --lane-6: #a78bfa;
  --lane-7: #7fb8f0;
  --lane-1-glow: rgba(25, 195, 209, 0.55);
}

.graph-scroll {
  display: flex;
  align-items: flex-start;
  overflow: auto;
  max-height: 72vh;
  border-top: 1px solid var(--line);
}

.graph-gutter {
  flex: none;
  display: block;
}
.graph-gutter path {
  fill: none;
  stroke-width: 2;
  stroke-linecap: round;
}
.graph-gutter path.lane-0 { stroke: var(--lane-0); }
.graph-gutter path.lane-1 { stroke: var(--lane-1); }
.graph-gutter path.lane-2 { stroke: var(--lane-2); }
.graph-gutter path.lane-3 { stroke: var(--lane-3); }
.graph-gutter path.lane-4 { stroke: var(--lane-4); }
.graph-gutter path.lane-5 { stroke: var(--lane-5); }
.graph-gutter path.lane-6 { stroke: var(--lane-6); }
.graph-gutter path.lane-7 { stroke: var(--lane-7); }
/* An edge that leaves the window: shorter, fading, and clickable — a fact
 * ("continues below"), not decoration. */
/* The gold lane shines. A real glow, kept subtle enough to read as light on
 * water rather than as a status. */
.graph-gutter path.lane-1 {
  filter: drop-shadow(0 0 3px var(--lane-1-glow));
}
.graph-gutter circle.lane-1 {
  filter: drop-shadow(0 0 4px var(--lane-1-glow));
}

.graph-gutter path.stub {
  opacity: 0.45;
  stroke-dasharray: 2 3;
  cursor: pointer;
}
.graph-gutter path.stub:hover { opacity: 1; }

/* Nodes sit on top of edges; the --bg ring keeps crossing lanes off the dot.
 * A merge is hollow — the shape says it before the colour does. */
.graph-gutter circle {
  stroke: var(--bg);
  stroke-width: 2;
  cursor: pointer;
}
.graph-gutter circle.lane-0 { fill: var(--lane-0); }
.graph-gutter circle.lane-1 { fill: var(--lane-1); }
.graph-gutter circle.lane-2 { fill: var(--lane-2); }
.graph-gutter circle.lane-3 { fill: var(--lane-3); }
.graph-gutter circle.lane-4 { fill: var(--lane-4); }
.graph-gutter circle.lane-5 { fill: var(--lane-5); }
.graph-gutter circle.lane-6 { fill: var(--lane-6); }
.graph-gutter circle.lane-7 { fill: var(--lane-7); }
.graph-gutter circle.hollow { fill: var(--bg); stroke-width: 2; }
.graph-gutter circle.hollow.lane-0 { stroke: var(--lane-0); }
.graph-gutter circle.hollow.lane-1 { stroke: var(--lane-1); }
.graph-gutter circle.hollow.lane-2 { stroke: var(--lane-2); }
.graph-gutter circle.hollow.lane-3 { stroke: var(--lane-3); }
.graph-gutter circle.hollow.lane-4 { stroke: var(--lane-4); }
.graph-gutter circle.hollow.lane-5 { stroke: var(--lane-5); }
.graph-gutter circle.hollow.lane-6 { stroke: var(--lane-6); }
.graph-gutter circle.hollow.lane-7 { stroke: var(--lane-7); }
.graph-gutter circle.hot { r: 5.5; }

.graph-rows {
  list-style: none;
  margin: 0;
  padding: 0;
  flex: 1;
  min-width: 0;
}
.graph-rows li {
  height: 28px; /* ROW_PITCH — must match graph::ROW_PITCH */
  display: flex;
  align-items: center;
  gap: 0.55rem;
  padding: 0 0.75rem;
  white-space: nowrap;
  cursor: pointer;
  border-bottom: 1px solid transparent;
}
.graph-rows li.hot {
  background: var(--surface-hover);
}
.graph-rows li .oid {
  flex: none;
}
.graph-rows li .badge {
  flex: none;
}
.graph-subject {
  overflow: hidden;
  text-overflow: ellipsis;
  min-width: 0;
}
.graph-author {
  flex: none;
  color: var(--ink-mute);
  margin-left: auto;
}
.graph-when {
  flex: none;
  color: var(--ink-mute);
  font-variant-numeric: tabular-nums;
}
.graph-foot {
  display: flex;
  align-items: center;
  gap: 1rem;
}
.graph-foot .truncation { margin: 0; }

/* The badger, per theme — in his ORIGINAL colours in every theme (owner,
   2026-10-06: black body, light-grey back stripe, red eye; never inverted),
   on NO ground: every file is transparent, so the bar shows through him
   whatever the palette. One file per theme, cut from the owner's drawing
   (1392x768):
     gunnar_light.webp     the drawing alone — light;
     gunnar_dark.webp      the same, ringed by a 12 px light keyline (#dcd8d2):
                           a black animal on a near-black bar needs an edge;
     gunnar_surprise.webp  the same, ringed in the surprise palette's lavender
                           (#b9a7ff), softened like its other glows.
   The animal's own pixels are the same in all three; only the ring differs.
   Until 2026-10-06 dark and surprise took a lightness-inverted badger (white
   body, dark stripe) — the owner wants his colours kept.

   A background-image rather than an <img> because the console's theme is a
   `data-theme` attribute it sets itself; a <picture> element can only follow
   the operating system. The aspect ratio keeps the box the height the image
   used to give it.

   ★ **AND NO `prefers-color-scheme` ARM, for the reason at the top of this
   file: the PALETTE never follows the system, so a mark that did would be the
   one thing on the page disagreeing with the ground it sits on.** The arm that
   stood here until 2026-09-13 read `@media (prefers-color-scheme: dark) {
   :root:not([data-theme="light"]) … }`, and `data-theme="light"` is a value
   this console never writes — `apply_theme` REMOVES the attribute for light
   (see `app.rs`), because light is its absence and not a second value. So that
   guard was true in every state the console can produce, and on any machine
   whose desktop is dark it put the lightness-inverted badger, on his near-black
   plate, inside a white sidebar — on a first visit, and again after somebody
   explicitly chose Light. Light is the attribute's absence; dark and surprise
   are its two values, each with its own file. */
.brand-mark {
  width: 100%;
  aspect-ratio: 1392 / 768;
  border-radius: 8px;
  display: block;
  background: url("/gunnar_light.webp") center / contain no-repeat;
}
:root[data-theme="dark"] .brand-mark {
  background-image: url("/gunnar_dark.webp");
}
:root[data-theme="surprise"] .brand-mark {
  background-image: url("/gunnar_surprise.webp");
}

/* ── The pre-receive gate ─────────────────────────────────────────────── */

/* The add-rule form: the pattern leads and takes the slack, the switches sit
 * beside it, the button lands on the same baseline as the inputs rather than
 * centred against a two-line field. */
.policy-form {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 0.75rem 1.1rem;
  margin-top: 0.85rem;
}

.policy-form .field {
  flex: 1 1 17rem;
}

.switches {
  display: flex;
  flex-wrap: wrap;
  gap: 0.9rem;
  padding-bottom: 0.35rem;
}

/* Phrased as what the rule FORBIDS, so the form reads the same way round as
 * the rows under it. `allow_force_push = false` is never on screen. */
.switch {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  color: var(--ink-dim);
  font-size: 0.84rem;
  font-weight: 550;
  cursor: pointer;
}

.switch input[type="checkbox"] {
  width: 0.95rem;
  height: 0.95rem;
  accent-color: var(--pass);
  cursor: pointer;
}

/* What one rule forbids, one badge per clause. A rule that forbids nothing
 * gets a single warn badge saying so — never an empty cell, which reads as
 * "loading" or "unknown" and is neither. */
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
}

.pattern-list {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

.pattern-list li {
  padding: 0.1rem 0.45rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
  font-family: var(--mono);
  font-size: 0.8rem;
}

.gate-list {
  margin: 0;
  padding: 0 1rem 1rem;
  list-style: none;
}

.gate-list li + li {
  margin-top: 0.7rem;
  padding-top: 0.7rem;
  border-top: 1px solid var(--line);
}

.gate-head {
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.gate-head code {
  font-family: var(--mono);
  font-size: 0.84rem;
}

.gate-list p {
  margin: 0.15rem 0 0;
  color: var(--ink-mute);
  font-size: 0.82rem;
}

/* ── The Room table: a wall is drawn, a reading is not ─────────────────────
 *
 * `Usage.Read` reports seven caps and only three of them are a door. The bar
 * is the difference, and it appears under a meter's name ONLY where gunnar
 * said it refuses (`pages::usage::Shape::Wall`) — never under a measured cap,
 * because a bar IS a ceiling and drawing one where nothing stops is exactly
 * the promise the product does not keep.
 *
 * The right-hand rule is the ceiling itself, drawn in the ink the refusals use
 * elsewhere: the track's end is a place the tenant arrives at, not the edge of
 * a box. `.indexing .bar` above is a PROGRESS bar and its track simply runs
 * out; the two look deliberately different because they mean different things.
 */
.room th[scope="row"] .wall {
  height: 4px;
  margin: 0.35rem 0 0.1rem;
  max-width: 12rem;
  background: var(--line);
  border-radius: var(--radius-sm);
  border-right: 2px solid var(--ink-mute);
  overflow: hidden;
}

.room th[scope="row"] .wall span {
  display: block;
  height: 100%;
  background: var(--ink-dim);
}

/* What a cap on the meter DOES, in monetize's own sentence. Quiet: it is a
 * property of the row, not a number in it, and it must not compete with the
 * figures it explains. */
.room td.does {
  color: var(--ink-mute);
  font-size: 0.82rem;
  max-width: 22rem;
}

/* ── Activity: thirty days of what MOVED ──────────────────────────────────
 *
 * Every meter in the Room table above is a STOCK meter and reads the same for
 * a namespace pushed to this morning and one last touched in March. This is
 * the other kind, and it is drawn as columns because the question is a shape —
 * is this rising, has it stopped, when did it stop — and not a number.
 *
 * The bars are ACHROMATIC, and that is the console's rule and not a taste:
 * colour means status here, so two ink weights carry "in" and "out" and the
 * only saturated pixel in the whole panel is the dot over a day transfers were
 * REFUSED on. A day worth looking at is the only day that is coloured.
 *
 * A day gunnar sent no row for gets no `span` at all, so it is empty ground
 * rather than a hairline: `min-height` below would otherwise draw a
 * zero-height bar, and a bar of height zero reads as a measurement that was
 * taken and came back empty. It was not taken. See `activity::bars`.
 */
.flow {
  padding: 0 1rem;
}

.flow h3 {
  margin: 0.9rem 0 0.4rem;
  font-size: 0.76rem;
  font-weight: 600;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--ink-mute);
}

.flow .days {
  display: flex;
  align-items: flex-end;
  gap: 2px;
  height: 56px;
  margin: 0;
  padding: 0;
  list-style: none;
  border-bottom: 1px solid var(--line);
}

.flow .days li {
  position: relative;
  flex: 1 1 0;
  min-width: 0;
  height: 100%;
  display: flex;
  align-items: flex-end;
  gap: 1px;
}

/* A row that exists but moved no bytes is a hairline, not nothing: something
 * happened that day and the panel says so. Only a day with NO row is blank. */
.flow .days li span {
  display: block;
  flex: 1 1 0;
  min-height: 1px;
  border-radius: 1px 1px 0 0;
}

.flow .days li .a { background: var(--ink-dim); }
.flow .days li .b { background: var(--ink-mute); }

.flow .days li.refused::after {
  content: "";
  position: absolute;
  top: -7px;
  left: 50%;
  width: 4px;
  height: 4px;
  margin-left: -2px;
  border-radius: 50%;
  background: var(--bad);
}

.flow .axis {
  display: flex;
  justify-content: space-between;
  margin: 0.3rem 0 0.2rem;
  font-family: var(--mono);
  font-size: 0.72rem;
  color: var(--ink-mute);
}

.flow + .flow { padding-top: 0.2rem; }

.legend {
  display: flex;
  flex-wrap: wrap;
  gap: 0.9rem;
  margin: 0.2rem 0 0.5rem;
  font-size: 0.78rem;
  color: var(--ink-mute);
}

.legend i {
  display: inline-block;
  width: 10px;
  height: 4px;
  margin-right: 0.35rem;
  border-radius: 1px;
  vertical-align: middle;
}

.legend i.a { background: var(--ink-dim); }
.legend i.b { background: var(--ink-mute); }
.legend i.mark {
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--bad);
}

/* The clone line under a repository's header: one pasteable URL. */
.clone-line { margin: -0.4rem 0 1rem; font-size: 0.86rem; color: var(--ink-mute); }
.clone-line code { user-select: all; }

/* The one command a stuck account has to run on the appliance. It carries a
 * whole OpenSSH public key, so it scrolls rather than wraps: a wrapped line
 * invites a paste that loses a character, and that failure surfaces far away
 * as a signature that does not verify. `user-select: all` is the same move
 * `.clone-line code` makes for a clone URL. */
.command-line {
  overflow-x: auto;
  margin: 0.6rem 0;
  padding: 0.5rem 0.65rem;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
  color: var(--ink);
  font-family: var(--mono);
  font-size: 0.82rem;
  user-select: all;
}

/* The one line under a page title that depends on who is looking. */
.page-lead { margin: -0.4rem 0 1rem; color: var(--ink-mute); font-size: 0.88rem; }

/* ── The README, as the server typeset it ──────────────────────────────────
 *
 * `.readme-page` holds an inline SVG the BFF drew. It arrives with
 * `width="100%"` and a `viewBox` and no height, so `height: auto` is what lets
 * the browser take the aspect ratio from the viewBox — the rule that makes it
 * shrink on a phone rather than overflow. The `overflow-x: auto` is the belt to
 * that brace: a page that is somehow still too wide scrolls inside its own box
 * instead of widening the console.
 *
 * **The typeset page follows the theme** (owner, 2026-10-06: "the rendered
 * README is locked to a white background with black text — the HTML view
 * follows the theme"). docbook's column template paints in fixed colours —
 * `page(fill: white)` under ink `#16191d`, links `#0a58ca`, code boxes
 * `#f2f2f4` / `#f5f6f8`, table rules `#cfd4da`, the `---` rule `#aaaaaa`, and
 * typst's own code-highlighting palette — and typst writes each one as a
 * presentation attribute (`fill="#ffffff"`) on the shape or glyph it paints.
 * A presentation attribute loses to ANY stylesheet rule, so each colour is
 * mapped here, by its exact attribute, onto the theme's token: the sheet is
 * `--surface` (the panel it sits in, as the HTML view's is), the words
 * `--ink`, a link `--readme-link`, a code box `--surface-raised`, a rule
 * `--line-strong`, the code colours the console's own highlighting tokens.
 * Light, dark and surprise alike, live on the switch, with no second render
 * and nothing in the server. `readme::tests` holds docbook to these colours
 * (a colour the template starts painting that is not mapped here is a red
 * test), and the UI test reads the painted colours against the tokens.
 *
 * Until 2026-10-06 the light theme laid the white sheet over `#f4f4f4` with
 * `mix-blend-mode: multiply` and the dark ones kept white paper on purpose
 * ("paper does not follow a console's palette"); the owner decided otherwise.
 *
 * It keeps a border so it reads as a document sitting in the panel. */
.readme-page {
  overflow-x: auto;
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
  background: var(--surface);
}

.readme-page svg {
  display: block;
  width: 100%;
  height: auto;
}

/* A link in the typeset page has no underline to tell it from the words
 * (docbook colours it and nothing else), so it keeps a colour of its own: the
 * console's blue, the surprise theme's lavender. */
:root {
  --readme-link: var(--live);
}

:root[data-theme="surprise"] {
  --readme-link: var(--lavender);
}

.readme-page svg [fill="#ffffff"] { fill: var(--surface); }
.readme-page svg [fill="#16191d"] { fill: var(--ink); }
.readme-page svg [fill="#0a58ca"] { fill: var(--readme-link); }
.readme-page svg [fill="#f2f2f4"],
.readme-page svg [fill="#f5f6f8"] { fill: var(--surface-raised); }
.readme-page svg [stroke="#cfd4da"],
.readme-page svg [stroke="#aaaaaa"] { stroke: var(--line-strong); }
/* typst's code-highlighting palette (typst-library `raw`), each onto the
 * console's own highlighting tokens. */
.readme-page svg [fill="#d73948"] { fill: var(--bad); }
.readme-page svg [fill="#198810"] { fill: var(--hl-string-ink); }
.readme-page svg [fill="#4b69c6"],
.readme-page svg [fill="#b60157"],
.readme-page svg [fill="#8b41b1"] { fill: var(--hl-constant-ink); }
.readme-page svg [fill="#16718d"],
.readme-page svg [fill="#1d6c76"] { fill: var(--live); }
.readme-page svg [fill="#74747c"],
.readme-page svg [fill="#6b6b6f"] { fill: var(--ink-mute); }
.readme-page svg [fill="#301414"] { fill: var(--ink); }

/* The typeset page carries a selectable TEXT LAYER (docbook 0.1.2): one
 * invisible `<text>` per shaped run, over the glyph paths, so the page can be
 * selected, copied and found with the browser's search. The SVG brings its own
 * `<style>` saying so — the rules travel with the page rather than living here —
 * and this is the belt to that brace: an ancestor of the panel that turned
 * selection off would otherwise win by specificity, and the failure mode is a
 * page that looks right and cannot be selected, which nobody would think to
 * test. `text` and not `*`, so the glyph paths stay unselectable and a drag does
 * not produce a selection of nothing. */
.readme-page svg .text-layer text {
  -webkit-user-select: text;
  user-select: text;
}

/* ★ SHADOW-SEL (owner 2026-10-26): selected, the text layer paints its highlight and NOTHING
 * else. docbook's own rule makes the selected layer's `fill` transparent, which is the word
 * Chromium paints SVG text by; Firefox and Safari paint a selection with its `color` (and
 * WebKit its text-fill colour), so the layer's words showed beside the glyphs as a second,
 * offset copy. Every engine's word, transparent; and no theme's `::selection` reaches it. */
:root .readme-page svg .text-layer text::selection {
  fill: transparent;
  color: transparent;
  -webkit-text-fill-color: transparent;
  text-shadow: none;
}
:root .readme-page svg .text-layer text::-moz-selection {
  fill: transparent;
  color: transparent;
  text-shadow: none;
}

/* The default grade: sanitised HTML from the server's typst HTML target — real
 * elements with real text in them, which is why it leads and the picture is a
 * toggle.
 *
 * typst's HTML export reserves `<h1>` for the document title, so a README's own
 * `#` heading arrives as `<h2>` and everything shifts down one. The sizes below
 * are set against THAT, not against markdown's levels: `h2` is the document's
 * title here and is sized like one. */
.readme {
  max-width: 46rem;
}

.readme h1,
.readme h2,
.readme h3 {
  margin: 1.2em 0 0.5em;
  line-height: 1.25;
}

.readme h1 { font-size: 1.5rem; }
.readme h2 { font-size: 1.42rem; }
.readme h3 { font-size: 1.14rem; }
.readme h4 { font-size: 1rem; }
.readme p, .readme ul, .readme ol, .readme blockquote { margin: 0.7em 0; }
/* The console's reset takes `list-style` off every `ul`/`ol` (nav lists, badge
 * rows, the sidebar — none of which want a bullet). A README's lists DO: an
 * unmarked list of steps is a paragraph with odd line breaks. The markers come
 * back here, scoped to the one place the document is somebody else's. */
.readme ul { list-style: disc; padding-left: 1.4em; }
.readme ol { list-style: decimal; padding-left: 1.4em; }
.readme code { font-family: var(--mono); font-size: 0.9em; }
.readme pre {
  overflow-x: auto;
  padding: 0.8rem;
  background: var(--surface);
  border: 1px solid var(--line);
  border-radius: var(--radius-sm);
}
.readme blockquote {
  padding-left: 0.9rem;
  border-left: 3px solid var(--line);
  color: var(--ink-mute);
}
.readme table { border-collapse: collapse; }
.readme th, .readme td { padding: 0.35rem 0.6rem; border: 1px solid var(--line); }
.readme img { max-width: 100%; height: auto; }

/* A plain-text README (a bare `README` with no extension). */
.readme-text {
  overflow-x: auto;
  margin: 0;
  font-size: 0.86rem;
  line-height: 1.5;
}

/* Why this README is plainer than it should be, in the server's own words. */
.readme-note {
  margin: 0.8rem 0 0;
  color: var(--ink-mute);
  font-size: 0.82rem;
}

/* ── A note under a panel's heading ────────────────────────────────────────
 *
 * `.hint` existed only as `.field .hint` — a form's helper text — while three
 * panels were already writing `<p class="hint">` beside their tables and
 * getting a plain paragraph in body ink. This is the base rule; the form's
 * descendant selector still wins where it applies. */
.hint {
  margin: 0 0 0.6rem;
  color: var(--ink-mute);
  font-size: 0.82rem;
  max-width: 54rem;
}

/* ── One line and a "?" (`ui::Hint`, owner 2026-10-04) ──────────────────────
 *
 * The explanation is ONE line; the rest is behind a small round "?" beside it,
 * a native <details> so it needs no script. Open, the rest drops below the
 * line at the hint's own size and ink — it is the same voice, just asked for. */
.hint-line {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.35rem;
}

.why {
  display: contents;
}

.why > summary {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.05rem;
  height: 1.05rem;
  border: 1px solid var(--line);
  border-radius: 50%;
  color: var(--ink-dim);
  font-size: 0.7rem;
  font-weight: 700;
  line-height: 1;
  cursor: pointer;
  list-style: none;
  user-select: none;
}

.why > summary::-webkit-details-marker {
  display: none;
}

.why[open] > summary {
  background: var(--ink);
  color: var(--surface);
  border-color: var(--ink);
}

.why > p {
  flex-basis: 100%;
  margin: 0.25rem 0 0;
  max-width: 54rem;
}

/* ── The Overview's Machine card: the estate drawn, and gunnar's few lines ── */
/* The estate drawn INLINE and swapped in place when gunnar's answer changes
 * (owner 2026-10-04: "make it svg dynamic"); the pulses are the drawing's own. */
.machine-card .estate-picture svg {
  display: block;
  width: 100%;
  height: auto;
  border-radius: var(--radius);
}

/* ★ **The picture follows the theme** (owner 2026-10-05: "make it follow the
 * chosen theme of the web site"). The shared renderer (nordisk-estate-svg)
 * draws every colour as `var(--estate-<name>, <skade's colour>)`; the card puts
 * the SVG inline, so these names reach it. Each is mapped ONCE, onto a token
 * the theme blocks above already switch — so the ☾/☀ toggle repaints the
 * board in place, with no fetch and no second drawing, and nothing here is a
 * per-theme value that could drift from the console's own.
 *
 * COLOUR MEANS STATUS here too: the board is surfaces, lines and ink; the only
 * hues are the three state words — up, behind, silent — on --pass, --warn and
 * --bad, which carry their own lightness per theme. Each state also keeps its
 * line and fill (a pulse, a slower pulse, a still line, a dashed one; an unlit
 * chip on the page's own ground), so neither theme reads by hue alone.
 *
 * Every name the renderer draws with must be here (`TOKENS` in the renderer;
 * gunnar-ui-server's topology test holds this block to it). */
.estate-picture {
  /* the board, a chip, main's chip, an inset slot, an unlit chip */
  --estate-board-top: var(--surface);
  --estate-board-bottom: var(--surface);
  --estate-chip-top: var(--surface-raised);
  --estate-chip-bottom: var(--surface-raised);
  --estate-rail-top: var(--surface-hover);
  --estate-rail-bottom: var(--surface-hover);
  --estate-slot-top: var(--bg);
  --estate-slot-bottom: var(--bg);
  --estate-dark-top: var(--bg);
  --estate-dark-bottom: var(--bg);
  /* ink and lines */
  --estate-ink: var(--ink);
  --estate-ink-dim: var(--ink-dim);
  --estate-ink-body: var(--ink-dim);
  --estate-ink-faint: var(--ink-mute);
  --estate-slot-ink: var(--ink);
  --estate-accent: var(--ink-mute);
  --estate-rail-edge: var(--ink-dim);
  --estate-chip-edge: var(--line-strong);
  --estate-rule: var(--line-strong);
  --estate-via: var(--line-strong);
  --estate-dark-edge: var(--line-strong);
  --estate-dark-ink: var(--ink-mute);
  --estate-pin: var(--ink-dim);
  --estate-bar-track: var(--line);
  --estate-bar-high: var(--warn);
  /* the state words */
  --estate-up: var(--pass);
  --estate-behind: var(--warn);
  --estate-silent: var(--bad);
  --estate-unknown: var(--ink-mute);
}

/* The surprise theme's board. The block above maps the picture onto the
 * surfaces and ink, which would draw it in navy and grey; this one gives every
 * name a surprise value of its own — still a token of the theme, never a
 * colour written here — so the board carries the theme's accents: the main
 * box's chip lifted to the purple-tinted surface with a purple edge, the slots
 * and pins in cyan, the board a vertical fall from the panel to the ground.
 * The state words stay on --pass, --warn and --bad. The same test that holds
 * the block above to the renderer's `TOKENS` holds this one. */
:root[data-theme="surprise"] .estate-picture {
  /* the board, a chip, main's chip, an inset slot, an unlit chip */
  --estate-board-top: var(--surface);
  --estate-board-bottom: var(--sidebar-bg);
  --estate-chip-top: var(--surface-raised);
  --estate-chip-bottom: var(--surface);
  --estate-rail-top: var(--surface-accent);
  --estate-rail-bottom: var(--surface-raised);
  --estate-slot-top: var(--sidebar-bg);
  --estate-slot-bottom: var(--bg);
  --estate-dark-top: var(--bg);
  --estate-dark-bottom: var(--bg);
  /* ink and lines */
  --estate-ink: var(--ink);
  --estate-ink-dim: var(--ink-dim);
  --estate-ink-body: var(--ink-dim);
  --estate-ink-faint: var(--ink-mute);
  --estate-slot-ink: var(--pale-cyan);
  --estate-accent: var(--cyan);
  --estate-rail-edge: var(--accent);
  --estate-chip-edge: var(--line-strong);
  --estate-rule: var(--line);
  --estate-via: var(--cyan-line);
  --estate-dark-edge: var(--line-strong);
  --estate-dark-ink: var(--ink-mute);
  --estate-pin: var(--pale-cyan);
  --estate-bar-track: var(--surface-hover);
  --estate-bar-high: var(--warn);
  /* the state words */
  --estate-up: var(--pass);
  --estate-behind: var(--warn);
  --estate-silent: var(--bad);
  --estate-unknown: var(--ink-mute);
}

.glance {
  list-style: none;
  margin: 0.6rem 0 0;
  padding: 0;
  display: grid;
  gap: 0.3rem;
}

.glance li {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.45rem;
  font-size: 0.88rem;
}

.glance .mono {
  overflow-wrap: anywhere;
}

/* ── The Machine page: the disks a box with no shell cannot otherwise show ──
 *
 * Three panels, three tenses: what the installer DECIDED (historical, and
 * carried as the key/value table gunnar declined to parse), what is true NOW
 * (measured on the call), what gunnar is CONFIGURED to do. The only chromatic
 * pixels on the whole page belong to the off-stripe badge, the signature badge
 * and the written-to badge, which is the console's rule — colour means status
 * — and on the first it means something sharper: a copy that shares a device
 * with what it copies is drawn in the refusal hue, one step ABOVE having no
 * copy at all.
 *
 * Everything else is ink weight. The fill bar is achromatic on purpose: a
 * filesystem filling up is a fact, and turning it red at a threshold this page
 * does not know would be this console inventing a policy gunnar has not got. */
.disks-head {
  margin: 0.2rem 0 0.55rem;
  font-size: 0.76rem;
  font-weight: 600;
  letter-spacing: 0.05em;
  text-transform: uppercase;
  color: var(--ink-mute);
}

/* The sentence under a badge or a figure: why this is what it is. Never
 * competes with the value it explains, and always present where a badge is —
 * colour alone is the failure this palette was built against. */
.disks-note {
  margin: 0.3rem 0 0;
  color: var(--ink-mute);
  /* Explicit, because one of these sits inside a `th[scope="row"]`, which is
   * semibold: an OS error rendered in the row header's weight competes with
   * the row's own name. */
  font-weight: 400;
  font-size: 0.82rem;
  line-height: 1.45;
  max-width: 54rem;
  overflow-wrap: anywhere;
}

/* ★ The off-stripe health lines: one sentence per copy that needs another
 * failure domain, each naming the copy AND its limitation. A list rather than
 * a badge row on purpose — "present" is never the whole answer here, and a
 * layout that could only fit one word would force one. */
.off-stripe-health {
  display: grid;
  gap: 0.35rem;
  margin: 0.55rem 0 0;
}

/* How full a filesystem is. Unlike the Room table's `.wall` this needs no
 * ceiling rule drawn on it: the end of a disk IS the end of the track, and
 * unlike `.indexing .bar` it is not progress towards anything good. It is
 * drawn ONLY where both halves were measured — see `machine::fill_percent`,
 * which answers `None` rather than let a failed `statvfs`'s zeroes become a
 * bar at 0 % under the words "0 B of 0 B". */
table.disks th[scope="row"] .fill {
  height: 4px;
  margin: 0.35rem 0 0.1rem;
  max-width: 12rem;
  background: var(--line);
  border-radius: var(--radius-sm);
  overflow: hidden;
}

table.disks th[scope="row"] .fill span {
  display: block;
  height: 100%;
  background: var(--ink-dim);
}

/* ★ **What publishing would hand over.** The block the visibility control opens
 * before it will publish anything: the branch names, in the warn wash rather
 * than the bad one, because this is a deliberate act and not a failure. It is
 * deliberately NOT `.truncation`'s muted small print — the whole point of the
 * two-press design is that this paragraph is read, and muted grey is what a
 * reader's eye skips on a page already full of metadata. */
.visibility .consequence {
  padding: 0.85rem 1rem;
  margin: 0 0 0.75rem;
  border: 1px solid color-mix(in oklch, var(--warn) 40%, transparent);
  border-radius: var(--radius);
  background: var(--warn-wash);
  overflow-wrap: anywhere;
}

.visibility .consequence p {
  font-size: 0.88rem;
}

.visibility .consequence .hint {
  margin-top: 0.4rem;
}

/* The panel's own paragraphs carry no border, unlike `.truncation`, which draws
 * a rule above every line it sits on and would fence the button off from the
 * sentence explaining it. */
.visibility p {
  margin: 0.55rem 0 0;
}

/* ── Twin panel and restore ──────────────────────────────────────────── */

.twin-alarm .panel {
  border-color: var(--bad);
}

.twin tr.alarm td {
  background: var(--bad-wash);
}

.restore .consequence {
  padding: 0.85rem 1rem;
  margin: 0 0 0.75rem;
  border: 1px solid color-mix(in oklch, var(--bad) 40%, transparent);
  border-radius: var(--radius);
  background: var(--bad-wash);
  overflow-wrap: anywhere;
}

/* An order's three steps, in a row: done, current, still to come. */
ol.steps {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1.25rem;
  list-style: none;
  margin: 0 0 0.75rem;
  padding: 0;
}
ol.steps li {
  color: var(--ink-mute);
}
ol.steps li.current {
  font-weight: 600;
  color: var(--ink);
}
ol.steps li.done {
  color: var(--pass);
}
ol.steps li.done::before {
  content: "\2713\00a0";
}

/* A page header's switch (Identities, Usage: "Show system principals"). */
.page-header label.toggle {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  color: var(--ink-dim);
  font-size: 0.85rem;
}


/* ── The top bar: the navigation (owner's final navigation, 2026-10-02) ──
   [gunnar]  Repositories  (search…)        You ▾   Administration ▾      */

.brand-top {
  display: inline-flex;
  align-items: center;
  gap: 0.45rem;
  font-weight: 640;
  letter-spacing: -0.015em;
  text-decoration: none;
  color: var(--ink);
  flex: none;
}

/* The mark in the top bar, 4.7rem wide (75 x 41 px) since 2026-10-06 (owner:
   "a tidy bit bigger", +15-20 %; it was 4rem, 64 x 35, from 2026-10-05, and
   2.1rem, 34 x 19, before that: "so small I can't see it"). The side-view
   badger is long and low; 41 px tall still leaves the 52 px bar its height. */
.brand-mark-small {
  display: inline-block;
  width: 4.7rem;
  aspect-ratio: 1392 / 768;
  border-radius: 4px;
  background: url("/gunnar_light.webp") center / contain no-repeat;
}

/* From 860 px down the bar WRAPS, and its first line is as tall as its
   tallest item: the links and the switch are 36 px, so a 41 px mark would
   make the bar taller. There the mark stays 4rem (35 px tall). */
@media (max-width: 860px) {
  .brand-mark-small {
    width: 4rem;
  }
}

/* On a phone the first line of the wrapped bar is brand · Repositories ·
   the theme switch. The phone takes the same step as the desktop, 2.6rem ->
   3.05rem (49 x 27), and the bar gives back the room it costs: its sides go
   from 1rem to 0.6rem and the gap between the items from 0.75rem to 0.6rem,
   so the line still holds at 390 px (that rule sits after the phone's top-bar
   block below, which it must outrank). A UI test measures both widths in
   every theme. */
@media (max-width: 440px) {
  .brand-mark-small {
    width: 3.05rem;
  }
}

:root[data-theme="dark"] .brand-mark-small {
  background-image: url("/gunnar_dark.webp");
}

:root[data-theme="surprise"] .brand-mark-small {
  background-image: url("/gunnar_surprise.webp");
}

.top-link {
  color: var(--ink-dim);
  font-size: 0.92rem;
  text-decoration: none;
  padding: 0.35rem 0.6rem;
  border-radius: var(--radius-sm);
  flex: none;
}

.top-link:hover,
.top-link[aria-current="page"] {
  background: var(--surface-hover);
  color: var(--ink);
}

.top-search input {
  width: 14rem;
  max-width: 100%;
  padding: 0.3rem 0.6rem;
  font-size: 0.86rem;
}

details.menu {
  position: relative;
  flex: none;
}

details.menu > summary {
  list-style: none;
  cursor: pointer;
  padding: 0.35rem 0.6rem;
  border-radius: var(--radius-sm);
  color: var(--ink-dim);
  font-size: 0.9rem;
  white-space: nowrap;
}

details.menu > summary::-webkit-details-marker {
  display: none;
}

details.menu[open] > summary,
details.menu > summary:hover {
  background: var(--surface-hover);
  color: var(--ink);
}

details.menu.admin-menu > summary {
  color: var(--bad);
}

details.menu > .menu-items {
  position: absolute;
  right: 0;
  top: calc(100% + 0.35rem);
  z-index: 30;
  display: flex;
  flex-direction: column;
  min-width: 13rem;
  padding: 0.35rem;
  background: var(--surface-raised);
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  box-shadow: 0 8px 24px rgb(0 0 0 / 0.12);
}

.menu-link {
  display: block;
  padding: 0.4rem 0.6rem;
  border: none;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--ink);
  font: inherit;
  font-size: 0.9rem;
  text-align: left;
  text-decoration: none;
  cursor: pointer;
}

.menu-link:hover,
.menu-link[aria-current="page"] {
  background: var(--surface-hover);
}

.menu-logout {
  margin-top: 0.25rem;
  border-top: 1px solid var(--line);
  border-radius: 0;
  color: var(--ink-dim);
}

/* ── A repository's tabs: Code · History · Pulls · Issues · Activity · Settings */
.repo-tabs {
  display: flex;
  gap: 0.15rem;
  margin: -0.5rem 0 1.25rem;
  border-bottom: 1px solid var(--line);
  overflow-x: auto;
  overflow-y: hidden;
}

/* A header's data line (the server key) breaks anywhere rather than pushing a
   phone's page sideways. */
.page-header p.page-line {
  overflow-wrap: anywhere;
}

.repo-tab {
  flex: none;
  padding: 0.5rem 0.85rem;
  color: var(--ink-dim);
  font-size: 0.9rem;
  text-decoration: none;
  white-space: nowrap;
  border-bottom: 2px solid transparent;
}

.repo-tab:hover {
  color: var(--ink);
}

.repo-tab.on {
  color: var(--ink);
  font-weight: 560;
  border-bottom-color: var(--ink);
}

/* The top bar on a phone: it wraps instead of scrolling sideways — brand,
   Repositories and the menus on the first line, the search box on its own
   line under them — and a menu opens across the width of the screen. */
@media (max-width: 860px) {
  .topbar {
    position: relative;
    flex-wrap: wrap;
    height: auto;
    min-height: 52px;
    padding: 0.5rem 1rem;
    gap: 0.5rem 0.75rem;
  }

  .top-search {
    order: 10;
    flex-basis: 100%;
  }

  /* The health badge goes AFTER the menus on a phone (round 5.3, UI row
     the-header-mark-is-big-enough-and-the-bar-keeps-its-height: "dark 390 px:
     … not one line"). Between Repositories and the switch it pushed the
     switch — "✦ Surprise", the widest label — off the first line whenever the
     badge was drawn; the brand, Repositories and the switch keep that line. */
  .topbar > .badge {
    order: 5;
  }

  .top-search input {
    width: 100%;
  }

  details.menu {
    position: static;
  }

  /* Fixed to the viewport, not to a bar that may be wider than the phone. */
  details.menu > .menu-items {
    position: fixed;
    left: 1rem;
    right: 1rem;
    top: auto;
    min-width: 0;
    margin-top: 0.35rem;
  }
}

/* The narrowest phones give the bigger mark (2026-10-06) back its room: see
   `.brand-mark-small`. */
@media (max-width: 440px) {
  .topbar {
    padding-left: 0.6rem;
    padding-right: 0.6rem;
    column-gap: 0.6rem;
  }
}

/* ── The surprise theme: what a palette cannot say ─────────────────────────
 *
 * Every rule here is scoped to `:root[data-theme="surprise"]`, so light and
 * dark never match one of them; the values come from the surprise block at
 * the top of this file. Kept short on purpose: a face and its headline
 * weight, the accents on the few things that are actions or selections, a
 * hairline of gradient under the header, and a glow that stays subtle. No
 * glass, no blur, no neon. */

/* Large, bold headlines in Manrope 800, set tight. */
:root[data-theme="surprise"] .page-header h1 {
  font-size: 1.85rem;
  font-weight: 800;
  letter-spacing: -0.03em;
  line-height: 1.15;
}

:root[data-theme="surprise"] .page-header {
  margin-bottom: 2rem;
}

:root[data-theme="surprise"] .readme h1,
:root[data-theme="surprise"] .readme h2 {
  font-weight: 800;
  letter-spacing: -0.025em;
}

:root[data-theme="surprise"] .panel > header h2 {
  letter-spacing: -0.01em;
}

/* The header: the gradient, once, as its bottom hairline. */
:root[data-theme="surprise"] .topbar {
  background: var(--sidebar-bg);
  border-bottom: 1px solid transparent;
  border-image: var(--gradient) 1;
}

/* Links: white ink, a lavender underline. */
:root[data-theme="surprise"] a {
  text-decoration-color: color-mix(in srgb, var(--lavender) 45%, transparent);
}

:root[data-theme="surprise"] a:hover {
  text-decoration-color: var(--lavender);
}

/* Focus: a lavender ring with a soft purple halo. */
:root[data-theme="surprise"] :focus-visible {
  outline-color: var(--lavender);
  box-shadow: 0 0 0 4px var(--accent-wash);
}

:root[data-theme="surprise"] ::selection {
  background: rgba(139, 92, 246, 0.4);
  color: var(--ink-extreme);
}

/* The primary action: purple, white words (5.7:1), the gradient as its edge
 * and a purple glow under it. The gradient is NOT the fill: white on its blue
 * and cyan end is 3.7:1 and 2.2:1, and dark ink on its purple end 3.4:1, so no
 * one ink could be read across all of it. */
:root[data-theme="surprise"] .btn.primary {
  background: linear-gradient(var(--accent-deep), var(--accent-deep)) padding-box, var(--gradient) border-box;
  border-color: transparent;
  color: var(--ink-extreme);
  box-shadow: 0 6px 22px -10px var(--glow-purple);
}

:root[data-theme="surprise"] .btn.primary:hover:not(:disabled) {
  background: linear-gradient(var(--accent-hover), var(--accent-hover)) padding-box, var(--gradient) border-box;
  border-color: transparent;
  box-shadow: 0 8px 28px -8px var(--glow-purple), 0 0 0 1px var(--glow-cyan);
}

:root[data-theme="surprise"] .btn.primary.busy::after {
  color: var(--ink-extreme);
}

:root[data-theme="surprise"] .btn:hover:not(:disabled):not(.primary):not(.danger):not(.quiet) {
  border-color: var(--control-edge);
}

/* A form control's edge is its boundary, so it is held to 3:1. */
:root[data-theme="surprise"] :is(input[type="text"], input[type="search"], input[type="password"], select, textarea) {
  border-color: var(--control-edge);
}

/* The current place: lavender-washed, white words; the repository tab's
 * underline purple. */
:root[data-theme="surprise"] .top-link[aria-current="page"],
:root[data-theme="surprise"] .menu-link[aria-current="page"] {
  background: var(--accent-wash);
  color: var(--ink);
}

:root[data-theme="surprise"] .repo-tab.on {
  border-bottom-color: var(--accent);
}

:root[data-theme="surprise"] details.menu > .menu-items {
  box-shadow: 0 14px 36px -10px rgba(0, 0, 0, 0.7), 0 0 0 1px var(--line);
}

/* The estate board: a faint cyan and purple glow under it, nothing more. */
:root[data-theme="surprise"] .machine-card .estate-picture svg {
  box-shadow: 0 0 0 1px var(--line), 0 18px 48px -26px var(--glow-cyan), 0 12px 40px -30px var(--glow-purple);
}

/* ── UI-ENC: an "i" beside a switch, and its short box (hover, keyboard focus, tap) ──
 * The words are `src/encryption_info.html`'s; this only shows them. Esc blurs the button
 * (ui::InfoTip), which closes the box for the keyboard. */
.infotip {
  position: relative;
  display: inline-block;
  vertical-align: middle;
}
.infotip-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.05rem;
  height: 1.05rem;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 50%;
  background: transparent;
  color: var(--ink-dim);
  font: 700 0.7rem/1 var(--sans);
  cursor: help;
}
.infotip-btn:focus-visible {
  outline: 2px solid var(--live);
  outline-offset: 2px;
}
.infotip-box {
  display: none;
  position: absolute;
  z-index: 20;
  left: 0;
  top: calc(100% + 0.35rem);
  width: min(22rem, 80vw);
  padding: 0.5rem 0.65rem;
  border: 1px solid var(--line-strong);
  border-radius: var(--radius-sm);
  background: var(--surface-raised);
  color: var(--ink);
  font-size: 0.8rem;
  font-weight: 400;
  line-height: 1.4;
  white-space: normal;
}
.infotip:hover .infotip-box,
.infotip:focus-within .infotip-box {
  display: block;
}
