:root {
  --bg: #0d1117;
  --bg-raised: #161b22;
  --bg-inset: #010409;
  --border: #30363d;
  --text: #e6edf3;
  --text-dim: #8b949e;
  --accent: #58a6ff;
  --ok: #3fb950;
  --warn: #d29922;
  --err: #f85149;
  --idle: #8b949e;
  --running: #58a6ff;
  /* The colour an update flashes in (#142). The accent, well diluted: this is a
     hint that something moved, not a status — a saturated wash would read as a
     new state the line had entered and stayed in. */
  --flash: rgb(88 166 255 / 0.22);
  --radius: 8px;
  --mono: ui-monospace, "JetBrains Mono", "Cascadia Code", Menlo, Consolas, monospace;
}
@media (prefers-color-scheme: light) {
  :root {
    --bg: #f6f8fa;
    --bg-raised: #ffffff;
    --bg-inset: #eaeef2;
    --border: #d0d7de;
    --text: #1f2328;
    --text-dim: #636c76;
  }
}

* { box-sizing: border-box; }
/* A column at least one viewport tall (#264). This is the top of the chain
   that hands the overview's activity feed the screen the grid leaves over:
   `#main.overview` below grows into exactly the height the chrome does not
   use, which only means something if the body has a height to distribute.
   `min-height`, not `height`, so every route taller than the viewport keeps
   scrolling the document exactly as before — on those pages this rule changes
   nothing at all. `#toasts` is fixed-position and does not participate. */
body {
  margin: 0;
  display: flex;
  flex-direction: column;
  min-height: 100vh;
  background: var(--bg);
  color: var(--text);
  font: 14px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }

#topbar {
  display: flex;
  align-items: center;
  /* The bar is a fixed set of items on one line and has always run off the right
     edge on a narrow screen — 157px past a 390px viewport before #49 added a
     fourth pill to it. Wrapping costs nothing at the widths this dashboard is
     actually read at (nothing wraps at 1280 or above) and is the difference
     between a second row and a page that scrolls sideways. */
  flex-wrap: wrap;
  /* Split, because the two gaps now pay for different things: 20px between the
     brand, the nav and the user box on one line, but only 6px between lines —
     the repo row of #53 is a deliberate second line on every screen, and 20px
     of air above a 11px strip would cost the 1920x1080 layout more than the row
     itself does. */
  column-gap: 20px;
  row-gap: 6px;
  padding: 10px 20px;
  border-bottom: 1px solid var(--border);
  background: var(--bg-raised);
  position: sticky;
  top: 0;
  z-index: 10;
}
/* Required, not redundant: the `display: flex` above is an id selector and beats the
   browser's own `[hidden] { display: none }`, so without this rule the `hidden`
   attribute on the bar would do nothing at all and the signed-out page would still
   show the nav (ai-fleet-dashboard#7). */
#topbar[hidden] { display: none; }
/* Wraps for the same reason the bar does: the pills beside the name are added to
   over time (host, shutdown, and now the version warning of #49), and a row that
   cannot wrap turns each new one into horizontal page scroll. */
.brand { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; font-weight: 600; }
#nav { display: flex; flex-wrap: wrap; gap: 4px; flex: 1; }
#nav a {
  padding: 5px 12px;
  border-radius: var(--radius);
  color: var(--text-dim);
}
#nav a.active { background: var(--bg-inset); color: var(--text); }
#nav a:hover { color: var(--text); text-decoration: none; }
.user-box { display: flex; align-items: center; gap: 10px; color: var(--text-dim); font-size: 13px; }

/* The repos the fleet covers, on a line of their own under the nav (#53).
 *
 * `flex-basis: 100%` is what makes it a row rather than another item on the
 * first one: the bar is a wrapping flex row, so a child that wants the whole
 * width is pushed onto the next line and takes the rest of it.
 *
 * Deliberately quiet — smaller than everything above it and in the dim text
 * colour — because it is reference information on a sticky bar that every page
 * pays for in height, not a status readout. */
#covered-repos {
  flex-basis: 100%;
  display: flex;
  flex-wrap: wrap;
  column-gap: 12px;
  row-gap: 2px;
  font-size: 11px;
  color: var(--text-dim);
}

/* Each repo is a label rather than a bare link (#157), coloured by what the
 * fleet is currently doing in it: red where something is stuck on a person,
 * blue while an agent is actually inside the repo, yellow at a limit, green
 * while work is moving, grey once there is nothing open.
 *
 * The colour is never the whole message — colour alone is unreadable to a
 * reader who cannot tell these five apart, and "yellow" does not say *which*
 * limit — so every label carries the same fact as a sentence in its `title`,
 * and `cursor: help` advertises that there is one.
 *
 * Sized to cost the bar nothing: no vertical padding, a 15px line box and a 1px
 * border come to 17px against the 16.5px the plain text line already stood at.
 * This row is on every page of a dashboard that must fit 1920x1080 without
 * scrollbars (CLAUDE.md), so a comfortable-looking 4px of padding here would be
 * 8px taken off every view. */
#covered-repos a {
  color: var(--text-dim);
  border: 1px solid var(--border);
  border-radius: 999px;
  background: var(--bg-inset);
  padding: 0 6px;
  line-height: 15px;
  white-space: nowrap;
}
#covered-repos a[title] { cursor: help; }
/* The hover state changes the ground, not the text: recolouring the label to
   `--text` (as the plain links used to) would drop the one thing it is now
   there to say for as long as the pointer rests on it. */
#covered-repos a:hover { background: var(--bg-raised); }
#covered-repos a.tone-ok { color: var(--ok); border-color: var(--ok); }
#covered-repos a.tone-warn { color: var(--warn); border-color: var(--warn); }
#covered-repos a.tone-err { color: var(--err); border-color: var(--err); }
#covered-repos a.tone-run { color: var(--running); border-color: var(--running); }
/* Drained and not-yet-known both draw as the quiet default: nothing is asked of
   the reader either way, and inventing a sixth colour for "no data" would make
   a cold page look like an event. */
#covered-repos a.tone-dim,
#covered-repos a.tone-unknown { color: var(--text-dim); border-color: var(--border); }
/* The `[issues/PRs]` counts (#329), drawn from an attribute so the anchor's
   text stays the bare repo name the painter identifies it by. Tabular digits
   keep a label from jittering as a count ticks over; the dimmer suffix keeps
   the name the thing the eye lands on. */
#covered-repos a[data-counts]::after {
  content: ' ' attr(data-counts);
  font-variant-numeric: tabular-nums;
  opacity: 0.75;
}
/* Required, not redundant — the same trap as `#topbar[hidden]` above: the
   `display: flex` on the id selector beats the browser's `[hidden]` rule, so
   without this an empty list would leave an empty row on every page. */
#covered-repos[hidden] { display: none; }

/* The content column is as wide as the screen, up to 1920px (#57).
 *
 * It used to be 1180px, which on the 1920x1080 monitor this dashboard actually
 * lives on left 363px of empty background down each side *and* pushed the page
 * past the bottom edge: the agent grid could only fit three columns in 1180px,
 * so six agent cards became two rows and the page stood 1081px tall against a
 * ~950px usable viewport. Widening the column is what removes the scrollbar —
 * the same six cards fit on one row, and the page loses a whole card row.
 *
 * Capped rather than unbounded so the tables and prose on the other routes do
 * not stretch to arm's length on an ultrawide; 1920 is the widest screen this is
 * read on, so the cap never binds there. */
/* `width: 100%` is load-bearing now that the body is a flex column (#264): a
   flex item with cross-axis auto margins is sized to its content instead of
   stretched, so without it the column would shrink-wrap the page. The cap and
   the centring margins behave exactly as they always did. */
#main { width: 100%; max-width: 1920px; margin: 0 auto; padding: 20px; }
.loading { color: var(--text-dim); padding: 40px; text-align: center; }

/* Release identity of the running server (#49). Deliberately quiet: dim, small
   and in the mono face the rest of the app uses for machine strings. It sits in
   normal flow rather than fixed to the viewport so it cannot eat rows on the
   1920x1080 layout — it is reference information, not a status readout. */
#app-footer {
  display: flex;
  justify-content: center;
  padding: 4px 20px 14px;
  color: var(--text-dim);
  font-family: var(--mono);
  font-size: 11px;
}
/* Required, not redundant — the `display: flex` above is an id selector and beats
   the browser's own `[hidden] { display: none }`, exactly as for `#topbar`. */
#app-footer[hidden] { display: none; }

/* ---- pills & badges ---- */
.pill {
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 2px 10px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: 600;
  border: 1px solid var(--border);
  white-space: nowrap;
}
.pill::before { content: ""; width: 8px; height: 8px; border-radius: 50%; background: currentColor; }
.pill-ok { color: var(--ok); }
.pill-idle { color: var(--idle); }
.pill-running { color: var(--running); }
.pill-running::before { animation: pulse 1.2s ease-in-out infinite; }
.pill-warn { color: var(--warn); }
.pill-err { color: var(--err); }
.pill-unknown { color: var(--text-dim); }
/* Required, not redundant — the same trap as `#topbar[hidden]` above. `.pill` sets
   `display: inline-flex`, and a class selector beats the browser's own
   `[hidden] { display: none }`, so without this the shutdown pill would be welded
   to the topbar permanently and announce a stopped fleet that is running (#18). */
#shutdown-pill[hidden] { display: none; }
/* Same trap once more, for the version-skew pill (#49): without this the warning
   would be welded on and would claim the daemon is behind on a fleet that is
   perfectly in sync. */
#daemon-pill[hidden] { display: none; }
/* And once more for the maintenance pill (#50) — the third time this exact trap
   has been worth a line of CSS, and the worst one to fall into: a welded-on
   "draining" pill claims the fleet is mid-maintenance forever. */
#maintenance-pill[hidden] { display: none; }
@keyframes pulse { 50% { opacity: 0.25; } }

.chip {
  display: inline-block;
  padding: 1px 8px;
  border-radius: 999px;
  font-size: 12px;
  background: var(--bg-inset);
  border: 1px solid var(--border);
  color: var(--text-dim);
  margin: 2px 4px 2px 0;
}
.chip b { color: var(--text); font-weight: 600; }
.chip-err { border-color: var(--err); color: var(--err); }
.chip-warn { border-color: var(--warn); color: var(--warn); }
/* A toned chip is one colour end to end (#124). The bold half is the label —
   `review requested` — and left at `--text` it turned the chip two-tone, which
   reads as the emphasis being the point rather than the tone. */
.chip-warn b { color: var(--warn); }
.chip-err b { color: var(--err); }

/* ---- banner ---- */
.banner {
  display: flex;
  gap: 10px;
  align-items: baseline;
  padding: 10px 16px;
  border: 1px solid var(--warn);
  border-radius: var(--radius);
  background: color-mix(in srgb, var(--warn) 8%, transparent);
  margin-bottom: 16px;
}
.banner.err { border-color: var(--err); background: color-mix(in srgb, var(--err) 8%, transparent); }
/* A banner that reports rather than warns (#190). Fleet shutdown is somebody's
   own decision, so the notice explaining a deliberately stopped agent takes the
   page's plain card colours — amber there would say "something is wrong" about
   the state the operator asked for. */
.banner.idle { border-color: var(--border); background: var(--bg-raised); }

/* ---- cards ---- */
.host-card, .card {
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 14px 16px;
  margin-bottom: 16px;
}
/* Cards whose whole content is a table: the table draws its own row padding, and a
   card padding on top of it leaves the header row floating off its own edge. */
.card.flush { padding: 0; }
/* ---- overview headbox ---- */
/* A grid, not a wrapping flex row (#25). The row put each `label value` pair
   immediately after the last, so where the line broke depended on the width of
   everything before it and nothing lined up with anything beneath it — which is
   what "it looks all unaligned" was about. Equal auto-fitted columns line up by
   construction at every viewport, and `auto-fit` still collapses to one column
   on a phone. Values go under their labels rather than beside them so a long
   hostname cannot shove the next cell out of its column. */
.host-card {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(112px, 1fr));
  gap: 12px 24px;
  align-items: start;
}
.host-card .stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
/* Same treatment as the countdown box's label: small, dim and upper-case, so the
   eye reads a column of values and only drops to the labels when it needs one. */
.host-card .stat .k {
  color: var(--text-dim);
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  white-space: nowrap;
}
.host-card .stat .v {
  display: flex;
  align-items: center;
  gap: 6px;
  min-width: 0;
  color: var(--text);
  font-size: 14px;
  /* The load figures and the counts change in place on every snapshot; tabular
     digits keep the column from twitching sideways when they do. */
  font-variant-numeric: tabular-nums;
  overflow-wrap: anywhere;
}
.host-card .stat .v.dim { color: var(--text-dim); }
.host-card .stat .v.ok { color: var(--ok); }
.host-card .stat .v.warn { color: var(--warn); }
.host-card .stat .v.err { color: var(--err); }
/* The second line of a cell, under its value (#194): the consumed shares below
   the budget verdict. A size smaller than the value, because it is evidence for
   the line above rather than a reading of its own, but in the full text colour —
   these are the percentages a reader acts on when the verdict says "over
   budget", and greyed out they read as a caption. On one line, since the point
   of moving it out of its own column was that `43% week · 9.0% 5h` had to wrap
   to fit beside the pill; `nowrap` is affordable now that the column is twice
   the width. A reading with no live window dims, the way the value does. */
.host-card .stat .sub {
  color: var(--text);
  font-size: 12px;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.host-card .stat .sub.dim { color: var(--text-dim); }
/* Exceptions get the full width on a row of their own: a chip appearing mid-grid
   would reflow every column the reader was already looking at. */
.host-card .flags {
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  gap: 2px 4px;
  padding-top: 2px;
  border-top: 1px solid var(--border);
  /* The row is in the grid whether or not it has chips in it, and it is always
     exactly one chip tall (#156).

     Two separate things used to move the headbox. The row itself was added only
     when there was something to say, so the first chip brought a rule, 2px of
     padding and a line of chips with it; and the row wrapped, so enough chips —
     or one long chip on a phone — added a second line. Both shoved every agent
     card down a line, on their own, while nobody was touching the page.

     So: one chip's worth of height is reserved here (one `.chip`'s line box plus
     its 2px vertical margins, with `box-sizing` keeping this row's own padding
     and rule inside the reservation), and the content is held to that one line
     by `nowrap` — the row scrolls sideways when the chips outgrow it, the way
     `.card.flush` scrolls its table on a phone, rather than growing downwards.
     Nothing is dropped: every chip is still in the row and reachable by swipe or
     shift-wheel, and the `+N more` chip is a link to the Tickets page, which
     prints every repo's note in full.

     The scrollbar is hidden because it is *also* height: in an auto-height box a
     horizontal scrollbar appearing pushes the border-box down by its own
     thickness, which is the same jump again by another route.

     The 29px is measured, not derived — `overview-headbox-stability.test.js`
     renders this box quiet, with one chip, and with the whole fleet stuck, at
     1920 down to 320, and requires every height to be equal, so a change to the
     chip's font, padding or margins fails there instead of quietly reintroducing
     the jump. */
  flex-wrap: nowrap;
  overflow-x: auto;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  min-height: 29px;
  box-sizing: border-box;
}
.host-card .flags::-webkit-scrollbar { display: none; }
/* A chip keeps its own width in the rail: it neither wraps its text onto a
   second line (which is what made one cap warning 18px taller than the box at
   320px) nor shrinks into an ellipsis that says less than nothing. */
.host-card .flags > * { flex: 0 0 auto; white-space: nowrap; }
/* Nothing to say: hold the height, but do not draw a rule under the box and a
   blank strip beneath it. `:empty` is exact here — the row's only children are
   chips, and it has no whitespace text node (the painter appends elements). */
.host-card .flags:empty { border-top-color: transparent; }
/* The count chip is a link (to the queue the hidden notes are printed beside),
   and a link is the one thing on this row that must not turn accent-blue: it is
   a chip first, coloured by its tone like the chips either side of it. */
.host-card .flags a.chip { color: inherit; cursor: pointer; }
.host-card .flags a.chip:hover { text-decoration: underline; }
/* Every cell explains itself on hover, so say so with the cursor (#25). */
.host-card .stat[title], .host-card .flags .chip[title] { cursor: help; }
/* A cell that links out is still a cell (#75): it keeps the grid's own colours
   rather than turning accent-blue, and swaps the help cursor for a pointer —
   the hover underline on the value is what says it is clickable. */
.host-card a.stat, .host-card a.stat[title] { cursor: pointer; color: inherit; text-decoration: none; }
.host-card a.stat:hover .v { text-decoration: underline; }
/* `human needed` (#193) and `qa tickets` (#199) are the cells that carry a
   button, and a <button> may not sit inside an <a> — so those cells are plain
   divs with the link around their value. These two rules give the inner link the
   look the whole-cell one above has, so nothing about the cells reads
   differently. */
.host-card .stat .v .v-link { cursor: pointer; color: inherit; text-decoration: none; }
.host-card .stat .v .v-link:hover { text-decoration: underline; }
/* The traffic light (#295): working, idle, or stuck — at the right-hand end of
   the box, spanning the stats row and the chip row.

   It must never make the box taller, so it takes no layout height at all: it is
   absolutely positioned inside a strip of right-hand padding the box reserves
   for it, and its lamps are sized from that strip's height (a size container,
   `cqh`) — the box sets the lamps, never the other way round.

   The strip is as wide as the stats can spare. `--stat-need` (set by
   `hostCard`) is what the stat cells need to stay on one row; the light gets
   the rest up to 270px, so where the full light would push a stat onto a
   second row it shrinks first. Below the width where the stats wrap anyway it
   keeps a small 60px — the `* 1000` clamp is a step: 60px there, 0 above.
   `100%` in padding is the containing block's width, which is this box's own
   border-box width. */
.host-card:has(> .light-slot) {
  --light-w: max(
    min(270px, 100% - var(--stat-need) - 51px),
    clamp(0px, (var(--stat-need) + 51px - 100%) * 1000, 60px)
  );
  position: relative;
  padding-right: calc(32px + var(--light-w));
}
.host-card .light-slot {
  position: absolute;
  top: 14px;
  bottom: 14px;
  right: 16px;
  width: var(--light-w);
  container-type: size;
  display: flex;
  align-items: center;
  justify-content: flex-end;
}
/* A real light, steady: a dark housing, unlit lenses in a dim version of their
   colour, the lit one in full colour with a glow that takes no layout space. */
.fleet-light {
  display: flex;
  align-items: center;
  gap: 6px;
  padding: 4px;
  border-radius: 999px;
  background: #1b1f24;
  border: 1px solid #000;
  text-decoration: none;
  cursor: help;
}
.fleet-light[href] { cursor: pointer; }
.fleet-light .lamp {
  flex: 0 0 auto;
  width: min(100cqh - 10px, (100cqw - 22px) / 3);
  aspect-ratio: 1;
  border-radius: 50%;
  opacity: 0.22;
}
.fleet-light .lamp-red { background: var(--err); }
.fleet-light .lamp-yellow { background: var(--warn); }
.fleet-light .lamp-green { background: var(--ok); }
.fleet-light .lamp.lit { opacity: 1; }
.fleet-light .lamp-red.lit { box-shadow: 0 0 14px 2px var(--err); }
.fleet-light .lamp-yellow.lit { box-shadow: 0 0 14px 2px var(--warn); }
.fleet-light .lamp-green.lit { box-shadow: 0 0 14px 2px var(--ok); }
/* The reload itself: quiet until pointed at, and no taller than the digits it
   sits beside. Six agent cards' worth of layout hangs off this box's height
   (see CLAUDE.md), so the button adds no line and no leading of its own — the
   icon is 13px inside a 14px value line, and `display: flex` on the button keeps
   the SVG off the text baseline, where an inline one would hang below it and
   grow the row. */
.stat-reload {
  display: flex;
  align-items: center;
  flex: 0 0 auto;
  padding: 0;
  border: 0;
  background: none;
  color: var(--text-dim);
  line-height: 1;
  cursor: pointer;
}
.stat-reload:hover:not(:disabled) { color: var(--text); }
/* Disabled means a re-count is already on its way, so the icon turns instead of
   inviting a second press. */
.stat-reload:disabled { cursor: default; }
/* Only `transform` is animated, which the compositor handles off the layout: the
   headbox cannot reflow because of it, however long it spins. */
.stat-reload.spinning .reload-icon { animation: spin 900ms linear infinite; transform-origin: 50% 50%; }
@media (prefers-reduced-motion: reduce) {
  .stat-reload.spinning .reload-icon { animation: none; opacity: 0.6; }
}

.agent-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(340px, 1fr));
  gap: 16px;
}
/* On a 1080p screen only, let the columns go down to 280px (#57).
 *
 * A narrower card is not free — less width means more wrapping, so the cards get
 * taller — and it only pays for itself when the extra column removes a whole
 * row. At 1920 it does: six 280px columns plus their gaps need 1760px and the
 * content column offers 1880, while 340px columns fit only five, leaving the
 * sixth agent alone on a second row worth ~430px. That row is the difference
 * between the overview fitting a ~950px usable viewport and overflowing it.
 *
 * Below the breakpoint it does not pay: at 1600 a 280px minimum buys a fifth
 * column, the page still needs two rows, and the taller cards make it *worse*.
 * So the rule is scoped to the width where six columns actually fit rather than
 * applied everywhere — 1800px is that width plus room for a classic scrollbar.
 * Measured, not assumed: the card's tightest row is the two-line "working on"
 * line, and `overview-1080p.test.js` reads its overflow back from a real browser
 * at 1280, 1600 and 1920. */
@media (min-width: 1800px) {
  .agent-grid { grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); }
}
.agent-card { display: flex; flex-direction: column; gap: 8px; margin-bottom: 0; }
/* The name owns the first line; the badges wrap onto the second (#106).
 *
 * The three children used to share one row, and only the name could give way:
 * the status pill is the card's verdict and the `⑂ N subagents` badge is
 * `flex: none` because a truncated count is worse than no count. At the 300px
 * column that left the name ~95px of a ~165px word, so every card on the fleet
 * overview read `ai-architect-…`, `static-dev-…`, `ai-review-…` — an agent
 * name cut at the point where it stops identifying the agent.
 *
 * So the head wraps and the name is given a whole line of its own
 * (`flex-basis: 100%`): the badges are pushed onto a second line at every
 * width, uniformly across the fleet, rather than only on the cards whose name
 * happens to be long. The row gap is 4px against the column gap's 10px, so the
 * second line costs the card ~28px and the 1080p overview keeps its single row
 * of cards (`overview-1080p.test.js` measures it).
 *
 * `min-width: 0` and the ellipsis below stay as the backstop for a name longer
 * than a whole card line — with the line to itself no fleet name reaches it. */
.agent-card .head { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; }
.agent-card .head h3 { margin: 0; font-size: 15px; flex: 1 1 100%; min-width: 0; }
.agent-card .head h3 a {
  color: var(--text);
  display: block;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Both badges spelled out as rigid, so a later change to `.pill` cannot silently
   let the verdict wrap to two lines inside a 280px card: the head's second line
   is for the badges side by side, not for one badge broken in half. */
.agent-card .head .pill { flex: none; }
/* `role · model` on one row (#87). The model is the fact that can drift, so it
   stays whole (`flex: none`); the role is the one child that gives way, with an
   ellipsis and its full text in a tooltip — the head row's rule again. The row
   clips rather than widening the card if a run ever reports a very long id. */
.agent-card .role {
  color: var(--text-dim);
  font-size: 12px;
  margin-top: -6px;
  display: flex;
  gap: 4px;
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
}
.agent-card .role .role-text { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.agent-card .role .sep,
.agent-card .role .model { flex: none; }
.agent-card .role .model-changed { color: var(--warn); }
/* `margin-top: auto` pins the footer to the bottom edge. Grid rows stretch every card
   to the tallest one in the row, so without it a short card left its "details →" and
   "Run now" floating mid-card above a block of dead space (#12). */
.agent-card .foot { display: flex; align-items: center; gap: 8px; margin-top: auto; padding-top: 4px; }
.agent-card .foot .spacer { flex: 1; }

/* ---- next-run countdown: the headline of an agent card ---- */
/* Monospaced and tabular so the box does not twitch as the digits change once a
   second, and big because "when does this thing run next" is the question the
   overview exists to answer (#12). */
.countdown {
  display: flex;
  flex-direction: column;
  gap: 1px;
  padding: 8px 12px;
  border: 1px solid var(--border);
  border-radius: var(--radius);
  background: var(--bg-inset);
}
.countdown .cd-label {
  color: var(--text-dim);
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
.countdown .cd-value {
  font-family: var(--mono);
  font-variant-numeric: tabular-nums;
  font-size: 26px;
  font-weight: 600;
  line-height: 1.2;
}
/* Held to exactly one line, for the reason #121 pinned the work note above it:
   six cards are read across a row and the countdown box is the row the reader
   scans, so nothing down to the box's bottom edge may take its height from the data. This note
   does — "started 17m ago · running for 17m 0s" is a character longer than its
   neighbour's, and on a 280px card that one character is the difference between
   one drawn line and two, which pushed everything below the box 18px down on
   that card alone. Whether it wraps also depends on the font the browser
   actually has, so it passed locally and failed on the runner (Pipeline run
   35081771850). `line-height` is pinned alongside so the box height is the same
   arithmetic everywhere; the full sentence stays in the `title`. */
.countdown .cd-note {
  color: var(--text-dim);
  font-size: 12px;
  line-height: 1.5;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.cd-run .cd-value { color: var(--running); }
.cd-soon .cd-value, .cd-due .cd-value { color: var(--warn); }
.cd-dim .cd-value { color: var(--text-dim); font-size: 20px; }

/* ---- working-on line ---- */
/* One line per agent saying what it is working on (#16). Unscoped on purpose,
   like `.rows` above: the overview card and the agent page use the same markup,
   and a class scoped to one of them is how the headbox lost its label column.
   It stays visually a row, not a block — the card already has one headline (the
   countdown) and a second one would leave neither of them the headline.

   A grid rather than a flex row, because a single row could not fit (#38 review).
   The label, the reference and the note are each unbreakable, so their minimum
   widths add up past a 340px card however far the title is cut, and the row ran
   out under the neighbouring card. Splitting it gives the note its own line and
   lets it wrap, which also stops the "carried over from the last run"
   qualification being the first thing squeezed off the card — it is the honest
   half of the line, not decoration. */
.work {
  display: grid;
  grid-template-columns: 74px minmax(0, 1fr);
  align-items: baseline;
  gap: 2px 8px;
  font-size: 13px;
  min-width: 0;
}
.work .k { color: var(--text-dim); grid-column: 1; }
/* Reference and title share the first row, so the title can hand its width over
   to the reference rather than the two competing for the card. */
.work .work-main {
  grid-column: 2;
  display: flex;
  align-items: baseline;
  gap: 8px;
  min-width: 0;
}
/* The reference is one unbreakable token, so it ellipsises rather than wraps.
   It shrinks only once the title is gone: the title's flex-basis is 0, so it
   holds no width of its own to give up and absorbs none of the shortfall. */
.work .work-text {
  flex: 0 1 auto;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The title takes whatever room is left over and is cut there. It is the only
   part that can be arbitrarily long, and it is the part a reader can lose
   without losing the answer — the reference beside it still names the work. */
.work .work-title {
  color: var(--text-dim);
  flex: 1 1 0;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* Wraps, because it is a sentence and the second line costs 14px. */
.work .work-note {
  grid-column: 2;
  color: var(--text-dim);
  font-size: 12px;
  min-width: 0;
  overflow-wrap: anywhere;
}
/* The pinned variant (`reserveNote`, #121). On the overview six cards are read
   across a row — "which agent runs next" is one left-to-right scan of the
   countdown boxes — and this note is the only block above them whose height
   follows the data: gone, one line, or wrapped to four. Every section below it
   inherited the offset. Held to exactly one line it stops moving: `min-height`
   keeps the row when the note is empty (an empty grid item is 0px tall), and
   the ellipsis pair caps it when the note is long. `line-height` is pinned in
   the same breath because `min-height` in `em` only matches one drawn line
   while the two agree. The full sentence stays in the `title`. */
.work-fixed .work-note {
  line-height: 1.5;
  min-height: 1.5em;
  overflow: hidden;
  overflow-wrap: normal;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.work-run .work-text { color: var(--running); }
.work-dim .work-text { color: var(--text-dim); }

/* ---- the "it just changed" flash (#142) ---- */
/* The dashboard repaints itself from an SSE push every ~30s and is read at a
   glance from across the room, so a line that changed looks exactly like one
   that did not — the reader has to have been watching that card at that instant
   to catch it. One short highlight as the new text lands is what makes an
   update visible without adding a pixel of furniture: it animates `background`
   and `box-shadow`, neither of which takes part in layout, so the 1080p budget
   (#57) is untouched and the card cannot grow.

   Both users put the class on a node that is *built* at the moment the flash
   should fire — a fresh work line, an arriving activity row — so the animation
   runs once on insertion and never replays on a later repaint.

   The colour sits at the very start and fades out: an update is a blink, not a
   pulse. A symmetric fade in and out reads as an element asking for attention,
   which is what a spinner does, rather than as news that just arrived.

   No `prefers-reduced-motion` branch on purpose: nothing here moves, scales or
   repeats — it is one colour fading to nothing on a node that has just been
   painted, which is the form that preference asks animations to take rather
   than one it asks to be removed. Dropping it would take the update away from
   exactly the readers who asked for less movement. */
@keyframes flash-in {
  from { background-color: var(--flash); box-shadow: 0 0 0 5px var(--flash); }
  to { background-color: transparent; box-shadow: 0 0 0 5px transparent; }
}
/* No `background` on the work line otherwise, so the animation's end state and
   the resting state are the same thing and nothing snaps when it finishes. */
.work-flash {
  animation: flash-in 1.4s ease-out;
  border-radius: 4px;
}

/* ---- labelled rows ---- */
/* Deliberately unscoped. These used to live under `.agent-card`, so the same markup
   reused on the agent page had no label column and no gap at all, and rendered as
   "roleIssue intake & dispatch" with the key welded to its value (#11). A key
   column is a page-wide convention, not one card's private styling. */
.rows { display: flex; flex-direction: column; gap: 3px; font-size: 13px; }
.row { display: flex; gap: 8px; }
.row .k { color: var(--text-dim); width: 74px; flex-shrink: 0; }

.btn {
  font: inherit;
  font-size: 13px;
  padding: 5px 14px;
  border-radius: var(--radius);
  border: 1px solid var(--border);
  background: var(--bg-inset);
  color: var(--text);
  cursor: pointer;
}
.btn:hover:not(:disabled) { border-color: var(--accent); color: var(--accent); }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-run { border-color: var(--accent); color: var(--accent); font-weight: 600; }
/* The fleet shutdown switch (#18) — the one control that can stop every agent, so
   it reads as the red it is. Mirrors `.btn-run` against `--err`, which both themes
   share. The `:hover` override is required, not decorative: the base `.btn:hover`
   turns a button blue, which on this one would say "safe" at the exact moment the
   pointer is on it. */
.btn-danger { border-color: var(--err); color: var(--err); font-weight: 600; }
.btn-danger:hover:not(:disabled) {
  border-color: var(--err);
  color: var(--err);
  background: color-mix(in srgb, var(--err) 12%, transparent);
}
.btn-ghost { background: none; border: none; color: var(--text-dim); }
.btn-ghost:hover { color: var(--text); }

/* ---- tables ---- */
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th, td { text-align: left; padding: 6px 10px; border-bottom: 1px solid var(--border); vertical-align: top; }
th { color: var(--text-dim); font-weight: 600; font-size: 12px; }
tr:hover td { background: color-mix(in srgb, var(--accent) 4%, transparent); }
td.num, th.num { text-align: right; font-variant-numeric: tabular-nums; }
.mono { font-family: var(--mono); font-size: 12px; }
.dim { color: var(--text-dim); }
.nowrap { white-space: nowrap; }

/* ---- log viewer ---- */
.logview {
  background: var(--bg-inset);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 12px;
  font-family: var(--mono);
  font-size: 12px;
  line-height: 1.45;
  white-space: pre-wrap;
  word-break: break-word;
  max-height: 480px;
  overflow-y: auto;
}
.logview .gap { color: var(--warn); }

/* ---- touched list ---- */
.touched { display: flex; flex-direction: column; gap: 4px; }
.touched .item { display: flex; gap: 8px; align-items: baseline; font-size: 13px; }
/* A reference is one token: left to wrap, "atomy/rustdroid-fake-rcon#16" broke across
   three lines inside a card column and dragged the titles out of alignment (#12). The
   title takes the rest of the row and is cut with an ellipsis instead.

   It is cut the same way once the title has nothing left to give (#57). Before the
   1080p layout the reference was never the thing that did not fit; in a 300px column
   "atomy/source-game-companion-code#90" is 237px on its own, and `nowrap` on an item
   that cannot shrink is an overflow, not a line break — it ran 4px past the right
   edge of the page and put a horizontal scrollbar on the whole dashboard. The title
   still loses its width first (`flex: 1 1 0` below has nothing to give up until the
   row is already full), so the ellipsis only reaches the reference when the card is
   genuinely too narrow for it, and `overview.js` puts the full text in the tooltip. */
.touched .item a { white-space: nowrap; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.touched .item .dim { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.touched .state {
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  width: 62px;
  flex-shrink: 0;
  text-align: right;
}
.state-open { color: var(--ok); }
.state-merged { color: #a371f7; }
.state-closed { color: var(--err); }
.state-unknown { color: var(--text-dim); }

/* ---- what the QA agent has filed (#220) ---- */
/* Same list as above, with a green plus where the OPEN/MERGED badge would be
   and the age of the ticket at the far end of the row. The plus is 1ch rather
   than the badge's 62px, and that is what pays for the age: the reference in
   between keeps the ellipsis it has everywhere else, so the row still gives way
   in the middle on a 280px card instead of running out from under it.

   The age is pushed to the right edge by the row's own free space and never
   shrinks — an "8m ago" cut to "8m…" is the one thing in the row that cannot be
   guessed back, and the reference above it can. */
.created .plus { color: var(--ok); font-weight: 700; flex-shrink: 0; }
.created .item .age { margin-left: auto; flex-shrink: 0; white-space: nowrap; color: var(--text-dim); }

/* ---- what the merge fast path has merged (#252) ---- */
/* The QA list's layout exactly — a glyph where the OPEN/MERGED badge would be
   and the age at the far end — with the merge icon the fleet already uses for
   this event in Discord. Drawn a size down from the row: an emoji sits taller
   than the text beside it, and at 13px it was the tallest thing in a list whose
   line height three lines of six cards pay for (#57). */
.merged .merge-icon { font-size: 11px; line-height: 1; flex-shrink: 0; }
.merged .item .age { margin-left: auto; flex-shrink: 0; white-space: nowrap; color: var(--text-dim); }

/* ---- what the dev lead's workers are on right now (#173) ---- */
/* Same list as above, with a spinner where the OPEN/MERGED badge would be: the
   rows are live sessions rather than finished work, and on a page that repaints
   itself every ~30s a still list cannot say that. The ring is 10px and sits in
   the row's own gap, so the block costs exactly what the touched list it
   replaces costs — the 1080p budget (#57) does not move.

   `transform` is the only thing animated, which the compositor handles off the
   layout: the card cannot reflow because of it, however long it spins.

   Centred rather than on the baseline: the ring has no text in it, so a
   baseline would hang it below the line it belongs to. */
.inflight .item { align-items: center; }
/* The review card's full-scan row has no PR to link to (#214), so its leading
   token is a plain span — and without this it is the one child of an inflight
   row that cannot shrink, which on a 280px card is an overflow rather than a
   line break. Same three properties `.touched .item a` gets, for the same
   reason. */
.inflight .item .ref { white-space: nowrap; min-width: 0; overflow: hidden; text-overflow: ellipsis; }
.spin {
  width: 10px;
  height: 10px;
  flex-shrink: 0;
  border: 2px solid var(--border);
  border-top-color: var(--running);
  border-radius: 50%;
  animation: spin 900ms linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* Unlike the one-shot flash above, this repeats forever, which is the form the
   preference is actually about. The ring stays — a static circle still marks
   the rows — it just stops turning. */
@media (prefers-reduced-motion: reduce) {
  .spin { animation: none; }
}

/* ---- activity table ---- */
/* Fixed layout so the columns stay put from row to row: with `auto` a long summary
   in one row widens its own column and shifts every value in the rows around it. */
.activity { table-layout: fixed; }
.activity th:nth-child(1), .activity td:nth-child(1) { width: 170px; }
.activity th:nth-child(2), .activity td:nth-child(2) { width: 130px; }
.activity th:nth-child(3), .activity td:nth-child(3) { width: 170px; }
.activity td:nth-child(3) { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.activity td:nth-child(4) { word-break: break-word; }
.tag { font-size: 11px; font-weight: 700; text-transform: uppercase; white-space: nowrap; }
.tag-run { color: var(--ok); }
.tag-review-action { color: var(--accent); }
.tag-review-failure, .tag-unit-failed { color: var(--err); }
.tag-command { color: var(--warn); }
.tag-host-online { color: var(--ok); }
.tag-host-offline { color: var(--err); }
/* The per-tool-call work steps (#108). Dimmed on purpose: they are by far the
   most numerous rows, and a feed where everything shouts reads as noise — the
   verdicts, failures and commands beside them have to stay the ones the eye
   catches. */
.tag-work { color: var(--text-dim); }
/* The dev workers' own rows. A note is what a worker says it is doing, in its
   own words — a sentence per several tool calls, so it gets the plain text
   colour the dimmed steps around it do not. Pickup and result bracket a
   worker's session, like a run row does an agent's; a result for a session
   that died is a failure, and reads as one. */
.tag-note { color: var(--text); }
.tag-pickup { color: var(--accent); }
.tag-result { color: var(--ok); }
.tag-result.tag-bad { color: var(--err); }

/* An arriving row lights up as it lands (#142). On the cells rather than the
   `<tr>`: a row is not a box of its own for painting purposes, and the spread
   shadow the work line uses would draw a halo around every cell — so this one
   is the background alone, which is all a full-width row needs anyway. */
@keyframes flash-in-cell {
  from { background-color: var(--flash); }
  to { background-color: transparent; }
}
.activity tbody tr.row-flash > td { animation: flash-in-cell 1.4s ease-out; }

/* ---- the overview's copy of the feed (#264) ---- */
/* The strip of screen the agent grid leaves over, given to the live feed. The
   1080p rule (CLAUDE.md) is why every height here is taken by flex rather than
   by content: the feed asks for *nothing* — basis 0, min-height 0 — so it can
   never be the reason the page grows a scrollbar; it fills whatever is left
   and its rows scroll inside the card. On a viewport the grid already fills,
   the feed folds away instead of pushing the page, which is what the
   `overflow: hidden` backstop is for.

   `:has(> .overview-feed)` rather than a class the view would have to put on
   `#main` and take off again: the route that mounts a feed *is* the flex
   column, structurally, and the view never reaches outside the node it was
   handed — several tests render it against a stub document with no
   `classList` to toggle.

   `flex: 1 0 auto` on the route: it grows into the viewport height the body
   has to give and never shrinks below its own content, so an overview taller
   than the screen (narrow desktop) still scrolls the document exactly as
   before — only genuinely spare height reaches the feed. */
#main:has(> .overview-feed) { display: flex; flex-direction: column; flex: 1 0 auto; }
#main:has(> .overview-feed) .overview-top { flex: none; }
.overview-feed {
  flex: 1 1 0;
  min-height: 0;
  display: flex;
  flex-direction: column;
  margin-top: 16px; /* the grid's own gap, so the feed reads as one more row of it */
  overflow: hidden;
}
/* The global h2 carries 22px of top margin for pages that stack sections; here
   it would come straight out of the feed's rows. */
.overview-feed .section-head { flex: none; margin-bottom: 8px; }
.overview-feed .section-head h2 { margin: 0; }
.section-head .spacer { flex: 1; }
.overview-feed .card {
  flex: 1;
  min-height: 0;
  overflow-y: auto;
  margin-bottom: 0; /* #main's own padding is the page's bottom edge */
}
/* The column names ride the internal scroll: on a feed that is watched rather
   than read, the header is the one row that must not stream away. Opaque on
   the card's own colour, or the rows show through as they pass under it. */
.overview-feed .activity thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  background: var(--bg-raised);
}

/* ---- sparkline ---- */
.spark { display: inline-block; vertical-align: middle; }
.spark polyline { fill: none; stroke-width: 1.5; }

/* ---- login ---- */
.login-wrap { display: grid; place-items: center; min-height: 70vh; }
.login-card {
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 40px 48px;
  text-align: center;
  display: flex;
  flex-direction: column;
  gap: 16px;
}
.login-card h1 { margin: 0; font-size: 20px; }
.login-err { color: var(--err); font-size: 13px; max-width: 320px; }

/* ---- toasts ---- */
#toasts { position: fixed; bottom: 20px; right: 20px; display: flex; flex-direction: column; gap: 8px; z-index: 100; }
.toast {
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-left: 3px solid var(--accent);
  border-radius: var(--radius);
  padding: 10px 16px;
  font-size: 13px;
  box-shadow: 0 4px 16px rgb(0 0 0 / 0.4);
  animation: slide-in 0.15s ease-out;
}
.toast.err { border-left-color: var(--err); }
.toast.ok { border-left-color: var(--ok); }
@keyframes slide-in { from { transform: translateX(30px); opacity: 0; } }

h2 { font-size: 16px; margin: 22px 0 10px; }
.section-head { display: flex; align-items: baseline; gap: 10px; }
.section-head .hint { color: var(--text-dim); font-size: 12px; }

/* ---- agent page header + headbox ---- */
/* The title line: back link, agent name, status pill, and the run button pushed to
   the right. Centred rather than baseline-aligned, because the pill and the button
   are boxes whose text baseline sits well below the 18px heading's. */
.page-head { display: flex; align-items: center; gap: 12px; margin: 4px 0 14px; flex-wrap: wrap; }
.page-head h2 { margin: 0; font-size: 18px; }
.page-head .back { font-size: 13px; }
.page-head .spacer { flex: 1; }

/* Label and value are their own grid columns, so the values line up down the box and
   a value long enough to wrap (role, unit) stays in its column instead of running
   back under its own label. */
.infobox {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
  gap: 10px 32px;
  padding: 16px;
}
.infobox .info { display: grid; grid-template-columns: 84px 1fr; gap: 12px; align-items: baseline; }
.infobox .k {
  color: var(--text-dim);
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
}
/* The work line spans the whole box. It is the answer the page is opened for,
   not one fact among the columns, and as an ordinary grid cell it would sit
   beside `role` at half the width with its title cut to nothing. */
.infobox .work {
  grid-column: 1 / -1;
  padding-bottom: 12px;
  border-bottom: 1px solid var(--border);
}
/* Matches the 84px label column the `.info` rows beside it use, so the work line
   and the facts under it line up on the same left edge. */
.infobox .work { grid-template-columns: 84px minmax(0, 1fr); }
.infobox .v { font-size: 13px; overflow-wrap: anywhere; }
.infobox .v.dim { color: var(--text-dim); }
.infobox .v.warn { color: var(--warn); }

/* ---- run history ---- */
/* Bound the summary column so one long line cannot push the numeric columns off the
   right-hand edge of the card. Tightened from 480px when tokens and cost joined the
   row (#14): the numeric columns are the ones that must stay visible. */
.runs td.summary { max-width: 360px; }
/* A cost or token figure never wraps — "$27.83" broken over two lines is unreadable
   and drags the row height around. */
.runs td.num, .runs th.num { white-space: nowrap; }

/* ---- usage page ---- */
/* Categorical series colours, one fixed slot per agent (colour follows the agent,
   never its rank, so a quiet week cannot repaint the others). Both columns are
   stepped for their own surface and validated as a set for colour-vision
   deficiency separation against that surface — the light column is not an
   automatic lightening of the dark one. Three of the light steps sit below 3:1
   against white, which is why the burn totals table beside the chart is not
   optional: it is the same data in text. */
:root {
  --series-1: #3987e5;
  --series-2: #d95926;
  --series-3: #199e70;
  --series-4: #c98500;
  --series-5: #d55181;
  --series-6: #8b949e;
}
@media (prefers-color-scheme: light) {
  :root {
    --series-1: #2a78d6;
    --series-2: #eb6834;
    --series-3: #1baf7a;
    --series-4: #eda100;
    --series-5: #e87ba4;
    --series-6: #636c76;
  }
}

.card-head { display: flex; align-items: baseline; gap: 12px; flex-wrap: wrap; margin-bottom: 10px; }
.card-head h3 { margin: 0; font-size: 14px; }
.card-head .hint { color: var(--text-dim); font-size: 12px; }

/* One column per subscription the fleet spends against (#36). Equal columns on
   purpose: the cards answer the same question and none is the footnote of the
   others, so they get the same width and the same layout and the reader
   compares them by looking across rather than by reading twice. Below ~1100px
   there is not enough width for a week-wide chart twice over, so they stack —
   still the same card, just one after the other. */
.providers { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 16px; }
/* Set by the view when only one provider has reported: a lone card gets the
   full width rather than sitting in half a row beside a hole. */
.providers-single { grid-template-columns: minmax(0, 1fr); }
@media (max-width: 1100px) { .providers { grid-template-columns: minmax(0, 1fr); } }

/* Both columns are already the same height — the grid stretches them — but
   their contents are not, and stacked from the top that put the two charts on
   different lines (#77). Claude reports a 5-hour window and a per-model share
   and the ChatGPT plan reports neither, so its headline card is three fact rows
   (67px) shorter and its chart sat that much higher: the same chart, the same
   axis, drawn at two heights, which reads as a difference in the data rather
   than in what the accounts report.
   The headline card absorbs the difference instead. It is the one row of the
   column allowed to grow, so the shorter card stretches to the taller one, the
   charts start on the same line, and the columns end together. `1 0 auto`, not
   `1`: growing from the card's own content height keeps the taller column's
   card exactly as it is, and never shrinks either. The chart card must not be
   the flexible one — it sizes itself from the SVG's aspect ratio, and giving it
   the slack would scale one chart against the other. */
.provider { display: flex; flex-direction: column; }
.provider > .card.budget { flex: 1 0 auto; }

.provider-head { display: flex; align-items: baseline; gap: 10px; margin: 4px 2px 8px; }
.provider-head h3 { margin: 0; font-size: 15px; }
.provider-head .hint { color: var(--text-dim); font-size: 12px; }
/* The jump-off to the provider's own usage page (#78): a convenience, not a
   call to action, so it sits dim at the far end of the head — off the name and
   the plan hint — and only takes the link colour on hover. */
.provider-head .usage-link { margin-left: auto; color: var(--text-dim); font-size: 12px; white-space: nowrap; }
.provider-head .usage-link:hover { color: var(--accent); }

.budget-head { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.budget-head .spacer { flex: 1; }
.budget-head .pill { font-size: 14px; padding: 4px 14px; }
.budget-delta { color: var(--text-dim); font-size: 13px; }
.budget-facts { margin-top: 12px; }
.budget-facts .k { width: 152px; }
.budget-facts .dim { font-size: 12px; }
/* The models row (#269) is the one value here that can outgrow its card — a
   joined key like `claude-haiku-4-5-20251001+claude-opus-5[1m]` is one long
   word — so it is the child that shrinks and wraps, and its short note stays whole. */
.budget-facts .model-shares { min-width: 0; overflow-wrap: anywhere; }
.budget-facts .model-shares + .dim { flex-shrink: 0; white-space: nowrap; }

/* The two meters share one 0–100 scale and sit directly above one another: the
   comparison the page exists to make is "is the filled bar past the other one",
   and that only works if the tracks line up. */
.meters { display: flex; flex-direction: column; gap: 6px; margin-top: 14px; }
.meter { display: grid; grid-template-columns: 110px 1fr 56px; gap: 10px; align-items: center; }
.meter-label { color: var(--text-dim); font-size: 12px; }
.meter-track {
  height: 10px;
  border-radius: 999px;
  background: var(--bg-inset);
  border: 1px solid var(--border);
  overflow: hidden;
}
.meter-fill { display: block; height: 100%; border-radius: 999px; transition: width 0.3s ease; }
.meter-value { font-variant-numeric: tabular-nums; font-size: 13px; text-align: right; }

.chart { width: 100%; }
.chart-svg { width: 100%; height: auto; display: block; overflow: visible; }
.chart-svg .grid { stroke: var(--border); stroke-width: 1; }
.chart-svg .grid-limit { stroke: var(--text-dim); stroke-dasharray: 4 4; }
.chart-svg .grid-v { stroke: var(--border); opacity: 0.5; }
.chart-svg .axis { fill: var(--text-dim); font-size: 10px; }
.chart-svg .series-line { fill: none; stroke: var(--series-1); stroke-width: 2; stroke-linejoin: round; }
.chart-svg .series-pace { stroke: var(--text-dim); stroke-width: 2; stroke-dasharray: 6 5; }
.chart-svg .series-projection { stroke-width: 2; stroke-dasharray: 3 4; }
.chart-svg .verdict-ok { stroke: var(--ok); }
.chart-svg .verdict-warn { stroke: var(--warn); }
.chart-svg .verdict-err { stroke: var(--err); }
.chart-svg .series-dot { fill: var(--series-1); stroke: var(--bg-raised); stroke-width: 2; }
.chart-svg .series-label { fill: var(--text); font-size: 12px; font-weight: 600; }
.chart-svg .series-now { stroke: var(--text-dim); stroke-width: 1; stroke-dasharray: 2 3; }
/* Account switch on the usage chart: the rule where one account's history ends
   and the next begins, and the label naming the new account. */
.chart-svg .series-boundary { stroke: var(--warn); stroke-width: 1.5; stroke-dasharray: 5 4; }
.chart-svg .boundary-label { fill: var(--warn); font-size: 10px; font-weight: 600; }
/* Hit targets: invisible, larger than the mark, and the only thing that carries a
   tooltip — a 2px line is not a hover target anyone can hit. */
.chart-svg .hit { fill: transparent; }
.chart-svg .hit:hover { fill: color-mix(in srgb, var(--text) 8%, transparent); }

/* The weekly chart opens enlarged on click (#86). The affordance is a zoom
   cursor and an outline on hover or focus — outline, not border or padding,
   because an outline takes no layout space and the card must not grow by a
   pixel on the 1080p screen. */
.chart-zoomable {
  cursor: zoom-in;
  border-radius: 6px;
  outline: 1px solid transparent;
  outline-offset: 4px;
  transition: outline-color 0.15s ease;
}
.chart-zoomable:hover { outline-color: var(--border); }
.chart-zoomable:focus-visible { outline: 2px solid var(--accent); }

/* The enlarged view: a fixed overlay outside the page flow, so it adds nothing
   to the page's height. The view sizes the chart to the room the dialog leaves
   inside this box, so it fills the screen without scrolling; `overflow: auto`
   is only the fallback for a window too short for the minimum chart height. */
.chart-modal {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 32px;
  background: color-mix(in srgb, var(--bg-inset) 80%, transparent);
  overflow: auto;
  cursor: zoom-out;
}
.chart-modal-dialog {
  width: min(1800px, 100%);
  margin: auto;
  cursor: default;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.chart-modal-dialog .card-head { align-items: center; }
.chart-modal-close { margin-left: auto; font-size: 22px; line-height: 1; padding: 0 6px; cursor: pointer; }
/* Drawn one user unit per pixel at the bigger size, so the type is set bigger
   here rather than scaled up with the drawing. */
.chart-modal .chart-svg .axis { font-size: 13px; }
.chart-modal .chart-svg .series-label { font-size: 15px; }
.chart-modal .chart-svg .boundary-label { font-size: 12px; }

/* "… and N more" under a card's list block opens the whole list (#237). A
   button, because it opens a dialog rather than going anywhere, drawn as the
   link it reads as: every box-model property is reset so the line costs the
   card exactly what the plain text it replaced did (#57). */
.more-link {
  font: inherit;
  color: var(--accent);
  background: none;
  border: 0;
  padding: 0;
  margin: 0;
  cursor: pointer;
  text-align: left;
}
.more-link:hover { text-decoration: underline; }
.more-link:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* The list itself: the same fixed overlay as the enlarged chart, outside the
   page flow. A long list scrolls inside the dialog, never the page. */
.list-modal {
  position: fixed;
  inset: 0;
  z-index: 50;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 32px;
  background: color-mix(in srgb, var(--bg-inset) 80%, transparent);
}
.list-modal-dialog {
  width: min(720px, 100%);
  max-height: 100%;
  display: flex;
  flex-direction: column;
  box-shadow: 0 12px 40px rgba(0, 0, 0, 0.45);
}
.list-modal-dialog .card-head { align-items: center; }
.list-modal-close { margin-left: auto; font-size: 22px; line-height: 1; padding: 0 6px; cursor: pointer; }
.list-modal-list { min-height: 0; overflow-y: auto; }

.legend { display: flex; gap: 14px; flex-wrap: wrap; color: var(--text-dim); font-size: 12px; }
.legend-item { display: inline-flex; align-items: center; gap: 6px; }
.legend-swatch {
  display: inline-block;
  width: 10px;
  height: 10px;
  border-radius: 3px;
  background: var(--swatch);
  margin-right: 6px;
  vertical-align: baseline;
}
.legend-swatch.dashed { height: 3px; border-radius: 2px; }

.burn-totals { margin-top: 14px; }
.burn-totals td:first-child { white-space: nowrap; }
.burn-totals .total-row td { font-weight: 600; border-bottom: none; }

/* ---------- tickets page (#35) ---------- */

/* Five columns, four equal and one narrow — and the narrow one is the FOURTH,
   because that is where the merge lane sits: between review and QA (#74). The
   track list is positional, so it has to move with the lane; leaving the narrow
   track last would squeeze QA, which carries two sections of real text, into
   0.6fr. Merge earns the narrow track by holding one-line entries and only ever
   a handful of them — a full column there would cost the lanes that carry real
   text a fifth of the page for a column of whitespace. The lanes are read side
   by side — that is the point of the page, work moving left to right — so they
   only stack once the viewport is too narrow for five of anything. */
.lanes {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr)) minmax(0, 0.6fr) minmax(0, 1fr);
  gap: 12px;
  align-items: start;
  margin-top: 14px;
}
@media (max-width: 1500px) { .lanes { grid-template-columns: repeat(2, minmax(0, 1fr)); } }
@media (max-width: 820px) { .lanes { grid-template-columns: minmax(0, 1fr); } }

.lane {
  background: var(--bg-raised);
  border: 1px solid var(--border);
  border-radius: var(--radius);
  padding: 12px;
  min-width: 0;
}
.lane-head { display: flex; align-items: center; gap: 8px; }
.lane-head h3 { margin: 0; font-size: 14px; flex: 1; min-width: 0; }
.lane-count { font-size: 18px; font-weight: 600; font-variant-numeric: tabular-nums; flex: none; }
/* The sparkline is a row of its own, not a third item on the header line. At a
   fixed 120px beside a title and a two-digit count it never fitted the narrow
   Merge lane — with every lane's history populated it ran 69px past the card at
   1920px — and shrinking it into whatever a ~124px column had left would draw a
   line too short to read. Full width under the header it is the same shape in
   every lane, and it scales because `sparkline()` carries a viewBox. */
.lane-spark { display: block; margin-top: 4px; }
.lane-spark .spark { display: block; width: 100%; height: 22px; }
.lane-hint { color: var(--text-dim); font-size: 12px; margin: 2px 0 8px; }
.lane-flags { display: flex; flex-direction: column; gap: 4px; margin-bottom: 8px; }
.lane-flags .chip { align-self: flex-start; max-width: 100%; margin: 0; }

.lane-section + .lane-section { margin-top: 10px; }
.lane-section-head {
  display: flex;
  align-items: baseline;
  gap: 6px;
  border-top: 1px solid var(--border);
  padding-top: 6px;
  margin-bottom: 4px;
}
/* Bold, both of them (#107). A lane stacks two or three sections under one
   header, and at 11px uppercase in the dim colour the section names read as
   captions rather than as the headings they are — the eye slides past "queued"
   to the rows. Weight is the only emphasis available here: size is fixed by the
   1920x1080 budget, and colour is already carrying a meaning on this page (dim
   is "a fact with no verdict"). The count is bold for the same reason the lane
   header's own count is — it is the number a reader is scanning for. */
.lane-section-head .k {
  color: var(--text-dim);
  font-size: 11px;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  flex: 1;
}
.lane-section-count { color: var(--text-dim); font-size: 12px; font-weight: 700; font-variant-numeric: tabular-nums; }
.lane-empty { color: var(--text-dim); font-size: 12px; padding: 2px 0 4px; }
.lane-rows { display: flex; flex-direction: column; gap: 4px; }

/* Three rows, not one. Five lanes inside the 1180px page give a column about
   210px wide, and a reference, a title, an age and a badge do not share 210px
   on one line — the first version overflowed its lane by 123px at 1920px. So
   the row admits it: identity and age on the first line, the title on its own,
   badges last. Each cell is either fixed or `minmax(0, …)`, because a grid item
   defaults to min-content width and `text-overflow` never fires without it. */
.ticket-row {
  display: grid;
  grid-template-columns: 8px minmax(0, 1fr) auto;
  grid-template-areas:
    "dot ref age"
    ".   title title"
    ".   badges badges";
  gap: 1px 6px;
  align-items: baseline;
  font-size: 13px;
}
.ticket-dot {
  grid-area: dot;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: currentColor;
  align-self: center;
  color: var(--text-dim);
}
.tone-ok .ticket-dot { color: var(--ok); }
.tone-warn .ticket-dot { color: var(--warn); }
.tone-err .ticket-dot { color: var(--err); }
/* Index and reference occupy one cell together, so an unnumbered lane does not
   carry an empty grid track (and its gap) where a number would have been. */
.ticket-head { grid-area: ref; display: flex; align-items: baseline; gap: 4px; min-width: 0; }
.ticket-index { color: var(--text-dim); font-variant-numeric: tabular-nums; flex: none; }
.ticket-ref { display: flex; align-items: baseline; min-width: 0; font-family: var(--mono); font-size: 12px; }
/* The repo gives way, the number never does: a truncated repo name still says
   roughly where the ticket lives, a truncated number names a different ticket. */
.ticket-repo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ticket-num { flex: none; }
.ticket-title {
  grid-area: title;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  color: var(--text-dim);
  font-size: 12px;
}
.ticket-age { grid-area: age; color: var(--text-dim); font-size: 12px; white-space: nowrap; }
.ticket-badges { grid-area: badges; display: flex; flex-wrap: wrap; gap: 4px; }
.ticket-badges:empty { display: none; }
/* The shared chip carries a right margin for prose runs; inside these flex
   rows the gap already spaces them, and both would read as a ragged double gap. */
.ticket-badges .chip { font-size: 10px; padding: 0 5px; margin: 0; }

.chip-ok { border-color: var(--ok); color: var(--ok); }
/* One colour end to end, the same rule `.chip-warn b` and `.chip-err b` follow
   (#124): the green chips carry a bold label too — the dev card's `queue 9`
   (#243) and `WIP 2` (#244) — and left at `--text` the label read as the
   emphasis rather than the tone. */
.chip-ok b { color: var(--ok); }
/* The status pill's `running` colour, for a chip that answers the same question
   the pill beside it does (#185) — the dev card's worker count. No pulsing dot:
   `.chip` has no `::before`, and the pill it sits next to is already blinking. */
.chip-run { border-color: var(--running); color: var(--running); }
.chip-dim { color: var(--text-dim); }
.chip[title] { cursor: help; }

.merge-item + .merge-item { margin-top: 8px; }
.merge-stall { display: flex; flex-wrap: wrap; gap: 4px; margin: 2px 0 0 14px; }
.merge-stall .chip { font-size: 10px; padding: 0 5px; margin: 0; }
/* The narrow lane has no room for a reference, a title and an age on one line;
   the age is the least of the three there — an approved PR is minutes old or it
   is stalled, and the stall chips below say which. */
.lane-narrow .ticket-title, .lane-narrow .ticket-age { display: none; }
.lane-narrow .ticket-row {
  grid-template-columns: 8px minmax(0, 1fr);
  grid-template-areas:
    "dot ref"
    ".   badges";
}

/* ---------- subagents (#43) ---------- */

/* Accent, not a verdict colour: four workers running is neither good news nor
   bad, it is the answer to "is this silent run actually doing anything".

   Sized as the status pill beside it, not as the generic `.chip` (#82): the
   two sit on one row and answer the same kind of question, and at the chip's
   lighter weight and tighter padding the count read as an afterthought next
   to `running`. Same 2px vertical padding as `.pill`, and the shared chip's
   2px vertical margin dropped, so the two badges are the same 24px tall and
   the head row — which every card on the 1080p overview pays for — does not
   grow; it was in fact the chip's margin that made the row 26px before. */
.chip-sub {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  padding: 2px 10px;
  margin: 0 4px 0 0;
  font-weight: 600;
  border-color: var(--accent);
  color: var(--accent);
  flex: none;
  white-space: nowrap;
  cursor: help;
}
/* The fork glyph is the icon of the badge, and at the text's 12px it was a
   speck (#82). Half as big again as the count, on a `line-height: 1` box:
   18px is exactly the height the 12px text line already has, so the icon
   grows without making the chip — and so the card head — one pixel taller. */
.chip-sub .sub-icon { font-size: 1.5em; line-height: 1; }

/* The dev card's worker-slot badge (`2/3 workers`): same 24px sizing as
   `.chip-sub` above, and for the same reason — it shares the head row with the
   status pill and must not grow it. Unlike the subagent chip it carries no
   accent of its own: it is a verdict, and the `.chip-ok/-warn/-err/-dim` tone
   class beside it says which. A link (it opens the Dev queue page), so the
   pointer replaces the tooltip cursor. */
.chip-workers {
  display: inline-flex;
  align-items: center;
  padding: 2px 10px;
  margin: 0 4px 0 0;
  font-weight: 600;
  flex: none;
  white-space: nowrap;
  cursor: pointer;
}
a.chip-workers:hover { text-decoration: underline; }

/* Four cells: type, what it is on, when it started, when it last spoke. Only
   the description may give way — the type is one short token and the two ages
   are the entire point of the row, so they never wrap and never ellipsise. The
   `.dim` rule overrides the touched-list one this card inherits, which stretches
   every dim span to fill the row.

   The row wraps rather than overflows, because on a phone the three fixed cells
   alone are most of a 358px card: the ages drop to a second line there instead
   of running off the edge, which is the failure this page has shipped twice. */
.card.subagents .item { flex-wrap: wrap; }
.card.subagents .item .sub-type {
  color: var(--text-dim);
  font-size: 11px;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  white-space: nowrap;
  flex: none;
}
.card.subagents .item .sub-what {
  flex: 1 1 140px;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.card.subagents .item .dim {
  flex: none;
  white-space: nowrap;
  font-variant-numeric: tabular-nums;
}

/* ---------- phones (#149) ---------- */

/* Everything above is written for the 1920x1080 monitor the dashboard lives on
 * (#57). A phone is the same page at a fifth of the width, and at 320-430 CSS
 * pixels the desktop layout does not merely look cramped — it pushes the
 * *document* sideways, which is the one thing a phone reader cannot work
 * around: every vertical swipe then drifts the text off the screen.
 *
 * Four things did it, and each gets its answer below: grid tracks with a
 * minimum wider than the screen (`.agent-grid` at 340px, `.infobox` at 300px),
 * tables whose columns add up to 470-660px, a header row of fixed items that
 * cannot wrap far enough, and furniture pinned a fixed distance from the right
 * edge.
 *
 * 700px is the breakpoint because it is below every tablet in portrait and
 * above every phone in landscape, and because nothing between it and 1100px
 * (where `.providers` already stacks) needs either treatment. Everything here
 * is scoped inside the query: the desktop layout this repo measures in
 * `overview-1080p.test.js` is not touched by any of it.
 *
 * `mobile-layout.test.js` measures the result on every route at 320, 360, 390
 * and 430 — a page that fits 320 fits every phone after it. */
/* ---------- workflow timing page (#152) ---------- */

/* The section head now also carries the range tabs on this page, so it has to
   wrap: at 320px the heading, its hint and four buttons are three rows. */
.section-head { flex-wrap: wrap; }
.section-head .spacer { flex: 1; }
.range-tabs { display: flex; flex-wrap: wrap; gap: 4px; }
.range-tabs .btn { padding: 3px 10px; font-size: 12px; }
/* The wall-clock/active toggle sits immediately left of the range tabs and is
   the same control, so it borrows `.range-tabs` rather than restating it. The
   margin is what keeps the two groups readable as two groups when the head
   wraps them onto one line. */
.mode-tabs { margin-right: 10px; }

/* A row of figures above a chart. Deliberately its own class rather than a
   reuse of `.host-card`: that grid is measured by the 1080p test against the
   overview's exact card row, and borrowing it would put this page inside that
   budget's blast radius for the sake of four shared declarations. */
.stat-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(118px, 1fr));
  gap: 12px 24px;
  align-items: start;
}
.stat-row .stat { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.stat-row .stat .k {
  color: var(--text-dim);
  font-size: 11px;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  white-space: nowrap;
}
.stat-row .stat .v { font-size: 18px; font-variant-numeric: tabular-nums; overflow-wrap: anywhere; }
.stat-row .stat[title] { cursor: help; }

/* The stacked bar. `flex-grow` by share against a zero basis, so the segments
   divide exactly the width available at any viewport — percentage widths would
   leave a rounding remainder, and a remainder in a row is a page that scrolls
   sideways on a phone. The 2px floor keeps a one-second stage visible; six of
   them cannot outgrow the narrowest card this is drawn in. */
.timing-bar {
  display: flex;
  height: 10px;
  border-radius: 999px;
  overflow: hidden;
  background: var(--bg-inset);
}
.timing-seg { flex: var(--share, 1) 1 0; min-width: 2px; background: var(--seg, var(--series-1)); }
.timing-seg[title] { cursor: help; }
/* Hatched, because an unsplit segment is two stages the recorder could not tell
   apart: it must not read as a sixth stage with a colour of its own. */
.timing-seg-mixed {
  background-image: repeating-linear-gradient(
    45deg, rgb(0 0 0 / 0.28) 0 3px, rgb(255 255 255 / 0.12) 3px 6px);
}

.timing-share { display: flex; flex-wrap: wrap; gap: 6px 16px; margin-top: 10px; color: var(--text-dim); font-size: 12px; }
.timing-share .legend-item { cursor: help; }

.timing-list .legend { padding-bottom: 10px; border-bottom: 1px solid var(--border); }
/* Three rows: reference and title, the bar, then the facts under it. The total
   rides the first row on the right, where a column of durations can be compared
   straight down the list. */
.timing-row {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-areas: "head total" "bar bar" "meta meta";
  gap: 4px 12px;
  padding: 10px 0;
  border-bottom: 1px solid var(--border);
}
.timing-row:last-child { border-bottom: none; padding-bottom: 0; }
.timing-head { grid-area: head; display: flex; align-items: baseline; gap: 8px; min-width: 0; }
.timing-ref { flex: none; white-space: nowrap; }
.timing-title {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
  font-size: 13px;
}
.timing-total {
  grid-area: total;
  font-variant-numeric: tabular-nums;
  font-weight: 600;
  white-space: nowrap;
}
.timing-bar { grid-area: bar; }
.timing-meta { grid-area: meta; display: flex; flex-wrap: wrap; gap: 2px 14px; font-size: 12px; }
/* A ticket the recorder has no active figure for, in part or at all: the bar
   above it is short by those stages. Warn-coloured rather than dim, because a
   bar missing a stage reads as a fast ticket unless something says otherwise. */
.timing-unknown { color: var(--warn); }
.timing-foot { color: var(--text-dim); font-size: 12px; margin: 14px 2px 0; }

.chart-svg .trend-bar { fill: var(--series-1); }
.chart-svg .trend-bar:hover { fill: var(--accent); }

/* ---- dev queue page (#169) ---- */
/* The lead's queue, workers and per-repo record are three tables of five short
   columns. Only two cells are arbitrarily long — a ticket title and a worker's
   closing summary — and they are the ones that give way: a width cap plus
   ordinary wrapping, so the columns that carry the numbers keep their place and
   nothing has to scroll on the desktop. (The summary is already clipped to a
   sentence in the view; the cap is what stops one long word from widening the
   column anyway.) */
.queue-table td.trunc { max-width: 520px; overflow-wrap: anywhere; }
/* The badge row under a queued item: the same chips the tickets page uses, at
   the same size, but inside a table cell they need their own margin reset. */
.queue-table .ticket-badges { margin-top: 3px; }
/* What a maintenance row's reason words are about (#223): the dirty paths, the
   red runs, a worker's PRs. A second, smaller line under the words — and one
   that may break anywhere, because a porcelain path is one long word and must
   not be what widens the column. */
.queue-table .queue-detail { margin-top: 3px; font-size: 12px; overflow-wrap: anywhere; }
/* A worker row frozen by fleet shutdown (#190): the lead is stopped, so this is
   the lease it last wrote down rather than a session anyone can vouch for now.
   Dimmed like every other piece of last-known state on the dashboard; the links
   keep their colour, because the ticket a paused row names is still that
   ticket and a dim link reads as an unclickable one. */
.queue-table tr.paused td { color: var(--text-dim); }

/* ---- the QA page (#238) ---- */
/* One card per open QA ticket: subject and link on the first line, the short
   description under it, then one row of close controls (#292) — a button per
   preset reason, then the custom comment and its Close. The input takes
   whatever width the row has left. */
.qa-head { grid-template-columns: repeat(auto-fit, minmax(140px, 220px)); }
.qa-reload-cell { justify-content: center; }
/* The project selector (#289) sits in one headbox track and may shrink with
   it: a long repo name is cut by the control, never allowed to widen the box. */
.qa-repo {
  width: 100%; min-width: 0; max-width: 100%; font: inherit; font-size: 13px;
  padding: 3px 6px; border-radius: var(--radius);
  border: 1px solid var(--border); background: var(--bg-inset); color: var(--text);
}
.qa-repo option.qa-repo-empty { color: var(--text-dim); }
.qa-list { display: flex; flex-direction: column; }
.qa-item { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; }
.qa-item-head { display: flex; align-items: baseline; gap: 10px; min-width: 0; }
.qa-item-head .qa-ref { flex: 0 0 auto; }
.qa-item-head .qa-title {
  flex: 1 1 auto; min-width: 0; color: var(--text); font-weight: 600;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.qa-item-head .qa-age { flex: 0 0 auto; font-size: 12px; }
.qa-excerpt { color: var(--text-dim); font-size: 13px; overflow-wrap: anywhere; }
.qa-actions { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }
.qa-actions .qa-preset { flex: 0 0 auto; }
/* The input and its Close are one group, so where the row runs out of room
   (a phone) they wrap under the presets together — Close never lands on a
   line of its own, away from the text it sends. */
.qa-custom { display: flex; gap: 8px; align-items: center; flex: 1 1 240px; min-width: 0; }
.qa-note {
  flex: 1 1 auto; min-width: 0; font: inherit; font-size: 13px;
  padding: 5px 8px; border-radius: var(--radius);
  border: 1px solid var(--border); background: var(--bg-inset); color: var(--text);
}
.qa-custom .qa-close { flex: 0 0 auto; }
.qa-timeline { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 6px; }
.qa-timeline li { display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 10px; min-width: 0; }
.qa-timeline .qa-history-title { min-width: 0; overflow-wrap: anywhere; }
.qa-timeline .qa-history-note, .qa-timeline .qa-history-detail { overflow-wrap: anywhere; }

/* ---- the repos page (#315) ---- */
/* One widget per fleet repo: a one-line head (the repo name, in the tone of its
   header label) over five compact rows of linked counts. Six to a row at 1920,
   so 30 repos (#319: 28 covered + 2 intake) are five rows and the page fits
   1080p — and 950 — with the real topbar above it (CLAUDE.md). Every pixel a
   widget gains is paid five times over, so nothing more goes into one without
   measuring `repo-layout.test.js`. */
.repo-page {
  /* The page's icons, drawn as masks over `currentColor`-style fills so each
     takes the colour of whatever it marks and costs no DOM: the stub document
     the view tests count writes against never sees them. Stroked on a 16px
     grid, like the GitHub glyphs they stand next to. */
  --icon-repo: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><path d='M2.75 13V3.25c0-.83.67-1.5 1.5-1.5h9v10.5h-9c-.83 0-1.5.67-1.5 1.5s.67 1.5 1.5 1.5h1.5'/><path d='M11.5 14.75h1.75V12.5'/><path d='M7.75 12.75v3l1.375-1 1.375 1v-3'/></svg>");
  --icon-open: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5'><circle cx='8' cy='8' r='6.25'/><circle cx='8' cy='8' r='1.6' fill='black' stroke='none'/></svg>");
  --icon-progress: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5'><circle cx='8' cy='8' r='6.25'/><path d='M8 3.75a4.25 4.25 0 0 1 0 8.5z' fill='black' stroke='none'/></svg>");
  --icon-qa: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><path d='M5.75 1.75h4.5'/><path d='M6.75 1.75v4.6L3 12.55c-.45.75.1 1.7.97 1.7h8.06c.87 0 1.42-.95.97-1.7L9.25 6.35v-4.6'/><path d='M4.7 9.75h6.6'/></svg>");
  --icon-human: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round'><circle cx='8' cy='5' r='2.75'/><path d='M2.75 14.25c0-2.9 2.35-4.75 5.25-4.75s5.25 1.85 5.25 4.75'/></svg>");
  --icon-pr: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'><circle cx='4' cy='3.5' r='1.75'/><circle cx='4' cy='12.5' r='1.75'/><circle cx='12' cy='12.5' r='1.75'/><path d='M4 5.25v5.5'/><path d='M12 10.75V6.5a2 2 0 0 0-2-2H7.5'/><path d='M9.25 2.75 7.5 4.5l1.75 1.75'/></svg>");
  --icon-check: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M3 8.5l3.25 3.25L13 5'/></svg>");
  --icon-person: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'><circle cx='8' cy='4.5' r='3.25'/><path d='M1.5 15.25c0-3.7 2.9-6.25 6.5-6.25s6.5 2.55 6.5 6.25z'/></svg>");
  /* QA is the purple GitHub gives a finished state; HUMAN is a red deep
     enough to carry white text, a step darker than `--err`. */
  --qa: #a371f7;
  --human: #da3633;
}
@media (prefers-color-scheme: light) {
  .repo-page { --qa: #8250df; --human: #cf222e; }
}
/* Every icon on the page is one of these: an inline box filled with `--ic`
   (the colour) through the `--icon` mask. */
.repo-head h2::before,
.repo-chip::before,
.repo-widget-head::before,
.repo-row-k::before,
.repo-widget-human::before {
  content: '';
  display: inline-block;
  flex: none;
  width: 14px;
  height: 14px;
  background: var(--ic, currentColor);
  -webkit-mask: var(--icon) center / contain no-repeat;
  mask: var(--icon) center / contain no-repeat;
}

/* The page head: the title, then the fleet-wide chips summed off the widgets. */
.repo-head { align-items: center; row-gap: 8px; margin-bottom: 16px; }
.repo-page h2 { margin: 0; display: inline-flex; align-items: center; gap: 8px; }
.repo-head h2::before { --icon: var(--icon-repo); --ic: var(--accent); width: 18px; height: 18px; }
.repo-summary { display: flex; flex-wrap: wrap; gap: 6px; }
.repo-chip {
  --icon: var(--icon-open);
  --ic: var(--text-dim);
  display: inline-flex; align-items: center; gap: 6px;
  padding: 2px 10px 2px 8px;
  border: 1px solid var(--border); border-radius: 999px;
  background: var(--bg-raised);
  color: var(--text-dim); font-size: 12px; line-height: 18px; white-space: nowrap;
}
.repo-chip[title]:not([title='']) { cursor: help; }
.repo-chip b { color: var(--text); font-weight: 600; font-variant-numeric: tabular-nums; }
.repo-chip.human { --icon: var(--icon-person); }
.repo-chip.human.hot {
  --ic: #fff;
  border-color: var(--human); background: var(--human); color: #fff; font-weight: 600;
  box-shadow: 0 2px 10px -3px color-mix(in srgb, var(--human) 80%, transparent);
}
.repo-chip.human.hot b { color: #fff; }
.repo-chip.human.calm { --icon: var(--icon-check); --ic: var(--ok); color: var(--ok); }
.repo-chip.repos { --icon: var(--icon-repo); }
.repo-chip.open { --ic: var(--ok); }
.repo-chip.in_progress { --icon: var(--icon-progress); --ic: var(--running); }
.repo-chip.qa { --icon: var(--icon-qa); --ic: var(--qa); }

.repo-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
  /* Taller than the column gap: the HUMAN badge hangs into the row gap above
     its card, and must not touch the card above that. */
  gap: 14px 12px;
}
/* The tone is drawn the way `#covered-repos a.tone-*` draws the header label:
   border and name in the tone's colour, the quiet default for drained and
   not-yet-known — plus a wash of it behind the head. Never re-derived: the
   class is `repoStatusView`'s tone. */
.repo-widget {
  --tone: var(--border);
  --tone-text: var(--text-dim);
  position: relative;
  margin: 0;
  padding: 8px 12px 6px;
  min-width: 0;
  border-color: var(--tone);
  background:
    linear-gradient(180deg, color-mix(in srgb, var(--tone) 14%, transparent), transparent 40px),
    var(--bg-raised);
  transition: box-shadow 0.15s ease;
}
.repo-widget:hover { box-shadow: 0 6px 20px -8px rgb(0 0 0 / 0.45); }
.repo-widget.tone-ok { --tone: var(--ok); --tone-text: var(--ok); }
.repo-widget.tone-warn { --tone: var(--warn); --tone-text: var(--warn); }
.repo-widget.tone-err { --tone: var(--err); --tone-text: var(--err); }
.repo-widget.tone-run { --tone: var(--running); --tone-text: var(--running); }
.repo-widget-head {
  display: flex; align-items: center; gap: 7px; min-width: 0;
  padding-bottom: 5px; margin-bottom: 3px;
  border-bottom: 1px solid color-mix(in srgb, var(--border) 70%, transparent);
  cursor: help;
}
.repo-widget-head::before { --icon: var(--icon-repo); --ic: var(--tone-text); width: 15px; height: 15px; }
/* The one child that gives way: the longest fleet name is cut with an
   ellipsis at the narrowest column, never pushed out of the widget, and its
   title carries the whole name. */
.repo-widget-name {
  min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  font-weight: 600; font-size: 14px; line-height: 18px; color: var(--tone-text);
}
/* An intake repo's muted tag: whole, after the name, which shrinks first. */
.repo-widget-tag {
  flex: none; padding: 0 6px;
  border: 1px solid var(--border); border-radius: 999px;
  font-size: 10px; font-weight: 600; line-height: 15px;
  text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-dim);
}
.repo-widget-tag[hidden] { display: none; }
/* HUMAN: an open `human` ticket or PR in the repo — exactly the `human` sort
   group, which is why these cards lead the grid. A tab on the card's top edge,
   ringed in the page colour so it cuts the border rather than sitting on it. */
.repo-widget-human {
  --icon: var(--icon-person);
  --ic: #fff;
  position: absolute; top: -9px; right: 10px;
  display: inline-flex; align-items: center; gap: 4px;
  padding: 0 7px 0 6px;
  border-radius: 999px;
  background: var(--human); color: #fff;
  font-size: 10px; font-weight: 700; line-height: 17px; letter-spacing: 0.08em;
  box-shadow: 0 0 0 3px var(--bg), 0 3px 10px -3px color-mix(in srgb, var(--human) 90%, transparent);
  cursor: help;
}
.repo-widget-human::before { width: 10px; height: 10px; }
.repo-widget-human[hidden] { display: none; }

/* Each row is a GitHub list: its icon and colour say which (`k-<key>`), the
   count is a pill in that colour, and a zero drops back to plain dim text so
   the rows that hold something are the ones the eye lands on. Green, blue and
   red mean here what they mean on the header labels. 18px a row, the pill a
   GitHub counter's size inside it: five of them are most of a widget, and the
   fifth row of widgets at 950 tall is paid out of their height (#319). */
.repo-row {
  --c: var(--ok);
  --icon: var(--icon-open);
  display: flex; justify-content: space-between; align-items: center; gap: 8px;
  margin: 0 -6px; padding: 0 6px;
  border-radius: 6px;
  font-size: 13px; line-height: 18px;
}
.repo-row:hover { background: color-mix(in srgb, var(--c) 8%, transparent); }
.repo-row.k-in_progress { --c: var(--running); --icon: var(--icon-progress); }
.repo-row.k-qa { --c: var(--qa); --icon: var(--icon-qa); }
.repo-row.k-human { --c: var(--human); --icon: var(--icon-human); }
.repo-row.k-human_prs { --c: var(--human); --icon: var(--icon-pr); }
.repo-row-k { color: var(--text); min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
/* -3px centres the icon on the text inside the 18px line; at -2px it stood
   out of the line box and grew every row by a pixel. */
.repo-row-k::before { --ic: var(--c); margin-right: 8px; vertical-align: -3px; }
.repo-row:has(.repo-row-v.zero) .repo-row-k { color: var(--text-dim); }
.repo-row:has(.repo-row-v.zero) .repo-row-k::before { --ic: var(--text-dim); opacity: 0.6; }
/* The counts stay whole: they are what the widget is for. */
.repo-row-v {
  flex: none; min-width: 26px; padding: 0 7px;
  border-radius: 999px;
  background: color-mix(in srgb, var(--c) 16%, transparent); color: var(--c);
  font-size: 12px; font-variant-numeric: tabular-nums; font-weight: 600; line-height: 16px;
  text-align: center; white-space: nowrap;
}
.repo-row-v a { color: inherit; }
.repo-row-v.zero { background: none; color: var(--text-dim); font-weight: 400; }
/* The status colours are tuned for the dark page; on white, a count in them on
   their own pale wash is too faint to read across the room. */
@media (prefers-color-scheme: light) {
  .repo-row-v:not(.zero) { color: color-mix(in srgb, var(--c) 70%, black); }
}

@media (max-width: 700px) {
  /* The bar carries a brand, four pills, five nav links, two buttons and an
     email address, so on a phone it is four wrapped rows — a third of a
     640px-tall viewport. Sticky, that height would be taken from every page on
     every scroll; in normal flow it is paid once at the top, which is the right
     trade when the content below it is the point. */
  #topbar { position: static; padding: 8px 12px; column-gap: 12px; row-gap: 8px; }
  /* A line of its own rather than the tail of the brand row: the nav is the
     only way between routes on a phone, and the links are the page's main
     touch targets. `flex-basis: 100%` is the same device `#covered-repos`
     uses — a child that wants the whole width takes the next line. */
  #nav { flex: 1 0 100%; gap: 2px; }
  /* 32px+ tall, which is the smallest target a thumb hits reliably; on the
     desktop the same links stay at their 5px padding. */
  #nav a { padding: 9px 12px; }
  .user-box { flex-wrap: wrap; min-width: 0; }
  /* The signed-in address is the one item in the bar that is arbitrarily long,
     and it is reference information — it gives way rather than pushing the
     sign-out button off the screen. */
  #user-email { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
  .btn { padding: 8px 14px; }

  /* 20px of padding down each side of a 320px screen is 12% of the page. */
  #main { padding: 12px; }
  /* `.card.flush` keeps its own zero padding: a class pair outranks `.card`
     whatever the order, so the table-only cards are unaffected by this line. */
  .host-card, .card { padding: 12px; }

  /* The tracks that are wider than the screen. `auto-fill`/`auto-fit` still
     honours the minimum when the container is narrower than it, so a 340px
     card in a 296px column does not shrink — it hangs 44px off the edge. One
     column is the only honest answer at this width, and it is what these grids
     already collapse to on any phone wide enough to fit their minimum. */
  .agent-grid { grid-template-columns: minmax(0, 1fr); }
  .infobox { grid-template-columns: minmax(0, 1fr); }
  /* 96px was below the headbox's own content: its labels are `white-space:
     nowrap` and the budget verdict (#81) is drawn as a `.pill`, which is too,
     so in a 102px track `CHATGPT BUDGET` and `↑ over budget` did not shrink —
     they hung out of the card and pushed the document sideways. How far past
     depends on the reader's `system-ui` font: nothing on a Helvetica-metric
     desktop, 5px of sideways scroll on the CI runner, where `system-ui`
     resolves to DejaVu Sans and the same two strings measure 119px and 134px.

     132px is the widest of those labels with room to spare, so on a phone the
     headbox still reads as a grid of one-line labels — and the two rules under
     it mean the width is no longer what holds the card together: a font wider
     than any measured here wraps the label and the verdict instead of
     reopening the bug. Both are scoped to this block; at 1280px and above the
     columns are twice these strings' width and neither has ever wrapped, which
     is what `overview-1080p.test.js` measures. */
  .host-card { grid-template-columns: repeat(auto-fit, minmax(132px, 1fr)); gap: 10px 16px; }
  .host-card .stat .k { white-space: normal; }
  .host-card .stat .v .pill { white-space: normal; }
  /* The traffic light (#295) becomes a strip across the full width, above the
     stats: the one place it adds height, and only on the phone. */
  .host-card:has(> .light-slot) { padding-right: 12px; }
  .host-card .light-slot {
    position: static;
    grid-column: 1 / -1;
    width: auto;
    height: 36px;
  }
  .host-card .fleet-light { flex: 1 1 auto; height: 100%; border-radius: 10px; }
  .host-card .fleet-light .lamp { flex: 1 1 0; width: auto; height: 100%; aspect-ratio: auto; border-radius: 6px; }

  /* A table of eight numeric columns (run history) cannot be made to fit 320px,
     and dropping columns would hide the figures
     the page exists for. So the card scrolls its table sideways instead of the
     document doing it: the reader swipes within the card and the page itself
     stays put. `flush` cards are exactly the table-only ones. */
  .card.flush { overflow-x: auto; }
  /* The long free-text column of the run history, bounded harder than the
     desktop's 360px so the sideways swipe is short. */
  .runs td.summary { max-width: 200px; }

  /* The activity feed is the page a phone is most likely to be opened for —
     "what is the fleet doing right now" — so it is reshaped rather than left
     to scroll: the header row goes, and each row becomes a block with its
     when/type/agent on one line and the summary wrapping underneath. The
     columns exist to be compared down the page at desk width; on a phone there
     is no width to compare across, and a 470px table in a 296px card is a
     sideways swipe per row.

     `:not(.burn-totals)` because the usage page's totals table borrows the
     `.activity` class for its column rules while being a different thing — a
     grid of figures read across the row, which gets its own treatment below. */
  .activity:not(.burn-totals) { table-layout: auto; }
  .activity:not(.burn-totals) thead { display: none; }
  .activity:not(.burn-totals),
  .activity:not(.burn-totals) tbody { display: block; }
  .activity:not(.burn-totals) tbody tr {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 2px 8px;
    padding: 8px 10px;
    border-bottom: 1px solid var(--border);
  }
  .activity:not(.burn-totals) tbody td { display: block; padding: 0; border-bottom: none; }
  .activity:not(.burn-totals) td:nth-child(1),
  .activity:not(.burn-totals) td:nth-child(2),
  .activity:not(.burn-totals) td:nth-child(3) { width: auto; }
  /* The agent name keeps its ellipsis but takes only what is left of the first
     line after the timestamp and the type tag. */
  .activity:not(.burn-totals) td:nth-child(3) { min-width: 0; max-width: 45%; }
  /* The summary is the row's content: its own line, wrapping, full width. */
  .activity:not(.burn-totals) td:nth-child(4) { flex: 1 0 100%; min-width: 0; word-break: break-word; }

  /* The burn totals borrow `.activity`'s column rules, and five no-wrap
     columns of figures — agent, cost, share, tokens, runs — do not fit a phone
     however the widths are shared out. The figures are the card's whole point,
     so none is dropped: the table becomes its own scroll container and the
     reader swipes it sideways inside a card that stays put. */
  .burn-totals { display: block; width: 100%; overflow-x: auto; table-layout: auto; }
  .burn-totals th, .burn-totals td { width: auto; }

  /* The overview's feed (#264): filling the leftover is a desktop game — a
     phone overview is taller than its screen, so flex would hand the feed
     exactly nothing and fold it away. It becomes an ordinary section instead,
     capped so the reader is never trapped scrolling a hundred rows to reach
     the bottom of the page: the rows scroll inside the card, like every other
     table at this width. */
  #main:has(> .overview-feed) { display: block; }
  .overview-feed { overflow: visible; margin-top: 12px; }
  .overview-feed .card { max-height: 60vh; overflow-y: auto; }

  /* Fixed-position furniture measured from the right edge (#18's toasts) sits
     at a width chosen for a desktop corner; on a phone it is simply pinned to
     both edges. */
  #toasts { left: 12px; right: 12px; bottom: 12px; }
  .toast { font-size: 13px; }

  /* The enlarged weekly chart (#86): 32px of padding down each side of a phone
     leaves the dialog no room for the chart it exists to enlarge. */
  .chart-modal { padding: 12px; }
  .list-modal { padding: 12px; }

  /* The meter's fixed label and value columns leave the track ~100px on a
     phone, which is too little of a bar to read a percentage off. */
  .meter { grid-template-columns: 76px minmax(0, 1fr) 44px; gap: 8px; }
  .budget-facts .k { width: 104px; }

  /* Half the screen for the log tail, so the facts above it stay reachable. */
  .logview { max-height: 50vh; }

  /* The sign-in card is the first thing a phone ever sees, and 48px of side
     padding on a 320px screen leaves 224px for the button in it. */
  .login-card { padding: 28px 20px; }
  .login-err { max-width: 100%; }

  /* The timing page: the duration column has nowhere to go at 320px, so the
     row head gives up its second column and the total drops under the title. */
  .timing-row { grid-template-columns: minmax(0, 1fr); grid-template-areas: "head" "total" "bar" "meta"; gap: 4px; }
  .stat-row { grid-template-columns: repeat(auto-fit, minmax(96px, 1fr)); gap: 10px 16px; }
  /* A `.stat` value can be a pill (the dev queue's freshness badge), and a pill
     is `white-space: nowrap` — in a 96px track "stale 41m" does not shrink, it
     hangs out of the card. Same fix the headbox needed at this width. */
  .stat-row .stat .k { white-space: normal; }
  .stat-row .stat .v .pill { white-space: normal; }
  /* Five columns have nowhere to go at 320px, so the dev queue's tables scroll
     inside their own card (`.card.flush` above) rather than widening the page —
     and the two wide cells stop reserving desktop width for themselves. */
  .queue-table td.trunc { max-width: 240px; }
  /* A QA ticket's head wraps rather than squeezing the title to nothing
     beside a long repo#number (#238). */
  .qa-item-head { flex-wrap: wrap; }
  .qa-item-head .qa-title { flex-basis: 100%; white-space: normal; }
  /* A preset reason wider than the screen breaks inside its button rather
     than widening the page (#292). */
  .qa-actions .qa-preset { flex-shrink: 1; min-width: 0; white-space: normal; }
  /* The repos page (#315): one widget per row — a 260px track minimum would
     hang off a 296px column. */
  .repo-grid { grid-template-columns: minmax(0, 1fr); }
}
