/*
 * Panel styling — plain CSS, no framework, no build step, no external font.
 *
 * Everything here is served from this box. The Content-Security-Policy in
 * index.php is `default-src 'self'`, so a webfont or a CDN stylesheet would not
 * merely be against the rules, it would fail to load — which is the point. The
 * panel has to render fully with no external network at all, because the moment
 * it is most needed is the moment the network is what is broken.
 *
 * The font stack is system fonts only, for the same reason.
 *
 * ===========================================================================
 * The theme, and the one rule it is not allowed to break
 * ===========================================================================
 *
 * Grimdark, and specifically the cogitator end of it: cold iron, oil-dark
 * panels, bone parchment text, stencilled labels, square plate corners. Not the
 * blood-and-gold end — and that choice is doing real work rather than being a
 * preference.
 *
 * The dashboard's one job is that a glance tells you whether anything is wrong.
 * A palette where everything is blood red and bone would put amber next to red
 * next to rust and make the three indistinguishable at speed, which is the whole
 * signal gone in exchange for atmosphere. So the frame is deliberately
 * **achromatic** — greys, iron, bone — and the four status hues are the only
 * saturated colours anywhere on the page. The theme and the constraint end up
 * pulling the same way: a monochrome frame makes amber *more* legible, not less.
 *
 * The same reasoning removed gold. Brass links are the obvious 40k move and they
 * would sit inches from an amber warning pill, in the one interface where those
 * two must never be confusable. Links, focus rings and hover borders are bone —
 * achromatic, so nothing competes with a status colour.
 *
 * The rule that keeps that true is **saturation, not hue**. Bone sits at roughly
 * the same hue as amber — 43° against 48° — and is still unmistakable beside it,
 * because bone is 22% saturated and amber is 87%. So the constraint on any chrome
 * colour is that it stay desaturated; a hue-distance rule would both fail the
 * accent already here and happily admit a fully saturated brass.
 *
 * tests/ThemeTest.php holds all of it: chrome stays achromatic, the four status
 * hues stay far apart, every pairing clears its contrast floor, and no colour is
 * written outside `:root`. That last one is not fussiness — a link colour was
 * once hardcoded three declarations down and went on being blue for a whole
 * theme after the variable it was meant to track had changed.
 */

:root {
  /* Iron and oil. Cold greys, near-black, no hue that could read as a status. */
  --bg: #0b0d0f;
  --panel: #131619;
  --panel-2: #1b1f23;
  --panel-3: #242a30;
  --line: #2b3138;

  /* Bone and parchment — warm enough to feel like printed vellum on a terminal,
     desaturated enough that it is never mistaken for a warning.
     --muted is as dark as it can go and still clear 4.5:1 on --panel-3, which is
     the surface a tile takes on hover; it read fine everywhere else and failed
     only there, which is precisely the kind of miss nobody makes by eye. */
  --text: #d8d0bd;
  --muted: #9a9485;

  /* Status colours. The ONLY saturated things on the page, and the reason the
     rest of the palette is grey.
     Every pill carries a word as well as a colour, so nothing here depends on
     telling two hues apart — but they are kept far apart anyway, and
     tests/ThemeTest.php asserts both the separation and the contrast.

     Amber and red are the pair that has to survive a glance, and the first cut
     of this palette put them 34° apart, which is close enough that "degraded"
     and "down" are one colour across a desk. Amber moved yellower to 48° and red
     moved to a truer 5°, giving 43°. Red also lifted: it cleared 4.5:1 on a card
     but not on a card being hovered, and hover is when you are reading it. */
  --ok: #3ac46c;
  --ok-bg: #102419;
  --warn: #f0c828;
  --warn-bg: #2d2405;
  --bad: #f06a5d;
  --bad-bg: #2c1310;
  --info: #6aa0dd;
  --info-bg: #121f2e;

  /* Chrome accent: bone, not brass. See the header. */
  --accent: #c9c0aa;

  /* The lit top edge of a plate — bone at a few percent, so a raised surface
     catches light the way stamped metal does. Named because it is bone, and a
     literal here would stop tracking --text the day bone changes. */
  --edge: rgba(216, 208, 189, 0.06);
  --edge-soft: rgba(216, 208, 189, 0.04);

  /* Sky inside the oculus: darker than --bg, because a window onto space has to
     read as further away than the plate around it. */
  --void: #04060a;
  /* The hard cut a plate is set into — darker than --bg, so the seam reads as a
     gap between two pieces of material rather than a drawn line. */
  --seam: #080a0c;

  /*
   * The shallow pane recess — a cut edge, a shadow the near wall throws, and a
   * lit lower lip, and deliberately nothing deeper.
   *
   * One declaration because three surfaces need exactly this and no other: the
   * login card, the Deck's scope face, and the command-output blocks on job
   * detail. All three hold text somebody reads, which is what rules out the
   * oculus's aperture bevel — `inset 0 6px 12px -4px rgba(0,0,0,0.95)` is right
   * for an empty window and would darken the band the first lines of output sit
   * in.
   *
   * A custom property rather than a shared selector because `box-shadow` does
   * not cascade additively: a consumer that also wants a glow has to restate the
   * whole list, and restating it is how the three drift.
   */
  --pane-bevel:
    inset 0 0 0 1px #000,
    inset 0 2px 5px -3px rgba(0, 0, 0, 0.9),
    inset 0 -1px 0 rgba(240, 234, 219, 0.14);
  /* The face of a raised mechanical key, and the same face under the cursor.
     Lighter than --panel-3: a key catches more light than the plate it is set
     into, which is most of what makes it read as standing proud of it. */
  --key: #2b3036;
  --key-lit: #333a41;
  /* The oculus's far dot field — dimmer than --text so the two depths read as
     distance rather than as one field with a gap in it. */
  --oc-far: rgba(216, 208, 189, 0.42);

  /* Stencilled labels — the typographic half of the theme. No webfont can be
     loaded, so the gothic-industrial feel comes from case, tracking and weight
     rather than from a typeface. */
  --stencil: 0.11em;

  /* ---------------------------------------------------------------------
     Phosphor — the cogitator's own cast, and why it is not green
     ---------------------------------------------------------------------

     A CRT terminal wants green, and the console page is the one place in this
     panel where the ship-console idiom is literal rather than decorative. But
     the standing rule is that the four status hues are the only saturated
     colours that carry meaning, and a saturated green would sit one shade from
     --ok on a page whose whole job is to make a red exit code findable in a wall
     of output.

     So this is bone with a green cast — 18% saturated, inside the same band the
     chrome occupies — and the CRT reading comes from the scanline texture and
     the still glow rather than from the hue. It carries no meaning: every status
     word in the transcript is a status colour, and everything else is phosphor.

     --phosphor-glow is the halo, deliberately weak. A text-shadow strong enough
     to be obvious is a text-shadow that blurs the character underneath, and this
     is a surface somebody reads log lines on during an incident. */
  --phosphor: #cdd6c4;
  --phosphor-dim: #8d9a86;
  --phosphor-glow: rgba(205, 214, 196, 0.28);

  /* The dark bar of a scanline. Black at a few percent, so it reads as the gap
     between two lines of a raster rather than as a stripe drawn over the text. */
  --scanline: rgba(0, 0, 0, 0.30);
}

* { box-sizing: border-box; }

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

code, pre, .age, time {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
}

/* Bone, not brass. A gold link beside an amber warning is the one confusion
   this interface cannot afford. */
a { color: var(--accent); text-decoration: none; }
a:hover { text-decoration: underline; }
a:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

.muted { color: var(--muted); }
.bad { color: var(--bad); }
/* The other two status hues as text utilities, so the runner tile's restart line
   can carry a tone without a rule of its own. Same four names the tiles use. */
.ok { color: var(--ok); }
.warn { color: var(--warn); }

/* The runner tile's restart line — how the *previous* process ended.
   Its own block so it reads as a separate fact from the meta above it, which is
   about the process that is running now. */
.tile-restart { margin-top: 0.35rem; font-size: 0.82rem; line-height: 1.35; }
.tile-restart.is-empty { display: none; }

/* --- Top bar ------------------------------------------------------------ */

.topbar {
  display: flex;
  align-items: center;
  gap: 1.5rem;
  padding: 0.75rem 1.25rem;
  background: linear-gradient(180deg, var(--panel-2), var(--panel));
  border-bottom: 1px solid var(--line);
  box-shadow: inset 0 1px 0 var(--edge), 0 1px 0 #000;
  flex-wrap: wrap;
}

.brand {
  font-weight: 700;
  letter-spacing: var(--stencil);
  text-transform: uppercase;
  font-size: 0.95rem;
  color: var(--text);
}

/* The cog. A character, not an image — the CSP forbids a remote one and an
   inline SVG would be one more thing to keep in two places. */
.brand::before { content: "\2699"; margin-right: 0.45rem; color: var(--muted); }
.topbar nav { display: flex; gap: 1rem; flex: 1; flex-wrap: wrap; }

/* --- The nav disclosure -------------------------------------------------- */

/*
 * Twelve links. Wide, they are a row; narrow, they are a `<details>` the reader
 * opens — because wrapped they took three or four lines and pushed the page below
 * the fold before anything had been read.
 *
 * Native `<details>` and no JavaScript, per the panel's rule that every page works
 * with scripting off. The element brings its own open/close state, keyboard
 * handling and ARIA; a scripted menu reimplements all three and usually gets one
 * wrong.
 *
 * ==========================================================================
 * Making a closed <details> show its content takes two overrides, not one
 * ==========================================================================
 *
 * Above the breakpoint this must not behave like a disclosure at all. Engines hide
 * a closed details' content by two different mechanisms and both have to be
 * answered, or the nav is invisible on desktop in whichever half got missed:
 *
 *   Older engines hide the slotted children, which a `display` on the child
 *   overrides.
 *
 *   Chrome 121+ hides `::details-content` with `content-visibility`, which a
 *   `display` on the child does not touch. An engine that does not know that
 *   pseudo-element ignores the rule, so carrying both is safe rather than
 *   belt-and-braces.
 */
.nav-disclosure { flex: 1; min-width: 0; }

@media (min-width: 721px) {
  .nav-disclosure > summary { display: none; }
  .nav-disclosure > nav { display: flex; }
  .nav-disclosure::details-content { content-visibility: visible; block-size: auto; }
}

@media (max-width: 720px) {
  /* A plain summary keeps its disclosure triangle, which is right here: it says
     "this opens" without a word of explanation and rotates itself when it does. */
  .nav-disclosure > summary {
    cursor: pointer;
    padding: 0.3rem 0;
    color: var(--muted);
    text-transform: uppercase;
    letter-spacing: var(--stencil);
    font-size: 0.8rem;
    font-weight: 600;
  }

  .nav-disclosure > summary:hover { color: var(--text); }

  /* Focus has to be visible on the one control that reaches the rest of the
     panel. Same outline the action buttons use. */
  .nav-disclosure > summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

  /*
   * Open, the list overlays the page rather than pushing it down. Pushing is what
   * the wrapping row already did and is the thing being fixed — a menu that
   * displaces content moves whatever the reader was looking at.
   */
  .nav-disclosure[open] > nav {
    position: absolute;
    left: 0;
    right: 0;
    top: 100%;
    z-index: 3;
    flex-direction: column;
    gap: 0;
    padding: 0.5rem 1.25rem 0.9rem;
    background: var(--panel-2);
    border-bottom: 1px solid var(--line);
    box-shadow: 0 10px 20px -14px #000;
  }

  .nav-disclosure[open] > nav a { padding: 0.5rem 0; }
}
.topbar nav a {
  color: var(--muted);
  padding: 0.2rem 0;
  text-transform: uppercase;
  letter-spacing: 0.08em;
  font-size: 0.8rem;
  font-weight: 600;
}
.topbar nav a:hover { color: var(--text); }
/* Bone, not blue: the active marker sits in the same eyeline as status pills. */
.topbar nav a.active { color: var(--text); border-bottom: 2px solid var(--accent); }
.signout { display: flex; gap: 0.75rem; align-items: center; margin: 0; }

/* Sign out is a submit button, because it is a mutation and every mutation here
   is a POST with a CSRF token. It reads as a link, because it sits in the topbar
   beside the nav and a plated button there would claim more weight than the
   thing it does deserves — and because it looked like a link before, and a
   security fix should not be discovered as a visual change.

   Not `appearance: none` alone: the button rules above set a plate, a border and
   a background that all have to be undone deliberately rather than hoped away. */
button.linklike {
  padding: 0;
  background: none;
  border: none;
  color: var(--muted);
  font-size: inherit;
  font-weight: inherit;
  text-transform: none;
  letter-spacing: normal;
}

button.linklike:hover:not(:disabled) {
  /* Overrides the plate the shared :hover rule would otherwise reapply. */
  background: none;
  border-color: transparent;
  color: var(--text);
  text-decoration: underline;
}

main { padding: 1.25rem; max-width: 1400px; margin: 0 auto; }
h1 {
  font-size: 1.15rem;
  margin: 0 0 1rem;
  text-transform: uppercase;
  letter-spacing: var(--stencil);
  font-weight: 700;
}

h1::after {
  content: "";
  display: block;
  height: 1px;
  margin-top: 0.5rem;
  background: linear-gradient(90deg, var(--line), transparent);
}

h2 {
  font-size: 0.85rem;
  margin: 0 0 0.5rem;
  text-transform: uppercase;
  letter-spacing: var(--stencil);
  color: var(--muted);
  font-weight: 700;
}

footer {
  padding: 1.5rem 1.25rem;
  text-align: center;
  font-size: 0.78rem;
  letter-spacing: 0.05em;
  border-top: 1px solid var(--line);
  margin-top: 2rem;
}

/* --- Banners ------------------------------------------------------------ */

.banner {
  padding: 0.9rem 1.1rem;
  margin: 0 1.25rem 1rem;
  border-radius: 0;
  border-left: 5px solid;
  position: relative;
}

main .banner { margin-left: 0; margin-right: 0; }

/* §4.3. Loud on purpose: stale ops data presented as current is worse than
   none, so this must not be mistakable for a decorative notice. */
.banner-degraded { background: var(--warn-bg); border-color: var(--warn); }
.banner-degraded ul { margin: 0.5rem 0 0; padding-left: 1.2rem; }
.banner-note { margin: 0.5rem 0 0; font-size: 0.9rem; color: var(--muted); }
.banner-ok { background: var(--ok-bg); border-color: var(--ok); }
.banner-bad { background: var(--bad-bg); border-color: var(--bad); }
.banner-note-inline { background: var(--warn-bg); border-color: var(--warn); }

/* --- Cards and tiles ---------------------------------------------------- */

/* Square corners and an inset top highlight: a bolted plate rather than a card.
   Removing the radius is the single largest visual change here and it costs
   nothing — no markup, no weight, no legibility. */
.card {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 0;
  padding: 1rem 1.15rem;
  margin-bottom: 1.25rem;
  box-shadow: inset 0 1px 0 var(--edge-soft);
}

.muted-section { opacity: 0.82; }
.section-note { color: var(--muted); font-size: 0.9rem; margin: 0 0 0.85rem; }

.tiles {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(230px, 1fr));
  gap: 0.85rem;
  margin-bottom: 0.5rem;
}

.tile {
  background: var(--panel-2);
  border: 1px solid var(--line);
  /* The status edge. Wider than it was, because it is now the only colour on an
     otherwise grey plate and it is what the eye is meant to land on. */
  border-left: 5px solid var(--muted);
  border-radius: 0;
  padding: 0.8rem 0.9rem;
}

.tile-ok { border-left-color: var(--ok); }
.tile-bad { border-left-color: var(--bad); }
.tile-warn { border-left-color: var(--warn); }

/* A tile that is a link stays a tile.
 *
 * The colour down the left edge is the status signal and it must not become a
 * link colour, so text decoration and colour are inherited rather than set:
 * whatever the tile said before it was clickable, it says now. Only the cursor
 * and a border lift on hover mark it as navigable. */
a.tile {
  display: block;
  color: inherit;
  text-decoration: none;
}

a.tile:hover,
a.tile:focus-visible {
  border-color: var(--accent);
  background: var(--panel-3, var(--panel-2));
}

a.tile:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* The operator's own words on the runner tile, set apart from the panel's.
   Italic and quoted, because everything else in a tile is this platform
   reporting and this one line is a person explaining. It keeps its box when
   empty — `is-empty` collapses the margin but not the element — so a note
   arriving on a poll has somewhere to land without the tile changing height
   under the pointer. */
.tile-note {
  font-size: 0.85rem;
  font-style: italic;
  color: var(--text);
  margin-bottom: 0.4rem;
}
.tile-note.is-empty { margin: 0; }

/* Two verbs and a shared reason, so the row reads as one control rather than as
   two unrelated buttons that happen to sit together. */
.runner-control .action-row { margin-bottom: 0; }

.tile-label { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.tile-value { font-size: 1.25rem; font-weight: 600; margin: 0.15rem 0 0.35rem; }
.tile-sub { font-size: 0.9rem; margin-bottom: 0.4rem; }
.tile-meta { font-size: 0.82rem; color: var(--muted); line-height: 1.7; }

.queue { color: var(--muted); font-size: 0.9rem; }

/* --- Pills -------------------------------------------------------------- */

/* Stencilled plates, not lozenges. The word is doing as much work as the colour
   — every pill carries one, so a reader who cannot separate two hues still gets
   the answer. */
.pill {
  display: inline-block;
  padding: 0.12rem 0.5rem;
  border-radius: 0;
  font-size: 0.72rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.07em;
  white-space: nowrap;
  border: 1px solid currentColor;
}

.pill-ok { background: var(--ok-bg); color: var(--ok); }
.pill-warn { background: var(--warn-bg); color: var(--warn); }
.pill-bad { background: var(--bad-bg); color: var(--bad); }
.pill-info { background: var(--info-bg); color: var(--info); }
.pill-neutral { background: var(--panel-2); color: var(--muted); }

/* --- Tables ------------------------------------------------------------- */

table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
thead th {
  text-align: left;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: var(--stencil);
  color: var(--muted);
  border-bottom: 1px solid var(--line);
  padding: 0.4rem 0.6rem;
  font-weight: 600;
}
tbody td { padding: 0.55rem 0.6rem; border-bottom: 1px solid var(--line); vertical-align: top; }
tbody tr:last-child td { border-bottom: none; }

/*
 * A table wider than the phone scrolls inside itself, never sideways.
 *
 * Measured, not assumed: rendering the views at 390px gave scrollWidth 752 on
 * Devices, 649 on Audit, 620 on Jobs and so on against a 390px viewport —
 * eleven of seventeen overflowing. The cause is data tables, and the columns
 * that will not wrap are the ones carrying an unbreakable string: a job ULID, a
 * device id, a UTC timestamp. Nothing here can be made narrower without making
 * it useless.
 *
 * `display: block` turns the table into its own scroll container while its rows
 * stay `table-row-group`, so an anonymous table box keeps the internal layout
 * and only the box scrolls. The alternative was wrapping thirty-one tables
 * across fourteen views in a div, which is the same result reached by editing
 * every call site and forgetting one.
 *
 * The point is not that the operator gets to scroll a table sideways. It is
 * that the **page** stops being wider than the screen — a whole layout shifted
 * under the thumb, with the nav and every card dragged along, because one cell
 * held a ULID.
 */
@media (max-width: 720px) {
  table {
    display: block;
    max-width: 100%;
    overflow-x: auto;
    /* Momentum scrolling on iOS; inert everywhere else. */
    -webkit-overflow-scrolling: touch;
  }

  /*
   * `table.nested` stays a table, because its `width: auto` is a decision.
   * It renders a heartbeat report inside a cell of the table above it, and is
   * already bounded by that cell — which now scrolls. Made a block it would
   * stretch to fill instead, quietly undoing the narrowing it was given.
   */
  table.nested { display: table; }
}

/* The live screen is not just first in the list, it is visually distinct —
   sorting alone leaves it looking like the first of many equals. */
.device-live .device-label { font-weight: 700; }
.device-quiet { color: var(--muted); }
.device-label { font-weight: 600; }

.destination code { font-size: 0.8rem; word-break: break-all; }

.empty { color: var(--muted); padding: 0.75rem 0; }
.empty.bad { color: var(--bad); }

/* --- Forms -------------------------------------------------------------- */

.filters {
  display: flex;
  gap: 1rem;
  align-items: flex-end;
  flex-wrap: wrap;
  margin-bottom: 1rem;
  padding: 0.85rem 1rem;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 0;
}

/* Field labels are read, not scanned, so they stay sentence case — the stencil
   is for headings and column headers, and putting it here would cost legibility
   in the one place a wrong reading changes what gets submitted. */
label { display: block; font-size: 0.85rem; color: var(--muted); }
label .hint { display: block; font-weight: 400; font-size: 0.8rem; margin-top: 0.15rem; }
label .req { color: var(--bad); }

input[type="text"], input[type="password"], input[type="number"], input[type="date"], select {
  display: block;
  margin-top: 0.25rem;
  padding: 0.4rem 0.55rem;
  background: var(--bg);
  border: 1px solid var(--line);
  border-radius: 0;
  color: var(--text);
  font: inherit;
  min-width: 12rem;
}

/* Bone, not blue. A focus ring is chrome; it must not borrow a status hue. */
input:focus, select:focus, button:focus { outline: 2px solid var(--accent); outline-offset: 1px; }

/* Every button in the panel is this plate. The variants below change size or
   emphasis and inherit the rest — one definition of what a control looks like,
   so the four of them cannot drift apart. */
button {
  padding: 0.45rem 1rem;
  background: var(--panel-2);
  color: var(--text);
  border: 1px solid var(--line);
  border-radius: 0;
  font: inherit;
  font-size: 0.8rem;
  font-weight: 700;
  text-transform: uppercase;
  letter-spacing: 0.07em;
  cursor: pointer;
}

button:hover:not(:disabled) { background: var(--panel-3); border-color: var(--accent); }
button:disabled { opacity: 0.5; cursor: not-allowed; }

/* The one button on this panel that takes the platform's own machinery down.
   Marked by its edge rather than by a filled red block: a solid warning colour
   would out-shout the status tiles, which are the only things on the page whose
   colour is a reading rather than a label. */
button.danger { border-color: var(--bad); color: var(--bad); }
button.danger:hover:not(:disabled) { background: var(--panel-3); border-color: var(--bad); }
.clear { align-self: center; font-size: 0.85rem; }

.runform { display: flex; flex-direction: column; gap: 0.75rem; margin-top: 0.75rem; max-width: 34rem; }
.runform .checkbox input[type="checkbox"] { margin-top: 0.3rem; }
.task-meta { display: grid; grid-template-columns: max-content 1fr; gap: 0.25rem 1rem; font-size: 0.88rem; margin: 0 0 0.75rem; }
.task-meta dt { color: var(--muted); }
.task-meta dd { margin: 0; }

details summary { cursor: pointer; color: var(--accent); font-size: 0.9rem; }

/* --- Detail and output -------------------------------------------------- */

.detail { display: grid; grid-template-columns: max-content 1fr; gap: 0.35rem 1.25rem; font-size: 0.9rem; }
.detail dt { color: var(--muted); }
.detail dd { margin: 0; }

/* A recessed well, not a raised plate: output is something read out of the
   machine rather than a surface of the panel. It sits at page black, which is
   already darker than the card it is set into. */
/*
 * Log viewers carry a scanline texture — a pict-display treatment, and it is
 * strictly a *background*.
 *
 * The rule this follows is the one already in force for the aquila's `alt=""`:
 * decoration sits beside or behind real content and never becomes it. So the
 * texture is painted with `background-image` under the text, which means the
 * text is still text — selectable, findable with the browser's own search,
 * copyable into a ticket, and read by a screen reader exactly as before. A
 * filter or a blend mode applied to the `<pre>` itself would touch the glyphs,
 * and task output is the one thing on this panel somebody copies verbatim into
 * an incident note.
 *
 * The lines are 3px apart at a couple of percent contrast, which is texture at a
 * glance and invisible at reading distance. `--edge-soft` rather than a literal,
 * so it moves with the palette and cannot drift from it.
 *
 * Deliberately not animated. A rolling scanline over a log is a novelty the
 * first time and an obstacle every time after, on the page somebody opens
 * *because* something has gone wrong.
 */
pre.output, pre.params {
  background-color: var(--bg);
  background-image: repeating-linear-gradient(
    180deg,
    var(--edge-soft) 0,
    var(--edge-soft) 1px,
    transparent 1px,
    transparent 3px
  );
  /*
   * A recess rather than a border, at the shallow depth — see `--pane-bevel`.
   *
   * This is the split the Jobs and Tasks pass turns on, and it is keyed to the
   * *element that holds the text* rather than to the view it appears on. A view
   * is not a reliable proxy: `task_detail` is a detail view and carries tiles,
   * not output, while a list view could grow an output preview tomorrow. The
   * rule that matters is "lengthy text lives in a shallow pane", so it is
   * attached to the thing that is lengthy text.
   *
   * The list views take the fuller treatment by taking nothing extra at all —
   * their tables and tiles stay flat inside the cased `main`, which is the same
   * decision the dashboard's tiles were left on and for the same reason: these
   * are the surfaces carrying numbers somebody reads during an incident.
   */
  border: 0;
  border-radius: 1px;
  box-shadow: var(--pane-bevel);
  padding: 0.85rem;
  overflow-x: auto;
  font-size: 0.82rem;
  line-height: 1.5;
  /* Task output is arbitrary text from another system. Wrapping rather than
     letting one long line stretch the page. */
  white-space: pre-wrap;
  word-break: break-word;
  max-height: 32rem;
}

/*
 * The raw record pane.
 *
 * ============================================================================
 * Wrapping is display only. The bytes must not change.
 * ============================================================================
 *
 * A real heartbeat is a single-line JSON body of a couple of kilobytes, which
 * without this scrolls off the page horizontally with no end — correct
 * rendering of the bytes and an unreadable page. That was invisible against a
 * fixture and obvious against RhosFawr.
 *
 * `pre-wrap` keeps every space and newline the body actually contains while
 * allowing the line to break for display; `overflow-wrap: anywhere` lets it
 * break inside a long unbroken token, which is what a minified JSON body is.
 *
 * **Neither alters the text node.** A copy, a select-all or a
 * `document.querySelector('.raw-body').textContent` returns exactly what was
 * stored. That is the difference between wrapping for display and reformatting,
 * and it is why the fix is CSS: inserting breaks into the content, or
 * pretty-printing the JSON, would undo at the last step what the API layer is
 * careful about throughout. `subject_log_page.php` asserts the rendered text
 * still matches the stored bytes byte for byte.
 *
 * `word-break: break-word` is deliberately not used here. It is the older
 * spelling and browsers treat it as a hint; `overflow-wrap: anywhere` is the
 * one that reliably breaks a 2.6 kB token.
 */
pre.raw-body {
  background-color: var(--bg);
  border: 0;
  border-radius: 1px;
  box-shadow: var(--pane-bevel);
  padding: 0.85rem;
  font-size: 0.82rem;
  line-height: 1.5;
  white-space: pre-wrap;
  overflow-wrap: anywhere;
  max-height: 32rem;
  overflow-y: auto;
}

/* The UTC value printed beside a local one, on detail pages. Smaller and
   monospaced: it is the machine reading of the same instant, and it is there to
   be quoted or compared rather than scanned. */
.utc-alongside {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
  font-size: 0.78rem;
  margin-left: 0.4rem;
}

.pager { display: inline-block; margin-top: 0.75rem; }

/* --- Login -------------------------------------------------------------- */

.login-page { display: grid; place-items: center; min-height: 100vh; }
/*
 * The card as the lit pane at the bottom of the well.
 *
 * The bevel is deliberately *lighter* than the oculus pane's. That pane is an
 * empty aperture, so it can carry deep interior shadows; this one holds a
 * heading, two labels and a password field, and the oculus's
 * `inset 0 6px 12px -4px rgba(0,0,0,0.95)` would darken exactly the band the
 * wolf and the wordmark sit in. Same technique — a crisp cut edge, a lit lower
 * lip, light from above — at the depth text can live inside.
 *
 * `isolation` makes this a stacking context so the watermark can sit at
 * `z-index: -1`: behind every child, still in front of the card's own
 * background. `overflow: hidden` crops the watermark to the card, which is what
 * lets it be larger than the card and read as an emblem rather than a picture.
 */
.login {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  width: min(24rem, 92vw);
  padding: 1.5rem;
  border: 0;
  border-radius: 1px;
  background: var(--panel);
  box-shadow:
    var(--pane-bevel),
    /* The screen's own light on the metal around it — the same two stops the
       oculus uses, desaturated and cool rather than the reference's teal. */
    0 0 26px 2px rgba(206, 214, 224, 0.1),
    0 0 62px 16px rgba(170, 186, 202, 0.05);
}

/*
 * The watermark.
 *
 * Centred by `inset: 0; margin: auto` on an element with intrinsic size, which
 * needs no magic number tied to whatever height the mark is rendered at — and no
 * transform, which is the rule this whole treatment is built inside.
 *
 * Alpha is the number that matters. The card's text is `--text` on `--panel`,
 * which has a large contrast margin, and a bronze mark at five percent moves it
 * by a fraction of a step. It is kept there rather than pushed because a
 * watermark that competes with a password field is a watermark that has stopped
 * being a background.
 */
.login-watermark {
  position: absolute;
  inset: 0 0 12% 0;
  z-index: -1;
  margin: auto;
  opacity: 0.05;
  pointer-events: none;
}
.login form { display: flex; flex-direction: column; gap: 0.9rem; margin: 1rem 0; }
.login input { width: 100%; }

/* The only control on the page, so it takes the emphasised plate without needing
   a class in the markup — it used to be the only blue thing here, and losing
   that left the sole action looking like chrome. */
.login button { border-color: var(--accent); background: var(--panel-3); }

@media (max-width: 700px) {
  .topbar { gap: 0.75rem; }
  table { font-size: 0.82rem; }
  main { padding: 0.75rem; }
}

/* ---------------------------------------------------------------------------
   Device detail — state history and device logs
   --------------------------------------------------------------------------- */

.crumb { margin: 0 0 0.6rem; font-size: 0.9rem; }

/* The device name is the link; the id beside it is not. Clicking a label is the
   natural gesture and the id is there to be read, not aimed at. */
.device-link { color: inherit; text-decoration: none; }
.device-link:hover .device-label,
.device-link:focus-visible .device-label { color: var(--accent); text-decoration: underline; }

/* The current state, at the top of the history.
   Marked with a left edge rather than a background: a filled row would compete
   with the status pill in the first column, and the pill is the thing that has
   to be read at a glance. */
.history-current td { background: var(--panel-2); }
.history-current td:first-child { border-left: 3px solid var(--accent); }

.history td strong { font-variant-numeric: tabular-nums; }

/* Log messages are the one place arbitrary device text lands, so it wraps and
   breaks rather than stretching the table off the side of the page. */
.log-message {
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, "Liberation Mono", monospace;
  font-size: 0.85rem;
  word-break: break-word;
  max-width: 46ch;
}

/* ---------------------------------------------------------------------------
   The favourites strip
   ---------------------------------------------------------------------------
   The glance before the reading. It only works while it stays short — which is
   why `favourite` is its own flag rather than every alerting device.

   -------------------------------------------------------------------------
   Dark is the good state. Only the exception emits light.
   -------------------------------------------------------------------------

   Warn and bad glow; ok does not, and that asymmetry is the whole design. If
   healthy panels lit up too, the interesting one would stop standing out and
   reading the wall would become hunting for a different hue among lit things —
   which is a comparison, not a glance. A wall of dark plates means nothing needs
   you. One glowing plate is where the eye lands before it has read anything.

   So the glow is not decoration on the alarming states, it is the signal, and
   `ok` having none is the load-bearing half. tests/ThemeTest.php asserts both
   directions, because "we removed a filter from one selector" is not a thing any
   amount of reading the file will make obvious later.

   The status word sits under every skull regardless. A coloured eye alone is the
   row-of-dots problem this strip already solved once: the four hues are kept far
   apart and that is not sufficient, because separable side by side is not the
   same as separable when it is the only mark on a panel seen at speed. */

.watchstrip {
  display: grid;
  /* Square plates, as many as fit. auto-fill rather than auto-fit so a single
     favourite stays a plate instead of stretching across the whole row. */
  grid-template-columns: repeat(auto-fill, minmax(9rem, 1fr));
  gap: 0.75rem;
  margin: 0 0 1.5rem;
}

.watch {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  aspect-ratio: 1;
  padding: 0.75rem;
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 0;
  color: var(--text);
  text-decoration: none;
  box-shadow: inset 0 1px 0 var(--edge-soft);
}

.watch:hover, .watch:focus-visible { border-color: var(--accent); text-decoration: none; }
.watch:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* --- The socket --------------------------------------------------------- */

/* The frame the skull sits in, and can leave.
   It is a real element rather than the plate itself, because a dispatched skull
   has to fly out while the label and the status word stay exactly where they
   were — those are the parts that must remain readable while the socket is
   empty, and animating the plate would take them along.
   `overflow: hidden` is what makes it a frame: the skull leaves *into* the dark
   rather than sliding across the card and over its neighbours. */
.watch-socket {
  position: relative;
  display: block;
  width: 60%;
  max-width: 5rem;
  aspect-ratio: 1;
  overflow: hidden;
}

/* --- The skull ---------------------------------------------------------- */

.skull { width: 100%; height: auto; display: block; }

.skull-frame { fill: none; stroke: var(--line); stroke-width: 1.5; }

/* Bone is muted, not text-bright. The skull is the housing; the eye is the
   reading, and a bone that competed with it would be a second bright thing on a
   panel meant to have exactly one. */
.skull-bone { fill: var(--muted); }

.skull-plate, .skull-mount { fill: none; stroke: var(--line); stroke-width: 1.6; }
.skull-rivet, .skull-mount-node { fill: var(--line); }

/* Holes in bone, at page black rather than at the plate colour, so they read as
   through the skull rather than as marks on it. */
.skull-socket, .skull-hollow { fill: var(--bg); }
.skull-hollow-line { fill: none; stroke: var(--bg); stroke-width: 1.4; }

/* The lens housing around the live eye. */
.skull-lens { fill: var(--bg); stroke: var(--line); stroke-width: 1.2; }

/* Neutral until a status class says otherwise — an unmapped state renders as a
   dark lens rather than borrowing whichever colour happened to be last. */
.skull-eye { fill: var(--muted); }

/* --- The site sigil ------------------------------------------------------ */

/* A cogitator screen rather than a skull, for the other collection on the strip.
   It reuses every class above, so the eye, the glow and the four states are the
   same rules and cannot drift apart — the only thing this adds is the text on
   the glass, which the skull has no equivalent of.

   `--line` and not `--bg`: the skull's hollows are page black because they are
   holes *through* bone, and these are marks *on* a lit surface. Painting them
   the same would make the screen read as a slab with three slots cut in it. */
.sigil-line { fill: none; stroke: var(--line); stroke-width: 1.6; }

/* --- The four states ---------------------------------------------------- */

/* Green: lit, and deliberately NOT glowing. Do not add a filter here without
   reading the header — the absence is the feature and there is a test on it. */
.watch-ok .skull-eye { fill: var(--ok); }

/* Amber and red: the eye is the light source and everything else is what the
   light falls on. Three drop-shadows for a lamp's falloff — a tight core, a
   bloom, and a wide halo that reaches the bone around it.

   The first attempt used `box-shadow: inset` on the plate for the wash. It was
   wrong in a way that only showed up rendered: an inset shadow hugs the border,
   so the *frame* glowed rather than the inside of the housing, and it put a
   saturated edge on a plate the theme deliberately keeps achromatic. It also
   moved the signal off the eye — the panel lit up, and the eye stopped being the
   thing you looked at. A radial gradient centred on the skull is the light
   filling the box instead, and it leaves the border iron. */
.watch-warn .skull-eye {
  fill: var(--warn);
  filter: drop-shadow(0 0 2px var(--warn)) drop-shadow(0 0 6px var(--warn)) drop-shadow(0 0 14px var(--warn));
}

/* Red's halo is wider than amber's, and that is compensation rather than taste.
   Amber's relative luminance is 0.600 and red's is 0.296 — red is half as bright
   by construction, because that is what those two hues *are* once they are far
   enough apart to be told apart at all. An identical bloom therefore reads about
   half as strong, which had the more urgent of the two states glowing more
   quietly than the less urgent one. The wider radius buys back the difference.
   ThemeTest asserts the ordering so nobody evens them up for tidiness. */
.watch-bad .skull-eye {
  fill: var(--bad);
  filter: drop-shadow(0 0 3px var(--bad)) drop-shadow(0 0 9px var(--bad)) drop-shadow(0 0 20px var(--bad));
}

/* Centred where the eye sits, not on the plate's middle, so the falloff points
   back at the source. The tint stops well before the border. */
.watch-warn { background: radial-gradient(circle at 50% 40%, var(--warn-bg), var(--panel) 65%); }
.watch-bad  { background: radial-gradient(circle at 50% 40%, var(--bad-bg),  var(--panel) 72%); }

/* --- The word ----------------------------------------------------------- */

.watch-label {
  font-weight: 600;
  font-size: 0.85rem;
  text-align: center;
  /* One line. A long label must not push the status word off a square plate —
     the word is the part that has to survive. */
  max-width: 100%;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.watch-state {
  color: var(--muted);
  font-size: 0.72rem;
  text-transform: uppercase;
  letter-spacing: var(--stencil);
  font-weight: 700;
}

/* The word takes the status colour too. Redundant with the eye by design: two
   channels carrying one fact is what makes it readable without either. */
.watch-warn .watch-state { color: var(--warn); }
.watch-bad .watch-state  { color: var(--bad); }

/* --- The hero plate, on a detail page ----------------------------------- */

/* Same component, larger, beside the heading rather than in a grid. */
.plate-header {
  display: flex;
  align-items: flex-start;
  gap: 1.25rem;
  flex-wrap: wrap;
  margin: 0 0 1rem;
}

.plate-header-body { flex: 1 1 18rem; }
.plate-header-body h2 { margin-top: 0; }
.plate-header-state { margin: 0 0 0.5rem; font-size: 1.05rem; }
.plate-header-note { font-size: 0.85rem; margin: 0.25rem 0 0; }

.watch-hero { width: 11rem; flex: 0 0 auto; }
.watch-hero .watch-socket { width: 70%; max-width: 6.5rem; }

/* --- Dispatch: the skull leaves the frame ------------------------------- */

/*
 * The animation is tied to a job, never to a clock.
 *
 * panel.js adds `is-away` when the enqueue succeeds and removes it when the job
 * reaches a terminal status — so the skull is out for exactly as long as the
 * work takes. There is deliberately no `animation` here that ends on its own: a
 * skull that flew out and drifted back after a fixed three seconds would be
 * showing a result nobody has, which is the one thing this platform does not do
 * anywhere else and is not going to start doing on its prettiest surface.
 *
 * Hence a *transition* rather than a keyframe animation. A transition holds its
 * end state until something changes it, and that something is the job.
 */
.watch-socket .skull {
  transition: transform 0.55s cubic-bezier(0.4, 0, 0.9, 0.4), opacity 0.55s ease-in;
}

.watch.is-away .watch-socket .skull {
  /* Up and out, shrinking as it goes — away from the reader rather than merely
     off the edge, which is what makes the empty frame read as depth instead of
     as a rendering fault. */
  transform: translateY(-115%) scale(0.55);
  opacity: 0;
}

/*
 * The glow goes out when the skull goes.
 *
 * The wash on a warn or bad plate is documented above as the light the eye casts
 * on the inside of the housing — so a dispatched plate that kept glowing would
 * be light with no source, and it looked exactly like that rendered: an amber
 * plate with an empty socket, which reads as "something is wrong here" while
 * showing nothing that could be wrong.
 *
 * It is also the more truthful reading. While the skull is out, the eye is not
 * being looked at, and the plate has no live indicator to shine. The status word
 * underneath keeps its colour and keeps saying what the state is — that fact has
 * not changed and is not what the glow was carrying.
 */
.watch.is-away { background: var(--panel); }

/* The empty socket is not quite empty: the mount the skull sits on stays lit, so
   a frame with nothing in it still reads as a fitting that something belongs in.
   Faint enough not to be mistaken for a reading. */
.watch.is-away .watch-socket::after {
  content: "";
  position: absolute;
  left: 50%;
  bottom: 18%;
  width: 34%;
  height: 1px;
  transform: translateX(-50%);
  background: var(--line);
}

/* --- The fault glyph ---------------------------------------------------- */

/*
 * What occupies the socket when the skull is not coming back — or is taking long
 * enough that "not coming back" and "still working" have become the same
 * picture.
 *
 * It sits in the socket's own coordinate space, hidden until `is-faulted`. It is
 * static: no transition, no pulse, nothing that could read as progress. That is
 * the point of it — an animation here would say "still working", which is
 * exactly the ambiguity the glyph exists to end.
 */
.fault-glyph {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  visibility: hidden;
}

.watch.is-faulted .fault-glyph { opacity: 1; visibility: visible; }

.fault-frame { fill: none; stroke: var(--muted); stroke-width: 2.5; stroke-linejoin: round; }
.fault-mark { fill: none; stroke: var(--muted); stroke-width: 3; stroke-linecap: round; }
.fault-dot { fill: var(--muted); }

/* Bone, not amber. The glyph says "there is no reading here", which is a
   different statement from "the reading is bad" — colouring it with a status hue
   would claim the second while meaning the first. */
.watch.is-faulted .watch-socket::after { background: var(--bad); }

/* --- Dispatch status, in words ------------------------------------------ */

.dispatch { display: flex; align-items: center; gap: 0.6rem; flex-wrap: wrap; }
.dispatch-status { font-size: 0.85rem; color: var(--muted); }
.dispatch-status.ok { color: var(--ok); }
.dispatch-status.bad { color: var(--bad); }
.dispatch-status a { margin-left: 0.4rem; }

/* The in-flight block cycle. Monospace so the three glyphs occupy a fixed width
   and the message beside them does not jitter as they change. */
.asciify {
  margin-left: 0.4rem;
  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
  color: var(--muted);
  letter-spacing: 0.1em;
}

/* --- Idle: the lens flickers -------------------------------------------- */

/*
 * A servo-skull that is powered should not be perfectly still. This is the
 * cheapest way to say so and the loudest it is allowed to be.
 *
 * The constraint it runs into is the same one the whole strip is built around:
 * the glow is the signal, and nothing may compete with it. So the flicker moves
 * *opacity only* — never size, never position, never colour — over seven
 * seconds, between 0.82 and 1. That is perceptible if you rest your eyes on one
 * skull and invisible in peripheral vision, which is the correct order: at a
 * glance you should see which panel is lit, not which panel is moving.
 *
 * Deliberately NOT applied to the glow, the halo or the plate. A flicker on a
 * warn or bad eye would make the alarming states twinkle, turning the one thing
 * that means something into the busiest thing on the page.
 */
@keyframes lens-idle {
  0%, 100% { opacity: 1; }
  46% { opacity: 0.82; }
  52% { opacity: 0.97; }
  58% { opacity: 0.86; }
}

@media (prefers-reduced-motion: no-preference) {
  .watch { transition: border-color 0.12s ease-out; }

  /* Staggered so a strip of plates does not pulse in unison, which reads as the
     page doing something rather than as several idle machines. */
  .skull-eye { animation: lens-idle 7s ease-in-out infinite; }
  .watch:nth-child(2n) .skull-eye { animation-duration: 8.5s; animation-delay: -2s; }
  .watch:nth-child(3n) .skull-eye { animation-duration: 6.5s; animation-delay: -4s; }

  /* A state that changed while the operator was reading something else gets one
     quiet fade, so it is not silent. A fade and not a movement — see above. */
  .watch.is-changed { animation: plate-changed 1.4s ease-out 1; }
}

@keyframes plate-changed {
  0% { border-color: var(--accent); }
  100% { border-color: var(--line); }
}

/*
 * Reduced motion: nothing moves, and everything still works.
 *
 * The skull does not fly — it cuts to gone and cuts back. The states are
 * identical, the job still drives them, and the fault glyph still appears; what
 * is removed is only the travel. Someone who has asked their system for less
 * movement gets a panel that says exactly the same things.
 */
@media (prefers-reduced-motion: reduce) {
  .watch-socket .skull { transition: none; }
  .skull-eye { animation: none; }
  .watch.is-changed { animation: none; }
}

/* ---------------------------------------------------------------------------
   Action buttons and the enqueued banner
   --------------------------------------------------------------------------- */

.action-row { display: flex; align-items: center; gap: 0.75rem; flex-wrap: wrap; margin: 0 0 1rem; }
.action-row .muted { font-size: 0.85rem; }
.action { display: inline; margin: 0; }

/* Inherits the plate; only the size differs. */
.action button { font-size: 0.75rem; padding: 0.4rem 0.9rem; }
.action button:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* Informational, not celebratory. What happened is that a row was written to a
   queue, and the banner says so in the same neutral register as everything
   else — a green "Done!" would be the panel claiming an outcome it has not
   observed. */
.banner-enqueued { background: var(--info-bg); border-color: var(--info); }

/* ---------------------------------------------------------------------------
   Settings
   --------------------------------------------------------------------------- */

.settings { margin: 0; }

.setting {
  display: grid;
  grid-template-columns: minmax(220px, 1fr) 2fr;
  gap: 0.5rem 1.5rem;
  padding: 0.9rem 0;
  border-bottom: 1px solid var(--line);
  align-items: start;
}

.setting:last-child { border-bottom: none; }
.setting dt { display: flex; flex-direction: column; gap: 0.25rem; }
.setting dt label { font-weight: 600; color: var(--text); font-size: 0.95rem; }
.setting dt code { font-size: 0.75rem; }
.setting dd { margin: 0; }
.setting dd input[type="text"], .setting dd select { margin-top: 0; max-width: 22rem; }
.setting .hint { font-size: 0.82rem; margin: 0.35rem 0 0; color: var(--muted); }

/* The source pill sits with the name, not the value: it is a fact about where
   the value came from, and putting it beside the input would read as a claim
   about what was typed there. */
.setting dt .pill { align-self: flex-start; }

@media (max-width: 720px) {
  .setting { grid-template-columns: 1fr; }
}

/* Emphasis is a bone edge and one step of lift, not a colour. The submit button
   on a settings form is not a status, and giving it the info hue put a blue
   plate on a page whose blue means something. */
button.primary {
  padding: 0.5rem 1.1rem;
  border-color: var(--accent);
  background: var(--panel-3);
}

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

/* --- Device classification form ----------------------------------------- */

.policy {
  margin: 1rem 0 0;
  padding-top: 1rem;
  border-top: 1px solid var(--line);
}

.policy h3 {
  font-size: 0.8rem;
  margin: 0 0 0.35rem;
  text-transform: uppercase;
  letter-spacing: var(--stencil);
  color: var(--muted);
}
.policy label { margin-bottom: 0.75rem; }
.policy input[type="text"] { max-width: 26rem; }
.policy .check { display: flex; align-items: center; gap: 0.5rem; color: var(--text); font-size: 0.9rem; }
.policy .check input[type="checkbox"] { margin: 0; }

.policy button { font-size: 0.75rem; padding: 0.4rem 0.9rem; }

/* ---------------------------------------------------------------------------
   Sparklines — the resources page
   ---------------------------------------------------------------------------

   The chart is the reading and the number beside it is the caption, so the line
   gets the status colour and the surrounding chrome stays iron. Everything here
   is a var(), like the rest of the file.

   The threshold rule is deliberately faint. It is a reference mark, not a
   reading: it must be findable when you look for it and must not compete with
   the line for attention, because the line is the thing that moves. */

.sparklines { display: flex; flex-direction: column; gap: 0.75rem; margin: 0 0 1rem; }

.spark-row { display: flex; align-items: center; gap: 0.75rem; }

.spark-label {
  flex: 0 0 5rem;
  font-size: 0.78rem;
  text-transform: uppercase;
  letter-spacing: var(--stencil);
  color: var(--muted);
  font-weight: 700;
}

.spark-now { flex: 0 0 5rem; text-align: right; font-weight: 600; }

.sparkline {
  flex: 1 1 auto;
  height: 2.6rem;
  min-width: 0;
  background: var(--panel-2);
  border: 1px solid var(--line);
}

/* No fill under the line. A filled area reads as a quantity — "this much of the
   disk" — and this is a series of instants, not an accumulation. */
.spark-line {
  fill: none;
  stroke: var(--muted);
  stroke-width: 1.5;
  stroke-linejoin: round;
  stroke-linecap: round;
  vector-effect: non-scaling-stroke;
}

.spark-ok .spark-line { stroke: var(--ok); }
.spark-warn .spark-line { stroke: var(--warn); }
.spark-bad .spark-line { stroke: var(--bad); }

.spark-threshold {
  stroke: var(--line);
  stroke-width: 1;
  stroke-dasharray: 3 3;
  vector-effect: non-scaling-stroke;
}

.sparkline-empty { font-size: 0.85rem; margin: 0; }

/* The slope, in words. Amber only on the way up: a falling figure is good news
   on every metric here and does not need a tint. */
.spark-delta { flex: 0 0 5.5rem; text-align: right; font-size: 0.85rem; }
.spark-rising { color: var(--warn); font-weight: 600; }

/* --- Screenshot filmstrip ------------------------------------------------
 *
 * A row that scrolls inside itself, never one that makes the page scroll
 * sideways. Twelve captures at a fortnight's retention is more than fits on any
 * screen, and a horizontally scrolling *page* breaks every other section on it.
 *
 * The shape is the argument, not decoration. "Has this changed" is answered by
 * running the eye along a row, which is far faster than reading down a column —
 * and the same question asked of a vertical list is a page nobody reaches the
 * bottom of. */
.filmstrip {
  display: flex;
  gap: 0.75rem;
  overflow-x: auto;
  overflow-y: hidden;
  padding: 0.25rem 0 0.75rem;

  /* Each frame snaps to the left edge, so scrolling lands on a whole image
     rather than halfway across two. Cheap, and it makes a trackpad flick
     behave like paging through them. */
  scroll-snap-type: x proximity;
}

.filmstrip-frame {
  margin: 0;
  flex: 0 0 auto;
  width: 15rem;
  scroll-snap-align: start;
}

.filmstrip-frame a {
  display: block;
  border: 1px solid var(--line);
  background: var(--panel-2);
  /* The image is the link's whole content, so the outline has to sit on the
     anchor to be visible at all. */
  box-shadow: inset 0 1px 0 var(--edge-soft);
}

.filmstrip-frame a:hover { border-color: var(--accent); }

.filmstrip-frame img {
  display: block;
  width: 100%;
  /* Overriding the intrinsic height the markup declares. The attributes are
     there so the browser reserves the right *ratio* before the bytes arrive —
     without them a strip of twelve reflows as each one loads, and the page
     jumps under the cursor. */
  height: auto;

  /* A screenshot is a document, not a photograph: text in it has to survive
     being scaled to a fifth of its size, and a smooth resample turns 12px body
     copy into grey mush. This keeps edges crisp enough to tell a rendered page
     from a broken one, which is the entire purpose of the strip. */
  image-rendering: -webkit-optimize-contrast;
}

.filmstrip-frame figcaption {
  font-size: 0.8rem;
  color: var(--text);
  padding-top: 0.35rem;
  display: flex;
  justify-content: space-between;
  gap: 0.5rem;
}

/* ---------------------------------------------------------------------------
   Audit
   ---------------------------------------------------------------------------

   Two streams share one table, and the difference between them is carried by a
   pill rather than by a row colour. A tinted row would be a fifth signal on a
   page whose colour channel is already fully spoken for by the four status hues
   — and it would tint by *provenance* while the reader is scanning for
   *outcome*, which is the one thing colour here is allowed to mean. */

/* The Detail column — the one that holds identifiers rather than prose.
   =========================================================================

   `overflow-wrap: anywhere` because everything in this cell is a machine value
   with no space in it to break at: a `job_` ULID, a `SHA256:` key fingerprint,
   a request id. A cell of those has a min-content width equal to its longest
   token, and a table cannot be narrower than the sum of its columns' minimums —
   so one 50-character fingerprint made the whole **page** wider than the window,
   and the operator had to scroll sideways to reach the Outcome column.

   Measured: an `ssh_session_opened` row carrying a fingerprint took the Detail
   column's minimum from 288px to 439px and the table's from 704px to 927px. In
   an 842px window that is 121px of horizontal scroll, and it dragged the nav and
   every card along with it.

   The ≤720px rule below does not cover this. That one makes a table scroll
   inside itself on a phone, and the breakpoint encodes an assumption this bug
   disproved — that a desktop window is always wide enough. It is not; a laptop
   half-screen is about 840px, which is where this was seen.

   `anywhere` rather than `break-all` because it only breaks a word that has no
   other way to fit, and unlike `break-word` it is counted in the min-content
   width — which is the whole point, since it is the minimum that was forcing the
   page wide. A broken ULID still selects and copies whole.

   Fixing the content rather than raising the breakpoint is deliberate: a page
   that fits is better than a table the operator has to scroll to read the
   outcome of a row, and Outcome is the column that was falling off. */
.audit-what { overflow-wrap: anywhere; }

/* A sudo command line. Wraps rather than scrolls: the interesting part of a long
   command is usually its tail — the file it was pointed at — and a truncated
   cell would hide exactly that. `break-all` because a path has no spaces to
   break at, the same reason .destination code uses it. */
.audit-command {
  margin-top: 0.25rem;
  font-size: 0.8rem;
  word-break: break-all;
  color: var(--text);
}

/* `audit.params` — the small key/value set an application row carries. Inline
   pairs rather than a grid: there are two or three of them, and a two-column
   layout for two items is a lot of vertical space spent on very little. */
.audit-detail {
  margin: 0.25rem 0 0;
  font-size: 0.82rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.15rem 0.6rem;
}

.audit-detail dt {
  color: var(--muted);
  font-weight: 600;
}

.audit-detail dt::after { content: ":"; }

.audit-detail dd {
  margin: 0 0.4rem 0 0;
  color: var(--text);
  word-break: break-word;
}

/* `input[type="date"]` picks up the shared field styling above, and needs two
   things beyond it.

   It is narrower, because a date is a fixed-width value: the 12rem minimum that
   suits a free-text filter leaves a date control two-thirds empty and pushes the
   rest of the filter row onto a second line.

   And the browser's calendar indicator is a dark glyph drawn by the UA, which on
   this background is a black mark on near-black. `invert` is the one adjustment
   available for it — the control is not otherwise stylable — and it is applied
   rather than the indicator being hidden, because a date field with no visible
   picker reads as a text box that rejects everything typed into it. */
input[type="date"] {
  min-width: 9rem;
  /* WebKit's spelling; Firefox draws its own and ignores this, which is fine —
     Firefox's indicator already follows the page's colour scheme. */
  color-scheme: dark;
}

/* ---------------------------------------------------------------------------
   Heartbeats — the report list a beat carried, and the disagreement notice
   --------------------------------------------------------------------------- */

/* Digits that line up in a column. Tabular figures so a changing sequence
   number does not shuffle the column width on every refresh. */
.num { text-align: right; font-variant-numeric: tabular-nums; }

/* Collapsed by default. The reports are the detail behind the tile, and a site
   sending twenty of them would otherwise push the probe history off the screen
   for the far commoner case of not wanting to read them. */
.beat-detail { margin: 0.75rem 0 1.25rem; }

.beat-detail > summary {
  cursor: pointer;
  padding: 0.5rem 0;
  font-weight: 600;
  /* The one control on this page that is not a link, so it says so on hover
     and on keyboard focus rather than relying on the disclosure triangle. */
  border-bottom: 1px solid transparent;
}

.beat-detail > summary:hover,
.beat-detail > summary:focus-visible { border-bottom-color: var(--line); }

/* A log tail from another host's disk: the least trustworthy bytes on the page.
   Bounded in height so a 200-line tail is a scroll rather than a page, and
   wrapped so one enormous line cannot stretch the layout. */
/*
 * A site's captured log lines — raw text from another system, so it takes the
 * shallow recess for the same reason job detail's stderr does. Found by looking
 * rather than assumed: it is the only text block outside Jobs, and the rule is
 * keyed to the element that holds the text rather than to which view it is on.
 */
.log-block {
  background: var(--bg);
  border: 0;
  border-radius: 1px;
  box-shadow: var(--pane-bevel);
  padding: 0.6rem 0.7rem;
  margin: 0;
  font-size: 0.78rem;
  line-height: 1.45;
  max-height: 14rem;
  overflow: auto;
  white-space: pre-wrap;
  word-break: break-word;
}

/* A `rows` report rendered inside a table cell. Quieter than its parent so the
   nesting reads as one value rather than as a second table competing with it. */
table.nested {
  width: auto;
  min-width: 0;
  font-size: 0.82rem;
  background: var(--bg);
  border: 1px solid var(--line);
}

table.nested th,
table.nested td { padding: 0.25rem 0.6rem; }
table.nested th { color: var(--muted); font-weight: 600; }

/* Probe and heartbeat disagreeing. Amber rather than red: neither signal is
   wrong, and which layer is broken is the thing the disagreement points at. */
.callout-warn {
  border-left: 3px solid var(--warn);
  background: var(--warn-bg);
  padding: 0.7rem 0.9rem;
}

/* A report's trend, under its current value. Laid out as a block so the
   sparkline and its scale label stay together when the cell wraps. */
.report-trend { margin-top: 0.35rem; }
.report-trend-scale { font-size: 0.75rem; display: block; margin-top: 0.15rem; }

/* --- Acknowledging an unproven backup ------------------------------------

   Sits under the verification pill on the Backups page, and is deliberately
   quiet: the pill above it is the finding, and this is a note about who has
   read it. Nothing here may restyle the pill — an acknowledged backup is still
   unproven and is still drawn that way (API.md §8). */

.ack {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  margin-top: 0.4rem;
  flex-wrap: wrap;
}

.ack input[type="text"] {
  font-size: 0.75rem;
  padding: 0.25rem 0.4rem;
  /* Wide enough for a real reason and narrow enough not to stretch the
     verification column past the columns that carry the actual data. */
  min-width: 14rem;
  max-width: 22rem;
  flex: 1 1 14rem;
}

.ack button { font-size: 0.7rem; padding: 0.3rem 0.7rem; }

.ack-set { font-size: 0.75rem; }
.ack-set form { display: inline; }

/* A button that reads as a link, for the undo beside an acknowledgement. It is
   a button because it posts — a GET that mutated would fire on any prefetch —
   and it looks like a link because undoing is a minor action beside the pill it
   sits under. */
.linkish {
  background: none;
  border: 0;
  padding: 0;
  font: inherit;
  color: var(--accent);
  text-decoration: underline;
  cursor: pointer;
}
.linkish:hover { color: var(--text); }
.linkish:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* --- Decorative marks ----------------------------------------------------

   The raster ornaments in assets/img, placed only through View::mark().

   Every rule here is about *restraint*, because these are the one source of
   colour on the panel that does not come from the palette above and that no
   test can see — an image carries its hue in its pixels. Nothing here may set a
   colour, give a mark a background, or size one large enough to compete with a
   status pill for the eye. They sit beside states; they never indicate one. */

.mark {
  /* Deliberately no `width`/`height` here. Both are written into the tag by
     View::mark(), computed from the file's own aspect ratio — and an `auto` in
     this block silently beats them, which renders every mark at its intrinsic
     size. That is exactly what happened the first time: a 22px aquila came out
     at 144px and took over the top bar. If a size is ever needed per placement,
     set it on the placement class below, never here. */
  max-width: 100%;
  vertical-align: middle;
  /* Not interactive and not selectable — a mark caught in a text selection or
     dragged out of the page is a picture behaving like content. */
  user-select: none;
  -webkit-user-drag: none;
  flex: none;
}

/* Inline beside a pill or a tile label. The left margin is the whole spacing
   contract: the mark trails the thing it decorates and never leads it. */
.mark-inline { margin-left: 0.45rem; }

/* The aquila in the top bar, leading the wordmark rather than trailing it —
   the one mark that is an identity rather than an annotation. */
.brand-mark { margin-right: 0.5rem; }

/* Beside a page heading. Nudged up a hair: the h1 sits on its baseline and a
   centred mark reads as sinking below the text. */
.title-mark {
  margin-right: 0.6rem;
  position: relative;
  top: -0.1rem;
}

/* The login page's one illustration. Centred above the wordmark, and the only
   mark on the panel given real size — see the note in login.php for why that is
   safe on that page and nowhere else. */
.login-mark {
  display: block;
  /* Left, not centred. The wordmark under it, the labels and the button are all
     left-aligned, and a centred ornament over a left-aligned block reads as an
     accident rather than a choice. */
  margin: 0 0 0.9rem;
}

/*
 * The paired crest: aquila above, wolf below, both left-aligned with everything
 * else on this card.
 *
 * A column and not a row, and that was measured. The card is about 336px of
 * content; the aquila is a 2:1 mark, so at any height that reads as a peer of
 * the 150px wolf it is 220px or wider and the pair wraps anyway — and the one
 * side-by-side arrangement that does fit puts a 72px aquila against a 150px
 * wolf, which is one mark and a footnote rather than a pair. See login.php.
 *
 * Neither mark shrinks to fit. The wolf is only legible at this size — shrinking
 * it is the badge-sized smudge the placement exists to avoid — and an aquila
 * scaled down to match would be the same mistake pointed the other way.
 *
 * `.login-mark`'s own bottom margin is dropped; the column owns the spacing.
 */
.login-crest {
  display: flex;
  flex-direction: column;
  /*
   * Centred on each other, not left-aligned.
   *
   * The two marks are different widths — the aquila is 220px and the wolf 150px
   * — and both are symmetrical about their own centre. Left-aligned, their
   * centres sit 35px apart and the stack reads as two things that happen to be
   * near each other rather than as one crest.
   *
   * Centring is on the *pair*, not on the card: the block stays the width of the
   * widest mark and stays left-aligned with the wordmark and the form below it,
   * so the page keeps its single left edge. Only the narrower mark moves.
   */
  align-items: center;
  width: max-content;
  gap: 0.5rem;
  margin: 0 0 0.9rem;
}

.login-crest .login-mark { margin: 0; }

/* One mark, not a stacked pair — the aquila is the watermark behind this now. */
.login-crest { gap: 0; }

/* --- Two-factor: enrolment and recovery codes ---------------------------- */

/* The QR code beside its manual fallback, and stacked on a narrow screen. The
   camera is the normal path and the typed secret is the fallback, so the QR
   leads on a wide viewport and stays first when they stack. */
.enrol {
  display: flex;
  flex-wrap: wrap;
  gap: 1.25rem;
  align-items: flex-start;
  margin: 1rem 0;
}

.enrol-manual { flex: 1 1 18rem; }

/* A white plate under the code, and it is not decoration. A QR code is read by
   a camera expecting dark-on-light with a quiet zone around it; the panel's own
   surfaces are near-black, and letting the theme show through the margins is
   how a code becomes slow to scan or unscannable. The svg carries its own
   white ground, so this is the frame around it rather than the ground itself. */
.enrol-qr {
  flex: 0 0 auto;
  background: #fff;
  padding: 0.5rem;
  border: 1px solid var(--line);
  line-height: 0;
}

.qr { display: block; width: 15rem; height: auto; max-width: 100%; }

/* The typed-by-hand secret. Wide letter spacing because it is read off a screen
   in groups of four and typed into a phone, which is the one place on this
   panel where legibility beats density. */
.secret {
  display: inline-block;
  font-size: 1.05rem;
  letter-spacing: 0.08em;
  word-break: break-all;
}

.kv { display: grid; grid-template-columns: auto 1fr; gap: 0.25rem 0.9rem; margin: 0.75rem 0 0; }
.kv dt { color: var(--muted); font-size: 0.85rem; }
.kv dd { margin: 0; font-size: 0.9rem; }

/* Recovery codes, in two columns so ten fit without scrolling, and monospaced
   so a transcription can be checked character by character. */
.code-list {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(11rem, 1fr));
  gap: 0.35rem 1.25rem;
  margin: 1rem 0;
  padding-left: 1.4rem;
}

.code-list code { font-size: 1.05rem; letter-spacing: 0.06em; }

/* A code field and its button on one line, wrapping rather than shrinking. Used
   for the three places a current code confirms an action. */
.inline-confirm {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 0.75rem;
  margin: 1rem 0 0;
  padding-top: 1rem;
  border-top: 1px solid var(--line);
}

.inline-confirm label { flex: 1 1 16rem; margin: 0; }
.inline-confirm input { width: 100%; }

/* Step-up — the confirmation demanded before a destructive action.
   ---------------------------------------------------------------------------
   Set apart from the fields above it by a rule and by space, because it is not
   another thing being edited: it is the proof that the edit may happen. Sharing
   the ordinary field styling would let it read as one more optional box on a
   long form like Settings, which is where somebody skips it and loses their
   work to a refusal.

   No colour of its own. `panel.css` keeps the frame achromatic so the four
   status hues are the only saturated colour on a page, and a prompt is not a
   status — nothing has gone wrong when it appears. */
.step-up {
  display: block;
  margin: 1.25rem 0 0.75rem;
  padding-top: 0.9rem;
  border-top: 1px dashed var(--line);
  max-width: 24rem;
}

.step-up input { width: 100%; }

/* The open-window state, which replaces the field rather than disabling it. It
   is deliberately quiet: it reports that nothing is being asked for, and a line
   that announced itself would be noise on every page of a maintenance session. */
.step-up-fresh {
  font-size: 0.85rem;
  color: var(--muted);
}

/* Inside the inline-confirm rows on the Security page the flex parent already
   supplies the rule and the spacing, so the block form would draw a second one. */
.inline-confirm .step-up {
  flex: 1 1 16rem;
  margin: 0;
  padding-top: 0;
  border-top: 0;
  max-width: none;
}

/* "Start again" under the code form. It abandons a verified password step, so it
   is a POST and therefore a button — but it must not read as a second primary
   action while somebody is squinting at a code on a phone.

   `display: block` is doing the work: `.login form` above is a flex column, which
   stretches its only child to full width and turns this into a plate the same
   size as Sign in. Overriding the container rather than the button keeps the
   button at its natural width. */
.login .restart { display: block; margin-top: -0.35rem; }
.login .restart button { width: auto; padding-left: 0; padding-right: 0; }

/* --- Stratagem Deck ------------------------------------------------------ */

/* The section strip inside one nav entry — the first sub-navigation in this
   panel, and deliberately the smallest thing that could work.

   Styled as a rule with an underlined active item rather than as a second plated
   bar. The top nav is already a bar; a second one directly beneath it would read
   as two navigations of equal weight, when this is a strip *within* one of them.
   The weight difference is the whole message. */
.sections {
  display: flex;
  gap: 1.25rem;
  margin: 0 0 1.25rem;
  border-bottom: 1px solid var(--line);
}

.sections a {
  padding: 0.35rem 0 0.5rem;
  font-size: 0.82rem;
  text-transform: uppercase;
  letter-spacing: var(--stencil);
  color: var(--muted);
  text-decoration: none;
  border-bottom: 2px solid transparent;
  margin-bottom: -1px;
}

.sections a:hover { color: var(--text); }
.sections a.active { color: var(--text); border-bottom-color: var(--accent); }

/* The timeline's kind filter. Links, not a form: each is its own URL, so a view
   of the attacks is linkable and survives a reload. Distinct from `.filters`
   above, which is a plated container for form fields and is aligned for them. */
.kind-filters {
  display: flex;
  gap: 0.75rem;
  flex-wrap: wrap;
  margin: 0 0 1rem;
  font-size: 0.85rem;
}

.kind-filters a {
  padding: 0.2rem 0.6rem;
  border: 1px solid var(--line);
  color: var(--muted);
  text-decoration: none;
}

.kind-filters a:hover { color: var(--text); border-color: var(--accent); }
.kind-filters a.active { color: var(--text); border-color: var(--accent); background: var(--panel-3); }

/* The row an inbound link from the Audit page points at.

   A left edge and a lifted background rather than a colour: the four status hues
   are the only saturated colour on a page (see the argument at the head of this
   file), and "this is the row you followed a link to" is not a status. Using
   `--accent` keeps it in the chrome's own palette. */
.row-focus > td { background: var(--panel-2); }
.row-focus > td:first-child { box-shadow: inset 3px 0 0 var(--accent); }

/* The raw fail2ban line, shown inline because a ban has no audit row to link to
   — fail2ban wrote it, and it was ingested into a different database. It is the
   full record, so it is shown rather than linked to. */
code.raw {
  display: block;
  margin-top: 0.35rem;
  font-size: 0.78rem;
  color: var(--muted);
  word-break: break-all;
}

/* The back-link from an audit row to its Deck entry. Muted, because it is a
   second link in a cell that already has one and the primary one is the jump to
   what actually happened. */
.deck-link { font-size: 0.82rem; color: var(--muted); }
.deck-link:hover { color: var(--text); }

/* The lifetime denominator beside a windowed ban count — "11 of 15".

   Deliberately smaller and muted: the window is the figure being reported and
   the total is the context that stops it being misread as one. Sized so the two
   cannot be confused for a single number at a glance. */
.tile-of {
  font-size: 0.72rem;
  font-weight: 400;
  color: var(--muted);
  margin-left: 0.4rem;
  letter-spacing: 0;
}

/* --- Dashboard: two backup sets, and the Stratagem card ------------------ */

/* A label above a row of tiles, for a card that draws more than one group.

   The Backups card is the only one that does: operational and forensic backups
   are different sets on different schedules and must never collapse into one
   figure, so each row says which set it is. Sized well below `h2` — these are
   groups within one card, not cards of their own, and a heading with the same
   weight as the card's would read as two sections. */
.tiles-heading {
  margin: 0.9rem 0 0.5rem;
  font-size: 0.74rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: var(--stencil);
  color: var(--muted);
}

.tiles-heading:first-of-type { margin-top: 0.25rem; }

/* A tile whose state is genuinely not known — distinct from ok, warn and bad.

   The case it exists for: a weekly backup that has not reached its first run.
   Red would be an alarm for a system working as designed, and green would claim
   a proof that does not exist. The muted edge says "nothing has been established
   here", which is the truth. */
.tile-unknown { border-left-color: var(--line); }

/* The per-set summary above the backup inventory.

   One line, both sets, always — a summary that only appears when something is
   wrong cannot be used to confirm that nothing is. */
.set-summary { display: flex; flex-wrap: wrap; gap: 1.5rem; margin: 0 0 1rem; font-size: 0.9rem; }
.set-summary-item { display: inline-flex; align-items: center; gap: 0.45rem; }

/* The login form's honeypot field.

   Off-screen rather than `display:none` or `visibility:hidden`. Both of those
   are the first thing an automated form filler tests for, and skipping them is
   one line of its code; a field positioned outside the viewport looks like a
   layout mistake, which is what makes it worth filling.

   Paired with tabindex="-1" and aria-hidden="true" in the markup — without those
   this would be a trap for keyboard and screen-reader users rather than for
   bots, which is the one way a honeypot can do real harm. */
.hp-field {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* The two credential tables side by side, stacking on a narrow screen. Usernames
   and password digests are read together — the interesting shape is one digest
   across many addresses paired with many usernames, which is a list being
   replayed rather than a person guessing. */
.cred-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(18rem, 1fr)); gap: 1.5rem; }

/* ---------------------------------------------------------------------------
   Ship console — the reskin
   ---------------------------------------------------------------------------

   Everything below is chrome. None of it encodes a state word, a number, or a
   colour a decision depends on: the four status hues, the pills and the tile
   glow remain the only things on this panel that mean anything, and the rules
   here are written so they cannot compete with them.

   Two constraints shape all of it:

     No colour literal outside `:root`, and no `var()` naming a variable that
     does not exist — `ThemeTest`'s rule 4, the one with a body count. Nothing
     below declares a new colour at all. The starfield tints existing text
     variables and controls its weight with `opacity`, which is not a colour,
     and the bezel uses `--edge` / `--edge-soft` / `--line` which already exist
     for exactly this. Pure black is on the allowed list and is the only literal
     used.

     No inline `style=""` and no inline `<script>`; the CSP has neither
     `unsafe-inline` nor a nonce. So every reactive piece is a class toggled by
     `panel.js` with the meaning defined here, which is the same shape the
     servo-skull strip has always had.
   --------------------------------------------------------------------------- */

/* --- Viewport hatch: the starfield --------------------------------------- */

/*
 * Stars as `box-shadow` dots on two 1px elements, not as tiled gradients.
 *
 * The first attempt used repeating `radial-gradient`s. Rendered and looked at,
 * it was wrong twice over: the tile repeat was visible as soft rectangular
 * banding across any empty area, and the dots themselves came out as smudges
 * rather than points. Both were invisible to the test suite, which is the
 * standing reason to render panel work and look at it before committing.
 *
 * `box-shadow` gives crisp single pixels at arbitrary positions with no repeat
 * to notice. The cost is that the coordinates are literal, so the field is a
 * fixed 1600x1000 patch rather than infinite — which is why it is `position:
 * fixed` and anchored top-left: it covers the viewport on any screen this panel
 * is used on, and a larger one simply has darkness past its edge, which is what
 * space looks like anyway.
 *
 * Colours are `--text` and `--accent`, never a literal, and the weight is set
 * with `opacity` on the layer rather than by baking alpha into a colour.
 *
 * `pointer-events: none` because it covers the whole viewport and must never eat
 * a click meant for the page.
 */
.starfield {
  position: fixed;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  overflow: hidden;

  /*
   * The console's 3D depth, and the only place on this panel it is applied.
   *
   * The brief asked for `transform`/`perspective` layering and a slight *static*
   * tilt. It is here and nowhere else, and the reason is a single rule: a
   * rotated plane resamples everything drawn on it, so text on one is softer at
   * every size on every renderer. Every other surface in this panel — the top
   * bar, the cards, the tables — exists to carry text that somebody reads during
   * an incident, and depth bought with legibility is not depth worth having in an
   * operational tool.
   *
   * This layer carries no text at all. It is the one surface where a tilt costs
   * nothing, and the effect is exactly what the tilt is for: the two dot fields
   * are set at different Z, so the near field sits proudest and the far field
   * recedes, and the window reads as having depth behind it rather than as a
   * flat pattern.
   *
   * Static, not parallax — nothing recomputes on scroll or pointer move. It is a
   * fixed transform, which is not motion, so it is correctly outside the
   * `prefers-reduced-motion` guards below: someone who asked for less movement
   * still gets the depth, and loses only the drift.
   */
  perspective: 900px;
  perspective-origin: 50% 45%;
}

.starfield span {
  position: absolute;
  top: 0;
  left: 0;
  border-radius: 50%;
}

/*
 * Density and size were set by rendering a *realistic* page and looking, not by
 * reasoning about the numbers.
 *
 * The first version had 33 dots over a 1600x1000 patch at 1px. On an empty
 * preview that looked like a starfield; on an actual dashboard it was invisible,
 * because the page is mostly opaque cards and the dots only show through the
 * gaps between them — the 1.25rem gutters, the margins either side of a 1400px
 * `main`, and the run down to the footer. Two or three landed in that visible
 * area and the reported result was "flat black", correctly.
 *
 * Positions come from a seeded generator with a minimum separation, so the field
 * scatters rather than clumps and is identical on every build. The dots are
 * dealt round-robin into the six groups, so each group is spread across the whole
 * sky rather than owning a patch of it — which is what stops the stagger below
 * reading as three regions taking turns.
 */

/* The near field: larger, brighter, fewer, and pushed toward the viewer. */
.starfield .star-near {
  width: 2px;
  height: 2px;
  opacity: 0.75;
  transform: translateZ(40px) rotateX(0.6deg);
}

/* The far field: 1px, dimmer, denser, and bone rather than white so the two read
   as different distances rather than as one field with a gap in it. */
.starfield .star-far {
  width: 1px;
  height: 1px;
  opacity: 0.5;
  transform: translateZ(-120px) rotateX(-0.4deg);
}

.starfield .star-near:nth-of-type(1) {
  box-shadow:
    1080px 313px var(--text), 1043px 782px var(--text), 380px 504px var(--text),
    121px 1178px var(--text), 1893px 871px var(--text), 1659px 860px var(--text),
    1835px 579px var(--text), 1614px 1197px var(--text), 1526px 201px var(--text),
    795px 478px var(--text), 1631px 988px var(--text), 1507px 488px var(--text),
    1195px 948px var(--text), 1296px 962px var(--text), 1152px 1009px var(--text),
    1350px 233px var(--text), 1147px 82px var(--text), 778px 149px var(--text),
    1578px 942px var(--text), 647px 222px var(--text);
}

.starfield .star-near:nth-of-type(2) {
  box-shadow:
    1167px 496px var(--text), 88px 835px var(--text), 358px 70px var(--text),
    97px 948px var(--text), 1824px 1100px var(--text), 92px 620px var(--text),
    1760px 683px var(--text), 1185px 265px var(--text), 1537px 1224px var(--text),
    1536px 1029px var(--text), 805px 930px var(--text), 10px 713px var(--text),
    1363px 637px var(--text), 1864px 1011px var(--text), 1697px 1111px var(--text),
    902px 1042px var(--text), 1688px 94px var(--text), 310px 553px var(--text),
    1449px 852px var(--text), 202px 95px var(--text);
}

.starfield .star-near:nth-of-type(3) {
  box-shadow:
    836px 5px var(--text), 1067px 534px var(--text), 1370px 456px var(--text),
    1774px 391px var(--text), 1325px 1113px var(--text), 1300px 892px var(--text),
    378px 897px var(--text), 1550px 714px var(--text), 1368px 949px var(--text),
    878px 401px var(--text), 852px 301px var(--text), 485px 535px var(--text),
    1159px 393px var(--text), 199px 909px var(--text), 955px 1096px var(--text),
    974px 558px var(--text), 383px 308px var(--text), 889px 481px var(--text),
    1181px 1231px var(--text), 1619px 482px var(--text);
}

.starfield .star-far:nth-of-type(1) {
  box-shadow:
    1779px 228px var(--accent), 1461px 947px var(--accent), 655px 753px var(--accent),
    1022px 862px var(--accent), 639px 235px var(--accent), 1243px 652px var(--accent),
    1241px 46px var(--accent), 96px 639px var(--accent), 951px 157px var(--accent),
    1653px 35px var(--accent), 72px 1033px var(--accent), 473px 1043px var(--accent),
    604px 615px var(--accent), 1589px 294px var(--accent), 587px 920px var(--accent),
    729px 62px var(--accent), 1787px 1217px var(--accent), 1817px 346px var(--accent),
    639px 1053px var(--accent), 732px 201px var(--accent), 1621px 151px var(--accent),
    592px 1180px var(--accent), 1679px 617px var(--accent), 539px 666px var(--accent),
    1150px 763px var(--accent), 412px 1063px var(--accent);
}

.starfield .star-far:nth-of-type(2) {
  box-shadow:
    1873px 976px var(--accent), 1418px 1088px var(--accent), 525px 464px var(--accent),
    428px 1188px var(--accent), 1002px 457px var(--accent), 1606px 1239px var(--accent),
    1793px 960px var(--accent), 1237px 1229px var(--accent), 1739px 22px var(--accent),
    409px 165px var(--accent), 1612px 1107px var(--accent), 941px 91px var(--accent),
    291px 898px var(--accent), 983px 1142px var(--accent), 1256px 791px var(--accent),
    1361px 403px var(--accent), 1589px 362px var(--accent), 279px 603px var(--accent),
    200px 829px var(--accent), 1169px 374px var(--accent), 1824px 1153px var(--accent),
    1641px 905px var(--accent), 1189px 1035px var(--accent), 1392px 87px var(--accent),
    885px 329px var(--accent), 1800px 620px var(--accent);
}

.starfield .star-far:nth-of-type(3) {
  box-shadow:
    1548px 154px var(--accent), 1856px 186px var(--accent), 1900px 285px var(--accent),
    1422px 474px var(--accent), 1305px 202px var(--accent), 1200px 1146px var(--accent),
    1761px 390px var(--accent), 1271px 861px var(--accent), 1069px 331px var(--accent),
    725px 366px var(--accent), 1858px 703px var(--accent), 591px 339px var(--accent),
    1630px 499px var(--accent), 1017px 192px var(--accent), 1403px 223px var(--accent),
    734px 555px var(--accent), 1401px 1150px var(--accent), 1000px 976px var(--accent),
    42px 878px var(--accent), 1165px 226px var(--accent), 427px 32px var(--accent),
    290px 458px var(--accent), 883px 1101px var(--accent), 636px 27px var(--accent),
    1762px 295px var(--accent), 6px 677px var(--accent);
}

/*
 * The drift, and it is `lens-idle` applied to a sky.
 *
 * Same trick, same constraints, same reasoning as the servo-skull's flicker
 * directly above: **opacity only** — never size, never position, never colour.
 * Anything that moved or resized out here would be the brightest event on a page
 * whose whole job is that the *glow* is the brightest event.
 *
 * The stagger is what makes it a field rather than a fade. Six groups on six
 * durations that share no common factor, each with its own negative delay so it
 * starts mid-cycle, means no two groups reach their dimmest together and the
 * whole sky never pulses at once. That is the same thing
 * `.watch:nth-child(2n) .skull-eye` does for a strip of plates, for the same
 * reason: a strip that pulses in unison reads as the page doing something.
 *
 * The dots are dealt round-robin into the groups, so a group is scattered across
 * the whole sky. Grouped by region instead, the stagger would read as three
 * patches of screen taking turns, which is worse than no motion at all.
 *
 * Periods are long — 17s to 31s — and the swing is small. At a glance you should
 * see which panel is lit, not which star is dimming.
 */
@keyframes hatch-near { 0%, 100% { opacity: 0.78; } 50% { opacity: 0.42; } }
@keyframes hatch-far  { 0%, 100% { opacity: 0.52; } 50% { opacity: 0.26; } }

@media (prefers-reduced-motion: no-preference) {
  .starfield .star-near { animation: hatch-near 21s ease-in-out infinite; }
  .starfield .star-near:nth-of-type(2) { animation-duration: 27s; animation-delay: -9s; }
  .starfield .star-near:nth-of-type(3) { animation-duration: 17s; animation-delay: -4s; }

  .starfield .star-far { animation: hatch-far 25s ease-in-out infinite; }
  .starfield .star-far:nth-of-type(2) { animation-duration: 31s; animation-delay: -13s; }
  .starfield .star-far:nth-of-type(3) { animation-duration: 19s; animation-delay: -6s; }
}

/*
 * Reduced motion: the stars stop, and every one of them is still there.
 * Identical to the skull's own block — what is removed is the travel, never a
 * state and never an element. The static `transform` above is deliberately not
 * removed: a fixed tilt is not movement, so the depth stays.
 */
@media (prefers-reduced-motion: reduce) {
  .starfield span { animation: none; }
}

/* --- Ambient flares ------------------------------------------------------ */

/*
 * A slow glint crossing the far field, twice, on long unrelated periods.
 *
 * Ambient and tied to nothing — see the partial for why the attack-volume tie was
 * dropped rather than built. Nothing here reads, so nothing here can contradict
 * the `bad` wash, which stays the only thing on this page that means something is
 * wrong.
 *
 * It is the one thing in the starfield that moves position rather than opacity,
 * and that is allowed only because it is a discrete event rather than a state: a
 * flare crosses and is gone, where the dots' rule exists to stop a *persistent*
 * layer competing with the glow. It is kept honest by being rare — 71s and 103s,
 * so they coincide roughly twice an hour — faint, and small.
 */
@keyframes flare-cross {
  /* Ninety-four percent of the cycle is nothing at all. */
  0%, 88%, 100% { opacity: 0; transform: translate3d(0, 0, 0); }
  91% { opacity: 0.5; }
  99.9% { opacity: 0; transform: translate3d(220px, 90px, 0); }
}

.starfield .flare {
  position: absolute;
  top: 18%;
  left: 12%;
  width: 2px;
  height: 2px;
  border-radius: 50%;
  opacity: 0;
  background: var(--accent);
  box-shadow: 0 0 6px 1px var(--accent);
}

.starfield .flare:nth-of-type(2) { top: 62%; left: 58%; }

@media (prefers-reduced-motion: no-preference) {
  .starfield .flare { animation: flare-cross 71s linear infinite; }
  .starfield .flare:nth-of-type(2) { animation-duration: 103s; animation-delay: -37s; }
}

/*
 * Reduced motion removes them entirely rather than freezing them. A frozen flare
 * is a stray bright dot sitting in the sky for ever with no explanation — the one
 * case where stopping the travel leaves something worse than nothing, so this is
 * the single exception to "reduced motion removes travel, never an element".
 */
@media (prefers-reduced-motion: reduce) {
  .starfield .flare { display: none; }
}

/* --- The viewport glass -------------------------------------------------- */

/*
 * The armoured pane the sky is seen through — in front of the dot layers, behind
 * everything anybody reads.
 *
 * `backdrop-filter` blurs what is behind the element in the stacking order. This
 * element lives inside `.starfield`, which is `z-index: 0`, and every readable
 * surface is at 1 or above — so the blur reaches the stars and the page
 * background and can never reach a number, a log line or a pill. That is the
 * property that makes it safe to use at all on an operational panel: the
 * legibility rules `ThemeTest` enforces are about text, and no text is behind
 * this.
 *
 * The blur is deliberately small. At 2px the near dots stay dots and the far
 * field softens just enough to read as depth through thick glass; past about 4px
 * the sky turns into a smear and the tilt work behind it stops being visible at
 * all, which would make this a replacement for that layer rather than a pane in
 * front of it.
 *
 * `@supports` is the whole degradation story: without `backdrop-filter` the pane
 * is its border and its inner shadow, which is a bezel edge and looks
 * deliberate rather than broken. Nothing is hidden and nothing moves.
 */
/*
 * Selector written as `.starfield .viewport-glass`, and the two classes are the
 * point rather than tidiness.
 *
 * `.starfield span` rounds every child to 50%, because the dots are circles — and
 * the pane is a span in that container. A bare `.viewport-glass` rule is one
 * class against a class-plus-element, so it *loses*, and the pane stays a
 * viewport-sized ellipse: the inset ring clips to it and renders as a huge faint
 * arc sweeping across the page, looking like a fault in something else entirely.
 *
 * That was diagnosed correctly the first time and the fix silently did nothing,
 * because specificity was the actual mechanism. Isolating it — rendering with the
 * pane removed, then with the blur disabled — is what showed the pane was
 * responsible and `backdrop-filter` was not.
 */
.starfield .viewport-glass {
  position: absolute;
  inset: 0;
  pointer-events: none;
  border-radius: 0;

  /*
   * A hairline and an inner lift, the same two-part treatment the top bar's
   * bezel uses, so the window belongs to the same console rather than looking
   * like a separate widget.
   *
   * There was a third layer here — a soft vignette, `inset 0 -40px 60px -60px
   * #000` — and it is gone because it rendered as a large elliptical arc sweeping
   * across the whole page. A big blur with a big negative spread on a
   * viewport-sized box does not read as a vignette; the corner arcs dominate and
   * you see the circle. Isolated by rendering the pane with the blur disabled,
   * which left the arc exactly where it was and cleared `backdrop-filter` of
   * having caused it.
   */
  box-shadow:
    inset 0 0 0 1px var(--edge-soft),
    inset 0 1px 0 var(--edge);
}

@supports (backdrop-filter: blur(2px)) or (-webkit-backdrop-filter: blur(2px)) {
  .starfield .viewport-glass {
    -webkit-backdrop-filter: blur(2px);
    backdrop-filter: blur(2px);
  }
}

/*
 * Mobile drops the blur and keeps the edge. `backdrop-filter` is the single most
 * expensive thing in this stylesheet — it forces a readback of everything behind
 * it on every paint — and the reason to have it is a decorative one, so it is the
 * first thing to go where rendering cost actually matters. The bezel stays, so
 * the window is still a window.
 */
@media (max-width: 720px) {
  .starfield .viewport-glass {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}

/* --- Console depth ------------------------------------------------------- */

/*
 * The page sits above the hatch, in its own stacking context, so no amount of
 * starfield can land on top of a table.
 */
main,
footer { position: relative; z-index: 1; }

/*
 * The top bar sits above the page, not level with it — and that one number is
 * load-bearing for the nav disclosure.
 *
 * At `z-index: 1` the bar tied with `main`, and `main` comes later in the
 * document, so it won. The open menu is a child of the bar, so no `z-index` on
 * the menu itself could lift it out: it is confined to the bar's stacking
 * context. The result rendered as a *transparent* menu with the page showing
 * through its links, which is unreadable and looks like a background that failed
 * to load rather than a layering mistake.
 */
.topbar { position: relative; z-index: 2; }

/*
 * Bezel depth on the top bar: a static, layered edge rather than a gradient or a
 * transform. A tilt was specified and is deliberately not applied to the top bar
 * — it holds the nav, and text rendered on a rotated plane is softer at every
 * size on every renderer. Depth that costs legibility is not depth worth having
 * in an operational tool.
 */
.topbar {
  /* The containing block for the open nav overlay, which is positioned against
     this bar's bottom edge. The `z-index: 1` rule above already lifts the bar
     over the starfield; the overlay sits above the bar's own content. */
  position: relative;
  box-shadow:
    inset 0 1px 0 var(--edge),
    inset 0 -1px 0 var(--edge-soft),
    0 1px 0 var(--line),
    0 6px 18px -12px #000;
}

/* --- Nameplate ----------------------------------------------------------- */

/*
 * A stencilled strip above the page heading. `--stencil` is the existing label
 * letter-spacing, so this matches every other label on the panel rather than
 * introducing a second typographic register.
 */
.nameplate {
  margin: 0 0 0.35rem;
  padding: 0.3rem 0 0.35rem;
  border-bottom: 1px solid var(--line);
  box-shadow: 0 1px 0 var(--edge-soft);
}

.nameplate-region {
  margin: 0;
  font-size: 0.7rem;
  letter-spacing: var(--stencil);
  text-transform: uppercase;
  color: var(--accent);
}

/*
 * The gloss is muted and hidden on narrow screens — it is the one genuinely
 * ornamental half of the nameplate, and the region name it follows is not.
 */
.nameplate-gloss { color: var(--muted); text-transform: none; letter-spacing: normal; }

/* --- Alert-reactive environment ------------------------------------------ */

/*
 * The whole-console wash, and it fires on `bad` alone.
 *
 * `warn` deliberately does not get one. The tile glow already gives `warn` a
 * clear signal that is *local* — on the tile that is warning, next to the words
 * explaining it — and a warn fires far more often than a genuine bad by
 * definition. A whole-environment reaction to a routine state is how an
 * environment stops being read, which is the same argument the file makes above
 * about why `ok` does not glow at all.
 *
 * There is no fifth "critical" tier on this platform and this does not invent
 * one: `bad` is already what fail2ban bans and Stratagem findings render as.
 *
 * `body.has-bad` is set by `panel.js` from what the page has *already rendered*
 * — see the comment there. So this cannot disagree with the pills: it is the
 * same signal, read a second time, at a larger scale.
 *
 * It is painted on `body` and NOT on `.starfield`. The first attempt put it
 * there and it was invisible on sight: that layer carries a low opacity so its
 * stars stay faint, and the wash inherited it. `--bad-bg` is already a very dark
 * red, so at full strength on the page background it is a tint at the top edge
 * rather than an alarm — which is the intended weight.
 */
body.has-bad {
  background-image: radial-gradient(120% 60% at 50% 0%, var(--bad-bg), transparent 70%);
  background-repeat: no-repeat;
  background-attachment: fixed;
}

/*
 * The sweep across the Estate strip — the alert area's centrepiece, and the only
 * place on this panel a sweep appears.
 *
 * ==========================================================================
 * It is not a second alert channel, and the selector is what guarantees that
 * ==========================================================================
 *
 * It hangs off `body.has-bad` — the same class, set from the same rendered
 * `.pill-bad`/`.watch-bad` as the environment wash. It has no condition of its
 * own, reads no data, and cannot fire when the wash is not already firing. So it
 * is the wash's centrepiece rather than a signal beside it, and the failure this
 * design is most exposed to — a decorative effect that starts meaning something
 * slightly different from the thing it sits next to — is closed by construction.
 *
 * Slow and muted, on the same argument as the wash and the ban tile: a public box
 * is scanned continuously, and an effect that reads as urgent whenever anything
 * is red teaches the reader to skip the strip. Twelve seconds, `--bad` at low
 * alpha through a gradient, and it passes rather than pulses.
 *
 * `overflow: hidden` on the section so the beam is clipped to the strip and never
 * crosses the page; `pointer-events: none` so it cannot take a click meant for a
 * tile, every one of which is a link.
 */
@keyframes estate-sweep {
  0% { transform: translateX(-60%); opacity: 0; }
  12% { opacity: 1; }
  88% { opacity: 1; }
  100% { transform: translateX(160%); opacity: 0; }
}

body.has-bad [aria-labelledby="estate-health"] {
  position: relative;
  overflow: hidden;
}

/*
 * The beam passes BEHIND the strip's content, and this rule is what makes that
 * true. Without it the effect is actively harmful rather than merely decorative.
 *
 * An `::after` with `position: absolute` paints in the positioned-descendant
 * layer, which is *above* in-flow content — so the beam crossed the tiles and
 * washed out the figures on them. Seen on the live panel: with a beam over it,
 * `1 of 4207 did not succeed` was materially harder to read than the identical
 * text on the tiles either side. Decoration reducing the legibility of a number
 * is the one thing this whole theme is built not to do.
 *
 * Promoting the children rather than pushing the beam to `z-index: -1`: a
 * negative index would drop it behind the section's own background and out
 * through any ancestor that is not a stacking context, which is a much longer
 * chain of assumptions than lifting three elements one layer.
 *
 * The result is the same rule the starfield already follows — decoration behind
 * opaque legible content, seen only in the gaps. Here that is the gutters between
 * tiles, which is all a passing beam needs.
 */
body.has-bad [aria-labelledby="estate-health"] > * {
  position: relative;
  z-index: 1;
}

@media (prefers-reduced-motion: no-preference) {
  body.has-bad [aria-labelledby="estate-health"]::after {
    content: "";
    position: absolute;
    inset: 0 auto 0 0;
    width: 38%;
    pointer-events: none;
    /* Transparent at both edges, so it reads as a beam passing rather than a
       block sliding. `--bad-bg` and not `--bad`: the saturated hue belongs to the
       pills and the tile edges, which must stay the brightest red on the strip. */
    background: linear-gradient(90deg, transparent, var(--bad-bg), transparent);
    animation: estate-sweep 12s ease-in-out infinite;
  }
}

/*
 * The bezel picks it up too, so the reaction is the frame and not a rectangle
 * floating in it. Still only an edge — the wash never tints the reading surface
 * a number is printed on.
 */
body.has-bad .topbar {
  box-shadow:
    inset 0 1px 0 var(--edge),
    inset 0 -1px 0 var(--edge-soft),
    0 1px 0 var(--bad),
    0 6px 22px -12px #000;
}

/* --- Mobile: cheaper to render, identical to read ------------------------ */

/*
 * Nothing is hidden, shortened or functionally reduced — the only thing that
 * changes is rendering cost. The nameplate's gloss goes because it is
 * ornamental prose and the region name it qualifies stays; the third starfield
 * layer goes and the other two stop animating.
 */
@media (max-width: 720px) {
  .nameplate-gloss { display: none; }
  .starfield .star-far { display: none; }
  .starfield .star-near { animation: none; }
  body.has-bad { background-image: none; }
  .topbar { box-shadow: 0 1px 0 var(--line); }
}

/* ===========================================================================
   The casing — a console set into worn metal
   ===========================================================================

   Opt-in per page via `body.console-cased`, set by the page (see
   panel/pages/dashboard.php). Phase 1 is the dashboard alone; the other pages
   get their own pass rather than all changing at once.

   ---------------------------------------------------------------------------
   The boundary this whole section is written inside
   ---------------------------------------------------------------------------

   Every rule here lands on `main`'s own background, border, box-shadow and
   pseudo-elements. **Nothing inside `main` is touched** — no table, tile, log
   line or status pill gains a transform, a filter, a blur, an opacity or a
   backdrop.

   That is the same rule the starfield's tilt is confined by, and the reason is
   the one already recorded there: a rotated or filtered plane resamples
   everything drawn on it, so text on it is softer at every size on every
   renderer. Depth bought with legibility is not depth worth having in a tool
   somebody reads during an incident.

   Both pseudo-elements below carry `z-index: -1`. `main` already establishes a
   stacking context (`z-index: 1`, Console depth above), so a negative index
   paints them above `main`'s background and behind every child — the casing
   cannot land on top of content even if a later change moves it into the
   content box. They are also `pointer-events: none`, so nothing here can eat a
   click meant for a row.
   ---------------------------------------------------------------------------

   The tokens this section needs are in the one `:root` at the top of this file,
   not declared here. A second `:root` block parses perfectly and is invisible to
   the palette guard in tests/ThemeTest.php, which reads the first one — so a
   colour declared in it is a colour outside the palette wearing a variable's
   clothes. That is exactly what the guard exists to catch, and it caught it.
*/

body.console-cased main {
  /* Room for the frame. The content box is unchanged in every other respect —
     this is the gutter the rivets, the groove and the brackets live in, and it
     is why none of them has to overlap anything that is read. Wider than the
     first pass: a plate reads as thick because there is material between its
     edge and its contents, and 1.6rem was not enough of it to be material. */
  padding: 2.5rem 2.2rem;
  border-radius: 2px;

  background-color: var(--panel);
  background-image:
    /*
     * Rivets, one strip per edge.
     *
     * Each is a single radial gradient with an *off-centre* focal point, which
     * is what turns a dot into a dome: specular highlight up and left, lit body,
     * shaded body away from the light, then a hard black seat ring and a soft
     * pool of shadow around it. The first pass used a centred two-stop gradient
     * and read as a printed dot, because a centred highlight is not a highlight
     * — it is a colour.
     *
     * Repeated gradients rather than elements: four rows of nodes that cannot be
     * selected, dragged or announced is a cost with no return.
     */
    radial-gradient(circle at 38% 32%,
      rgba(240, 234, 219, 0.66) 0 0.8px,
      rgba(190, 182, 164, 0.44) 0.8px 2.1px,
      rgba(78, 75, 68, 0.52) 2.1px 3.2px,
      rgba(0, 0, 0, 0.88) 3.2px 4.3px,
      rgba(0, 0, 0, 0.38) 4.3px 5.3px,
      transparent 5.5px),
    radial-gradient(circle at 38% 32%,
      rgba(240, 234, 219, 0.66) 0 0.8px,
      rgba(190, 182, 164, 0.44) 0.8px 2.1px,
      rgba(78, 75, 68, 0.52) 2.1px 3.2px,
      rgba(0, 0, 0, 0.88) 3.2px 4.3px,
      rgba(0, 0, 0, 0.38) 4.3px 5.3px,
      transparent 5.5px),
    radial-gradient(circle at 38% 32%,
      rgba(240, 234, 219, 0.56) 0 0.8px,
      rgba(190, 182, 164, 0.38) 0.8px 2.1px,
      rgba(78, 75, 68, 0.48) 2.1px 3.2px,
      rgba(0, 0, 0, 0.84) 3.2px 4.3px,
      rgba(0, 0, 0, 0.34) 4.3px 5.3px,
      transparent 5.5px),
    radial-gradient(circle at 38% 32%,
      rgba(240, 234, 219, 0.56) 0 0.8px,
      rgba(190, 182, 164, 0.38) 0.8px 2.1px,
      rgba(78, 75, 68, 0.48) 2.1px 3.2px,
      rgba(0, 0, 0, 0.84) 3.2px 4.3px,
      rgba(0, 0, 0, 0.34) 4.3px 5.3px,
      transparent 5.5px),
    /*
     * Raking light, and the single biggest reason the first pass read as a
     * border rather than a surface. A plate is lit from somewhere: bright along
     * the upper-left, falling off through the middle, and genuinely dark at the
     * far corner. At 0.055 to 0.28 that fall-off was inside the noise; this is
     * roughly two and a half times the range.
     */
    linear-gradient(159deg,
      rgba(240, 234, 219, 0.14) 0%,
      rgba(216, 208, 189, 0.05) 17%,
      rgba(216, 208, 189, 0) 40%,
      rgba(0, 0, 0, 0.24) 70%,
      rgba(0, 0, 0, 0.52) 100%),
    /* A second, vertical wash. Two light sources is what stops the diagonal
       reading as a stripe laid over a flat fill. */
    linear-gradient(to bottom,
      rgba(240, 234, 219, 0.055) 0,
      rgba(240, 234, 219, 0) 120px),
    /* Brushed grain, at three percent rather than one. At reading distance it is
       texture; at a glance it is a machined surface rather than a colour. */
    repeating-linear-gradient(135deg,
      rgba(240, 234, 219, 0.032) 0 2px,
      rgba(0, 0, 0, 0.032) 2px 4px),
    /* A coarser second grain across it, so the fine one does not read as a
       regular screen pattern. */
    repeating-linear-gradient(48deg,
      rgba(240, 234, 219, 0.018) 0 3px,
      rgba(0, 0, 0, 0.022) 3px 7px);
  background-repeat:
    repeat-x, repeat-x, repeat-y, repeat-y,
    no-repeat, no-repeat, repeat, repeat;
  background-position:
    0 13px, 0 calc(100% - 13px), 13px 0, calc(100% - 13px) 0,
    0 0, 0 0, 0 0, 0 0;
  background-size:
    44px 11px, 44px 11px, 11px 44px, 11px 44px,
    auto, auto, auto, auto;

  box-shadow:
    /* The lip. Two stops on the top edge rather than one, so the light catches a
       chamfer instead of a line, and a genuinely dark undercut below. */
    inset 0 1px 0 rgba(240, 234, 219, 0.20),
    inset 0 2px 0 rgba(240, 234, 219, 0.07),
    inset 0 -3px 0 rgba(0, 0, 0, 0.72),
    inset 2px 0 0 rgba(240, 234, 219, 0.055),
    inset -2px 0 0 rgba(0, 0, 0, 0.6),
    /* Interior vignette. A large soft inset is what gives a flat fill the sense
       of sitting at the bottom of something with walls. */
    inset 0 0 70px 14px rgba(0, 0, 0, 0.3),
    /* The seam, doubled: a hard black cut against the page, then the frame line
       outside it. One line was a border; two is a plate in a hole. */
    0 0 0 1px var(--seam),
    0 0 0 2px var(--line),
    /* Cast, not glow: offset downward and tight, so the plate sits *in* the page
       rather than floating above it. */
    0 4px 0 rgba(0, 0, 0, 0.62),
    0 28px 56px -28px #000;
}

/* The recess line — the inner edge of the cut the plate is set into. */
body.console-cased main::before {
  content: "";
  position: absolute;
  inset: 9px;
  z-index: -1;
  pointer-events: none;
  border-radius: 1px;
  /* A groove has two edges: the wall the light misses and the lip below it that
     catches. One 0.5-alpha border was a drawn rectangle; this is a cut. */
  border: 1px solid rgba(0, 0, 0, 0.78);
  box-shadow:
    inset 0 1px 0 rgba(240, 234, 219, 0.11),
    inset 0 -1px 0 rgba(0, 0, 0, 0.4),
    0 1px 0 rgba(240, 234, 219, 0.07);
}

/* Corner brackets: four short rules, drawn as gradients on one element so the
   casing costs no extra nodes. */
body.console-cased main::after {
  content: "";
  position: absolute;
  inset: 14px;
  z-index: -1;
  pointer-events: none;
  background-image:
    linear-gradient(to right, rgba(240, 234, 219, 0.16) 0 34px, transparent 34px),
    linear-gradient(to bottom, rgba(240, 234, 219, 0.16) 0 34px, transparent 34px),
    linear-gradient(to left, rgba(240, 234, 219, 0.16) 0 34px, transparent 34px),
    linear-gradient(to top, rgba(240, 234, 219, 0.16) 0 34px, transparent 34px),
    /* The shadow each strap casts, one pixel down and right. */
    linear-gradient(to right, rgba(0, 0, 0, 0.55) 0 34px, transparent 34px),
    linear-gradient(to bottom, rgba(0, 0, 0, 0.55) 0 34px, transparent 34px);
  background-repeat: no-repeat;
  background-size:
    100% 1px, 1px 100%, 100% 1px, 1px 100%,
    100% 1px, 1px 100%;
  background-position:
    left top, left top, right bottom, right bottom,
    left 1px, 1px top;
}

/* ===========================================================================
   The oculus — the window in the casing
   ===========================================================================

   See panel/views/partials/oculus.php for what is in the markup and why the
   passive scene is what the server renders. In short: the passive layer is the
   default and the canvas replaces it only once `panel.js` has proved it runs, so
   a blocked or broken script leaves a complete window rather than an empty box.
*/
/*
 * ===========================================================================
 * The housing — the dominant mass, per the reference stills
 * ===========================================================================
 *
 * Round 3. Rounds 1 and 2 built one plane with an inset shadow on it, and the
 * reference is a stepped object: an outer plate, a raised lip, a dark well, and
 * the screen at the bottom of the well. Four planes, not one, and the metal is
 * the bulk of the object rather than a frame around a screen.
 *
 * So the proportion is inverted from round 2 deliberately. `.oculus-frame` below
 * caps the screen's width, which costs window and is the point: in the reference
 * the lit screen is roughly a third to a half of the housing's width, and the
 * metal either side of it is where the bolts, the wear and the light live. A
 * screen that spans the whole plate leaves nowhere for the object to be.
 *
 * Light comes from the upper left and only the upper left. Every gradient in
 * this section runs the same way and every raised edge is lit on top and cut
 * away underneath — the reference reads as a real object because its lighting is
 * consistent and asymmetric, not because it has more detail.
 *
 * The reference is lit teal. Nothing here is: the palette rule stands, chrome
 * stays desaturated, and what is taken from those frames is structure,
 * proportion and lighting logic.
 *
 * ---------------------------------------------------------------------------
 * Shared with the login page, deliberately in one rule
 * ---------------------------------------------------------------------------
 *
 * `.login-housing` is the same object with different contents: the dashboard's
 * pane is a window onto something else, login's pane is the terminal screen
 * itself. The material took several rounds to get right — off-centre-lit domed
 * bolts rather than flat rings, one light direction, wear under about six
 * percent — and none of that is content-specific, so it is one declaration with
 * two selectors rather than two copies to drift apart. Only the geometry differs,
 * and that is set per consumer below.
 */
.oculus,
.login-housing,
/* ---------------------------------------------------------------------------
 * DEAD as of 7 Sep 2026: every .deck-scope rule below matches no markup.
 *
 * The activity scope was removed from panel/views/stratagem.php and panel.js —
 * it drew only events arriving while the page was open, which on this box meant
 * it was empty essentially always. The rules are left rather than bulk-deleted
 * because they are scattered across four media queries and two keyframes and
 * there is no linter here to catch a bad cut; they are unreachable, not
 * misleading. Remove them in a pass that can be checked.
 * ------------------------------------------------------------------------- */
.deck-scope {
  position: relative;
  border-radius: 3px;

  background-color: var(--panel-2);
  background-image:
    /* The raking light, running upper-left to lower-right across the whole
       plate. Wider range than the casing's, because this is the object the eye
       is meant to read as lit. */
    /*
     * The highlight is narrow and the body is dark. The reference object is dark
     * metal with a lit edge, not a mid-grey plate that is brighter at one end —
     * a first cut at 0.17 falling to 0.66 read as evenly lit plastic, because
     * most of the surface was in the bright half of the ramp.
     */
    linear-gradient(156deg,
      rgba(240, 234, 219, 0.13) 0%,
      rgba(240, 234, 219, 0.03) 12%,
      rgba(0, 0, 0, 0.16) 34%,
      rgba(0, 0, 0, 0.4) 62%,
      rgba(0, 0, 0, 0.6) 84%,
      rgba(0, 0, 0, 0.76) 100%),
    repeating-linear-gradient(134deg,
      rgba(240, 234, 219, 0.016) 0 2px,
      rgba(0, 0, 0, 0.016) 2px 4px),
    /* Near-parallel to the first and on an unrelated period. Crossed at a wide
       angle the two grains cross-hatch into a weave, which reads as fabric; at a
       degree or two apart they beat against each other into irregular banding,
       which is what a brushed surface actually looks like. */
    repeating-linear-gradient(131deg,
      rgba(240, 234, 219, 0.014) 0 5px,
      rgba(0, 0, 0, 0.018) 5px 11px);

  box-shadow:
    inset 0 1px 0 rgba(240, 234, 219, 0.22),
    inset 0 2px 0 rgba(240, 234, 219, 0.07),
    inset 0 -4px 0 rgba(0, 0, 0, 0.78),
    inset 3px 0 0 rgba(240, 234, 219, 0.05),
    inset -3px 0 0 rgba(0, 0, 0, 0.62),
    inset 0 0 90px 20px rgba(0, 0, 0, 0.3),
    0 0 0 1px var(--seam),
    0 0 0 2px var(--line),
    0 5px 0 rgba(0, 0, 0, 0.66),
    0 30px 60px -30px #000;
}

/* Geometry, per consumer. The dashboard's plate is thickest at the bottom
   because that is the switch rail; login has no rail and is close to square. */
.oculus {
  margin: 0 0 1.25rem;
  padding: 42px 52px 88px;
}

.login-housing {
  /* Wider than it first read. At 42px the card was about 78% of the housing;
     the reference's screen is nearer half, and the metal is the object. This
     lands around two thirds — the honest ceiling for a card that has to stay a
     comfortable reading width, since the remaining distance would have to come
     out of the form rather than out of the frame. */
  padding: 54px 76px 58px;
}

/*
 * Wear. Nothing in rounds 1 or 2 addressed surface imperfection, and a perfectly
 * even surface is what makes a plate read as illustrated rather than physical.
 *
 * Two kinds, both cheap: soft elliptical patches of grime and lightening, placed
 * off-centre and at odd sizes so they do not read as a pattern; and scratches,
 * which are repeating gradients with a hairline stripe and a very long gap, at
 * three unrelated angles and periods so they never line up into a weave.
 *
 * Kept under about six percent alpha throughout. Wear that is legible as
 * *marks* is a texture; wear you notice is damage.
 */
.oculus::before,
.login-housing::before,
.deck-scope::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  border-radius: 3px;
  background-image:
    radial-gradient(ellipse 260px 90px at 11% 16%, rgba(0, 0, 0, 0.3), transparent 72%),
    radial-gradient(ellipse 190px 130px at 82% 78%, rgba(0, 0, 0, 0.3), transparent 68%),
    radial-gradient(ellipse 150px 60px at 63% 12%, rgba(240, 234, 219, 0.06), transparent 70%),
    radial-gradient(ellipse 120px 210px at 4% 62%, rgba(0, 0, 0, 0.22), transparent 74%),
    radial-gradient(ellipse 210px 70px at 44% 96%, rgba(0, 0, 0, 0.24), transparent 70%),
    repeating-linear-gradient(74deg,
      transparent 0 37px,
      rgba(240, 234, 219, 0.05) 37px 37.6px,
      transparent 37.6px 96px),
    repeating-linear-gradient(101deg,
      transparent 0 53px,
      rgba(0, 0, 0, 0.16) 53px 53.7px,
      transparent 53.7px 131px),
    repeating-linear-gradient(63deg,
      transparent 0 79px,
      rgba(240, 234, 219, 0.03) 79px 79.5px,
      transparent 79.5px 173px);
}

/*
 * The bolts.
 *
 * Round 2 ran small dots along every edge at a 44px pitch, which reads as
 * stitching. These are six large fasteners at the places a plate would actually
 * be held: the four corners of the housing and the two mid-points of the long
 * edges, where a wide panel would sag without one.
 *
 * Each is one radial gradient with an off-centre focal point, at roughly three
 * times round 2's diameter.
 *
 * The stops are *smooth* percentages, not hard pixel bands. The first cut of
 * this used hard-edged stops and rendered as a flat concentric target: a dome is
 * a continuous curve, and banding it into rings is the one thing that reliably
 * makes it read as printed. Only a magnified crop showed it — at page scale the
 * rings were too small to separate, which is exactly why the crop is part of the
 * verification and not an extra.
 *
 * Two layers per bolt, and one will not do. A radial gradient's shape and its
 * highlight share a centre, so offsetting the focal point to get a lit side also
 * offsets the circle — with the default extent the near corners never reach the
 * transparent stop and the bolt renders as a dark *square* with a bright blob in
 * it. The body is a centred circle with an explicit radius, and the specular is a
 * small offset blob drawn over it.
 */
.oculus::after,
.login-housing::after,
.deck-scope::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
  background-image:
    radial-gradient(circle 5px at 38% 31%,
      rgba(252, 248, 238, 0.92) 0%,
      rgba(252, 248, 238, 0.28) 42%,
      rgba(252, 248, 238, 0) 100%),
    radial-gradient(circle 11px at 50% 50%,
      rgba(196, 189, 172, 0.92) 0%,
      rgba(150, 144, 131, 0.85) 30%,
      rgba(88, 84, 76, 0.79) 55%,
      rgba(30, 29, 26, 0.87) 76%,
      rgba(0, 0, 0, 0.92) 90%,
      rgba(0, 0, 0, 0) 100%),
    radial-gradient(circle 5px at 38% 31%,
      rgba(252, 248, 238, 0.92) 0%,
      rgba(252, 248, 238, 0.28) 42%,
      rgba(252, 248, 238, 0) 100%),
    radial-gradient(circle 11px at 50% 50%,
      rgba(196, 189, 172, 0.92) 0%,
      rgba(150, 144, 131, 0.85) 30%,
      rgba(88, 84, 76, 0.79) 55%,
      rgba(30, 29, 26, 0.87) 76%,
      rgba(0, 0, 0, 0.92) 90%,
      rgba(0, 0, 0, 0) 100%),
    radial-gradient(circle 5px at 38% 31%,
      rgba(252, 248, 238, 0.84) 0%,
      rgba(252, 248, 238, 0.25) 42%,
      rgba(252, 248, 238, 0) 100%),
    radial-gradient(circle 11px at 50% 50%,
      rgba(196, 189, 172, 0.84) 0%,
      rgba(150, 144, 131, 0.77) 30%,
      rgba(88, 84, 76, 0.72) 55%,
      rgba(30, 29, 26, 0.80) 76%,
      rgba(0, 0, 0, 0.84) 90%,
      rgba(0, 0, 0, 0) 100%),
    radial-gradient(circle 5px at 38% 31%,
      rgba(252, 248, 238, 0.84) 0%,
      rgba(252, 248, 238, 0.25) 42%,
      rgba(252, 248, 238, 0) 100%),
    radial-gradient(circle 11px at 50% 50%,
      rgba(196, 189, 172, 0.84) 0%,
      rgba(150, 144, 131, 0.77) 30%,
      rgba(88, 84, 76, 0.72) 55%,
      rgba(30, 29, 26, 0.80) 76%,
      rgba(0, 0, 0, 0.84) 90%,
      rgba(0, 0, 0, 0) 100%),
    radial-gradient(circle 5px at 38% 31%,
      rgba(252, 248, 238, 0.88) 0%,
      rgba(252, 248, 238, 0.26) 42%,
      rgba(252, 248, 238, 0) 100%),
    radial-gradient(circle 11px at 50% 50%,
      rgba(196, 189, 172, 0.88) 0%,
      rgba(150, 144, 131, 0.81) 30%,
      rgba(88, 84, 76, 0.76) 55%,
      rgba(30, 29, 26, 0.84) 76%,
      rgba(0, 0, 0, 0.88) 90%,
      rgba(0, 0, 0, 0) 100%),
    radial-gradient(circle 5px at 38% 31%,
      rgba(252, 248, 238, 0.88) 0%,
      rgba(252, 248, 238, 0.26) 42%,
      rgba(252, 248, 238, 0) 100%),
    radial-gradient(circle 11px at 50% 50%,
      rgba(196, 189, 172, 0.88) 0%,
      rgba(150, 144, 131, 0.81) 30%,
      rgba(88, 84, 76, 0.76) 55%,
      rgba(30, 29, 26, 0.84) 76%,
      rgba(0, 0, 0, 0.88) 90%,
      rgba(0, 0, 0, 0) 100%);
  background-repeat: no-repeat;
  /* Chunky. The reference's fasteners are a structural size, not a stud. */
  background-size: 26px 26px;
  background-position:
    16px 16px,
    16px 16px,
    calc(100% - 16px) 16px,
    calc(100% - 16px) 16px,
    16px calc(100% - 16px),
    16px calc(100% - 16px),
    calc(100% - 16px) calc(100% - 16px),
    calc(100% - 16px) calc(100% - 16px),
    16px 50%,
    16px 50%,
    calc(100% - 16px) 50%,
    calc(100% - 16px) 50%;
}

/*
 * The well — the plane rounds 1 and 2 did not have.
 *
 * In the reference there is a visible dark gap between the outer plate and the
 * screen: a trough the screen sits at the bottom of, with its own walls. That is
 * two separated depth planes, and an inset shadow drawn on the screen's own
 * border is not a substitute for it — it puts the shadow *on* the screen instead
 * of between the screen and the plate.
 *
 * `max-width` is the proportion change, and it is the expensive one: it gives up
 * screen width to leave metal either side. Without it there is nowhere for the
 * bolts, the wear or the light to be, and the object goes back to being a frame
 * around a display.
 */
.oculus-frame,
.login-well,
.deck-scope-well {
  position: relative;
  z-index: 1;
  padding: 15px;
  border-radius: 2px;
  background-color: rgba(0, 0, 0, 0.5);
  background-image:
    /* The far wall of the trough catches the light; the near wall is in the
       shadow of the lip above it. */
    linear-gradient(180deg,
      rgba(0, 0, 0, 0.5) 0%,
      rgba(0, 0, 0, 0.12) 46%,
      rgba(240, 234, 219, 0.045) 100%);
  box-shadow:
    /* Inside the well: heavy shadow cast down from the lip, a lit lower wall. */
    inset 0 10px 16px -6px rgba(0, 0, 0, 0.95),
    inset 0 -2px 0 rgba(240, 234, 219, 0.13),
    inset 5px 0 12px -7px rgba(0, 0, 0, 0.9),
    inset -5px 0 12px -7px rgba(0, 0, 0, 0.75),
    inset 0 0 0 1px rgba(0, 0, 0, 0.85),
    /* Outside: the lip of the cut, lit on its upper surface and dark below —
       which is what separates the two planes rather than outlining one. */
    0 -1px 0 rgba(240, 234, 219, 0.14),
    0 1px 0 rgba(0, 0, 0, 0.85),
    0 3px 6px -2px rgba(0, 0, 0, 0.7);
}

/* The dashboard's well is capped so metal is left either side of the window.
   Login's card already has its own width, so the well simply wraps it. */
.oculus-frame {
  max-width: 760px;
  margin: 0 auto;
}

/*
 * The aperture. `overflow: hidden` is load-bearing twice over: it clips the
 * scene to the window, and it is what stops a dragged canvas painting over the
 * tiles below.
 */
.oculus-pane {
  position: relative;
  overflow: hidden;
  /* Narrower now, so shorter too — a window roughly 4:3 rather than a band. */
  height: clamp(260px, 34vh, 400px);
  border-radius: 1px;
  background-color: var(--void);
  box-shadow:
    /*
     * Light falls *into* a hole from the top, so the bright edge is the bottom
     * of the aperture and the top is in shadow — the inverse of every raised
     * plate here, and the thing that makes this read as cut rather than drawn.
     *
     * The depth is carried by three stacked insets of increasing spread: a hard
     * dark line at the cut, the shadow the near wall throws across the opening,
     * and a wide soft one for the thickness of the plate itself.
     */
    inset 0 0 0 1px #000,
    inset 0 6px 12px -4px rgba(0, 0, 0, 0.95),
    inset 0 26px 34px -26px rgba(0, 0, 0, 0.9),
    inset 3px 0 10px -6px rgba(0, 0, 0, 0.8),
    inset -3px 0 10px -6px rgba(0, 0, 0, 0.8),
    /* The lit lower lip of the cut. */
    inset 0 -1px 0 rgba(240, 234, 219, 0.16),
    inset 0 -2px 0 rgba(240, 234, 219, 0.05),
    /*
     * The screen's own light spilling onto the metal around it.
     *
     * A lit screen in a dark object is a light source, and in the reference the
     * bezel immediately around it is visibly picking that up. Two stops: a tight
     * bright one on the well's walls and a wide faint one reaching the plate.
     *
     * Desaturated and cool, not the reference's teal. The scene behind this
     * glass is bone stars on near-black, so bone at a few percent is what it
     * would actually throw — and the palette rule stands regardless: the four
     * status hues are the only saturated colour on the page.
     */
    0 0 26px 2px rgba(206, 214, 224, 0.11),
    0 0 62px 16px rgba(170, 186, 202, 0.055);
}

.oculus-passive,
.oculus-canvas,
.oculus-glass {
  position: absolute;
  inset: 0;
}

/* A dot group is a 1px seed carrying its field in `box-shadow`. Same technique
   as the page starfield, scoped to this pane. */
.oculus-passive span {
  position: absolute;
  top: 0;
  left: 0;
  width: 1px;
  height: 1px;
  border-radius: 50%;
}

.oculus-passive .oc-near { width: 2px; height: 2px; opacity: 0.8; }
.oculus-passive .oc-far { opacity: 0.55; }

/*
 * The planet: two gradients, no image.
 *
 * An image would be a request, a cache entry and a thing that can 404 on the
 * night the panel is what you have. The first gradient is the lit sphere, the
 * second is the terminator — a soft black wash from the unlit side, which is
 * what makes it read as a sphere rather than a disc.
 */
.oculus-passive .oc-planet {
  width: 190px;
  height: 190px;
  top: auto;
  left: auto;
  right: -46px;
  bottom: -74px;
  border-radius: 50%;
  background-image:
    radial-gradient(circle at 32% 28%, rgba(160, 176, 190, 0.30) 0%, rgba(96, 110, 124, 0.16) 42%, rgba(20, 26, 32, 0.05) 70%),
    radial-gradient(circle at 74% 78%, rgba(0, 0, 0, 0.72) 0%, rgba(0, 0, 0, 0.42) 46%, rgba(0, 0, 0, 0) 72%);
  box-shadow: inset 0 0 34px 6px rgba(0, 0, 0, 0.5);
}

/* One slow glint, the same restraint as the page starfield's flares: opacity 0
   for most of the cycle, so it is an event rather than a light left on. */
.oculus-passive .oc-glint {
  width: 2px;
  height: 2px;
  top: 26%;
  left: 18%;
  opacity: 0;
  background: var(--accent);
  box-shadow: 0 0 7px 1px var(--accent);
}

@media (prefers-reduced-motion: no-preference) {
  .oculus-passive .oc-near { animation: oc-drift 34s ease-in-out infinite; }
  .oculus-passive .oc-near:nth-of-type(2) { animation-duration: 43s; animation-delay: -11s; }
  .oculus-passive .oc-near:nth-of-type(3) { animation-duration: 29s; animation-delay: -19s; }
  .oculus-passive .oc-far { animation: oc-drift-far 51s ease-in-out infinite; }
  .oculus-passive .oc-far:nth-of-type(2) { animation-duration: 61s; animation-delay: -23s; }
  .oculus-passive .oc-far:nth-of-type(3) { animation-duration: 39s; animation-delay: -7s; }
  .oculus-passive .oc-glint { animation: oc-glint 83s linear infinite; }
}

@keyframes oc-drift {
  0%, 100% { opacity: 0.8; }
  50% { opacity: 0.5; }
}

@keyframes oc-drift-far {
  0%, 100% { opacity: 0.55; }
  50% { opacity: 0.3; }
}

@keyframes oc-glint {
  0%, 92%, 100% { opacity: 0; }
  95% { opacity: 0.55; }
}

/*
 * Reduced motion: the scene stays, the movement goes. Identical content, and
 * the glint is removed rather than frozen — a frozen glint is a stray bright dot
 * sitting in the sky for ever with no explanation, which is the one case where
 * stopping the travel leaves something worse than nothing.
 */
@media (prefers-reduced-motion: reduce) {
  .oculus-passive span { animation: none; }
  .oculus-passive .oc-glint { display: none; }
}

/* The armoured pane over the sky. A hairline and a lift, matching the page
   starfield's glass, and no blur: there is nothing behind it on this page worth
   the readback cost, and the scene is drawn to be looked *through*. */
.oculus-glass {
  pointer-events: none;
  box-shadow:
    inset 0 0 0 1px var(--edge-soft),
    inset 0 1px 0 var(--edge),
    inset 0 0 30px 8px rgba(0, 0, 0, 0.34);
}

/* ---------------------------------------------------------------------------
   Live mode — everything below applies only once the script has said so
   --------------------------------------------------------------------------- */

.oculus-canvas,
.oculus-controls { display: none; }

.oculus.is-live .oculus-canvas { display: block; }
.oculus.is-live .oculus-passive { display: none; }
.oculus.is-live .oculus-controls { display: flex; }

/* `touch-action: none` is what makes a one-finger drag pan the scene instead of
   scrolling the page — and it is exactly why the live scene is not enabled at
   narrow widths. See the media query at the end of this section. */
.oculus-canvas {
  width: 100%;
  height: 100%;
  touch-action: none;
  cursor: grab;
}

.oculus-canvas.is-dragging { cursor: grabbing; }

/*
 * The switch bank.
 *
 * The first pass used flat bordered squares with minus/target/plus glyphs, which
 * read as a web widget bolted to a console and broke the illusion on sight.
 *
 * These are keys in a recessed tray: the tray is a cut in the rail with a dark
 * interior and a lit lower lip, and each key is a raised block with a chamfered
 * top edge, a shaded underside, and a glyph *engraved* into it — dark ink with
 * the lit edge below it, which is what stamped metal does and what printed ink
 * does not.
 *
 * Pressing inverts the bevel rather than moving the key. A transform is the
 * obvious way to do it and is avoided on purpose: nothing in this feature moves
 * a box with a glyph drawn on it, which is the rule the whole casing is built
 * inside.
 */
.oculus-controls {
  /* On the housing's bottom rail, positioned against `.oculus` rather than the
     well — switches are bolted to the casing, not hung inside the aperture. */
  position: absolute;
  z-index: 2;
  right: 58px;
  bottom: 26px;
  gap: 7px;
  /* Deeper at the bottom than the top: a key's cast shadow falls downward, and
     with even padding it fell outside the tray — which read as keys resting on
     the rail rather than seated into it. */
  padding: 6px 7px 9px;
  border-radius: 2px;
  background-color: rgba(0, 0, 0, 0.42);
  box-shadow:
    inset 0 2px 4px rgba(0, 0, 0, 0.85),
    inset 0 0 0 1px rgba(0, 0, 0, 0.7),
    inset 0 -1px 0 rgba(240, 234, 219, 0.09);
}

.oculus-key {
  min-width: 46px;
  height: 25px;
  padding: 0 9px;
  font: inherit;
  font-size: 0.62rem;
  font-weight: 700;
  line-height: 1;
  text-transform: uppercase;
  letter-spacing: var(--stencil);
  cursor: pointer;
  border-radius: 2px;

  /* Engraved, not printed: the glyph is darker than the key it is cut into and
     the lit edge is *below* it, because the light is above. */
  color: rgba(16, 18, 20, 0.92);
  text-shadow: 0 1px 0 rgba(240, 234, 219, 0.24);

  border: 1px solid var(--seam);
  background-color: var(--key);
  background-image:
    linear-gradient(to bottom,
      rgba(240, 234, 219, 0.20) 0,
      rgba(240, 234, 219, 0.03) 45%,
      rgba(0, 0, 0, 0.18) 78%,
      rgba(0, 0, 0, 0.34) 100%);
  box-shadow:
    inset 0 1px 0 rgba(240, 234, 219, 0.30),
    inset 0 -2px 0 rgba(0, 0, 0, 0.5),
    0 2px 0 rgba(0, 0, 0, 0.62),
    0 4px 7px -3px #000;
}

.oculus-key:hover {
  background-color: var(--key-lit);
  color: rgba(10, 11, 12, 0.95);
}

.oculus-key:active {
  background-image:
    linear-gradient(to bottom,
      rgba(0, 0, 0, 0.34) 0,
      rgba(0, 0, 0, 0.12) 55%,
      rgba(240, 234, 219, 0.05) 100%);
  box-shadow:
    inset 0 2px 5px rgba(0, 0, 0, 0.75),
    inset 0 -1px 0 rgba(240, 234, 219, 0.08);
}

/* ---------------------------------------------------------------------------
   Mobile: the casing recedes and the window stops being interactive
   --------------------------------------------------------------------------- */

/*
 * The casing thins rather than disappearing — same rule as the rest of the ship
 * console: nothing is hidden or functionally reduced, the cost of rendering it
 * is what changes. The rivets and the brushed grain go, the seam stays, and the
 * dashboard gets the width back.
 *
 * The oculus stays and stops being interactive, and that is a deliberate choice
 * rather than a shortfall. A canvas that pans under one finger needs
 * `touch-action: none`, which means a finger landing anywhere on it does not
 * scroll the page — on a phone, where the window is most of the visible area
 * and the dashboard beneath it is the reason anyone opened the page, that turns
 * an ornament into an obstacle. `panel.js` refuses to activate below this width
 * for that reason, so what a phone gets is the passive scene: complete, already
 * proven, and scrollable.
 */
@media (max-width: 720px) {
  body.console-cased main {
    padding: 0.75rem;
    background-image: none;
    box-shadow: 0 0 0 1px var(--line);
  }

  body.console-cased main::before,
  body.console-cased main::after { display: none; }

  /*
   * The housing recedes hardest of anything here, because at this width it is
   * the thing competing with the dashboard for the screen. The mass, the wear,
   * the bolts and the well all go; what is left is a thin surround and the
   * window, which is the same trade the rest of the ship console makes.
   */
  .oculus {
    padding: 6px;
    background-image: none;
    box-shadow: 0 0 0 1px var(--line);
  }

  .oculus::before,
  .oculus::after { display: none; }

  .oculus-frame {
    /* No rail and no cap: at this width the window is the object. */
    max-width: none;
    padding: 4px;
    background-image: none;
    box-shadow: inset 0 2px 5px -2px rgba(0, 0, 0, 0.9);
  }

  /*
   * Login's housing recedes for the same reason the dashboard's does, and it is
   * not optional here: at the desktop padding the object is about 540px wide,
   * which runs off a 390px viewport and takes the right-hand end of every field
   * with it. Caught by rendering at a real phone width — the desktop view gave
   * no hint of it.
   */
  .login-housing {
    width: 92vw;
    padding: 10px;
    background-image: none;
    box-shadow: 0 0 0 1px var(--line);
  }

  .login-housing::before,
  .login-housing::after { display: none; }

  .login-well {
    padding: 5px;
    background-image: none;
    box-shadow: inset 0 2px 5px -2px rgba(0, 0, 0, 0.9);
  }

  .login { width: 100%; }

  .oculus-pane {
    height: 168px;
    box-shadow:
      inset 0 0 0 1px #000,
      inset 0 4px 8px -3px rgba(0, 0, 0, 0.9),
      inset 0 -1px 0 rgba(240, 234, 219, 0.12);
  }
  .oculus-passive .oc-far { display: none; }
  .oculus-passive .oc-planet { width: 132px; height: 132px; right: -34px; bottom: -52px; }
}
.oculus-passive .oc-near:nth-of-type(1) {
  box-shadow:
    1239px 215px var(--text),
    299px 18px var(--text),
    1142px 134px var(--text),
    187px 13px var(--text),
    514px 219px var(--text),
    397px 10px var(--text),
    981px 279px var(--text),
    5px 236px var(--text),
    610px 303px var(--text),
    620px 10px var(--text),
    432px 246px var(--text);
}
.oculus-passive .oc-near:nth-of-type(2) {
  box-shadow:
    702px 39px var(--text),
    1227px 87px var(--text),
    1046px 88px var(--text),
    652px 139px var(--text),
    342px 229px var(--text),
    189px 203px var(--text),
    1196px 278px var(--text),
    862px 155px var(--text),
    56px 130px var(--text),
    691px 232px var(--text),
    376px 144px var(--text);
}
.oculus-passive .oc-near:nth-of-type(3) {
  box-shadow:
    89px 32px var(--text),
    903px 74px var(--text),
    63px 294px var(--text),
    992px 168px var(--text),
    754px 311px var(--text),
    1136px 234px var(--text),
    767px 162px var(--text),
    447px 122px var(--text),
    1308px 33px var(--text),
    1388px 193px var(--text),
    819px 34px var(--text);
}
.oculus-passive .oc-far:nth-of-type(1) {
  box-shadow:
    1229px 166px var(--oc-far),
    765px 26px var(--oc-far),
    281px 85px var(--oc-far),
    655px 294px var(--oc-far),
    948px 242px var(--oc-far),
    1302px 311px var(--oc-far),
    611px 253px var(--oc-far),
    1291px 114px var(--oc-far),
    790px 220px var(--oc-far),
    433px 11px var(--oc-far),
    834px 17px var(--oc-far),
    1140px 136px var(--oc-far),
    978px 44px var(--oc-far),
    438px 188px var(--oc-far),
    679px 251px var(--oc-far),
    163px 95px var(--oc-far),
    703px 301px var(--oc-far),
    17px 17px var(--oc-far),
    1396px 88px var(--oc-far);
}
.oculus-passive .oc-far:nth-of-type(2) {
  box-shadow:
    1384px 140px var(--oc-far),
    689px 174px var(--oc-far),
    499px 296px var(--oc-far),
    1238px 250px var(--oc-far),
    14px 100px var(--oc-far),
    505px 77px var(--oc-far),
    1129px 58px var(--oc-far),
    1028px 273px var(--oc-far),
    780px 281px var(--oc-far),
    390px 268px var(--oc-far),
    148px 147px var(--oc-far),
    58px 266px var(--oc-far),
    581px 67px var(--oc-far),
    1328px 77px var(--oc-far),
    650px 106px var(--oc-far),
    146px 202px var(--oc-far),
    1153px 16px var(--oc-far),
    339px 285px var(--oc-far),
    828px 309px var(--oc-far);
}
.oculus-passive .oc-far:nth-of-type(3) {
  box-shadow:
    1212px 111px var(--oc-far),
    238px 193px var(--oc-far),
    391px 169px var(--oc-far),
    1120px 253px var(--oc-far),
    1396px 297px var(--oc-far),
    962px 98px var(--oc-far),
    373px 9px var(--oc-far),
    441px 290px var(--oc-far),
    891px 303px var(--oc-far),
    551px 249px var(--oc-far),
    289px 314px var(--oc-far),
    633px 14px var(--oc-far),
    968px 174px var(--oc-far),
    555px 109px var(--oc-far),
    731px 150px var(--oc-far),
    112px 118px var(--oc-far),
    122px 38px var(--oc-far),
    1062px 64px var(--oc-far),
    820px 114px var(--oc-far);
}

/* --- The login boot sequence ---------------------------------------------

   Added by `panel.js` and never present in the markup, so a page without
   JavaScript renders the form and nothing else — the same ordering the oculus
   uses, and the reason it is safe: what the server sends is the finished state,
   and the script's job is to delay it, not to produce it.

   Under `prefers-reduced-motion` the script does not build this at all. That is
   the correct reading of the preference here: freezing a half-typed line would
   leave a stuck boot screen, and the resolved state is the form.
   ------------------------------------------------------------------------- */

.login-boot {
  position: absolute;
  inset: 0;
  z-index: 3;
  margin: 0;
  padding: 1.5rem;
  background: var(--panel);
  /* The system monospace stack. No webfont, for the reason at the top of this
     file: this page must render with no external network at all. */
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace;
  font-size: 0.76rem;
  line-height: 1.75;
  letter-spacing: 0.04em;
  color: var(--text);
  white-space: pre-wrap;
  overflow: hidden;
}

.login-boot-cursor {
  display: inline-block;
  width: 0.5em;
  height: 0.95em;
  vertical-align: -0.1em;
  background: var(--text);
}

@media (prefers-reduced-motion: no-preference) {
  .login-boot-cursor { animation: boot-blink 1.05s steps(1) infinite; }
}

@keyframes boot-blink {
  0%, 49% { opacity: 1; }
  50%, 100% { opacity: 0; }
}

/* ===========================================================================
   The Stratagem Deck's activity scope
   ===========================================================================

   Hidden until `panel.js` adds `is-live`. Without the script this page is
   exactly what it was before the overlay existed — the static tiles in their
   casing — which is the opposite default from the oculus and correct for the
   opposite reason: the oculus has a complete passive scene to show, and this has
   nothing to say without a poller.

   ---------------------------------------------------------------------------
   Volume must never read as severity, and that is enforced by having no lever
   ---------------------------------------------------------------------------

   The page's own standing rule is that a busy box must not read as a worse box:
   a public address being scanned is fail2ban working, not a fault. So every blip
   here is *identical* — one size, one colour, one opacity, one lifetime — and a
   burst is more of them and nothing else.

   There is no rule below that binds anything to a count, a rate or a severity,
   and the payload it draws from carries no such number to bind (see
   State::stratagemActivity). That is the enforcement: not a promise to be
   careful, but nothing to be careless with.

   ---------------------------------------------------------------------------
   Green, and it is the right hue rather than a borrowed one
   ---------------------------------------------------------------------------

   `--ok`, the palette's existing "this is working" green, and no fifth saturated
   hue is introduced. That is not a compromise with the radar aesthetic, it is
   the same statement the page already makes in words: fail2ban banning a scanner
   is the defence functioning. Red would be the alarming reading the rule above
   forbids, and bone would say nothing at all.
   --------------------------------------------------------------------------- */

.deck-scope { display: none; }
.deck-scope.is-live { display: block; }

.deck-scope {
  margin: 0 0 1.25rem;
  padding: 26px 30px;
}

.deck-scope-well { padding: 12px; }

/* The lit face. Same shallow bevel as login's card rather than the oculus's deep
   aperture: this one carries a label and a note somebody reads. */
.deck-scope-face {
  position: relative;
  padding: 0.85rem 1rem 0.75rem;
  border-radius: 1px;
  background: var(--void);
  box-shadow:
    var(--pane-bevel),
    0 0 26px 2px rgba(58, 196, 108, 0.05);
}

.deck-scope-label,
.deck-scope-note {
  margin: 0;
  font-size: 0.68rem;
  text-transform: uppercase;
  letter-spacing: var(--stencil);
  color: var(--muted);
}

.deck-scope-note {
  margin-top: 0.6rem;
  text-transform: none;
  letter-spacing: 0;
}

/* The field the blips land in. A graticule rather than a sweep: a rotating sweep
   is motion with no information in it, and this scope has to work for somebody
   who has asked for less of exactly that. */
.deck-scope-field {
  position: relative;
  height: 132px;
  margin-top: 0.6rem;
  overflow: hidden;
  border-radius: 1px;
  background-color: rgba(0, 0, 0, 0.45);
  background-image:
    repeating-linear-gradient(to right, rgba(58, 196, 108, 0.05) 0 1px, transparent 1px 44px),
    repeating-linear-gradient(to bottom, rgba(58, 196, 108, 0.05) 0 1px, transparent 1px 44px);
  box-shadow: inset 0 0 0 1px rgba(58, 196, 108, 0.09);
}

/*
 * One blip. Every value here is a constant — nothing scales with anything.
 *
 * `--ok` at a fixed alpha, a fixed 7px, a fixed lifetime. Two blips look like
 * one blip twice.
 */
.deck-scope-blip {
  position: absolute;
  width: 7px;
  height: 7px;
  margin: -3.5px 0 0 -3.5px;
  border-radius: 50%;
  background: var(--ok);
  box-shadow: 0 0 6px 1px rgba(58, 196, 108, 0.45);
  opacity: 0.85;
}

/*
 * The honeypot blip is a ring rather than a disc.
 *
 * A shape difference, not a brightness or size one: the two sources are
 * genuinely different things and telling them apart is information, while making
 * one of them louder would be severity by the back door.
 */
.deck-scope-blip.is-honeypot {
  background: transparent;
  box-shadow:
    inset 0 0 0 1.5px var(--ok),
    0 0 6px 1px rgba(58, 196, 108, 0.35);
}

@media (prefers-reduced-motion: no-preference) {
  .deck-scope-blip { animation: blip-in 0.5s ease-out 1; }
}

@keyframes blip-in {
  0% { opacity: 0; }
  100% { opacity: 0.85; }
}

/*
 * Reduced motion here removes the arrival animation and nothing else.
 *
 * This is a real departure from the starfield's twinkle and the oculus's flares,
 * and the difference is that those carry no information. A blip *is* the
 * information — a new event occurred — so the preference removes how it arrives,
 * never whether it arrives or how long it stays. The same way the status pills
 * and the bad-tier wash keep saying what they say when their animation is off.
 */
@media (prefers-reduced-motion: reduce) {
  .deck-scope-blip { animation: none; opacity: 0.85; }
}

@media (max-width: 720px) {
  .deck-scope {
    padding: 8px;
    background-image: none;
    box-shadow: 0 0 0 1px var(--line);
  }

  .deck-scope::before,
  .deck-scope::after { display: none; }

  .deck-scope-well { padding: 4px; background-image: none; }
  .deck-scope-field { height: 96px; }
}

/* ---------------------------------------------------------------------------
   The cogitator — the operational console
   ---------------------------------------------------------------------------

   Heavy chrome, and every bit of it behind the text.

   The scanline overlay is a static repeating gradient at 30% black on a surface
   that is already near-black, `pointer-events: none`, and drawn on a
   pseudo-element so it cannot end up between the operator and a scrollbar. There
   is no animation anywhere in this block: a flickering terminal is the obvious
   idea and it is wrong here, because the panel's rule is that movement never
   competes with the thing that means something, and on this page the thing that
   means something is a line of output somebody is reading.

   The phosphor cast is a token in `:root` — bone with a green tilt, not a
   saturated green. See the palette for why. Every colour below is a `var()`;
   there is no literal in this block, which is `ThemeTest`'s rule 4.

   The transcript is `pre`-wrapped and never truncated by CSS. Command output is
   the entire point of the page, and an ellipsis in the middle of a stack trace
   is a page that has decided what matters on the reader's behalf. --------------------------------------------------------------------------- */

.cogitator {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 4px;
  box-shadow: var(--pane-bevel);
  margin-bottom: 1.25rem;
  overflow: hidden;
}

.cogitator-head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.75rem;
  padding: 0.75rem 1rem;
  background: var(--panel-2);
  border-bottom: 1px solid var(--line);
}

.cogitator-head h2 {
  margin: 0;
  font-size: 0.95rem;
  letter-spacing: var(--stencil);
  text-transform: uppercase;
  color: var(--accent);
}

.cogitator-state {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
  margin-left: auto;
}

.cogitator-identity {
  color: var(--muted);
  font-size: 0.85rem;
}

.cogitator-halted {
  margin: 0;
  padding: 0.6rem 1rem;
  background: var(--warn-bg);
  color: var(--warn);
  border-bottom: 1px solid var(--line);
  font-size: 0.9rem;
}

/* The screen itself. `--void` rather than `--panel`, because a display is a hole
   in the plate and has to read as further away than the metal around it — the
   same reason the oculus uses it. */
.cogitator-screen {
  position: relative;
  background: var(--void);
  padding: 0.75rem 1rem 1rem;
}

.cogitator-scanlines {
  position: absolute;
  inset: 0;
  /* Never in the way. This covers the transcript, so without it the overlay
     would swallow text selection and the scroll wheel — the two things somebody
     reading output actually does. */
  pointer-events: none;
  background: repeating-linear-gradient(
    to bottom,
    transparent 0,
    transparent 2px,
    var(--scanline) 2px,
    var(--scanline) 3px
  );
  opacity: 0.55;
}

.cogitator-out {
  position: relative;
  min-height: 16rem;
  max-height: 28rem;
  overflow-y: auto;
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.86rem;
  line-height: 1.45;
  color: var(--phosphor);
  text-shadow: 0 0 6px var(--phosphor-glow);
}

.cogitator-line {
  margin: 0;
  white-space: pre-wrap;
  word-break: break-word;
}

.cogitator-block {
  margin: 0.15rem 0 0.5rem;
  /* Wrapped, not scrolled sideways, and never clipped. A log line that runs off
     the right edge is a log line nobody reads the end of. */
  white-space: pre-wrap;
  word-break: break-word;
  font: inherit;
  color: var(--phosphor);
}

/* The transcript's own tones. Only the two that carry a judgement take a status
   colour; everything else is phosphor at two weights, so a refusal is findable
   without the page having five colours in it. */
.cogitator-echo { color: var(--accent); }
.cogitator-boot { color: var(--phosphor-dim); letter-spacing: var(--stencil); }
.cogitator-help { color: var(--phosphor); }
.cogitator-dim { color: var(--phosphor-dim); text-shadow: none; }
.cogitator-warn { color: var(--warn); text-shadow: none; }
.cogitator-bad { color: var(--bad); text-shadow: none; }

.cogitator-prompt {
  position: relative;
  display: flex;
  align-items: center;
  gap: 0.5rem;
  margin-top: 0.75rem;
  padding-top: 0.75rem;
  border-top: 1px solid var(--line);
}

.cogitator-sigil {
  color: var(--phosphor);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  text-shadow: 0 0 6px var(--phosphor-glow);
}

.cogitator-prompt input[type="text"] {
  flex: 1 1 auto;
  min-width: 0;
  background: transparent;
  border: none;
  /* The caret is the browser's own, which is the one thing on this page that
     genuinely should blink — and it does so at the platform's rate, which
     respects the reader's own accessibility settings without this stylesheet
     having to know about them. */
  color: var(--phosphor);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 0.9rem;
  padding: 0.35rem 0;
}

.cogitator-prompt input[type="text"]:focus {
  outline: none;
}

.cogitator-prompt input[type="text"]:disabled {
  color: var(--muted);
}

/* The screen dims when the console cannot run anything, so the state is legible
   from across a desk rather than only from the pills. */
.cogitator.is-halted .cogitator-out,
.cogitator.is-halted .cogitator-sigil {
  color: var(--muted);
  text-shadow: none;
}

/* `display: flex` beats the browser's own `[hidden] { display: none }`, which is
   a rule with a body count in every codebase that has ever toggled `el.hidden`
   on a flex container. The step-up field rendered permanently open until this
   line existed. */
.cogitator-stepup[hidden] {
  display: none;
}

.cogitator-stepup {
  position: relative;
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  gap: 0.5rem;
  margin-top: 0.75rem;
  padding: 0.6rem 0.75rem;
  background: var(--warn-bg);
  border: 1px solid var(--line);
  border-radius: 3px;
}

.cogitator-stepup label {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  color: var(--warn);
  font-size: 0.85rem;
}

.cogitator-stepup .hint {
  color: var(--muted);
  font-size: 0.8rem;
  font-weight: 400;
}

.cogitator-stepup input {
  background: var(--panel);
  border: 1px solid var(--line);
  border-radius: 3px;
  color: var(--text);
  padding: 0.35rem 0.5rem;
  min-width: 12rem;
}

/* The console's own controls on Settings, and the kill switch on the Deck. */
.console-toggle,
.console-approve {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.6rem;
  margin-top: 0.75rem;
}

.console-request {
  padding: 0.75rem 0;
  border-top: 1px solid var(--line);
}

.console-request-line code {
  font-size: 0.95rem;
  color: var(--text);
}

.console-request-actions {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem;
}

.console-constraints {
  border: 1px solid var(--line);
  border-radius: 3px;
  padding: 0.6rem 0.75rem;
  margin: 0;
}

.console-constraints legend {
  color: var(--muted);
  font-size: 0.85rem;
  padding: 0 0.35rem;
}

.console-constraint-name {
  margin: 0.4rem 0 0.2rem;
  color: var(--muted);
  font-size: 0.85rem;
  letter-spacing: var(--stencil);
  text-transform: uppercase;
}

.console-constraints .check {
  display: inline-flex;
  align-items: center;
  gap: 0.35rem;
  margin-right: 0.75rem;
}

/* ---------------------------------------------------------------------------
   The kill switch, on the Stratagem Deck
   ---------------------------------------------------------------------------

   Deliberately the loudest control in the panel, and the only one that looks
   like a physical stop. It is easy to hit on purpose because it can only ever
   reduce capability; the confirm step below it is what makes it hard to hit by
   accident. --------------------------------------------------------------------------- */

.deck-kill {
  border: 1px solid var(--bad);
  border-radius: 4px;
  background: var(--bad-bg);
  padding: 0.85rem 1rem;
}

.deck-kill h3 {
  margin: 0 0 0.35rem;
  color: var(--bad);
  font-size: 0.9rem;
  letter-spacing: var(--stencil);
  text-transform: uppercase;
}

.deck-kill p {
  margin: 0 0 0.6rem;
  color: var(--text);
  font-size: 0.88rem;
}

.deck-kill .button {
  border-color: var(--bad);
  color: var(--bad);
}

.deck-kill-confirm {
  color: var(--bad);
  font-weight: 600;
}
