/* =============================================================================
   Nimbus control panel — design system.
   Operator console: FQDNs/container names in monospace as first-class identity,
   a teal "signal" accent (network/TLS), status signal-dots as the signature.
   Custom CSS with design tokens (no framework) per architecture-principles
   laravel/design-system.md.
   ============================================================================= */

:root {
  /* `light dark` means: follow the device unless an explicit choice overrides it
     below. It also makes native controls, scrollbars and form widgets follow the
     theme, which matters on a console that is mostly inputs and tables. */
  color-scheme: light dark;

  /* Every token carries BOTH values on one line via light-dark(). There is no
     second dark-mode block to keep in sync — that duplication was how the dark
     palette drifted from the light one in the first place. Left value = light. */

  /* Surfaces (cool off-white — deliberately not cream) */
  --bg:        light-dark(#eef1f4, #0e1418);
  --surface:   light-dark(#ffffff, #151d23);
  --surface-2: light-dark(#f6f8fa, #1a242b);
  --ink:       light-dark(#141a20, #e8eef2);
  --muted:     light-dark(#5a6773, #93a1ac);
  --line:      light-dark(#dde3e9, #263139);

  /* Signal accent — teal (network/TLS), not acid-green/terracotta */
  --signal:       light-dark(#0e7c86, #35b3bd);
  --signal-strong:light-dark(#0a5c64, #63cdd6);
  --signal-tint:  light-dark(#e3f2f3, #10312f);
  /* Text ON a --signal fill. White works on the dark teal and fails on the light
     one (~2.2:1), so the pairing is a token rather than a literal in .btn. */
  --on-signal:    light-dark(#ffffff, #062a2e);

  /* Semantic status */
  --up:       light-dark(#1f9d57, #34c46f);
  --down:     light-dark(#d64545, #ef6b6b);
  --warn:     light-dark(#c17d12, #e0a44a);
  --starting: light-dark(#2f6fed, #5a8cf5);
  --idle:     #8a94a0;

  /* Type */
  --font-ui:   ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-mono: ui-monospace, "SF Mono", "JetBrains Mono", Menlo, Consolas, monospace;

  --radius: 7px;
  --radius-sm: 5px;
  /* light-dark() takes colours, not shadow lists — so the geometry is shared and
     only the two colours switch. */
  --shadow-near: light-dark(rgba(20,26,32,.06), rgba(0,0,0,.40));
  --shadow-far:  light-dark(rgba(20,26,32,.05), rgba(0,0,0,.35));
  --shadow: 0 1px 2px var(--shadow-near), 0 5px 18px var(--shadow-far);
  --wire: linear-gradient(90deg, var(--signal), #4bb8bf 60%, transparent);
}

/* An explicit choice. The server writes data-theme onto <html> from the cookie,
   so the palette is right in the first bytes the browser parses — no flash, and
   no dependence on JavaScript. "Follow the device" is the ABSENCE of the
   attribute, not a third value here. */
:root[data-theme="light"] { color-scheme: light; }
:root[data-theme="dark"]  { color-scheme: dark; }

* { box-sizing: border-box; }

html, body { margin: 0; padding: 0; }

body {
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-ui);
  font-size: 15px;
  line-height: 1.5;
  -webkit-font-smoothing: antialiased;
}

code, .mono, .domain, .cname { font-family: var(--font-mono); }

/* A label/value list breaks its identifiers rather than pushing its card wide —
   at EVERY width, not only on a phone. `.card` is `overflow: hidden`, so on a
   desktop layout an over-long token is not a scrollbar, it is silently cut off:
   a stack's CodeCommit URL measured 921px against a 768px viewport with nothing
   on screen to say the rest existed. Breaking only happens when it must.

   Deliberately NOT applied to table cells. A `table.grid` answers the same
   problem by SCROLLING inside `.table-scroll`, and the two answers cannot both
   apply: `anywhere` drops the table's min-content width to almost nothing, so
   `width: 100%` then wins and every domain in the column is shredded into a
   five-line stack instead of the table being wide. Below 760px the table stops
   being a table (`.stack-sm`) and breaking is right again — see that block. */
.detail dd, .kv dd { overflow-wrap: anywhere; }

a { color: var(--signal-strong); text-decoration: none; }
a:hover { text-decoration: underline; }

/* One keyboard-focus treatment for everything that takes focus. Declared once,
   here, so a new control cannot ship without a visible focus ring. */
a:focus-visible, button:focus-visible, .btn:focus-visible,
summary:focus-visible, [tabindex]:focus-visible {
  outline: 2px solid var(--signal); outline-offset: 2px; border-radius: var(--radius-sm);
}

/* The mark: a besom (resources/views/components/brand-mark.blade.php). Handle in
   currentColor, bristles in --signal, so it takes the colour of wherever it sits. */
.brand-mark { flex: none; display: block; }

/* Visually hidden but available to a screen reader. */
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap; border: 0;
}

/* ---- Theme switch --------------------------------------------------------
   A segmented three-way control: follow the device / light / dark. The states
   are discs because that is already this design's vocabulary for state (.dot) —
   no icon set is introduced for one control. Sized to clear a 44px touch target
   including the wrapper's padding. */
.theme-switch {
  display: inline-flex; flex: none; align-items: center; gap: 2px;
  padding: 2px; border-radius: 999px;
  background: var(--surface-2); border: 1px solid var(--line);
}
.theme-switch-option {
  display: inline-flex; align-items: center; justify-content: center;
  width: 30px; height: 30px; padding: 0;
  background: none; border: 0; border-radius: 999px;
  color: var(--muted); cursor: pointer;
  transition: background .12s, color .12s;
}
.theme-switch-option:hover { color: var(--ink); background: var(--surface); }
.theme-switch-option[aria-pressed="true"] {
  background: var(--surface); color: var(--signal-strong);
  box-shadow: 0 1px 2px var(--shadow-near);
}
.theme-switch-option:focus-visible { outline-offset: -2px; }

.theme-disc {
  display: block; width: 13px; height: 13px; border-radius: 50%;
  border: 1.5px solid currentColor;
}
/* Half-filled — the device decides. */
.theme-disc-system { background: linear-gradient(90deg, currentColor 50%, transparent 50%); }
.theme-disc-light  { background: transparent; }
.theme-disc-dark   { background: currentColor; }

/* ---- App shell ----------------------------------------------------------- */
.app { display: grid; grid-template-columns: 240px 1fr; min-height: 100vh; }

/* ---- The narrow-screen chrome --------------------------------------------
   Above 760px none of this exists: the rail is the rail, and the topbar, the
   hamburger and the scrim are all display:none. Below it the rail becomes a
   drawer and these three carry it. Declared HERE, out of the media query, only
   so the "off" state is stated once rather than implied. */
.topbar, .rail-scrim, .rail-close { display: none; }

/* The bars and the cross are drawn from box-shadow, not from an icon set — the
   same decision the theme control's discs made (design-language §6). The panel
   has one drawn icon, the mark, and a hamburger is not worth becoming the
   second. Both are 18px wide and share the toggle's currentColor. */
.nav-bars, .nav-cross { display: block; width: 18px; height: 2px; position: relative; }
.nav-bars {
  background: currentColor; border-radius: 2px;
  box-shadow: 0 -6px 0 currentColor, 0 6px 0 currentColor;
}
.nav-cross::before, .nav-cross::after {
  content: ""; position: absolute; left: 0; top: 0;
  width: 18px; height: 2px; background: currentColor; border-radius: 2px;
}
.nav-cross::before { transform: rotate(45deg); }
.nav-cross::after  { transform: rotate(-45deg); }

/* Both drawer controls are the same 44px target, which is the floor for a
   thumb — .btn's 34px is right for a mouse and wrong for this. */
.nav-toggle, .rail-close {
  align-items: center; justify-content: center;
  width: 44px; height: 44px; flex: none; padding: 0;
  background: none; border: 0; border-radius: var(--radius-sm);
  color: var(--ink); cursor: pointer;
}
.nav-toggle:hover, .rail-close:hover { background: var(--surface-2); }

.rail {
  background: var(--surface);
  border-right: 1px solid var(--line);
  padding: 20px 16px;
  display: flex; flex-direction: column; gap: 6px;
}
.rail .brand {
  display: flex; align-items: center; gap: 10px;
  font-weight: 650; letter-spacing: .02em; font-size: 16px;
  padding: 6px 8px 16px; color: var(--ink);
}
.rail .brand:hover { text-decoration: none; }
.rail nav a {
  display: flex; align-items: center; gap: 10px;
  padding: 9px 10px; border-radius: var(--radius-sm);
  color: var(--ink); font-weight: 500;
}
.rail nav a:hover { background: var(--surface-2); text-decoration: none; }
.rail nav a.active { background: var(--signal-tint); color: var(--signal-strong); }
/* Three groups — what is hosted, who may reach it, how the platform is set up.
   Separated by air and a hairline rather than by headings: a heading per group
   would be three more words competing with eight links, and the grouping is the
   whole message. The rule is on the group ABOVE, so a group a role cannot see
   takes its separator with it and never leaves a stray line behind. */
.rail nav { display: flex; flex-direction: column; gap: 6px; }
.rail nav .nav-group { display: flex; flex-direction: column; gap: 2px; }
.rail nav .nav-group + .nav-group {
  margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line);
}
.rail .spacer { flex: 1; }
.rail-theme { padding: 8px 8px 10px; }
.rail .who { color: var(--muted); font-size: 13px; padding: 8px; border-top: 1px solid var(--line); }
.rail .who-out { margin-top: 8px; }

/* min-width:0 because a grid item refuses to shrink below its content's
   min-content width by default, and this column holds things with a wide one —
   a `wrap="off"` textarea holding a deploy log, a long unbreakable identifier.
   Without it those push the COLUMN wider than the viewport and the whole page
   scrolls sideways; with it they scroll inside themselves, which is where the
   scrolling belongs. */
.main { padding: 28px 32px; max-width: 1100px; min-width: 0; }

/* ---- Page header --------------------------------------------------------- */
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 22px; }
.page-head h1 { font-size: 22px; margin: 0; font-weight: 640; letter-spacing: -.01em; }
.page-head .sub { color: var(--muted); font-size: 14px; margin-top: 2px; }
.eyebrow { text-transform: uppercase; letter-spacing: .12em; font-size: 11px; color: var(--signal-strong); font-weight: 600; }

/* ---- Cards / tables ------------------------------------------------------ */
.card {
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden;
}
.card + .card { margin-top: 18px; }
/* A card is followed either by another card, by actions that act on it, or by a
   footnote about it. All three get the same air, declared once. */
.card + .btn-row, .card + p.hint, .btn-row + p.hint { margin-top: 14px; }
/* A single form reads better short than full-width. */
.card.narrow { max-width: 560px; }
.card .card-h { padding: 14px 18px; border-bottom: 1px solid var(--line); font-weight: 600; font-size: 14px; }
.card .card-b { padding: 18px; }

/* Sub-heading inside a card body, and the same treatment for the create form's
   settings group. Every page that needed one was inventing it locally. */
.card-b h3, .alert h3, .field-group-title {
  font-size: 14px; font-weight: 640; letter-spacing: -.005em; margin: 22px 0 10px;
}
.card-b > :first-child, .alert > :first-child { margin-top: 0; }
.right { text-align: right; }
.muted { color: var(--muted); }

table.grid { width: 100%; border-collapse: collapse; }
table.grid th {
  text-align: left; font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
  color: var(--muted); font-weight: 600; padding: 12px 18px; border-bottom: 1px solid var(--line);
}
table.grid td { padding: 14px 18px; border-bottom: 1px solid var(--line); vertical-align: middle; }
table.grid tr:last-child td { border-bottom: 0; }
table.grid tr.row:hover td { background: var(--surface-2); }
/* Two column roles, opted into per table — an OBJECT list (sites, routes) only.
   A data table is left alone: its cells are long paths and snapshot ids that
   have to be free to wrap.

   `col-grow` is the identity column — the domain, the address, the name — and
   it absorbs all the spare width. `col-state` is everything beside it: short,
   known-length tokens that must never be squeezed. Both are needed TOGETHER,
   and that is the part that is easy to get wrong: col-grow alone hands the
   identity column every spare pixel, which is exactly how a two-word heading
   ("Site type") and a hyphenated value ("web-base") end up broken across two
   lines in a column nothing was stopping from being wider. */
table.grid th.col-grow, table.grid td.col-grow { width: 100%; }
table.grid th.col-state, table.grid td.col-state { white-space: nowrap; }
.domain { font-size: 14.5px; font-weight: 550; }
.cname { color: var(--muted); font-size: 12.5px; }

/* ---- Status signal-dot (the signature) ----------------------------------- */
.signal { display: inline-flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 550; white-space: nowrap; }
.dot { width: 9px; height: 9px; border-radius: 50%; background: var(--idle); position: relative; flex: none; }
.signal.up    .dot { background: var(--up); }
.signal.down  .dot { background: var(--down); }
.signal.starting .dot { background: var(--starting); }
.signal.up    { color: var(--up); }
.signal.down  { color: var(--down); }
.signal.starting { color: var(--starting); }
.signal.maintenance .dot { background: var(--warn); }
.signal.maintenance { color: var(--warn); }
/* gentle pulse only for healthy — motion reserved for the "all good" signal */
.signal.up .dot::after {
  content: ""; position: absolute; inset: -4px; border-radius: 50%;
  border: 2px solid var(--up); opacity: .5; animation: pulse 2.4s ease-out infinite;
}
@keyframes pulse { 0% { transform: scale(.6); opacity: .55; } 100% { transform: scale(1.5); opacity: 0; } }
@media (prefers-reduced-motion: reduce) { .signal.up .dot::after { animation: none; } }

/* ---- Publish state chip -------------------------------------------------- */
/* nowrap because a pill that wraps stops reading as one token — a narrow Owner
   column broke "0 Domains" across two lines inside the rounded edge. */
.chip { display: inline-block; white-space: nowrap; padding: 3px 9px; border-radius: 999px; font-size: 12px; font-weight: 600; border: 1px solid var(--line); }
.chip.public  { background: var(--signal-tint); color: var(--signal-strong); border-color: transparent; }
.chip.private { background: var(--surface-2); color: var(--muted); }
.chip.zero    { background: transparent; color: var(--muted); border-style: dashed; }
/* Something the operator should see but cannot fix from here (DNS not resolving yet). */
.chip.warn    { color: var(--warn); border-color: color-mix(in srgb, var(--warn) 40%, var(--line)); }

/* ---- Buttons ------------------------------------------------------------- */
.btn {
  display: inline-flex; align-items: center; gap: 7px; cursor: pointer;
  font: inherit; font-weight: 560; font-size: 13.5px;
  padding: 8px 14px; border-radius: var(--radius-sm); border: 1px solid var(--line);
  background: var(--surface); color: var(--ink); transition: background .12s, border-color .12s;
}
.btn:hover { background: var(--surface-2); text-decoration: none; }
.btn.primary { background: var(--signal); border-color: var(--signal); color: var(--on-signal); }
.btn.primary:hover { background: var(--signal-strong); border-color: var(--signal-strong); }
.btn.danger { color: var(--down); border-color: color-mix(in srgb, var(--down) 40%, var(--line)); }
.btn.danger:hover { background: color-mix(in srgb, var(--down) 12%, var(--surface)); }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn.block { width: 100%; justify-content: center; }
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
/* A cell holding more than one action still reads as a row of buttons. */
table.grid td .btn-row { justify-content: flex-end; }

/* ---- Action bar: site-level actions, held apart from the settings tabs -----
   A bordered surface strip above the tabs so the destructive + state actions
   read as "things you do to the site", separate from "settings you edit". */
.action-bar {
  display: flex; align-items: center; justify-content: space-between;
  flex-wrap: wrap; gap: 12px 16px;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); box-shadow: var(--shadow);
  padding: 12px 16px; margin-bottom: 20px;
}
.action-bar .action-group { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; }
/* The destructive group keeps the right edge even when the bar WRAPS. Without
   this it is `justify-content: space-between` that puts it there, and that only
   works while both groups share a line — the moment they do not (a narrow
   desktop window), Delete lands at the left margin under the safe verbs, which
   is the one place it should never be. */
.action-bar .action-group:last-child { margin-left: auto; }
.action-bar form { margin: 0; }

/* A NAMED cluster inside the bar: verbs that are alternative answers to one
   question (Restart / Recreate — keep the container or throw it away). The
   label is the whole point, so a cluster of one is never built: a name on a
   single verb is decoration, and the plain .action-group already covers it.

   The metrics are tight on purpose. The bar wraps (flex-wrap), and a roomier
   cluster pushed Delete onto a second line just above the 760px breakpoint —
   a group that costs the bar a whole row is not worth the row. */
.action-cluster {
  display: flex; align-items: center; gap: 6px;
  padding: 4px 8px; border: 1px solid var(--line);
  border-radius: var(--radius); background: var(--surface-2);
}
.action-cluster > .cluster-label {
  text-transform: uppercase; letter-spacing: .07em;
  font-size: 10.5px; font-weight: 650; color: var(--muted);
  padding: 0 2px; white-space: nowrap;
}
/* The cluster sits on --surface-2, so its buttons come back up to --surface to
   keep the figure/ground relationship they have everywhere else in the bar. */
.action-cluster .btn { background: var(--surface); }
.action-cluster .btn:hover { background: var(--bg); }

/* ---- Tabs: underline strip. Active tab carries the brand wire gradient,
   tying the settings navigation to the mark in the rail and the auth card. */
/* The strip scrolls at EVERY width, not only below 760px. Six site tabs measure
   ~847px, so between 761px and ~850px — an iPad mini, which takes the desktop
   layout — the last tab hung off the right edge and took the whole PAGE sideways
   with it. Wrapping is not the alternative: it would break the underline that
   ties the strip together, which is why the narrow-screen rules chose scrolling
   in the first place. This is that same decision, applied unconditionally. */
.tabs {
  display: flex; gap: 2px; align-items: stretch;
  border-bottom: 1px solid var(--line); margin-bottom: 20px;
  overflow-x: auto; scrollbar-width: none;
}
.tabs::-webkit-scrollbar { display: none; }
.tabs .tab {
  flex: none;
  position: relative; display: inline-flex; align-items: center; gap: 8px;
  padding: 11px 14px; margin-bottom: -1px;
  color: var(--muted); font-weight: 560; font-size: 14px;
  border-bottom: 2px solid transparent;
}
.tabs .tab:hover { color: var(--ink); text-decoration: none; }
.tabs .tab.active { color: var(--signal-strong); }
.tabs .tab.active::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -1px; height: 2px;
  background: var(--wire);
}
.tabs .tab:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; border-radius: var(--radius-sm); }
/* Static "deployments on" dot — reuses the signal colour, no pulse in a tab. */
.tab-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--up); flex: none; }

/* ---- The move banner (site-type preview) ----------------------------------
   "Moving from web-spa to laravel" is the whole reason that page exists, and it
   was a grey .sub under the title — the exact slot a reader has been trained to
   skim. So the two types are stated as identities (mono, §3), large, with the
   direction drawn between them instead of written. Not an .alert: nothing here
   is wrong, and the warning colours are spent on the dropped-settings block
   further down, which IS the bad news. */
.move {
  display: flex; align-items: center; gap: 20px; flex-wrap: wrap;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); box-shadow: var(--shadow);
  padding: 16px 20px; margin-bottom: 20px;
}
.move-side { display: flex; flex-direction: column; gap: 3px; min-width: 0; }
.move-label { font-size: 11.5px; font-weight: 600; letter-spacing: .06em; text-transform: uppercase; color: var(--muted); }
.move-value { font-size: 17px; font-weight: 600; overflow-wrap: anywhere; }
/* Drawn, not typed: an arrow glyph renders at a different weight in every font
   the stack falls back to, and this one has to line up with two rules of text. */
.move-arrow {
  flex: none; position: relative; width: 34px; height: 2px;
  background: var(--line); border-radius: 1px;
}
.move-arrow::after {
  content: ""; position: absolute; right: 0; top: 50%;
  width: 8px; height: 8px; transform: translateY(-50%) rotate(45deg);
  border-top: 2px solid var(--line); border-right: 2px solid var(--line);
}

/* ---- Sub-tabs (services page, Phase 7 S0) ---------------------------------
   A SECOND level of navigation under the site tabs: Environment / Databases /
   Locking all belong to one page now, and a second row of the same .tab styling
   would read as a competing primary nav. So: pills, quieter than .tabs, on the
   same 2px rhythm. Phase 5 adds a Backup entry here — the row is built from an
   array, so that is one element and not a re-layout. */
.subtabs {
  display: flex; flex-wrap: wrap; gap: 4px; align-items: center;
  margin: 0 0 20px;
}
.subtabs .subtab {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 6px 12px; border-radius: 999px;
  color: var(--muted); font-weight: 540; font-size: 13.5px;
  border: 1px solid transparent;
}
.subtabs .subtab:hover { color: var(--ink); background: var(--surface-2); text-decoration: none; }
.subtabs .subtab.active {
  color: var(--signal-strong); background: var(--surface-2); border-color: var(--line);
}
.subtabs .subtab:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; }

/* ---- Page-level note ------------------------------------------------------
   One sentence of orientation under a page head or a tab strip. Every tab was
   reaching for `p.hint` with its own margin; this is that, standardised. */
.page-note { color: var(--muted); font-size: 13.5px; margin: 0 0 18px; max-width: 72ch; }

/* ---- Forms --------------------------------------------------------------- */
.field { margin-bottom: 16px; }
.field label { display: block; font-size: 13px; font-weight: 600; margin-bottom: 6px; }
/* A checkbox and its label are one line, not a label above a control. */
.check { display: flex; align-items: center; gap: 9px; font-weight: 500; }
/* .field label wins on specificity, so the one-line variant restates both. */
.field label.check { margin-bottom: 0; font-size: 13.5px; font-weight: 500; }
.field label.check + .hint { margin-top: 6px; }
.field .hint { color: var(--muted); font-size: 12.5px; margin-top: 5px; }
.input, select.input {
  width: 100%; padding: 9px 11px; font: inherit; color: var(--ink);
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm);
}
.input:focus, select.input:focus { outline: 2px solid var(--signal); outline-offset: 1px; border-color: var(--signal); }
.input.mono { font-family: var(--font-mono); }
/* A value the panel derives and shows rather than asks for — the site folder on
   the create form. Quieter than an editable field so it does not invite typing,
   but NOT the disabled treatment: it still focuses, selects and copies, and a
   readonly field still posts. The cursor is the honest signal — text, because
   selecting is exactly what it is still for. */
.input[readonly] { background: var(--surface-2); color: var(--muted); border-style: dashed; }
.input[readonly]:focus { border-style: solid; }
.err { color: var(--down); font-size: 12.5px; margin-top: 5px; }

/* One control that narrows the list below it — the sites list's owner filter.
   Its own card above the table, not a row inside it, because it acts on the
   whole list (same reason .action-bar sits above .tabs).

   Horizontal because it is a single question with a single answer: label, the
   control, the verb. `.input` is width:100% by default, which is right for a
   form column and wrong here, so the select is sized to its content and capped
   — a filter is not a form field, it is a toolbar. Wraps to rows under the
   single 760px breakpoint like everything else. */
.filter-bar { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; }
.filter-bar label { font-size: 13px; font-weight: 600; }
.filter-bar select.input { width: auto; min-width: 220px; max-width: 100%; }
/* A control that only exists because scripting might not. The `.js` class is
   written by an inline script in the head, so this never flashes: the button is
   gone in the first bytes the browser paints, not after a script has run. */
.js .no-js-only { display: none; }

/* A choice between a handful of options where each one needs a line of copy
   saying when to pick it — a <select> can hold the values but not the reason,
   and the reason IS the decision (a stack's deploy key type: Ed25519 or the RSA
   that AWS CodeCommit insists on). Rows, not a horizontal strip, because the
   hint sets on its own line. The whole row is the label, so the hint is part of
   the hit target rather than text beside a target. */
.choice { display: grid; gap: 8px; }
.choice-option {
  display: flex; align-items: flex-start; gap: 10px; padding: 10px 12px; cursor: pointer;
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius-sm);
}
.choice-option:hover { background: var(--surface-2); }
/* .field label sets display:block and a bottom margin; the row variant restates
   both, the same way .check does. */
.field .choice-option { margin-bottom: 0; font-weight: 500; }
.choice-option input { margin-top: 2px; flex: none; accent-color: var(--signal); }
/* The chosen row carries the brand tint, so the state is legible without
   relying on the native radio dot alone. */
.choice-option:has(input:checked) { border-color: var(--signal); background: var(--signal-tint); }
.choice-option:has(input:focus-visible) { outline: 2px solid var(--signal); outline-offset: 1px; }
.choice-body { display: grid; gap: 3px; }
.choice-title { font-size: 13.5px; font-weight: 600; }
.choice-hint { color: var(--muted); font-size: 12.5px; font-weight: 400; line-height: 1.5; }

/* ---- Alerts / flashes ---------------------------------------------------- */
.alert { padding: 12px 16px; border-radius: var(--radius-sm); margin-bottom: 18px; font-size: 14px; border: 1px solid transparent; }
.alert.ok   { background: color-mix(in srgb, var(--up) 12%, var(--surface)); border-color: color-mix(in srgb, var(--up) 30%, transparent); color: color-mix(in srgb, var(--up) 60%, var(--ink)); }
.alert.bad  { background: color-mix(in srgb, var(--down) 12%, var(--surface)); border-color: color-mix(in srgb, var(--down) 30%, transparent); color: color-mix(in srgb, var(--down) 65%, var(--ink)); }
.alert.warn { background: color-mix(in srgb, var(--warn) 14%, var(--surface)); border-color: color-mix(in srgb, var(--warn) 34%, transparent); color: color-mix(in srgb, var(--warn) 62%, var(--ink)); }

/* ---- Detail list --------------------------------------------------------- */
.detail { display: grid; grid-template-columns: 160px 1fr; gap: 2px 18px; }
.detail dt { color: var(--muted); font-size: 13px; padding: 9px 0; }
.detail dd { margin: 0; padding: 9px 0; border-bottom: 1px solid var(--line); }
.detail dt { border-bottom: 1px solid var(--line); }
.detail dt:last-of-type, .detail dd:last-of-type { border-bottom: 0; }

/* ---- Empty state --------------------------------------------------------- */
.empty { text-align: center; padding: 56px 20px; color: var(--muted); }
.empty h3 { color: var(--ink); margin: 0 0 6px; font-weight: 600; }

/* ---- Access: the address as the key --------------------------------------
   On a network console, your IP *is* your credential — so on the unlock view it
   carries the weight a headline would, rather than sitting in a label row. */
.ipkey { margin-bottom: 6px; }
.ipkey-label {
  display: block; text-transform: uppercase; letter-spacing: .12em;
  font-size: 11px; color: var(--muted); font-weight: 600; margin-bottom: 6px;
}
.ipkey-value {
  display: block; font-size: 30px; font-weight: 600; letter-spacing: -.01em;
  line-height: 1.15; word-break: break-all;
}
.ipkey-state { color: var(--muted); font-size: 13.5px; margin: 10px 0 20px; }
.ipkey-state.admitted { color: var(--up); display: flex; align-items: center; gap: 8px; }

/* Locked chip — dashed edge reads as "gated", distinct from public/private. */
.chip.locked {
  background: var(--signal-tint); color: var(--signal-strong);
  border-color: color-mix(in srgb, var(--signal) 45%, transparent); border-style: dashed;
}

/* Lock panel row: statement on the left, the single action on the right. */
.lock-row {
  display: flex; align-items: center; justify-content: space-between; gap: 16px;
  padding: 14px 0 18px; margin-bottom: 6px; border-bottom: 1px solid var(--line);
}
.lock-row .hint { color: var(--muted); font-size: 12.5px; margin-top: 3px; }

textarea.input { resize: vertical; min-height: 84px; line-height: 1.55; }

/* The base rule. There were only `p.hint` and `.field .hint`, so a
   `<div class="hint">` — which is how a hint under a `dd` or beside a lock row
   is written — inherited nothing and set at body size in body colour, reading
   as a second sentence of the value rather than a note about it. */
.hint { color: var(--muted); font-size: 12.5px; }
p.hint { color: var(--muted); font-size: 12.5px; }

/* Stored credential — shown as a masked hint, never a value.
   The dashed edge is this panel's existing idiom for "gated" (.chip.locked,
   .chip.zero), reused here so a value you cannot read looks deliberate rather
   than broken or still loading. */
.cred-value {
  display: inline-block; font-size: 20px; font-weight: 600; letter-spacing: .02em;
  padding: 8px 14px; margin-bottom: 4px; border-radius: var(--radius-sm);
  border: 1px dashed color-mix(in srgb, var(--signal) 45%, transparent);
  background: var(--signal-tint); color: var(--signal-strong);
}
.cred-value.cred-empty {
  border-color: var(--line); background: var(--surface-2);
  color: var(--muted); font-weight: 500;
}

@media (max-width: 760px) {
  .lock-row { flex-direction: column; align-items: flex-start; }
  .ipkey-value { font-size: 24px; }
  .cred-value { font-size: 17px; word-break: break-all; }
}

/* ---- Auth screen --------------------------------------------------------- */
.auth-wrap { min-height: 100vh; display: grid; place-items: center; padding: 24px; }
.auth-theme { display: flex; justify-content: center; margin-top: 18px; }
.auth-card { width: 100%; max-width: 380px; }
.auth-card .top { height: 4px; background: var(--wire); }
.auth-card .card-b { padding: 26px 26px 28px; }
.auth-brand {
  display: flex; align-items: center; gap: 10px;
  font-weight: 650; font-size: 18px; margin-bottom: 4px; color: var(--ink);
}
.auth-brand:hover { text-decoration: none; }
.auth-brand:hover .brand-mark { color: var(--signal-strong); }
.auth-sub { color: var(--muted); font-size: 13.5px; margin: 0 0 22px; }

/* =============================================================================
   The host's front door ('/').

   Small on purpose. This is not the product's landing page — that is the
   separate static site at panel.nimbus-online.net. Every box that runs a panel
   serves this one, so it says what the box is, offers the way in, and stops.
   ============================================================================= */
.lp { background: var(--bg); }

.host { min-height: 100vh; display: grid; place-content: center; justify-items: center; gap: 22px; padding: 32px 24px; }

.host-card {
  width: 100%; max-width: 520px; text-align: center;
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); box-shadow: var(--shadow);
  padding: 38px 34px 30px;
}
/* No tile behind the mark: §2 of the design language says the mark is never set
   on a coloured shape, and a rule is worth nothing if its author skips it. */
.host-mark { display: inline-flex; margin-bottom: 16px; color: var(--ink); }
.host-card h1 { font-size: 21px; font-weight: 640; letter-spacing: -.01em; margin: 0; line-height: 1.3; }
.host-lede { color: var(--muted); font-size: 14.5px; line-height: 1.6; margin: 12px 0 0; }
.host-card .btn-row { justify-content: center; margin-top: 24px; }
.host-foot {
  color: var(--muted); font-size: 12.5px; line-height: 1.55;
  margin: 24px 0 0; padding-top: 18px; border-top: 1px solid var(--line);
}

@media (max-width: 760px) {
  .host-card { padding: 30px 22px 24px; }
}

/* ---- Choice dialog (SweetAlert2, themed) ---------------------------------
   A confirmation with more than two answers cannot be window.confirm(), which
   only ever returns yes/no — so the .env editor's "save, or save and restart?"
   question uses SweetAlert2, vendored at public/vendor/sweetalert2/ (never a
   CDN). Everything below exists to make it read as one of OUR dialogs: the
   library ships a look of its own, and a borrowed look is exactly what the
   design language spends its restraint avoiding.

   SweetAlert2 declares its own tokens on :root and wraps its selectors in
   :where(), so these overrides win on specificity alone and do not depend on
   stylesheet order — this file may load before or after the library's. The
   tokens are set on .swal2-container rather than :root so a declaration ON the
   element beats the library's inherited one either way. */
.swal2-container {
  --swal2-backdrop: light-dark(rgba(20, 26, 32, .38), rgba(0, 0, 0, .60));
  --swal2-width: 30em;
  --swal2-background: var(--surface);
  --swal2-color: var(--ink);
  --swal2-border: 1px solid var(--line);
  --swal2-border-radius: var(--radius);
  --swal2-padding: 0 0 18px;
  --swal2-title-padding: 18px 20px 0;
  --swal2-html-container-padding: 10px 20px 0;
  --swal2-actions-margin: 20px 0 0;
  --swal2-actions-padding: 0 20px;
  --swal2-actions-justify-content: flex-end;
  --swal2-actions-width: 100%;
  /* The panel's motion budget is three animations and this is not one of them
     (design-language §7): a 120ms fade, the same curve as a button hover, not
     the library's 300ms zoom-and-bounce entrance. */
  --swal2-show-animation: dialog-in .12s ease-out;
  --swal2-hide-animation: none;
  --swal2-action-button-transition: background .12s, border-color .12s;
  /* One focus ring, the global :focus-visible one. */
  --swal2-action-button-focus-box-shadow: none;
}
@keyframes dialog-in { from { opacity: 0 } to { opacity: 1 } }

.swal2-popup { font-family: var(--font-ui); box-shadow: var(--shadow); }
/* Matches .card .card-h — the dialog is a card that happens to float. */
.swal2-title {
  font-size: 15px; font-weight: 600; letter-spacing: -.005em; color: var(--ink);
}
/* Matches .field .hint: this text explains a consequence, so it is set as help
   text, not as body copy competing with the title. */
.swal2-html-container {
  font-size: 13px; line-height: 1.6; color: var(--muted); text-align: left; margin: 0;
}
.swal2-actions { gap: 8px; flex-wrap: wrap; }

/* The action buttons ARE .btn — same metrics, same tokens, same hover timing. */
.swal2-container .swal2-styled {
  font: inherit; font-weight: 560; font-size: 13.5px;
  padding: 8px 14px; margin: 0; border-radius: var(--radius-sm);
  box-shadow: inset 0 0 0 1px var(--line);
  background: var(--surface); color: var(--ink);
}
.swal2-container .swal2-styled:hover { background: var(--surface-2); }
/* One primary per dialog: the answer that finishes what the operator came to do. */
/* The typed-confirmation field, and its refusal. SweetAlert2 ships a light grey
   bar with a red disc for the validation message, which on a dark panel reads as
   a piece of another application that fell into ours. Restyled to the .err
   treatment the forms already use — same colour, same weight, no decoration. */
.swal2-container .swal2-input {
  font-family: var(--font-mono); font-size: 14px;
  background: var(--surface-2); color: var(--ink);
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  box-shadow: none; height: auto; padding: 10px 12px; margin: 4px 20px 0; width: calc(100% - 40px);
}
.swal2-container .swal2-input:focus { outline: 2px solid var(--signal); outline-offset: 1px; border-color: var(--signal); }
.swal2-container .swal2-input.swal2-inputerror { border-color: var(--down); box-shadow: none; }
.swal2-container .swal2-validation-message {
  background: transparent; color: var(--down);
  font-size: 13px; font-weight: 550; justify-content: flex-start;
  padding: 8px 20px 0; margin: 0;
}
/* The library's icon disc — the panel has no icon set (§6). */
.swal2-container .swal2-validation-message::before { display: none; }
/* A confirm that DESTROYS something wears the destructive treatment, matching
   the button that opened it. panel.js adds this when the form's own button is
   .btn.danger, so the two can never disagree. */
.swal2-container .swal2-styled.swal2-confirm.confirm-danger {
  background: var(--down); border-color: var(--down); color: var(--on-signal);
}
.swal2-container .swal2-styled.swal2-confirm.confirm-danger:hover {
  background: color-mix(in srgb, var(--down) 82%, black); border-color: color-mix(in srgb, var(--down) 82%, black);
}
.swal2-container .swal2-styled.swal2-confirm {
  background: var(--signal); color: var(--on-signal); box-shadow: inset 0 0 0 1px var(--signal);
}
.swal2-container .swal2-styled.swal2-confirm:hover {
  background: var(--signal-strong); box-shadow: inset 0 0 0 1px var(--signal-strong);
}

@media (prefers-reduced-motion: reduce) {
  .swal2-container { --swal2-show-animation: none; --swal2-backdrop-transition: none; }
}
/* Below this the three answers stop fitting on one line; stacking them keeps
   the commitment order (cancel → save → save and restart) readable top to bottom. */
@media (max-width: 480px) {
  .swal2-actions { flex-direction: column; align-items: stretch; }
  .swal2-container .swal2-styled { width: 100%; }
}

/* =============================================================================
   Responsive — the single 760px breakpoint.

   The rail used to become a horizontal scroller here: eight destinations in a
   strip you had to swipe, above every page, costing vertical space on the
   screen with the least of it, and hiding the session block entirely — so on a
   phone there was no way to see who you were signed in as or to sign out.

   It is now a DRAWER, which is what a phone user reaches for. The nav is one
   tap away, the whole rail comes with it (mark, sections, appearance, session),
   and the page below gets its full width back.
   ============================================================================= */
@media (max-width: 760px) {
  /* align-content:start because the shell keeps `min-height: 100vh`, and a grid
     with spare vertical space stretches its auto rows to share it — which on a
     short page (Backups with nothing configured) grew the TOPBAR to 287px and
     pushed the content a third of the way down the screen. The rail is the
     reason 100vh is there and the rail is out of flow here, so the rows should
     simply sit at the top. */
  .app { grid-template-columns: 1fr; align-content: start; }
  .main { padding: 18px 16px 40px; }

  /* ---- The bar the page always has ---------------------------------------
     Sticky: on a long site list the way back to the nav must not be a scroll
     to the top. */
  .topbar {
    display: flex; align-items: center; gap: 6px;
    position: sticky; top: 0; z-index: 30;
    padding: 6px 10px; min-width: 0;
    background: var(--surface); border-bottom: 1px solid var(--line);
  }
  .topbar-brand {
    display: flex; align-items: center; gap: 9px;
    font-weight: 650; letter-spacing: .02em; font-size: 15px; color: var(--ink);
  }
  .topbar-brand:hover { text-decoration: none; }
  .nav-toggle { display: inline-flex; }

  /* ---- The drawer --------------------------------------------------------
     Only when scripting is on: `.js` is set in the head of the layout, and
     without it the rail below stays the static block it renders as anyway, so
     a scriptless browser gets a reachable nav instead of a hidden one. */
  .js .rail {
    position: fixed; top: 0; bottom: 0; left: 0; z-index: 45;
    width: min(19rem, 84vw);
    overflow-y: auto; overscroll-behavior: contain;
    border-right: 1px solid var(--line); box-shadow: var(--shadow);
    transform: translateX(-100%);
    /* Hidden, not merely moved. A closed drawer that is only translated off
       screen still holds eight links in the tab order, so a keyboard lands in
       navigation nobody can see. The 180ms delay is what keeps it on screen
       for the whole slide OUT. */
    visibility: hidden;
    /* Motion #5 (design-language §7): 180ms, and it carries information — the
       nav came from the edge the hamburger sits on, and it goes back there. */
    transition: transform .18s ease, visibility 0s linear .18s;
  }
  body.nav-open .rail {
    transform: translateX(0); visibility: visible;
    transition: transform .18s ease, visibility 0s;
  }

  .js .rail-scrim {
    display: block; position: fixed; inset: 0; z-index: 40;
    background: color-mix(in srgb, var(--ink) 42%, transparent);
    opacity: 0; pointer-events: none; transition: opacity .18s ease;
  }
  body.nav-open .rail-scrim { opacity: 1; pointer-events: auto; }

  /* The drawer is over the page, so the page must not scroll under it. */
  body.nav-open { overflow: hidden; }

  /* The mark and the way out, on one line. */
  .js .rail-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 8px; }
  .js .rail-close { display: inline-flex; }

  /* The session block comes BACK. Hiding it was the old strip's compromise:
     the drawer is a full column, so there is room for the thing that says who
     you are and lets you leave. */
  .rail .spacer { flex: 1; }
  .rail .who { display: block; }

  /* A tap target is a thumb, not a pointer. */
  .btn { min-height: 42px; padding: 10px 16px; }
  .theme-switch-option { width: 40px; height: 40px; }

  /* ---- Page head: title first, then what you can do to it ---------------- */
  .page-head { flex-direction: column; align-items: stretch; gap: 14px; margin-bottom: 18px; }
  .page-head h1 { font-size: 20px; overflow-wrap: anywhere; }

  /* ---- Identifiers wrap rather than push the page sideways ---------------
     A container name or a repository URL is one long unbreakable token; on a
     phone the choice is breaking it or scrolling the whole page, and the
     quality floor (design-language §10) picked breaking it. Rows are blocks
     here (`.stack-sm`), so a broken domain costs a line rather than a column.
     `.detail dd` / `.kv dd` break at every width — that pair lives in the base
     stylesheet now; this is the rest, and it is phone-only on purpose. */
  .domain, .cname, .mono, code { overflow-wrap: anywhere; }

  /* Tabs scroll at every width now — the rules moved out of this block, up to
     the base `.tabs` (they were needed just above the breakpoint too). */

  /* ---- Action bar: one column of full-width verbs ----------------------- */
  .action-bar { flex-direction: column; align-items: stretch; }
  .action-bar .action-group { flex-direction: column; align-items: stretch; }
  .action-bar .action-group .btn { justify-content: center; }
  .action-bar form { display: flex; }
  .action-bar form .btn { flex: 1; }
  /* The cluster stacks like everything else, but keeps its label — which is
     exactly what stops Restart and Recreate from dissolving into the column of
     unrelated verbs above and below them once nothing is side by side. */
  .action-cluster { flex-direction: column; align-items: stretch; padding: 8px 10px 10px; }
  .action-cluster > .cluster-label { padding: 0 0 2px; }
  .action-cluster form { display: flex; }
  .action-cluster form .btn { flex: 1; justify-content: center; }
  /* The move banner stacks: two long image names side by side do not fit a
     phone, and the arrow turns a quarter-turn to keep pointing at the target. */
  .move { flex-direction: column; align-items: stretch; gap: 12px; }
  .move-arrow { width: 2px; height: 22px; margin-left: 4px; }
  .move-arrow::after {
    right: auto; left: 50%; top: auto; bottom: 0;
    transform: translateX(-50%) rotate(135deg);
  }

  /* ---- Forms and toolbars ----------------------------------------------- */
  .filter-bar select.input { width: 100%; min-width: 0; }
  .filter-bar .btn { width: 100%; justify-content: center; }
  .btn-row .btn { flex: 1 1 auto; justify-content: center; }

  /* ---- Read-only pairs: label over value --------------------------------- */
  .detail { grid-template-columns: 1fr; }
  .detail dt { border-bottom: 0; padding-bottom: 0; }

  /* A rows="22" .env editor is three phone screens of empty box before the
     button that saves it. The row count still sets the desktop height; this
     only says a text area may not take more than about half the viewport,
     after which it scrolls inside itself like any other editor. */
  textarea.input { max-height: 55vh; }

  /* ---- An empty box does not need half a screen of air ------------------- */
  .empty { padding: 36px 16px; }
  .card .card-b { padding: 16px; }

  /* ---- Tables -----------------------------------------------------------
     Two shapes, two answers.

     A DATA table — the backup listings, whose cells are dates, byte counts and
     snapshot ids — means nothing without its header row, so it keeps the
     header and scrolls sideways inside its own card. `display: block` is what
     makes the table itself the scroll container, so the page never moves.

     An OBJECT list — sites, routes, users, addresses — is the other shape:
     every cell already says what it is (a status reads "Running", a chip reads
     "Public", a domain is obviously a domain), so it can drop the header and
     stack into one block per row. That is `.stack-sm`, opted into per table,
     because whether a column can survive losing its label is a judgement about
     that column and not something a stylesheet can guess. */
  table.grid { display: block; overflow-x: auto; }
  table.grid th, table.grid td { white-space: nowrap; padding: 12px 14px; }
  table.grid th.hide-sm, table.grid td.hide-sm { display: none; }

  /* Every level goes to `block`, the table box included. Left as `display:
     table` it keeps table sizing, and a table sizes to its widest unbreakable
     cell — so one long hostname made the whole PAGE scroll sideways instead of
     wrapping inside its own row. */
  /* The padding is what holds the bordered rows below off the card's own edge —
     the table is the card's only child, so without it the two borders would sit
     on top of each other. */
  table.grid.stack-sm { display: block; overflow-x: visible; padding: 10px; }
  table.grid.stack-sm thead { display: none; }
  table.grid.stack-sm tbody, table.grid.stack-sm tr, table.grid.stack-sm td { display: block; }
  /* Each row is its own bordered block with air around it. A hairline alone was
     enough while a row was one line; stacked, a row is four or five lines tall,
     and consecutive rows ran together into one wall of text with no way to see
     where one site ended and the next began. */
  table.grid.stack-sm tr { padding: 14px 16px; border: 1px solid var(--line); border-radius: var(--radius-sm); }
  table.grid.stack-sm tr + tr { margin-top: 10px; }
  table.grid.stack-sm td { padding: 0; border-bottom: 0; white-space: normal; }
  /* No hover state on a touch screen: there is no pointer to hover with, and a
     tap leaves the highlight stuck on the last row touched, which reads as a
     selection nobody made. */
  table.grid tr.row:hover td { background: transparent; }
  table.grid th.col-grow, table.grid td.col-grow { width: auto; }
  table.grid th.col-state, table.grid td.col-state { white-space: normal; }
  table.grid.stack-sm td + td { margin-top: 9px; }
  /* The row's actions are the last thing in it, and they are full width like
     every other button on this breakpoint. */
  table.grid.stack-sm td.right { text-align: left; }
  table.grid.stack-sm td.right .btn-row { justify-content: stretch; }
  table.grid.stack-sm td.right .btn-row form { display: flex; flex: 1; }
  table.grid.stack-sm td.right .btn { flex: 1; justify-content: center; }
  /* Restated after the block rules above: same specificity, so order decides. */
  table.grid.stack-sm th.hide-sm, table.grid.stack-sm td.hide-sm { display: none; }
}

/* The drawer's movement is the only thing reduced motion loses: it still opens,
   it just arrives rather than slides. */
@media (prefers-reduced-motion: reduce) {
  .js .rail, .js .rail-scrim { transition: none; }
}

/* --- Backup (Phase 5 K6 — docs/phase5-backup/) ---------------------------
   Four rules, each because an existing class genuinely did not cover the
   shape. Anything the panel already had (grid, card, chip, field, alert, err,
   hint, btn) is reused rather than restyled. */

/* A retention count is 1–30. A full-width input for two digits reads as a
   text field and invites a paragraph. */
.input.short { max-width: 8ch; }

/* This installation's identity: three labelled, mostly READ-ONLY values. A
   .field would imply they are editable, which for the slug and uuid is exactly
   the wrong invitation — they name the directory on the backup target and are
   what a restore matches on. */
.kv { display: grid; grid-template-columns: minmax(9rem, auto) 1fr; gap: 10px 18px; margin: 0; }
.kv dt { font-size: 13px; font-weight: 600; color: var(--muted); }
.kv dd { margin: 0; }
@media (max-width: 640px) { .kv { grid-template-columns: 1fr; gap: 4px 0; } .kv dd { margin-bottom: 12px; } }

/* A stack's captured volume names — a list of facts, not navigation. */
ul.plain { list-style: none; padding: 0; margin: 0 0 10px; }
ul.plain li { padding: 3px 0; font-size: 13.5px; }

/* "Test without saving" sits beside Save rather than under it: it is the same
   decision, not a later one. */
.inline-form { display: inline-block; margin-top: 10px; }

/* --- Long-running operations (Phase 5) -----------------------------------
   A slow action now starts a queued operation and returns; this is how the
   page follows one. The veil BLOCKS on purpose — these actions change what the
   page is showing, so clicking stale controls invites a second restore on top
   of a running one — but it can be dismissed to a chip, because a ten-minute
   restore should not hold the whole panel hostage. */
.op-veil {
  position: fixed; inset: 0; z-index: 60;
  display: flex; align-items: center; justify-content: center;
  padding: 24px;
  background: color-mix(in srgb, var(--bg) 80%, transparent);
  backdrop-filter: blur(3px);
}
.op-card {
  width: min(30rem, 100%);
  background: var(--surface); border: 1px solid var(--line);
  border-radius: var(--radius); box-shadow: var(--shadow);
  padding: 22px;
}
.op-head { display: flex; align-items: center; gap: 12px; font-size: 15px; }
.op-message { color: var(--muted); font-size: 14px; margin: 12px 0 0; word-break: break-word; }
.op-actions { margin-top: 18px; display: flex; gap: 10px; justify-content: flex-end; }

/* A failure keeps its reason on screen and turns the accent red, so the state
   is legible before the text is read. */
.op-veil.op-failed .op-card { border-color: color-mix(in srgb, var(--down) 45%, var(--line)); }
.op-veil.op-failed .op-spinner { animation: none; border-color: var(--down); border-top-color: var(--down); }
.op-veil.op-failed .op-message { color: color-mix(in srgb, var(--down) 70%, var(--ink)); white-space: pre-wrap; }

/* Dismissed: the work continues and says so, out of the way. */
.op-chip {
  position: fixed; right: 18px; bottom: 18px; z-index: 60;
  display: flex; align-items: center; gap: 10px;
  padding: 10px 14px; font-size: 13px;
  background: var(--surface); color: var(--ink);
  border: 1px solid var(--line); border-radius: 999px; box-shadow: var(--shadow);
}

.op-spinner {
  width: 15px; height: 15px; flex: none;
  border: 2px solid color-mix(in srgb, var(--signal) 30%, transparent);
  border-top-color: var(--signal);
  border-radius: 50%;
  animation: op-spin .8s linear infinite;
}
@keyframes op-spin { to { transform: rotate(360deg); } }

/* Respect the setting: a spinner is decoration, not information. */
@media (prefers-reduced-motion: reduce) {
  .op-spinner { animation: none; }
  .op-veil { backdrop-filter: none; }
}

/* ==========================================================================
   Resource Usage (Phase 8 — docs/phase8-resource-limits/monitoring-epics.md)
   ==========================================================================
   Two pages: a triage list of every site, and one site's charts over a window.

   Nothing here is bold, per design-language §8: the interesting thing on this
   screen is the operator's own numbers, and a usage page that decorated them
   would be competing with the one thing it exists to show. So the whole
   vocabulary is three additions to what the panel already has — a proportion
   bar, tabular numerals, and a plain SVG plot — and everything else is the
   existing card, grid table and signal dot.

   The measurements are drawn against something in every case. A bare "160 MB"
   is not information; 160 MB of 3.7 GB is, and 160 MB of a 512 MB limit is
   more so. That is the meter's whole job. */

/* Numbers that must compare down a column: tabular figures so digits align,
   and the mono face the panel already uses for identifiers. */
.num {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
  font-size: 13px;
}
.num-lg { font-size: 17px; font-weight: 600; }

/* "not measured" is not "0 B", and must never look like a number
   (resource-limits.md's honesty constraints). */
.unmeasured { color: var(--muted); font-size: 12.5px; font-style: italic; }

/* ---- The proportion bar --------------------------------------------------
   Deliberately 3px and unlabelled: it is a second reading of the number above
   it, not a chart. Colour carries one meaning only — how close to the ceiling —
   so it uses the same semantic tokens as the status dot rather than a new
   scale. */
.meter {
  --meter-fill: var(--signal);
  height: 3px; width: 100%; min-width: 60px; margin-top: 5px;
  /* The TRACK is --line, not --surface-2: on a white card the latter is very
     nearly invisible, so a small fill (a site using 5% of the box) read as a
     stray dash floating under the number rather than as a bar with a little of
     it filled. The track is what gives the fill its meaning. */
  background: var(--line);
  border-radius: 999px; overflow: hidden;
}
.meter > i { display: block; height: 100%; background: var(--meter-fill); border-radius: 999px; }
.meter.warn { --meter-fill: var(--warn); }
.meter.over { --meter-fill: var(--down); }

/* ---- The host bar --------------------------------------------------------
   The box itself, above the list. A slim bar rather than a card of boxed
   statistics: this is CONTEXT for the table, and giving it card weight made the
   host figures look more important than the sites they exist to be measured
   against (design-language §8 — the operator's own data is the interesting
   thing on the screen, not the chrome). */
.hostbar {
  display: flex; flex-wrap: wrap; gap: 10px 34px; align-items: flex-start;
  padding: 12px 16px; margin-bottom: 18px;
  background: var(--surface); border: 1px solid var(--line); border-radius: var(--radius);
}
.hostbar-item { display: grid; gap: 3px; min-width: 150px; flex: 1 1 auto; }
.hostbar-label {
  font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
  color: var(--muted); font-weight: 600;
}
.hostbar-value { font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
/* The denominator is quieter than the number it divides: "2.5 GB" is the
   reading, "/ 4 GB" is the scale it is read against. */
.hostbar-of { color: var(--muted); font-weight: 500; }
.hostbar-sub { color: var(--muted); font-size: 12px; }
.hostbar-meta { flex: 0 0 auto; min-width: 0; }

/* ---- Metric tiles (the site page's "Now") --------------------------------
   Three figures with one job each. The number leads at a size that reads from
   across a desk, its qualifier sits under it, and nothing is boxed — the card
   around them is already the box. */
.metrics {
  display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr));
  gap: 20px 28px;
}
.metric { min-width: 0; }
.metric-label {
  font-size: 11px; text-transform: uppercase; letter-spacing: .08em;
  color: var(--muted); font-weight: 600; margin-bottom: 5px;
}
.metric-value {
  font-size: 26px; font-weight: 600; letter-spacing: -.01em;
  font-variant-numeric: tabular-nums; line-height: 1.15;
}
.metric-sub { color: var(--muted); font-size: 12.5px; margin-top: 5px; line-height: 1.45; }

/* One stack service's block of figures, and the same heading above its charts. */
.service-heading {
  font-family: var(--font-mono); font-size: 12.5px; color: var(--muted);
  margin: 18px 0 10px; overflow-wrap: anywhere;
}
.card-b > .service-heading:first-child { margin-top: 0; }

/* A column header that has to say WHEN, because "CPU 6%" means nothing without
   knowing whether that is this minute or an average over a day. */
.th-when { text-transform: none; letter-spacing: 0; font-weight: 500; opacity: .75; }
.card-h-note { float: right; font-weight: 500; color: var(--muted); font-size: 12.5px; }
/* The leading figure in a cell that carries more than one. */
.num-lead { font-size: 14px; font-weight: 600; }
/* The memory column carries three things — a figure, a proportion bar and the
   peak beneath it — so it needs a floor. Without one, `col-grow` on the site
   column took every spare pixel and squeezed this to its narrowest possible
   width: the peak wrapped onto two lines and the bar rendered as a 40px stub,
   on a screen with 400px of unused space beside it.

   11rem and not 9.5: measured on the deployed page, "peak 24h 268.3 MB" needs
   128px and the cell's own 18px padding either side left only 116px, so the
   first attempt still wrapped by 12px. A site that also has a LIMIT carries a
   longer line and still wraps — deliberately: that is two facts, and two lines
   is the honest shape for it. */
th.col-mem, td.col-mem { min-width: 11rem; }

/* The qualifier under a figure may wrap; only the figure itself is nowrap.
   `col-state` sets nowrap for the whole cell, which is right for "260 MB" and
   wrong for "of 512M limit · peak 24h 168.4 MB" — that one line was adding
   ~250px to the table's natural width and pushing it into a scrollbar at every
   width below 1200. */
td.col-state .cname { white-space: normal; }

/* A wide table scrolls inside its own card, never by moving the page (the
   mobile mandate in CLAUDE.md). Needed BETWEEN 761px and ~1100px: below 760 the
   `.stack-sm` rules turn each row into a block, and above ~1100 there is room —
   but just above the breakpoint six nowrap columns measured 1202px against a
   761px viewport, and the far side of the table was simply unreachable.

   EVERY `table.grid` is wrapped in one, not just the Resource usage tables it
   was written for. `.card` is `overflow: hidden` (that is what clips content to
   the rounded corners), so a table wider than its card was not pushing the page
   sideways — it was being CUT OFF, silently, with no way to reach the far
   columns at all. Measured at a 768px viewport (iPad mini, which renders the
   desktop layout): sites 1060px, users 1195px, routes 1082px, allowlist 1050px,
   Resource usage 1068px — all clipped. The wrapper, not the card, is the scroll
   box so a `.card-h` above the table stays put while the columns move.

   A new table needs this wrapper. There is no rule that adds it automatically,
   because the scroll box has to sit inside the card rather than be the card.

   The scrollbar is STYLED rather than left to the platform, and that is the
   half that was actually reported: macOS hides an overlay scrollbar until
   something scrolls, so a table that is perfectly reachable still reads as
   simply cut off at the card's edge. Styling `::-webkit-scrollbar` opts out of
   the overlay behaviour — the bar is always there, which is the only honest
   signal that the far columns exist. Slim, and in the same greys as `--line`,
   so it reads as part of the card rather than as browser chrome. */
.table-scroll { overflow-x: auto; scrollbar-color: var(--line) transparent; }
.table-scroll::-webkit-scrollbar { height: 10px; }
.table-scroll::-webkit-scrollbar-track { background: transparent; }
.table-scroll::-webkit-scrollbar-thumb {
  background: var(--line); border-radius: 999px;
  border: 3px solid var(--surface);
}
.table-scroll::-webkit-scrollbar-thumb:hover { background: var(--muted); }

/* ---- A stack's containers, under the site they belong to -----------------
   The first attempt indented the STATUS cell and tinted the row; both were
   invisible — the indent moved a 9px dot, and the tint was a few percent of an
   already dark surface. What reads as nesting is the service NAME stepping in
   behind a tree elbow, with a rule down the left edge of the group. */
tr.child > td { background: color-mix(in srgb, var(--surface-2) 60%, transparent); }
tr.child > td:first-child { box-shadow: inset 2px 0 0 var(--line); }
tr.child .service-name {
  font-family: var(--font-mono); font-size: 12.5px; color: var(--muted);
  overflow-wrap: anywhere; padding-left: 20px; position: relative; display: inline-block;
}
/* The elbow. Drawn rather than typed, so it cannot be selected or read aloud. */
tr.child .service-name::before {
  content: ''; position: absolute; left: 6px; top: 0; bottom: 55%;
  width: 7px; border-left: 1px solid var(--line); border-bottom: 1px solid var(--line);
  border-bottom-left-radius: 3px;
}
/* The child's dot carries its state; it needs no word beside it. */
tr.child .signal { gap: 0; }

/* ---- Charts (Chart.js, vendored) -----------------------------------------
   The hand-built SVG this replaced drew the data correctly and had no axes, no
   units and no hover — a shape, not a chart. Chart.js owns the plot; these rules
   own the box around it.

   Two charts side by side on a wide screen, stacked on a narrow one. Memory and
   CPU are never one chart with two y-axes: they are different units, and a
   shared frame invites a comparison that has no meaning. */
/* `min(300px, 100%)` and not a bare 300px: at 320px the page's own padding and
   the card's leave ~250px, and a hard 300px minimum made the chart 333px wide —
   wider than the viewport it sits in. `min()` lets the track collapse to the
   space that actually exists instead of insisting on a size that does not. */
.chart-pair { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(300px, 100%), 1fr)); gap: 22px; }
.chart-box { min-width: 0; }
.chart-head { display: flex; align-items: baseline; justify-content: space-between; gap: 10px; margin-bottom: 8px; }
.chart-title { font-size: 13px; font-weight: 600; }
.chart-note { color: var(--muted); font-size: 12px; }
/* Fixed height: canvas needs one, and `min-width: 0` on the box is what keeps a
   wide plot from pushing the page sideways. */
.chart-canvas { position: relative; height: 170px; min-width: 0; }
/* The canvas is taken OUT OF FLOW, which is the documented Chart.js layout and
   fixes a real bug: Chart.js sizes the canvas from its container, but an in-flow
   canvas also SIZES that container — so shrinking the window left the canvas
   wider than the viewport (measured: 521px inside 500px) because each resize
   read a width the canvas itself was still inflating. A fresh load at the same
   width was always fine, which is what makes it easy to miss. `!important`
   because Chart.js writes width/height as INLINE styles. */
.chart-canvas canvas {
  position: absolute; top: 0; left: 0;
  width: 100% !important; height: 100% !important;
}

@media (max-width: 760px) {
  /* Numbers stay legible rather than shrinking with the column. */
  .num { font-size: 12.5px; }
  .metric-value { font-size: 22px; }
  /* The host bar becomes two readings stacked, still not a card. */
  .hostbar { gap: 14px; padding: 12px 14px; }
  .hostbar-item { min-width: 100%; }
  .card-h-note { float: none; display: block; margin-top: 3px; }
  .chart-canvas { height: 150px; }
  /* Stacked, a child row is its own block, so the left rule is what says it
     belongs to the site above — there are no columns left to indent. */
  table.grid.stack-sm tr.child { border-left: 3px solid var(--line); }
}
