/* 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/web); 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/web/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));

  /* 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;

  --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;

  --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);
}
