/* One stylesheet for both pages: the landing page and the control panel are the
   same site, and a second theme to keep in sync would drift within a month.

   Light and dark both hang off [data-theme] on <html>, set before the first
   paint by static/theme.js. It used to be a bare prefers-color-scheme media
   query with no toggle, on the reasoning that a toggle needs somewhere to
   remember the choice and this page has no session to put it in — which was
   half right. The panel's whole user model is a git-tracked text file
   (admin/users.conf), so there is genuinely nowhere server-side to write a
   preference, and adding a mutable per-user store for a colour would be a
   database concern this box doesn't otherwise have. So the choice lives in
   localStorage and nowhere else: it is per-browser here, unlike the other three
   apps where it follows the account. That's the deviation, it's deliberate, and
   the switch says as much rather than implying it travels. */

:root {
  /* Native scrollbars, date pickers and select popups, which no stylesheet
     reaches. Stated per theme now that the device no longer decides. */
  color-scheme: light;
  --bg: #f6f7fb;
  --bg-soft: #ffffff;
  --line: rgba(17, 24, 39, 0.08);
  --line-strong: rgba(17, 24, 39, 0.16);
  --ink: #111827;
  --ink-soft: #5b6579;
  --ink-faint: #8a93a6;
  --accent: #4f46e5;
  --accent-ink: #ffffff;
  --good: #16a34a;
  --bad: #dc2626;
  --warn: #d97706;
  /* Warmth, and only for that: the landing page's promises and its sign-off.
     Deliberately not `--warn`, which is the same family and means something —
     a panel that borrows its alert colour to look friendly is a panel whose
     alerts stop reading as alerts. */
  --warm: #b45309;
  --shadow: 0 1px 2px rgba(16, 24, 40, 0.04), 0 12px 32px -12px rgba(16, 24, 40, 0.18);
  --radius: 16px;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;

  /* The same colours as bare channels, for anything that needs one at an alpha:
     `rgba(var(--accent-rgb), 0.12)`. Not `color-mix()` and not `rgb(from …)` —
     both want Chromium 111, and the estate is read on a TV browser years short
     of it, where a colour the engine can't parse is a dropped declaration, not a
     fallback. See the head of apps/rasoi/web/css/rasoi-theme.css. Move a hex
     above and move its triple with it. */
  --bg-soft-rgb: 255, 255, 255;
  --ink-rgb: 17, 24, 39;
  --ink-faint-rgb: 138, 147, 166;
  --accent-rgb: 79, 70, 229;
  --good-rgb: 22, 163, 74;
  --bad-rgb: 220, 38, 38;
  --warn-rgb: 217, 119, 6;
  --warm-rgb: 180, 83, 9;

  /* The accent pulled towards the ink, so a pale accent stays a word you can
     read and a dark one doesn't disappear. Four strengths because four things
     ask for it; the percentage in the name is what it was mixed at. */
  --accent-ink-70: #3c38ac;
  --accent-ink-75: #403ab6;
  --accent-ink-78: #413cbb;
  --accent-ink-80: #433dbf;
  --accent-line-45: rgba(73, 66, 212, 0.494);  /* accent 45% on --line */
  --accent-good-40: #2d7e88;  /* accent 40% on --good — the signed-in mark */

  /* The middle of the capability ramp — see the block above .plot for what the
     four bands are. Ends are `--accent` and `--ink-faint`; these two are the
     accent mixed 70% and 42% towards the faint ink. */
  --tier-2: #615dd2;
  --tier-3: #7173c0;
}

/* The same values the media query used to carry — this is a change of trigger,
   not of design. `system` still means the device: theme.js resolves it and sets
   the attribute, so the device stays the default and the choice can outrank it. */
:root[data-theme="dark"] {
  --bg: #0b0d13;
  --bg-soft: #141822;
  --line: rgba(255, 255, 255, 0.08);
  --line-strong: rgba(255, 255, 255, 0.16);
  --ink: #eef1f7;
  --ink-soft: #a5adbf;
  --ink-faint: #767f92;
  --accent: #818cf8;
  --accent-ink: #0b0d13;
  --good: #4ade80;
  --bad: #f87171;
  --warn: #fbbf24;
  --warm: #e9a765;
  --shadow: 0 1px 2px rgba(0, 0, 0, 0.4), 0 16px 40px -16px rgba(0, 0, 0, 0.7);

  --bg-soft-rgb: 20, 24, 34;
  --ink-rgb: 238, 241, 247;
  --ink-faint-rgb: 118, 127, 146;
  --accent-rgb: 129, 140, 248;
  --good-rgb: 74, 222, 128;
  --bad-rgb: 248, 113, 113;
  --warn-rgb: 251, 191, 36;
  --warm-rgb: 233, 167, 101;

  --accent-ink-70: #a2aaf8;
  --accent-ink-75: #9ca5f8;
  --accent-ink-78: #99a2f8;
  --accent-ink-80: #97a0f8;
  --accent-line-45: rgba(140, 150, 249, 0.494);
  --accent-good-40: #60bdb0;
  --tier-2: #7e88d9;
  --tier-3: #7b84bd;

  color-scheme: dark;
}

* {
  box-sizing: border-box;
}

/* The whole admin page is sections toggled with the `hidden` attribute, and a
   class that sets `display` beats the browser's own `[hidden] { display: none }`
   — which shows up as every state of the page rendering at once. */
[hidden] {
  display: none !important;
}

html {
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  min-height: 100vh;
  background: var(--bg);
  color: var(--ink);
  font: 16px/1.55 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Inter,
    "Helvetica Neue", Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
}

/* Two soft blobs behind everything. Fixed and non-interactive, so nothing on
   the page has to account for them. */
.aurora {
  position: fixed;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background:
    radial-gradient(42rem 42rem at 12% -10%, rgba(var(--accent-rgb), 0.22), transparent 60%),
    radial-gradient(34rem 34rem at 92% 8%, rgba(var(--good-rgb), 0.14), transparent 62%);
  filter: blur(4px);
}

a {
  color: inherit;
}

h1,
h2 {
  margin: 0;
  letter-spacing: -0.02em;
  line-height: 1.15;
}

.wrap {
  max-width: 60rem;
  margin: 0 auto;
  padding: clamp(2rem, 6vw, 5rem) clamp(1rem, 4vw, 2rem) 4rem;
}

.mono {
  font-family: var(--mono);
  font-size: 0.86em;
}

.muted {
  color: var(--ink-soft);
}

/* The counterpart to .muted, for one item in a list of otherwise-quiet ones:
   the instance of an app that isn't answering, among the ones that are. */
.bad {
  color: var(--bad);
}

/* The same, one step down: something the scrape or the check *said*, on a row
   that is otherwise fine. "1 of 2 instances did not answer" is not a failure —
   the numbers arrived — but it is why they are lower than they should be, and
   in red it reads as the app being down. */
.warn {
  color: var(--warn);
}

.small {
  font-size: 0.82rem;
}

/* ------------------------------------------------------------- landing --- */

.eyebrow {
  margin: 0 0 0.4rem;
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--accent);
}

.hero {
  margin-bottom: clamp(2rem, 5vw, 3.5rem);
}

.hero h1 {
  font-size: clamp(2.1rem, 6vw, 3.4rem);
  font-weight: 700;
}

.lede {
  max-width: 34rem;
  margin: 0.9rem 0 0;
  font-size: 1.05rem;
  color: var(--ink-soft);
}

/* ------------------------------------------------------------ catalogue ---
   Each tile is coloured by the app it links to: --accent and --accent-2 come
   from the catalogue entry, and everything below is written in terms of those
   two. Nothing here names a specific app — and neither does this file name
   where they arrive from, which is two places. Served by the app, they are a
   nonced <style> the page carries, one `.tile[data-app="…"]` rule per card
   across both grids (landing.render_tile_css); opened off disk, home.js sets
   them on each card it builds through the CSSOM. Both because a style-src of
   'self' drops a style
   attribute and a nonce can't name markup that didn't exist when it was
   minted. */

.tiles {
  display: grid;
  gap: 1.5rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
}

.tile {
  display: flex;
  flex-direction: column;
  overflow: hidden;
  border: 1px solid var(--line);
  border-radius: 22px;
  background: var(--bg-soft);
  box-shadow: var(--shadow);
  color: inherit;
  text-decoration: none;
  transition: transform 0.22s cubic-bezier(0.22, 1, 0.36, 1),
    border-color 0.22s ease, box-shadow 0.22s ease;
  animation: rise 0.55s cubic-bezier(0.22, 1, 0.36, 1) both;
  animation-delay: calc(var(--i, 0) * 80ms);

  /* This tile's accent blended with the page — the hover edge, the tagline, the
     arrow. Both palettes arrive with the tile (landing.py's tile_vars, and
     paintAccent in home.js) because a blend can't be written in CSS any more
     and the accent isn't known until the catalogue is read; these three lines
     pick the half in use. The fallbacks are the portal's own accent, for a tile
     rendered by something that didn't set them — which is also what the whole
     grid looked like while the CSP was dropping them. */
  --tile-edge: var(--tile-edge-l, var(--line-strong));
  --tile-tagline: var(--tile-tagline-l, var(--accent-ink-70));
  --tile-arrow: var(--tile-arrow-l, var(--accent-ink-70));
}

:root[data-theme="dark"] .tile {
  --tile-edge: var(--tile-edge-d, var(--line-strong));
  --tile-tagline: var(--tile-tagline-d, var(--accent-ink-70));
  --tile-arrow: var(--tile-arrow-d, var(--accent-ink-70));
}

.tile:hover,
.tile:focus-visible {
  transform: translateY(-4px);
  border-color: var(--tile-edge);
  box-shadow: 0 26px 50px -26px rgba(var(--accent-rgb), 0.65),
    var(--shadow);
}

/* The banner. With no artwork the gradient *is* the banner, which is why an app
   nobody has drawn an icon for still looks deliberate. */
.tile-art {
  position: relative;
  aspect-ratio: 16 / 7;
  overflow: hidden;
  background: linear-gradient(135deg, var(--accent), var(--accent-2));
}

.tile-art img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform 0.5s cubic-bezier(0.22, 1, 0.36, 1);
}

.tile:hover .tile-art img {
  transform: scale(1.035);
}

.tile-body {
  position: relative;
  display: flex;
  flex: 1;
  flex-direction: column;
  gap: 0.7rem;
  padding: 2.9rem 1.4rem 1.3rem;
}

/* Straddles the seam between the artwork and the body, punched out of the
   banner by a border in the card's own colour. */
.tile-chip {
  position: absolute;
  top: 0;
  left: 1.4rem;
  display: grid;
  place-items: center;
  width: 4rem;
  height: 4rem;
  border: 4px solid var(--bg-soft);
  border-radius: 20px;
  background: linear-gradient(140deg, var(--accent), var(--accent-2));
  box-shadow: 0 12px 24px -12px rgba(var(--accent-rgb), 0.75);
  transform: translateY(-50%);
}

.tile-chip img {
  width: 56%;
  height: 56%;
}

.tile-chip-text {
  color: #fff;
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: 0.02em;
}

/* Stated rather than inherited from the h1/h2 rule above: a card in the "in the
   works" grid sits under a real heading and is an h3, which that rule doesn't
   reach — and an h3 carrying the browser's own margins would sit a line lower
   than the tile beside it. */
.tile-title {
  margin: 0;
  font-size: clamp(1.4rem, 2.4vw, 1.75rem);
  font-weight: 700;
  letter-spacing: -0.02em;
  line-height: 1.15;
}

/* FONT= in a catalogue entry picks one of these. Stacks of what the reader
   already has — the page loads nothing off-origin, fonts included. */
.tile[data-font="serif"] .tile-title {
  font-family: "Iowan Old Style", "Palatino Linotype", Palatino, Georgia, serif;
  letter-spacing: -0.005em;
}

.tile[data-font="rounded"] .tile-title {
  font-family: ui-rounded, "SF Pro Rounded", "Avenir Next", Avenir, "Segoe UI",
    system-ui, sans-serif;
  letter-spacing: -0.01em;
}

.tile[data-font="mono"] .tile-title {
  font-family: var(--mono);
  font-size: clamp(1.2rem, 2vw, 1.45rem);
  letter-spacing: -0.03em;
}

.tile[data-font="display"] .tile-title {
  font-weight: 800;
  letter-spacing: -0.035em;
}

.tile-tagline {
  margin: 0.15rem 0 0;
  font-size: 0.92rem;
  font-weight: 500;
  /* Mixed with the ink rather than used raw, so a pale accent stays legible in
     light mode and a dark one in dark mode. */
  color: var(--tile-tagline);
}

.tile-blurb {
  margin: 0;
  color: var(--ink-soft);
  font-size: 0.95rem;
}

.tile-go {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-top: auto;
  padding-top: 0.9rem;
  border-top: 1px solid var(--line);
}

.tile-host {
  font-family: var(--mono);
  font-size: 0.8rem;
  color: var(--ink-faint);
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.tile:hover .arrow {
  transform: translateX(4px);
  opacity: 1;
  color: var(--tile-arrow);
}

/* ------------------------------------------------------------- in the works ---
   An app that is being built rather than run. Same card, one step back: a
   dashed edge because the thing it describes isn't solid yet, no shadow and no
   hover — it is a div, so there is nothing to press and nothing here pretends
   otherwise. Everything is a token or a blend of the tile's own accent, so both
   palettes come free. */

.soon {
  margin-top: clamp(2.5rem, 6vw, 4rem);
}

.section-head {
  font-size: 1.15rem;
  font-weight: 700;
  letter-spacing: -0.01em;
}

.section-lede {
  margin: 0.3rem 0 1.5rem;
  color: var(--ink-soft);
  font-size: 0.95rem;
}

/* The dashed edge is what says "not yet", so it is drawn in the stronger of the
   two line tokens — at `--line` the dashes disappear into the light palette and
   the card just looks like a tile with no link in it. */
.tile-idle {
  border-color: var(--line-strong);
  border-style: dashed;
  box-shadow: none;
}

/* The banner is a promise rather than a picture — held back so a card with no
   artwork doesn't shout louder than the apps that exist. */
.tile-idle .tile-art {
  opacity: 0.72;
}

/* Where a live tile prints its hostname. Same weight as one, so the two grids
   line up. */
.tile-soon {
  padding: 0.16rem 0.6rem;
  border-radius: 999px;
  background: rgba(var(--accent-rgb), 0.14);
  color: var(--tile-tagline);
  font-size: 0.74rem;
  font-weight: 600;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.tile.skeleton {
  min-height: 21rem;
  border-style: dashed;
  box-shadow: none;
  opacity: 0.45;
  animation: pulse 1.6s ease-in-out infinite;
}

.mark {
  flex: none;
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  border-radius: 12px;
  background: linear-gradient(140deg, var(--accent), var(--accent-good-40));
  color: var(--accent-ink);
  font-weight: 700;
  font-size: 0.9rem;
  letter-spacing: 0.02em;
}

.mark.small {
  width: 2rem;
  height: 2rem;
  border-radius: 9px;
  font-size: 0.72rem;
}

.arrow {
  opacity: 0.4;
  transition: transform 0.2s ease, opacity 0.2s ease, color 0.2s ease;
}

.empty {
  margin-top: 1.5rem;
  color: var(--ink-soft);
}

/* -------------------------------------------------------- the promises ---
   Four things that are true of every app listed above. Warm on purpose and by
   one device only: `--warm`, on a tinted panel, against the same ink and the
   same hairlines as everything else. A trust section that arrives in its own
   private styling reads like an advert for trustworthiness; this one is the
   page, a shade warmer. */

.promises {
  margin-top: clamp(2.5rem, 6vw, 4rem);
  padding: clamp(1.5rem, 4vw, 2.4rem);
  border: 1px solid var(--line);
  border-radius: 22px;
  background:
    linear-gradient(160deg, rgba(var(--warm-rgb), 0.09), rgba(var(--warm-rgb), 0.02) 60%),
    var(--bg-soft);
}

.promise-list {
  display: grid;
  gap: 1.4rem 2rem;
  margin: 0;
  padding: 0;
  list-style: none;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 20rem), 1fr));
}

.promise {
  display: flex;
  gap: 0.9rem;
  align-items: flex-start;
}

.promise-mark {
  flex: none;
  display: grid;
  place-items: center;
  width: 2.4rem;
  height: 2.4rem;
  border-radius: 12px;
  background: rgba(var(--warm-rgb), 0.14);
  color: var(--warm);
}

.promise-mark svg {
  width: 1.3rem;
  height: 1.3rem;
}

.promise-words h3 {
  margin: 0.15rem 0 0.3rem;
  font-size: 1rem;
  font-weight: 700;
  letter-spacing: -0.01em;
  line-height: 1.3;
}

.promise-words p {
  margin: 0;
  color: var(--ink-soft);
  font-size: 0.92rem;
}

/* ------------------------------------------------------- install card ---
   "Keep it on your home screen", under the promises. The same panel as
   .promises one shade along: accent rather than warm, because this one is an
   offer rather than a reassurance and the two sitting in the same colour would
   read as one section. Every value is a token or a tint of one, so both
   palettes come free.

   It is display:grid rather than block so the button and the iOS sentence —
   only ever one of the two, and sometimes neither — sit at the start of the
   line without a margin left behind by whichever is hidden. `[hidden]` is
   !important above, so the section and both children still disappear. */

.install {
  display: grid;
  justify-items: start;
  gap: 0.2rem;
  margin-top: clamp(2.5rem, 6vw, 4rem);
  padding: clamp(1.5rem, 4vw, 2.4rem);
  border: 1px solid var(--line);
  border-radius: 22px;
  background:
    linear-gradient(160deg, rgba(var(--accent-rgb), 0.1), rgba(var(--accent-rgb), 0.02) 60%),
    var(--bg-soft);
}

.install .section-lede {
  max-width: 52ch;
  margin-bottom: 1rem;
}

/* Comfortably past the 44px a thumb wants, which the shared .button.primary
   padding is a little under on a phone. */
.install .button.primary {
  padding: 0.7rem 1.4rem;
  font-size: 0.95rem;
}

/* The iOS route, in place of the button rather than beside it. Quieter than the
   button by a step — it is an instruction, not a control — but not fine print:
   it is the only way in on most of the phones that read this page. */
.install-how {
  margin: 0;
  color: var(--ink-soft);
  font-size: 0.92rem;
}

.install-how strong {
  color: var(--ink);
  font-weight: 600;
}

/* ------------------------------------------------------------ sign-off ---
   The note at the end is the one place on this page that speaks in the first
   person, so it is the one place set slightly larger than the fine print it
   sits above rather than in it. */

.site-note {
  margin-top: 3rem;
  padding-top: 1.5rem;
  border-top: 1px solid var(--line);
  color: var(--ink-faint);
  font-size: 0.85rem;
  max-width: 56ch;
}

.sign-off {
  margin: 0;
  color: var(--ink-soft);
  font-size: 0.95rem;
}

.sign-off-name {
  margin: 0.8rem 0 0;
  color: var(--ink);
  font-size: 0.95rem;
  font-weight: 600;
}

.made-in {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin: 0.9rem 0 0;
  color: var(--ink-soft);
  font-size: 0.9rem;
}

.made-in-heart {
  display: inline-grid;
  place-items: center;
  color: var(--warm);
}

.made-in-heart svg {
  width: 1rem;
  height: 1rem;
}

/* The disclosure, kept as quiet as it was when it was the whole footer: it is
   a disclosure, not a message. */
.fineprint {
  margin: 1.6rem 0 0;
  padding-top: 1.2rem;
  border-top: 1px solid var(--line);
}

/* -------------------------------------------------------------- status --- */
/* labs.rasoi.io/status: a headline, then a row of ninety day-bars per app.
   Rendered entirely by admin/statuspage.py — there is no JavaScript on the page
   beyond the theme resolver, so every state below is a class the server chose.

   Both palettes come free, as they do for .site-note: --good, --bad and --warn
   already flip with [data-theme], and nothing here names a colour outright.
   What does need saying per palette is the *unknown* bar, which is a shade of
   the page rather than a hue — see .status-day-unknown. */

.status-headline {
  display: flex;
  align-items: center;
  gap: 0.75rem;
  margin-bottom: 2.5rem;
}

.status-headline h1 {
  margin: 0;
  font-size: clamp(1.5rem, 4vw, 2.25rem);
  line-height: 1.15;
}

/* The dot carries the same information as the words beside it, for the reader
   scanning rather than reading. aria-hidden in the markup, so it is decoration
   in the accessibility tree and the headline text is the whole message. */
.status-dot {
  flex: none;
  width: 0.85rem;
  height: 0.85rem;
  border-radius: 50%;
  background: var(--ink-faint);
}

.status-headline.status-up .status-dot {
  background: var(--good);
  /* A ring rather than a shadow: it reads at the same weight in both palettes,
     where a dark-on-light glow disappears against a dark page. */
  box-shadow: 0 0 0 4px rgba(var(--good-rgb), 0.22);
}

.status-headline.status-degraded .status-dot {
  background: var(--warn);
  box-shadow: 0 0 0 4px rgba(var(--warn-rgb), 0.22);
}

.status-headline.status-down .status-dot {
  background: var(--bad);
  box-shadow: 0 0 0 4px rgba(var(--bad-rgb), 0.22);
}

.status-row {
  padding: 1.25rem 0;
  border-top: 1px solid var(--line);
}

.status-row-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 0.85rem;
}

.status-row-head h2 {
  margin: 0;
  font-size: 1.05rem;
  font-weight: 600;
}

.status-row-head a {
  color: inherit;
  text-decoration: none;
}

.status-row-head a:hover {
  text-decoration: underline;
}

.status-pill {
  flex: none;
  padding: 0.2rem 0.6rem;
  border-radius: 999px;
  font-size: 0.75rem;
  font-weight: 600;
  background: rgba(var(--ink-faint-rgb), 0.16);
  color: var(--ink-soft);
}

.status-pill.status-up {
  background: rgba(var(--good-rgb), 0.14);
  color: var(--good);
}

.status-pill.status-degraded {
  background: rgba(var(--warn-rgb), 0.16);
  color: var(--warn);
}

.status-pill.status-down {
  background: rgba(var(--bad-rgb), 0.14);
  color: var(--bad);
}

/* Ninety bars that must fit a phone without scrolling sideways, so they share
   the width rather than claiming a fixed one: each is a flex child with no
   basis, and the gap is what stops them merging into a single block. On a
   narrow screen they end up under a pixel wide, which still reads as a texture
   — the colour is the message at that size, not the individual day. */
.status-bars {
  display: flex;
  gap: 2px;
  height: 2.25rem;
}

/* `.status-day`, not `.bar`: the panel further down this file already owns
   `.bar` for its header row, and at equal specificity the later rule wins —
   which showed up as ninety collapsed flex containers with the panel's
   margin-bottom on them. One stylesheet for both pages is the trade this file
   made at the top; a prefix per page is the cost of it. */
.status-day {
  flex: 1 1 0;
  min-width: 0;
  border-radius: 2px;
  background: var(--good);
}

.status-day-degraded {
  background: var(--warn);
}

.status-day-down {
  background: var(--bad);
}

/* A day nobody was watching. Not grey-the-colour but grey-the-absence: mixed
   from the page's own ink so it recedes in light and dark alike, where a fixed
   #e5e7eb would be invisible on one and a bright stripe on the other. Every
   bar is this on the day the recorder ships, which is correct — the page has
   no history yet and should not draw any. */
.status-day-unknown {
  background: rgba(var(--ink-faint-rgb), 0.22);
}

.status-row-foot {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 1rem;
  margin-top: 0.5rem;
  font-size: 0.78rem;
  color: var(--ink-faint);
}

.status-pct {
  color: var(--ink-soft);
  font-variant-numeric: tabular-nums;
}

.status-pct-none {
  color: var(--ink-faint);
}

/* The "90 days ago" / "today" ends of the row. Hidden on a narrow screen, where
   the bars are already edge to edge and the labels would wrap into the space
   the percentage needs. */
@media (max-width: 30rem) {
  .status-span {
    display: none;
  }

  .status-row-foot {
    justify-content: center;
  }
}

.status-checked,
.status-empty {
  margin-top: 2rem;
  color: var(--ink-faint);
  font-size: 0.85rem;
}

/* --------------------------------------------------------- panel shell --- */
/* The control panel is four sections rather than one scroll, and this is the
   whole of the layout that makes that work at both sizes.

   The navigation is *one* control drawn two ways. On a phone it is a bar across
   the bottom, where a thumb already is and where every native app on the device
   has taught people to look; from `--shell-wide` up the same list becomes a
   column down the left. Nothing is hidden behind a hamburger at either size: on
   the surface you use most, a tap to find out what the options are is a tap too
   many.

   The breakpoint is where two columns stop crowding the rows, not a device. */

.shell {
  max-width: 74rem;
  margin: 0 auto;
  padding: clamp(1.5rem, 5vw, 3.5rem) clamp(0.9rem, 4vw, 2rem) 0;
  /* Clear of the fixed bar *and* of the home indicator underneath it. Without
     the inset the last row of the page sits under the gesture area on a modern
     phone, where it can be read and not tapped. */
  padding-bottom: calc(5.75rem + env(safe-area-inset-bottom));
}

.views {
  min-width: 0; /* or a wide log stretches the grid column instead of scrolling */
}

@media (min-width: 60rem) {
  .shell {
    display: grid;
    grid-template-columns: 12rem minmax(0, 1fr);
    grid-template-areas:
      "bar  bar"
      "nav  views";
    column-gap: clamp(1.5rem, 3vw, 2.5rem);
    padding-bottom: 3rem;
  }

  .shell > .bar {
    grid-area: bar;
  }

  .shell > .viewnav {
    grid-area: nav;
  }

  .shell > .views {
    grid-area: views;
  }
}

/* ------------------------------------------------------------ view nav --- */

.viewnav {
  position: fixed;
  z-index: 20;
  right: 0;
  bottom: 0;
  left: 0;
  display: flex;
  gap: 0.25rem;
  padding: 0.35rem 0.5rem calc(0.35rem + env(safe-area-inset-bottom));
  border-top: 1px solid var(--line);
  /* Translucent over a blur so the rows scrolling under it stay visible as
     content rather than disappearing under a slab. */
  background: rgba(var(--bg-soft-rgb), 0.88);
  backdrop-filter: blur(14px);
  -webkit-backdrop-filter: blur(14px);
}

.viewnav-item {
  position: relative;
  display: flex;
  flex: 1;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  /* 3.25rem ≈ 52px: comfortably past the 44px minimum, and the whole column is
     the target rather than the glyph in the middle of it. */
  gap: 0.15rem;
  min-height: 3.25rem;
  padding: 0.3rem 0.25rem;
  border: 0;
  border-radius: 12px;
  background: transparent;
  color: var(--ink-faint);
  font: inherit;
  font-size: 0.72rem;
  font-weight: 600;
  cursor: pointer;
  transition: color 0.15s ease, background 0.15s ease;
}

.viewnav-item:hover {
  color: var(--ink-soft);
}

.viewnav-item.on {
  color: var(--accent);
  background: rgba(var(--accent-rgb), 0.1);
}

.viewnav-item:focus-visible {
  outline: 2px solid rgba(var(--accent-rgb), 0.55);
  outline-offset: -2px;
}

.viewnav-icon {
  width: 1.35rem;
  height: 1.35rem;
  fill: none;
  stroke: currentColor;
  stroke-width: 1.7;
  stroke-linecap: round;
  stroke-linejoin: round;
}

.viewnav-label {
  line-height: 1.1;
}

/* How many jobs are in flight. On the nav rather than only inside the section,
   because splitting the page up is exactly what stops you seeing the others. */
.viewnav-badge {
  position: absolute;
  top: 0.25rem;
  right: 50%;
  min-width: 1.05rem;
  margin-right: -1.35rem;
  padding: 0 0.25rem;
  border-radius: 999px;
  background: var(--accent);
  color: var(--accent-ink);
  font-size: 0.66rem;
  font-weight: 700;
  line-height: 1.05rem;
  text-align: center;
}

@media (min-width: 60rem) {
  .viewnav {
    position: sticky;
    top: 2rem;
    right: auto;
    bottom: auto;
    left: auto;
    flex-direction: column;
    gap: 0.15rem;
    align-self: start;
    padding: 0;
    border-top: 0;
    background: none;
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
  }

  .viewnav-item {
    flex: none;
    flex-direction: row;
    justify-content: flex-start;
    gap: 0.65rem;
    min-height: 2.5rem;
    padding: 0.5rem 0.75rem;
    font-size: 0.92rem;
  }

  .viewnav-badge {
    position: static;
    margin: 0 0 0 auto;
  }
}

/* --------------------------------------------------------------- admin --- */

.gate {
  display: grid;
  place-items: center;
  min-height: 100vh;
  padding: 1.5rem;
}

.panel {
  margin-bottom: 1.25rem;
  padding: 1.25rem clamp(1rem, 3vw, 1.5rem);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: rgba(var(--bg-soft-rgb), 0.92);
  box-shadow: var(--shadow);
}

.panel.signin {
  max-width: 25rem;
  text-align: center;
}

.panel.signin h1 {
  font-size: 1.5rem;
}

.panel.signin .muted {
  margin: 0.75rem 0 1.25rem;
  font-size: 0.94rem;
}

.panel-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  margin-bottom: 0.9rem;
}

.panel-head h2 {
  font-size: 1.05rem;
}

.head-actions {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  justify-content: flex-end;
  gap: 0.5rem;
}

.bar {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 1rem;
  margin-bottom: 1.75rem;
}

.bar h1 {
  font-size: 1.6rem;
}

.bar-id {
  min-width: 0;
}

/* ---------------------------------------------------------- breadcrumbs --- */
/* Where you are and the way back, above the heading. Two levels at most — the
   panel, and one app — so it never wraps and never earns a scroller. Quiet:
   it's navigation, and the heading under it is what you came to read. */

.crumbs {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.35rem;
  margin-bottom: 0.15rem;
  font-size: 0.8rem;
  line-height: 1.4;
}

.crumb {
  color: var(--ink-faint);
  text-decoration: none;
}

a.crumb:hover,
a.crumb:focus-visible {
  color: var(--accent);
  text-decoration: underline;
}

/* The one you're on. Says so rather than being a link that goes nowhere. */
.crumb.here {
  color: var(--ink-soft);
  font-weight: 600;
}

.crumb-sep {
  color: var(--ink-faint);
  opacity: 0.6;
}

/* ------------------------------------------------------- the account menu -- */
/* You, and everything that is about you rather than about the box: the
   palette, the install prompt, signing out. One control in the title bar
   instead of a switch, a sentence and three buttons strung across it — see
   wireAccountMenu in common.js.
   Anchored rather than fixed: the menu belongs to the button, and a fixed
   overlay would have to be repositioned on every scroll of a page that
   re-renders itself every few seconds. */

.account {
  position: relative;
  flex: none;
}

.account-button {
  display: flex;
  align-items: center;
  gap: 0.55rem;
  max-width: 100%;
  padding: 0.35rem 0.5rem;
  border: 1px solid transparent;
  border-radius: 12px;
  background: transparent;
  color: inherit;
  font: inherit;
  font-size: 0.9rem;
  text-align: left;
  cursor: pointer;
  transition: background 0.12s ease, border-color 0.12s ease;
}

.account-button:hover,
.account-button[aria-expanded="true"] {
  border-color: var(--line);
  background: rgba(var(--ink-rgb), 0.04);
}

.account-button:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.account-who {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  min-width: 0;
}

.account-tags {
  display: flex;
  align-items: center;
  gap: 0.3rem;
}

.who-email {
  display: block;
  max-width: 14rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.account-chevron {
  flex: none;
  width: 1rem;
  height: 1rem;
  fill: none;
  stroke: var(--ink-faint);
  stroke-width: 2;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform 0.15s ease;
}

.account-button[aria-expanded="true"] .account-chevron {
  transform: rotate(180deg);
}

.account-menu {
  position: absolute;
  z-index: 20;
  top: calc(100% + 0.4rem);
  right: 0;
  width: min(20rem, calc(100vw - 2rem));
  padding: 0.75rem;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: var(--bg-soft);
  box-shadow: 0 18px 40px rgba(16, 24, 40, 0.18);
}

:root[data-theme="dark"] .account-menu {
  box-shadow: 0 18px 40px rgba(0, 0, 0, 0.55);
}

.account-menu-head {
  display: flex;
  flex-direction: column;
  gap: 0.1rem;
  padding-bottom: 0.6rem;
  border-bottom: 1px solid var(--line);
}

.account-menu-name {
  font-weight: 600;
  font-size: 0.9rem;
}

.account-menu-email {
  font-size: 0.8rem;
  color: var(--ink-soft);
  overflow-wrap: anywhere;
}

.account-menu-group {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.5rem;
  padding: 0.7rem 0;
  border-bottom: 1px solid var(--line);
}

.account-menu-group:last-child {
  padding-bottom: 0;
  border-bottom: 0;
}

.account-menu-title {
  font-size: 0.72rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

/* A menu row, not a button in a row of buttons: full width, left-aligned, and
   the whole strip is the target. */
.account-menu-item {
  width: 100%;
  padding: 0.45rem 0.55rem;
  margin-inline: -0.55rem;
  border: 0;
  border-radius: 9px;
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: 0.88rem;
  font-weight: 500;
  text-align: left;
  cursor: pointer;
}

.account-menu-item:hover {
  background: rgba(var(--ink-rgb), 0.06);
}

.account-menu-item:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: -2px;
}

/* The palette hint. Long, and it earns its place — it says the choice is per
   browser here, which is the one way the panel differs from the other apps. */
.account-menu-group #theme-hint {
  line-height: 1.45;
}

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

.row {
  display: flex;
  align-items: center;
  gap: 0.9rem;
  padding: 0.85rem 0;
  border-top: 1px solid var(--line);
}

.row:first-child {
  border-top: 0;
}

.row.compact {
  padding: 0.6rem 0;
}

.row-main {
  flex: 1;
  min-width: 0;
}

.row-title {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  font-weight: 600;
}

.row-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 0.65rem;
  margin-top: 0.2rem;
  font-size: 0.82rem;
  color: var(--ink-soft);
}

/* A commit subject is as long as whoever wrote it felt like. Clipped so the
   line it's on stays one line — the full text is on the element's title. */
.row-meta .clip {
  max-width: 42ch;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.row-meta a {
  text-decoration: none;
}

.row-meta a:hover {
  text-decoration: underline;
}

/* ------------------------------------------------------------ app rows --- */
/* An app in the list is two lines: what it is, and what it's running. The
   sizing does the work the words used to — the name is the only thing at full
   size, and everything under it is one small, quiet, single-line strip. A box
   with nothing wrong should be scannable in the time it takes to look down the
   left edge. */

.app-row {
  padding-inline: 0.6rem;
  margin-inline: -0.6rem;
  border-radius: 12px;
  transition: background 0.12s ease;
}

.app-row:hover {
  background: rgba(var(--ink-rgb), 0.04);
}

/* The whole row opens the app, not just its name. A two-line card whose only
   target was eight characters of heading is a card you have to aim at — on a
   phone that is a 44px strip inside a 70px one, and everywhere else it is a
   guess about which part of the row is live. The controls at the right-hand end
   still do their own thing; see appRow in admin.js for what is excluded. */
.app-row.clickable {
  cursor: pointer;
}

/* The name keeps the accent on row hover, so it still reads as the thing that
   navigates rather than as a heading that happens to be over a live strip. */
.app-row.clickable:hover .row-name {
  color: var(--accent);
}

/* Fixed, so a longer button label eats into this column rather than into the
   row — "Deploy 3 new" is four characters longer than "Redeploy", and without
   this the counters to its left stop forming a column.
   Wide enough for the longest label any of these buttons takes, which is the
   footnote's "Pull & refresh queue": the moment one of them overflows, that
   group is pushed wider than the rest and its switch leaves the column. */
.app-row .row-actions,
.ops-tree-head .row-actions {
  min-width: 14rem;
  justify-content: flex-end;
}

/* The switch held at that column's left edge, so the button alone absorbs the
   longer label. Right-alignment by itself moved each switch along with its own
   button: one row saying "Deploy 3 new" and the rest "Redeploy" put four
   switches at two different offsets, and the ops-tree footnote's at a third.
   `margin-right: auto` and not `space-between`, which would strand a group
   that is only a button — a viewer's rows — at the left instead. */
.app-row .row-actions .switch,
.ops-tree-head .row-actions .switch {
  margin-right: auto;
}

.row-head {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.45rem;
}

/* The way in to that app's own page — a real link, so a middle click opens it
   in a tab and a screen reader reads it as the row's destination. The rest of
   the row navigates too (see .app-row.clickable), which is why this looks like
   the heading it is rather than like the only live thing on the line. */
.row-name {
  font-weight: 600;
  font-size: 1rem;
  letter-spacing: -0.01em;
  color: inherit;
  text-decoration: none;
}

.row-name:hover,
.row-name:focus-visible {
  color: var(--accent);
  text-decoration: underline;
}

.row-host {
  font-family: var(--mono);
  font-size: 0.78rem;
  color: var(--ink-faint);
  text-decoration: none;
}

.row-host:hover,
.row-host:focus-visible {
  color: var(--accent);
  text-decoration: underline;
}

/* The second line. One line, always: nothing here wraps, and the only elastic
   part is the commit subject, which is clipped. So a row is the same height
   whatever anyone wrote in their commit message. */
.row-sub {
  display: flex;
  align-items: center;
  gap: 0.45rem;
  min-width: 0;
  margin-top: 0.2rem;
  font-size: 0.8rem;
  line-height: 1.5;
  color: var(--ink-faint);
  white-space: nowrap;
}

.row-sub .grow {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
}

.row-sub .ink-bad {
  color: var(--bad);
}

.row-sub .to {
  flex: none;
  color: var(--ink-faint);
}

/* Named only when it isn't the branch everything else is on. */
.row-sub .on-branch {
  flex: none;
  font-family: var(--mono);
  font-size: 0.72rem;
  color: var(--warn);
}

.row-sub .stamp {
  flex: none;
}

/* A commit. Small enough to read as punctuation in the line rather than as the
   subject of it, but a real link — the sha is how you get to the diff. */
.sha {
  flex: none;
  padding: 0.05rem 0.35rem;
  border-radius: 6px;
  background: rgba(var(--ink-rgb), 0.08);
  font-family: var(--mono);
  font-size: 0.74rem;
  color: var(--ink-soft);
  text-decoration: none;
}

.sha:hover,
.sha:focus-visible {
  color: var(--accent);
}

/* Where a deploy would take it. Coloured, because it's the one thing on a row
   that is asking to be acted on. */
.sha.next {
  background: rgba(var(--warn-rgb), 0.16);
  color: var(--warn);
}

/* What the app has on record, at the end of its line: numbers only, with the
   words on the group's tooltip. Pushed right so they line up down the list and
   can be read as a column. */
.counts {
  flex: none;
  display: flex;
  align-items: center;
  gap: 0.3rem;
  margin-left: auto;
  padding-left: 0.5rem;
}

.count {
  min-width: 1.35rem;
  padding: 0.05rem 0.3rem;
  border-radius: 6px;
  background: rgba(var(--ink-rgb), 0.07);
  font-family: var(--mono);
  font-size: 0.72rem;
  text-align: center;
  color: var(--ink-soft);
}

.count.bad {
  background: rgba(var(--bad-rgb), 0.14);
  color: var(--bad);
}

.count.good {
  background: rgba(var(--good-rgb), 0.14);
  color: var(--good);
}

/* The one chip in the group the app said itself — its own user count, scraped
   from it — as against the four the portal watched happen. In the accent
   colour, since it isn't good news or bad news, it's just the app's number. */
.count.own {
  background: rgba(var(--accent-rgb), 0.14);
  color: var(--accent);
}

/* The same chip, named rather than counted, in a group that is the whole width
   of a panel rather than the end of a row: one per tier of a checks run, and
   there are thirty-odd of them.

   It is its own class because `.counts` above is the opposite shape in both
   directions — a fixed column of numbers, pushed right, that must not wrap —
   and a run's tiers wore it for a while. `.count`'s `min-width` overrides the
   `auto` a flex item would otherwise have, so on a row that couldn't wrap every
   chip shrank under its own text and thirty-three names printed through each
   other. Wrapping is the fix; `min-width: auto` is what stops the last line
   doing it again. */
.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  margin: 0 0 0.9rem;
}

.chips .count {
  min-width: auto;
  padding: 0.1rem 0.45rem;
}

/* One scraped metric per row: name and labels on the left, the number on the
   right, where they line up as a column down the tab. */
.metric {
  flex: none;
  display: flex;
  align-items: baseline;
  gap: 0.45rem;
  margin-left: auto;
  padding-left: 0.75rem;
}

.metric-number {
  font-family: var(--mono);
  font-size: 1.05rem;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}

/* How far it has moved since yesterday. Quiet on purpose — it's the second
   thing you read, after the number it belongs to. */
.metric-change {
  min-width: 3rem;
  font-family: var(--mono);
  font-size: 0.72rem;
  text-align: right;
  color: var(--ink-faint);
}

.metric-change.up {
  color: var(--good);
}

/* Not red: a gauge going down is a fact about the app, not a fault. */
.metric-change.down {
  color: var(--ink-soft);
}

.metric-row .row-title {
  align-items: baseline;
}

/* On a phone there isn't room for a name, a status line and two controls side
   by side — squeezed into one row the name wraps to three lines and the domain
   runs under the switch. So the controls drop to their own full-width line
   under the app, and the commit subject goes altogether: it's the one thing
   here that is also on the app's page, one tap away. */
@media (max-width: 34rem) {
  /* The heading and its buttons side by side leaves the heading three words
     wide and wrapping. Stacked, and without the aside that only explains what
     tapping a row does. */
  .panel-head {
    flex-direction: column;
    align-items: flex-start;
  }

  .panel-head h2 .small {
    display: none;
  }

  .app-row {
    flex-wrap: wrap;
  }

  .app-row .row-actions {
    flex-basis: 100%;
    min-width: 0;
    justify-content: flex-start;
    padding-left: 1.5rem;
  }

  /* There is no column to line up with here — the controls have a line to
     themselves — so the switch goes back to hugging its button instead of
     being held a screen's width away from it. */
  .app-row .row-actions .switch {
    margin-right: 0;
  }

  .row-sub .grow {
    display: none;
  }

  /* .panel-head stacks here, and a shrink-to-fit strip in a column has no
     width to scroll inside — so it would clip the last tabs instead of
     offering them. Full width, and the swipe works. */
  .panel-head .tabs {
    align-self: stretch;
  }

  /* Same reason: the head-actions row is the panel's controls, and on a phone
     it should use the width it now has rather than huddling at the right. */
  .panel-head .head-actions {
    justify-content: flex-start;
  }

  /* The header was a third of a phone screen before anything on the page
     started. It says who you are and which box this is — worth keeping, not
     worth that much of the fold. */
  .bar {
    margin-bottom: 1.1rem;
  }

  .bar h1 {
    font-size: 1.35rem;
  }

  .account-button {
    font-size: 0.84rem;
  }

  .who-email {
    max-width: 11rem;
  }
}

.row-error {
  margin-top: 0.35rem;
  font-size: 0.8rem;
  color: var(--bad);
}

/* Sits under the app rows, describing the checkout they were all judged by.
   Separated by a rule so it doesn't read as another row. */
.ops-tree {
  margin: 0.9rem 0 0;
  padding-top: 0.8rem;
  border-top: 1px solid var(--line);
  font-size: 0.78rem;
  color: var(--ink-faint);
  overflow-wrap: anywhere;
}

.ops-tree p {
  margin: 0;
}

/* The commits on the left, the switch and the button on the right — in the
   same column, and to the same right edge, as every app row's controls above.
   The alternative was a switch sitting under the footnote's left edge, which
   reads as belonging to the sentence rather than to the column of switches it
   is one more of. */
.ops-tree-head {
  display: flex;
  align-items: center;
  gap: 0.7rem;
}

.ops-tree-head .ops-tree-line {
  flex: 1;
  min-width: 0;
}

/* The rows drop their controls to a full-width line on a phone; this has to do
   the same, or the footnote keeps a column the screen hasn't got and the
   commits wrap under it. After the rules above, not in the breakpoint they
   belong with — same specificity means source order decides, and there they'd
   lose. */
@media (max-width: 34rem) {
  .ops-tree-head {
    flex-wrap: wrap;
    align-items: flex-start;
  }

  .ops-tree-head .row-actions {
    flex-basis: 100%;
    min-width: 0;
    justify-content: flex-start;
  }

  .ops-tree-head .row-actions .switch {
    margin-right: 0;
  }
}

/* The commits themselves, which are read as identifiers rather than as prose. */
.ops-tree-line {
  font-family: var(--mono);
}

/* Why the two commits above differ. Only ever shown when something is wrong,
   so it is the one part of the footnote that is allowed to be a sentence — set
   in the body face at the panel's normal small size, and coloured, so it isn't
   read as more of the faint identifier line it hangs off. */
.ops-tree-message {
  margin-top: 0.4rem;
  font-size: 0.84rem;
  line-height: 1.5;
  color: var(--ink-soft);
}

.ops-tree.bad .ops-tree-message {
  color: var(--bad);
}

/* The command that fixes it, next to the button that copies it. Wraps as a
   unit on a narrow screen rather than stranding the button on its own line. */
.ops-tree-fix {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.55rem;
}

.ops-tree-fix code {
  padding: 0.2rem 0.45rem;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--bg-soft);
  font-family: var(--mono);
  font-size: 0.8rem;
  color: var(--ink);
  user-select: all;
}

/* Sized for sitting beside a line of text rather than in a row of controls. */
.button.tiny {
  padding: 0.2rem 0.55rem;
  border-radius: 8px;
  font-size: 0.75rem;
}

/* The controls at the end of an app row. Grouped so the row has one thing on
   its right rather than two competing for the space the app's name needs. */
.row-actions {
  flex: none;
  display: flex;
  align-items: center;
  gap: 0.7rem;
}

/* The auto-deploy switch. A real checkbox, kept where a screen reader and the
   keyboard can find it, with the track drawn next to it — `appearance: none` on
   the input itself would take the focus ring with it. */
.switch {
  display: inline-flex;
  align-items: center;
  gap: 0.4rem;
  cursor: pointer;
  user-select: none;
}

.switch input {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.switch .track {
  position: relative;
  flex: none;
  width: 2.1rem;
  height: 1.2rem;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  background: rgba(var(--ink-rgb), 0.08);
  transition: background 0.15s ease, border-color 0.15s ease;
}

.switch .track::after {
  content: "";
  position: absolute;
  top: 0.15rem;
  left: 0.18rem;
  width: 0.8rem;
  height: 0.8rem;
  border-radius: 50%;
  background: var(--bg-soft);
  box-shadow: 0 1px 2px rgba(16, 24, 40, 0.35);
  transition: transform 0.15s ease;
}

.switch input:checked + .track {
  border-color: transparent;
  background: var(--accent);
}

.switch input:checked + .track::after {
  transform: translateX(0.85rem);
}

.switch input:focus-visible + .track {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
}

.switch-text {
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--ink-faint);
}

.switch input:checked ~ .switch-text {
  color: var(--ink-soft);
}

.switch:has(input:disabled) {
  opacity: 0.45;
  cursor: not-allowed;
}

.dot {
  flex: none;
  width: 0.6rem;
  height: 0.6rem;
  border-radius: 50%;
  background: var(--ink-faint);
  box-shadow: 0 0 0 4px rgba(var(--ink-faint-rgb), 0.18);
}

.dot.up {
  background: var(--good);
  box-shadow: 0 0 0 4px rgba(var(--good-rgb), 0.2);
}

.dot.down {
  background: var(--bad);
  box-shadow: 0 0 0 4px rgba(var(--bad-rgb), 0.2);
}

/* Serving, from fewer processes than it should have. Amber rather than red
   because nobody visiting the site can tell — and rather than green because
   the spare that makes a deploy invisible is the thing that has been spent. */
.dot.degraded {
  background: var(--warn);
  box-shadow: 0 0 0 4px rgba(var(--warn-rgb), 0.2);
}

/* Something is happening to this app right now. The only moving thing on the
   page, which is the point — a deploy in flight is the one row state that is
   about to change on its own, and it has to be findable without reading. */
.dot.working {
  background: var(--accent);
  box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.22);
  animation: pulse 1.4s ease-in-out infinite;
}

.badge.working {
  border-color: rgba(var(--accent-rgb), 0.45);
  background: rgba(var(--accent-rgb), 0.12);
  color: var(--accent-ink-80);
  animation: pulse 1.4s ease-in-out infinite;
}

/* The row's button while its app is deploying. Quiet — it isn't the action any
   more, it's the way to the log — but not disabled, because it is still the way
   to the log. */
.button.working {
  color: var(--accent-ink-75);
  border-color: rgba(var(--accent-rgb), 0.3);
}

.badge {
  padding: 0.1rem 0.5rem;
  border: 1px solid var(--line-strong);
  border-radius: 999px;
  font-size: 0.72rem;
  font-weight: 600;
  letter-spacing: 0.02em;
  color: var(--ink-soft);
  text-transform: lowercase;
}

.badge.good {
  border-color: rgba(var(--good-rgb), 0.4);
  color: var(--good);
}

.badge.bad {
  border-color: rgba(var(--bad-rgb), 0.4);
  color: var(--bad);
}

.badge.warn {
  border-color: rgba(var(--warn-rgb), 0.45);
  background: rgba(var(--warn-rgb), 0.12);
  color: var(--warn);
}

.badge.quiet {
  opacity: 0.7;
}

/* The settings an app says it has, and hasn't. One pill per env var name — the
   spelling somebody would type into .env.prod, so that reading this and editing
   that file are the same words. Never a value: see pylib/ops_config.py.

   Two pages wear these now. An app's own Configuration panel (site.js) and the
   estate's Shared settings panel on the app list (admin.js) are the same list
   at two altitudes — one setting per row, a key, a state, and a field behind a
   button — so they are one set of rules rather than two that drift. */
.cfg-keys {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  margin: 0.5rem 0 0.1rem;
}

.cfg-key {
  padding: 0.12rem 0.45rem;
  border: 1px solid var(--line-strong);
  border-radius: 6px;
  font-size: 0.72rem;
  white-space: nowrap;
}

.cfg-key.good {
  border-color: rgba(var(--good-rgb), 0.4);
  color: var(--good);
}

/* A required setting with nothing in it. Filled rather than only outlined —
   this is the one row on the page somebody has to act on, and an outline reads
   as a category where a fill reads as a problem. */
.cfg-key.bad {
  border-color: rgba(var(--bad-rgb), 0.45);
  background: rgba(var(--bad-rgb), 0.12);
  color: var(--bad);
}

/* Unset and optional: a feature that is off, which is a fact rather than a
   fault. Quiet enough that the red ones are what the eye lands on. */
.cfg-key.quiet {
  color: var(--ink-faint);
  opacity: 0.8;
}

/* ------------------------------------------------- one setting, editable --- */
/* The Configuration panel on an app's page: a row per setting the app declares,
   and a way to fill one in. The name is a pill in the same three colours the
   list used, so the panel reads the same way it did — what changed is that each
   one now has room for a sentence and a button.

   There is no value anywhere in here, on purpose. The input is write-only: it
   starts empty on a setting that already has a value, because the portal has
   never seen that value and showing an empty box is the honest way to say so. */

.cfg-row {
  align-items: flex-start;
}

.cfg-row .row-title {
  flex-wrap: wrap;
}

/* The form under a row, revealed by its button. Its own line rather than in
   the row's right-hand column: a secret is long, and a field squeezed into the
   width a "Change" button left is a field you can't read what you typed into. */
.cfg-edit {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.5rem;
  margin-top: 0.55rem;
}

.cfg-input {
  flex: 1 1 16rem;
  min-width: 0;
  padding: 0.4rem 0.6rem;
  border: 1px solid var(--line-strong);
  border-radius: 9px;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--mono);
  font-size: 0.82rem;
}

.cfg-input:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* Reveal, so a pasted key can be checked before it restarts the app. A
   checkbox rather than an eye: it is a state, and it has to say which. */
.cfg-reveal {
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
  font-size: 0.75rem;
  color: var(--ink-faint);
  white-space: nowrap;
}

.cfg-say {
  flex-basis: 100%;
  font-size: 0.78rem;
  color: var(--ink-soft);
}

.cfg-say.bad {
  color: var(--bad);
}

.button {
  flex: none;
  padding: 0.45rem 0.9rem;
  border: 1px solid var(--line-strong);
  border-radius: 10px;
  background: var(--bg-soft);
  color: var(--ink);
  font: inherit;
  font-size: 0.88rem;
  font-weight: 600;
  text-decoration: none;
  cursor: pointer;
  transition: border-color 0.15s ease, background 0.15s ease, opacity 0.15s ease;
}

.button:hover:not(:disabled) {
  border-color: var(--accent);
}

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

.button.primary {
  display: inline-block;
  border-color: transparent;
  background: var(--accent);
  color: var(--accent-ink);
  padding: 0.6rem 1.2rem;
}

.button.ghost {
  background: transparent;
  border-color: var(--line);
  font-weight: 500;
  color: var(--ink-soft);
}

.notice {
  margin: 0 0 1.25rem;
  padding: 0.7rem 0.9rem;
  border: 1px solid rgba(var(--warn-rgb), 0.35);
  border-radius: 12px;
  background: rgba(var(--warn-rgb), 0.1);
  font-size: 0.88rem;
}

.notice.bad {
  border-color: rgba(var(--bad-rgb), 0.35);
  background: rgba(var(--bad-rgb), 0.1);
}

/* The backup rotation: one card per tier, above the list of databases. Three
   fixed things side by side rather than three more rows — they're read across
   (7 days, 4 weeks, 6 months) rather than down, and the list under them is the
   part that varies. */
.tiers {
  display: flex;
  flex-wrap: wrap;
  gap: 0.6rem;
  margin-bottom: 1rem;
}

/* A box with no backups at all draws no cards, and an empty flex row would
   still take its gap and its margin. */
.tiers:empty {
  display: none;
}

.tier {
  flex: 1 1 8rem;
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  padding: 0.6rem 0.8rem;
  border: 1px solid var(--line);
  border-radius: 12px;
}

/* Empty, on a box that has been backing up. Everything else about a tier that
   isn't full yet is just a tier filling up. */
.tier.bad {
  border-color: rgba(var(--bad-rgb), 0.35);
  background: rgba(var(--bad-rgb), 0.08);
}

.tier-name {
  font-size: 0.72rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-faint);
}

.tier-count {
  display: flex;
  align-items: baseline;
  font-family: var(--mono);
  font-size: 1.05rem;
  font-variant-numeric: tabular-nums;
  color: var(--ink);
}

/* The second half of "5/7" is the setting, and it's typed in place. Styled to
   look like the number it replaces rather than like a form field: the box only
   appears on hover and focus, so three cards of settings still read as three
   numbers. */
.tier-keep {
  /* Wide enough for the largest limit (365) and not a character wider: it has
     to sit against the slash like the digit it replaces, not float in a box. */
  width: 3.5ch;
  padding: 0;
  border: 1px solid transparent;
  border-radius: 6px;
  background: none;
  font: inherit;
  color: inherit;
  -moz-appearance: textfield;
}

.tier-keep::-webkit-outer-spin-button,
.tier-keep::-webkit-inner-spin-button {
  margin: 0;
  -webkit-appearance: none;
}

.tier-keep:hover:not(:disabled) {
  border-color: var(--line);
}

.tier-keep:focus-visible {
  border-color: var(--accent);
  outline: none;
}

/* A viewer sees the number and can't change it. No greying: it's the same fact
   either way, and a dimmed 7 next to a bright 7 reads as two different numbers. */
.tier-keep:disabled {
  opacity: 1;
}

.tier-span {
  font-size: 0.75rem;
  color: var(--ink-soft);
}

/* Backups this tier is about to prune, because its limit was just lowered.
   Amber rather than red: it is what you asked for, and it hasn't happened. */
.tier-span.warn {
  color: var(--warn);
}

.log {
  max-height: 22rem;
  margin: 0;
  padding: 0.9rem 1rem;
  overflow: auto;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: rgba(var(--ink-rgb), 0.05);
  font-family: var(--mono);
  font-size: 0.78rem;
  line-height: 1.5;
  white-space: pre-wrap;
  word-break: break-word;
}

/* A log that follows something rather than being the whole panel — the backup
   log under its list, where `margin: 0` would weld the two together. */
.log.spaced {
  margin-top: 1rem;
}

.fineprint {
  margin: 1.25rem 0 0;
  font-size: 0.85rem;
}

.fineprint a {
  color: var(--ink-soft);
  text-decoration: none;
}

/* ------------------------------------------------------------- charts --- */
/* One series per chart, so the accent is doing identity work and there is no
   palette here to get colourblind-safety wrong. Everything is recessive except
   the bars: hairline grid one shade off the surface, no dashes, no borders
   around marks — the gap between bars is surface, not ink. */

.chart {
  margin: 1rem 0 0;
}

.chart-board {
  position: relative;
}

.chart-plot {
  display: block;
  width: 100%;
  /* The bars overshoot the baseline so their bottom corners are clipped away
     here: a rounded data-end on top, square on the floor. */
  overflow: hidden;
}

.chart-grid {
  stroke: var(--line);
  stroke-width: 1;
}

.chart-bar {
  fill: rgba(var(--accent-rgb), 0.45);
}

/* The busiest day, which is the one thing anybody scans a spend chart for. */
.chart-bar.peak {
  fill: var(--accent);
}

/* The hit target: the full column including its gap, so reading a quiet day
   doesn't mean landing on a two-pixel sliver. Transparent until you're on it. */
.chart-hit {
  fill: transparent;
  cursor: pointer;
}

.chart-hit:hover,
.chart-hit:focus-visible {
  fill: rgba(var(--accent-rgb), 0.09);
  outline: none;
}

.chart-hit:focus-visible {
  stroke: rgba(var(--accent-rgb), 0.55);
  stroke-width: 2;
}

.chart-tip {
  position: absolute;
  bottom: calc(100% - 0.25rem);
  display: flex;
  flex-direction: column;
  padding: 0.4rem 0.6rem;
  border: 1px solid var(--line-strong);
  border-radius: 10px;
  background: var(--bg-soft);
  box-shadow: var(--shadow);
  font-size: 0.86rem;
  line-height: 1.35;
  white-space: nowrap;
  transform: translateX(-50%);
  pointer-events: none;
}

.chart-tip-sub {
  font-size: 0.76rem;
}

.chart-axis {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.75rem;
  margin-top: 0.4rem;
}

/* The one direct label on the plot. A number on every bar is chaos and goes
   unread; the tooltip and the table carry the rest. */
.chart-peak {
  color: var(--accent-ink-70);
  font-weight: 600;
}

.chart-empty {
  margin: 0;
  padding: 1.75rem 0;
  text-align: center;
}

/* The table twin. Every chart has one — a tooltip is the nicest way to read a
   value and must never be the only way. */
.chart-table {
  width: 100%;
  margin-top: 0.5rem;
  border-collapse: collapse;
  font-size: 0.84rem;
}

.chart-table th,
.chart-table td {
  padding: 0.35rem 0.5rem;
  border-top: 1px solid var(--line);
  text-align: left;
}

.chart-table th {
  border-top: 0;
  color: var(--ink-soft);
  font-size: 0.76rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

/* Tabular figures where numbers stack in a column and have to line up — and
   deliberately not on the hero figure, where equal-width digits read loose. */
.chart-table .num,
.ai-break-value,
.ai-call-cost {
  font-variant-numeric: tabular-nums;
  text-align: right;
}

/* ------------------------------------------------------------- ai keys --- */

.ai-keys {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 21rem), 1fr));
}

/* Prose in a panel that ends with prose: the panel already carries the bottom
   padding, so a trailing paragraph margin would double it. */
.ai-ref {
  max-width: 62ch;
  margin: 0;
  line-height: 1.55;
}

/* A refresh holds the previous render rather than blanking to a skeleton: the
   numbers are still true a second later, and a layout jump on every poll is
   worse than a slightly stale figure. */
.ai-keys.busy {
  opacity: 0.55;
  transition: opacity 0.2s ease;
}

.ai-key {
  padding: 1rem;
  border: 1px solid var(--line);
  border-radius: 14px;
  background: rgba(var(--ink-rgb), 0.02);
}

.ai-key-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.6rem;
}

.ai-key-name {
  margin: 0;
  font-size: 1rem;
}

.ai-hero {
  display: flex;
  align-items: baseline;
  gap: 0.4rem;
  margin-top: 0.5rem;
}

/* Proportional figures, not tabular: equal-width digits make a large standalone
   number look gappy, and there is nothing under it to line up with. */
.ai-hero-figure {
  font-size: clamp(1.9rem, 6vw, 2.5rem);
  font-weight: 700;
  font-variant-numeric: proportional-nums;
  letter-spacing: -0.03em;
  line-height: 1.05;
}

.ai-hero-figure.bad {
  color: var(--bad);
}

.ai-hero-figure.warn {
  color: var(--warn);
}

.ai-hero-since,
.ai-hero-by {
  margin: 0.3rem 0 0;
}

.ai-hero-since.warn {
  color: var(--warn);
}

.ai-spend {
  margin: 0.85rem 0 0;
  padding-top: 0.75rem;
  border-top: 1px solid var(--line);
  font-size: 0.92rem;
}

/* Where a key's money went — a ranking, so the bar length is the comparison and
   there is no second chart. */
.ai-break {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
  margin-top: 0.4rem;
}

.ai-break-row {
  display: grid;
  grid-template-columns: minmax(4rem, 1fr) 2fr auto;
  align-items: center;
  gap: 0.5rem;
  font-size: 0.84rem;
}

.ai-break-name {
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ai-break-fill {
  height: 0.4rem;
  min-width: 2px;
  border-radius: 999px;
  background: rgba(var(--accent-rgb), 0.4);
}

.ai-break-sub {
  grid-column: 1 / -1;
  margin-top: -0.15rem;
}

.ai-break-head {
  margin: 0.9rem 0 0;
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.04em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.ai-table {
  margin-top: 0.85rem;
  font-size: 0.9rem;
}

.ai-table summary {
  cursor: pointer;
  color: var(--ink-soft);
  font-weight: 600;
}

.ai-table summary:hover {
  color: var(--ink);
}

/* Two things in one form, because they're the same act with different
   arithmetic — see creditForm in ai.js. */
.ai-credit {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.4rem;
  margin-top: 0.9rem;
  padding-top: 0.85rem;
  border-top: 1px solid var(--line);
}

.ai-credit select,
.ai-credit input {
  padding: 0.4rem 0.5rem;
  border: 1px solid var(--line-strong);
  border-radius: 9px;
  background: var(--bg-soft);
  color: var(--ink);
  font: inherit;
  font-size: 0.86rem;
}

.ai-credit-amount {
  width: 6rem;
}

.ai-credit-note {
  flex: 1;
  min-width: 6rem;
}

.ai-credit-dollar {
  font-size: 0.9rem;
}

.ai-purpose-group + .ai-purpose-group {
  margin-top: 1.1rem;
}

.ai-purpose-app {
  margin: 0 0 0.4rem;
  font-size: 0.78rem;
  font-weight: 600;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--ink-soft);
}

.ai-purpose {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.5rem;
  padding: 0.65rem 0;
  border-top: 1px solid var(--line);
}

.ai-purpose-title {
  font-weight: 600;
}

.ai-purpose-text {
  flex-basis: 100%;
  margin: 0;
  font-size: 0.88rem;
}

/* One line per call where there's room, wrapping to two where there isn't. The
   model name takes the slack, so the costs stay in a column you can read down
   — which is the only reason to list individual calls at all. */
.ai-call {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.1rem 0.55rem;
  padding: 0.45rem 0;
  border-top: 1px solid var(--line);
  font-size: 0.86rem;
}

.ai-call:first-child {
  border-top: 0;
}

.ai-call-app {
  font-weight: 600;
}

.ai-call-model {
  flex: 1;
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.ai-call-cost {
  min-width: 4.5rem;
}

.ai-call-when {
  min-width: 5rem;
  text-align: right;
}

.ai-call.failed .ai-call-app {
  color: var(--bad);
}

@media (max-width: 34rem) {
  /* Two lines on a phone: who and what, then what it cost and when. The model
     name is the thing to give up first — it's the same on nearly every row. */
  .ai-call-model {
    flex-basis: 100%;
    order: 3;
  }

  .ai-call-cost {
    order: 1;
    margin-left: auto;
  }

  .ai-call-when {
    order: 4;
    min-width: 0;
  }
}

/* ----------------------------------------------------------- one site --- */

/* Six tabs don't fit across a phone, and wrapping them pushed the panel's
   heading and its button onto three lines. One row that scrolls sideways
   instead: the first tabs stay put, the rest are a swipe away, and the header
   keeps its height whatever the app's page has to offer. */
.tabs {
  display: flex;
  gap: 0.25rem;
  min-width: 0;
  overflow-x: auto;
  scrollbar-width: none;
  -webkit-overflow-scrolling: touch;
  /* Padding so a focus ring on the first or last tab isn't clipped by the
     scroll container. */
  padding: 2px;
  margin: -2px;
}

.tabs::-webkit-scrollbar {
  display: none;
}

.tab {
  flex: none;
  /* 2.4rem tall on a phone: still a pill, but a tappable one. */
  min-height: 2.4rem;
  padding: 0.35rem 0.85rem;
  border: 1px solid transparent;
  border-radius: 999px;
  background: transparent;
  color: var(--ink-soft);
  font: inherit;
  font-size: 0.88rem;
  font-weight: 600;
  white-space: nowrap;
  cursor: pointer;
  transition: background 0.15s ease, color 0.15s ease, border-color 0.15s ease;
}

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

.tab:focus-visible {
  outline: 2px solid rgba(var(--accent-rgb), 0.55);
  outline-offset: 1px;
}

.tab.on {
  border-color: rgba(var(--accent-rgb), 0.35);
  background: rgba(var(--accent-rgb), 0.12);
  color: var(--accent-ink-75);
}

/* --------------------------------------------------------- the form ----- */

.wrap.narrow {
  max-width: 40rem;
}

.form {
  display: flex;
  flex-direction: column;
  gap: 1rem;
}

.field {
  display: flex;
  flex-direction: column;
  gap: 0.35rem;
  font-size: 0.88rem;
  font-weight: 600;
}

.field em {
  font-style: normal;
  font-weight: 400;
}

.field input,
.field select,
.field textarea {
  width: 100%;
  padding: 0.55rem 0.7rem;
  border: 1px solid var(--line-strong);
  border-radius: 10px;
  background: var(--bg-soft);
  color: var(--ink);
  font: inherit;
  font-weight: 400;
}

.field textarea {
  resize: vertical;
  min-height: 7rem;
}

.field input:focus-visible,
.field select:focus-visible,
.field textarea:focus-visible {
  outline: 2px solid rgba(var(--accent-rgb), 0.55);
  outline-offset: 1px;
  border-color: var(--accent);
}

.form-go {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 0.9rem;
}

.form-go .fineprint {
  flex: 1;
  min-width: 14rem;
  margin: 0;
}

/* The honeypot. Off-screen rather than display:none — a bot that skips hidden
   fields is a bot that skips this one, and the point is that it doesn't.
   Nothing focusable reaches it: it's tabindex=-1 and aria-hidden. */
.trap {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(10px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

@keyframes pulse {
  50% {
    opacity: 0.25;
  }
}

@media (prefers-reduced-motion: reduce) {
  * {
    animation: none !important;
    transition: none !important;
  }
}

/* ---------------------------------------------------------- dish pictures --
   The image audit page (imageaudit.html / imageaudit.js): six headline numbers,
   a grid of pictures with the verdict under each, and — for the thousands of
   dishes carrying no picture — a plain list instead of a grid of empty frames.

   Tokens only, so the dark palette above carries it without a rule of its own. */

.stats {
  display: grid;
  gap: 0.75rem;
  margin: 1.25rem 0;
  grid-template-columns: repeat(auto-fit, minmax(9.5rem, 1fr));
}

.stat {
  padding: 0.75rem 0.9rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--bg-soft);
}

.stat b {
  display: block;
  font-family: var(--mono);
  font-size: 1.5rem;
  font-weight: 600;
  line-height: 1.2;
}

.stat span {
  color: var(--ink-soft);
  font-size: 0.78rem;
}

.shots {
  display: grid;
  gap: 1rem;
  grid-template-columns: repeat(auto-fill, minmax(min(100%, 15rem), 1fr));
}

.shot-card {
  overflow: hidden;
  padding-bottom: 0.85rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--bg-soft);
}

/* A fixed frame so the grid doesn't reflow as lazy images arrive. */
.shot {
  aspect-ratio: 4 / 3;
  background: rgba(var(--ink-rgb), 0.05);
}

.shot img {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

.shot-gone {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  color: var(--ink-faint);
  font-size: 0.85rem;
}

.shot-card h3 {
  margin: 0.7rem 0.85rem 0.2rem;
  font-size: 1rem;
}

.shot-id,
.shot-meta {
  margin: 0 0.85rem 0.2rem;
  color: var(--ink-faint);
  font-size: 0.72rem;
  word-break: break-all;
}

.shot-why {
  margin: 0.45rem 0.85rem 0.2rem;
  font-size: 0.82rem;
  color: var(--ink-soft);
}

.shot-src {
  display: block;
  margin: 0.35rem 0.85rem 0;
  color: var(--ink-faint);
  font-size: 0.7rem;
  word-break: break-all;
}

/* The switch between one app's dish pictures and its item pictures. Links
   rather than buttons — the subject is in the URL so a report can be sent to
   somebody — which needs the underline off and the pill's own alignment on. */
.tabs a.tab {
  display: inline-flex;
  align-items: center;
  text-decoration: none;
}

/* .tabs pulls itself up by 2px so a focus ring isn't clipped; what follows this
   one can be a notice with no margin of its own, and the two would touch. */
#subjects {
  margin-bottom: 0.9rem;
}

/* --- scoring one picture out of 5 -------------------------------------------
   Five pips, a way to take the score back, and a line for why. Sized for a
   thumb: there are a few hundred of these to get through and most of that
   happens on a phone, so every target here clears 2.25rem. */
.score-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem;
  margin: 0.6rem 0.85rem 0;
}

.score {
  display: flex;
  gap: 0.2rem;
}

.pip {
  min-width: 2.25rem;
  min-height: 2.25rem;
  padding: 0;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--bg);
  color: var(--ink-soft);
  font: inherit;
  font-family: var(--mono);
  font-size: 0.82rem;
  cursor: pointer;
  transition: background 0.12s ease, color 0.12s ease, border-color 0.12s ease;
}

.pip:hover {
  border-color: var(--accent-line-45);
  color: var(--ink);
}

.pip:focus-visible {
  outline: 2px solid rgba(var(--accent-rgb), 0.55);
  outline-offset: 1px;
}

.pip.on {
  border-color: rgba(var(--accent-rgb), 0.55);
  background: rgba(var(--accent-rgb), 0.16);
  color: var(--accent-ink-78);
  font-weight: 600;
}

/* Only there once there's a score to take back — an always-visible "clear" on
   an unscored picture is a sixth pip that does nothing. */
.pip.clear {
  min-width: 2rem;
  font-size: 1rem;
  line-height: 1;
}

.score-why {
  flex: 1 1 8rem;
  min-width: 0;
  min-height: 2.25rem;
  padding: 0.3rem 0.5rem;
  border: 1px solid var(--line);
  border-radius: 8px;
  background: var(--bg);
  color: var(--ink);
  font: inherit;
  font-size: 0.8rem;
}

.score-why:focus-visible {
  outline: 2px solid rgba(var(--accent-rgb), 0.55);
  outline-offset: 1px;
}

/* Whether the last press reached the box, and where the card counts now. */
.score-note {
  flex: 1 1 100%;
  color: var(--ink-faint);
  font-size: 0.72rem;
}

.score-note.bad {
  color: var(--bad);
}

/* Dimmed while the save is in flight, so a second press on a slow connection
   reads as "already going" rather than as nothing having happened. The control
   stays live: the score has already been painted, and blocking the way back
   would be the wrong half to freeze. */
.score-row .score.saving {
  opacity: 0.6;
}

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

.misses li {
  display: grid;
  gap: 0.75rem;
  align-items: baseline;
  padding: 0.4rem 0.2rem;
  border-bottom: 1px solid var(--line);
  grid-template-columns: minmax(8rem, 2fr) 4rem minmax(10rem, 1fr);
  font-size: 0.85rem;
}

.miss-name {
  font-weight: 600;
}

.miss-no,
.misses code {
  color: var(--ink-faint);
  font-size: 0.75rem;
}

/* On a phone the id is the first thing worth dropping: it's for copying into a
   query, which is not what anybody is doing on a phone. */
@media (max-width: 34rem) {
  .misses li {
    grid-template-columns: 1fr auto;
  }

  .misses code {
    display: none;
  }
}

/* ------------------------------------------------------ what it all costs --

   The market snapshot at /ai/models (static/market.js, admin/market.conf).
   Reference material rather than anything about this box, so it borrows the
   panel's shells — .panel, .tabs, .stat, .chart-table — and adds only the
   things a price-and-capability table genuinely needs that nothing else here
   has: a capability ramp, a scatter plot, and a value bar. */

/* The four capability bands, as one hue at four lightnesses rather than four
   hues. chart.js keeps a single colour on purpose — a palette is a thing to get
   colourblind-safety wrong with, and the accent is doing identity work. A tier
   ladder is ordinal, though, so an ordered ramp of the accent says "stronger"
   without introducing a second meaning for a colour. Assigned by position in
   market.conf's [tiers], top band first (tierVar in market.js). */
   The two middle rungs are blends of the ends, so they sit with the palette
   blocks at the top of this file where both ends are defined, rather than here
   — a blend can't be written where it's used any more. */
:root {
  --tier-1: var(--accent);
  --tier-4: var(--ink-faint);
}

.plot,
.ladder,
.findings,
.table-scroll {
  margin-top: 0.9rem;
}

/* Tier cards ------------------------------------------------------------- */

.ladder {
  display: grid;
  gap: 0.75rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 14rem), 1fr));
}

.tier-card {
  display: flex;
  flex-direction: column;
  gap: 0.5rem;
  padding: 0.85rem 0.9rem;
  border: 1px solid var(--line);
  /* The band's own step of the ramp, as a rule down the side: it is the only
     place on the page where the colour is the label. */
  border-left: 3px solid var(--tc, var(--accent));
  border-radius: var(--radius);
  background: var(--bg-soft);
}

.tier-card h3 {
  margin: 0;
  color: var(--tc, var(--accent));
  font-size: 0.82rem;
  font-weight: 700;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.tier-def {
  margin: 0;
  color: var(--ink-soft);
  font-size: 0.84rem;
  line-height: 1.45;
}

.tier-pick {
  margin-top: auto;
  padding-top: 0.6rem;
  border-top: 1px dashed var(--line-strong);
}

.tier-pick .lbl {
  display: block;
  color: var(--ink-faint);
  font-size: 0.7rem;
  letter-spacing: 0.06em;
  text-transform: uppercase;
}

.tier-pick .who {
  display: block;
  font-weight: 650;
}

.tier-pick .cost {
  color: var(--ink-soft);
  font-size: 0.78rem;
}

/* The scatter ------------------------------------------------------------ */

/* SVG in the DOM, so every colour below is a token and the dark palette needs
   no rule of its own — see the note at the top of market.js. */
.plot-svg {
  display: block;
  width: 100%;
  height: auto;
  overflow: visible;
}

.plot-grid {
  stroke: var(--line);
  stroke-width: 1;
}

.plot-tick,
.plot-axis,
.plot-label {
  fill: var(--ink-faint);
  font-family: var(--mono);
  font-size: 11px;
}

.plot-tick {
  text-anchor: middle;
}

.plot-tick.end,
.plot-label.end {
  text-anchor: end;
}

.plot-axis {
  letter-spacing: 0.08em;
  text-anchor: middle;
}

.plot-label {
  fill: var(--ink);
  font-family: inherit;
  font-size: 11.5px;
  font-weight: 600;
}

.plot-frontier {
  fill: none;
  stroke: var(--accent);
  stroke-dasharray: 5 4;
  stroke-width: 1.5;
}

.plot-dot {
  fill: var(--tc, var(--accent));
  /* Unscored models aren't here at all, so the only thing opacity has to say is
     "on the frontier or behind it". */
  opacity: 0.55;
}

.plot-dot.on {
  opacity: 1;
  stroke: var(--bg-soft);
  stroke-width: 2;
}

/* The two tables --------------------------------------------------------- */

/* Sixty-three rows and ten columns do not fold onto a phone, and a table that
   wraps its numbers is a table nobody can scan a column of. So it scrolls
   inside its own box — sideways for the columns, and down, which is what keeps
   the header row stuck to the top of the numbers it names. The page never
   scrolls sideways, and a panel below this one is a scroll away rather than
   sixty rows away. */
.table-scroll {
  max-height: min(34rem, 75vh);
  overflow: auto;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--bg-soft);
}

.chart-table.matrix,
.chart-table.picks {
  margin-top: 0;
}

/* The matrix is ten columns of figures and never wraps one: a column of
   numbers you can't run your eye down is the one thing this table is for, so it
   scrolls sideways instead. */
.chart-table.matrix {
  min-width: 52rem;
}

/* The picks table is the opposite — five short labels and a sentence — so it
   wraps to fit and only the price holds its line. */
.chart-table.picks {
  min-width: 34rem;
}

.chart-table.matrix th,
.chart-table.matrix td,
.chart-table.picks th,
.chart-table.picks td {
  padding: 0.45rem 0.7rem;
}

.chart-table.matrix th,
.chart-table.matrix td,
.chart-table.picks td.mono,
.chart-table.picks td.unit {
  white-space: nowrap;
}

.chart-table.matrix thead th,
.chart-table.picks thead th {
  position: sticky;
  top: 0;
  z-index: 1;
  border-bottom: 1px solid var(--line-strong);
  background: var(--bg-soft);
}

.chart-table tbody tr:hover td {
  background: rgba(var(--ink-rgb), 0.03);
}

/* A band's heading inside the table rather than four tables: the ordering only
   means anything read top to bottom, cheapest-in-tier first. */
.chart-table tr.grp td {
  background: rgba(var(--accent-rgb), 0.1);
  color: var(--ink);
  font-size: 0.74rem;
  font-weight: 700;
  letter-spacing: 0.08em;
  text-transform: uppercase;
}

.chart-table tr.grp:hover td {
  background: rgba(var(--accent-rgb), 0.1);
}

.chart-table .name {
  color: var(--ink);
  font-weight: 620;
}

/* The vendor's own asterisk — an intro rate, a context ceiling, open weights.
   Beside the model rather than in a footnote: several of them are the
   difference between two prices in the same row. It doesn't wrap: the column
   widens instead, which keeps every row one line tall and the numbers to the
   right of them scannable as a column. */
.flag {
  margin-left: 0.5rem;
  color: var(--accent);
  font-size: 0.72rem;
  white-space: nowrap;
}

.flag.todo {
  color: var(--warn);
}

/* Why a pick is the pick is a sentence, and the one column on either table that
   is allowed to wrap. Every other column is shrunk to its own content (the 1%
   trick) so the sentence gets the rest of the width — it is the column somebody
   is actually reading, and having to scroll sideways to reach it would make the
   table a list of model names. */
.chart-table.picks td:not(.why),
.chart-table.picks th:not(:last-child) {
  width: 1%;
}

.chart-table td.why {
  min-width: 18rem;
  color: var(--ink-soft);
  font-size: 0.82rem;
  white-space: normal;
}

.chart-table td.value {
  display: flex;
  gap: 0.5rem;
  align-items: center;
}

/* A track of fixed width, so the bar's length means the same thing in every row
   and the figure beside it always lands in the same place. A bar sized as a
   percentage of the cell moves the number, which is the one part of the column
   that has to stay in a line. */
.value-track {
  display: inline-block;
  flex: none;
  width: 4.5rem;
  height: 7px;
  border-radius: 2px;
  background: rgba(var(--ink-rgb), 0.08);
}

.value-bar {
  display: block;
  height: 100%;
  border-radius: 2px;
  background: rgba(var(--accent-rgb), 0.55);
}

/* Findings and caveats --------------------------------------------------- */

.findings {
  display: grid;
  gap: 0.75rem;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 17rem), 1fr));
}

.find {
  padding: 0.85rem 0.9rem;
  border: 1px solid var(--line);
  border-radius: var(--radius);
  background: var(--bg-soft);
}

.find .num {
  display: block;
  color: var(--accent);
  font-size: 1.35rem;
  font-weight: 600;
  letter-spacing: -0.02em;
}

.find h4 {
  margin: 0.2rem 0 0.35rem;
  font-size: 0.95rem;
}

.find p {
  margin: 0;
  color: var(--ink-soft);
  font-size: 0.86rem;
  line-height: 1.5;
}

.caveats {
  margin: 0.9rem 0 0;
  padding-left: 1.1rem;
}

.caveats li {
  margin-bottom: 0.55rem;
  color: var(--ink-soft);
  font-size: 0.88rem;
  line-height: 1.55;
}

.caveats li:last-child {
  margin-bottom: 0;
}

/* The head of a panel that carries filters wraps rather than squeezing them:
   three switchers and a heading don't share a line on a phone. */
@media (max-width: 40rem) {
  .chart-table.matrix,
  .chart-table.picks {
    min-width: 44rem;
  }

  .chart-table td.why {
    min-width: 16rem;
  }
}
