/* app.css — the dashboard stylesheet, vendored from
   design/dashboard/industry-styles.css ("Industry").

   This file is a COPY of the upstream design-system sheet with exactly five
   sanctioned edits applied, as specified in 03-UI-SPEC.md § Design System →
   Vendoring contract. Everything beyond those five edits is append-only, so
   re-applying them after an upstream refresh stays a mechanical patch.

     Edit 1  the Google Fonts import rule is deleted and replaced by local
             face declarations for the three vendored WOFF2 files. CSP is
             `default-src 'self'` with `font-src 'self'`; an external host is
             not a preference here, it is blocked.
     Edit 2  the six sub-pixel spacing tokens become the seven-token integer
             scale {4,8,16,24,32,48,64}. The 12px token is DELETED, not
             retuned — it was never a member of the standard set.
     Edit 3  the nine component rules that referenced the deleted token, or
             that pin a fixed height this contract declares, are rewritten.
     Edit 4  every upstream font-size is remapped onto the four-size ramp
             (72 / 24 / 14 / 11). There is no fifth size.
     Edit 5  the dashboard's own tokens and classes. It grows by appending,
             and an existing Edit-5 declaration may be retuned in place — see
             the three clauses below, which say exactly what that permits.

   WHAT "APPEND-ONLY" PROTECTS, IN THREE CLAUSES. Settled in Phase 3.1, which
   is the first phase that needed to change an Edit-5 VALUE rather than add a
   rule, and recorded here rather than in a plan file because this is where the
   next reader looks.

     (1) The SET of rules ABOVE Edit 5 is FROZEN. Edits 1-4 and every upstream
         rule they rewrite are what "append-only" protects, and freezing them
         is what keeps re-applying this vendoring after an upstream refresh a
         mechanical patch instead of an archaeology exercise.
     (2) Edit 5 GROWS BY APPENDING. Each appended block names the plan that
         appended it, restates these terms, and quotes the measurement that is
         its argument.
     (3) An Edit-5 DECLARATION may be retuned in place when the design contract
         changes its value — provided the comment above it is rewritten in the
         same edit to say why the new value is the right one.

   Phase 3.1 is the first exercise of clause (3): --row-h moves from 36px to
   44px because 03.1-UI-SPEC.md § Row-Height Arithmetic corrects the arithmetic
   that produced 36px, and the comment above the token is rewritten with it.
   The alternative considered and REJECTED was declaring a sixth edit. A sixth
   edit would assert a sixth CATEGORY OF DIVERGENCE from
   design/dashboard/industry-styles.css, and that would be false: Phase 3.1
   touches no upstream rule at all. Every token and class it changes is already
   Edit-5 material, which is precisely why clause (3) is the honest framing and
   a sixth edit is not.

   Enforced by TestVendoredCSSContract in internal/web, which reads these
   bytes back out of the embedded filesystem. A vendoring regression is
   otherwise invisible until someone screenshots the page. */

/* ── Edit 1 ── Self-hosted fonts. Latin subset, exactly three files: that is
   the asset-level enforcement of the two-weight rule (400 + 600). Barlow 500
   and Barlow Condensed 400 are deliberately not shipped.
   SIL Open Font License 1.1 — Copyright 2017 The Barlow Project Authors
   (https://github.com/jpt/barlow).
   font-display: swap so a slow font never blanks the table. */
@font-face {
  font-family: "Barlow";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/static/barlow-400.woff2") format("woff2");
}
@font-face {
  font-family: "Barlow";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/static/barlow-600.woff2") format("woff2");
}
@font-face {
  font-family: "Barlow Condensed";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/static/barlow-condensed-600.woff2") format("woff2");
}

:root {
  --color-bg: #f2f2f3;
  --color-surface: #e9e9ea;
  --color-text: #1d1f20;
  --color-accent: #5980a6;
  --color-accent-2: #728fab;
  --color-divider: color-mix(in srgb, #1d1f20 16%, transparent);

  /* Tonal ramps — generated in OKLCH on one shared lightness scale, so the
     same step of any role matches the others in visual value. */
  --color-neutral-100: #f5f5f8;
  --color-neutral-200: #e7e7ea;
  --color-neutral-300: #d4d4d7;
  --color-neutral-400: #b7b7ba;
  --color-neutral-500: #98989b;
  --color-neutral-600: #7a7a7d;
  --color-neutral-700: #5d5d60;
  --color-neutral-800: #424244;
  --color-neutral-900: #2b2b2d;

  --color-accent-100: #eef6ff;
  --color-accent-200: #d6ebff;
  --color-accent-300: #b5d9fd;
  --color-accent-400: #94bce3;
  --color-accent-500: #749dc4;
  --color-accent-600: #597ea3;
  --color-accent-700: #416180;
  --color-accent-800: #2c455d;
  --color-accent-900: #1d2d3d;

  --color-accent-2-100: #eef6ff;
  --color-accent-2-200: #d6ebff;
  --color-accent-2-300: #bdd8f2;
  --color-accent-2-400: #9ebbd8;
  --color-accent-2-500: #7e9cb8;
  --color-accent-2-600: #627d98;
  --color-accent-2-700: #486077;
  --color-accent-2-800: #314457;
  --color-accent-2-900: #1f2d3a;

  --font-heading: "Barlow Condensed", system-ui, sans-serif;
  --font-heading-weight: 600;
  --font-body: "Barlow", system-ui, sans-serif;

  /* ── Edit 2 ── The integer spacing scale. Every value is a member of
     {4, 8, 16, 24, 32, 48, 64}; all seven are declared and nothing outside
     the set exists. The upstream 0.85x-density values were sub-pixel
     (3.4 / 6.8 / 10.2 / 13.6 / 20.4 / 27.2) and produced fractional row
     heights in a 1,033-row table — a direct conflict with the anti-flicker
     rule. The 12px token is gone entirely: see Edit 3 for its nine callers. */
  --space-1: 4px;
  --space-2: 8px;
  --space-4: 16px;
  --space-6: 24px;
  --space-8: 32px;
  --space-12: 48px;
  --space-16: 64px;

  --radius-sm: 2px;
  --radius-md: 4px;
  --radius-lg: 7px;

  /* Elevation — derived from the ground: soft ink-tinted shadows on a
     light theme, a hairline edge + ambient darkness on a dark one. */
  --shadow-sm: 0 1px 2px color-mix(in srgb, #2b2b2d 14%, transparent);
  --shadow-md: 0 3px 10px color-mix(in srgb, #2b2b2d 16%, transparent);
  --shadow-lg: 0 12px 32px color-mix(in srgb, #2b2b2d 22%, transparent);

  /* ── Edit 5 ── Dashboard tokens.
     One semantic exception to the mono-steel palette. --color-alert is NOT
     "destructive": Phase 3 has zero mutating actions. It means exactly one
     thing — a human is required. Body-size text never uses it directly; the
     accent/alert pairs are tuned to 3:1, which is chrome contrast, not body
     contrast. Use --color-alert-text at 14px and below. */
  --color-alert: #a35138;
  --color-alert-text: #7a3b26;
  --color-alert-tint: color-mix(in srgb, var(--color-alert) 18%, var(--color-bg));
  --color-muted: color-mix(in srgb, var(--color-text) 55%, transparent);

  /* The reversed field's text colour. The spec names exactly THREE reversed
     surfaces — the login hero, the all-clean plate and the run-log <pre> — and
     all three already agreed on this value, hardcoded, three times. Declared
     here with its reasoning for the same purpose the alert tokens above are:
     the risk being closed is a FOURTH reversed surface added later, picking a
     different value, with nothing in the sheet to catch the disagreement. This
     was the only un-tokenized colour left in an otherwise fully token-driven
     sheet (03.1-UI-SPEC.md § Color → "One token to add", M22). */
  --color-reversed-text: #cfe0f0;

  /* One token, one render. Never per-row: a table with mixed 36 and 44px rows
     is the worst of both, which is why this is a token and not a per-row style.

     Retuned in place from 36px in Phase 3.1, under clause (3) of the Edit-5
     terms at the top of this file. The arithmetic is the argument
     (03.1-UI-SPEC.md § Row-Height Arithmetic):

         44px   border-box height declared on the <td>
       -  8px   block padding (--space-1 x 2)
       = 36px   content box

       tallest content — td.col-remaining and td.col-confidence, which carry TWO
       line boxes:
         line box 1  strip / tick glyphs, 14px x 1.5   = 21.0px
         line box 2  the .sub-line caption, 11px x 1.2 = 13.2px  (display:block,
                     per the D1 ellipsis rule)
                                                  total  34.2px  <  36px, slack 1.8px

     So the cause of the bump is that SECOND LINE BOX and the phase that makes
     it is Phase 3.1. The comment this replaces was wrong on both counts — it
     credited Phase 5 and the Device cell's account caption — and a comment that
     misattributes a change to a future phase is how the next reader concludes
     the value was raised by mistake and reverts it, silently reopening D1.

     The D1 ellipsis rule (.table-fleet td .sub-line, far below) is UNTOUCHED by
     this retune. This phase changes the DECLARED height to match the content
     the design actually has; it does not undo the ellipsis, and content still
     never sets the height. */
  --row-h: 44px;
  /*
   * Phase 5 does not move this again. The site_label sub-line the roster import
   * adds lands INSIDE the Device cell, whose two inline boxes are already
   * covered by the same 36px content box (21.0 + 13.2 = 34.2px), so the
   * declared height stays 44px when the roster import ships.
   */

  /* The status glyphs are not in Barlow's glyph set and WILL fall back to a
     system font. Naming the fallback chain here, plus the fixed metrics on
     .glyph below, is what stops a substituted glyph from shifting row
     height by a pixel on every poll. */
  --font-glyph: "Barlow", "Segoe UI Symbol", "Segoe UI", system-ui, sans-serif;

  /* A voice, not a size — technical readouts render at Body 14px/1.5. Never
     apply font-size-adjust to this stack: it resolves differently per
     machine (Cascadia Mono on a dev box, Consolas on stock Windows 11) and
     would produce a different rendered size per client. */
  --font-mono: ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace;
}

body {
  background: var(--color-bg);
  color: var(--color-text);
  font-family: var(--font-body);
}
h1, h2, h3, h4 { font-family: var(--font-heading); font-weight: var(--font-heading-weight); }

.blueprint {
  position: relative;
  border: 1px solid var(--color-divider);
  border-radius: 0;
}
/* The overlay image treatments (halftone, duotone) clip their overlay
   (overflow:hidden); a blueprint wrapper draws its registration marks
   outside the box, so when both classes share a wrapper the frame must
   win. */
.blueprint.halftone, .blueprint.plate, .blueprint.duotone { overflow: visible; }
.blueprint > .corner {
  position: absolute; width: 11px; height: 11px;
  color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.blueprint > .corner::before, .blueprint > .corner::after {
  content: ""; position: absolute; background: currentColor;
}
.blueprint > .corner::before { left: 5px; top: 0; width: 1px; height: 100%; }
.blueprint > .corner::after  { top: 5px; left: 0; width: 100%; height: 1px; }
/* The -6px offsets are the one true exception to the 4-grid. They are drawn
   to align with the 1px frame; rounding them breaks the corner crosshair. */
.blueprint > .corner.tl { top: -6px; left: -6px; }
.blueprint > .corner.tr { top: -6px; right: -6px; }
.blueprint > .corner.bl { bottom: -6px; left: -6px; }
.blueprint > .corner.br { bottom: -6px; right: -6px; }

.duotone{position:relative;overflow:hidden}
.duotone::after{content:"";position:absolute;inset:0;pointer-events:none;
  background:var(--color-accent);mix-blend-mode:color}

/* ══════════════════════════════════════════════════════════════════════════
   Components — built with the tokens above. Plain CSS
   on plain HTML: no JavaScript, no build step. Each class is documented in
   readme.md and demonstrated in foundations/ and components/.
   ══════════════════════════════════════════════════════════════════════ */

*, *::before, *::after { box-sizing: border-box; }
/* Edit 4: body 15px/1.55 -> Body 14px/1.5. */
body { margin: 0; font-size: 14px; line-height: 1.5; font-weight: 400; }
h1, h2, h3, h4, h5, h6 {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  line-height: 1.12; letter-spacing: -0.015em; margin: 0 0 var(--space-2);
}
/* Edit 4: h1-h4 (42/32/25/20) -> Heading 24px; h5/h6 (16/13) -> Label 11px. */
h1 { font-size: 24px; line-height: 1.2; }
h2 { font-size: 24px; line-height: 1.2; }
h3 { font-size: 24px; line-height: 1.2; }
h4 { font-size: 24px; line-height: 1.2; }
h5 { font-size: 11px; line-height: 1.2; letter-spacing: 0.14em; text-transform: uppercase; }
h6 { font-size: 11px; line-height: 1.2; }
h6 { letter-spacing: 0.14em; text-transform: uppercase; }
/* Edit 3: the deleted 12px token -> --space-4. */
p { margin: 0 0 var(--space-4); }
a { color: var(--color-accent-700); text-underline-offset: 3px; }
img { display: block; max-width: 100%; }
figure { margin: 0; }
figcaption {
  font-size: 11px; margin-top: var(--space-1);
  color: color-mix(in srgb, var(--color-text) 55%, transparent);
}
.text-muted { color: color-mix(in srgb, var(--color-text) 55%, transparent); }
:focus { outline: none; }
:focus-visible { outline: 2px solid var(--color-accent); outline-offset: 2px; }
::selection { background: color-mix(in srgb, var(--color-accent) 30%, transparent); }

/* — rules — */
.hr {
  height: 1px; border: 0; margin: var(--space-4) 0;
  background: var(--color-divider);
}

/* — buttons — */
/* Edit 3: .btn gets the declared 32px filter-bar height instead of deriving
   it from the deleted token. One height, one baseline across the filter bar. */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 6px;
  cursor: pointer; text-decoration: none;
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 14px; line-height: 1.2; color: var(--color-text);
  background: transparent; border: 1px solid transparent;
  height: 32px; padding: 0 var(--space-4);
  border-radius: var(--radius-md);
}
.btn svg { display: block; }
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
.btn-primary { background: var(--color-accent); color: var(--color-bg); }
.btn-primary:hover { background: var(--color-accent-600); }
.btn-primary:active { background: var(--color-accent-700); }
.btn-secondary { border-color: var(--color-divider); }
.btn-secondary:hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.btn-secondary:active { background: color-mix(in srgb, var(--color-text) 14%, transparent); }
.btn-ghost { color: var(--color-accent-700); padding-inline: var(--space-1); }
.btn-ghost:hover { background: color-mix(in srgb, var(--color-accent) 10%, transparent); }
.btn-ghost:active { background: color-mix(in srgb, var(--color-accent) 18%, transparent); }
.btn-icon { width: 36px; height: 36px; padding: 0; }
.btn-block { width: 100%; margin-top: var(--space-2); }

/* — forms — */
/* Edit 3 + Edit 4: 12px -> Label 11px, 5px margin -> --space-1. */
.field > label {
  display: block; font-size: 11px; margin-bottom: var(--space-1);
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}
/* Edit 3: the login field height, deliberately 8px taller than the 32px
   table chrome so the sole solid-filled object reads as primary. */
.input {
  width: 100%; height: 40px; padding: 0 var(--space-4); font: inherit;
  font-size: 14px; color: var(--color-text); caret-color: var(--color-accent);
  background: var(--color-surface);
  border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
.input:hover { border-color: color-mix(in srgb, var(--color-text) 45%, transparent); }
.input:focus-visible { border-color: var(--color-accent); outline-offset: 0; }
textarea.input { min-height: 90px; height: auto; padding: var(--space-2) var(--space-4); resize: vertical; }
.radio { display: inline-flex; align-items: center; gap: 8px; cursor: pointer; font-size: 14px; }
.radio input, .seg-opt input {
  position: absolute; opacity: 0; width: 0; height: 0; pointer-events: none;
}
.radio .dot {
  width: 16px; height: 16px; flex: none; border-radius: 50%;
  border: 1.5px solid var(--color-divider);
}
.radio:hover .dot { border-color: var(--color-accent); }
.radio input:checked + .dot {
  border-color: var(--color-accent); background: var(--color-accent);
  box-shadow: inset 0 0 0 4px var(--color-bg);
}
.radio input:focus-visible + .dot { outline: 2px solid var(--color-accent); outline-offset: 2px; }
.seg {
  display: inline-flex; overflow: hidden;
  border: 1px solid var(--color-divider); border-radius: var(--radius-md);
}
/* Edit 4: .seg-opt 13px -> Body 14px. Unlisted in the Normalisation table
   because the class is unused in Phase 3, but remapped anyway: the contract
   requires every declared size to be one of the four, unused or not. */
.seg-opt {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 7px 12px; font-size: 14px; cursor: pointer;
}
.seg-opt + .seg-opt { border-left: 1px solid var(--color-divider); }
.seg-opt:has(input:checked) { background: var(--color-accent); color: var(--color-bg); }
.seg-opt:not(:has(input:checked)):hover { background: color-mix(in srgb, var(--color-text) 7%, transparent); }
.seg-opt:has(input:focus-visible) { outline: 2px solid var(--color-accent); outline-offset: -2px; }

/* — cards — */
/* Edit 3: the deleted 12px token -> --space-4. */
.card {
  display: flex; flex-direction: column; gap: var(--space-2);
  padding: var(--space-4); border-radius: var(--radius-md); background: var(--color-surface);
}
/* Edit 4: .card-kicker 10px -> Label 11px. */
.card-kicker { font-size: 11px; letter-spacing: 0.14em; text-transform: uppercase; color: var(--color-accent-700); }
/* Edit 4: .card-title 17px -> Heading 24px. */
.card-title {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 24px; line-height: 1.2;
}
/* Edit 4: .card-body 13px -> Body 14px. */
.card-body { margin: 0; font-size: 14px; opacity: 0.8; flex: 1; }
.card-meta {
  display: flex; align-items: center; gap: 6px; font-size: 11px;
  color: color-mix(in srgb, var(--color-text) 50%, transparent);
}
.elev-sm { box-shadow: var(--shadow-sm); }
.elev-md { box-shadow: var(--shadow-md); }
.elev-lg { box-shadow: var(--shadow-lg); }

/* — tags — */
.tag {
  display: inline-flex; align-items: center; font-size: 11px;
  letter-spacing: 0.02em; padding: 3px 10px;
  border-radius: calc(var(--radius-md) * 0.75);
}
.tag-accent { background: var(--color-accent-100); color: var(--color-accent-800); }
.tag-accent-2 { background: var(--color-accent-2-100); color: var(--color-accent-2-800); }
.tag-neutral { background: var(--color-neutral-100); color: var(--color-neutral-800); }
.tag-outline { border: 1px solid var(--color-accent); color: var(--color-accent-700); }

/* — navigation — */
/* Edit 3: .nav pins the 56px chrome height explicitly rather than deriving
   it from padding, so a font fallback cannot change it. */
.nav {
  display: flex; align-items: center; gap: var(--space-4);
  height: 56px; padding: 0 var(--space-8);
  border-bottom: none;
}
/* Edit 4: .nav-brand 18px -> Heading 24px. */
.nav-brand {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 24px; margin-right: auto;
}
.nav a { color: inherit; text-decoration: none; font-size: 14px; }
.nav a:hover, .nav a[aria-current='page'] { color: var(--color-accent-700); }

/* — tables — */
.table { width: 100%; border-collapse: collapse; font-size: 14px; }
/* Edit 3: --space-1 block / --space-2 inline. 4px x 2 + a 28px content box
   is the 36px row; horizontal padding never contributed to row height, and
   8px is what makes the 32px chevron column viable. */
.table th {
  text-align: left; font-size: 11px; letter-spacing: 0.08em; text-transform: uppercase;
  color: color-mix(in srgb, var(--color-text) 60%, transparent);
  padding: var(--space-1) var(--space-2); border-bottom: 1px solid var(--color-divider);
}
.table td {
  padding: var(--space-1) var(--space-2);
  border-bottom: 1px solid color-mix(in srgb, var(--color-text) 8%, transparent);
}
.table tbody tr:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }

/* — dialog — */
.dialog-backdrop {
  position: fixed; inset: 0; display: grid; place-items: center;
  padding: var(--space-4);
  background: color-mix(in srgb, var(--color-neutral-900) 50%, transparent);
}
/* Edit 3: the class is UNUSED in Phase 3 — read-only, no confirmations —
   and is remapped only so no reference to the deleted token survives. */
.dialog {
  width: min(440px, 100%); display: flex; flex-direction: column; gap: var(--space-4);
  padding: var(--space-4); border-radius: var(--radius-lg);
  background: var(--color-surface); box-shadow: var(--shadow-lg);
}
/* Edit 4: .dialog-title 20px -> Heading 24px. */
.dialog-title {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 24px;
}
.dialog-body { font-size: 14px; opacity: 0.85; }
.dialog-actions { display: flex; justify-content: flex-end; gap: var(--space-2); margin-top: var(--space-2); }

/* — blueprint frame: components are wireframe objects (see .blueprint
     and .corner above) — square, transparent, hairline-bordered — */
.card, .btn, .input, .tag, .seg, .dialog { border-radius: 0; }
.card, .dialog { background: transparent; border: 1px solid var(--color-divider); }
.btn { border: 1px solid var(--color-divider); }
.btn-primary { border-color: var(--color-accent); }
.btn-ghost { border-color: transparent; }

/* ══════════════════════════════════════════════════════════════════════════
   Edit 5 — APPEND ONLY. The dashboard's own classes.

   Nothing below may declare a CSS transition on a table row or a count span:
   anti-flicker rule 5 forbids motion on polled elements. A row that changes
   status changes instantly; a 200ms fade across 100 rows reads as a glitch.
   ══════════════════════════════════════════════════════════════════════ */

/* — voices — */
.kicker {
  font-size: 11px; font-weight: 600; line-height: 1.2;
  letter-spacing: 0.14em; text-transform: uppercase;
}
.sub-line {
  font-size: 11px; font-weight: 400; line-height: 1.2;
  color: var(--color-muted);
  /* No top margin. Its own 1.2 line box supplies the optical gap; 4px of
     margin would overflow the 44px sub-line row's content box and the row
     would silently grow to 46px — exactly the flicker this forbids. */
  margin: 0;
}
.device-name { font-size: 14px; font-weight: 600; letter-spacing: 0.01em; }
.mono {
  font-family: var(--font-mono); font-size: 14px; font-weight: 400;
  line-height: 1.5;
}

/* The headline figure. One element, one view. */
.figure {
  font-family: var(--font-heading); font-weight: 600;
  font-size: 72px; line-height: 0.9;
  font-variant-numeric: tabular-nums;
}

/* Every numeric readout. Non-negotiable: this is what stops a 30-second poll
   from reflowing the table. */
.num, .tile-count, .table td, .table th { font-variant-numeric: tabular-nums; }

/* — glyphs —
   Verbatim from 03-UI-SPEC.md § Glyph Contract. Fixed width plus
   line-height: 1 plus vertical-align: middle makes a fallback substitution
   metric-neutral, and caps a glyph's contribution to a row at its em box. */
.glyph {
  font-family: var(--font-glyph);
  display: inline-block;
  width: 1em; text-align: center;
  line-height: 1; vertical-align: middle;
}

/* — buttons, additional heights — */
/* 28px: it must fit inside the 36px panel-header strip (4px block padding
   x 2 + 28px = exactly 36px). The size difference from the 32px filter bar
   is also the only cue that says "scoped to one panel". */
.btn-sm { height: 28px; padding: 0 var(--space-2); }
/* 40px: the login/primary height. */
.btn-lg { height: 40px; padding: 0 var(--space-4); }

/* — status chips —
   Eight treatments carry thirteen labels. The chip's text is the derived
   status verbatim; the treatment is its tile group's. Every chip is one
   line: 11px x 1.2 (13.2px) + 4px x 2 padding + 1px x 2 border = 23.2px,
   the tallest thing in a 28px row content box. A chip must never wrap. */
.chip {
  display: inline-flex; align-items: center; gap: var(--space-1);
  font-size: 11px; font-weight: 600; line-height: 1.2;
  letter-spacing: 0.14em; text-transform: uppercase;
  padding: var(--space-1) var(--space-2);
  border: 1px solid transparent; border-radius: 0;
  white-space: nowrap;
}
/* 1 — solid steel */
.chip-verified-clean { background: var(--color-accent-800); color: var(--color-neutral-100); border-color: var(--color-accent-800); }
/* 2 — solid steel + alert hairline. Structurally distinct from 1, not just
   hue-distinct: it is the pair most likely to be conflated. */
.chip-rmm-clean-av-blocked { background: var(--color-accent-800); color: var(--color-neutral-100); border-color: var(--color-alert); }
/* 3 — steel tint */
.chip-remaining { background: var(--color-accent-200); color: var(--color-accent-800); border-color: var(--color-accent-400); }
/* 4 — steel outline */
.chip-awaiting-reboot { background: transparent; color: var(--color-accent-800); border-color: var(--color-accent-600); }
/* 5 — alert tint (fill) */
.chip-av-blocked { background: var(--color-alert-tint); color: var(--color-alert-text); border-color: var(--color-alert); }
/* 6 — alert outline. Shares a hue with 5, separated by fill vs outline. */
.chip-needs-human { background: transparent; color: var(--color-alert-text); border-color: var(--color-alert); }
/* 7 — alert outline, dashed. Sends the operator to the server, not the PC. */
.chip-agent-fault { background: transparent; color: var(--color-alert-text); border-color: var(--color-alert); border-style: dashed; }
/* 8 — neutral dashed. Never styled as an error: no_data is a routine
   lock_held skip and must never page anyone. */
.chip-no-data { background: transparent; color: var(--color-neutral-700); border-color: var(--color-neutral-400); border-style: dashed; }

/* 9 — never reported. Tile only; there are no device rows behind it, the
   count is arithmetic. Ground + neutral diagonal hatch + hairline. */
.tile-never-reported {
  border: 1px solid var(--color-divider);
  background-color: var(--color-bg);
  background-image: repeating-linear-gradient(135deg,
    var(--color-neutral-300) 0, var(--color-neutral-300) 1px,
    transparent 1px, transparent 6px);
}

/* Regression prefix badge: 1px border + a 14px content box for an 11px
   glyph at line-height 1. */
.badge-regressed {
  display: inline-flex; align-items: center; justify-content: center;
  width: 16px; height: 16px; flex: none;
  font-size: 11px; line-height: 1;
  border: 1px solid var(--color-alert); color: var(--color-alert-text);
}

/* — the inverted component strip —
   Filled = STILL PRESENT. Hollow = gone. A clean machine's strip is empty,
   so the fleet's success state is an empty, quiet grid and activity reads as
   debt. Anyone building this from intuition builds it backwards and it looks
   correct. Data-driven per D-27: min(total, 12) cells, `filled` of them
   solid. At the 12-cell cap: 12 x 8px + 11 x 4px = 140px, which fits the
   200px column beside the count label. */
.strip { display: inline-flex; align-items: center; gap: var(--space-1); }
.strip-cell {
  width: 8px; height: 8px; flex: none;
  border: 1px solid var(--strip-fill, var(--color-accent-800));
  background: transparent;
}
.strip-cell.is-filled { background: var(--strip-fill, var(--color-accent-800)); }
/* Fill colour follows the row's tile group: steel for groups 1-4, alert for
   groups 5-7. */
.strip.is-alert { --strip-fill: var(--color-alert); }
/* The +N label when total exceeds the cap. */
.strip-more { font-size: 11px; color: var(--color-muted); font-variant-numeric: tabular-nums; }

/* — freshness as row solidity, never as a category —
   This is what avoids an 8 x 3 status-freshness matrix. Freshness carries no
   hue at all: three-tick glyphs plus the treatments below.

   The veil is applied to a wrapper <div> INSIDE each <td>, never to the <tr>:
   a <tr> opacity change would fade the alert left edge and the confidence
   glyph too, and the contract requires a faded row to keep one sharp thing
   in it to pull the eye. Templates therefore omit .veil in the confidence
   cell — opacity cannot be undone by a child. */
.veil { opacity: 1; }
tr.is-stale .veil { opacity: 0.62; }
tr.is-missing .veil { opacity: 0.45; }
tr.is-stale {
  background-image: repeating-linear-gradient(135deg,
    color-mix(in srgb, var(--color-text) 6%, transparent) 0,
    color-mix(in srgb, var(--color-text) 6%, transparent) 1px,
    transparent 1px, transparent 7px);
}
tr.is-missing {
  background-image: repeating-linear-gradient(135deg,
    color-mix(in srgb, var(--color-text) 11%, transparent) 0,
    color-mix(in srgb, var(--color-text) 11%, transparent) 1px,
    transparent 1px, transparent 5px);
  /* border-left does not render reliably under border-collapse; an inset
     shadow gives the 2px alert edge without disturbing the fixed layout. */
  box-shadow: inset 2px 0 0 0 var(--color-alert);
}

/* — the fleet table —
   Fixed layout with declared widths: a polled swap must never re-measure a
   column. Rule 1 is only sound because --row-h is declared and no cell
   content can exceed it (see the row-height arithmetic in the UI-SPEC).

   The declared minimum is the SUM of the eight column widths below
   (240 + 150 + 200 + 130 + 130 + 110 + 70 + 32 = 1062), and it is why no
   horizontal scrollbar appears at >= 1280px once the fleet view's nested
   620px scroller is gone (Phase 3.1, Q4). It is also the number that
   permanently rules out a sticky left rail — see the .tile comment. */
.table-fleet { table-layout: fixed; width: 100%; min-width: 1062px; }
.table-fleet td { height: var(--row-h); overflow: hidden; }
.col-device { width: 26%; min-width: 240px; }
.col-status { width: 150px; }
.col-remaining { width: 200px; }
.col-confidence { width: 130px; }
.col-last-seen { width: 130px; }
.col-first-seen { width: 110px; }
.col-runs { width: 70px; }
.col-chevron { width: 32px; }
/* The sticky header never re-renders; only <tr> and count <span> elements
   are swap targets. */
.table-fleet thead th { position: sticky; top: 0; z-index: 1; background: var(--color-surface); }
/* Inactive sortable headers reserve the arrow's width so toggling sort does
   not shift the header text. */
.sort-indicator { display: inline-block; width: 1em; text-align: center; color: var(--color-accent-700); }
.row-chevron { color: var(--color-accent-700); }

/* — panels and scroll wrappers — */
.panel { border: 1px solid var(--color-divider); background: transparent; }
.panel-header {
  display: flex; align-items: center; gap: var(--space-2);
  height: 36px; padding: var(--space-1) var(--space-4);
  background: var(--color-surface);
}
.scroll-620 { max-height: 620px; overflow: auto; }
/* 432px = 12 x the 36px module: twelve whole run entries and a cleanly
   half-cut thirteenth, which reads as "there is more, scroll". */
.scroll-432 { max-height: 432px; overflow: auto; }

/* — the reversed field: exactly three surfaces —
   the login hero panel, the all-clean end-state plate, and the run log. All
   three read --color-reversed-text; none of them carries a literal any more. */
.reversed { background: var(--color-accent-900); color: var(--color-reversed-text); }
.log-pre {
  font-family: var(--font-mono); font-size: 14px; line-height: 1.5;
  background: var(--color-accent-900); color: var(--color-reversed-text);
  white-space: pre-wrap; overflow-wrap: anywhere;
  max-height: 620px; overflow: auto;
  margin: 0; padding: var(--space-4);
}

/* — empty states — */
.empty-state { padding: var(--space-12) var(--space-4); text-align: center; }
/* The shared padding-and-field rule for a reversed surface. It was originally
   named after the all-clean end-state plate — the first of its TWO callers —
   and the login hero has worn a class named after a state it is not in ever
   since. login.gohtml:10-17 has carried that as a written ticket since plan
   03-05; this rename closes it. No alias is left behind: a second name for one
   concept means the next reader cannot tell which one is current. Its two
   template callers move in the same wave (plan 03.1-05). */
.reversed-plate { padding: var(--space-16); background: var(--color-accent-900); color: var(--color-reversed-text); }
.hatch-ground {
  background-image: repeating-linear-gradient(135deg,
    var(--color-divider) 0, var(--color-divider) 1px,
    transparent 1px, transparent 7px);
}

/* — the live poll indicator — */
.pulse-dot {
  display: inline-block; width: 8px; height: 8px; flex: none;
  border-radius: 50%; background: var(--color-accent);
}

/* — the fleet status tiles —
   A tile is a FILTER CONTROL, so it ships as a <button aria-pressed> and the
   pressed state is announced rather than merely tinted.

   The nine tiles are NOT a free-wrapping inline row any more. Phase 3.1 (Q2)
   puts them into three weighted groups — ACTION REQUIRED / CONVERGING /
   REFERENCE — inside the summary strip's right-hand column, so the containers
   below (.status-groups, .status-group-tiles) are what arranges them. This rule
   still describes the FULL tile: the treatment worn by the three ACTION
   REQUIRED tiles.

   .tile IS ALSO WORN BY THE FRESHNESS ROWS (fleet.gohtml:149). That is why the
   CONVERGING / REFERENCE compact treatment is a NEW class (.tile-compact, in
   the Phase 3.1 block at the end of this file) and never a change to .tile:
   retuning .tile to be compact would silently reshape the freshness chips too.

   Layout B — the mockup's compact sticky left rail — stays rejected, but not
   for the reason once recorded here. It has nothing to do with the tile count.
   The fleet table's own DECLARED column widths sum to 1062px, and the mockup's
   rail is 236px plus its 20px grid gap, which at 1366 leaves 1031px of a 1287px
   content box — the table overflows by 31px. Reducing the tile count does not
   reopen it.

   The 24px count and the 11px label are Heading and Label from the four-size
   ramp. No fifth size is introduced here. */
.tile {
  display: inline-flex; flex-direction: column; gap: var(--space-1);
  align-items: flex-start; text-align: left;
  min-width: 168px; margin: 0 var(--space-2) var(--space-2) 0;
  padding: var(--space-2) var(--space-4);
  cursor: pointer; text-decoration: none; color: var(--color-text);
  background: transparent; border: 1px solid var(--color-divider);
}
.tile:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
/* The active-filter cue, from the accent-reserved list ("the active filter chip
   border"). Selected state is carried by aria-pressed, so the border follows
   the attribute rather than a second class that could disagree with it. */
.tile[aria-pressed="true"] { border-color: var(--color-accent); }
.tile-count {
  font-family: var(--font-heading); font-weight: var(--font-heading-weight);
  font-size: 24px; line-height: 1.2;
}
.tile-label {
  font-size: 11px; font-weight: 600; line-height: 1.2;
  letter-spacing: 0.14em; text-transform: uppercase; color: var(--color-muted);
}

/* — the fleet composition bar —
   A flex box, not a charting library; none is permitted.

   THE CELLS ARE EQUAL-WIDTH AND THE PROPORTION IS IN THE CELL COUNT. The
   mockup draws nine segments carrying percentage widths, but the CSP is
   style-src 'self' with no 'unsafe-inline', so a per-segment width would have
   to be an inline style attribute — which the browser drops silently while the
   server reports 200, i.e. a bar that renders as one flat block. Putting the
   proportion in the number of cells is what lets a class name express it, and
   it is also what makes the caption literally true: every device counted once.

   One fill per tile, from 03-UI-SPEC.md's Composition-bar fill column. The
   class names are the nine URL tile keys unaltered — underscores and all —
   because they are derived in Go from Tile.Key rather than hand-listed here,
   and a transliteration would be a second mapping nothing tests. */
.comp-bar {
  display: flex; height: 32px; overflow: hidden;
  border: 1px solid var(--color-divider); background: var(--color-neutral-100);
}
.comp-cell { flex: 1 1 0; min-width: 0; }
.comp-verified_clean { background: var(--color-accent-800); }
/* Solid steel with the 2px inset alert top edge — the structural, not merely
   chromatic, separation from tile 1. */
.comp-rmm_clean_av_blocked {
  background: var(--color-accent-800);
  box-shadow: inset 0 2px 0 0 var(--color-alert);
}
.comp-remaining { background: var(--color-accent-500); }
.comp-awaiting_reboot { background: var(--color-accent-300); }
.comp-av_blocked { background: var(--color-alert); }
.comp-needs_human { background: color-mix(in srgb, var(--color-alert) 55%, var(--color-bg)); }
.comp-agent_fault {
  background-image: repeating-linear-gradient(135deg,
    var(--color-alert) 0, var(--color-alert) 1px,
    transparent 1px, transparent 5px);
}
.comp-no_data { background: var(--color-neutral-300); }
.comp-never_reported {
  background-color: var(--color-bg);
  background-image: repeating-linear-gradient(135deg,
    var(--color-neutral-300) 0, var(--color-neutral-300) 1px,
    transparent 1px, transparent 6px);
}

/* — the filter bar (DASH-03) —
   One height and one baseline: .btn is already the declared 32px, and the
   search input is brought down to match it here rather than by editing the
   upstream .input rule, which is 40px because the login form needs it.

   No transition on anything below: the poll never swaps this region, but rule 5
   is a property of the sheet rather than of one selector. No new font size —
   every control inherits .btn's 14px or .num's. */
.filter-bar {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2);
  padding: var(--space-2) var(--space-4);
  border: 1px solid var(--color-divider); border-bottom: 0;
}
.filter-bar .input { height: 32px; flex: 1 1 240px; min-width: 240px; }
.filter-facet { display: inline-flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); }
/* Selected state follows aria-pressed rather than a second class that could
   disagree with it — the same rule .tile[aria-pressed] already follows. */
.filter-facet .btn[aria-pressed="true"] { border-color: var(--color-accent); }

/* — sortable column headers —
   A real <button> inside the <th>, unstyled down to the header's own type so it
   reads as a header and not as a control bolted onto one. */
.th-sort {
  display: inline-flex; align-items: center; gap: var(--space-1);
  font: inherit; color: inherit; letter-spacing: inherit; text-transform: inherit;
  background: transparent; border: 0; padding: 0; cursor: pointer;
}
.th-sort:hover { color: var(--color-accent-700); }

/* — the live poll indicator row and the chunked-append footer — */
.poll-live {
  display: flex; align-items: center; justify-content: flex-end;
  gap: var(--space-2); padding: var(--space-1) var(--space-4);
}
.table-footer {
  display: flex; align-items: center; gap: var(--space-2);
  padding: var(--space-2) var(--space-4);
  border-top: 1px solid var(--color-divider);
}

/* — the drill-down (DASH-04) —
   Append-only, like everything above it in Edit 5: no upstream rule is altered,
   no @import, no deleted spacing token, and NO FIFTH FONT SIZE — every rule here
   inherits .mono's 14px or .sub-line's 11px.

   .break-all is the only one that is load-bearing rather than cosmetic. The
   UI-SPEC asks for word-break: break-all on the hardware ID and MachineGuid, and
   under style-src 'self' that CANNOT be an inline style attribute — the browser
   would drop the declaration while the server reported 200, and a 36-character
   UUID would overflow its panel on a narrow viewport with nothing in any log to
   say so. overflow-wrap is paired with it so the break happens at the boundary
   first and mid-token only when it has to. */
.break-all { overflow-wrap: anywhere; word-break: break-all; }

/* The run history's entries. The scroll region itself is .scroll-432, which
   already existed: 12 x the 36px row module, so a full panel shows twelve whole
   entries and half of the thirteenth — a deliberate "scroll for more" cue rather
   than an arbitrary crop. */
.run-list { list-style: none; margin: 0; padding: 0; }
.run-entry {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--space-2);
  padding: var(--space-1) var(--space-4);
  border-bottom: 1px solid var(--color-divider);
}
/* The notes block under an entry. It is rendered ONLY when there is something to
   put in it — no empty container and no placeholder — because a block that is
   always present is a block the operator learns to stop reading. */
.run-detail { padding: 0 var(--space-4) var(--space-2) var(--space-4); }

/* — the fleet table's sub-line, pinned to one line box (D1) —
   Appended by plan 03-19, and append-only like everything above it in Edit 5:
   no upstream rule is altered, no external stylesheet is pulled in, no deleted
   spacing token is used, and there is NO FIFTH FONT SIZE — this block declares
   no font-size at all, so it inherits .sub-line's 11px.

   The measurement is the argument. At white-space: nowrap the two captions the
   server can put in this cell measure 273.67px ("No components have ever been
   collected from this device", 63 rows) and 243.80px ("Every component ever
   seen here has been removed", 5 rows) — against a Components-remaining column
   the UI-SPEC fixes at 200px. So the sub-line wrapped at every viewport where
   the percentage layout did not stretch that column past ~274px: measured row
   heights were {36.00: 99, 51.00: 1} at 1920x1000 but {51.00: 68, 36.00: 32} at
   BOTH 1366x768 and 1600x900, against a declared 36px row. That is why the
   defect was invisible at 1920 and hit 68% of rows at 1366 — it is not a
   narrow-viewport edge case, it is the designed column being narrower than the
   copy, concealed by a wider-than-designed rendering.

   This is the structural remedy and not a copy fix. Shortening today's two
   sentences, or widening the column, both leave tomorrow's sentence free to
   grow a second line box and break anti-flicker rule 0 — "--row-h is a declared
   height and no cell content can exceed it" — which rule 1 depends on. The
   declared height stays 36px and the column stays 200px; what changes is that
   the content can no longer exceed either.

   All four declarations are load-bearing:
     display: block          text-overflow has no effect on an inline box, and
                             rows.gohtml renders the components-remaining
                             sub-line as a <span> inside the .veil <div>.
     white-space: nowrap     the structural half — the cell can no longer grow a
                             second line box, whatever the copy says.
     overflow: hidden        text-overflow does nothing without it.
     text-overflow: ellipsis a clipped sentence must READ as clipped rather than
                             as a sentence that simply ends.

   SCOPED TO FLEET-TABLE CELLS DELIBERATELY. The same class is a three-line
   paragraph in the all-clean side panel (empty-states.gohtml, the "devices last
   confirmed 3+ days ago" card) and the filter summary in the no-match state
   (empty-states.gohtml, empty-no-match); it also carries the drill-down text,
   the live poll indicator in fleet.gohtml and the load-more footer in
   rows.gohtml. A global nowrap would clip every one of them. Widening this
   selector back to a bare .sub-line is a red build — see
   TestFleetRowContentCannotWrap in internal/web/rowheight_test.go. */
.table-fleet td .sub-line {
  display: block;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* — the filter identity, inline beside the device name —
   Appended by quick task 260817-jtg, and append-only like everything above it
   in Edit 5: no upstream rule is altered, no external stylesheet is pulled in,
   no deleted spacing token is used, and there is NO FIFTH FONT SIZE — 11px is
   already a member of the four-size ramp (72/24/14/11) that
   TestVendoredCSSContract enforces mechanically.

   .filter-user is NOT .sub-line, and that is a decision rather than an
   oversight. `.table-fleet td .sub-line` above declares display: block, so
   reusing that class inside a fleet cell would put the identity on a SECOND
   LINE BOX — which is both the placement the locked decision forbids (it is to
   render on the same line as the device name) and the growth anti-flicker rule
   0 forbids. A separate class is what lets it be an inline box.

   The second rule is anti-flicker rule 0 applied to the Device cell
   specifically. That cell now carries TWO inline boxes — the name anchor and
   this identity — in a column the UI-SPEC fixes at 26% with a 240px minimum,
   and defect D1 established by measurement that `overflow: hidden` on the td
   does NOT stop a wrapped line box from growing the row past the declared 36px.
   nowrap is the structural half: the cell can no longer grow a second line box
   whatever the identity's length. The ellipsis is what makes a clipped identity
   READ as clipped rather than as one that simply ends — an operator who cannot
   tell a truncated address from a whole one will act on the wrong one.

   The selector deliberately does not match rowheight_test.go's existing
   `\.table-fleet\s+td\s*\{` or `\.table-fleet\s+td\s+\.sub-line\s*\{` patterns:
   `td.col-device` has no whitespace-then-brace after `td`, so both pre-existing
   assertions are untouched and this rule gets its own. */
.filter-user { font-size: 11px; font-weight: 400; color: var(--color-muted); }
.table-fleet td.col-device .veil {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* ══════════════════════════════════════════════════════════════════════════
   Phase 3.1 — the layout Phase 3 approved and never implemented.

   Appended by plan 03.1-01, which OWNS this stylesheet for the whole of Phase
   3.1: every other plan in the phase writes markup that consumes the classes
   below and touches no CSS. That is what lets those plans run in parallel, and
   it is why there is one digest regeneration for the phase rather than four.

   Same Edit-5 terms as the three append blocks above: no upstream rule is
   altered, no external stylesheet is pulled in, no deleted spacing token is
   used, NO FIFTH FONT SIZE (the one size declared below is 14px, already on the
   72/24/14/11 ramp) and NO TRANSITION on any polled element.

   THE MEASUREMENT IS THE ARGUMENT — and this block exists because the argument
   was already won and the rule was simply never written. A grep for a bare
   `main` selector in this file returned ZERO MATCHES, so every panel on every
   page rendered as a full-width stacked block: measured at 1920, every .card
   1903px wide with anyMultiColumn false, and the fleet headline card 1903x244px
   with ~1800px of empty ground beside a 72px figure. Not a vertical stack
   anyone chose — no layout rule existed at all. 03-UI-SPEC.md:594 already said
   "inline" and :57 already said "copy the mockup's layout".

   Locked by TestPhase31LayoutRulesExist in internal/web/cssrules_test.go, which
   captures each declaration block below with a scoped regexp and asserts the
   declarations INSIDE it. That test reads bytes; it does not lay them out. The
   rendered measurements M1-M8 belong to plan 03.1-12's browser pass.
   ══════════════════════════════════════════════════════════════════════ */

/* — the page shell (M15, B-20) —
   24px top, 32px sides, 32px bottom. --space-8 is already the page-gutter token
   and .nav already uses it as its inline gutter, so this reuses an established
   value rather than inventing one.

   It also closes B-20, which is not a cosmetic defect. The blueprint
   registration marks are drawn at -6px OUTSIDE their frame — the one sanctioned
   exception to the 4-grid, declared far above — so with a full-bleed panel at a
   zero page gutter they hang off the edge of the document: measured scrollWidth
   1356 against clientWidth 1351, i.e. a horizontal scrollbar on every page of
   the dashboard. A 32px gutter puts them back inside the document. */
main { padding: var(--space-6) var(--space-8) var(--space-8); }

/* — the fleet summary strip (M6) —
   Two real columns. Measured today the three cards' tops are 279 / 523 / 632
   and all three are 1349px wide: a vertical stack, three times.

   380px is the mockup's OWN left column (design/dashboard/Fleet Cleanup
   Dashboard.dc.html), not a new number. The right track is minmax(0, 1fr)
   rather than 1fr because a grid track's automatic minimum is `auto`: a wide
   table, or one long unbroken string, would otherwise push that track past its
   share and reintroduce exactly the horizontal overflow the rule above closes.
   align-items: start so neither column drags the other to its height. */
.fleet-summary {
  display: grid;
  grid-template-columns: 380px minmax(0, 1fr);
  gap: var(--space-6);
  align-items: start;
}

/* — the login split (M7) —
   Measured today: hero and form render as two stacked full-width blocks, so at
   1366 the password input is ~1330px wide for ~20 characters (B-04) and content
   ends at 590px of a 768px viewport with the rest dead grey (B-08).

   padding: 0 overrides the bare-`main` gutter deliberately — the hero is
   full-bleed by design, and the navy running the full height of its own column
   is also what removes the hard seam where it used to meet the grey body
   (B-07). main.login is more specific than main, so the order of the two rules
   does not matter and this does not depend on staying below it.

   396px is 460 minus 2 x 32 of gutter. The point of it is that the input is
   bounded by the CARD and not by the viewport: no control on this page is
   wider. */
main.login {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 460px;
  min-height: 100vh;
  padding: 0;
  align-items: stretch;
}
.login-form-col {
  display: flex; flex-direction: column; justify-content: center;
  padding: var(--space-8);
}
.login-form-col .card { width: 100%; max-width: 396px; }

/* — the device drill-down grid (M8) —
   Measured at 1920: every .card 1903px wide, anyMultiColumn false, six panels
   stacked to 1260px of page on the one view that is naturally two-column.

   .device-col IS LOAD-BEARING and not a wrapper for its own sake. CSS grid
   auto-placement fills ROW BY ROW, so six panels dropped straight into a
   two-column grid land as three rows of pairs — Identity beside Component
   inventory, and so on down — NOT as a left column and a right column. Two
   explicit column wrappers are what produce the grouping 03.1-UI-SPEC.md
   § Device drill-down states.

   #run-log-panel and the legend stay DIRECT children of the grid so .span-2
   applies to them — and so it applies to whatever htmx swaps into the log slot
   as well, which a nested wrapper would have broken silently. */
.device-grid {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: var(--space-6);
  align-items: start;
}
.device-grid > .span-2 { grid-column: 1 / -1; }
.device-col { display: flex; flex-direction: column; gap: var(--space-6); }

/* — the single breakpoint —
   THE ONLY MEDIA QUERY THIS PHASE ADDS, and the only one in the sheet. Below
   1100px of content width all three grids above collapse to one column in
   source order. A second media query is the beginning of a responsive system
   this phase did not sign up for: the viewports of record are 1366x768 and
   1920x1080, with 1280x720 the untested minimum. */
@media (max-width: 1100px) {
  main.login { grid-template-columns: minmax(0, 1fr); min-height: auto; }
  .fleet-summary { grid-template-columns: minmax(0, 1fr); }
  .device-grid { grid-template-columns: minmax(0, 1fr); }
}

/* — the three status groups (Q2) —
   Nine tiles, three weighted groups, zero renames: same keys, same
   id="tile-{key}-count" out-of-band swap targets, same <button aria-pressed>
   filter behaviour, no URL change, TileFor's exhaustive switch untouched.

   .tile-compact IS A SIZE, NOT A DEMOTION. It is still a real
   <button aria-pressed>, and it still carries the same accent border every
   other active filter in the product wears — so a filtered CONVERGING tile is
   exactly as visible as a filtered ACTION REQUIRED one.

   The hierarchy is bought with SIZE, ORDER AND POSITION ONLY. No group
   introduces a colour, so the grouping stays fully available to a colourblind
   operator, and each group heading is real text so it is available to a screen
   reader too. 14px and 11px are both already on the four-size ramp — 24px for
   the three things that need a human, 14px for the six that do not. There is no
   fifth size and no fourth chip treatment.

   .status-group-heading is worn ALONGSIDE the existing .kicker, which is where
   its size, weight, tracking and case come from; this rule introduces none of
   them. */
.status-groups { display: flex; flex-direction: column; gap: var(--space-2); }
.status-group-heading { color: var(--color-muted); }
.status-group-tiles {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2);
}
.tile-compact {
  display: inline-flex; align-items: baseline; gap: var(--space-1);
  padding: var(--space-1) var(--space-2); margin: 0;
  text-align: left; cursor: pointer;
  color: var(--color-text); background: transparent;
  border: 1px solid transparent;
}
.tile-compact:hover { background: color-mix(in srgb, var(--color-text) 4%, transparent); }
.tile-compact[aria-pressed="true"] { border-color: var(--color-accent); }
.tile-count-sm { font-size: 14px; font-weight: 600; line-height: 1.2; }

/* — the AV / EDR vendor facet (Q3-B, M9-M12) —
   Measured today: 23 chips in a 104px block across 3 rows, at the very top of
   the page, above all content — the operator's stated primary complaint. The
   facet becomes ONE CLOSED <details> reading `EDR/AV vendor: any`, so its
   collapsed height is 32px INVARIANT BY CONSTRUCTION rather than by a cap: no
   chip is rendered at all, and a 16-vendor and a 40-vendor fleet produce
   byte-identical markup for this control.

   The summary's width cap is LOAD-BEARING, not cosmetic. A long multi-vendor
   summary that wrapped the filter bar to a second row would blow the 56px
   collapsed band budget outright, and the budget is what M1 measures. The full
   selection stays available in the title/aria-label.

   The expanded panel's cap is 4 chip rows — 4 x 32 + 3 x 8 = 152px — and what it
   buys is that the expanded bar is BOUNDED at every vendor count. Retuned by
   quick task 260819-i3a, which took the panel out of the <details> (F-6): the
   open bar is now 1 border + 8 padding + 32 (the controls row, unchanged from
   the closed state) + 8 row gap + the panel + 8 padding, i.e. 145px at 16
   vendors and 209px with the panel at its bound. The arithmetic this replaces
   (8 + 32 + 8 + 152 + 8 = 208px) counted neither the bar's own border nor the
   third row the open facet used to displace, which is the 217px F-3 in
   03.1-MEASUREMENTS.md records and its 65px of omissions. IDENTICAL heights at
   16 and 40 vendors is still a claim the geometry does not support — a 2-row
   panel is not at its 4-row cap — and F-3 states that separately. Bounded is
   what criterion 3 asks for and bounded is what this delivers.

   .is-active is a NAMED CLASS the template carries from a Go boolean, not a
   :has() selector — the UI-SPEC's own stated preference, because a named class
   is greppable and cannot be broken by a markup change three files away. It
   cannot reuse .filter-facet .btn[aria-pressed="true"] above either: a
   <summary> has no aria-pressed for that rule to key on. */
details.provider-facet > summary {
  max-width: 260px;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
details.provider-facet.is-active > summary { border-color: var(--color-accent); }
/* THE OPEN FACET IS NO LONGER PROMOTED TO FULL WIDTH. Retuned in place from
   `flex: 1 0 100%` under clause (3) of the Edit-5 terms at the top of this file,
   by quick task 260819-i3a, closing F-6.

   What 1 0 100% did, measured live at 1366x768 on 2026-08-19: it made the whole
   <details> a full-width flex item, its own 32px summary included, so the
   summary was dragged off the top row and Clear filters and Export CSV were
   pushed onto a third one. One row at 49px became three rows at 217px, and the
   search input stretched 557 -> 888px on the way. The operator's requirement is
   "everything on the top line should stay, only add a line with the vendors".

   0 1 auto is the RIGHT value and not merely the absence of the old one: opening
   this disclosure must not change the sizing of its own control at all, in
   either direction. The full-width row is now the CHIP PANEL, which is a sibling
   of the <details> and therefore a flex item in its own right — see the
   .filter-bar > .provider-chips block appended at the end of this sheet.

   The declaration is KEPT rather than deleted, though 0 1 auto is the initial
   value, because [open] is what the next reader greps for when the facet
   misbehaves. A one-line diff with this comment over it answers them; a vanished
   rule leaves them looking for a rule that used to exist. */
details.provider-facet[open] { flex: 0 1 auto; }
.provider-chips {
  display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-2);
  max-height: 152px; overflow-y: auto;
  padding: var(--space-2) 0;
}

/* — the htmx request-in-flight indicator (B-03, M20) —
   Broken fleet-wide today, and not subtly: htmx applies the indicator's opacity
   as an INLINE STYLE, which style-src 'self' drops silently while the server
   reports 200. One CSP violation per page, and no in-flight feedback on any
   htmx request the dashboard makes. The same class of defect as the
   unsafe-eval one, one directive over.

   NO TRANSITION, deliberately. Anti-flicker rule 5 forbids motion on a polled
   element, and an indicator that fades in over 200ms is indistinguishable from
   a slow server — which is the one thing it exists to tell apart.

   The companion half is plan 03.1-02, which sets
   htmx.config.includeIndicatorStyles = false through an htmx-config <meta> that
   htmx reads at load. FAIL-OFF PROPERTY, written down so nobody has to
   rediscover it: if that meta is lost, htmx re-applies its inline style, CSP
   drops it, and the indicator is merely broken exactly as it is today. It does
   not start flickering and it is not a security regression. */
.htmx-indicator { opacity: 0; }
.htmx-request .htmx-indicator, .htmx-request.htmx-indicator { opacity: 1; }

/* — the clickable Device cell —
   03.1-UI-SPEC.md § Clickable rows asked for `display: block` on the device
   name anchor inside td.col-device. THAT CANNOT BE IMPLEMENTED, and the
   contract now carries a dated note saying so. The Device cell renders TWO
   inline boxes on ONE line box — the name anchor and the .filter-user identity
   appended by quick task 260817-jtg — so making the anchor a block pushes the
   identity onto a second line box and grows the row past its declared height.
   That is D1 reopened, in the very cell the .veil rule above exists to protect.

   A stretched pseudo-element reaches the contract's stated GOAL — the cell's
   full 26% width navigates — with a real, keyboard-reachable <a> and no
   JavaScript, which is what this phase's Registry Safety section requires. The
   chevron cell keeps its own anchor at the other end of the row.

   THE ACCEPTED COST, recorded rather than left to be discovered: the stretched
   pseudo-element captures pointer selection inside that one cell, so the
   computer name is no longer selectable THERE. It stays selectable on the
   drill-down and it is a CSV column, and the export is the evidence path.

   The selectors deliberately do not match rowheight_test.go's existing
   `\.table-fleet\s+td\s*\{` or `\.table-fleet\s+td\s+\.sub-line\s*\{`
   patterns, for the same reason the .filter-user block above records: `td`
   followed immediately by a class has no whitespace-then-brace after it, so
   both pre-existing assertions are untouched and these rules get their own. */
.table-fleet td.col-device { position: relative; }
.table-fleet td.col-device a.device-name::after {
  content: "";
  position: absolute;
  inset: 0;
}
.table-fleet tbody tr { cursor: pointer; }

/* — the live-poll indicator, now in the nav —
   It moves out of a 21px strip above the filter bar and into the nav, where it
   costs ZERO document height. The class name is read by static/poll-guard.js at
   :80 and :112 (`document.querySelector('.' + POLL_CLASS)`), which makes it
   LOAD-BEARING RATHER THAN A STYLE HOOK: it must stay on the polling element or
   the background-tab guard silently stops working, and one forgotten tab cost
   ~380 MB/day the last time it did. This rule adjusts the box for the nav and
   changes nothing else about it. */
.nav .poll-live { padding: 0; }

/* — the fleet table's column header, pinned to its band budget (F-2) —
   Appended by quick task 260819-i3a, and append-only on the same terms as every
   block above it in Edit 5: no upstream rule is altered, no @import is
   introduced, no deleted spacing token is used, NO FONT SIZE IS DECLARED AT ALL,
   and no transition is declared on a polled element.

   MEASURED, not guessed. `.table-fleet thead th` far above declares
   position/top/z-index/background and NO HEIGHT — unlike `.table-fleet td`,
   which is pinned to var(--row-h) with overflow: hidden. The header row
   therefore measured 41.50px at 1366x768 and 25.00px at 1920x1080: content-
   driven and width-dependent, against the 36px that 03.1-UI-SPEC.md's band table
   budgets band 7. That is F-2 in 03.1-MEASUREMENTS.md, +5.5px at the
   constraining viewport and 11px UNDER at the wider one.

   THE CAUSE IS THE SECOND LINE BOX D1 WAS. The Components-remaining header is
   the label plus the `filled = still present` .sub-line caption (fleet.go's
   fleetColumns, D-27) — two inline boxes in a 200px declared column. At 1920 the
   fixed-layout table distributes its surplus width and both fit on ONE line box
   (25px); at 1366 they do not and a second line box appears (41.5px). Clipping
   the caption is not available: it states the strip's inversion, and
   `filled = still pre…` is worse than a 5.5px overrun.

   So this is the D1 REMEDY APPLIED TO THE HEADER — make the content
   deterministic first, then declare the height. The caption gets its own line
   box at every width, the label can never wrap, and the arithmetic is

       36px   height declared on the <th>
     -  8px   block padding (--space-1 x 2, from .table th above)
     -  1px   border-bottom (.table th above)
     = 27px   content box

       line box 1  the .th-sort label, 11px x 1.2 = 13.2px
       line box 2  the .sub-line caption, 11px x 1.2 = 13.2px
                                    total  26.4px  <  27px, slack 0.6px

   The slack is thin and it is SOUND, which is the whole reason line-height is
   declared here rather than left at the sheet's body 1.5. Both figures are
   font-size x ratio, so neither depends on a font metric: the header measures
   26.4px of content whether Barlow loaded or the fallback did. `.th-sort`
   declares `font: inherit`, which resets line-height to the inherited value, so
   the button picks this up rather than the 1.5 it would otherwise take. The
   comparable margin on `.table-fleet td` is 0.8px (35px content box against
   34.2px), so 0.6px is this sheet's existing tolerance and not a new one.

   line-height: 1.2 is a RATIO, not a font size. The four-size ramp is untouched
   and TestVendoredCSSContract sees no new font-size declaration.

   The selectors deliberately do not match rowheight_test.go's
   `\.table-fleet\s+td\s*\{` or `\.table-fleet\s+td\s+\.sub-line\s*\{`: this is
   `thead th`, not `td`, so both pre-existing assertions are untouched and these
   rules carry their own consequence. */
.table-fleet thead th {
  height: 36px; line-height: 1.2;
  white-space: nowrap; overflow: hidden;
}
.table-fleet thead th .sub-line {
  display: block; overflow: hidden; text-overflow: ellipsis;
}

/* — the vendor chip panel, as the filter bar's own second row (F-6) —
   Appended by quick task 260819-i3a, and append-only on the same terms as every
   block above it in Edit 5: no upstream rule is altered, no @import is
   introduced, no deleted spacing token is used, no font size is declared, and no
   transition is declared on a polled element.

   THE PANEL IS THE FULL-WIDTH FLEX ITEM NOW, NOT THE <details>. A flex item has
   to be a direct child of the flex container, which is why filterbar.gohtml
   moved the panel out of the disclosure and made it the form's LAST child — last
   rather than adjacent, because a `flex: 1 0 100%` item forces its own flex line
   and an adjacent one would push Clear filters and Export CSV onto a third row,
   which is the defect being closed. Last means the DOM order and the visual
   order are the same, so a keyboard user's focus order is the order they read.

   Measured live at 1366x768 on 2026-08-19, before this change: closed 1 row /
   49px, open 3 rows / 217px, with the search input stretching 557 -> 888px as it
   opened. Required end state: open is the closed top row UNCHANGED plus exactly
   one additional full-width row. One row becomes two, never three.

   THE CLOSED STATE IS HIDDEN BY THIS SHEET, NOT BY THE MARKUP. The chips were
   previously hidden by the <details> natively; outside it, CSS has to do it. They
   are still always present in the HTML — no `open` attribute is ever emitted and
   opening the facet issues no request, both of which
   TestCollapsedFacetRendersNoOpenAttribute and filterbar.gohtml hold.

   WHY THE RULE IS WRITTEN AS :not(:has()) AND NOT AS ITS INVERSE. The obvious
   spelling is `display: none` on the panel plus a `:has([open])` rule turning it
   back on. That fails in the WRONG DIRECTION: `:has()` is Chromium 105+ /
   Firefox 121+ / Safari 15.4+, and an engine that does not support it drops the
   whole rule — leaving a disclosure that opens onto nothing, silently, with the
   control still announcing itself as expanded. Written this way the same engine
   drops the HIDING rule instead, and the chips are simply always visible: the
   pre-3.1 layout, which is loud, obvious the moment anyone looks, and fully
   functional. Both clients of record are far past the floor (the operator runs
   Chromium 151 and Firefox 153); this is about which failure the next unknown
   client gets. Same reasoning as the htmx-indicator FAIL-OFF note above.

   The panel keeps `.filter-facet` in filterbar.gohtml so that
   `.filter-facet .btn[aria-pressed="true"]` — a DESCENDANT selector — still
   gives selected chips their accent border. Do not drop it: the failure is a
   selected vendor that announces as pressed and looks unselected, and it has no
   server-side symptom whatsoever. */
.filter-bar > .provider-chips { flex: 1 0 100%; }
.filter-bar:not(:has(details.provider-facet[open])) > .provider-chips { display: none; }
