/* Helix design tokens — the single source of truth for both surfaces.
 *
 * THIS FILE IS COPIED BYTE-IDENTICAL TO apps/desktop/src/tokens.css.
 * The cloud copy is canonical because the web pages must be served a real
 * file with no build step (a deliberate constraint of cloud/api/console); the
 * desktop imports its copy through Vite. A test in cloud/api asserts the
 * two are identical — the same mechanism that keeps the NMS contract from
 * drifting, applied to CSS. Edit here, copy there, or the suite goes red.
 *
 * Consumption: cloud pages get this via `@import url("tokens.css")` at the
 * top of app.css; the desktop imports it in main.tsx before styles.css.
 *
 * Rules that shaped this file:
 * - Colour is SEMANTIC. Components use --status-* and --chart-*, never a
 *   literal. The legacy names (--ok, --warn, --err, --accent) are kept as
 *   aliases so existing rules keep working while they migrate.
 * - Status is never carried by hue alone: every status colour has a paired
 *   glyph (--status-*-glyph) that components render alongside it, because
 *   a red/green quality dashboard is unusable for roughly 1 in 12 men.
 * - The chart palette is SIX series, not eight, and every value in it is
 *   generated rather than chosen. On 2026-08-19 the shipped eight were
 *   measured against the Machado-Oliveira-Fernandes CVD simulation and
 *   failed: --chart-4 (green) and --chart-5 (rose) sat adjacent at OKLab
 *   ΔE 4.0 under deuteranopia against a target of 8 — the exact defect the
 *   old comment here claimed the ordering prevented — --chart-7 had fallen
 *   below the chroma floor and rendered gray, and every dark value sat
 *   above the L 0.48–0.67 band, so lightness carried no information at all.
 *
 *   The cause was structural. A search over the whole sRGB gamut finds no
 *   seven-hue set that clears all-pairs CVD separation inside the dark
 *   band at the chroma floor; six is the ceiling on this surface. Eight
 *   slots could not have worked, so slots 7 and 8 are gone rather than
 *   re-tinted. A seventh series folds into an "Other" bucket, small
 *   multiples, or a second encoding channel — never a generated hue.
 *
 *   Both themes now pass all five checks, adjacent AND all-pairs. Re-run
 *   before changing any value here; do not hand-pick a replacement:
 *     node scripts/validate_palette.js "<the six>" --mode dark --surface "#040918"
 *
 *   Series colour is assigned by index via palette.ts / palette.js — never
 *   picked inside a component.
 * - Numerics everywhere use tabular figures (.tnum, or the token) so live
 *   values do not jitter in width as digits change.
 * - LIGHT is the app default (operator decision 2026-08-24, superseding
 *   the 2026-08-19 dark-default): the bare :root is the light palette,
 *   dark is the explicit [data-theme="dark"], and the marketing site
 *   opts into dark pre-paint because darkest-blue glass remains the
 *   brand. The OS preference still decides nothing — there is no
 *   prefers-color-scheme block in this file — so the only input is
 *   [data-theme], persisted by theme.ts / theme.js / site.js.
 */

/* ------------------------------------------------ the typeface, shipped
 * This file named Inter from the beginning and NOTHING EVER LOADED IT.
 * There was no @font-face anywhere in the repository and no font file in
 * it, so all three surfaces fell through to the platform sans — the
 * desktop app, which is Tauri and offline, rendered every glyph in Segoe
 * UI. The product's typographic voice was whatever Windows happened to
 * install, on the surface whose whole argument is precision.
 *
 * Both faces are now bundled as latin variable subsets, one axis each
 * (47kb + 39kb), at a path that is IDENTICAL relative to all three copies
 * of this file — apps/desktop/src/fonts, cloud/api/console/public/fonts, site/fonts
 * — which is what lets the byte-identical rule survive self-hosting. Vite
 * resolves the desktop's copy at build; the other two are served as files,
 * so no surface gains a build step and none makes a network request for
 * type.
 *
 * font-display: swap, and the platform stack stays behind each one: the
 * first paint of a monitoring tool must not wait on a typeface. */
@font-face {
  font-family: "Inter Variable";
  font-style: normal;
  font-weight: 100 900;
  font-display: swap;
  src: url("fonts/inter-var.woff2") format("woff2");
}

@font-face {
  font-family: "JetBrains Mono Variable";
  font-style: normal;
  font-weight: 100 800;
  font-display: swap;
  src: url("fonts/jbmono-var.woff2") format("woff2");
}

/* The display voice. Inter is the correct face for this product's DATA —
 * it is drawn for dense small text and ships the tabular figures, the
 * slashed zero and the straight-sided one that a surface printing
 * evidence needs. It is not a voice. Carrying both jobs, it left every
 * heading and the provider's own name in the same neutral grotesque as a
 * 10px column label, which is legible and says nothing.
 *
 * IBM Plex Sans was drawn by Bold Monday for IBM's technical products, so
 * its character comes from engineering rather than from fashion — flat-
 * sided a, squared g, narrower caps with more contrast — and beside Inter
 * at heading sizes it reads as a deliberate pair rather than an accident.
 * That last part is why it beat the closer alternatives: a display face
 * only a little unlike the body face looks like a mistake. */
@font-face {
  font-family: "IBM Plex Sans Variable";
  font-style: normal;
  font-weight: 100 700;
  font-display: swap;
  src: url("fonts/plex-var.woff2") format("woff2");
}

/* ------------------------------------------------ theme-independent */
:root {
  --font-ui: "Inter Variable", Inter, "Segoe UI Variable", "Segoe UI", system-ui, sans-serif;
  --font-mono: "JetBrains Mono Variable", "Cascadia Code", Consolas, ui-monospace, monospace;

  /* Headings and the provider's name. NEVER FIGURES — every measurement
   * on every surface stays in --font-ui, so a number means the same thing
   * whether it sits in a hero, a tile or a table cell. On an evidence
   * product, one set of numerals is not a stylistic preference: two faces
   * rendering the same figure is two chances for a subscriber reading a
   * report to think they are looking at two different things. The display
   * face carries the voice; the UI face carries the data. */
  --font-display: "IBM Plex Sans Variable", "Inter Variable", "Segoe UI Variable", system-ui, sans-serif;

  /* Inter ships a "straight-sided one" and a slashed zero behind feature
   * flags. Both are on everywhere, permanently: this product prints
   * measurements a subscriber may retype into a support ticket, and a
   * zero that can be read as an O is a defect in evidence, not a
   * preference. `tnum` is set per-surface on numeric runs rather than
   * globally — proportional figures still read better in prose. */
  --font-features: "cv05" 1, "zero" 1, "ss03" 1;

  /* THE SMALL END, RE-CUT 2026-09-06. It was 10 / 11.5 / 12.5 / 14, and
     it was carrying more of the product than the body size was.
     Measured before changing anything, on one dashboard render: 11.5px
     held 68 of ~148 text nodes and 14px held 59, while 12.5px held 7.
     So the two smallest steps were the de facto body, the third step was
     vestigial, and 11.5 → 12.5 is an 8.7% gap — under the 8%-ish
     threshold snap-tokens itself treats as "the same size". Three steps
     were doing the work of two, badly, and all of it below the 12px
     floor at which small text stops being a size and starts being a
     squint.

     Each step now has ONE job and a name that says which:

       --text-2xs  11px  a unit suffix, a column header. Never a sentence.
       --text-xs   12px  labels and chips: a word or two, never prose.
       --text-meta 13px  metadata and notes — the register that 11.5 was
                         overloaded with. Prose lives here or above.
       --text-base 14px  body. Unchanged, and still the anchor.

     --text-sm is retired: 12.5px was 4% from this --text-meta, which is
     not a difference anybody can see, and keeping both would leave the
     duplicate register the re-cut exists to remove. Its ~200 call sites
     moved to --text-meta in the same commit.

     Nothing above --text-base moved. The upper scale was already doing
     one job per step, and re-cutting it would have moved every heading
     on three surfaces to fix a problem that lived entirely at the
     bottom. */
  --text-2xs: 11px;
  --text-xs: 12px;
  --text-meta: 13px;
  --text-base: 14px;
  --text-lg: 16px;
  --text-xl: 20px;
  --text-2xl: 28px;
  /* The display step between --text-2xl and --text-hero. The 28→56 jump
     left stat-tile figures nowhere to stand: big enough to lead a card,
     not the one hero number on the page. Measured before naming: the
     shipped .um-num display figure (24px, a documented snap-tokens
     refusal) and the console hero clamp() top out in exactly this band. */
  --text-display: 40px;
  --text-hero: 56px;

  --space-1: 4px;
  --space-2: 8px;
  --space-3: 12px;
  --space-4: 16px;
  --space-5: 24px;
  --space-6: 32px;
  --space-7: 40px;
  --space-8: 48px;

  /* COMPONENT INTERNALS, below the 4px layout scale on purpose.
   *
   * A chip's vertical padding and the gap between a glyph and its label are
   * optical choices at a finer grain than layout rhythm, and forcing them
   * onto the 4px step makes controls either cramped or puffy. They were
   * already in use and simply undeclared: a measurement of every shipped
   * surface (packaging/ui-audit.mjs) found six unnamed values used 476
   * times, 10px alone 254 times, each one hand-picked at the point of use.
   *
   * Naming them changes no pixels. It means the next component inherits the
   * rhythm instead of re-deriving it. */
  --inset-1: 2px;
  --inset-2: 3px;
  --inset-3: 6px;
  --inset-4: 10px;

  --radius: 14px;
  --radius-sm: 8px;
  --radius-pill: 999px;

  /* The stacking register. Nine literal z-indexes (1, 2, 50, 59, 60)
   * were coordinated only by comments across five stylesheets — the
   * speedtest overlay's "above the first-run picker (50) — never both,
   * but tidy" is exactly the arrangement a named stack makes structural.
   * TWO named heights are the whole system: overlays that own the screen,
   * toasts that outrank overlays. A third (--layer-nav) shipped for one
   * afternoon and was deleted the same day: the rail is position static
   * and nothing floats at chrome height, so it was a token with no
   * consumer — which is noise, the register's own founding rule. In-card
   * layering (a meter over its track) stays 1/2 literals — that is
   * geometry inside a component, not the page stack. */
  --layer-overlay: 50;
  --layer-toast: 60;

  /* The notification grammar's status edge: a 3px left border in the
   * status colour, shared by the desktop card and the console toast.
   * It appeared as a literal eleven times across five files, which is
   * eleven chances for the grammar to drift one pixel at a time. */
  --edge-status: 3px;

  /* Weight register. 196 literal font-weight declarations existed with
   * 500/600/650/700 chosen per author; these three are the system. New
   * and edited rules consume the tokens; old literals migrate as
   * touched, the same policy as the legacy colour aliases below. */
  /* The weight axis, and the two registers it was missing.
   *
   * --weight-display is the heading and lockup weight. It was 650 at 26
   * call sites across all three surfaces -- used more often than the
   * named --weight-medium, and named nowhere. --weight-regular is the
   * explicit reset back to normal, which five rules were spelling as a
   * literal 400.
   *
   * --weight-lockup is heavier than bold on purpose and is the ONLY
   * weight above 700 in the product: both its uses are uppercase and
   * tracked, where the extra weight compensates for the space tracking
   * opens between stems. It is not a "more emphatic bold" and must not
   * be reached for as one.
   *
   * Three one-off values were collapsed into these when they were named
   * -- 550, 680 and a second 750 -- because eight weights for four jobs
   * is the same defect four text sizes for one register was. */
  --weight-regular: 400;
  --weight-medium: 500;
  --weight-semibold: 600;
  --weight-display: 650;
  --weight-bold: 700;
  --weight-lockup: 750;

  /* THE READING MEASURE IS GONE, and this note is here so the next
   * author knows it was removed rather than never considered.
   *
   * `--measure` (48ch), `--measure-tight` (38ch) and `--measure-wide`
   * (90ch) capped running prose at the 45-75 character band, on the
   * reasoning that a longer line loses the eye on the way back to the
   * left. They were consumed by 124 `max-width` declarations across the
   * three surfaces and policed by two checks in `audit-probe.js` and a
   * source walk in `measure.test.ts`.
   *
   * All of it was removed on 2026-09-08 by operator instruction: "let it
   * be full width for all pages and dashboards", and "remove all pins
   * related to typesetting that set constrained widths for content".
   *
   * The reading argument was not shown to be wrong -- it was overruled,
   * which is the operator's call to make, and this product had already
   * accepted ~128-character lines on the marketing statistics pages for
   * the same reason two days earlier. What is recorded here is that the
   * decision was deliberate and dated, so a future pass reintroducing a
   * cap is reversing an instruction rather than fixing an oversight.
   *
   * The measurement work is not lost: what `ch` actually renders as, per
   * surface and per face, is in the 2026-09-06 typeset walkthrough. `ch`
   * is the advance of "0" and overstates a line of prose by a factor
   * that differs by face -- 1.33x on the desktop, 1.55x on the site --
   * so if a cap is ever wanted again, measure the rendered result and do
   * not reason about the unit. */

  /* The one tracking treatment: eyebrows and small-cap-ish labels. It
   * was re-derived at 78 call sites as 0.02–0.08em; 0.04 is the median
   * of what shipped. Body text is never tracked. */
  --tracking-label: 0.04em;
  /* A hair of positive tracking on semibold UI text at 14-18px, where
   * this face's default fit is a touch tight for a lockup or a card
   * heading. Four sites on two surfaces had it as `0.2px`, which is the
   * same intent unable to say so: in px it means 1.4% of a 14px heading
   * and 1.1% of the 18px lockup beside it. */
  --tracking-ui: 0.012em;

  /* Display tracking. A face set for body copy at 14px is loose at 40 and
   * conspicuously loose at 56 — the letter spacing that makes small text
   * readable is the same spacing that makes a headline look unset. These
   * are negative because optical tracking scales inversely with size, and
   * they exist as tokens because otherwise every display rule picks its
   * own. Applied by size, never by importance. */
  --tracking-display: -0.02em;
  --tracking-hero: -0.032em;

  /* ------------------------------------------------------ the rules
   * THE LIGHT THEME'S DEPTH MODEL, and the reason it needed one.
   *
   * Until 2026-09-02 both themes were the same design with the values
   * inverted, and the depth model was glass: a translucent card plus
   * backdrop blur. That works on the dark theme, where a card is a lit
   * pane over a deep navy ground and the glows are there for the blur to
   * catch. On light it does nothing at all — a 66%-white card over a
   * near-white ground separates by a hairline and the blur has nothing
   * beneath it to soften. Light was the DEFAULT theme, so the default
   * theme was the one with no depth.
   *
   * Light now separates the way a measuring instrument does: by RULE
   * WEIGHT. Three weights, and the difference between them is the
   * hierarchy — hairline inside a component, rule between components,
   * strong under a heading that owns what follows. Dark keeps the glass
   * and maps the same three onto its border colour, so a component
   * written once reads correctly in both without a theme branch. */
  --rule-hair: 1px;
  --rule: 1px;
  --rule-strong: 2px;

  /* Icon geometry, so a glyph beside a label stops guessing. */
  --icon-sm: 16px;
  --icon-md: 20px;

  /* Focus ring geometry. The colour is per-theme (--focus-ring); the
   * width and offset were literals inside the one shared utility, so a
   * component wanting an inset ring re-derived them. */
  --focus-w: 2px;
  --focus-offset: 2px;

  /* Motion exists to explain a state change, never to decorate.
   * --ease-expo is the entrance curve (long decelerating tail) the site
   * already used for its one animation; lifted here so a surface reaching
   * for it does not mint a fourth bezier. */
  --dur-fast: 120ms;
  --dur: 200ms;
  --dur-slow: 320ms;
  --ease: cubic-bezier(0.2, 0.7, 0.3, 1);
  --ease-expo: cubic-bezier(0.16, 1, 0.3, 1);

  /* Line heights. The scale exists so a surface reaching for "tight" or
   * "body" picks the same number as the other two; values match what the
   * base rules already shipped, so adopting a token changes no pixels. */
  /* The leading scale. Six steps, and two of them were added on
   * 2026-09-06 because they already existed without names: the
   * stylesheets carried 18 distinct line-height literals across 112
   * declarations, and 44 of those wrote out a value one of these tokens
   * already held -- so the scale was unable to move.
   *
   * --leading-display is for a figure or a hero heading, where 1.25 is
   * loose and 1 clips descenders. --leading-snug is the COMPACT body
   * leading, for a dense table row or a chip's second line; it was the
   * biggest unnamed register on any axis, re-derived 32 times as 1.3,
   * 1.35, 1.4 or 1.45 by whoever needed it.
   *
   * These are RATIOS, not lengths, and consumers add --optical-leading
   * to them -- see the note on that token for why the unit matters. */
  --leading-none: 1;
  --leading-display: 1.15;
  --leading-tight: 1.25;
  --leading-snug: 1.4;
  --leading-normal: 1.5;
  --leading-relaxed: 1.6;

  /* OPTICAL COMPENSATION FOR LIGHT-ON-DARK, and the reason it is a token
   * rather than a habit.
   *
   * The same face at the same size reads HEAVIER as light text on a dark
   * ground than as dark text on light: the glyphs bloom into the
   * background, counters close up, and lines crowd. Every serious type
   * system compensates on three axes — a little more leading, a little
   * more tracking, and where the face allows it one step less weight.
   * This product renders both themes from one stylesheet, so without
   * tokens the compensation would have to be a per-theme branch at every
   * text rule, which is exactly the drift the token file exists to stop.
   *
   * These are the LIGHT values: no compensation, because light is the
   * default and the uncompensated case. The dark block overrides them.
   *
   * --optical-leading IS UNITLESS AND MUST STAY UNITLESS. It is consumed
   * as calc(var(--leading-normal) + var(--optical-leading)), and
   * --leading-normal is a bare ratio: calc() refuses to add a length to a
   * number, so an em here makes the whole line-height declaration invalid
   * and every body rule silently falls back to `normal`. Nothing reports
   * it -- the page renders, a little tighter, in both themes at once.
   * --optical-tracking is a letter-spacing and IS a length; the two
   * tokens sit together and take different units for that reason. */
  --optical-leading: 0;
  --optical-tracking: 0em;

  /* Glassmorphism: every .card-like surface pairs --card with
   * backdrop-filter: blur(var(--glass-blur)). The blur amount is a token
   * so the whole product softens or sharpens together — a surface that
   * hardcodes its own blur is one theme change away from looking pasted
   * on. Operator instruction 2026-08-10: the general background across
   * both dashboards is darkest-blue glass.
   *
   * THE VALUE IS NOW PER-THEME, and this is the single line that switches
   * the product's depth model. Glass is a dark-theme material: it needs a
   * lit pane, a deep ground, and something behind it worth softening. The
   * light theme has none of those, so it declares 0 and its cards are
   * opaque paper separated by rules. Because every surface already reads
   * the token rather than hardcoding a blur, no component changed — the
   * whole light theme stopped being frosted by moving one declaration
   * into the two theme blocks. Fourteen backdrop-filter call sites across
   * eleven stylesheets would otherwise each have needed a theme branch. */

  /* Density: modes set data-density on <html>; these are what it moves. */
  --gap: 14px;
  --pad-card: 14px 16px;

  /* Paired glyphs so status never rides on hue alone. */
  --status-ok-glyph: "●";
  --status-degraded-glyph: "◐";
  --status-down-glyph: "✕";
  --status-unknown-glyph: "◌";

  /* ---------------------------------------------- derived text inks
   * --accent and the status colours are tuned as UI colours: fills,
   * borders, the focus ring, an SVG mark. MEASURED against the light
   * theme, several fail WCAG AA (4.5:1) when they carry TEXT:
   *
   *     --accent            3.65 on --bg, 3.92 on a card
   *     --status-ok         4.20 on its own soft pill
   *     --status-degraded   4.20 on its own soft pill
   *     --insufficient      4.04 on its own soft pill
   *
   * So text mixes the colour toward --text. ONE rule serves both themes
   * with no second theme block, because --text is by definition the
   * high-contrast colour of whichever theme is live: in light the mix
   * deepens the hue, in dark it lifts it.
   *
   *     accent 70%      -> 5.47 on --bg, 5.86 on a card (light);
   *                        10.80 on --bg (dark)
   *     status 78%      -> 5.26 to 5.55 on their soft pills (light)
   *
   * These lived as three per-surface copies with three different subsets
   * (site 5, console 5, desktop 1) until 2026-08-24 — and the desktop's
   * rules referenced --accent-ink before anything defined it, which CSS
   * does not report: the colour just silently fell back. Canonical now;
   * a test refuses any consumer stylesheet that redeclares one.
   *
   * --muted-ink and --down-ink exist for the site's pill text; on app
   * surfaces raw --muted (5.32) and --status-down (5.77) already clear
   * the bar as text and may be used raw. Non-text uses keep the raw
   * token everywhere: the brand mark, focus and hover borders, the meter
   * fill, the active tab's underline. Their bar is 3:1, which the raw
   * tokens clear. The percentages were chosen by MEASURING; changing one
   * means re-measuring it. */
  --accent-ink: color-mix(in srgb, var(--accent) 70%, var(--text));
  --muted-ink: color-mix(in srgb, var(--muted) 78%, var(--text));
  --ok-ink: color-mix(in srgb, var(--status-ok) 78%, var(--text));
  --degraded-ink: color-mix(in srgb, var(--status-degraded) 78%, var(--text));
  --down-ink: color-mix(in srgb, var(--status-down) 78%, var(--text));
  --unknown-ink: color-mix(in srgb, var(--status-unknown) 78%, var(--text));
  --insufficient-ink: color-mix(in srgb, var(--insufficient) 78%, var(--text));

  /* Two SERIES colours that also carry text. --chart-* are tuned as marks
     against the 3:1 non-text bar, and --chart-1 measured 2.99 on the dark
     card as a 24px figure — under the bar it was being read against, let
     alone the 4.5 a smaller one would need. Found 2026-08-26 by shooting
     the Usage page in dark for the first time; the light theme passed at
     7.27, which is why a light-only measurement never saw it.

     Only these two exist because only these two are ever text: the live
     meter's down/up figures and the speed test's direction arrows. The
     LINE, the dot swatch and the bar keep the raw token — they are marks,
     and the association between the figure and its series survives the
     mix, which is the whole reason not to reach for --accent-ink here.

     78%, the same ratio the status inks use rather than a new number.
     MEASURED against the composited card in both themes:
       --chart-1-ink   4.44 dark, 8.69 light
       --chart-3-ink   6.09 dark, 6.53 light */
  --chart-1-ink: color-mix(in srgb, var(--chart-1) 78%, var(--text));
  --chart-3-ink: color-mix(in srgb, var(--chart-3) 78%, var(--text));

  /* WAYFINDING HUES (2026-09-22). The desktop rail groups its pages by
     intent — Observe, Investigate, Measure, Instrument — and until now the
     grouping was an eyebrow and a gap in one grey. Each group carries a
     hue: on the rail (icon, eyebrow, the active item's fill) and on the
     page it opens (a wash on the lead card), so where the reader is and
     what the rail said about it are the same colour. Navigation, never
     status, never data: the four are SEPARATE tokens so a change to the
     chart palette or a status colour cannot recolour the rail — but
     their values are borrowed from the six-series palette, because that
     is the one set of hues in the product already proven separable under
     every simulated deficiency, and inventing four more would mean
     redoing that work. The soft is a tint at --nav-wash, set per theme
     below (12% on paper, where the status softs sit; deeper on dark
     glass, where the dark chart values are dim and 12% of one over
     near-black did not register); the ink is a mix toward --text at
     --nav-ink-mix, also per theme: 78% on light like every other ink,
     but the dark chart values are marks tuned to the 3:1 bar and
     --chart-1 sits at 2.99 there (see --chart-1-ink above), so at 78%
     the Observe ink measured 3.92 on the rail and 4.44 on the lead card.
     Dark mixes 55% — measured 2026-09-22 against the dark ground: the
     Observe ink 7.12, Measure 6.90, Investigate 8.77, Instrument 9.45,
     and the probe reports zero failures over the composited glass —
     and stays recognisably the hue. Observe is the
     brand blue: the connection is what the product is about.

     Instrument mixes deeper on light, at --nav-instrument-ink-mix: amber
     is the lightest of the four hues, and at the shared 78% its ink was
     rgb(160,96,10) on the active fill's composited rgb(239,229,213) --
     4.04:1 at 11px, under AA. Found 2026-10-02 by the audit probe run
     INSIDE the real desktop app on System and NETStats; the harness
     fixtures measured zero, because no fixture view had an Instrument page
     active with the rail in that state. 66% computes to ~4.99:1 there and
     stays amber; dark keeps the shared mix (9.45 measured). */
  --nav-observe: var(--chart-1);
  --nav-investigate: var(--chart-3);
  --nav-measure: var(--chart-5);
  --nav-instrument: var(--chart-2);
  --nav-observe-soft: color-mix(in srgb, var(--nav-observe) var(--nav-wash), transparent);
  --nav-investigate-soft: color-mix(in srgb, var(--nav-investigate) var(--nav-wash), transparent);
  --nav-measure-soft: color-mix(in srgb, var(--nav-measure) var(--nav-wash), transparent);
  --nav-instrument-soft: color-mix(in srgb, var(--nav-instrument) var(--nav-wash), transparent);
  --nav-observe-ink: color-mix(in srgb, var(--nav-observe) var(--nav-ink-mix), var(--text));
  --nav-investigate-ink: color-mix(in srgb, var(--nav-investigate) var(--nav-ink-mix), var(--text));
  --nav-measure-ink: color-mix(in srgb, var(--nav-measure) var(--nav-ink-mix), var(--text));
  --nav-instrument-ink: color-mix(in srgb, var(--nav-instrument) var(--nav-instrument-ink-mix), var(--text));

  /* Film grain, as a tile. Imperceptible on its own; it exists because
   * large areas of near-black blue band badly on 8-bit panels and the
   * noise breaks the banding up — the exact background both dashboards
   * paint. A token rather than a rule because each surface layers it
   * differently: the app surfaces put it under all content as a body
   * background layer; the site draws it over section art (brand.css
   * .grain). Opacity is baked into the tile so a background layer needs
   * no pseudo-element. */
  --grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.05'/%3E%3C/svg%3E");
}

/* ------------------------------------------------ light (default)
 * OPERATOR DECISION 2026-08-24, superseding 2026-08-19: the app surfaces
 * (desktop and console) default to LIGHT — the mission is a tool an
 * average person reads at a glance, and light is the register most of
 * them live in. Dark remains fully supported, one toggle away, and it
 * remains the BRAND: the marketing site opts into dark explicitly,
 * pre-paint (site build head script), because darkest-blue glass is the
 * division's identity.
 *
 * Light is the bare :root because the default is whatever an untouched
 * page renders. The OS still decides NOTHING — there is deliberately no
 * prefers-color-scheme block, and the "auto" control state stays deleted
 * (theme.ts / theme.js / site.js); the only input is [data-theme]. */
:root {
  color-scheme: light;

  /* PAPER, not a tinted screen. The ground was #eef2fb — a blue-grey that
   * read as the dark theme with the lights on, and that tinted every
   * white card sitting on it toward the same hue, so nothing separated.
   * A near-neutral ground with only a trace of warmth does two things: it
   * reads as stock rather than as UI chrome, and it leaves blue free to
   * be INK. On a product whose accent is its brand, an ambient blue wash
   * spends the brand colour on the background and has none left for the
   * one figure that matters. */
  --bg: #f4f4f2;
  --bg-soft: #ffffff;

  /* The glows exist for the blur to catch. Light has no blur, so on light
   * they are just a coloured wash over paper — the exact tint removed
   * above, reintroduced through a different door. Transparent, not
   * deleted: every surface paints them unconditionally, and a missing
   * custom property inherits rather than resolving to nothing. */
  --bg-glow-1: transparent;
  --bg-glow-2: transparent;

  --glass-blur: 0px;

  /* Opaque. A translucent card on paper is a slightly dirty paper. */
  --card: #ffffff;
  --card-border: rgba(17, 22, 30, 0.14);

  /* The three rule weights the light theme separates with. Hairline is
   * within a component (a table row), rule is between components, strong
   * sits under a heading that owns what follows it. */
  --rule-color: rgba(17, 22, 30, 0.11);
  --rule-color-strong: rgba(17, 22, 30, 0.24);

  /* Ink. Deepened from #1a2233: on paper rather than on blue-grey, the
   * old value read soft, and a measurement surface should look printed. */
  --text: #111620;
  --muted: #59606d;

  /* Deepened one step from #0284c7 for the same reason — as ink on paper
   * it now carries authority at rule weight and small sizes, and the
   * derived --accent-ink inherits the improvement everywhere at once. */
  --accent: #0b6aae;
  --accent-soft: rgba(11, 106, 174, 0.09);
  --focus-ring: #0b6aae;
  --nav-wash: 12%;
  --nav-ink-mix: 78%;
  --nav-instrument-ink-mix: 66%;

  --status-ok: #15803d;
  --status-ok-soft: rgba(21, 128, 61, 0.1);
  --status-degraded: #b45309;
  --status-degraded-soft: rgba(180, 83, 9, 0.1);
  --status-down: #b91c1c;
  --status-down-soft: rgba(185, 28, 28, 0.08);
  --status-unknown: #64748b;
  --status-unknown-soft: rgba(100, 116, 139, 0.1);

  --insufficient: #64748b;
  --insufficient-hatch: repeating-linear-gradient(
    -45deg,
    transparent 0 4px,
    rgba(100, 116, 139, 0.25) 4px 6px
  );

  --chart-1: #035787;
  --chart-2: #c87504;
  --chart-3: #7f51c1;
  --chart-4: #019c63;
  --chart-5: #901461;
  --chart-6: #596103;

  /* For surfaces that genuinely FLOAT — a toast, a dialog, the
   * notification card — never for cards on the page plane, which are
   * separated by their rule. Two layers: a tight contact shadow that
   * seats the edge, and a wide soft one that lifts it. The single diffuse
   * 32px blur it replaces had no contact term, so a floating panel hovered
   * without ever touching down. Neutral-cool rather than the old
   * blue-violet, which tinted white paper lilac where it fell. */
  --shadow-soft:
    0 1px 2px rgba(17, 22, 30, 0.07),
    0 10px 28px rgba(17, 22, 30, 0.09);
}

/* Legacy aliases — do not use in new code; migrate old rules as touched. */
:root {
  --ok: var(--status-ok);
  --warn: var(--status-degraded);
  --err: var(--status-down);
}

/* ------------------------------------------------ dark (explicit)
 * Darkest-blue glass: the base is a deep navy rather than neutral
 * near-black, and card surfaces are a faint BLUE-tinted translucency —
 * white-tinted glass over neutral grey was the pre-tokens look and it
 * read as charcoal, not glass. The glows are what the blur has to catch
 * for the material to register at all.
 *
 * Selected by [data-theme="dark"]: the apps' toggle stores and applies
 * it; the site sets it by default before first paint. */
:root[data-theme="dark"] {
  /* See --optical-leading in the light block: light text on a dark ground
   * blooms, so it needs a little more air between lines and between
   * letters to read at the same ease. Small numbers on purpose — this is
   * a correction, and a correction anybody notices has overcorrected. */
  --optical-leading: 0.06;
  --optical-tracking: 0.006em;
  color-scheme: dark;

  --bg: #040918;
  --bg-soft: #0a1226;
  --bg-glow-1: rgba(56, 189, 248, 0.1);
  --bg-glow-2: rgba(167, 139, 250, 0.08);
  /* Dark keeps the glass, and it is the theme the material was designed
   * for: a lit pane over deep navy, with the glows beneath it for the
   * blur to catch. */
  --glass-blur: 14px;

  --card: rgba(158, 190, 255, 0.055);
  --card-border: rgba(158, 190, 255, 0.14);

  /* The same three rule weights the light theme separates with, mapped
   * onto the glass border colour. A component that draws a rule is
   * written once and reads correctly in both themes with no branch:
   * on paper it is a ruled line, on glass it is the pane's edge. */
  --rule-color: rgba(158, 190, 255, 0.13);
  --rule-color-strong: rgba(158, 190, 255, 0.3);

  --text: #e6e9f0;
  --muted: #8b93a7;
  --accent: #38bdf8;
  --accent-soft: rgba(56, 189, 248, 0.16);
  --focus-ring: #7dd3fc;
  --nav-wash: 26%;
  --nav-ink-mix: 55%;
  --nav-instrument-ink-mix: 55%;

  --status-ok: #4ade80;
  --status-ok-soft: rgba(74, 222, 128, 0.12);
  --status-degraded: #fbbf24;
  --status-degraded-soft: rgba(251, 191, 36, 0.12);
  --status-down: #f87171;
  --status-down-soft: rgba(248, 113, 113, 0.12);
  --status-unknown: #8b93a7;
  --status-unknown-soft: rgba(139, 147, 167, 0.12);

  /* Insufficient data (coarse_loss) is its own state, not a softer OK. */
  --insufficient: #8b93a7;
  --insufficient-hatch: repeating-linear-gradient(
    -45deg,
    transparent 0 4px,
    rgba(139, 147, 167, 0.28) 4px 6px
  );

  --chart-1: #0a669c;
  --chart-2: #cc780e;
  --chart-3: #9466d8;
  --chart-4: #00aa6d;
  --chart-5: #9a3b70;
  --chart-6: #656d02;

  /* The one elevation shadow, for surfaces that FLOAT (toasts, dialogs,
   * the notification card) — cards on the page plane get no shadow; the
   * glass border is their edge. Theme-dependent because a black shadow
   * on the light theme reads as dirt; light uses the deep-blue tint. */
  --shadow-soft: 0 8px 32px rgba(0, 0, 0, 0.45);
}

/* ------------------------------------------------ density
 * Set by mode: home=comfortable, operator=compact. The bare-:root values
 * above are "medium" and remain the default for every surface that has no
 * mode at all — the ISP portal, the admin pages, the marketing site — but
 * since 2026-08-25 no MODE selects it: gamer and enterprise were the two
 * that did, and both were removed.
 *
 * Only spacing moves — type size shifts are done by the components that
 * opt in, so a density change cannot silently shrink body text below
 * readable. */
:root[data-density="compact"] {
  --gap: 10px;
  --pad-card: 10px 12px;
}

:root[data-density="comfortable"] {
  --gap: 18px;
  --pad-card: 18px 20px;
}

/* ------------------------------------------------ motion opt-out */
@media (prefers-reduced-motion: reduce) {
  :root {
    --dur-fast: 0ms;
    --dur: 0ms;
    --dur-slow: 0ms;
  }
}

/* ------------------------------------------------ shared utilities
 * The three rules small enough that duplicating them per surface would
 * only invite drift. Everything else lives in the surface's own CSS. */
.tnum {
  font-variant-numeric: tabular-nums;
}

.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
}

:focus-visible {
  outline: var(--focus-w) solid var(--focus-ring);
  outline-offset: var(--focus-offset);
}
