/* The palette, unchanged from the flat-tab portal — same greens, same warm
   greys, same pills. Only the layout around it is new.

   Declared three times rather than once, because a toggle cannot override a
   media query. Bare :root is light; the media block applies dark only when the
   viewer has not explicitly chosen light; the attribute block wins over both.
   No colour has its only definition inside a conditional. */
:root {
  --bg: #fbfbfa;
  --panel: #ffffff;
  --ink: #1c1c1a;
  --muted: #6d6c66;
  --line: #e3e2dd;
  --accent: #2f5d50;
  --good-bg: #e2efe6;
  --good-ink: #1f5136;
  --bad-bg: #f8e3e0;
  --bad-ink: #8a2b1c;
  --warn-bg: #f6eeda;
  --warn-ink: #7a5a15;
  /* Work in progress. Not one of the three verdict colours on purpose: a
     running check has not said anything yet, and blue is the one hue here that
     carries no opinion about whether the news is good. */
  --busy-bg: #e0e9f4;
  --busy-ink: #1f4a72;
  --shadow: 0 1px 2px rgba(28, 28, 26, 0.06), 0 8px 24px rgba(28, 28, 26, 0.06);
  --sidebar: 232px;
  --topbar: 52px;

  /* The spacing scale. It is here because it was not anywhere: twelve
     declarations referenced var(--space-N) against a variable no stylesheet
     defined, and an unresolvable var() is invalid at computed-value time — so
     every one of them silently became nothing. That is why the roster lines and
     the deployment cards had their text against the border. Not a taste
     problem; a missing definition.

     Four steps, 4/8/12/20px. Two adjacent steps have to be tellable apart or
     the scale is decoration, which is why there is no 16px between 3 and 4. */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1.25rem;
}

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #16161a;
    --panel: #1e1e23;
    --ink: #ecebe6;
    --muted: #9a988f;
    --line: #32323a;
    --accent: #7fb8a4;
    --good-bg: #1d3a2c;
    --good-ink: #a4dcbe;
    --bad-bg: #3d2320;
    --bad-ink: #f0a99b;
    --warn-bg: #3a3120;
    --warn-ink: #e3c583;
    --busy-bg: #1d2c3d;
    --busy-ink: #9cc4ea;
    --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
  }
}

:root[data-theme="dark"] {
  --bg: #16161a;
  --panel: #1e1e23;
  --ink: #ecebe6;
  --muted: #9a988f;
  --line: #32323a;
  --accent: #7fb8a4;
  --good-bg: #1d3a2c;
  --good-ink: #a4dcbe;
  --bad-bg: #3d2320;
  --bad-ink: #f0a99b;
  --warn-bg: #3a3120;
  --warn-ink: #e3c583;
  --busy-bg: #1d2c3d;
  --busy-ink: #9cc4ea;
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 8px 24px rgba(0, 0, 0, 0.35);
}

* { box-sizing: border-box; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font: 15px/1.5 ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
}

/* ===== Topbar ===== */

.topbar {
  position: sticky;
  top: 0;
  z-index: 40;
  display: flex;
  align-items: center;
  gap: 0.75rem;
  height: var(--topbar);
  padding: 0 1rem;
  border-bottom: 1px solid var(--line);
  background: var(--panel);
}

.topbar-right {
  margin-left: auto;
  display: flex;
  align-items: center;
  gap: 0.5rem;
}

.brand {
  font-weight: 650;
  letter-spacing: -0.01em;
  color: var(--ink);
  text-decoration: none;
}

.icon-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  margin: 0;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--bg);
  color: var(--muted);
  cursor: pointer;
}

.icon-btn:hover { color: var(--ink); }

/* The profile is a placeholder until preferences exist, so it is deliberately
   an outline rather than anything that suggests an identity is loaded. */
.avatar {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 2rem;
  height: 2rem;
  border-radius: 999px;
  border: 1px solid var(--line);
  background: var(--bg);
  color: var(--muted);
  text-decoration: none;
}

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

/* Only ever visible below the layout breakpoint. */
.menu-btn { display: none; }

/* ===== Layout ===== */

.shell {
  display: flex;
  align-items: flex-start;
}

.sidebar {
  flex: 0 0 var(--sidebar);
  width: var(--sidebar);
  position: sticky;
  top: var(--topbar);
  height: calc(100vh - var(--topbar));
  overflow-y: auto;
  padding: 1rem 0.6rem 2rem;
  border-right: 1px solid var(--line);
  background: var(--panel);
}

.sidebar-overlay { display: none; }

.nav-section { margin-bottom: 0.15rem; }

.nav-section > a {
  display: block;
  padding: 0.4rem 0.65rem;
  border-radius: 5px;
  color: var(--ink);
  text-decoration: none;
  font-size: 0.9rem;
  font-weight: 550;
}

.nav-section > a:hover { background: var(--bg); }
.nav-section > a.on { color: var(--accent); background: var(--bg); }

.nav-pages {
  margin: 0.1rem 0 0.5rem;
  padding: 0 0 0 0.65rem;
  list-style: none;
  border-left: 1px solid var(--line);
  margin-left: 0.65rem;
}

.nav-pages a {
  display: block;
  padding: 0.25rem 0.5rem;
  border-radius: 4px;
  color: var(--muted);
  text-decoration: none;
  font-size: 0.85rem;
}

.nav-pages a:hover { color: var(--ink); }
.nav-pages a.on { color: var(--accent); font-weight: 600; }

main {
  flex: 1 1 auto;
  min-width: 0;
  max-width: 1100px;
  padding: 1.5rem 1.5rem 4rem;
}

h1 { font-size: 1.5rem; margin: 0 0 1.25rem; letter-spacing: -0.02em; }
h2 { font-size: 1rem; margin: 2rem 0 0.65rem; letter-spacing: -0.01em; }
h3 { font-size: 0.9rem; margin: 1.25rem 0 0.5rem; }

section:first-of-type h2 { margin-top: 0.5rem; }

/* Links, in the portal's own green rather than the browser's blue.

   There was no rule here at all: tables set a colour, the nav set a colour, and
   every link outside both — "Full report", the links inside a note — fell
   through to the user agent's default. On the dark theme that is a saturated
   blue on a near-black panel, which is the one colour pair in this portal
   nobody can read. */
a { color: var(--accent); }

/* A link that is an action rather than a word in a sentence: "Full report",
   "Download traces", "Configuration →". These sit alone on a line or in a row
   of their own, and a bare underlined phrase there reads as decoration rather
   than as the thing to press.

   Outlined rather than filled, because a page can hold four of them and four
   solid accent buttons all claim to be the primary one. `.button-link` stays
   the filled form for the single action a page exists to perform. */
.action,
.todo li a,
.gcard-links a {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  padding: 0.3rem 0.7rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: var(--panel);
  color: var(--ink);
  font-size: 0.82rem;
  font-weight: 600;
  line-height: 1.3;
  text-decoration: none;
  white-space: nowrap;
}

.action:hover,
.todo li a:hover,
.gcard-links a:hover {
  border-color: var(--accent);
  color: var(--accent);
  background: var(--bg);
}

/* A row of them, with the sentence that follows sitting on its own line rather
   than being pushed around by the buttons. */
.actions-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2);
}

/* The breadcrumb, which is also the page heading — see views/partials/head.ejs.
   Small and uppercase because it is navigation first: the page says what it is
   in its own content, and a 1.5rem shout of "AGENTS › RUNS" would say it
   twice. Wraps rather than scrolls, so three levels still fit on a phone. */
.crumbs { margin: 0 0 1.25rem; }

.crumbs h1 {
  margin: 0;
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.4rem;
}

.crumbs a { color: var(--muted); text-decoration: none; }
.crumbs a:hover { color: var(--ink); text-decoration: underline; }
.crumbs a[aria-current="page"] { color: var(--ink); }
.crumbs .sep { color: var(--muted); opacity: 0.6; }

/* A run id, an app name, a host name. Left as it is written everywhere else,
   because shouting an identifier makes it harder to match against the one in
   the URL or the table you came from. */
.crumbs .name { text-transform: none; letter-spacing: 0; }

/* ===== Tables ===== */

/* Wide tables scroll inside their own box rather than pushing the page sideways
   — the run and event tables are both wider than a phone. */
.table-wrap {
  overflow-x: auto;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--panel);
}

table {
  width: 100%;
  border-collapse: collapse;
  background: var(--panel);
  font-size: 0.875rem;
}

/* A table not wrapped in .table-wrap keeps the border it used to draw itself. */
table:not(.table-wrap > table) {
  border: 1px solid var(--line);
  border-radius: 6px;
  overflow: hidden;
}

th, td {
  text-align: left;
  padding: 0.5rem 0.7rem;
  border-bottom: 1px solid var(--line);
  vertical-align: top;
}

th {
  font-weight: 600;
  font-size: 0.75rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
  white-space: nowrap;
}

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

td a { color: var(--accent); }

/* Date over time, so the eye can run down the times while the date stays
   available on the rows where it changes. */
.ts { white-space: nowrap; line-height: 1.3; }
.ts .d { display: block; color: var(--muted); font-size: 0.78rem; }
.ts .t { display: block; }

.mono {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.82em;
}

.payload {
  max-width: 380px;
  overflow-wrap: anywhere;
  color: var(--muted);
}

.num { text-align: right; white-space: nowrap; }

/* ===== Pills, notes, cards ===== */

.pill {
  display: inline-block;
  padding: 0.1rem 0.45rem;
  border-radius: 999px;
  font-size: 0.75rem;
  background: var(--line);
  color: var(--muted);
  white-space: nowrap;
}

.pill.good { background: var(--good-bg); color: var(--good-ink); }
.pill.bad { background: var(--bad-bg); color: var(--bad-ink); }
.pill.warn { background: var(--warn-bg); color: var(--warn-ink); }
.pill.busy { background: var(--busy-bg); color: var(--busy-ink); }

/* ===== Gate pills =====

   What has been checked about what an environment is running. A dot on the left
   carries the state a second time, because colour alone is not a way to tell
   someone something.

   These used to sit inside the environment's `h2`, with buttons that ran the
   same gates in a card underneath — the same fact twice, a line apart, in two
   visual languages. The pill is now the face of that control rather than a
   second telling of it: see `.gate-row`. */
.gate-row .pill { text-decoration: none; }
.gate-row .pill:hover { filter: brightness(1.08); }

.pill-dot {
  display: inline-block;
  width: 0.45rem;
  height: 0.45rem;
  border-radius: 50%;
  margin-right: 0.35rem;
  background: currentColor;
  vertical-align: baseline;
}

/* Both blink, and they are different colours: queued is grey because nothing is
   happening yet, running is blue because something is. The dot says "not
   settled", the colour says which half of that you are in. */
.pill-live .pill-dot { animation: pill-pulse 1.4s ease-in-out infinite; }

@keyframes pill-pulse {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.15; }
}

/* Motion is decoration here: the word beside the dot already says it. */
@media (prefers-reduced-motion: reduce) {
  .pill-live .pill-dot { animation: none; }
}

/* The header's own: something is running somewhere, click for the queue. */
.live-work { text-decoration: none; align-self: center; }
.live-work[hidden] { display: none; }

/* ===== One run's tests =====

   A list rather than a table: the fields are a status, a name, and sometimes an
   error of wildly varying length, which is the shape tables are worst at. It
   also means no separate mobile treatment — this reads the same at 390px and
   1400px. */
.tests { list-style: none; margin: 0; padding: 0; }

/* A row, not a card. Ninety-seven of these is a long page, and the shape that
   makes it readable is one line each with the name on the left and the verdict
   on the right -- so the eye runs down two columns instead of reading blocks. */
.test {
  padding: 0.22rem 0 0.22rem 0.6rem;
  border-left: 3px solid var(--line);
  margin: 0;
  font-size: 0.85rem;
}

/* Colour on the rail rather than the background: a page of thirteen tinted
   blocks is harder to read than a page with three marked edges. */
.test-failed { border-left-color: var(--bad-ink); }
.test-flaky { border-left-color: var(--warn-ink); }
.test-passed { border-left-color: var(--good-ink); }

.test-head {
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
}

/* Takes the space between the mark and the time, and wraps when the name is
   long -- which is the only reason a row is ever taller than one line. */
.test-name { font-weight: 400; min-width: 0; flex: 1; }

.test-time {
  font-size: 0.78rem;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
  flex-shrink: 0;
  white-space: nowrap;
}

/* First on the row, so the marks form a column of their own: a hundred tests
   is scannable when the three that failed are found without reading any of the
   names. The state is carried twice over -- colour, and a glyph that survives a
   colour deficiency and a black-and-white print. */
.test-mark { font-weight: 700; width: 1ch; text-align: center; flex-shrink: 0; }
.test-mark-passed { color: var(--good-ink); }
.test-mark-failed { color: var(--bad-ink); }
.test-mark-flaky { color: var(--warn-ink); }
.test-mark-skipped { color: var(--muted); }

.test-error {
  margin: 0.35rem 0 0;
  font-size: 0.8rem;
  color: var(--bad-ink);
  overflow-wrap: anywhere;
}

/* The history of one gate: dense on purpose. Every row is a link, so the row
   carries what you choose between -- when, how it went, what it checked, how
   long -- and nothing you would open the row to read. */
.runs-compact { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.runs-compact td { padding: 0.3rem 0.6rem 0.3rem 0; border-bottom: 1px solid var(--line); vertical-align: top; }
.runs-compact tr:last-child td { border-bottom: none; }
.runs-compact .run-when { white-space: nowrap; width: 1%; line-height: 1.25; }
.runs-compact .run-when .d { display: block; }
.runs-compact .run-when .t { display: block; color: var(--muted); font-size: 0.78rem; }
.runs-compact .run-outcome { width: 1%; white-space: nowrap; }
.runs-compact .run-checked { font-size: 0.8rem; }
.runs-compact .run-duration { width: 1%; text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }

/* How far a running job has got. A bar when there is a denominator, words when
   there is not, and nothing pretending to be either when the job has not said. */
.progress-line { display: flex; align-items: center; gap: 0.6rem; margin: 0.4rem 0; font-size: 0.85rem; }
.progress-count { font-variant-numeric: tabular-nums; white-space: nowrap; }
.progress-bar {
  flex: 1;
  height: 6px;
  min-width: 6rem;
  border-radius: 999px;
  background: var(--line);
  overflow: hidden;
}
.progress-fill { display: block; height: 100%; background: var(--busy-ink); transition: width 0.4s ease; }
.progress-pct { color: var(--muted); white-space: nowrap; }

/* The verdicts so far, under the bar: what a suite has decided about the tests
   it has run, in the colours the result column uses for the same words. */
.progress-verdicts { margin: 0 0 0.4rem; font-size: 0.85rem; color: var(--muted); font-variant-numeric: tabular-nums; }
.progress-verdicts .passed { color: var(--good-ink); }
.progress-verdicts .failed { color: var(--bad-ink); font-weight: 600; }

/* The output, folded. The numbers above answer "is it stuck"; this answers
   "where", and is opened by someone who wants to follow it line by line. */
.job-output { margin: var(--space-3) 0 0; }
.job-output summary { cursor: pointer; font-size: 0.85rem; }
.job-output summary .muted { margin-left: 0.35rem; }

/* The last of what it printed: a tail, not a log. Scrolls sideways rather than
   wrapping, because a wrapped stack trace is unreadable and this is evidence
   about where something stopped. */
.progress-tail {
  margin: 0.4rem 0 0;
  padding: 0.5rem 0.6rem;
  max-height: 12rem;
  overflow: auto;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 8px;
  font-size: 0.75rem;
  line-height: 1.4;
  white-space: pre;
  color: var(--muted);
}

.skipped-fold { margin-top: 0.75rem; }
.skipped-fold > summary {
  cursor: pointer;
  font-size: 0.85rem;
  color: var(--muted);
  padding: 0.3rem 0;
}
.skipped-fold[open] > summary { margin-bottom: 0.4rem; }

/* The backup form's explanation of the per-volume choices: a definition list,
   one choice per term, read before a select is changed rather than after. */
.volume-help { max-width: 60rem; }
.volume-help-list dt { font-weight: 600; margin-top: 0.5rem; font-family: var(--mono, monospace); }
.volume-help-list dd { margin: 0.15rem 0 0 1rem; color: var(--muted); }
.warn-text { color: var(--warn-ink); }

/* The gate's own report, embedded. Tall because it is a full application and a
   short frame makes it useless; the viewport unit keeps it sane on a phone,
   where 900px of iframe would be the whole page and then some. */
.report {
  width: 100%;
  height: min(70vh, 640px);
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--panel);
}

/* One check and its findings on a line, wrapping rather than widening the cell —
   several findings on one check is the normal case, not the exception. */
.check {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.35rem;
  margin-bottom: 0.25rem;
}
.check:last-child { margin-bottom: 0; }

/* ===== Platform components, told apart from what runs on them =====

   Two devices, chosen by what kind of list it is.

   A *roster* — apps, gates, deployments — has no inherent order, so the tier
   becomes a heading and the groups sit apart. A *timeline* — runs, events — is
   ordered by time, and splitting it in two destroys the property that makes it
   a timeline; there the row carries `.pill.platform` instead.

   Marked with a rule and a heading rather than a coloured panel, because colour
   in this portal already means outcome — good, warn, bad. A third meaning on
   the same channel would make a perfectly healthy platform read as a warning. */
.tier { margin-bottom: 2rem; }
.tier:last-child { margin-bottom: 0; }

.tier-head {
  font-size: 0.8rem;
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--muted);
  font-weight: 600;
  margin: 0 0 0.5rem;
  padding-bottom: 0.35rem;
  border-bottom: 1px solid var(--line);
}

.tier-head .muted { font-weight: 400; }

/* The heavier rule on the platform group, so the eye lands there first. */
.tier-platform .tier-head { border-bottom-color: var(--ink); color: var(--ink); }

.tier .note { margin-top: 0; }

/* For timelines, where grouping is not available. Deliberately quiet: it marks
   a row, it does not raise an alarm about one. */
.pill.platform {
  background: transparent;
  border: 1px solid var(--line);
  color: var(--muted);
  letter-spacing: 0.04em;
}

/* ===== Tables that stop being tables on a phone =====

   `.table-wrap` scrolls sideways, which is the right escape hatch for a genuinely
   wide table and the wrong primary layout for the thing you check from your phone.
   A four-column table in a 390px viewport is a scrollbar and one and a half
   columns; the ten-column gates table is worse.

   So below the breakpoint each row becomes a card and each cell labels itself from
   `data-label`. One template, two layouts — the alternative was a second block of
   markup per page, and two renderings of one truth drift the moment somebody edits
   only the one they were looking at.

   The breakpoint is 720px rather than the 899px the sidebar uses. Between the two
   the drawer has already freed the full width, and a table that fits should stay a
   table: cards are a concession, not an improvement.

   Opt in with `.cards-sm` rather than applying to every table, because a two-column
   table is already fine and would only get taller. */
@media (max-width: 720px) {
  /* The scroller has nothing to scroll once rows are blocks, and leaving it
     armed puts a stray horizontal scrollbar under the cards. */
  .table-wrap:has(.cards-sm) {
    overflow-x: visible;
    border: none;
    background: none;
  }

  .cards-sm,
  .cards-sm tbody,
  .cards-sm tr,
  .cards-sm td {
    display: block;
    width: 100%;
  }

  /* Headers are the labels, and they move onto each cell below. */
  .cards-sm thead { display: none; }

  .cards-sm tr {
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: 8px;
    padding: 0.65rem 0.8rem;
    margin-bottom: 0.6rem;
  }
  .cards-sm tr:last-child { margin-bottom: 0; }

  .cards-sm td {
    border: none;
    padding: 0.2rem 0;
  }

  .cards-sm td[data-label]::before {
    content: attr(data-label);
    display: block;
    font-size: 0.68rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    color: var(--muted);
    margin-bottom: 0.05rem;
  }

  /* The first cell is what the card is *about*, so it reads as a heading rather
     than as another labelled field. */
  .cards-sm td.card-title {
    font-size: 1rem;
    font-weight: 600;
    padding-bottom: 0.35rem;
  }
  .cards-sm td.card-title::before { content: none; }

  /* An empty cell would otherwise render as a label with nothing under it —
     common in these tables, where a trailing cell holds an optional action. */
  .cards-sm td:empty { display: none; }

  /* Long identifiers — image refs, URLs, shas — must wrap rather than push the
     card wider than the screen. */
  .cards-sm td.mono,
  .cards-sm td.payload {
    overflow-wrap: anywhere;
  }
}

/* ===== The lists that become compact cards on a phone =====

   Three tables — runs, backups, configured gates — are lists you scroll rather
   than rows you read one of, and the generic card above is wrong for all three
   in the same way: one labelled line per field made a card taller than the
   thing it describes. A run is "what ran, how it went, when, what it cost";
   stacked with a caps-lock label over each, that is six lines and two runs to a
   screen.

   So they share a two-column grid — what it is on the left, what it cost or how
   it went on the right — and each table places its own cells into it below. The
   labels go, because a duration, a timestamp and a dollar amount describe
   themselves; where a value genuinely cannot (a bare `7 / 30d`), the label
   comes back inline in front of it rather than on a line of its own. */
@media (max-width: 720px) {
  .cards-sm.runs tr,
  .cards-sm.backups tr,
  .cards-sm.gates tr {
    display: grid;
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: baseline;
    column-gap: 0.6rem;
    row-gap: 0.15rem;
    padding: 0.5rem 0.7rem;
    margin-bottom: 0.4rem;
  }

  .cards-sm.runs td,
  .cards-sm.backups td,
  .cards-sm.gates td {
    padding: 0;
    min-width: 0;
  }

  .cards-sm.runs td[data-label]::before,
  .cards-sm.backups td[data-label]::before,
  .cards-sm.gates td[data-label]::before { content: none; }

  /* An opened error needs the width the pill did not, so the cell leaves the
     right-hand column and takes a line of its own under the card. Only the row
     you asked about moves, and only while it is open. */
  .cards-sm.runs td:has(.outcome[open]),
  .cards-sm.backups td:has(.outcome[open]) {
    grid-area: 9 / 1 / auto / -1;
    justify-self: stretch;
    margin-top: 0.2rem;
    text-align: left;
  }

  .cards-sm.runs .outcome .why,
  .cards-sm.backups .outcome .why { max-width: none; }

  /* ---- Runs: the name and how it went, then the app, then time and money.

         agent name                        [outcome]
         app  ·  platform                    4m 12s
         29 Aug 14:03                         $1.23           */
  .cards-sm.runs td[data-label="Name"] {
    grid-area: 1 / 1;
    font-size: 0.95rem;
    font-weight: 600;
  }
  .cards-sm.runs td[data-label="Outcome"] { grid-area: 1 / 2; justify-self: end; }
  .cards-sm.runs td[data-label="App"] { grid-area: 2 / 1; color: var(--muted); }
  .cards-sm.runs td[data-label="Duration"] { grid-area: 2 / 2; justify-self: end; }
  .cards-sm.runs td[data-label="Cost"] { grid-area: 3 / 2; justify-self: end; }

  /* The timestamp drops to caption weight — on a list ordered by time, the row
     above already told you roughly when this was. */
  .cards-sm.runs td.card-title {
    grid-area: 3 / 1;
    font-size: 0.8rem;
    font-weight: 400;
    color: var(--muted);
    padding-bottom: 0;
  }

  /* Date over time costs a line here and buys nothing: these cards are already
     separated from each other, so the date has no column of times to head. */
  .cards-sm.runs td.ts .d,
  .cards-sm.runs td.ts .t {
    display: inline;
    font-size: inherit;
    color: inherit;
  }
  .cards-sm.runs td.ts .d { margin-right: 0.35rem; }

  /* A running row tints the whole card. The desktop rule paints cells, and the
     cells here are five separate boxes with gaps between them. */
  .cards-sm.runs tr.live { background: color-mix(in srgb, var(--accent) 6%, transparent); }
  .cards-sm.runs tr.live td { background: none; }

  /* ---- Gates: what is watched and whether it is green.

         gifts/test                         [failure]
         E2E                               on deploy
         7 passed · 2 failed · 1 finding                  */
  .cards-sm.gates td.card-title { grid-area: 1 / 1; font-size: 0.95rem; }
  .cards-sm.gates td[data-label="Result"] { grid-area: 1 / 2; justify-self: end; }
  .cards-sm.gates td[data-label="Type"] { grid-area: 2 / 1; }
  .cards-sm.gates td[data-label="Triggers"] { grid-area: 2 / 2; justify-self: end; }
  .cards-sm.gates td[data-label="Checked"] { grid-area: 3 / 1 / auto / -1; font-size: 0.85rem; }

  /* ---- Backups: the policy, the verdict, and what is in the copy.

         gifts/prod                         [success]
         every 24h                    27 Aug 03:00
         CONTENTS 1 database · 812 rows    KEEPS 7 / 30d
         agentplatform-prod-data            [Run now]     */
  .cards-sm.backups td[data-label="Backup"] {
    grid-area: 1 / 1;
    font-size: 0.95rem;
    font-weight: 600;
  }
  .cards-sm.backups td[data-label="Result"] { grid-area: 1 / 2; justify-self: end; }
  .cards-sm.backups td[data-label="Schedule"] { grid-area: 2 / 1; color: var(--muted); }
  .cards-sm.backups td[data-label="Last copy"] { grid-area: 2 / 2; justify-self: end; }
  .cards-sm.backups td[data-label="Contents"] { grid-area: 3 / 1; }
  .cards-sm.backups td[data-label="Keeps"] { grid-area: 3 / 2; justify-self: end; white-space: nowrap; }
  .cards-sm.backups td[data-label="Volumes"] { grid-area: 4 / 1; overflow-wrap: anywhere; }
  .cards-sm.backups td[data-label=""] { grid-area: 4 / 2; justify-self: end; }

  /* The two values that cannot describe themselves keep their label, in front
     of the value rather than above it. */
  .cards-sm.backups td[data-label="Keeps"]::before,
  .cards-sm.backups td[data-label="Contents"]::before {
    content: attr(data-label) " ";
    display: inline;
    margin: 0;
    font-size: 0.7rem;
    letter-spacing: 0.06em;
    text-transform: uppercase;
    color: var(--muted);
  }

  /* The volume names carry that line, so they are read at the size of the text
     around them rather than at code size on top of it. */
  .cards-sm.backups td[data-label="Volumes"] code {
    background: none;
    padding: 0;
    font-size: 0.78rem;
  }

  .cards-sm.backups button { padding: 0.2rem 0.6rem; font-size: 0.78rem; }
}


/* ===== The handful of things that used to be inline style attributes =====

   Moved here so the portal's CSP can be `style-src 'self'` with no
   `unsafe-inline` — which is what makes the header worth sending at all, since
   a policy that permits inline styles permits an injected one. Named for what
   they are for rather than for what they do, so a second use reads correctly. */

/* Several small forms sitting on one line as a row of buttons, rather than each
   claiming a block of its own. */
form.inline { display: inline; }

/* An editor that fills the column it is in. */
.fill { width: 100%; }

/* Stored text shown as written — a definition revision, a captured log — where
   the line breaks are part of the content. */
.wrap { white-space: pre-wrap; }

/* The quiet half of a pair of actions: the one that does not do the thing. */
.button-link.secondary { background: var(--line); color: var(--ink); margin-left: 0.5rem; }

/* What a gate checked, beside the pill saying how it went. Small and quiet:
   the pill is the answer and this is the working, and a table row that shouts
   both is a table nobody scans. */
.summary { display: block; font-size: 0.75rem; margin-top: 0.2rem; }

/* Findings, named rather than counted — the reason they travel home at all.
   Unstyled markers, because the severity pill already opens each line. */
.findings { list-style: none; margin: 0.35rem 0 0; padding: 0; font-size: 0.8rem; }
.findings li { margin: 0.15rem 0; }

.counts { display: flex; gap: 1.25rem; flex-wrap: wrap; margin: 0 0 1rem; }
.count { font-size: 0.85rem; color: var(--muted); }
.count strong { color: var(--ink); font-size: 1rem; }

.empty, .note { color: var(--muted); font-size: 0.875rem; margin: 0 0 1rem; }
.note.good { color: var(--good-ink); }
.note.bad { color: var(--bad-ink); }
.muted { color: var(--muted); }
.error { color: var(--bad-ink); }

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(190px, 1fr));
  gap: 0.75rem;
  margin: 0 0 1.5rem;
}

.card {
  display: block;
  padding: 0.8rem 0.9rem;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--panel);
  text-decoration: none;
  color: inherit;
}

.card:hover { border-color: var(--accent); }

.card .label {
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--muted);
}

.card .value {
  font-size: 1.35rem;
  font-weight: 600;
  letter-spacing: -0.02em;
  margin: 0.15rem 0 0.1rem;
}

.card .sub { font-size: 0.8rem; color: var(--muted); }

/* Two columns that become one on a narrow screen. */
.split {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
  gap: 1.5rem;
}

/* ===== Waiting-for-you list ===== */

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

.todo li {
  display: flex;
  align-items: baseline;
  gap: 0.6rem;
  padding: 0.55rem 0.8rem;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--panel);
  margin-bottom: 0.4rem;
  font-size: 0.9rem;
}

.todo li.bad { border-left: 3px solid var(--bad-ink); }
.todo li.warn { border-left: 3px solid var(--warn-ink); }
.todo .grow { flex: 1 1 auto; }

/* ===== Forms ===== */

input, select, textarea {
  width: 100%;
  padding: 0.3rem 0.45rem;
  border: 1px solid var(--line);
  border-radius: 4px;
  background: var(--bg);
  color: var(--ink);
  font-size: 0.9rem;
}

input[type="checkbox"] { width: auto; }

/* A dropdown is never improved by being wider than its longest option. A text
   field can use every pixel it is given; "Stand-alone app" cannot, and stretched
   to the full width of the page it read as an unfinished form. */
select { max-width: 24rem; }

button {
  margin-top: 1rem;
  padding: 0.45rem 1.1rem;
  border: none;
  border-radius: 5px;
  background: var(--accent);
  color: var(--bg);
  font-size: 0.875rem;
  font-weight: 600;
  cursor: pointer;
}

button.danger { background: var(--bad-ink); }

button.quiet {
  background: transparent;
  color: var(--muted);
  border: 1px solid var(--line);
}

code {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.85em;
  background: var(--line);
  padding: 0.1rem 0.3rem;
  border-radius: 3px;
}

.button-link {
  display: inline-block;
  padding: 0.4rem 0.9rem;
  border-radius: 5px;
  background: var(--accent);
  color: var(--bg);
  font-size: 0.85rem;
  font-weight: 600;
  text-decoration: none;
}

/* Secrets and commands are copied by hand — they must not wrap ambiguously. */
.token {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.82rem;
  background: var(--panel);
  border: 1px solid var(--line);
  border-left: 3px solid var(--accent);
  border-radius: 5px;
  padding: 0.7rem 0.9rem;
  overflow-x: auto;
  white-space: pre;
  user-select: all;
}

td label { font-weight: 600; }

/* ===== Errors in a table ===== */

/* The message used to be dumped into a full-width row under every failed run,
   which doubled the height of the table and put the least-read text in the
   most prominent position. A <details> keeps it one click away and needs no
   JavaScript to open. */
.outcome > summary {
  list-style: none;
  cursor: pointer;
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
}

.outcome > summary::-webkit-details-marker { display: none; }
.outcome[open] > summary { margin-bottom: 0.35rem; }

.outcome .why {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.78rem;
  color: var(--bad-ink);
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  max-width: 420px;
  margin: 0;
}

/* The caret only appears when there is something behind it. */
.outcome > summary::after { content: " ⌄"; color: var(--muted); font-size: 0.7rem; }
.outcome[open] > summary::after { content: " ⌃"; }

/* ===== Dialog ===== */

dialog {
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--panel);
  color: var(--ink);
  padding: 1.25rem;
  max-width: 520px;
  width: calc(100vw - 2rem);
  box-shadow: var(--shadow);
}

dialog::backdrop { background: rgba(0, 0, 0, 0.45); }
dialog h2 { margin-top: 0; }

/* An editor, not a confirmation. 520px is right for "are you sure"; a prompt of
   two hundred lines needs the width to show where its own lines wrap, because
   that is how the person writing it reads its shape. */
dialog.wide { max-width: 56rem; }
dialog.wide textarea { min-height: 60vh; }

/* A row of controls that belong together, one of which is a form of its own —
   without this they stack, because a form is a block. */
.actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-3);
  margin: var(--space-3) 0;
}

.actions form { margin: 0; }
.actions button { margin-top: 0; }

button.secondary { background: var(--line); color: var(--ink); }
button.small { padding: 0.25rem 0.7rem; font-size: 0.85rem; }

/* Secondary text inside a cell that is already dense — a mount list beside the
   columns it qualifies, or the sentence under a state pill. Quieter than the
   row, because the row is the answer and this is the detail behind it. */
.small { font-size: 0.78rem; line-height: 1.35; }

/* An input whose content is a path, an image reference or a volume list. Those
   are long, and a field that shows a third of one is a field people paste into
   without reading. */
input.wide { width: 100%; max-width: 34rem; }

/* ===== A document, rendered =====

   A definition is prose someone wrote for someone to follow, so it is set as
   prose: a measure that does not run the width of a monitor, and headings that
   sit under this page's own rather than competing with them.

   The width is on the text and not on the section, so the tables and controls
   around it still use the room they need. */
.prose {
  max-width: 46rem;
  margin: var(--space-4) 0;
}

.prose h3, .prose h4, .prose h5, .prose h6 {
  margin: 1.6rem 0 0.5rem;
  letter-spacing: -0.01em;
}

.prose h3 { font-size: 1.15rem; }
.prose h4 { font-size: 1rem; }
.prose h5, .prose h6 { font-size: 0.9rem; color: var(--muted); }
.prose p { margin: 0 0 0.85rem; }
.prose ul, .prose ol { margin: 0 0 0.85rem; padding-left: 1.35rem; }
.prose li { margin-bottom: 0.3rem; }
.prose hr { border: none; border-top: 1px solid var(--line); margin: 1.5rem 0; }

.prose code {
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 4px;
  /* Tight horizontally: 0.3rem left a gap before a following full stop that
     read as a typo rather than as padding. */
  padding: 0.05rem 0.22rem;
  font-size: 0.9em;
}

.prose pre.code {
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: var(--space-3);
  overflow-x: auto;
}

.prose pre.code code { background: none; border: none; padding: 0; }

.prose blockquote {
  margin: 0 0 0.85rem;
  padding-left: var(--space-3);
  border-left: 3px solid var(--line);
  color: var(--muted);
}

/* ===== The documents themselves =====

   A document is prose too, so .doc sits on top of .prose and adds only what a
   whole document has and a definition inside a page does not.

   renderDocument offsets headings by one: the document's own title becomes the
   page's h2 and its sections h3. .prose stops at h3 because a definition lives
   under a page that owns the h1 and h2 -- so without this, the largest heading
   in a document was the browser default. The h2 here is deliberately the page
   title's size and not the global h2's, which is a section label. */
.doc h2 {
  font-size: 1.5rem;
  margin: 0 0 1rem;
  letter-spacing: -0.02em;
}

.doc h3 { font-size: 1.15rem; margin-top: 2rem; }
.doc h2 + h3 { margin-top: 1rem; }

/* Headings are link targets from the contents list; give one that has been
   jumped to room above so it does not sit under the topbar. */
.doc h2, .doc h3, .doc h4 { scroll-margin-top: 4rem; }

/* Tables came into the renderer for the port allocation table in
   architecture.md. They arrive wrapped in .table-wrap and so are already styled
   as the portal's tables are; the document only needs them spaced like its
   paragraphs, and the headers allowed to wrap, since a column heading written
   in prose is not the two-word label the tables elsewhere carry. */
.doc .table-wrap { margin: 0 0 1rem; }
.doc th { white-space: normal; }

/* The index: title and summary per document, no bullet, no card. A card would
   make three documents look like a dashboard. */
.doc-list {
  list-style: none;
  margin: 0;
  padding: 0;
  max-width: 46rem;
}

.doc-list li {
  padding: var(--space-3) 0;
  border-bottom: 1px solid var(--line);
}

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

.doc-list h2, .doc-list h3 { margin: 0 0 0.25rem; font-size: 1.05rem; }
.doc-list h2 a, .doc-list h3 a { color: var(--accent); text-decoration: none; }
.doc-list h2 a:hover, .doc-list h3 a:hover { text-decoration: underline; }
.doc-list p { margin: 0; }
.doc-summary { margin: 0 0 0.2rem; color: var(--ink); }
.doc-list .muted { font-size: 0.78rem; }

/* The contents list, folded. It is above the text and not beside it (see
   doc.ejs), so it is a bordered block that opens downward rather than a raw
   disclosure triangle. */
.doc-contents {
  max-width: 46rem;
  margin: var(--space-3) 0;
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--panel);
}

.doc-contents > summary {
  cursor: pointer;
  padding: 0.55rem var(--space-3);
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
}

.doc-contents ul {
  margin: 0;
  padding: 0 var(--space-3) var(--space-3) var(--space-3);
  list-style: none;
}

.doc-contents li { margin: 0.2rem 0; font-size: 0.9rem; }
.doc-contents a { color: var(--accent); text-decoration: none; }
.doc-contents a:hover { text-decoration: underline; }

/* ===== Settings =====

   A definition list rather than a table: each row is a label and one control,
   and a table would give the label a column as wide as the longest one.

   One line per setting. The label column is fixed so every control lines up
   down the page — a form whose inputs step left and right as the labels change
   length is harder to scan than one with a ragged right edge — and the
   explanation is behind the info button rather than under the label. Nine
   settings each carrying two sentences of prose was a page you scrolled to find
   the box you came for, and the prose was read once. */
.settings { margin: 0 0 var(--space-4); }

.setting {
  display: grid;
  grid-template-columns: minmax(10rem, 18rem) minmax(0, 1fr);
  gap: 0 var(--space-3);
  align-items: baseline;
  padding: 0.3rem 0;
  border-top: 1px solid var(--line);
}

.setting dt { margin: 0; display: flex; align-items: baseline; gap: 0.4rem; }
.setting dd { margin: 0; display: flex; flex-wrap: wrap; align-items: baseline; gap: 0 var(--space-2); }
.setting label { font-weight: 600; }
.setting input, .setting select { max-width: 18rem; }
.setting input[type="number"] { max-width: 8rem; }
.setting .unit { font-size: 0.85rem; color: var(--muted); }

/* Where the value came from, said in words beside it rather than under it. A
   page that shows an inherited value and an override identically makes clearing
   a box a leap of faith. */
.setting .source { font-size: 0.8rem; color: var(--muted); }
.setting.overridden label::after {
  content: "override";
  margin-left: 0.5rem;
  font-size: 0.7rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--warn-ink);
}

/* The explanation, one tap away. Round, quiet and the size of the text it sits
   beside: it is available on every field, so it must not read as a warning on
   any of them. */
.info {
  flex: none;
  width: 1.05rem;
  height: 1.05rem;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 999px;
  background: transparent;
  color: var(--muted);
  font-size: 0.7rem;
  font-weight: 700;
  line-height: 1;
  cursor: pointer;
}

.info:hover { color: var(--ink); border-color: var(--muted); }

dialog.help { max-width: 24rem; }
dialog.help h2 { margin-top: 0; font-size: 1rem; }
dialog.help p { margin: 0; }
dialog.help .dismiss { margin-top: var(--space-3); font-size: 0.78rem; color: var(--muted); }

/* Still one line on a phone, with the label giving up its fixed width and the
   control taking the right-hand half. Stacking the two would double a page
   whose whole point is that you can see every setting at once; what wraps
   instead, and only when it has to, is the sentence saying where the value came
   from. */
@media (max-width: 700px) {
  .setting {
    grid-template-columns: minmax(5rem, auto) minmax(0, 58%);
    justify-content: space-between;
    padding: 0.45rem 0;
  }
  .setting dd { justify-content: flex-end; text-align: right; }

  /* The control takes the room that is left rather than all of it, so a unit or
     a short source note stays on the line beside it instead of wrapping under —
     which was the second line this layout exists to remove. */
  .setting input, .setting select, .setting input[type="number"] {
    flex: 1 1 5rem;
    width: auto;
    min-width: 0;
    max-width: 100%;
  }
}

/* ===== Meters =====

   One box per figure, wide enough that the bar is worth drawing. Three or four
   across a laptop: a host has a CPU, a memory and one or two disks, and they are
   read together — "is this box in trouble" is a question about all of them at
   once, not a list to scroll. */
.meters {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  gap: var(--space-3);
  margin-bottom: var(--space-4);
}

.meter {
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--panel);
  padding: var(--space-3) var(--space-4);
}

.meter-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: var(--space-2);
  margin-bottom: var(--space-2);
}

.meter-label { font-weight: 600; letter-spacing: -0.01em; }
.meter-value { font-variant-numeric: tabular-nums; font-size: 1.1rem; }

.meter-track {
  height: 0.5rem;
  border-radius: 999px;
  background: var(--bg);
  border: 1px solid var(--line);
  overflow: hidden;
}

.meter-fill {
  display: block;
  height: 100%;
  background: var(--accent);
  border-radius: 999px;
}

/* Colour only at the point where it means something. A bar that shades from
   green through amber at every value makes 40% look like a warning. */
.meter-fill.warn { background: var(--warn-ink); }
.meter-fill.bad { background: var(--bad-ink); }

.meter-detail {
  margin: var(--space-2) 0 0;
  font-size: 0.8rem;
  color: var(--muted);
}

/* Three figures in a column, for the list where the bars would not fit. */
.usage { display: flex; flex-wrap: wrap; gap: 0.5rem; font-size: 0.8rem; }
.usage .hot { color: var(--bad-ink); font-weight: 600; }

.definition { margin-bottom: 3rem; }
.definition h2 .muted { font-weight: 400; font-size: 0.85rem; }
.history { margin-top: var(--space-4); }

/* The global button rule adds 1rem above, which is right after a paragraph and
   wrong inside a table cell — it made the row carrying the button taller than
   every other row. */
.history td button { margin-top: 0; }
.history td form { margin: 0; }

.choice {
  display: flex;
  gap: 0.55rem;
  align-items: flex-start;
  padding: 0.5rem 0;
  border-bottom: 1px solid var(--line);
}

.choice:last-of-type { border-bottom: none; }
.choice input { margin-top: 0.25rem; }
.choice .name { font-weight: 600; font-size: 0.9rem; }
.choice .why { font-size: 0.8rem; color: var(--muted); }

.dialog-actions { display: flex; gap: 0.6rem; align-items: center; }
.dialog-actions button { margin-top: 0; }

/* A block that will hold the commit list once something generates one. */
.placeholder {
  border: 1px dashed var(--line);
  border-radius: 6px;
  padding: 0.7rem 0.85rem;
  color: var(--muted);
  font-size: 0.82rem;
  margin: 0.75rem 0;
}

/* ===== What a run did =====

   The reduction of a transcript, on the run page. Three bands, in the order the
   questions are asked: what it concluded, what it reached for, what it changed.
   The transcript itself is a page away, and the link to it is the last thing
   here rather than the first — someone who needed the messages has already read
   past everything above. */

.verdict {
  border: 1px solid var(--line);
  border-left: 3px solid var(--accent);
  border-radius: 6px;
  background: var(--panel);
  padding: 0.7rem 0.9rem;
  margin: 0 0 1rem;
}

.verdict-head { margin: 0; display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; font-size: 0.875rem; }

/* The agent's own prose. Wrapped and left at reading width rather than stretched
   across a wide screen, and pre-line because these summaries arrive with the
   paragraph breaks the agent wrote. */
.verdict-body {
  margin: 0.6rem 0 0;
  white-space: pre-line;
  max-width: 70ch;
}

.artifact-list, .file-list { margin: 0.5rem 0 0; padding-left: 1.1rem; font-size: 0.82rem; }
.artifact-list li, .file-list li { margin: 0.15rem 0; overflow-wrap: anywhere; }

/* The tally. Chips rather than a table: these are three to six short facts, and
   a table of two columns and five rows is a heavier object than the thing it
   would be describing. */
.did { font-size: 0.875rem; margin: 0 0 1rem; display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
.did .sep { color: var(--muted); opacity: 0.6; }

.tool {
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.75rem;
  background: var(--line);
  color: var(--muted);
  border-radius: 4px;
  padding: 0.1rem 0.4rem;
  white-space: nowrap;
}

.transcript-link { font-weight: 600; }

/* ===== Transcript ===== */

.transcript {
  border: 1px solid var(--line);
  border-radius: 6px;
  background: var(--panel);
  max-height: 70vh;
  overflow: auto;
  font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 0.78rem;
}

/* On its own page there is nothing to scroll past, so the box does not need to
   scroll inside itself. A pane with its own scrollbar inside a page with
   another one is the arrangement where the mouse wheel does the wrong thing. */
.transcript-page .transcript { max-height: none; overflow: visible; }

.turn { padding: 0.5rem 0.75rem; border-bottom: 1px solid var(--line); }
.turn:last-child { border-bottom: none; }
.turn .who { color: var(--muted); font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.05em; }
.turn pre { margin: 0.25rem 0 0; white-space: pre-wrap; overflow-wrap: anywhere; }

/* A tool call or result: one line to scan by, the whole of it one click away. */
.tool { margin-top: 0.25rem; }
.tool > summary {
  cursor: pointer;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.tool-name { font-weight: 600; }
.tool-brief { color: var(--muted); }
.tool[open] > summary .tool-brief { white-space: normal; }
.tool > pre {
  max-height: 60vh;
  overflow: auto;
  padding: 0.5rem;
  border: 1px solid var(--line);
  border-radius: 4px;
}
.tool-error .tool-name { color: var(--bad-ink); }
.turn-tool-result .who { display: none; }

/* ===== Mobile ===== */

@media (max-width: 899px) {
  .menu-btn { display: inline-flex; }

  /* The albumsite drawer: off-canvas, slid in, with the page behind it locked
     and an overlay that closes it. */
  .sidebar {
    position: fixed;
    top: 0;
    left: 0;
    height: 100vh;
    width: min(280px, 82vw);
    flex-basis: auto;
    z-index: 70;
    transform: translateX(-110%);
    transition: transform 0.22s ease;
    box-shadow: var(--shadow);
    padding-top: 1rem;
  }

  .sidebar.open { transform: translateX(0); }

  .sidebar-overlay {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.45);
    z-index: 60;
  }

  body.sidebar-open { overflow: hidden; }
  body.sidebar-open .sidebar-overlay { display: block; }

  main { padding: 1.25rem 1rem 3rem; }
  .cards { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
}

@media (prefers-reduced-motion: reduce) {
  .sidebar { transition: none; }
}

/* A pick-one form. The agents form beside it is two short checkboxes and flows
   fine inline; these labels are sentences, and inline they wrapped away from
   their own radio button. Stacked, each option is one line you can hit with a
   thumb. */
.choice {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.4rem;
  margin-bottom: 0.5rem;
}

.choice label {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  cursor: pointer;
}

.choice button { margin-top: 0.35rem; }

/* ===== A command to run elsewhere =====

   Wraps rather than scrolls: this is read before it is run as root, and a line
   that runs off the edge of a phone is one you cannot check. */
.copy-command {
  display: flex;
  align-items: flex-start;
  gap: 0.5rem;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 0.6rem 0.7rem;
  margin: 0.5rem 0 1rem;
}

.copy-command code {
  flex: 1 1 auto;
  min-width: 0;
  overflow-wrap: anywhere;
  font-size: 0.82rem;
  background: none;
  padding: 0;
}

.copy-button { flex: 0 0 auto; }

/* The deployer is listed among the deployments but is not one of them. */
.deployer-row td { border-top: 1px solid var(--line); }

/* ===== The roster =====

   One line per app, three columns, no prose. What this page is asked is "is
   production all right, and what is it running"; everything else has a page of
   its own with room for it.

   The line is an anchor rather than a row with a link in it, so the click
   target is the whole line and not the few characters of a name. */
.app-lines {
  list-style: none;
  margin: 0 0 var(--space-4);
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 8px;
  overflow: hidden;
  background: var(--panel);
}

.app-line + .app-line { border-top: 1px solid var(--line); }

/* Name left, the two facts about it together on the right, and a chevron on the
   edge. The middle column used to be a fixed 10rem with the name taking every
   remaining pixel, which on a wide screen left the version stranded in the
   middle of a field of nothing — related things reading as unrelated. */
.app-line > a {
  display: grid;
  grid-template-columns: minmax(8rem, 1fr) auto minmax(9rem, 13rem) 0.75rem;
  align-items: center;
  gap: var(--space-4);
  padding: 0.7rem var(--space-4);
  text-decoration: none;
  color: inherit;
}

/* A row is a touch target as well as a line of text. 0.7rem top and bottom puts
   it a shade over 40px, which is the smallest a pointer target should be, and
   is the difference between a list and a wall of text. */
.app-line > a:hover { background: var(--bg); }
.app-line.unmanaged > a { opacity: 0.62; }
.app-line-name { font-weight: 600; letter-spacing: -0.01em; }
.app-line-version { color: var(--muted); font-size: 0.85rem; text-align: right; }

/* The affordance for a row that is entirely a link. Aria-hidden: it says
   "clickable", which a screen reader already knows from the anchor. */
.app-line-go { color: var(--line); text-align: right; }
.app-line > a:hover .app-line-go { color: var(--muted); }
.app-line-health {
  font-size: 0.85rem;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  justify-self: start;
}

/* A dot rather than a coloured word: the state is read at a glance and the
   word is there for anyone who cannot distinguish the colours. */
.dot {
  /* inline-block, not inline: width and height do nothing to an inline box, so
     every dot outside a flex parent — the ones inside pills — was zero by zero
     and invisible. It worked everywhere it was first used because those parents
     happened to be flex containers. */
  display: inline-block;
  width: 0.55rem;
  height: 0.55rem;
  border-radius: 999px;
  background: var(--muted);
  flex: none;
}

.dot-ok { background: var(--good-ink); }
.dot-bad { background: var(--bad-ink); }
.dot-warn { background: var(--warn-ink); }
.dot-muted { background: var(--line); border: 1px solid var(--muted); }

/* Narrow: the version drops out before the state does. Which version production
   runs is a detail; whether it is up is the reason the page was opened. */
@media (max-width: 620px) {
  .app-line > a { grid-template-columns: 1fr auto 0.75rem; gap: var(--space-3); padding: 0.7rem var(--space-3); }
  .app-line-version { display: none; }
}

/* ===== Environment cards =====

   The table these replace had eight columns and scrolled sideways on anything
   narrower than a laptop, which put "is production up" behind a scrollbar.

   Every card has the same five rows whether or not it has anything to say in
   them. A card that grows a line when a job starts would shift every card below
   it on a page someone is reading. */
/* Set explicitly rather than inherited: the global h2 rule gives 2rem above,
   but `section:first-of-type h2` cuts it to 0.5rem — and because every group
   lives inside one section, that shorter margin applied to all of them and the
   environments ran together. */
.env-group { margin-bottom: 1.75rem; }
.env-group:last-child { margin-bottom: 0; }
.env-group h2 {
  text-transform: lowercase;
  margin: 0 0 var(--space-2);
  color: var(--muted);
  display: flex;
  align-items: baseline;
  gap: var(--space-3);
}

/* ===== The environment's gate row =====

   Between the heading and the cards, because that is the level a gate is at: it
   points at a URL and runs beside whatever the environment's primary deployment
   is, so it is not a property of any one container. Inside a card it made that
   card taller than the ones beside it, which is exactly what a grid of
   `align-items: start` cards cannot hide.

   One line per environment, wrapping as whole gates rather than mid-gate. */
.gate-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  align-items: baseline;
  margin: 0 0 var(--space-3);
}

/* One gate: its pill, its reading, and the button that runs it, held close
   enough to read as one object rather than as three adjacent things. Baseline
   rather than centre — the pill and the age are text, and they should sit on
   the same line as each other. */
.gate-act {
  display: inline-flex;
  align-items: baseline;
  gap: 0.4rem;
}
.gate-act form { margin: 0; }

/* The half a pill cannot say: how long since this gate reached a verdict, and
   about which build. Quiet, because the pill beside it is the headline and this
   qualifies it. */
.gate-reading {
  font-size: 0.75rem;
  color: var(--muted);
  white-space: nowrap;
}

/* And the line that says even the age is not the whole story — a scan killed at
   its timeout, or a verdict about a build this environment has moved off.
   Quieter again: it qualifies the reading, which qualifies the pill. */
.gate-note {
  font-size: 0.7rem;
  color: var(--muted);
  opacity: 0.85;
}

/* A verb and nothing else. It used to carry the reading on its own face, which
   is why it was wide; the pill and the line beside it hold that now, so the
   button is the size of the word on it again. `margin-top: 0` because the
   default button rule pushes a button a whole line down, which is right at the
   foot of a form and wrong on a row of text. */
button.gate-run {
  margin-top: 0;
  font-size: 0.7rem;
  padding: 0.1rem 0.5rem;
  line-height: 1.4;
}

/* Already queued, already running, or switched off — a gate whose control is
   present because the gate is, and dead because pressing it now would do
   nothing. Faded rather than removed: a control that disappears takes the
   reason with it. */
button.gate-run:disabled { opacity: 0.45; cursor: default; }

/* ===== Whether the environment follows builds =====

   Under the gate row, and above the cards. The two rows are at the same level
   and answer the two halves of one question — the gate row says what has been
   checked about what is running here, and this one says what decides what runs
   here. Read in that order: the verdict, then the control that acts on it.

   One line now, where it was three. The version picker and the paragraph about
   rollback safety that used to sit here are in each card's modal; what stays is
   the switch and a reading of the state it is in, which is the fact the control
   exists to make visible.

   Same bottom margin as `.gate-row` so they space as a pair rather than as one
   row and an afterthought. `align-items: center` where the gate row uses
   `baseline`, because that row is text and this one has a checkbox in it: a form
   control sitting on a text baseline rides a pixel high. */
.env-follow {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-2) var(--space-3);
  margin: 0 0 var(--space-3);
  max-width: 70rem;
}

.env-follow form { margin: 0; }
.env-follow .muted { font-size: 0.8rem; }

/* The mark that holds a sentence nobody needs twice.
   
   Quiet by default and legible on focus, because a `title` is reachable by
   keyboard only if the element can take focus — `tabindex="0"` puts it in the
   tab order and this is what makes that visible when it lands. `help` as the
   cursor rather than `pointer`: it opens nothing, and a pointer promises a
   click that does something. */
.help {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.15rem;
  height: 1.15rem;
  border-radius: 50%;
  border: 1px solid var(--rule);
  color: var(--muted);
  font-size: 0.7rem;
  line-height: 1;
  cursor: help;
  user-select: none;
}

.help:hover,
.help:focus-visible {
  color: var(--fg);
  border-color: var(--fg);
}

.autodeploy-form label.inline {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0;
  cursor: pointer;
}

.autodeploy-form label.inline > span { margin: 0; color: var(--ink); font-weight: 600; }

/* ===== Choosing a version, on the card =====

   Wider than the 520px default: the dialog leads with a row per component of
   the environment, and that table is the reason it is a modal rather than a
   confirm() — a split is something you have to be able to see coming.

   With no scripting `noscript.css` renders every dialog as an ordinary block,
   which inside `.cards` makes it a grid item one column wide. Full width there,
   so it reads as the form it is. */
dialog.pin-dialog { max-width: 34rem; }

/* Narrower than the version picker, deliberately. It asks one question about
   two gates; the picker asks about every container in the environment and
   carries a table. Sizing them alike would say they were the same weight of
   decision. */
dialog.gate-dialog { max-width: 24rem; }

/* One gate per line: pill, reading, and the button that runs it. The same three
   parts as `.gate-act` on the row above the cards, stacked instead of strung
   out, because here there are two of them and the reader is comparing them
   rather than scanning past. */
.gate-compact {
  list-style: none;
  margin: 0.6rem 0 0.9rem;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
}
.gate-compact li {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
}
.gate-compact form { margin: 0; }
/* The reading takes the slack so the Run buttons line up down the right, which
   is what makes two gates read as a list rather than as two sentences. */
.gate-compact .gate-reading { flex: 1; }

.cards > dialog { grid-column: 1 / -1; }

.pin-components {
  width: 100%;
  border-collapse: collapse;
  margin: 0 0 var(--space-3);
  font-size: 0.82rem;
}

.pin-components th {
  text-align: left;
  font-weight: 600;
  color: var(--muted);
  padding: 0.2rem 0.5rem 0.2rem 0;
  border-bottom: 1px solid var(--line);
}

.pin-components td { padding: 0.3rem 0.5rem 0.3rem 0; vertical-align: baseline; }
.pin-components tr.is-here td { font-weight: 600; }

/* The scope of the change, and the half of this control that keeps an
   environment on one version. Boxed rather than loose, because the two radios
   are one question and the answer is not obvious from where the button was
   pressed. */
.pin-scope {
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: 0.55rem 0.75rem;
  margin: var(--space-3) 0 0;
}

.pin-scope legend { padding: 0 0.35rem; font-size: 0.78rem; color: var(--muted); }

.pin-scope label.inline {
  display: flex;
  align-items: baseline;
  gap: 0.45rem;
  margin: 0.15rem 0;
  cursor: pointer;
}

.pin-scope label.inline > span { margin: 0; }

.pin-dialog select { width: 100%; max-width: none; margin: 0.25rem 0 0; }
.pin-caveat { font-size: 0.8rem; margin: var(--space-3) 0; }

.cards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr));
  gap: var(--space-3);
  /* Wide enough for the longest line the card holds — "Build #316 (a1b2c3d) |
     24.08.26 18:49" beside two buttons. Narrower, that line wrapped and left
     the divider dangling at the end of a row. */
  grid-template-columns: repeat(auto-fill, minmax(21rem, 1fr));
  max-width: 70rem;

  /* Cards size to their own content rather than stretching to the tallest in
     the row. Stretching plus a footer pinned with margin:auto is what put a
     hole in the middle of every card that had less to say than its neighbour. */
  align-items: start;
}

/* Two columns rather than a header above a body. The right-hand side is taller
   than one line and, stacked, its height pushed the left-hand side down — a
   band of nothing under the component name, and a card as tall as its button
   instead of as tall as what it had to say. */
.dcard {
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--panel);
  padding: var(--space-3) var(--space-4);
  display: flex;
  justify-content: space-between;
  align-items: start;
  gap: var(--space-3);
}

.dcard-facts {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  min-width: 0;
  /* Smaller than the page around it: this is reference data read in a glance
     across several cards, not prose. */
  font-size: 0.85rem;
}

/* Two facts that are read together, with a divider that is quieter than either
   of them. */
.dcard-line {
  margin: 0;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.4rem;
}

.dcard-line .sep { color: var(--line); }
.dcard-line .muted { font-size: 0.95em; }

/* Date over time, so the pair takes the width of the wider half rather than of
   both. The card is not tall enough to care and is not wide enough not to. */
.deployed-at { display: inline-flex; flex-direction: column; line-height: 1.15; }
.deployed-at .d { font-size: 0.85em; color: var(--muted); }
.deployed-at .t { color: var(--muted); }

/* A restart has nothing to name, so its button is one line and should not
   reserve a second. */
button.deploy:has(> :only-child) { justify-content: center; }

/* The host and, under it, the one action for this component. Stacked in the
   corner rather than laid across the foot of the card: it acts on one component
   of one environment, and a full-width bar gave it the weight of a decision
   about the whole card. There was room here the card was not using. */
.dcard-act {
  display: flex;
  flex-direction: column;
  align-items: flex-end;
  gap: 0.35rem;
  text-align: right;
  flex: none;
}

.dcard-act form { margin: 0; }
.dcard-act button { margin-top: 0; white-space: nowrap; }

/* Present on every card whether or not it can be pressed, so a row of cards is
   the same height regardless of which of them has a promotion outstanding.
   Faded rather than hidden: the absence of a control is ambiguous — nothing to
   send, or not that kind of card — and a disabled one is not. */
button.deploy {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 0.05rem;
  padding: 0.25rem 0.6rem;
  line-height: 1.15;
  font-size: 0.75rem;
  min-width: 6.5rem;
}

.deploy-verb { font-weight: 600; }

/* The one mark separating a Deploy that submits from one that opens the gate
   dialog first. Small and on the button itself: the alternative is a button
   that claims the same thing in both states, with the difference announced only
   after the press. */
.deploy-flag {
  font-weight: 700;
  font-size: 0.7rem;
  color: var(--warn-ink);
}
.deploy-sha { font-size: 0.7rem; opacity: 0.85; }

/* The separator never ends a line: if the pair has to wrap, it wraps as a pair
   rather than leaving a divider hanging off the end of a row. */
.dcard-line { white-space: nowrap; }
.dcard-line > * { flex: none; }

button.deploy:disabled {
  background: var(--line);
  color: var(--muted);
  cursor: default;
  opacity: 0.65;
}

/* Three sizes, and they rank the three facts. The version is what the card is
   for — which of the things we build is running here — so it is the largest and
   the only one in ink; the component names the card; the host qualifies it. */
.dcard-component { font-weight: 600; letter-spacing: -0.01em; }
.dcard-host { font-size: 0.8rem; }
.dcard-version {
  margin: 0;
  font-size: 0.95rem;
  color: var(--ink);
}

.dcard-state {
  margin: 0;
  display: flex;
  align-items: center;
  gap: var(--space-2);
  font-size: 0.85rem;
  color: var(--muted);
}

.dcard-manual { border-style: dashed; }

/* ===== A check, as a card =====

   Wider than a deployment card because the rows inside are lists — the checks a
   build ran, the counts a suite reported — and a narrow column wraps every one
   of them onto three lines. Two or three across a laptop rather than four.

   The head is the link and the rest of the card is not: the body holds links of
   its own (the report, the scan run), and a card that is entirely a link
   swallows them. */
/* ===== Security cards =====

   Three lines each: who and what it is running, what the scan said, what CI
   said. Wide enough that a row of pills stays a row -- the pills are the
   content here, and a card narrow enough to wrap them into four lines is
   taller and says less. The grid takes as many columns as the window allows,
   which on a wide screen is the difference between a page and a scroll. */
.scards {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(26rem, 1fr));
  gap: var(--space-3);
}

.scard {
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--panel);
  padding: 0.5rem 0.75rem;
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  min-width: 0;
}

.scard-head {
  display: flex;
  align-items: baseline;
  gap: 0.5rem;
  text-decoration: none;
  color: inherit;
  min-width: 0;
}

.scard-app { font-weight: 600; white-space: nowrap; }

/* Takes the slack, so the pill and the chevron stay pinned right. */
.scard-version {
  flex: 1;
  min-width: 0;
  font-size: 0.78rem;
  color: var(--muted);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.scard-go { color: var(--muted); }
.scard-head:hover .scard-app { color: var(--accent); }

.scard-row {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.3rem;
  margin: 0;
  font-size: 0.8rem;
  min-width: 0;
}

/* A fixed column so DAST and CI line up down the card and the eye reads two
   rows rather than two sentences. */
.scard-label {
  width: 2.6rem;
  flex-shrink: 0;
  font-size: 0.7rem;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--muted);
}

.gcard {
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--panel);
  padding: var(--space-3) var(--space-4);
  display: flex;
  flex-direction: column;
  gap: var(--space-2);
}

.gcard-head {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  text-decoration: none;
  color: inherit;
  margin: calc(var(--space-3) * -1) calc(var(--space-4) * -1) 0;
  padding: var(--space-3) var(--space-4) var(--space-2);
  border-radius: 10px 10px 0 0;
}

.gcard-head:hover { background: var(--bg); }
.gcard-app { font-weight: 600; letter-spacing: -0.01em; }
.gcard-app .muted { font-weight: 400; }
.gcard-go { margin-left: auto; color: var(--line); }
.gcard-head:hover .gcard-go { color: var(--muted); }

.gcard-state {
  margin: 0;
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-2);
  font-size: 0.85rem;
}

.gcard-state .label {
  font-size: 0.7rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--muted);
}

.gcard-when { color: var(--muted); }
.gcard-summary { margin: 0; font-size: 0.85rem; }
.gcard-links { margin: 0; display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2); font-size: 0.85rem; }

/* The checks a build ran, one pill each and wrapping. */
.checkline { margin: 0; display: flex; flex-wrap: wrap; gap: 0.35rem; font-size: 0.85rem; }

/* Wider tracks than the deployment cards get: these hold lists. */
.tier .cards { grid-template-columns: repeat(auto-fill, minmax(20rem, 1fr)); }

.finding-group { margin-bottom: var(--space-4); }
.finding-group h3 { display: flex; align-items: baseline; gap: var(--space-2); margin-bottom: var(--space-2); }
.finding-group h3 .muted { font-weight: 400; font-size: 0.85rem; }

/* ===== The build =====

   One line, above the environments, about the only version on this page that
   may not exist yet. The whole line is the link, and it goes to GitHub — this
   is the one place in the portal where the answer is somewhere else. */
.builds { margin-bottom: var(--space-4); display: flex; flex-direction: column; gap: var(--space-2); }

.build-line {
  display: flex;
  align-items: baseline;
  /* One line. It wraps only when the viewport genuinely cannot hold it, rather
     than because a stacked timestamp made it two lines tall by default. */
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-3);
  padding: 0.65rem var(--space-4);
  border: 1px solid var(--line);
  border-radius: 10px;
  background: var(--panel);
  text-decoration: none;
  color: inherit;
}

.build-line:hover { background: var(--bg); }
.build-state { font-weight: 600; }
.build-number { font-weight: 600; font-variant-numeric: tabular-nums; }
.build-sha { font-size: 0.9rem; color: var(--muted); }
.build-when { font-size: 0.8rem; color: var(--muted); white-space: nowrap; }
.build-on { display: flex; gap: 0.3rem; flex-wrap: wrap; font-size: 0.8rem; }
.build-why { font-size: 0.85rem; }
.build-go { margin-left: auto; color: var(--line); }
.build-line:hover .build-go { color: var(--muted); }

.run-lines { list-style: none; margin: 0; padding: 0; }
.run-lines li + li { border-top: 1px solid var(--line); }
.run-lines a { display: flex; gap: var(--space-3); align-items: center; padding: 0.35rem 0; text-decoration: none; color: inherit; }
.run-lines a:hover { color: var(--accent); }

/* Two lines of title and no more: a list of issues is for finding one, and a
   wrapped paragraph per row turns twenty issues into a page of scrolling. */
.issue-title {
  display: -webkit-box;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  -webkit-box-orient: vertical;
  overflow: hidden;
}

/* A heading with what is true about now beside it: on a run page, that it is
   still going, and the one control that ends it. One line rather than a box
   above the record -- the box was the largest thing on the page and said one
   word. */
.section-head { display: flex; align-items: center; flex-wrap: wrap; gap: var(--space-2) var(--space-3); }
.section-head h2 { margin-right: var(--space-1); }
.section-head .inline-form { margin: 0; }
.section-head button { margin: 0; }
.section-head .muted { font-size: 0.85rem; }
section:first-of-type .section-head { margin-top: 0.5rem; }
section:first-of-type .section-head h2 { margin-top: 0; }

/* A run that has not finished yet. The dot beats rather than the row flashing:
   the row is text somebody is reading, and one small moving thing says "this is
   now" without making the rest hard to read. */
@keyframes beat {
  0%, 100% { opacity: 1; }
  50% { opacity: 0.25; }
}

.dot.beating { animation: beat 1.4s ease-in-out infinite; }

@media (prefers-reduced-motion: reduce) {
  .dot.beating { animation: none; }
}

.runs .live td { background: color-mix(in srgb, var(--accent) 6%, transparent); }
.runs .pill .dot { margin-right: 0.35rem; }

tr.clickable { cursor: pointer; }
tr.clickable:hover { background: var(--bg); }

/* ===== Configuration =====

   Two columns and an action, sized so nothing needs a sideways scrollbar: a
   variable name and its value are both short, and the page that shows them is
   read far more often than it is edited. */
table.kv { width: 100%; table-layout: fixed; }
table.kv td { padding: 0.3rem 0.5rem; vertical-align: top; word-break: break-word; }
table.kv .kv-name { width: 40%; }
table.kv .kv-act { width: 2.5rem; text-align: right; }
table.kv .kv-act form { display: inline; }

/* Half and half, on one line. The pair used to stack, which made adding two
   variables look like four decisions. */
.config-add {
  display: grid;
  grid-template-columns: 1fr 1fr auto;
  gap: var(--space-2);
  align-items: center;
  margin-top: var(--space-2);
}

.config-add input { width: 100%; }
.config-add button { padding: 0.25rem 0.7rem; font-weight: 600; }
.config-add-error { grid-column: 1 / -1; color: var(--bad-ink); font-size: 0.85rem; }

@media (max-width: 560px) {
  .config-add { grid-template-columns: 1fr auto; }
  .config-add input[name="value"] { grid-column: 1 / -1; }
}

.grants .choice { display: block; margin-bottom: var(--space-2); }

/* ===== Platform configuration =====

   This table earns its horizontal scroll: the keys are long, the values are
   the thing being edited, and squeezing both into the viewport made the inputs
   too small to read what was in them.

   So the input gets the room and the defaults column gives it up — truncated to
   a dozen characters, with the whole value one click away. */
.config-table input[type="text"],
.config-table input:not([type]) {
  width: 100%;
  min-width: 22rem;
  font-family: var(--mono, ui-monospace, monospace);
  font-size: 0.9rem;
  padding: 0.35rem 0.5rem;
}

.config-table td, .config-table th { font-size: 0.85rem; }
.config-table .config-key { white-space: nowrap; }

/* Sections on System · Config. The page is a hundred keys, and the three kinds
   on it carry very different weight: a poll interval affects one process, an
   exported one restarts every container on every host. */
.config-group + .config-group { margin-top: 1.75rem; }
.config-group > h2 { text-transform: lowercase; letter-spacing: 0.02em; }

/* The exported ones, marked because saving one is a fleet-wide restart. The
   rule is on the left edge rather than a background: it reads at a glance
   without making the inputs harder to see. */
.config-exported { border-left: 3px solid var(--accent, #6b8afd); padding-left: 0.9rem; }
.config-exported-as { white-space: nowrap; font-weight: 600; }

/* And the ones nobody sets. Quiet, present, and last. */
.config-recorded { opacity: 0.75; }
.config-recorded > h2 { font-weight: 500; }

.config-default {
  max-width: 12ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 0.78rem;
  color: var(--muted);
  cursor: pointer;
  border: none;
  background: none;
  font-family: var(--mono, ui-monospace, monospace);
}

.config-default:hover { text-decoration: underline; }

/* ------------------------------------------------------------- status page */

/*
 * The public page. It renders no portal chrome and links this stylesheet only
 * for the tokens and the dots, so everything it needs beyond that is here.
 */
.status-page { padding: 2rem 1rem; }

.status { max-width: 40rem; margin: 0 auto; }

.status h1 { margin-bottom: 1.5rem; }

.status .stale { border-left: 3px solid var(--bad-ink); padding-left: 0.75rem; }

.status .message { margin: 1rem 0 1.5rem; padding: 0.75rem 1rem; border-radius: 6px; background: var(--panel); }
.status .message.degraded { border-left: 3px solid var(--warn-ink); }
.status .message.outage { border-left: 3px solid var(--bad-ink); }

.status .services { list-style: none; padding: 0; margin: 0; }

.status .services li {
  display: grid;
  grid-template-columns: auto 1fr auto;
  align-items: baseline;
  gap: 0.5rem 0.75rem;
  padding: 0.75rem 0;
  border-bottom: 1px solid var(--line);
}

.status .services .name { font-weight: 600; }
.status .services .state { text-align: right; }
/* Under the name, spanning the two columns after the dot. */
.status .services .when { grid-column: 2 / -1; color: var(--muted); font-size: 0.875rem; }

.status .as-of { margin-top: 1.5rem; color: var(--muted); font-size: 0.875rem; }

/* A year of days, one cell each, oldest at the left. */
.status .year {
  grid-column: 1 / -1;
  display: flex;
  gap: 1px;
  margin: 0.5rem 0 0.25rem;
  /* The cells shrink to fit rather than scrolling: a year that needs a
     horizontal scrollbar on a phone is a year nobody looks at. */
  min-width: 0;
}

.status .year .day {
  flex: 1 1 0;
  min-width: 1px;
  height: 26px;
  border-radius: 1px;
  background: var(--line);
}

.status .year .day.ok { background: var(--good-ink); }
.status .year .day.warn { background: var(--warn-ink); }
.status .year .day.bad { background: var(--bad-ink); }
/* Not grey-as-in-down. Nothing was recorded, and the cell says so by being
   nearly absent rather than by being a colour on the scale. */
.status .year .day.none { background: var(--line); opacity: 0.4; }

.status .uptime {
  grid-column: 1 / -1;
  margin: 0;
  color: var(--muted);
  font-size: 0.875rem;
}

/* The cost page.

   Four fixed windows above a set of breakdowns for a chosen one. The cards are
   deliberately large and the tables deliberately plain: the figure people come
   for is the total, and the tables are what they read once it surprises them. */
.cost-windows {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: 0.75rem;
  margin-bottom: 1.5rem;
}

.cost-card {
  border: 1px solid var(--line);
  border-radius: 6px;
  padding: 0.75rem 0.9rem;
}

.cost-figure {
  margin: 0;
  font-size: 1.6rem;
  font-variant-numeric: tabular-nums;
}

.cost-label { margin: 0.1rem 0 0; }

.window-picker { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.window-picker .pill { text-decoration: none; }

/* Day-by-day spend. A list rather than a chart: the question is which days were
   expensive, which a labelled row answers without a legend or an axis. Scaled to
   the busiest day in the window, so a quiet fortnight still has shape. */
.day-bars { list-style: none; margin: 0; padding: 0; }

.day-bars li {
  display: grid;
  grid-template-columns: 6.5rem 1fr 5rem 3rem;
  align-items: center;
  gap: 0.6rem;
  padding: 0.15rem 0;
}

.day-track {
  background: var(--track, rgba(127, 127, 127, 0.18));
  border-radius: 3px;
  height: 0.7rem;
  overflow: hidden;
}

.day-fill {
  display: block;
  height: 100%;
  background: var(--accent, #4b7bec);
  border-radius: 3px;
}

.day-value, .day-bars .muted { text-align: right; font-variant-numeric: tabular-nums; }

@media (max-width: 34rem) {
  /* The bar is the first thing to go: on a narrow screen the day and the figure
     are what is being read, and a 2 cm track compares nothing. */
  .day-bars li { grid-template-columns: 6.5rem 1fr 3rem; }
  .day-track { display: none; }
}

/* The CSV link beside a table's heading.

   Small, in the heading rather than under the table, because it is an action on
   the thing named there — and a reader looking for "how do I get all of this"
   looks at the title before they look past the last row. */
h2 .csv {
  font-size: 0.7rem;
  font-weight: normal;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  margin-left: 0.5rem;
  padding: 0.1rem 0.4rem;
  border: 1px solid var(--line);
  border-radius: 4px;
  text-decoration: none;
  vertical-align: middle;
}

/* ===== A schema, rendered =====

   The page is a list of tables, each a card: name, purpose, columns, then the
   constraints as a definition list. Chips on a column say what is structural
   about it (key, reference, required) so the eye can find the keys without
   reading types. Everything is in the existing tokens; the one new thing is
   the pair of chip colours, which reuse busy/good rather than adding hues. */

.schema-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-3);
  flex-wrap: wrap;
  margin-bottom: var(--space-3);
}

.schema-envs { display: flex; gap: var(--space-1); }
.schema-envs a {
  padding: 0.2rem 0.7rem;
  border: 1px solid var(--line);
  border-radius: 999px;
  text-decoration: none;
  font-size: 0.85rem;
}
.schema-envs a.current { background: var(--ink); color: var(--bg); border-color: var(--ink); }
.schema-envs a.muted { color: var(--muted); border-style: dashed; }

.schema-fetch button { margin-top: 0; }

.schema-meta { color: var(--muted); font-size: 0.85rem; margin: 0 0 var(--space-3); }

.schema-absent { max-width: 40rem; }
.schema-absent h2 { text-transform: capitalize; }
.schema-absent-help dt { font-weight: 600; margin-top: var(--space-2); }
.schema-absent-help dd { margin: 0.25rem 0 0; color: var(--muted); }

.schema-filter {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-bottom: var(--space-3);
}
.schema-filter label { flex: 1 1 auto; max-width: 28rem; }
.schema-filter span[data-schema-count] { font-size: 0.85rem; white-space: nowrap; }

.visually-hidden {
  position: absolute;
  width: 1px; height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

.schema-dbs { display: flex; gap: var(--space-2); flex-wrap: wrap; margin-bottom: var(--space-3); font-size: 0.85rem; }

.schema-db { margin-bottom: var(--space-4); }
.schema-db > h2 { display: flex; align-items: center; gap: var(--space-2); flex-wrap: wrap; }
.schema-db > h2 .pill { font-weight: normal; }

.schema-purpose { margin: 0 0 var(--space-3); max-width: 65ch; }
.schema-purpose p:last-child { margin-bottom: 0; }

.schema-table {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 8px;
  padding: var(--space-3);
  margin-bottom: var(--space-3);
}
.schema-table h3 {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  flex-wrap: wrap;
  margin: 0 0 var(--space-2);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 1rem;
}
.schema-table h3 .pill { font-family: system-ui, sans-serif; font-weight: normal; }
.schema-table .table-wrap { margin: 0; }
.schema-columns { width: 100%; font-size: 0.85rem; }
.schema-columns td { vertical-align: top; }
.schema-chips { white-space: nowrap; }
.schema-chips .pill { margin-right: 0.2rem; }
.pill.pk { background: var(--busy-bg); color: var(--busy-ink); }
.pill.fk { background: var(--good-bg); color: var(--good-ink); }
.schema-note-cell p { margin: 0; }
.schema-note-cell p + p { margin-top: 0.3rem; }

.schema-constraints {
  display: grid;
  grid-template-columns: max-content 1fr;
  gap: 0.3rem var(--space-3);
  margin: var(--space-3) 0 0;
  font-size: 0.85rem;
}
.schema-constraints dt { color: var(--muted); }
.schema-constraints dd { margin: 0; display: flex; gap: var(--space-1); flex-wrap: wrap; align-items: baseline; }
.schema-definition { width: 100%; }
.schema-definition summary { cursor: pointer; color: var(--muted); }
.schema-definition pre { margin: 0.3rem 0 0; white-space: pre-wrap; }

/* The schema-document form on an app's page. */
.doc-addresses { list-style: none; padding: 0; margin: 0 0 var(--space-3); }
.doc-addresses li { display: flex; gap: var(--space-2); align-items: center; flex-wrap: wrap; margin-bottom: 0.4rem; font-size: 0.9rem; }
.doc-address-form { max-width: 32rem; }
.doc-address-form label { display: block; margin-bottom: var(--space-2); }
.doc-address-form label > span { display: block; font-size: 0.85rem; color: var(--muted); margin-bottom: 0.2rem; }
.doc-address-form label.inline { display: flex; gap: 0.5rem; align-items: center; }
.doc-address-form label.inline > span { margin: 0; color: var(--ink); }
