/* ============================================================
   reMaestro cloud console
   The same design system as the hub (src/Remaestro.Hub/wwwroot/app.css) — same tokens, same type scale,
   same card/chip/button treatment — written out again rather than imported.

   Why a copy: these are two applications, deployed separately, that must be able to change at different
   speeds. Importing the hub's sheet would make a rail tweak for a living-room tablet a deploy of the paid
   service, and it would drag in three thousand lines of device cards, remote walls and IR designers that
   nothing here renders. What is shared is the *language* — the variable names below are deliberately
   identical, so anything copied between the two projects lands looking right.
   ============================================================ */

/* ---- Tokens: shared ---- */
:root {
  /* Hanken Grotesk if the reader has it (the hub ships the webfont; this service doesn't carry a
     binary just for a headline), otherwise the platform's own UI face — which is what the fallback
     stack is for, and it is a better answer than a flash of nothing while a font downloads. */
  --font-sans: 'Hanken Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
  --font-mono: ui-monospace, 'SF Mono', 'JetBrains Mono', Menlo, Consolas, monospace;

  --r-xs: 8px; --r-sm: 10px; --r-md: 14px; --r-lg: 18px; --r-xl: 24px; --r-2xl: 32px; --r-pill: 999px;

  --ease: cubic-bezier(0.22, 1, 0.36, 1);
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --dur-1: 0.14s; --dur-2: 0.22s; --dur-3: 0.4s;

  /* A scale rather than a list of users, which is why `--z-toast` stays with nothing reading it: `#395`
     deleted `.toast` — no markup had named it for long enough that the dead-rule check found it on its
     first run — and the top of a layering order is not a rule, it is the answer to "what goes above a
     modal". Nothing in this suite checks custom properties either way. */
  --z-dropdown: 100; --z-sticky: 200; --z-rail: 300; --z-backdrop: 400; --z-modal: 500; --z-toast: 600;

  /* ---- The four insets, named once ----

     The document asks for the whole screen (viewport-fit=cover, in App.razor) and until now nothing in
     this sheet did anything with that — there was not a single env() in the file, so on a phone the top
     bar sat under the notch and the sign-out button sat under the home indicator. These four numbers are
     what put that right, and they are the only thing doing so.

     Named rather than written out at each site, for the same two reasons the hub gives: `env()` cannot be
     overridden, so a layout built straight on it can only be checked on hardware that has real insets,
     whereas these can be forced to 59px in any browser; and the fallback belongs in one place, because
     `env(safe-area-inset-top)` with no fallback is an invalid value in a browser that has never heard of
     it and an invalid value drops the whole declaration — including the padding that had nothing to do
     with safe areas. */
  --safe-t: env(safe-area-inset-top, 0px);
  --safe-r: env(safe-area-inset-right, 0px);
  --safe-b: env(safe-area-inset-bottom, 0px);
  --safe-l: env(safe-area-inset-left, 0px);

  /* What the browser paints its own chrome with — the strip above the page on iOS, the status bar on
     Android — via <meta name="theme-color">, kept in step with the theme by the script in App.razor. A
     token rather than a colour written into that script: the script runs after this sheet and can simply
     read it, so there is one definition of the page's colour and it lives where every other colour does.
     It is `--bg` today and is a separate name so it need not be forever. */
  --chrome-tint: var(--bg);
}

/* ---- Tokens: dark (default — "cinema") ---- */
:root, :root[data-theme="dark"] {
  color-scheme: dark;
  --bg: oklch(0.155 0.012 264);
  --bg-deep: oklch(0.12 0.012 264);
  --surface: oklch(0.198 0.013 264);
  --surface-2: oklch(0.238 0.014 264);
  --surface-3: oklch(0.29 0.015 264);
  --border: oklch(0.99 0.01 264 / 0.10);
  --border-strong: oklch(0.99 0.01 264 / 0.20);
  --ink: oklch(0.96 0.006 264);
  --ink-muted: oklch(0.735 0.014 264);
  --ink-faint: oklch(0.57 0.016 264);

  /* Faint, as *words*. `--ink-faint` is a graphic tone — a dot, a chevron — and it clears the 3:1 those
     need everywhere; as a sentence it reads 4.04 on `--surface`, 3.69 on `--surface-2` and 3.16 on
     `--surface-3` in this theme, so a caption drawn in it fails almost wherever it lands. The pair, and
     `--ink-faint`'s own chroma and hue so it stays the same grey: 4.71 at worst, on `--surface-3`.
     Measured on served pages in both engines. The same value as `src/Remaestro.Ui/wwwroot/tokens.css`, on
     purpose and by the rule at the top of this file — the names are deliberately identical so that markup
     copied between the two projects lands looking right. The long note is there; this is the copy.

     What that copy costs, recorded here because it was found by reading rather than by anything failing:
     this file's `--warn-text` is `oklch(0.86 0.12 88)` where the palette's is `oklch(0.85 0.13 88)`. That
     one is measured now and still here — see the note beside it, which says which way it should converge.
     The other thing `#169` found here, a missing `--ok-text`, is fixed below by `#168`. */
  --ink-faint-text: oklch(0.67 0.016 264);

  /* A select's arrow. A `<select>` takes no pseudo-element, so it is a background image; a `data:` URL is
     a separate document, so `var(--ink-faint)` does not reach inside it and the hex is written out. It is
     written out *here*, beside the colour it is meant to be, rather than in the `.select` rule four
     hundred lines down where it was `#888` and answerable to nothing. `--ink-faint` in sRGB. */
  --chevron: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%23737881' stroke-width='2' stroke-linecap='round'><path d='M7 10l5 5 5-5'/></svg>");

  --accent: oklch(0.77 0.155 60);
  --accent-hi: oklch(0.84 0.14 66);
  --accent-text: oklch(0.84 0.13 66);
  --on-accent: oklch(0.2 0.045 58);
  --accent-soft: oklch(0.77 0.155 60 / 0.16);
  --accent-line: oklch(0.77 0.155 60 / 0.32);
  --accent-glow: oklch(0.77 0.155 60 / 0.42);

  --signal: oklch(0.82 0.1 205);
  --signal-text: oklch(0.85 0.09 205);
  --signal-soft: oklch(0.82 0.1 205 / 0.16);

  --ok: oklch(0.79 0.15 155);

  /* Green, as *words*. `#160` added this to the palette and it never reached the copy, so until now this
     console had a green for borders and glyphs and nothing that could be READ in green — which is why its
     `.chip.ok` was the graphic tone used as prose. (`.toast.ok` was the other example and `#395` deleted
     it: nothing on this console had drawn a toast for long enough that no markup named the class at all.)
     Same value as
     `src/Remaestro.Ui/wwwroot/tokens.css` by the rule at the top of this file; the long note is there.
     In this theme it is a small lift and the light one is where it earns its keep. */
  --ok-text: oklch(0.82 0.13 155);

  --warn: oklch(0.83 0.14 88);

  /* NOT the palette's `oklch(0.85 0.13 88)`, and that is drift rather than a decision — found by `#169`,
     left by `#168`, and MEASURED by `#182` on 2026-08-12, which is what that note asked for.

     Every backdrop this console draws warn text on, in the theme where the two differ, both engines
     agreeing to 0.03. `.chip.warn` is the 13% warn wash over a card; `.fact-state.warn` is a 15px svg on
     `--surface` and `--surface-2`; nothing else names the token.

                          --surface   --surface-2   warn 13% / --surface
       this file, 0.86 0.12    11.85         10.81                   9.13
       the palette, 0.85 0.13  11.45         10.44                   8.82

     So THIS FILE'S VALUE WINS, by 0.31 to 0.40 of a ratio on every ground — and both clear 4.5:1 by more
     than double, so nothing here is a contrast defect. The light values are already identical, in this
     block's sibling and in the `prefers-color-scheme` arm, so the drift is one number in one theme.

     Left, and the direction is the finding: converging should raise the palette to this value, not lower
     this file to the palette's. That is a `tokens.css` edit with three consumers behind it — the hub
     console, the phone app's `index.html`, and `App.xaml`'s native colours — and every amber site in the
     hub would have to be re-measured to make it. Worth doing; not worth doing inside a task about green. */
  --warn-text: oklch(0.86 0.12 88);

  --danger: oklch(0.72 0.16 22);
  --danger-text: oklch(0.8 0.12 22);
  --danger-soft: oklch(0.72 0.16 22 / 0.16);

  --shadow-1: 0 1px 2px oklch(0 0 0 / 0.4), 0 3px 10px oklch(0 0 0 / 0.28);
  --shadow-2: 0 12px 40px oklch(0 0 0 / 0.5);
  --shadow-pop: 0 30px 80px oklch(0 0 0 / 0.6);
  --scrim: oklch(0.08 0.01 264 / 0.66);

  --body-glow: radial-gradient(1200px 720px at 88% -8%, oklch(0.77 0.155 60 / 0.09), transparent 60%),
               radial-gradient(900px 600px at 8% 108%, oklch(0.82 0.1 205 / 0.05), transparent 55%);
}

/* ---- Tokens: light ("daylight") ---- */
:root[data-theme="light"] {
  color-scheme: light;
  --bg: oklch(0.981 0.004 264);
  --bg-deep: oklch(0.955 0.005 264);
  --surface: oklch(1 0 0);
  --surface-2: oklch(0.976 0.004 264);
  --surface-3: oklch(0.95 0.006 264);
  --border: oklch(0.22 0.02 264 / 0.10);
  --border-strong: oklch(0.22 0.02 264 / 0.18);
  --ink: oklch(0.26 0.02 264);
  --ink-muted: oklch(0.45 0.02 264);
  --ink-faint: oklch(0.56 0.02 264);

  /* Faint, as words, on a white page. `--ink-faint` clears 4.5 only on `--surface` in this theme — 4.40 on
     `--bg`, 4.07 on `--bg-deep`, 4.33 on `--surface-2`, 4.00 on `--surface-3`. This is 4.75 at worst. Kept
     the same as tokens.css. */
  --ink-faint-text: oklch(0.52 0.02 264);

  /* `--ink-faint` in sRGB again — see the note beside the dark one. */
  --chevron: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%236e7581' stroke-width='2' stroke-linecap='round'><path d='M7 10l5 5 5-5'/></svg>");

  --accent: oklch(0.7 0.17 54);
  --accent-hi: oklch(0.66 0.18 50);
  --accent-text: oklch(0.5 0.16 46);

  /* Dark ink on the ember in daylight too, and the same value as the dark block above. The near-white
     that was here measured 2.72:1 on this theme's `--accent` — under 3:1 for a glyph, let alone the 4.5:1
     `.btn-primary`'s label needs — and no light ink can clear it, because pure white is only 2.82:1 on
     that fill. Amber is a light colour in both themes, so the ink on it is dark in both. It reads 6.47:1
     on `--accent` and 5.50:1 on `--accent-hi`. The palette carries the full note; this file is the fourth
     consumer of it and has to move at the same time, because nothing links the two stacks. */
  --on-accent: oklch(0.2 0.045 58);
  --accent-soft: oklch(0.7 0.17 54 / 0.12);
  --accent-line: oklch(0.7 0.17 54 / 0.34);
  --accent-glow: oklch(0.7 0.17 54 / 0.3);

  --signal: oklch(0.62 0.12 220);
  --signal-text: oklch(0.46 0.13 222);
  --signal-soft: oklch(0.62 0.12 220 / 0.12);

  --ok: oklch(0.62 0.15 155);

  /* Green, as words, on a white page — the half that matters. `--ok` here measures 3.39:1 on `--surface`,
     2.92 on `--surface-3` and 2.90 through a chip's own 13% green wash, so a word drawn in it fails
     everywhere in this theme and fails the 3:1 a glyph needs on most of it. Same value as the palette's;
     the argument, and why the chroma is 0.11 rather than 0.15, is in the note there. */
  --ok-text: oklch(0.48 0.11 155);

  --warn: oklch(0.68 0.14 70);
  --warn-text: oklch(0.5 0.13 62);
  --danger: oklch(0.58 0.19 24);
  --danger-text: oklch(0.52 0.19 24);
  --danger-soft: oklch(0.58 0.19 24 / 0.1);

  --shadow-1: 0 1px 2px oklch(0.4 0.02 264 / 0.08), 0 4px 14px oklch(0.4 0.02 264 / 0.08);
  --shadow-2: 0 14px 40px oklch(0.3 0.02 264 / 0.14);
  --shadow-pop: 0 30px 80px oklch(0.3 0.02 264 / 0.24);
  --scrim: oklch(0.4 0.02 264 / 0.28);

  --body-glow: radial-gradient(1200px 720px at 88% -8%, oklch(0.7 0.17 54 / 0.06), transparent 60%),
               radial-gradient(900px 600px at 8% 108%, oklch(0.62 0.12 220 / 0.05), transparent 55%);
}

/* The theme is stamped on <html> before first paint by a script in App.razor. This is the fallback for the
   half-second before that runs and for anyone with script off: follow the operating system rather than
   assuming dark, because a white page arriving in a dark room is the failure people actually notice.

   ---- and it is a second copy of the light palette, now compared to the first ----

   Thirty-four declarations, every one of them a duplicate of the block above. Until `#449` nothing checked
   that: every tone guard reads a theme block by its `[data-theme]` selector, which cannot see this one at
   all, so a token retuned here and not there — or there and not here — was served to anyone with script
   off and to the first half-second of every page load, with nothing reporting it. `#449` retuned
   `--signal-text` and found the second copy by reading the file rather than by anything failing.

   `CloudConsoleThemeCopyTests` compares the two, whitespace-normalised, and names any token that differs. */
@media (prefers-color-scheme: light) {
  :root:not([data-theme]) {
    color-scheme: light;
    --bg: oklch(0.981 0.004 264); --bg-deep: oklch(0.955 0.005 264);
    --surface: oklch(1 0 0); --surface-2: oklch(0.976 0.004 264); --surface-3: oklch(0.95 0.006 264);
    --border: oklch(0.22 0.02 264 / 0.10); --border-strong: oklch(0.22 0.02 264 / 0.18);
    --ink: oklch(0.26 0.02 264); --ink-muted: oklch(0.45 0.02 264); --ink-faint: oklch(0.56 0.02 264);
    --ink-faint-text: oklch(0.52 0.02 264);
    --chevron: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='24' height='24' fill='none' stroke='%236e7581' stroke-width='2' stroke-linecap='round'><path d='M7 10l5 5 5-5'/></svg>");
    --accent: oklch(0.7 0.17 54); --accent-hi: oklch(0.66 0.18 50); --accent-text: oklch(0.5 0.16 46);
    --on-accent: oklch(0.2 0.045 58); --accent-soft: oklch(0.7 0.17 54 / 0.12);
    --accent-line: oklch(0.7 0.17 54 / 0.34); --accent-glow: oklch(0.7 0.17 54 / 0.3);
    --signal: oklch(0.62 0.12 220); --signal-text: oklch(0.46 0.13 222); --signal-soft: oklch(0.62 0.12 220 / 0.12);
    --ok: oklch(0.62 0.15 155); --ok-text: oklch(0.48 0.11 155);
    --warn: oklch(0.68 0.14 70); --warn-text: oklch(0.5 0.13 62);
    --danger: oklch(0.58 0.19 24); --danger-text: oklch(0.52 0.19 24); --danger-soft: oklch(0.58 0.19 24 / 0.1);
    --shadow-1: 0 1px 2px oklch(0.4 0.02 264 / 0.08), 0 4px 14px oklch(0.4 0.02 264 / 0.08);
    --shadow-2: 0 14px 40px oklch(0.3 0.02 264 / 0.14);
    --shadow-pop: 0 30px 80px oklch(0.3 0.02 264 / 0.24);
    --scrim: oklch(0.4 0.02 264 / 0.28);
    --body-glow: radial-gradient(1200px 720px at 88% -8%, oklch(0.7 0.17 54 / 0.06), transparent 60%),
                 radial-gradient(900px 600px at 8% 108%, oklch(0.62 0.12 220 / 0.05), transparent 55%);
  }
}

/* The light-theme `.btn-primary` override that used to sit here is gone, for the reason the hub's app.css
   gives at the same place: it repeated the declaration `.btn-primary` already makes for every theme, and
   the white ink it was defending is what measured 2.72:1 on the light ember. */

/* ---- Reset / base ---- */
* { box-sizing: border-box; }
/* `-webkit-text-size-adjust` stops iOS reflowing the type when the phone is turned sideways;
   `-webkit-tap-highlight-color` removes the grey rectangle it flashes over anything tappable, which lands
   square and on the border box on top of controls that already have their own `:active` state in the right
   colours and at the right radius.

   Deliberately no `touch-action: manipulation` here, which is the one place this console departs from the
   hub's. The hub sets it to kill double-tap-to-zoom, because on a remote a double tap is somebody pressing
   channel-up twice. This is a console with tables of prices in it, and a double tap to magnify a column of
   numbers is a person using their browser correctly — the same reason the viewport tag above leaves zoom
   alone here and pins it there. */
html {
  -webkit-text-size-adjust: 100%; text-size-adjust: 100%;
  -webkit-tap-highlight-color: transparent;
}
html, body { margin: 0; min-height: 100%; }
/* 16px is the threshold below which iOS zooms into a focused field and drags the page sideways with it. */
input, select, textarea { font-size: max(16px, 1em); }
body {
  font-family: var(--font-sans);
  font-weight: 450;
  background: var(--bg);
  color: var(--ink);
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}
body::before {
  content: ""; position: fixed; inset: 0; z-index: -1; pointer-events: none;
  background: var(--body-glow);
}
a { color: inherit; text-decoration: none; }
button { font-family: inherit; }
:focus-visible { outline: 2px solid var(--signal); outline-offset: 2px; }
/* Except the page heading, which is a focus *target* rather than a focus *control* (#177).

   `<FocusOnNavigate Selector="h1" />` in Routes.razor moves focus to the heading on every navigation so a
   screen reader announces the page you have arrived on — worth keeping, and not what this is about. To do
   it Blazor makes the heading focusable, `blazor.web.js`'s `focusBySelector` setting `tabindex="-1"` on
   the live element, which is why no `<h1>` here has one in its markup; both engines then count that
   programmatic focus as `:focus-visible` and the rule above drew a rectangle the width of the page around
   the heading, on arrival, before anybody had touched a key.

   A ring means "the keyboard is here and this is a thing you can work". A heading cannot be worked, so
   half of that is a lie, and the announcement it would reinforce has already been made out loud. Scoped
   to the pair — a heading, and the attribute Blazor adds — so it cannot reach a control anybody wrote,
   and `tabindex="-1"` is itself the guarantee that nobody arrives here by tabbing. The hub's app.css
   carries the same exception for the same reason; this file is a separate deploy and would otherwise
   have kept the bug. */
h1[tabindex="-1"]:focus-visible { outline: none; }
::selection { background: var(--accent-soft); color: var(--ink); }
::-webkit-scrollbar { width: 11px; height: 11px; }
::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 99px; border: 3px solid transparent; background-clip: padding-box; }

h1, h2, h3, h4 { font-weight: 750; letter-spacing: -0.02em; line-height: 1.15; text-wrap: balance; margin: 0; }
h1 { font-size: 1.6rem; letter-spacing: -0.03em; }
h2 { font-size: 1.2rem; margin: 0 0 14px; }
h3 { font-size: 1rem; }
p { margin: 0; }
code, .mono { font-family: var(--font-mono); }
small { font-size: 0.82em; }

/* ============================================================
   Console shell — left rail + content
   ============================================================ */
.app { min-height: 100dvh; display: grid; grid-template-columns: 248px 1fr; }

/* The rail's background reaches the top and the bottom of the screen — it is `height: 100dvh` and stuck to
   the top — and its padding is what keeps the brand off the notch and the sign-out button off the home
   indicator. The left inset is the one that earns its keep: a notched phone or tablet held sideways puts
   the cutout down the leading edge, which is where this column is. */
.rail {
  position: sticky; top: 0; align-self: start; height: 100dvh; z-index: var(--z-rail);
  display: flex; flex-direction: column; gap: 4px;
  padding: calc(18px + var(--safe-t)) 14px calc(18px + var(--safe-b)) max(14px, var(--safe-l));
  background: var(--bg-deep);
  border-right: 1px solid var(--border);
}
.rail-brand { display: flex; align-items: center; gap: 11px; padding: 6px 8px 14px; }
.rail-mark {
  width: 34px; height: 34px; border-radius: 10px; flex: none;
  display: grid; place-items: center; color: var(--on-accent);
  background: linear-gradient(150deg, var(--accent-hi), var(--accent));
  box-shadow: 0 6px 18px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.35);
}
.rail-mark svg { width: 19px; height: 19px; }
.rail-word { display: flex; flex-direction: column; line-height: 1.1; font-weight: 800; letter-spacing: -0.02em; font-size: 1.06rem; }
.rail-word small { color: var(--ink-faint-text); font-weight: 600; font-size: 0.62rem; letter-spacing: 0.14em; text-transform: uppercase; }

.rail-label { font-size: 0.64rem; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-faint-text); padding: 14px 10px 6px; }
.rail-nav { display: flex; flex-direction: column; gap: 2px; }
.rail-link {
  display: flex; align-items: center; gap: 11px; padding: 9px 11px; border-radius: var(--r-sm);
  color: var(--ink-muted); font-weight: 600; font-size: 0.92rem;
  transition: background var(--dur-1), color var(--dur-1);
}
.rail-link svg { width: 18px; height: 18px; flex: none; opacity: 0.85; }
.rail-link:hover { background: var(--surface); color: var(--ink); }
.rail-link.active { background: var(--accent-soft); color: var(--accent-text); }
.rail-link.active svg { opacity: 1; }

.rail-foot { margin-top: auto; display: flex; flex-direction: column; gap: 10px; padding-top: 12px; }
.rail-who {
  display: flex; align-items: center; gap: 10px; padding: 10px 11px; border-radius: var(--r-md);
  background: var(--surface); border: 1px solid var(--border); min-width: 0;
}
.rail-who-mark {
  width: 28px; height: 28px; flex: none; border-radius: var(--r-pill); display: grid; place-items: center;
  background: var(--accent-soft); color: var(--accent-text); font-weight: 800; font-size: 0.78rem;
}
.rail-who-txt { min-width: 0; line-height: 1.25; }
.rail-who-email { font-size: 0.8rem; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.rail-who-role { font-size: 0.66rem; color: var(--ink-faint-text); font-weight: 650; letter-spacing: 0.08em; text-transform: uppercase; }
.rail-out { width: 100%; }

.main { min-width: 0; display: flex; flex-direction: column; }

/* THE PAGE COLUMN OWNS THE VERTICAL RHYTHM, AND NOTHING ELSE DOES.
   ------------------------------------------------------------------------------------------------
   This used to be a plain block, and the space between one block of a page and the next came from
   `.card + .card`, `.note + .card` and a scatter of inline `style="margin-top:14px"`. Adjacent-sibling
   selectors describe what happens to precede what, which is not the same question as what a page's
   rhythm should be — and they are silently wrong the moment anything gets wrapped. The account page
   was the proof: two cards inside a `.grid-2` followed by a third card, and because the password
   card's previous sibling was the grid rather than a card, it sat flush against the block above it.
   Every page here is one `@if` away from the same bug.

   `.grid-2 + .card` would have fixed that one page and left the next arrangement broken in exactly
   the same way. A gap on the column fixes the class of bug: whatever a page puts at its top level —
   a card, a grid of cards, a note, a stack, a block that only exists on Tuesdays — is spaced from
   its neighbours because it is a child of this column, not because of what it happens to be.

   The cost is that a block can no longer opt out with a negative margin, and that is a feature. Two
   things stay out of it by their own nature: `hidden` elements, which are `display:none` and skipped
   by `gap`, and the dialogs and toasts, which are out of flow and never were siblings in the layout
   sense. `.page-head` keeps a margin of its own so the space under a page's title is bigger than the
   space between its cards; the gap and that margin add to the 24px it always had. */
/* No top inset here, deliberately. Above 900px the rail is the thing touching the top of the screen and it
   carries the inset; below 900px the top bar does. A third claim on the same 59px is the double-padding
   that starts a page a third of the way down a phone. The bottom and the sides are this column's own,
   because nothing else is under or beside them. */
.content {
  flex: 1; width: 100%; max-width: 1180px; margin: 0 auto;
  padding: 30px max(32px, var(--safe-r)) calc(90px + var(--safe-b)) max(32px, var(--safe-l));
  display: flex; flex-direction: column; gap: 14px;
}
.page-head { display: flex; align-items: flex-end; justify-content: space-between; gap: 16px; margin-bottom: 10px; flex-wrap: wrap; }
.page-head h1 { font-size: 1.7rem; }
/* A descendant selector, and the hub's counterpart is a CHILD selector — this is a deliberate difference
   between the two sheets rather than drift, and it is a difference in the MARKUP, not in the intent.

   The hub's header is a plain block with `.page-head-top` inside it, so its description is a direct
   child and `#424` narrowed `.page-head p` to `.page-head > p` there: on that surface the descendant
   form was reaching into a popover mounted on the header's action strip and deciding, at (0,1,1), how
   somebody else's paragraphs were drawn. Here the header IS the flex row: every one of the eleven pages
   writes `<div class="page-head"><div><h1>…</h1><p>…</p></div><div class="page-head-actions">`, so the
   description is a GRANDCHILD and a `>` would unstyle all eleven at once.

   Checked before it was left alone: nothing but that one paragraph is a `<p>` under a `.page-head` on
   this surface, and nothing mounts a subtree here — there is no popover, no top layer and no component
   with prose in it anywhere in these headers. So the reach this rule has is a hazard rather than a bug,
   and closing it means giving that wrapper a class in eleven files. If one is ever added, this is the
   line that will decide how its paragraphs look, and it should become `> p` and a class in the same
   change. */
.page-head p { margin: 6px 0 0; color: var(--ink-muted); font-size: 0.95rem; max-width: 62ch; line-height: 1.55; }
.page-head-actions { display: flex; gap: 8px; flex-wrap: wrap; }

/* ---- Mobile: the rail folds into a bar whose menu is a <details>, because this layout renders
        statically and must work with no circuit and no script at all. ---- */
.topbar { display: none; }

@media (max-width: 900px) {
  .app { grid-template-columns: 1fr; }
  .rail { display: none; }
  .topbar {
    display: block; position: sticky; top: 0; z-index: var(--z-sticky);
    background: var(--bg-deep); border-bottom: 1px solid var(--border);
  }
  /* The inset lives in this row's padding, not in a spacer above the bar. Stuck to `top: 0` the bar's
     background already runs to the physical top of the screen and under the status bar, which is the half
     of edge-to-edge that has to bleed; the padding is the half that has to not. A spacer would push the
     background down with the content and leave a strip of bare page behind the clock.

     Only inside this media query: above 900px the bar is `display: none` and the rail takes the inset. */
  .topbar-row {
    display: flex; align-items: center; gap: 12px;
    padding: calc(10px + var(--safe-t)) max(16px, var(--safe-r)) 10px max(16px, var(--safe-l));
  }
  .topbar-row .rail-word { flex: 1; min-width: 0; }
  .topbar-menu { list-style: none; position: relative; flex: none; }
  .topbar-menu > summary {
    display: inline-flex; align-items: center; gap: 8px; cursor: pointer;
    padding: 8px 13px; border-radius: var(--r-sm);
    background: var(--surface); border: 1px solid var(--border-strong);
    font-weight: 700; font-size: 0.9rem;
  }
  .topbar-menu > summary::-webkit-details-marker { display: none; }
  .topbar-menu[open] > summary { border-color: var(--accent-line); color: var(--accent-text); }
  /* Dropped out of flow so the row keeps its height whether the menu is open or shut. */
  .topbar-panel {
    position: absolute; right: 0; top: calc(100% + 8px); z-index: var(--z-dropdown);
    width: min(300px, calc(100vw - 32px));
    padding: 2px 12px 14px; border-radius: var(--r-md);
    background: var(--surface); border: 1px solid var(--border-strong); box-shadow: var(--shadow-2);
  }
  .topbar-panel .rail-label:first-child { padding-top: 10px; }
  .content { padding: 20px max(16px, var(--safe-r)) calc(70px + var(--safe-b)) max(16px, var(--safe-l)); }
  .page-head { margin-bottom: 2px; gap: 10px; }
  .page-head h1 { font-size: 1.32rem; }
  .page-head p { font-size: 0.88rem; }
}

/* ============================================================
   Primitives — same names as the hub, so markup travels
   ============================================================ */
/* No `.card + .card { margin-top }` and no `.note + .card` family: `.content` is a column with a gap
   and it spaces every top-level block on every page, including the ones that are not cards. See the
   argument over `.content`. */
.card { background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg); padding: 20px; box-shadow: var(--shadow-1); }
.card h2 { font-size: 1.05rem; margin: 0 0 4px; }
.card > p.muted { margin-bottom: 14px; line-height: 1.55; }
.grid { display: grid; gap: 16px; grid-template-columns: repeat(auto-fill, minmax(300px, 1fr)); align-items: start; }
.grid-2 { display: grid; gap: 16px; grid-template-columns: repeat(auto-fit, minmax(340px, 1fr)); align-items: start; }
.stack { display: flex; flex-direction: column; gap: 14px; }

.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  border: 1px solid var(--border-strong); background: var(--surface-2); color: var(--ink);
  border-radius: var(--r-sm); padding: 9px 15px; font-size: 0.9rem; font-weight: 650; cursor: pointer;
  transition: background var(--dur-1), border-color var(--dur-1), transform var(--dur-1) var(--ease), box-shadow var(--dur-1);
}
.btn:hover { background: var(--surface-3); border-color: var(--border-strong); }
.btn:active { transform: scale(0.975); }
.btn:disabled { opacity: 0.5; cursor: not-allowed; transform: none; }
.btn svg { width: 16px; height: 16px; }
.btn-primary {
  background: linear-gradient(150deg, var(--accent-hi), var(--accent)); border: none; color: var(--on-accent);
  box-shadow: 0 6px 18px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.3);
}
.btn-primary:hover {
  border: none;
  background: linear-gradient(150deg, color-mix(in oklab, var(--accent-hi) 86%, white), color-mix(in oklab, var(--accent) 90%, white));
  box-shadow: 0 8px 24px var(--accent-glow), inset 0 1px 0 oklch(1 0 0 / 0.35);
}
.btn-danger { background: var(--danger-soft); border-color: color-mix(in oklab, var(--danger) 40%, transparent); color: var(--danger-text); }
.btn-danger:hover { background: color-mix(in oklab, var(--danger) 22%, transparent); }
.btn-sm { padding: 6px 11px; font-size: 0.82rem; border-radius: var(--r-xs); }
.btn-ghost { background: transparent; border-color: transparent; color: var(--ink-muted); }
.btn-ghost:hover { background: var(--surface-2); color: var(--ink); }
.btn-block { width: 100%; }

/* ---- Forms ---- */
.field { margin-bottom: 14px; }
.field label, .field > .lbl { display: block; font-size: 0.78rem; font-weight: 650; color: var(--ink-muted); margin-bottom: 6px; }
.input, .select, textarea.input {
  width: 100%; padding: 10px 13px; border-radius: var(--r-sm); background: var(--bg-deep);
  border: 1px solid var(--border); color: var(--ink); font-size: 0.92rem; font-family: inherit; outline: none;
  transition: border-color var(--dur-1), box-shadow var(--dur-1);
}
.input::placeholder { color: var(--ink-faint-text); }
.input:focus, .select:focus, textarea.input:focus { border-color: var(--signal); box-shadow: 0 0 0 3px var(--signal-soft); outline: none; }
.input-sm { padding: 6px 9px; font-size: 0.84rem; }
.input-num { text-align: right; font-variant-numeric: tabular-nums; font-family: var(--font-mono); }
.row { display: flex; gap: 12px; flex-wrap: wrap; }
.row > * { flex: 1; min-width: 140px; }
.row-tight { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
/* The arrow is `--chevron`, declared with the other colours at the top of this file — the same name the
   hub's palette uses, which is this sheet's whole arrangement: the *language* is shared, the declarations
   are each application's own. It was `#888` written out here and `#888` written out there, a colour
   answerable to no token in either, in the two files this header says are copies. */
/* `overflow: hidden`: WebKit sizes a `<select>` to its widest option and reports that as the element's
   layout overflow even where a `max-width` has already constrained what it paints — so the control looks
   correct and the box around it scrolls sideways anyway. #127 measured it at 38px in the hub's step-editor
   rail, and 0px in Chromium, which is why it went unseen. Clipped at the control so no narrow container
   here has to remember to defend itself, and so a longer option cannot bring it back. The same line is in
   the hub's `app.css`; these two sheets are copies that have drifted before (`#97`), and
   `SelectChevronRenderTests` reads both. */
.select {
  appearance: none; -webkit-appearance: none;
  background-image: var(--chevron);
  background-repeat: no-repeat; background-position: right 9px center; padding-right: 34px; cursor: pointer;
  overflow: hidden;
}
.select option { background: var(--surface); color: var(--ink); }
.select-sm { padding: 6px 28px 6px 9px; font-size: 0.84rem; width: auto; }
.check { display: inline-flex; align-items: center; gap: 8px; font-size: 0.86rem; font-weight: 600; color: var(--ink-muted); cursor: pointer; }
.check input { accent-color: var(--accent); width: 16px; height: 16px; }

/* ---- Chips / tags / status ---- */
.chip { display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: var(--r-pill); background: var(--surface-2); border: 1px solid var(--border); font-size: 0.74rem; font-weight: 650; white-space: nowrap; }
.chip.ok { color: var(--ok-text); border-color: color-mix(in oklab, var(--ok) 34%, transparent); background: color-mix(in oklab, var(--ok) 13%, transparent); }
.chip.warn { color: var(--warn-text); border-color: color-mix(in oklab, var(--warn) 34%, transparent); background: color-mix(in oklab, var(--warn) 13%, transparent); }
.chip.bad { color: var(--danger-text); border-color: color-mix(in oklab, var(--danger) 32%, transparent); background: var(--danger-soft); }
/* `.chip.on`, and it was `.chip.accent` until #462 — a class named for its paint, which is exactly how it
   came to be drawn on three things that are not states. Four sites: a plan's granted features on two
   pages, which model plans a model is offered to, and the model a service is actually using. Only the
   last is the chosen one; the other three were categories, listed one chip per feature per row, so an
   accounts table of a dozen rows carried thirty-odd amber chips saying what a plan contains. Those three
   are plain `.chip` now and the class carries the meaning it always had in the hub — `.tv-chip.on`,
   `.asn-zchip.on`, `.part-pill.on`, and `.pick.on` on this very console, which is the row this chip sits
   inside and says the same thing about. A colour named after itself cannot be used wrongly; a colour
   named after what it means can. */
.chip.on { color: var(--accent-text); border-color: var(--accent-line); background: var(--accent-soft); }
/* A chip you can press, and this sheet had none — the plan chips on the model editor were `<button
   class="chip">`, which is the readout's exact paint, sitting in a page that also draws readout chips.
   The distinction is the hub's and it is deliberately the same one: the control frame (`--border-strong`,
   against the readout's `--border`) and a fill a tone up, so it survives a phone with no hover. See the
   long note beside `.chip-btn` in the hub's `app.css` for why it is not simply amber. */
.chip-btn {
  cursor: pointer; font: inherit; font-size: 0.72rem; font-weight: 700;
  color: var(--ink); border-color: var(--border-strong); background: var(--surface-3); padding: 4px 13px;
  transition: background var(--dur-1), border-color var(--dur-1), transform var(--dur-1) var(--ease);
}
.chip-btn:hover { background: color-mix(in oklab, var(--ink) 10%, var(--surface-3)); border-color: var(--accent-line); }
.chip-btn:active { transform: scale(0.975); }
.chips { display: flex; flex-wrap: wrap; gap: 6px; }
.tag { font-family: var(--font-mono); font-size: 0.72rem; padding: 2px 8px; border-radius: var(--r-xs); background: var(--surface-2); color: var(--ink-muted); border: 1px solid var(--border); white-space: nowrap; }
.dot { width: 8px; height: 8px; border-radius: 50%; background: var(--ink-faint); flex: none; }
.dot.online { background: var(--ok); box-shadow: 0 0 0 3px color-mix(in oklab, var(--ok) 20%, transparent); }
.pill { display: inline-block; min-width: 62px; text-align: center; font-size: 0.68rem; font-weight: 700; letter-spacing: 0.04em; text-transform: uppercase; padding: 3px 9px; border-radius: var(--r-pill); }
/* What a person IS, once per row of an accounts table — not the live one, the current one or the chosen
   one, so it came down in #462 with the rest of that census. Note it is NOT the hub's `.api-role-admin`,
   which that census kept: this is a two-way admin/user pair whose other half is already neutral, so
   demoting one end leaves a coherent table, where the hub's is a three-way ladder with a spelled-out blue
   in the middle and pulling one colour out of it is the mistake this whole pass is about. Weight is the
   difference now: `--ink` against `.pill-user`'s `--ink-muted`, on the same surface, with the words
   themselves doing the rest. */
.pill-admin { background: var(--surface-3); color: var(--ink); border: 1px solid var(--border-strong); }
.pill-user { background: var(--surface-3); color: var(--ink-muted); border: 1px solid var(--border-strong); }

/* ---- Page furniture ---- */
.muted { color: var(--ink-muted); }
.faint { color: var(--ink-faint-text); }
.hint { font-size: 0.8rem; color: var(--ink-faint-text); margin-top: 5px; line-height: 1.5; }
.divider { height: 1px; background: var(--border); margin: 18px 0; }
.num { font-variant-numeric: tabular-nums; }
.empty { text-align: center; padding: 46px 20px; color: var(--ink-muted); }
.empty h3 { color: var(--ink); font-size: 1.1rem; margin: 0 0 8px; }
.empty p { max-width: 50ch; margin: 0 auto; line-height: 1.6; font-size: 0.92rem; }

/* A sentence the reader is meant to act on. Three tones and no icons — the colour and the words carry it. */
.note { border-radius: var(--r-md); padding: 11px 14px; font-size: 0.86rem; line-height: 1.55; border: 1px solid var(--border); background: var(--surface-2); color: var(--ink-muted); }
.note strong { color: var(--ink); font-weight: 700; }
.note.ok { border-color: color-mix(in oklab, var(--ok) 34%, transparent); background: color-mix(in oklab, var(--ok) 10%, transparent); color: var(--ink); }
.note.warn { border-color: color-mix(in oklab, var(--warn) 36%, transparent); background: color-mix(in oklab, var(--warn) 10%, transparent); color: var(--ink); }
.note.bad { border-color: color-mix(in oklab, var(--danger) 38%, transparent); background: var(--danger-soft); color: var(--ink); }

/* ---- Segmented control ---- */
.seg { display: inline-flex; background: var(--bg-deep); border: 1px solid var(--border); border-radius: var(--r-sm); padding: 3px; gap: 2px; }
.seg-btn { border: none; background: none; color: var(--ink-muted); font-size: 0.82rem; font-weight: 650; padding: 6px 11px; border-radius: var(--r-xs); cursor: pointer; white-space: nowrap; transition: background var(--dur-1), color var(--dur-1); }
.seg-btn:hover { color: var(--ink); }
.seg-btn.active { background: var(--surface-3); color: var(--accent-text); box-shadow: var(--shadow-1); }

/* ============================================================
   THE SPEC LIST — the one way this console states a fact
   ------------------------------------------------------------
   A label, its value, and — where the value alone would mislead — a line of qualification underneath.
   Rendered by Web/Shared/Facts.razor and Fact.razor; specified, for both consoles, in
   docs/spec-list.md.

   This is a second implementation of that specification, not a copy of the hub's. The two services
   share no code by design — the hub must not depend on the paid one and the paid one must not depend
   on the hub — so what travels between them is the written rules, and the point of writing them down
   is that the reimplementation comes out the same. Where the vocabularies differ, this file uses its
   own: `--border` for the hairline the hub calls `--hairline`, `--warn-text` for the warn glyph
   because this console's `--warn` was mixed for a chip's tinted background rather than for ink on a
   card (measured: 2.96:1 on --surface in the light theme, under the 3:1 a graphical mark needs;
   --warn-text is 6.22:1). Same rules, this console's tokens.

   WHAT THIS REPLACED WAS ALREADY A `<dl>` AND STILL BROKE FOUR OF THE FIVE RULES.

   1. MONO MEANS "LITERAL", AND IT IS NEVER THE LABEL.
      A monospace face here says exactly one thing: this is a string you might copy, type or paste —
      a URL, an id, a token prefix, a linking code. It is a modifier on the VALUE (`.lit`, from
      "literal"), and there is no rule in this file that can put it on a label. The values that
      needed it were previously wearing `.tag` instead: a bordered mono pill at --ink-muted, which
      made the one genuinely-copyable thing in the card the QUIETEST thing in it.

   2. THE VALUE IS THE LOUDEST THING IN THE ROW.
      The old label was uppercase, letter-spaced and weight 650 against a value at plain body weight,
      so colour said "value" while size, tracking and weight all said "label", and the eye landed on
      `EMAIL`, `ROLE`, `PLAN` — the half of the row that carries no information. Now colour (--ink),
      size (0.875rem) and weight (550) all point at the value, and the label is quiet on every axis.
      Labels are sentence-case nouns: `Account id`, not `ACCOUNT ID` and not `signed in as`.

      Uppercase tracked micro-labels are kept elsewhere in this sheet on purpose — `.tbl th`,
      `.rail-label`, `.pill`. Those are chrome that names a region or a band, read once and then
      ignored, and the tracking is what marks them as not-content. A spec list's label sits inline
      with its value and is read against it, which is the case where the same treatment stops being
      a signpost and starts competing.

   3. " · " IS NOT A LAYOUT ENGINE.
      `Hub · linked 2 Aug 2026` used a separator to do structural work. It cannot wrap (a phone
      breaks the line wherever the glue falls and orphans the dot), it cannot be styled per part, and
      it gives a timestamp exactly the same weight as a hub's name. Qualifying information is
      `.fact-meta`: its own line, under the value, inside the value column. If it is not a
      qualification but a second fact of equal standing, it is a row of its own — the separator is
      never the answer.

   4. ICONS ARE FOR STATE.
      Four tones, four fixed glyphs, and no way to pass a free-form one. A row that states a fact —
      `Email`, `Plan`, `Size` — gets no glyph, because thirty rows each with its own little picture
      is decoration. The mark is aria-hidden: the state is already in the value's own words.

   5. NOTHING IN THE ROW IS TOO FAINT TO READ.
      Measured against this console's tokens, --ink-faint is 4.07:1 on --surface in the dark theme
      and 4.34:1 on --surface-2 in the light one. Both miss AA's 4.5:1, and --ink-faint is what the
      old label used. So the meta line takes --ink-muted (7.74:1 dark, 7.44:1 light) because it is
      prose somebody has to read, and the label takes two thirds --ink-faint to one third --ink-muted
      — 5.11:1 and 4.65:1 dark, 5.45:1 and 5.09:1 light, on the two surfaces a spec list sits on.
      Still far below the meta line, so the loudness order is unchanged: value, then meta, then label.

      Local, not a token change. --ink-faint carries `.hint`, `.faint`, `.mdl-name`, `.pager-where`
      and most of this console's small print at those same sub-AA ratios; raising it is the better
      fix and a much larger one, and the spec list will not need touching when somebody takes it on.

   There is no dense variant here. The hub has one because it has a dialog holding fifteen state
   facts at once; nothing in this console does. When something here needs it, it is the spec's dense
   mode arriving — not a new idea, and not a second class family.
   ============================================================ */
.facts {
  display: grid;
  grid-template-columns: minmax(92px, max-content) 1fr;
  margin: 10px 0 0;
}

/* `display: contents` earns the wrapper: <Fact> stays one component and one DOM group while its dt
   and dd sit directly in the parent grid's tracks. A component emitting two bare siblings would work
   too, but then nothing in the markup shows which dt belongs to which dd. */
.fact { display: contents; }

/* The rule is drawn on the two cells rather than on the wrapper, which paints nothing — and the
   column gap is padding on the label rather than `column-gap`, so the two halves meet and the
   hairline is continuous. A rule with a 20px hole in the middle of every row reads as a bug. */
.fact > dt,
.fact > dd {
  align-self: start;
  padding: 9px 0;
  border-top: 1px solid var(--border);
  /* One shared line box, so the two different font sizes put their first baselines on the same line
     without `align-items: baseline`, which would align the text and stagger the two hairlines. */
  line-height: 1.27rem;
}
.facts > .fact:first-child > dt,
.facts > .fact:first-child > dd { border-top: 0; padding-top: 2px; }

.fact > dt {
  padding-right: 20px;
  font-size: 0.78rem; font-weight: 500;
  color: color-mix(in oklab, var(--ink-faint) 66%, var(--ink-muted));
}
.fact > dd {
  margin: 0;
  font-size: 0.875rem; font-weight: 550; color: var(--ink);
  min-width: 0; overflow-wrap: anywhere;
}

/* The literal. It steps down a size because a monospace face at the same nominal size reads larger
   than the sans beside it. */
.fact .lit { font-family: var(--font-mono); font-size: 0.82rem; font-weight: 500; }

/* The qualification, on its own line. The family is restated because `Literal` puts mono on the whole
   value cell, and a date or a sentence of explanation underneath is prose either way. */
.fact-meta {
  display: block; margin-top: 2px;
  font-family: var(--font-sans);
  font-size: 0.75rem; font-weight: 400; color: var(--ink-muted); line-height: 1.45;
}
.fact-meta .lit { font-size: 0.72rem; font-weight: 400; }

/* State, and only state. The glyph is sunk below the baseline rather than centred with a flex box,
   because the value it precedes may wrap to a second line and the mark belongs to the first. */
.fact-state { display: inline-block; vertical-align: -0.16em; margin-right: 7px; }
.fact-state svg { width: 15px; height: 15px; display: block; }
/* The one green in this file that KEEPS the graphic tone, and the odd one out among its own three
   siblings for a reason rather than by neglect. This is a 15px `<svg>` and nothing else — a glyph, so 3:1
   is its bar — and `--ok` measures 3.39:1 on the `.card` a fact list is drawn on and 3.16 on `--surface-2`,
   the only two grounds this console gives it (nothing holding a `.facts` is drawn on `--surface-3`). Amber
   had to move because its graphic tone is 2.96 in daylight and fails the glyph bar too; green does not, so
   moving it would be retuning something already correct. #168.

   **Red did not have to move and this note used to say it did.** `#448` measured `--danger` at 4.71:1 on
   `.card` and 4.40 on `--surface-2` in daylight — comfortably over a glyph's 3:1 and over a sentence's 4.5
   on the first of them — so `.fact-state.bad` taking `--danger-text` is a choice about which red and not a
   contrast fix. Nothing here is wrong on the screen; the reason written beside it was, and a fabricated
   "had to" is the kind that stops the next author asking. The 2.68 it quoted was amber's and was itself
   wrong by 0.28 — see the note beside `--warn-text` in `tokens.css`. */
.fact-state.ok { color: var(--ok); }
.fact-state.warn { color: var(--warn-text); }
.fact-state.bad { color: var(--danger-text); }

/* Narrow: the label column becomes a line of its own. This is the reason secondary information was
   moved off the value line in the first place — three short stacked lines survive a 360px phone,
   where "Hub · linked 2 Aug 2026" breaks after the dot and leaves an orphan. */
@media (max-width: 520px) {
  .facts { grid-template-columns: 1fr; }
  .fact { display: block; padding: 8px 0; border-top: 1px solid var(--border); }
  .fact > dt, .fact > dd { border-top: 0; padding: 0; line-height: 1.4; }
  .facts > .fact:first-child { border-top: 0; padding-top: 2px; }
}

/* ---- Tables ---- */
.tbl-wrap { overflow-x: auto; margin: 0 -20px; padding: 0 20px; }
.tbl { width: 100%; border-collapse: collapse; font-size: 0.88rem; }
.tbl th {
  text-align: left; font-size: 0.66rem; text-transform: uppercase; letter-spacing: 0.07em;
  color: var(--ink-faint-text); font-weight: 700; padding: 10px 10px; white-space: nowrap;
  border-bottom: 1px solid var(--border);
}
.tbl td { padding: 11px 10px; border-top: 1px solid var(--border); vertical-align: middle; }
.tbl tr.off { opacity: 0.55; }
.tbl .r { text-align: right; font-variant-numeric: tabular-nums; }

/* ============================================================
   Public pages — sign in, sign up, sign out
   ============================================================ */
/* The glow is on the shell and the shell is the full height of the screen, so the gradient runs under the
   notch and the home indicator — which is the whole point of it. The padding is what keeps the card off
   both. `max()` rather than addition, so a phone with no insets still gets exactly 24px. */
.auth-shell {
  min-height: 100dvh; display: grid; place-items: center;
  padding: max(24px, var(--safe-t)) max(24px, var(--safe-r)) max(24px, var(--safe-b)) max(24px, var(--safe-l));
  background: radial-gradient(120% 80% at 50% -10%, var(--accent-glow), transparent 60%), var(--bg);
}
.auth-card { width: 100%; max-width: 400px; background: var(--surface); border: 1px solid var(--border-strong); border-radius: var(--r-2xl); box-shadow: var(--shadow-pop); padding: 34px 30px 30px; }
@media (max-width: 460px) {
  .auth-shell {
    padding: max(16px, var(--safe-t)) max(16px, var(--safe-r)) max(16px, var(--safe-b)) max(16px, var(--safe-l));
    align-content: safe center;
  }
  .auth-card { padding: 26px 22px 24px; }
}
.auth-brand { display: flex; align-items: center; gap: 11px; justify-content: center; margin-bottom: 24px; }
.auth-word { font-weight: 800; letter-spacing: -0.02em; font-size: 1.15rem; text-align: left; }
.auth-word small { display: block; color: var(--ink-faint-text); font-weight: 600; font-size: 0.6rem; letter-spacing: 0.14em; text-transform: uppercase; }
.auth-h { font-size: 1.4rem; margin: 0 0 4px; text-align: center; }
.auth-sub { margin: 0 0 20px; text-align: center; color: var(--ink-muted); font-size: 0.9rem; line-height: 1.55; }
.auth-form { display: flex; flex-direction: column; gap: 14px; }
.auth-field { display: flex; flex-direction: column; gap: 6px; }
.auth-field > span { font-size: 0.78rem; font-weight: 650; color: var(--ink-muted); }
.auth-submit { margin-top: 4px; padding-top: 11px; padding-bottom: 11px; }
.auth-err { background: var(--danger-soft); color: var(--danger-text); border-radius: var(--r-sm); padding: 10px 13px; font-size: 0.84rem; margin-bottom: 16px; line-height: 1.5; }
.auth-alt { margin: 18px 0 0; text-align: center; font-size: 0.86rem; color: var(--ink-muted); }
.auth-alt a { color: var(--accent-text); font-weight: 650; }
.auth-alt a:hover { text-decoration: underline; }

/* The first-account-is-the-administrator notice. Deliberately loud on the sign-up page: it is the one fact
   about this form whose consequences are permanent, and burying it in small grey text would be a way of
   technically having said it. */
.auth-first {
  border: 1px solid var(--accent-line); background: var(--accent-soft); color: var(--ink);
  border-radius: var(--r-md); padding: 12px 14px; margin-bottom: 18px; font-size: 0.85rem; line-height: 1.55;
}
.auth-first strong { color: var(--accent-text); }

/* ============================================================
   Feature list (what an account may use)
   ============================================================ */
.feat { display: flex; gap: 14px; align-items: flex-start; padding: 15px 0; border-top: 1px solid var(--border); }
.feat:first-child { border-top: none; padding-top: 4px; }
.feat-mark { width: 32px; height: 32px; flex: none; border-radius: var(--r-sm); display: grid; place-items: center; background: var(--surface-2); border: 1px solid var(--border); color: var(--ink-faint-text); }
.feat-mark svg { width: 17px; height: 17px; }
/* The mark is a glyph and its bar is 3:1, and it moves anyway — because it is drawn on its own 14% green
   wash, and green on green measures 2.87:1 in daylight. The fill and the hairline stay on `--ok`; only the
   ink moves, and it reads 5.27 there now. #168. */
.feat.on .feat-mark { background: color-mix(in oklab, var(--ok) 14%, transparent); border-color: color-mix(in oklab, var(--ok) 34%, transparent); color: var(--ok-text); }
.feat-body { min-width: 0; flex: 1; }
.feat-name { display: flex; align-items: center; gap: 9px; flex-wrap: wrap; font-weight: 700; font-size: 0.96rem; }
.feat-desc { color: var(--ink-muted); font-size: 0.87rem; margin-top: 3px; line-height: 1.55; }
.feat-meta { color: var(--ink-faint-text); font-size: 0.78rem; margin-top: 5px; }

/* ============================================================
   Hubs
   ============================================================ */
.hub { display: flex; gap: 14px; align-items: flex-start; }
.hub-mark { width: 38px; height: 38px; flex: none; border-radius: var(--r-md); display: grid; place-items: center; background: var(--surface-2); border: 1px solid var(--border); color: var(--ink-muted); }
.hub-mark svg { width: 20px; height: 20px; }
.hub.live .hub-mark { background: var(--accent-soft); border-color: var(--accent-line); color: var(--accent-text); }
.hub-body { flex: 1; min-width: 0; }
.hub-name { font-weight: 750; font-size: 1rem; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
/* What was `.hub-meta` — a flex row of three spans, one of them a date glued to the word "Linked" —
   is a spec list now. Those were label/value pairs written as sentences: see `.facts`. */
.hub-actions { margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap; }

/* ============================================================
   Model browser — the admin centrepiece
   ============================================================ */
.mdl-bar { display: flex; flex-wrap: wrap; gap: 10px; align-items: center; margin-bottom: 14px; }
.mdl-search { flex: 1 1 260px; min-width: 200px; }
.mdl-count { font-size: 0.82rem; color: var(--ink-faint-text); white-space: nowrap; font-variant-numeric: tabular-nums; }
.mdl-filters { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; }

.mdl-id { font-family: var(--font-mono); font-size: 0.8rem; color: var(--ink); overflow-wrap: anywhere; }
.mdl-name { font-size: 0.78rem; color: var(--ink-faint-text); margin-top: 2px; }
.mdl-price { font-family: var(--font-mono); font-size: 0.82rem; font-variant-numeric: tabular-nums; white-space: nowrap; }
.mdl-price .was { color: var(--ink-faint-text); text-decoration: line-through; margin-right: 6px; }

/* Drift is the one thing on this screen that must not be findable only by squinting: it means we are
   quoting a price that upstream has already moved away from, and every request in between costs real
   money. So it gets a colour, a border and its own row under the model rather than a subtle asterisk. */
.mdl-drift {
  display: flex; flex-wrap: wrap; gap: 6px 12px; align-items: center;
  margin-top: 7px; padding: 7px 11px; border-radius: var(--r-sm);
  font-size: 0.79rem; line-height: 1.4;
  border: 1px solid color-mix(in oklab, var(--warn) 40%, transparent);
  background: color-mix(in oklab, var(--warn) 12%, transparent);
  color: var(--ink);
}
.mdl-drift.rose { border-color: color-mix(in oklab, var(--danger) 42%, transparent); background: var(--danger-soft); }
.mdl-drift b { font-weight: 750; }
.mdl-drift .mdl-price { font-size: 0.79rem; }

.mdl-editor { border: 1px solid var(--accent-line); background: var(--surface-2); border-radius: var(--r-md); padding: 16px; margin-top: 10px; }
.mdl-editor h3 { font-size: 0.95rem; margin: 0 0 3px; }
.mdl-editor .row { margin-bottom: 12px; }
.mdl-editor-foot { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 4px; }

@media (max-width: 900px) {
  .tbl-wrap { margin: 0 -16px; padding: 0 16px; }
  .tbl th, .tbl td { padding-left: 7px; padding-right: 7px; }
}

/* ============================================================
   Pager
   ============================================================ */
.pager { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-top: 16px; flex-wrap: wrap; }
.pager-where { font-size: 0.82rem; color: var(--ink-faint-text); font-variant-numeric: tabular-nums; }
.pager-btns { display: flex; gap: 8px; }

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

/* ---- The model picker ----------------------------------------------------------------------------------
   A popup rather than a field, because choosing a model is a decision made against a list — what it costs,
   what it can do, whether we can even meter it — and none of that fits beside a text input. The veil dismisses
   on click; the panel stops the click. */
/* The veil covers the glass — including the notch and the home indicator, which is right; a dimmed screen
   should be dimmed all the way to the edges. What must not do that is the panel inside it, so the veil's
   own padding is what holds it clear. */
.dlg-veil {
  position: fixed; inset: 0; z-index: var(--z-modal);
  background: oklch(0 0 0 / 0.55);
  display: flex; align-items: center; justify-content: center;
  padding: max(24px, var(--safe-t)) max(24px, var(--safe-r)) max(24px, var(--safe-b)) max(24px, var(--safe-l));
  animation: dlg-in var(--dur-2) ease;
}
@keyframes dlg-in { from { opacity: 0; } to { opacity: 1; } }

.dlg {
  background: var(--surface); border: 1px solid var(--border); border-radius: var(--r-lg);
  box-shadow: var(--shadow-2); width: min(760px, 100%); max-height: min(720px, 88dvh);
  display: flex; flex-direction: column; overflow: hidden;
}
.dlg-head { display: flex; align-items: flex-start; gap: 16px; padding: 18px 20px 12px; }
.dlg-head h2 { font-size: 1.05rem; margin: 0 0 3px; }
.dlg-head p { margin: 0; font-size: 0.86rem; line-height: 1.5; max-width: 60ch; }
.dlg-head > button { margin-left: auto; flex: none; }
.dlg-bar { display: flex; align-items: center; gap: 12px; padding: 0 20px 14px; border-bottom: 1px solid var(--border); }
.dlg-bar .mdl-search { flex: 1; min-width: 0; }
/* The second bar is the upstream tab's filters, which wrap — and a row of controls that wrapped without
   room to breathe reads as one long control. Padding on top because the bar above it has none. */
.dlg-bar.mdl-filters { flex-wrap: wrap; gap: 8px; padding-top: 12px; }
/* Pushed to the end of the row it wraps onto, so "24 of 312" reads as a count of the list rather than as a
   label belonging to whichever checkbox it happened to land beside. */
.dlg-bar.mdl-filters .mdl-count { margin-left: auto; }
.dlg-body { overflow-y: auto; padding: 10px; display: flex; flex-direction: column; gap: 6px; }

/* The picker that absorbed the models screen carries a price editor with two fields side by side, and 760px
   puts those two fields at about twenty characters each. Nothing else opens a dialog, so this is a modifier
   rather than a change to .dlg — a confirmation with one sentence in it should stay narrow. */
.dlg-wide { width: min(920px, 100%); }

/* A row is a choose-button and, beside it, the button that opens its prices. Two buttons rather than one
   with a button inside it: nesting them is invalid, and the browser's repair of it swallows the click. */
.pick-wrap { display: flex; align-items: center; gap: 6px; }
.pick-wrap .pick { flex: 1; min-width: 0; }
.pick-edit { flex: none; }

.pick {
  display: flex; align-items: center; gap: 14px; text-align: left; width: 100%;
  background: transparent; border: 1px solid transparent; border-radius: var(--r-sm);
  padding: 11px 13px; cursor: pointer; color: inherit; font: inherit;
  transition: background var(--dur-1), border-color var(--dur-1);
}
.pick:hover:not(:disabled) { background: var(--surface-2); border-color: var(--border); }
.pick.on { background: var(--accent-soft); border-color: var(--accent-line); }
.pick.blocked, .pick:disabled { cursor: not-allowed; opacity: 0.72; }
.pick-main { min-width: 0; flex: 1; }
.pick-id { font-family: var(--font-mono); font-size: 0.86rem; font-weight: 650; word-break: break-all; }
.pick-name { font-size: 0.82rem; color: var(--ink-muted); margin-top: 1px; }
.pick-detail { font-size: 0.78rem; color: var(--ink-faint-text); margin-top: 3px; }
/* Why a row can't be chosen. Muted, not red: in the upstream tab every model we have never priced carries
   one of these, so a danger colour would paint four hundred rows as failures and leave nothing to say a
   failure with. `.bad` is that something — see the delisted marker in ModelPicker. */
.pick-blocked { font-size: 0.78rem; color: var(--ink-muted); margin-top: 5px; line-height: 1.45; }
.pick-blocked.bad { color: var(--danger-text); }

/* What choosing this row gives up. Warn-tinted rather than muted or red, and the distinction is the point:
   `.pick-blocked` is a row saying no, `.bad` is something broken, and this is a row saying yes with a
   consequence attached. It is a boxed note rather than another line of small print because it is the one
   thing on the row an operator must not skim — the same weight `.mdl-drift` is given, for the same reason. */
.pick-uncosted {
  font-size: 0.78rem; line-height: 1.45; margin-top: 6px; padding: 7px 9px;
  border-radius: var(--r-sm); color: var(--ink);
  border: 1px solid color-mix(in oklab, var(--warn) 40%, transparent);
  background: color-mix(in oklab, var(--warn) 12%, transparent);
}
.pick-uncosted b { font-weight: 750; }
.pick-side { display: flex; align-items: center; gap: 8px; flex: none; flex-wrap: wrap; justify-content: flex-end; }

/* What is in force, as a line rather than a field — the value is not typed here any more. */
.chosen { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; margin-top: 4px; }
.chosen-id { font-family: var(--font-mono); font-size: 0.88rem; font-weight: 650; word-break: break-all; }
.chosen-none { font-size: 0.88rem; color: var(--ink-muted); font-style: italic; }
.chosen > button { margin-left: auto; }

@media (max-width: 640px) {
  .dlg-veil { padding: 0; align-items: stretch; }
  .dlg { max-height: 100dvh; border-radius: 0; border: none; }
  .pick { flex-wrap: wrap; }
  .pick-side { justify-content: flex-start; }
  /* The prices button drops under the row it belongs to rather than squeezing it — at this width the id is
     already breaking across lines and a 70px column beside it leaves nothing for the name. */
  .pick-wrap { flex-wrap: wrap; }
  .pick-edit { margin: 0 13px 8px; }
}

/* ---------------------------------------------------------------------------------------------------
   The usage timeline.

   Bars in CSS rather than a charting library, and that is a decision rather than a shortcut: this is one
   series of at most a few dozen points on one screen, and a library that drew it would be a dependency to
   keep current, to ship in the image and to explain — for a shape that is fourteen lines of flexbox.

   The rule it has to obey is the one every chart in this console has to obey: it is a shape you scan and a
   number you can read, never only the first. Every column carries its own figures in a `title`, so nothing
   here is a picture you have to take on trust.

   An empty column is drawn and not skipped. A period with no traffic is a fact, and an axis that jumps from
   Monday to Thursday hides it — which is the one thing a chart of spending must not do. `min-height` on the
   bar is what makes a nought-dollar hour still occupy its slot rather than vanishing into the baseline. */
.spark { display: flex; align-items: flex-end; gap: 2px; height: 128px; padding: 8px 0 0; border-bottom: 1px solid var(--border); }
.spark-col { flex: 1 1 0; display: flex; align-items: flex-end; height: 100%; min-width: 2px; }
.spark-bar {
  width: 100%;
  min-height: 2px;
  border-radius: var(--r-xs) var(--r-xs) 0 0;
  background: color-mix(in oklab, var(--accent) 62%, transparent);
  transition: background var(--dur-1);
}
.spark-col:hover .spark-bar { background: var(--accent); }

/* A step in which something failed is tinted rather than annotated. The count is in the title with
   everything else; this is only what makes it worth hovering. */
.spark-bar.bad { background: color-mix(in oklab, var(--danger) 62%, transparent); }
.spark-col:hover .spark-bar.bad { background: var(--danger); }

.spark-axis { display: flex; justify-content: space-between; gap: 12px; margin-top: 7px; font-size: 0.76rem; color: var(--ink-faint-text); }
