/* ── Theming ───────────────────────────────────────────────────────────────
   Two themes off one set of declarations. The mechanism is a CHANNEL variable,
   not a value swap: page CSS says `rgba(var(--sf-rgb), 0.07)` instead of
   `rgba(255,255,255,0.07)`, so the alpha — which is where all the per-page
   design judgement lives — stays exactly where its author put it, and only the
   ink colour flips. Dark output is byte-identical to what it was before the
   theme existed; that is the point, and it is what makes the diff reviewable.

   The same trick was already in this codebase before this change: Jobs.css
   declares `--wev-edge: 196,181,253` and consumes it as `rgba(var(--wev-edge), .2)`.

   What CANNOT ride the channel is text. Dark allows informational ink down to
   alpha .45; light needs .63 for the same 4.5:1, so a channel flip would push a
   large share of this app's muted text under AA. Ink is therefore mapped to the
   four named steps below and each theme sets its own alphas.

   Measured against #f1ebfc — a panel at its minimum opacity over the strongest
   orb, which is the worst surface this app can composite. See
   docs/superpowers/specs/2026-08-04-light-theme-design.md. */
:root {
    color-scheme: dark;

    /* Channels. Everything a page paints a SURFACE, BORDER or SHADOW with. */
    --sf-rgb: 255, 255, 255;
    --sh-rgb: 0, 0, 0;

    /* The ground the glass floats on. */
    /* ── The WP-4 surface vocabulary, dark side ───────────────────────────────────────────────
       These eight names are authored FOR light — canvas/surface/keyline/accent is the language of
       an occlusion-based theme, and dark builds depth by tint over near-black instead. They are
       declared here because a colour token is a PAIR: a token declared in one theme only silently
       keeps the other theme's value forever, with a green build and no error, and token-parity.mjs
       exists to make that impossible.

       Each value is transcribed from the token dark already uses for that job, so adopting one of
       these names at a site is a no-op in dark rather than a redesign of it. */
    --canvas: #050309;                          /* = --ground */
    --surface: rgba(255, 255, 255, 0.07);       /* = --glass-bg, the tint-over-black surface */
    --surface-inset: rgba(255, 255, 255, 0.05); /* = --recess-a05, a step below the card */
    /* The stat tile's fill. Its own token rather than --surface-inset because the two are different
       roles: --surface-inset is a RECESS (a well, a track, something carved into the card), while a
       tile is the one filled CONTAINER the light theme still ships. In dark that container is the
       ordinary glass surface; only light gives it a distinct step. Declared here so the light value
       is a token and not a literal — nothing in dark consumes it, so this cannot move dark. */
    --tile-fill: rgba(255, 255, 255, 0.07);     /* = --glass-bg, dark's card surface */
    --keyline: rgba(255, 255, 255, 0.14);       /* = --glass-border */
    --keyline-strong: rgba(255, 255, 255, 0.18);/* = --border-strong */
    --accent: #7c3aed;                          /* = --accent-solid */
    /* The accent as a CHANNEL, so a tint of it can be composed with rgba() instead of hand-typed.
       Without this a wash of the accent has to be spelled as a literal, which goes stale the moment
       the accent is re-derived — and that has already happened once during this rebuild. */
    --accent-rgb: 124, 58, 237;
    --accent-hover: #6d28d9;                    /* = --ref-accent-deep */
    --accent-subtle: #a78bfa;                   /* = --accent-soft */

    --ground: #050309;
    --ground-deep: #020103;

    --astra-bg: #030303;
    --astra-accent: #7c3aed;
    --astra-accent-soft: #a78bfa;
    --astra-text: #ffffff;
    --astra-muted: #94a3b8;

    /* Width of viewport to keep clear at the TOP-RIGHT: the fixed notification bell (40px at
       right:64) + the nav hamburger (40px at right:14) both live there, above every page, so any
       header control that runs to the right edge gets buried under them. Pages reserve this. */
    --topright-safe: 112px;
}

/* Phone has no fixed bell and no hamburger — MainLayout does not mount either component below
   768px — so the 112px strip 18 stylesheets reserve against them is 112px of a 390px screen, 29%,
   reserved for nothing. Tablet's own answer is the rule below, once the rail replaces the drawer. */
:root[data-shell='phone'] { --topright-safe: 0px; }

/* Tablet has no fixed bell and no hamburger either: the nav rail on the left replaces both, and the
   Notiser row inside it opens the same panel. Landing here in the same task that removes those two
   controls is binding, not tidy — the 112px strip 18 stylesheets reserve is protection against
   controls that still render, so zeroing it first is an overlap regression, not a saving. */
:root[data-shell='tablet'] { --topright-safe: 0px; }

:root {
    --glass-bg: rgba(255, 255, 255, 0.07);
    --glass-border: rgba(255, 255, 255, 0.14);
    --glass-blur: blur(24px) saturate(160%);
    --glass-shadow: 0 4px 24px rgba(0, 0, 0, 0.4),
                    inset 0 1px 0 rgba(255, 255, 255, 0.22),
                    inset 0 -1px 0 rgba(255, 255, 255, 0.04);
    --glass-shadow-hover: 0 12px 40px rgba(0, 0, 0, 0.45),
                          0 0 24px rgba(124, 58, 237, 0.18),
                          inset 0 1px 0 rgba(255, 255, 255, 0.28),
                          inset 0 -1px 0 rgba(255, 255, 255, 0.06);

    /* ── Status palette ────────────────────────────────────────────────────
       Three meanings, three hues, and each hue means ONE thing app-wide. The
       brand violet above is identity and primary action; it is never a status.

       Validated with the dataviz validator against this app's real composited
       surfaces (a glass panel over #050309 flattens to ~#0d0b12):

         normal-vision separation  worst pair ΔE 21.2   PASS (floor 15)
         CVD separation            worst adjacent 10.6  PASS (target 8)
         contrast vs surface       all ≥ 3:1            PASS

       One constraint rides along and is binding on every use site: BREACH
       against LOGGED measures ΔE 6.5 for deuteranopia — inside the 6–8 floor
       band. Wherever those two can appear in the same control, colour alone is
       not allowed to carry the difference; a label, a gap or the geometry must
       separate them too. See the `contrast-audit` skill. */
    --st-logged: #34d399;          /* work actually recorded / done */
    --st-logged-soft: rgba(52, 211, 153, 0.12);
    --st-logged-line: rgba(52, 211, 153, 0.38);
    --st-expected: #fbbf24;        /* owed but not here yet: booked, unarrived, behind */
    --st-expected-soft: rgba(251, 191, 36, 0.1);
    --st-expected-line: rgba(251, 191, 36, 0.35);
    --st-breach: #f87171;          /* over capacity, past the ready date */
    --st-breach-soft: rgba(248, 113, 113, 0.12);
    --st-breach-line: rgba(248, 113, 113, 0.38);

    /* Unread-count red. NOT part of the status palette above — it does not mean "breach" or any
       other app-wide status, it marks a count of unread things (the bell badge, the tab counts,
       More's badge). Kept as its own token so the one colour has one home instead of being
       hand-copied at every badge site. */
    /* Carries white text at 10px bold, so the badge's own contrast is what decides whether the
       count can be read: #ef4444 put --ink at 3.33:1 (sampler, /vehicle-storage). #c81e1e reads
       5.08:1 with the ink and still holds 3.58:1 against the ground it sits on, so the badge is
       both legible and visible as a mark. */
    --unread-count: #c81e1e;

    /* Ink. Anything at or below --ink-faint is decorative: it may not be the only
       place a piece of information appears. */
    --ink: #f1f1f1;
    --ink-dim: rgba(255, 255, 255, 0.72);
    --ink-muted: rgba(255, 255, 255, 0.5);
    --ink-faint: rgba(255, 255, 255, 0.38);
    --hair: rgba(255, 255, 255, 0.07);

    /* ── Type scale ────────────────────────────────────────────────────────
       Seven steps, and nothing between them. Before this the page stylesheets
       carried 40+ distinct font sizes whose six most-used values were 0.68–0.9rem
       — every page was set at 10–14px end to end, so no page had a headline size
       and nothing on any screen could be primary. Hierarchy was then faked with
       another bordered box, which is why cards nest three deep.

       --t-xs is the floor for anything a mechanic reads. --t-micro is for
       uppercase eyebrows and units only; a sentence never gets it. */
    --t-display: 2rem;        /* 32px — the one hero figure on a page */
    --t-h1: 1.375rem;         /* 22px — page / card title */
    --t-h2: 1.0625rem;        /* 17px — section title */
    --t-body: 0.9375rem;      /* 15px — body, a row's primary fact */
    --t-sm: 0.8125rem;        /* 13px — secondary fact, meta */
    --t-xs: 0.75rem;          /* 12px — labels, badges */
    --t-micro: 0.6875rem;     /* 11px — eyebrow / unit, uppercase, never prose */

    /* ── Space ─────────────────────────────────────────────────────────────
       4px base. The app's two most common paddings were 3px and 2px, which is
       how rows ended up dense without being readable: density has to come from
       carrying fewer facts per row, never from closing the gaps around them.
       A row's vertical padding starts at --sp-3. */
    --sp-1: 4px;
    --sp-2: 8px;
    --sp-3: 12px;
    --sp-4: 16px;
    --sp-5: 24px;
    --sp-6: 32px;
    --sp-7: 48px;

    /* ── Radius ────────────────────────────────────────────────────────────
       Four values, chosen by what the thing IS. Fifteen were in use
       (3,4,6,7,8,9,10,11,12,14,16,18,20,50,999), so no two surfaces agreed. */
    --r-control: 8px;         /* inputs, chips, small buttons */
    /* `--r-sm` was CONSUMED at two sites and DECLARED NOWHERE — InvoiceDraftDetailDesktop.css:1005
       and InvoiceDraftDetailPhone.css:109, both on the PDF button. An undeclared custom property
       makes the whole declaration invalid at computed-value time, so `border-radius` fell back to 0
       in BOTH themes and has done since the day it was written. Nothing errored; the corner was
       simply square and nobody looked.

       Declared as an alias rather than a new number: the two sites want "the small control radius",
       which is exactly what `--r-control` already means, and aliasing keeps them on the ladder in
       each theme (8px dark, 4px light) instead of pinning a third value that would then have to be
       maintained. Only the invoice-draft routes consume it, and those are outside the dark baseline,
       so this is safe to fix at source. */
    --r-sm: var(--r-control);
    --r-card: 12px;           /* a record: quote card, event, row surface */
    --r-panel: 16px;          /* a page-level container */
    --r-pill: 999px;          /* badges, status pills */

    /* ── Measure ───────────────────────────────────────────────────────────
       One centred column for a page's whole composition — the gutter rail AND
       the content, not each centred against the viewport separately. Two
       independent centrings is what left a third of a 1900px monitor empty
       with the content adrift to the left. */
    --measure: 1240px;

    /* ── The ledger measure — ONE value, BOTH themes ───────────────────────
       `--lx-page` is the width of a list-plus-pane ledger composition (the
       column, the reading pane and the gap). It is read by `listLedgerStyle.ts`
       and by every page built on that pattern.

       It was declared SIX times, per page, at six different values, and the
       ledger is one component:

         Customers.css:20              68.5rem  = 1096px
         Vehicles.css:20               71.5rem  = 1144px
         QuoteDraftsDesktop.css:12     var(--measure) = 1240px
         QuoteDraftsDesktop.css:1009   82rem    = 1312px
         PhoneBookingsDesktop.css:19   1560px
         WorkQueueDesktop.css:63       1560px

       Worse, the two themes then disagreed on top of that, because light drops
       the page cap and dark keeps it. Measured at a 1900px viewport, the SAME
       `.lx-split` on the SAME markup:

         Telefon          light 1820   dark 1528
         Work Queue       light 1528   dark 1528
         Incoming quotes  light 1312   dark 1312

       So Telefon in light — the page that was signed off and the one the shop
       owner points at — is 292px wider than Work Queue and 508px wider than the
       incoming-quote ledger, and 292px wider than its own dark twin.

       1820px is that signed-off Telefon composition, and it is now the default
       for every ledger in both themes. A page may still override it, but it has
       to have a reason and say so — six silent overrides is what produced a
       component that renders at six widths.

       It is a LENGTH, not a colour, so it is theme-invariant by construction and
       `compare.mjs` (which records paint only) will not see it move. That is the
       correct outcome: the two themes are meant to differ in surface, never in
       where the content sits. */
    --lx-page: 1820px;

    /* ── The page measure — ONE value, BOTH themes ─────────────────────────
       The centred column a non-ledger page composes on. It already existed,
       but only inside `public/theme-light/_base.css`, which is a THEME file —
       and a length in a theme file cannot be agreed on by construction. That
       one misplacement is why six pages measured 1560 in light and 1096–1240
       in dark. Measured at 1900, light vs dark, before this moved:

         /customers   170 / 1560   vs   402 / 1096     (464px apart)
         /vehicles    170 / 1560   vs   378 / 1144
         /settings    170 / 1560   vs   360 / 1180
         /complaints  170 / 1560   vs   330 / 1240
         /dropoffs, /vehicle-storage — the same 320px split

       Deliberately NOT `--measure` above. That token is ALSO the width of a
       wide modal (`modalShellStyle.ts:87`, `.ibx-sheet`, `.rtx-add`,
       `.vsx-sheet`, `.dsx-sheet`), and a 1560px dialog on a 1900px monitor is
       a full-bleed sheet, not a dialog. A page measure and a dialog measure are
       two decisions that happened to share one name. This one names only the
       page.

       1560 rather than uncapped: `registers.css` argued from evidence that an
       uncapped ledger row puts the identity cell and its figure a screen apart.
       It is also the number light already shipped, and light's positioning is
       the one that was signed off. */
    --page-measure: 1560px;

    /* ── The dashboard measure — ONE value, BOTH themes ────────────────────
       A page of FIGURES over a period: KPI tiles, a chart band, a summary
       table. Not a board (a board's rows are records you scan and act on, and
       they earn --page-measure), and not a document.

       `/finance` argued this number from evidence and is the signed-off page:
       "1360, down from 1520: at the old width a table holding one mechanic put
       the label KUND at the far left and its value 1400px away at the far
       right" (FinanceDesktop.css:41-45). `/analytics` makes the SAME argument
       in its own words — "a statistics page that runs the full width of a
       27-inch monitor makes the eye travel the length of a desk between a
       label and its figure" (Analytics.css:18-19) — and then picked 1240,
       which is `--measure`, the DIALOG width. Two pages of the same kind, one
       argument, 120px apart and neither number named.

       So it gets a name. 1360 rather than 1240 because 1360 is the one of the
       two that was reasoned to and shipped; /analytics moves to it. */
    --dash-measure: 1360px;

    /* ── Form fields ───────────────────────────────────────────────────────
       A field is a box you are invited to type into, and it has to say so in
       both themes. Page stylesheets all reached for `rgba(var(--sf-rgb), 0.05)`,
       which works in dark (5% white on near-black is a visible inset) and fails
       in light: --sf-rgb flips to the ink colour, so the same rule renders 5%
       dark ink on a near-white panel — no edge, no fill, no field.

       So the two themes are not one alpha applied to a flipping colour. Dark
       reads by its FILL (a lit inset in a dark panel); light reads by its
       BORDER (a white field on a white-ish panel is defined by its outline, the
       way every light-theme form in the world is). --field-ring is the focus
       ring, which the old form did not have at all: it signalled focus by
       shifting a border alpha, invisible in light and easy to lose halfway down
       a fourteen-field keyboard pass. */
    /* The dialog surface. Its own token rather than the legacy .modal-panel gradient, because that
       gradient's light values (#d4ddee → #d6dff1) are a mid blue-grey, and every light-theme status
       colour in this file was measured against near-white glass. Composited and measured, the amber
       --st-expected landed at 2.6:1 on it and the red at 4.1:1 — both under AA, on a surface that
       carries a price. Near-white in light puts them back where they were validated. */
    --sheet-bg: linear-gradient(160deg, #171a24, #10131b);

    --field-bg: rgba(255, 255, 255, 0.05);
    --field-bg-hover: rgba(255, 255, 255, 0.08);
    --field-border: rgba(255, 255, 255, 0.12);
    --field-border-hover: rgba(255, 255, 255, 0.2);
    --field-border-focus: rgba(167, 139, 250, 0.75);
    /* The ring is a NON-TEXT indicator and WCAG 2.2 SC 1.4.11 asks it for 3:1 against what it
       covers. At rgba(124,58,237,0.3) it composited to 1.7:1 on a field and the sampler measured
       the real thing at 2.91:1 on /vehicle-storage's search box — a focus state a keyboard user
       can lose. The lit indigo at 0.7 measures 3.8:1 over both the field fill and the bar behind
       it, and reads as the same violet the border already shifts to. */
    --field-ring: rgba(167, 139, 250, 0.7);

    /* ── Difficulty ────────────────────────────────────────────────────────
       A job's skill tier is NOT a status: an Expert job is not a breach and an
       Enkel one is not work already recorded. It used --st-logged/expected/breach
       verbatim, which spent all three status hues on a non-status and left green
       and amber meaning two different things on the same card.

       Its own ramp instead — and never the only carrier: every use site prints the
       tier's name beside the colour.

       Measured with the dataviz validator against the real composited chip
       (panel #0d0b12 under the tier's own 16% tint ≈ #1b1424):

         normal-vision separation  worst all-pairs ΔE 17.2   PASS (floor 15)
         CVD separation            worst 10.9 protan / 8.3 tritan   PASS (target 8)
         WCAG text (13px/600)      7.2 · 5.0 · 10.5 : 1      PASS (floor 4.5)

       The first two candidates failed here and are recorded so they don't come
       back: a pale-violet ramp (#cbd5e1/#c4b5fd/#f5d0fe) measured ΔE 10.4 normal
       between tiers 2 and 3 — invisible as a distinction — and #8b5cf6 for tier 2
       cleared every ΔE check but landed at 4.2:1 on its own tint, under AA. */
    --diff-1: #94a3b8;        /* Enkel — neutral slate, deliberately unremarkable */
    --diff-1-soft: rgba(148, 163, 184, 0.12);
    --diff-1-line: rgba(148, 163, 184, 0.3);
    --diff-2: #9270f7;        /* Avancerad — violet, one step deeper than the brand ink */
    --diff-2-soft: rgba(124, 58, 237, 0.16);
    --diff-2-line: rgba(167, 139, 250, 0.36);
    --diff-3: #f0abfc;        /* Expert — lightest, pushed toward magenta */
    --diff-3-soft: rgba(217, 70, 239, 0.18);
    --diff-3-line: rgba(240, 171, 252, 0.42);

    /* ── The finance surface's ink, as CHANNELS ────────────────────────────
       Ekonomi, Fakturor, the invoice editor, Analys and Kalender share one ink
       ramp, declared in lib/financeTokens.ts. That file is INJECTED through
       usePageStyle, which scopes a sheet by nesting it — so an
       `html[data-theme="light"]` selector inside it becomes a descendant of the
       page scope and can never match (pageStyle.ts lists this among its known
       limits, and it had no live instance until the light pass tried to add one).

       So the theme difference lives here, where `html[data-theme]` works
       natively, and financeTokens consumes these. Same trick as --sf-rgb above:
       the channel flips, the alphas flip with it, and the page CSS stays
       theme-agnostic.

       Dark is unchanged — these are the values that were already in
       financeTokens. Light mirrors this file's own ink ladder (#241b3d), because
       the codemod maps the dark ink to #5400b6 and a violet neutral is not a
       neutral. Measured against #f1ebfc, the worst light surface this app
       composites: 13.9 / 8.2 / 6.0 / 4.5 : 1 — every step clears AA, step 4
       included, and step 4 carries column heads on that surface. */
    --fin-ink-rgb: 245, 243, 249;
    --fin-a2: 0.72;
    --fin-a3: 0.58;
    --fin-a4: 0.49;
    /* Amber, and the one step that had to be re-measured rather than mapped: the
       codemod's #9a6000 lands at 4.45:1 in light, under AA — and it is the colour
       of the reason text on every blocked row of the invoice queue. */
    --fin-warn-c: var(--c-orange-f5c451);
    /* A recess — darker than the sheet it sits in, never lighter. */
    --fin-well-c: rgba(var(--sh-rgb), 0.22);

    /* ── Motion ────────────────────────────────────────────────────────────
       Three durations, two curves. Before this the app used 31 distinct
       durations, each picked in isolation. Neither curve overshoots: a tool
       opened forty times a shift should not bounce. See the `motion-scale` skill. */
    --mo-quick: 120ms;   /* state under the cursor: hover, focus, colour swaps */
    --mo-base: 220ms;    /* things that move or resize: meters, expand/collapse */
    --mo-enter: 320ms;   /* things that arrive: modals, toasts */
    --ease-out: cubic-bezier(0.22, 1, 0.36, 1);
    --ease-inout: cubic-bezier(0.65, 0, 0.35, 1);
}

/* ── Light ─────────────────────────────────────────────────────────────────
   Lifted violet: the same living violet ground, inverted, with white glass on
   it. Not a lightness map of the dark theme — several dark values were chosen
   for how they composite over near-black and those measurements do not survive
   the change of ground.

   `html[data-theme]` (specificity 0,1,1) deliberately outranks `:root`
   (0,1,0), so an explicit choice always beats the media query below it. */
html[data-theme="light"] {
    color-scheme: light;

    /* ═══════════════════════════════════════════════════════════════════════════════════════════
       STRIPE-LIGHT — geometry and type are THEME-VARIANT from here.
       ───────────────────────────────────────────────────────────────────────────────────────────
       This amends page-standard.md §8.1, which declared geometry theme-invariant. That rule assumed
       geometry carries no theme meaning. It does: corner radius is one of the strongest signals
       separating a consumer product from an instrument, and 16px panels are much of why the light
       theme read as soft. Same for type — a 32px display figure on a dense light dashboard reads as
       a marketing page, not as a financial terminal.

       The leverage is why this is four lines rather than a sweep: 501 radius declarations and 1 186
       font-size declarations already read these tokens, so they all move at once. The literals that
       do not are a second pass.

       Dark keeps its own values; every declaration here lives inside the light block and cannot
       match in dark. See docs/design/stripe-light-decisions.md. */
    /* MEASURED: no radius token above 4px exists anywhere in Stripe's shipped Dashboard CSS. Not 8,
       not 12, not 16. Softness comes from the SHADOW, never from the corner — which is the opposite
       of the reflex, and it is why a Stripe card reads as precise rather than soft-edged. */
    --r-control: 4px;
    /* Restated in the light block purely so `token-parity.mjs` can see it in both — the alias would
       resolve correctly from the base declaration alone, but a token that appears in one block only
       is exactly the silent failure that check exists to catch, and it cannot tell an intentional
       alias from an omission. Same value, same meaning, one line of noise for a working guard. */
    --r-sm: var(--r-control);
    --r-card: 4px;            /* see --r-control: 4 is the ceiling, not a step on a ladder */
    --r-panel: 4px;           /* even modals. Measured, not inferred. */
    --r-pill: 999px;          /* true pills only; badges take 4px in the badge layer */

    /* Flat and weight-driven. Hierarchy comes from weight and colour, not from size jumps — which
       is what lets a dense table stay dense without any row shouting. The 11px floor is unchanged
       and non-negotiable: the bay tablet is read at arm's length under glare. */
    --t-display: 1.25rem;     /* 20px — the one hero figure */
    --t-h1: 1.25rem;          /* 20px — page title */
    --t-h2: 0.875rem;         /* 14px — card / section title */
    --t-body: 0.8125rem;      /* 13px — body, row primary */
    --t-sm: 0.8125rem;        /* 13px — secondary fact */
    --t-xs: 0.75rem;          /* 12px — labels, badges */
    --t-micro: 0.6875rem;     /* 11px — uppercase eyebrow, tracked */

    --sf-rgb: 27, 35, 61;
    --sh-rgb: 29, 57, 149;

    /* Candidate C's ground, 2026-08-09. Deeper than the shipped #f3f0fb on purpose: the light theme's
       panels are OPAQUE white, so the ground is the only thing they can be lighter than. Chroma is
       held at 0.008 — the shipped theme's failure is chroma at high lightness, where the same
       absolute tint that reads as "dark grey" at L=10% reads as unmistakable lavender at L=95%. */
    /* ═══════════════════════════════════════════════════════════════════════════════════════════
       WP-4 — the pivot. Light stops being a translation of dark and becomes a design for light.
       ───────────────────────────────────────────────────────────────────────────────────────────
       Three attempts failed by tuning values inside a set of mechanisms that belong to darkness:
       glass, blur, glow, drifting orbs, coloured status text, hierarchy carried by size. Each one
       needs a dark ground to mean anything. Glass over white samples white. Glow on white is a
       smudge. No amount of value-tuning fixes a mechanism that does not belong.

       What replaces them is occlusion and layering: an off-white canvas, pure-white surfaces, a
       structural 1px keyline, and shadows so slight they read as contact rather than as lift.
       Depth comes from edges, not from atmosphere.

       Every declaration in this block lives inside html[data-theme="light"] and cannot match in
       dark. See docs/design/stripe-light-token-spec.md for the measured values. */

    /* The canvas is never pure white — white is reserved for surfaces, so that a card can be
       lighter than the thing it sits on. That single relationship is what carries elevation here. */
    /* #E3E8EE — Stripe gray-100, measured off the shipped Dashboard CSS. Far darker and cooler than
       the #eef2f7 this held, and the delta to a white card is deliberately UNEVEN (+28 R, +23 G,
       +17 B): the canvas is a cool blue-grey and the card is neutral white, so they separate on hue
       as well as lightness. A neutral grey page under a white card separates on lightness alone,
       which is exactly what reads as a sticker glued on. */
    --canvas: #e3e8ee;
    --surface: #ffffff;
    --surface-inset: #f3f6f8;      /* steps BELOW the card: wells, tracks, recesses */
    /* MEASURED gray-50. Stripe removed its cards on purpose — their own published before/after says
       cards "take up too much space" and "can't be placed side-by-side" — and the ONE container that
       survived the cull is a soft grey fill with no border, used for stat tiles. This is that fill.

       It is a step ABOVE --surface-inset, not the same value: a recess is carved into a surface, a
       tile sits on the page as the only filled object left. Two roles, two values. */
    --tile-fill: #f7fafc;

    --ground: #f6f8fa;
    --ground-deep: #eef2f6;

    --astra-bg: #f4f5f7;
    --astra-accent: #2f4fd0;
    /* Follows the re-derived accent. This one declaration feeds ~20 accent-text sites across the
       boards (link hovers, active day chips, state labels); left at the retired #7c3aed it was the
       largest remaining source of the old, louder violet in light. Dark is untouched — that value
       lives on line 33. */
    --astra-accent-soft: #2f4fd0;
    --astra-text: #1b233d;
    --astra-muted: #545b70;

    /* Ink. Alphas are NOT the dark ones, and the whole ladder is compressed
       upward rather than mapped step-for-step.

       Dark reaches 4.5:1 at alpha .45, so its bottom step is still legible body
       text. Light does not reach 4.5:1 until .63 — put the dark ladder's .38
       through here unchanged and the app's small flag labels ("Behöver lyft",
       "Expert", the nav section headings) land at 3.0:1, which is the LARGE-text
       floor being used for 11px text.

       So every step sits at or above .63 and the hierarchy is carried by the
       gaps between them instead. Four steps, all of them legible:
         1.00 -> 14.5:1   .82 -> ~8:1   .72 -> 6.1:1   .63 -> 4.5:1
       There is deliberately no decorative step below the body floor in light. */
    /* ── Re-based 2026-08-09 (Phase 5) ────────────────────────────────────────────────────────
       Two things were wrong, and the second is the one that made light pages read flat.

       1. The BASE was `36, 27, 61`, and the ladder was validated against `#f1ebfc` — "a panel at its
          minimum opacity over the strongest orb". That surface does not exist on a shipped page:
          real card interiors composite to `#e1def4`, because `.fx-card` adds another 3% ink over the
          panel. On the real ground the bottom step measured 4.22–4.24:1 in situ — under AA, on
          surfaces that carry a price. Dropping the base to `15, 12, 28` buys that back without
          spending chroma, because it is a violet-black rather than a grey.

       2. The SPAN was 1.00 → .63, i.e. 0.19, against dark's 0.34. That is why nothing recedes on a
          light page: four steps crowded into a fifth of the range dark uses. The fix is to move the
          TOP down rather than push the bottom under AA — 0.92 → 0.60 restores a 0.32 span with every
          step still clearing 4.5:1 on `#e1def4`.

       Dark is untouched: every value here lives only in the light block. */
    /* ── WP-4: the navy ink ladder ────────────────────────────────────────────────────────────
       Solid hexes, not alphas over a ground. An alpha ink is a dark-theme device: it lets one
       value composite correctly over a range of near-black surfaces. In light the surfaces are
       white and the ladder wants to be exact.

       The navy is the fingerprint. Measured at hue 211-216, roughly 50% more chromatic than a
       neutral near-black — and slate ink is the single most legible reason a light theme reads as
       "Tailwind default" rather than as an instrument. Contrast measured on the CANVAS (#f6f8fa),
       the worse of the two grounds:
         --ink        16.32:1      --ink-dim   11.02:1
         --ink-muted   5.08:1      --ink-faint  5.08:1

       --ink-faint deliberately ALIASES --ink-muted. Light has no sub-AA decorative rung: dark's
       0.45 step is legible on near-black, the same idea on white lands under 4.5:1. Anything that
       must recede further does it with isolation or role, never with a paler ink.

       #94A3B8 is banned as a muted ink here — it measures 2.56:1 on white, fails AA outright, and
       it is the token for every timestamp and placeholder in the app. */
    --ink: #061b31;
    --ink-dim: #273951;
    /* C0: #5d6b80 measured 5.41:1 on white and cleared AA comfortably on paper. It is also the
       token carrying every timestamp, helper string and inactive parameter in the app, read at
       arm's length in a workshop with the door open. AA is a floor for a controlled environment,
       not a target for a glare-lit one. #475569 is 7.58:1 and still recedes behind --ink-dim. */
    /* MEASURED gray-500. Stripe ships exactly THREE inks: gray-900 held in reserve for emphasis,
       gray-700 for body, gray-500 for secondary. Ours had four rungs and a decorative one.
       Note this value fails AA on the canvas (3.88:1) and passes on white (4.78:1) — that is not a
       defect, it is the system: Stripe puts no content directly on the canvas, so the ramp and the
       layout enforce each other. Anything set in this ink must sit on a surface. */
    --ink-muted: #697386;
    --ink-faint: #5d6b80;

    /* The structural 1px that replaces blur. 1.18:1 against white — correct and deliberate: a
       keyline is an edge, not a contrast target. Do not "fix" it upward. */
    /* ── C0: the keyline is load-bearing, so it has to be VISIBLE ────────────────────────────
       These were #e5edf5 (1.18:1) and #cfd9e4 (1.43:1), with a note in the spec saying "a keyline
       is an edge, not a contrast target — do not fix it upward." That note was wrong, and it is
       worth saying why rather than quietly bumping the numbers.

       The light theme separates surfaces with ONE mechanism. Once --elevation-2 was deleted on the
       argument that cards carry a border instead of a shadow, the entire Z-axis rested on a single
       hairline at 1.18:1, over a canvas only 1.06:1 from the surface it sits on. Three
       imperceptible deltas stacked, with no second cue behind any of them. On a calibrated monitor
       that just holds. On a bay tablet under glare, or any uncalibrated screen, the card dissolves
       into the page and a calendar grid — which is nothing BUT these lines — reads as one flat
       plane.

       1.48:1 and 1.97:1 are still quiet: at 1px they read as structure, not as a drawn box. The
       strong rung is for dense grids and table columns, where the line does real partitioning work
       rather than just terminating a card. */
    /* The keyline IS the canvas colour. Stripe draws a card edge as 0 0 0 1px of gray-100 — the same
       value as the page — so the border is not a line drawn ON the card, it is the page showing
       through at its edge. That is the whole trick behind an edge that does not look applied. */
    --keyline: #e3e8ee;
    --keyline-strong: #adbaca;
    --hair: #e5edf5;

    /* Glass. The 0.68 fill has a hard floor at 0.505 — below that a panel over
       the strongest orb can no longer hold --st-expected above 3:1. */
    /* ── WP-4: glass becomes surface ──────────────────────────────────────────────────────────
       46 consumers re-skin from this one edit, which is why the rebuild is affordable at all.

       The fill goes OPAQUE. A translucent white over a violet ground was the third attempt's
       central device and it is the reason light read as cheap: it samples a ground that has
       nothing worth sampling, and it costs a full backdrop-filter pass on a shop tablet to do it.

       The shadow goes to NONE, and that is the strongest consensus in the whole corpus — Stripe's
       own single `keyline` token, Linear, Carbon, Polaris and Notion all converge on border-first
       separation with content cards carrying no shadow at all. Given a border is present, a 18px
       blur is float on a dense ops board.

       The hover glow goes too. `0 0 20px rgba(124, 58, 237, 0.10)` is emission, and emission needs
       darkness to read as light; on white it is a smudge. Hover becomes contact: --elevation-1. */
    --glass-bg: var(--surface);
    --glass-border: var(--keyline);
    --glass-shadow: none;
    --glass-shadow-hover: var(--elevation-1);

    /* Status. The dark trio cannot carry over — on a light card #34d399 is
       1.4:1, #fbbf24 is 1.7:1, #f87171 is 2.3:1. Replaced, not adjusted.
       Validated all-pairs on #f1ebfc: deutan worst 8.4, normal worst 18.9,
       all >= 3:1. That is BETTER than the dark palette, which measures 6.5 on
       BREACH vs LOGGED — but the secondary-encoding rule stays, so that the
       two themes never disagree about what a control is allowed to do. */
    --st-logged: #047857;
    --st-logged-soft: rgba(4, 120, 87, 0.10);
    --st-logged-line: rgba(4, 120, 87, 0.30);
    /* Amber, re-derived 2026-08-09. `#b87c05` measures 3.04:1 on the light audit surface and
       **2.50:1 in situ** over the strongest orb — under AA on rows that carry a price. `#8a5500` is
       already shipping as this theme's `--fin-warn-c` at 5.3:1, so this also collapses the app's two
       competing light ambers into one. Dark is untouched. */
    --st-expected: #8a5500;
    --st-expected-soft: rgba(138, 85, 0, 0.10);
    --st-expected-line: rgba(138, 85, 0, 0.30);
    --st-breach: #b91c1c;
    --st-breach-soft: rgba(185, 28, 28, 0.10);
    --st-breach-line: rgba(185, 28, 28, 0.30);

    /* Fields. Not the dark values re-alphaed — the opposite construction, for
       the reason set out beside the dark block. The panel a field sits on is
       --glass-bg (68% white over the violet ground) or the modal gradient, both
       near-white, so the field goes brighter than its panel and takes a violet
       hairline that is actually visible against it. The focus ring darkens
       rather than lightens, because a pale glow on a pale panel is nothing. */
    /* Flat white, not a gradient. A 160deg wash toward #f6f4fc is a marketing-page device and it
       tints every modal faintly violet — the exact drift this rebuild exists to end. */
    --sheet-bg: var(--surface);

    /* ── WP-4: one accent, and it is BLUE ─────────────────────────────────────────────────────
       The Stripe blueprint's accent is #533afd, which is hue 253 — violet. The standing rule here
       is that everything violet becomes blue in light, everywhere, so the accent is re-derived on
       the shipping blue family instead. Measured:
         --accent        hue 228   6.67:1 under white text   (target was 6.18)
         --accent-hover  hue 228   8.14:1                    (target was 7.81)
       One token clears both the fill job and the line job, which is what makes a single accent
       possible at all. --accent is also 6.67:1 as text on white.

       Accent discipline: under 5% of screen area. That is Stripe's own stated position — they
       restrict colour BECAUSE colour freedom is an accessibility hazard — not a preference. */
    /* Interaction blue. Focus is a DIFFERENT hue on purpose — see --focus-ring below. */
    --accent: #2f4fd0;
    --accent-rgb: 47, 79, 208;
    --accent-hover: #2743b8;
    --accent-subtle: #c5d0f6;      /* tint only. Never text, never a fill under text. */

    /* ── WP-4: the weight clamp ───────────────────────────────────────────────────────────────
       Four declarations, 678 sites. This is the whole payoff of pinning the rungs by value in
       Stage A: dark keeps 650/700/750/800 exactly as authored, light collapses them onto 600, and
       no site is touched twice.

       700 is deleted in light. Nobody serious ships 700 in a dense light product UI — weight moves
       inversely to size in every system measured, and a 100-unit step is barely perceptible at
       13px, so the real ladder is two rungs plus a tuning weight. 300 comes UP to 400: a 300 on
       white is a whisper, and the 11px floor exists because this is read at arm's length. */
    --fw-300: 400;
    --fw-650: 600;
    --fw-700: 600;
    --fw-750: 600;
    --fw-800: 600;

    --field-bg: rgba(255, 255, 255, 0.85);
    --field-bg-hover: rgba(255, 255, 255, 1);
    --field-border: rgba(40, 81, 217, 0.22);
    --field-border-hover: rgba(40, 81, 217, 0.36);
    --field-border-focus: rgba(47, 79, 208, 0.9);
    /* Same SC 1.4.11 rule as dark's ring, and light was the weaker of the two: at α .22 the ring
       measured 1.41:1 against the white field it sits on (sampler, /vehicle-storage). α .7 reads
       3.50:1 on the field and 3.25:1 on bare ground, which is the pair of surfaces a focused
       control can land on in this theme. */
    --field-ring: rgba(47, 79, 208, 0.7);

    /* The finance surface's ink. See the channel block in the dark section for why this lives here
       rather than in lib/financeTokens.ts. The ladder is this file's own light ink, and the alphas
       are its light alphas — light does not reach 4.5:1 until .63, so the dark ramp's .49 bottom step
       would put every column head on Ekonomi and Fakturor under AA. */
    --fin-ink-rgb: 27, 35, 61;
    --fin-a2: 0.82;
    --fin-a3: 0.72;
    --fin-a4: 0.63;
    /* Re-stepped, not mapped: the codemod's light amber is 4.45:1 here. 5.3:1. */
    --fin-warn-c: #8a5500;
    --fin-well-c: rgba(29, 57, 149, 0.06);
}

/* There is deliberately no `@media (prefers-color-scheme: light)` block here.
   The OS preference is resolved once, in the inline script in index.html, which
   writes data-theme on <html> before first paint — so this file declares each
   theme exactly once instead of carrying a second copy that has to be kept in
   step by hand. The app is a client-rendered SPA, so a no-JS path does not
   exist to regress. */

/* Tabular figures. Declared here because `.fig` is used in Routines.vue,
   SlotRail.vue and InspectionBooking.vue but was declared only in Routines.css —
   so on every page except Routines it did nothing, and columns of numbers that
   were meant to align did not. */
.fig {
    font-variant-numeric: tabular-nums;
    font-feature-settings: "tnum" 1;
}

/* ── Reduced motion, app-wide ──────────────────────────────────────────────
   Only 8 of 25 page stylesheets handled this, so most of the app animated
   regardless of the OS setting. Declared once here instead of seventeen more
   times. Note this also has to cancel the `scroll-behavior: smooth` that the
   `*` rule below sets — without the override, a user who asked for no motion
   still gets smooth scrolling.

   This removes MOVEMENT, not feedback: every hover in the app is expected to
   carry a colour/border cue as well as its 1px lift, so controls still respond
   visibly when the transform is switched off. */
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
        scroll-behavior: auto !important;
    }
}

/* ── The page eyebrow badge ────────────────────────────────────────────────
   Bootstrap owns the bare class name `.badge` and every page declares it bare,
   so until now each one was half ours and half vendor: Bootstrap supplied
   `font-weight: 700`, `line-height: 1`, `white-space: nowrap` and — invisibly —
   `position: relative; top: -1px`, nudging every badge in the app up a pixel
   that nobody wrote. This declaration is self-contained and neutralises that.

   Pages may still override the colours (this file loads before the injected
   page stylesheets); the point is that the geometry no longer comes from a
   vendor sheet by accident. */
.badge {
    display: inline-block;
    position: static;
    top: auto;
    width: fit-content;
    padding: 3px 10px;
    border-radius: 50px;
    font-size: 0.62rem;
    font-weight: 600;
    line-height: 1.45;
    letter-spacing: 2px;
    white-space: nowrap;
    color: var(--astra-accent-soft);
    background: rgba(124, 58, 237, 0.12);
    border: 1px solid rgba(167, 139, 250, 0.35);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.1);
}

/* ── The two pieces of furniture every page was declaring for itself ───────
   `.purple-gradient` (the accent half of a page title) was declared in NINETEEN page stylesheets and
   `.state-msg` (loading / empty / error line) in TWENTY-FOUR. Nineteen copies of one gradient is
   nineteen chances to disagree, and they already did: one page's "purple" gradient was emerald.

   Declared once here. Pages still win at equal specificity — this file loads before the injected
   page stylesheets — so a page that genuinely needs its own tone can still say so; it just no longer
   has to restate the default to get it.

   The page-local copies are being deleted as each page is rebuilt rather than in one 43-file sweep:
   several of those files are open in other work right now, and a redeclaration is harmless (same
   rule, later in the cascade) whereas a half-finished sweep is not. */
.purple-gradient {
    background: linear-gradient(to right, var(--astra-accent-soft), var(--astra-accent));
    -webkit-background-clip: text;
    background-clip: text;
    -webkit-text-fill-color: transparent;
}

.state-msg {
    margin-top: 3rem;
    text-align: center;
    font-size: 0.9375rem;
    color: var(--ink-faint);
}

.state-msg.error {
    color: var(--st-breach);
}

/* ── Text selection, app-wide ──────────────────────────────────────────────
   Nothing defined ::selection before this, so selecting anything painted the browser default: an
   opaque light box that keeps the text's own colour. Dim text on a dark surface — a hint line, a
   muted label — lands inside that box unreadable. Violet wash + forced white, so a selection is
   legible on glass, on the near-black page, and on the violet-tinted chips alike.

   Deliberately NOT paired with `user-select: none` on buttons: plenty of this app's buttons wrap
   real data (a phone-quote card is a <button> holding the plate, the customer and the price), and
   those are exactly the strings a mechanic copies out. */
::selection {
    background: rgba(139, 92, 246, 0.42);
    color: #fff;
}

::-moz-selection {
    background: rgba(139, 92, 246, 0.42);
    color: #fff;
}

/* On the light ground the same violet wash sits under DARK ink, so forcing white
   text makes a selection less readable than no selection at all. Same wash, the
   theme's own ink. */
html[data-theme="light"] ::selection {
    background: rgba(167, 139, 250, 0.45);
    color: #241b3d;
}

html[data-theme="light"] ::-moz-selection {
    background: rgba(167, 139, 250, 0.45);
    color: #241b3d;
}

/* ── Bootstrap's `.placeholder` utility, switched off ──────────────────────
   index.html loads Bootstrap whole, and Bootstrap owns the class name `placeholder` as a SKELETON
   LOADER: `background-color: currentcolor; opacity: .5; cursor: wait`. Text inside it is invisible
   by design — the background is painted in the same colour as the text, which is the point of a
   skeleton and a disaster on a label.

   Our classes are prefixed (`dpf-`, `sr-`, `pb-`), but one bare `placeholder` slipped into the date
   picker and put a grey slab over the word "Datum" in all fifteen places that control is used. The
   class is renamed at the source; this rule is the guard, so the same slip can never paint a slab
   again. Nothing in this app uses Bootstrap skeletons — CardSkeleton/JobsSkeleton are our own.
   This file is loaded after bootstrap.min.css, so it wins at equal specificity. */
.placeholder {
    background-color: transparent;
    opacity: 1;
    min-height: 0;
    cursor: inherit;
    animation: none;
}

/* ── Inter Variable, self-hosted, LIGHT THEME ONLY ─────────────────────────────────────────────
   Two files, 133KB total, served from /fonts. Self-hosted rather than a CDN because the Android
   build is a file:// WebView under Capacitor and a remote font request is both a CSP problem and a
   cold-start stall on a shop tablet.

   WHY THIS IS LIGHT-ONLY, AND WHY THAT IS NOT A COMPROMISE
   The light theme's hierarchy is weight-driven, and the system stack cannot deliver it: Segoe UI has
   no 500 (a request for 500 matches 400) and Roboto has no 600 (a request for 600 ascends to 700).
   The same declaration would render three rungs on desktop and two on the tablet, and not the same
   two. A variable face is the only way 400/500/600 renders as authored on both.

   Applying it to BOTH themes would re-face every glyph in dark — `font-family` is in the harness's
   computed-style dump, so that is a ~14 600-element dark regression and it breaks the one hard
   constraint of this programme. Dark keeps the system stack, byte-identical. This is the same
   amendment already made for radius and type: geometry and type are theme-variant.

   `font-display: swap` and not `optional`: the fallback metrics are close enough that the swap is
   not a jarring reflow, and `optional` would silently drop the whole weight ladder on a slow first
   paint — which is exactly the platform-dependent rendering this font exists to eliminate. */
@font-face {
    font-family: 'Inter Variable';
    font-style: normal;
    font-display: swap;
    font-weight: 100 900;
    src: url('/fonts/inter-latin-wght-normal.woff2') format('woff2-variations');
    unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC,
                   U+0304, U+0308, U+0329, U+2000-206F, U+2074, U+20AC, U+2122, U+2191,
                   U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
    font-family: 'Inter Variable';
    font-style: normal;
    font-display: swap;
    font-weight: 100 900;
    src: url('/fonts/inter-latin-ext-wght-normal.woff2') format('woff2-variations');
    unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304,
                   U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB,
                   U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}

html, body {
    caret-color: var(--astra-accent-soft);
    background-color: var(--ground);
    color: var(--astra-text);
    /* Segoe UI stays first so Windows and the existing desktop app are byte-identical. Roboto and
       -apple-system are the Android and iOS WebView faces; system-ui catches everything else before
       the old Tahoma/Verdana fallbacks, which no platform should ever now reach. */
    font-family: 'Segoe UI', Roboto, -apple-system, system-ui, Tahoma, Geneva, Verdana, sans-serif;
    margin: 0;
    padding: 0;
    min-height: 100vh;
}

/* (0,2,0) against the (0,0,2) above, so this wins without !important and without touching dark.
   Swedish å/ä/ö live in the latin subset; the ext file covers the rest. */
html[data-theme="light"], html[data-theme="light"] body {
    font-family: 'Inter Variable', 'Segoe UI', Roboto, -apple-system, system-ui, sans-serif;
}

/* Scrollbars hidden everywhere (chat log, dropdowns, calendar grids, page scroll, ...) —
   scrolling itself stays fully functional via wheel/touch/keyboard, just no visible track.
   !important beats every per-page scrollbar-width/-webkit-scrollbar rule regardless of source
   order, so this is the one place that needs to change. */
* {
    scrollbar-width: none !important;
    -ms-overflow-style: none !important;
    scroll-behavior: smooth;
}
*::-webkit-scrollbar {
    display: none !important;
    width: 0 !important;
    height: 0 !important;
}

/* The animated glow (four drifting orbs) lives in BackgroundGlow.vue, mounted once at the
   app root — replaces the old static bg.jpg + two-blob gradient. Ensure page content sits
   above it. */
#app, .page, main, article {
    position: relative;
    z-index: 1;
}

.astra-card {
    background: var(--glass-bg);
    backdrop-filter: var(--glass-blur);
    -webkit-backdrop-filter: var(--glass-blur);
    border: 1px solid var(--glass-border);
    border-radius: 20px;
    padding: 1.5rem;
    box-shadow: var(--glass-shadow);
}

/* ── The four canonical button types. Every button in the app uses one of these — no page
   defines its own bespoke button CSS. Base sizing matches in-card/in-form usage; a page that
   needs a larger hero CTA (e.g. PreLogin) adds a local size-only modifier, never a new palette. ── */
button {
    font-family: inherit;
}

.btn-astra,
.btn-astra-glass,
.btn-astra-neutral,
.btn-astra-danger {
    padding: 0.7rem 1.4rem;
    border-radius: 12px;
    font-weight: 700;
    font-size: 0.85rem;
    cursor: pointer;
}

.btn-astra:disabled,
.btn-astra-glass:disabled,
.btn-astra-neutral:disabled,
.btn-astra-danger:disabled {
    opacity: 0.45;
    cursor: not-allowed;
}

.btn-astra:focus-visible,
.btn-astra-glass:focus-visible,
.btn-astra-neutral:focus-visible,
.btn-astra-danger:focus-visible {
    outline: 2px solid var(--astra-accent-soft);
    outline-offset: 2px;
}

.btn-astra {
    background: linear-gradient(135deg, rgba(109, 40, 217, 0.92), rgba(139, 92, 246, 0.78));
    color: white;
    /* Transparent 1px rather than `none`: the glass/neutral/danger variants all carry a 1px border and
       none of these buttons set an explicit height, so `border: none` here made the primary button 2px
       shorter than whatever it sat next to. */
    border: 1px solid transparent;
    box-shadow: 0 0 20px rgba(124, 58, 237, 0.45),
                inset 0 1px 0 rgba(255, 255, 255, 0.2);
    transition: transform 0.15s, box-shadow 0.2s;
}

.btn-astra:hover:not(:disabled) {
    transform: translateY(-1px);
    box-shadow: 0 0 28px rgba(124, 58, 237, 0.6),
                inset 0 1px 0 rgba(255, 255, 255, 0.25);
}

.btn-astra:active:not(:disabled) {
    transform: translateY(0) scale(0.97);
    transition-duration: 0.08s;
}

/* Glass — indigo-tinted, translucent, no backdrop-filter of its own (so it never doubles up
   blur on whatever's behind it). Secondary action in the same colour family as .btn-astra. */
.btn-astra-glass {
    background: linear-gradient(135deg, rgba(49, 46, 129, 0.36), rgba(67, 56, 202, 0.24));
    color: #e0e7ff;
    border: 1px solid rgba(99, 102, 241, 0.5);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14);
    transition: transform 0.15s, background 0.2s, border-color 0.2s;
}

.btn-astra-glass:hover:not(:disabled) {
    background: linear-gradient(135deg, rgba(49, 46, 129, 0.48), rgba(67, 56, 202, 0.34));
    border-color: rgba(99, 102, 241, 0.7);
    transform: translateY(-1px);
}

.btn-astra-glass:active:not(:disabled) {
    transform: translateY(0) scale(0.97);
    transition-duration: 0.08s;
}

/* Neutral — cancel/dismiss. Never the loudest control on screen. */
.btn-astra-neutral {
    background: rgba(255, 255, 255, 0.05);
    border: 1px solid rgba(255, 255, 255, 0.1);
    color: rgba(255, 255, 255, 0.55);
    font-weight: 500;
    transition: background 0.18s, color 0.18s, transform 0.08s;
}

.btn-astra-neutral:hover:not(:disabled) {
    background: rgba(255, 255, 255, 0.09);
    color: rgba(255, 255, 255, 0.85);
}

.btn-astra-neutral:active:not(:disabled) {
    transform: scale(0.97);
}

/* Danger — reserved for destructive actions: reject, delete, remove, archive. */
.btn-astra-danger {
    background: rgba(239, 68, 68, 0.14);
    border: 1px solid rgba(248, 113, 113, 0.35);
    color: #fca5a5;
    transition: background 0.18s, transform 0.08s;
}

.btn-astra-danger:hover:not(:disabled) {
    background: rgba(239, 68, 68, 0.24);
}

.btn-astra-danger:active:not(:disabled) {
    transform: scale(0.97);
}

/* ── Checkboxes, app-wide: native white squares → dark glass with a violet check.
   Hidden checkboxes (custom chips/switches) are display:none and unaffected. ── */
input[type="checkbox"] {
    appearance: none;
    -webkit-appearance: none;
    width: 18px;
    height: 18px;
    flex-shrink: 0;
    margin: 0;
    display: inline-block;
    vertical-align: middle;
    cursor: pointer;
    background-color: rgba(255, 255, 255, 0.05);
    border: 1px solid rgba(255, 255, 255, 0.22);
    border-radius: 6px;
    background-position: center;
    background-repeat: no-repeat;
    transition: background-color 0.15s, border-color 0.15s, box-shadow 0.15s;
}

input[type="checkbox"]:hover:not(:disabled):not(:checked) {
    border-color: rgba(167, 139, 250, 0.55);
    background-color: rgba(124, 58, 237, 0.12);
}

input[type="checkbox"]:focus-visible {
    outline: 2px solid rgba(167, 139, 250, 0.6);
    outline-offset: 2px;
}

input[type="checkbox"]:checked {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='white' stroke-width='3.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpolyline points='20 6 9 17 4 12'/%3E%3C/svg%3E"), linear-gradient(135deg, #7c3aed, #a78bfa);
    background-size: 12px 12px, 100% 100%;
    border-color: rgba(167, 139, 250, 0.8);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.25);
}

input[type="checkbox"]:disabled {
    opacity: 0.45;
    cursor: default;
}

/* ── Money-input currency adornment ──────────────────────────────────────────
   A money field carries its currency symbol next to the box, and WHICH SIDE is
   a property of the market, not of the page: "$1 200" leads, "1 200 kr" trails.
   Every money field renders the symbol span in markup order and tags it with
   currencyService.adornClass; these rules put it on the correct side.

   The page stylesheets are injected after this file (usePageStyle), so each
   rule is written as `span.cur-adorn.cur-*` — one element + two classes — to
   outrank the per-page `.cost-input-wrap .currency` padding it overrides. */
span.cur-adorn.cur-before {
    order: -1;
}

span.cur-adorn.cur-after {
    order: 2;
}

/* The boxed money field (Jobs / JobDetail / ManualBookingDetail modals) pads the
   symbol and the input asymmetrically, on the assumption the symbol leads. With
   the symbol trailing, both paddings mirror. */
.cost-input-wrap span.currency.cur-after {
    padding: 0 0.75rem 0 0.4rem;
}

.cost-input-wrap:has(> span.cur-after) input {
    padding-left: 12px;
    padding-right: 0;
}

/* The manual labour-rate row reads "900 kr / tim": the "/ h" unit always sits
   last, so the trailing symbol lands between the digits and the unit rather
   than after it. */
.qa-rate-row .qa-rate-unit {
    order: 3;
}

/* The assistant's inline grid pins its symbol against the box with a margin. */
.qa-g-field span.qa-cur.cur-after,
.qa-rate-row span.qa-cur.cur-after {
    margin-right: 0;
    margin-left: 4px;
}

/* Routines' cost field overlays its symbol on an absolutely-positioned span
   instead of laying it out in flow, so the side switch is a left/right swap
   plus the matching input padding. */
.rt-cost.cur-after > span.cur-adorn {
    left: auto;
    right: 12px;
}

.rt-cost.cur-after input {
    padding: 10px 34px 10px 12px;
}

/* ── Generated palette steps ───────────────────────────────────────────────
   Written by scripts/theme-codemod.mjs; do not hand-edit, re-run it instead.

   `--ch-*` are CHANNELS (an "r, g, b" triplet) for values page CSS uses at an
   alpha; `--c-*` are opaque steps. Both are keyed by their DARK hex, so the dark
   column below is by construction the value that was in the page CSS before the
   codemod ran — which is what makes `scripts/theme-verify.mjs` able to prove
   dark did not move.

   The light column is anchored, not inverted: a straight lightness inversion of
   #fbbf24 lands on #521c00, a brown. Steps that carry a fixed meaning (the
   status trio, the violet ladder, the four undocumented accent families) are
   pinned to validated values in the codemod's EXACT map; the rest are derived
   from their family anchor and clamped to clear 3:1 on #f1ebfc. */
:root {

    --ch-amber-fde68a: 253, 230, 138;
    --ch-blue-06080f: 6, 8, 15;
    --ch-blue-0f1119: 15, 17, 25;
    --ch-blue-10131b: 16, 19, 27;
    --ch-blue-13161f: 19, 22, 31;
    --ch-blue-151821: 21, 24, 33;
    --ch-blue-171a24: 23, 26, 36;
    --ch-blue-3b82f6: 59, 130, 246;
    --ch-blue-60a5fa: 96, 165, 250;
    --ch-blue-64748b: 100, 116, 139;
    --ch-blue-93c5fd: 147, 197, 253;
    --ch-blue-94a3b8: 148, 163, 184;
    --ch-blue-cbd5e1: 203, 213, 225;
    --ch-blue-e2e8f0: 226, 232, 240;
    --ch-cyan-38bdf8: 56, 189, 248;
    --ch-cyan-7dd3fc: 125, 211, 252;
    --ch-emerald-059669: 5, 150, 105;
    --ch-emerald-10b981: 16, 185, 129;
    --ch-emerald-34d399: 52, 211, 153;
    --ch-emerald-6ee7b7: 110, 231, 183;
    --ch-emerald-a7f3d0: 167, 243, 208;
    --ch-indigo-030108: 3, 1, 8;
    --ch-indigo-060210: 6, 2, 16;
    --ch-indigo-080412: 8, 4, 18;
    --ch-indigo-0c0818: 12, 8, 24;
    --ch-indigo-0e091b: 14, 9, 27;
    --ch-indigo-0e0a1c: 14, 10, 28;
    --ch-indigo-120c22: 18, 12, 34;
    --ch-indigo-140d26: 20, 13, 38;
    --ch-indigo-18102b: 24, 16, 43;
    --ch-indigo-7c52e8: 124, 82, 232;
    --ch-indigo-8b5cf6: 139, 92, 246;
    --ch-indigo-a78bfa: 167, 139, 250;
    --ch-indigo-c4b5fd: 196, 181, 253;
    --ch-indigo-ddd6fe: 221, 214, 254;
    --ch-indigo-e2d6ff: 226, 214, 255;
    --ch-indigo-e9e2ff: 233, 226, 255;
    --ch-indigo-e9e4ff: 233, 228, 255;
    --ch-indigo-ede9fe: 237, 233, 254;
    --ch-indigo-f0eefa: 240, 238, 250;
    --ch-orange-f59e0b: 245, 158, 11;
    --ch-orange-fbbf24: 251, 191, 36;
    --ch-pink-f472b6: 244, 114, 182;
    --ch-red-1e0c0e: 30, 12, 14;
    --ch-red-ef4444: 239, 68, 68;
    --ch-red-f43f5e: 244, 63, 94;
    --ch-red-f87171: 248, 113, 113;
    --ch-red-fb7185: 251, 113, 133;
    --ch-red-fca5a5: 252, 165, 165;
    --ch-teal-14b8a6: 20, 184, 166;
    --ch-teal-2dd4bf: 45, 212, 191;
    --ch-teal-5eead4: 94, 234, 212;
    --ch-teal-99f6e4: 153, 246, 228;
    --ch-violet-0a0512: 10, 5, 18;
    --ch-violet-140e18: 20, 14, 24;
    --ch-violet-1e1022: 30, 16, 34;
    --ch-violet-6324c7: 99, 36, 199;
    --ch-violet-7c3aed: 124, 58, 237;
    --ch-violet-c084fc: 192, 132, 252;
    --c-amber-fcd34d: #fcd34d;
    --c-amber-fde68a: #fde68a;
    --c-amber-fef3c7: #fef3c7;
    --c-blue-0d1017: #0d1017;
    --c-blue-10131b: #10131b;
    --c-blue-12151d: #12151d;
    --c-blue-141721: #141721;
    --c-blue-171a24: #171a24;
    --c-blue-1b1f2b: #1b1f2b;
    --c-blue-212445: #212445;
    --c-blue-596577: #596577;
    --c-blue-60a5fa: #60a5fa;
    --c-blue-7c899c: #7c899c;
    --c-blue-93c5fd: #93c5fd;
    --c-blue-94a3b8: #94a3b8;
    --c-blue-bfdbfe: #bfdbfe;
    --c-blue-cbd5e1: #cbd5e1;
    --c-blue-e2e8f0: #e2e8f0;
    --c-blue-e5e7eb: #e5e7eb;
    --c-cyan-1a2c35: #1a2c35;
    --c-cyan-38bdf8: #38bdf8;
    --c-cyan-4d809b: #4d809b;
    --c-cyan-7dd3fc: #7dd3fc;
    --c-cyan-a9dcf7: #a9dcf7;
    --c-cyan-bae6fd: #bae6fd;
    --c-cyan-bfe6fb: #bfe6fb;
    --c-emerald-04231a: #04231a;
    --c-emerald-059669: #059669;
    --c-emerald-10b981: #10b981;
    --c-emerald-34d399: #34d399;
    --c-emerald-6ee7b7: #6ee7b7;
    --c-emerald-a7f3d0: #a7f3d0;
    --c-emerald-ccfbf1: #ccfbf1;
    --c-green-22c55e: #22c55e;
    --c-green-4ade80: #4ade80;
    --c-green-86efac: #86efac;
    --c-indigo-050309: #050309;
    --c-indigo-272137: #272137;
    --c-indigo-a78bfa: #a78bfa;
    --c-indigo-c4b5fd: #c4b5fd;
    --c-indigo-ddd0ff: #ddd0ff;
    --c-indigo-ddd6fe: #ddd6fe;
    --c-indigo-e9e4ff: #e9e4ff;
    --c-indigo-ede9fe: #ede9fe;
    --c-indigo-f4f1ff: #f4f1ff;
    --c-indigo-f6f6f8: #f6f6f8;
    --c-indigo-f7f5ff: #f7f5ff;
    --c-orange-3a2922: #3a2922;
    --c-orange-422006: #422006;
    --c-orange-f59e0b: #f59e0b;
    --c-orange-fbbf24: #fbbf24;
    --c-pink-f472b6: #f472b6;
    --c-pink-f9a8d4: #f9a8d4;
    --c-pink-fbcfe8: #fbcfe8;
    --c-red-2b0707: #2b0707;
    --c-red-39231e: #39231e;
    --c-red-dc2626: #dc2626;
    --c-red-ef4444: #ef4444;
    --c-red-f87171: #f87171;
    --c-red-fb7185: #fb7185;
    --c-red-fca5a5: #fca5a5;
    --c-red-fda4af: #fda4af;
    --c-red-fecaca: #fecaca;
    --c-teal-14b8a6: #14b8a6;
    --c-teal-2dd4bf: #2dd4bf;
    --c-teal-5eead4: #5eead4;
    --c-teal-99f6e4: #99f6e4;
    --c-teal-a5f3fc: #a5f3fc;
    --c-violet-251342: #251342;
    --c-violet-6d28d9: #6d28d9;
    --c-violet-75509b: #75509b;
    --c-violet-7c3aed: #7c3aed;
    --c-violet-a855f7: #a855f7;
    --c-violet-c084fc: #c084fc;
    --c-violet-d2b6f9: #d2b6f9;
    --c-violet-ddc6fb: #ddc6fb;
    --ch-blue-14203a: 20, 32, 58;
    --ch-blue-1e2028: 30, 32, 40;
    --ch-indigo-04020a: 4, 2, 10;
    --ch-indigo-050309: 5, 3, 9;
    --ch-indigo-06030e: 6, 3, 14;
    --ch-indigo-080512: 8, 5, 18;
    --ch-indigo-0b0910: 11, 9, 16;
    --ch-indigo-0d0b12: 13, 11, 18;
    --ch-indigo-0f0a1a: 15, 10, 26;
    --ch-indigo-130f1e: 19, 15, 30;
    --ch-indigo-261842: 38, 24, 66;
    --ch-indigo-b9a3ff: 185, 163, 255;
    --ch-indigo-f4f4f8: 244, 244, 248;
    --ch-indigo-f5f3f9: 245, 243, 249;
    --ch-orange-3c2c14: 60, 44, 20;
    --ch-orange-422006: 66, 32, 6;
    --ch-orange-f5c451: 245, 196, 81;
    --ch-red-f8a4a4: 248, 164, 164;
    --ch-teal-102a28: 16, 42, 40;
    --ch-violet-4c1d95: 76, 29, 149;
    --c-blue-15162a: #15162a;
    --c-indigo-0b0910: #0b0910;
    --c-indigo-0d0b12: #0d0b12;
    --c-indigo-100c1a: #100c1a;
    --c-indigo-14101f: #14101f;
    --c-indigo-14111d: #14111d;
    --c-indigo-141220: #141220;
    --c-indigo-16121f: #16121f;
    --c-indigo-191426: #191426;
    --c-indigo-191428: #191428;
    --c-indigo-3f2a78: #3f2a78;
    --c-indigo-4c31a5: #4c31a5;
    --c-indigo-6d4bd8: #6d4bd8;
    --c-indigo-8b4ff5: #8b4ff5;
    --c-indigo-8b6ff0: #8b6ff0;
    --c-indigo-b9a3ff: #b9a3ff;
    --c-indigo-d6c8ff: #d6c8ff;
    --c-indigo-ded2ff: #ded2ff;
    --c-indigo-ded7ff: #ded7ff;
    --c-indigo-e9e0ff: #e9e0ff;
    --c-indigo-ece8ff: #ece8ff;
    --c-indigo-f0ebff: #f0ebff;
    --c-indigo-f3efff: #f3efff;
    --c-indigo-f3f0ff: #f3f0ff;
    --c-indigo-f4f4f8: #f4f4f8;
    --c-indigo-f5f3f9: #f5f3f9;
    --c-orange-1a1206: #1a1206;
    --c-orange-b8913a: #b8913a;
    --c-orange-dcb04a: #dcb04a;
    --c-orange-f0dcae: #f0dcae;
    --c-orange-f5c451: #f5c451;
    --c-red-2a0b0b: #2a0b0b;
    --c-red-b91c1c: #b91c1c;
    --c-red-f43f5e: #f43f5e;
    --c-red-f7cccc: #f7cccc;
    --c-red-f8a4a4: #f8a4a4;
    --c-teal-7fe3d4: #7fe3d4;
    --c-teal-a5efe4: #a5efe4;
    --c-violet-020103: #020103;
    --c-violet-4c1d95: #4c1d95;
}

html[data-theme="light"] {
    --ch-amber-fde68a: 198, 138, 18;
    --ch-blue-06080f: 219, 228, 245;
    --ch-blue-0f1119: 215, 224, 242;
    --ch-blue-10131b: 214, 223, 241;
    --ch-blue-13161f: 213, 222, 240;
    --ch-blue-151821: 213, 221, 239;
    --ch-blue-171a24: 212, 221, 238;
    --ch-blue-3b82f6: 29, 78, 216;
    --ch-blue-60a5fa: 29, 78, 216;
    --ch-blue-64748b: 36, 87, 226;
    --ch-blue-93c5fd: 29, 78, 216;
    --ch-blue-94a3b8: 19, 65, 203;
    --ch-blue-cbd5e1: 5, 42, 182;
    --ch-blue-e2e8f0: 5, 42, 182;
    --ch-cyan-38bdf8: 3, 105, 161;
    --ch-cyan-7dd3fc: 3, 105, 161;
    --ch-emerald-059669: 4, 120, 87;
    --ch-emerald-10b981: 4, 120, 87;
    --ch-emerald-34d399: 4, 120, 87;
    --ch-emerald-6ee7b7: 4, 120, 87;
    --ch-emerald-a7f3d0: 11, 143, 104;
    --ch-indigo-030108: 228, 233, 250;
    --ch-indigo-060210: 226, 231, 248;
    --ch-indigo-080412: 225, 230, 246;
    --ch-indigo-0c0818: 223, 228, 244;
    --ch-indigo-0e091b: 222, 227, 244;
    --ch-indigo-0e0a1c: 222, 227, 243;
    --ch-indigo-120c22: 221, 226, 242;
    --ch-indigo-140d26: 220, 225, 241;
    --ch-indigo-18102b: 219, 224, 240;
    --ch-indigo-7c52e8: 50, 91, 225;
    --ch-indigo-8b5cf6: 40, 81, 217;
    --ch-indigo-a78bfa: 58, 100, 237;
    --ch-indigo-c4b5fd: 92, 128, 246;
    --ch-indigo-ddd6fe: 139, 165, 250;
    --ch-indigo-e2d6ff: 0, 42, 182;
    --ch-indigo-e9e2ff: 0, 42, 182;
    --ch-indigo-e9e4ff: 0, 42, 182;
    --ch-indigo-ede9fe: 181, 198, 253;
    --ch-indigo-f0eefa: 0, 42, 182;
    --ch-orange-f59e0b: 164, 105, 0;
    --ch-orange-fbbf24: 184, 124, 5;
    --ch-pink-f472b6: 190, 24, 93;
    --ch-red-1e0c0e: 238, 218, 215;
    --ch-red-ef4444: 185, 28, 28;
    --ch-red-f43f5e: 190, 18, 60;
    --ch-red-f87171: 185, 28, 28;
    --ch-red-fb7185: 170, 0, 11;
    --ch-red-fca5a5: 185, 28, 28;
    --ch-teal-14b8a6: 13, 148, 136;
    --ch-teal-2dd4bf: 13, 148, 136;
    --ch-teal-5eead4: 13, 148, 136;
    --ch-teal-99f6e4: 15, 118, 110;
    --ch-violet-0a0512: 224, 229, 246;
    --ch-violet-140e18: 221, 226, 242;
    --ch-violet-1e1022: 219, 224, 240;
    --ch-violet-6324c7: 67, 108, 241;
    --ch-violet-7c3aed: 40, 81, 217;
    --ch-violet-c084fc: 15, 59, 202;
    --c-amber-fcd34d: #b87c05;
    --c-amber-fde68a: #c68a12;
    --c-amber-fef3c7: #985e00;
    --c-blue-0d1017: #d8e0f2;
    --c-blue-10131b: #d6dff1;
    --c-blue-12151d: #d6dff0;
    --c-blue-141721: #d5deef;
    --c-blue-171a24: #d4ddee;
    --c-blue-1b1f2b: #d2dbed;
    --c-blue-212445: #cfd8ea;
    --c-blue-596577: #2a5ee9;
    --c-blue-60a5fa: #1d4ed8;
    --c-blue-7c899c: #1c4dd7;
    --c-blue-93c5fd: #1d4ed8;
    --c-blue-94a3b8: #1341cb;
    --c-blue-bfdbfe: #1e40af;
    --c-blue-cbd5e1: #052ab6;
    --c-blue-e2e8f0: #052ab6;
    --c-blue-e5e7eb: #052ab6;
    --c-cyan-1a2c35: #d0d9e0;
    --c-cyan-38bdf8: #0369a1;
    --c-cyan-4d809b: #106fa7;
    --c-cyan-7dd3fc: #0369a1;
    --c-cyan-a9dcf7: #004c82;
    --c-cyan-bae6fd: #075985;
    --c-cyan-bfe6fb: #004c82;
    --c-emerald-04231a: #d4ded9;
    --c-emerald-059669: #047857;
    --c-emerald-10b981: #047857;
    --c-emerald-34d399: #047857;
    --c-emerald-6ee7b7: #047857;
    --c-emerald-a7f3d0: #0b8f68;
    --c-emerald-ccfbf1: #005b3c;
    --c-green-22c55e: #047857;
    --c-green-4ade80: #047857;
    --c-green-86efac: #047857;
    --c-indigo-050309: #e3e8f8;
    --c-indigo-272137: #d6dbeb;
    --c-indigo-a78bfa: #3a64ed;
    --c-indigo-c4b5fd: #2144b6;
    --c-indigo-ddd0ff: #2144b6;
    --c-indigo-ddd6fe: #2851d9;
    --c-indigo-e9e4ff: #1d3995;
    --c-indigo-ede9fe: #1d3995;
    --c-indigo-f4f1ff: #002ab6;
    --c-indigo-f6f6f8: #002ab6;
    --c-indigo-f7f5ff: #002ab6;
    --c-orange-3a2922: #dcd5cb;
    --c-orange-422006: #ddd6cc;
    --c-orange-f59e0b: #a46900;
    --c-orange-fbbf24: #b87c05;
    --c-pink-f472b6: #be185d;
    --c-pink-f9a8d4: #be185d;
    --c-pink-fbcfe8: #9b0042;
    --c-red-2b0707: #edd9d6;
    --c-red-39231e: #e6d2cf;
    --c-red-dc2626: #b91c1c;
    --c-red-ef4444: #b91c1c;
    --c-red-f87171: #b91c1c;
    --c-red-fb7185: #aa000b;
    --c-red-fca5a5: #b91c1c;
    --c-red-fda4af: #be123c;
    --c-red-fecaca: #960000;
    --c-teal-14b8a6: #0d9488;
    --c-teal-2dd4bf: #0d9488;
    --c-teal-5eead4: #0d9488;
    --c-teal-99f6e4: #0f766e;
    --c-teal-a5f3fc: #00766b;
    --c-violet-251342: #d8dded;
    --c-violet-6d28d9: #2144b6;
    --c-violet-75509b: #3c65ea;
    --c-violet-7c3aed: #2851d9;
    --c-violet-a855f7: #2750d8;
    --c-violet-c084fc: #0f3bca;
    --c-violet-d2b6f9: #2851d9;
    --c-violet-ddc6fb: #002ab6;
    --ch-blue-14203a: 210, 218, 236;
    --ch-blue-1e2028: 210, 219, 236;
    --ch-indigo-04020a: 227, 232, 249;
    --ch-indigo-050309: 227, 232, 248;
    --ch-indigo-06030e: 226, 231, 247;
    --ch-indigo-080512: 225, 230, 246;
    --ch-indigo-0b0910: 223, 228, 245;
    --ch-indigo-0d0b12: 223, 228, 244;
    --ch-indigo-0f0a1a: 222, 227, 243;
    --ch-indigo-130f1e: 220, 225, 242;
    --ch-indigo-261842: 215, 220, 236;
    --ch-indigo-b9a3ff: 0, 46, 195;
    --ch-indigo-f4f4f8: 0, 42, 182;
    --ch-indigo-f5f3f9: 0, 42, 182;
    --ch-orange-3c2c14: 185, 124, 6;
    --ch-orange-422006: 221, 214, 204;
    --ch-orange-f5c451: 154, 96, 0;
    --ch-red-f8a4a4: 158, 0, 0;
    --ch-teal-102a28: 208, 220, 218;
    --ch-violet-4c1d95: 29, 57, 149;
    --c-blue-15162a: #d5ddef;
    --c-indigo-0b0910: #dfe4f5;
    --c-indigo-0d0b12: #dfe4f4;
    --c-indigo-100c1a: #dee3f3;
    --c-indigo-14101f: #dce1f1;
    --c-indigo-14111d: #dce1f1;
    --c-indigo-141220: #dce1f1;
    --c-indigo-16121f: #dbe0f1;
    --c-indigo-191426: #dadff0;
    --c-indigo-191428: #dadfef;
    --c-indigo-3f2a78: #537bff;
    --c-indigo-4c31a5: #4a72f7;
    --c-indigo-6d4bd8: #3861e6;
    --c-indigo-8b4ff5: #2e57de;
    --c-indigo-8b6ff0: #264fd7;
    --c-indigo-b9a3ff: #002ec3;
    --c-indigo-d6c8ff: #002ab6;
    --c-indigo-ded2ff: #002ab6;
    --c-indigo-ded7ff: #002ab6;
    --c-indigo-e9e0ff: #002ab6;
    --c-indigo-ece8ff: #002ab6;
    --c-indigo-f0ebff: #002ab6;
    --c-indigo-f3efff: #002ab6;
    --c-indigo-f3f0ff: #002ab6;
    --c-indigo-f4f4f8: #002ab6;
    --c-indigo-f5f3f9: #002ab6;
    --c-orange-1a1206: #e5ded4;
    --c-orange-b8913a: #b07500;
    --c-orange-dcb04a: #a36800;
    --c-orange-f0dcae: #985e00;
    --c-orange-f5c451: #9a6000;
    --c-red-2a0b0b: #edd8d6;
    --c-red-b91c1c: #ca312c;
    --c-red-f43f5e: #be123c;
    --c-red-f7cccc: #960000;
    --c-red-f8a4a4: #9e0000;
    --c-teal-7fe3d4: #00776c;
    --c-teal-a5efe4: #00766b;
    --c-violet-020103: #e5eafb;
    --c-violet-4c1d95: #1d3995;
}

/* ── The shared components, in light ───────────────────────────────────────
   public/app.css is excluded from the theme codemod because it DECLARES the
   tokens — running the codemod over it rewrites the definitions into references
   to themselves. The cost is that the component rules living in this same file
   (the four buttons, the checkbox) keep their dark literals, so their light
   values are declared here by hand.

   These are `html[data-theme="light"]` (0,1,1) against a bare class (0,1,0), so
   they win on specificity wherever they land in the file. */

/* The primary button goes OPAQUE in light. Its dark gradient is violet at .92/.78
   alpha, which over a pale card flattens to roughly #a97cf5 at the light end —
   white text on that is 2.4:1. Solid violet keeps the same shape at 5.9:1. */
/* Flat, not a gradient, and reading the token rather than two literals.
   Three things were wrong. The literals predate the accent's re-derivation, so the app's most-seen
   control was the last place still painting the old louder violet. The gradient is a dark-theme
   device — on near-black a two-stop fill reads as a lit surface, while on white it reads as a
   printing error — and the accompanying 12px violet bloom is a glow, which light has no token for
   and which the plan caps far below this. A solid fill, a real shadow, and the highlight from the
   channel that stays white in both themes. */
html[data-theme="light"] .btn-astra {
    background: var(--accent-solid);
    box-shadow: var(--elevation-1), inset 0 1px 0 rgba(var(--hl-rgb), 0.18);
}
html[data-theme="light"] .btn-astra:active:not(:disabled) {
    background: var(--accent-solid-hover);
}

/* Hover goes DARKER in light — there is no headroom above a solid fill on white, which is the
   inverse of dark's lighten-on-hover. */
html[data-theme="light"] .btn-astra:hover:not(:disabled) {
    background: var(--accent-solid-hover);
    box-shadow: var(--elevation-2), inset 0 1px 0 rgba(var(--hl-rgb), 0.22);
}

/* Secondary. Dark fills it with translucent indigo and writes in #e0e7ff; that ink
   is 1.1:1 here, so the pair swaps — pale indigo ground, deep indigo ink. */
/* The secondary button goes NEUTRAL in light, and this is a deliberate departure from dark.
   In dark it is indigo-tinted glass, which reads as "secondary action in the same colour family" —
   correct there, because on near-black a tint is a lift and the primary is unmistakably louder.
   In light the same construction is a pale indigo pill next to a deep violet primary, and the two
   compete: on /work-queue it was the loudest violet on the page while being the *secondary* action.
   A secondary is a surface, a border and text; the colour belongs to the primary. */
html[data-theme="light"] .btn-astra-glass {
    background: var(--surface-3);
    color: var(--text);
    border-color: var(--border-default);
    box-shadow: var(--elevation-1);
}
html[data-theme="light"] .btn-astra-glass:hover:not(:disabled) {
    background: var(--surface-3-hover);
    border-color: var(--border-strong);
}

html[data-theme="light"] .btn-astra-glass:hover:not(:disabled) {
    background: linear-gradient(135deg, rgba(99, 102, 241, 0.2), rgba(79, 70, 229, 0.14));
    border-color: rgba(79, 70, 229, 0.5);
}

html[data-theme="light"] .btn-astra-neutral {
    background: rgba(36, 27, 61, 0.05);
    border-color: rgba(36, 27, 61, 0.14);
    color: var(--ink-muted);
}

html[data-theme="light"] .btn-astra-neutral:hover:not(:disabled) {
    background: rgba(36, 27, 61, 0.09);
    color: var(--ink-dim);
}

html[data-theme="light"] .btn-astra-danger {
    background: rgba(185, 28, 28, 0.09);
    border-color: rgba(185, 28, 28, 0.3);
    color: #b91c1c;
}

html[data-theme="light"] .btn-astra-danger:hover:not(:disabled) {
    background: rgba(185, 28, 28, 0.16);
}

/* An unchecked box is a white square on a white card in light — it has to tint the
   other way to have an edge at all. Checked keeps the violet fill, which reads on
   both grounds. */
html[data-theme="light"] input[type="checkbox"] {
    background-color: rgba(36, 27, 61, 0.04);
    border-color: rgba(36, 27, 61, 0.28);
}

html[data-theme="light"] input[type="checkbox"]:hover:not(:disabled):not(:checked) {
    border-color: rgba(109, 40, 217, 0.6);
    background-color: rgba(124, 58, 237, 0.1);
}

html[data-theme="light"] input[type="checkbox"]:checked {
    border-color: rgba(109, 40, 217, 0.85);
}

/* The eyebrow badge's light block used to live here: a violet wash, a violet border and
   `inset 0 1px 0 rgba(255,255,255,0.6)`. theme-light/_base.css §1 supersedes the first three at
   equal specificity but could not reach the fourth — a superseding rule only overrides the
   properties it names — so an explicitly banned lit top edge survived on every page eyebrow in the
   app. Fully superseded now (the eyebrow is bare text there, and the shared badge rule writes
   `box-shadow: none`), so the block is gone rather than patched. */

/* Added by a later codemod run over the remaining files. */
:root {
    --c-blue-3b82f6: #3b82f6;
    --c-indigo-8b5cf6: #8b5cf6;
    --c-indigo-f4f2ff: #f4f2ff;
    --c-indigo-f5f3ff: #f5f3ff;
    --ch-amber-fcd34d: 252, 211, 77;
    --ch-indigo-0a0616: 10, 6, 22;
    --ch-indigo-18102c: 24, 16, 44;
    --ch-red-dc2626: 220, 38, 38;
    --ch-teal-a5f3fc: 165, 243, 252;
    --ch-violet-6d28d9: 109, 40, 217;
}

html[data-theme="light"] {
    --c-blue-3b82f6: #1d4ed8;
    --c-indigo-8b5cf6: #2851d9;
    --c-indigo-f4f2ff: #002ab6;
    --c-indigo-f5f3ff: #002ab6;
    --ch-amber-fcd34d: 184, 124, 5;
    --ch-indigo-0a0616: 224, 229, 245;
    --ch-indigo-18102c: 219, 224, 240;
    --ch-red-dc2626: 185, 28, 28;
    --ch-teal-a5f3fc: 0, 118, 107;
    --ch-violet-6d28d9: 33, 68, 182;
}

/* Added when the usePageStyle template-literal stylesheets were converted. */
:root {
    --c-blue-0e1017: #0e1017;
    --c-blue-171a26: #171a26;
    --c-blue-1c2030: #1c2030;
    --ch-blue-0e1017: 14, 16, 23;
}

html[data-theme="light"] {
    --c-blue-0e1017: #d7e0f2;
    --c-blue-171a26: #d4ddee;
    --c-blue-1c2030: #d2daec;
    --ch-blue-0e1017: 215, 224, 242;
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   THE SEMANTIC TIER
   ───────────────────────────────────────────────────────────────────────────────────────────────
   Added 2026-08-09 by the light-theme programme (docs/design/light-theme-plan.md, Phase 3).
   Derivation: docs/design/research/token-tier.md, phase4-candidates.md (candidate C, chosen by the
   shop owner), phase5-ink-accent-status.md.

   WHY THIS EXISTS
   Colour lives at two tiers today: raw palette steps (--c-*, --ch-*) and a handful of role names.
   Page CSS reaches past both and composes its own surfaces out of a CHANNEL variable —
   `rgba(var(--sf-rgb), 0.07)` — 3 776 times. That is not a colour, it is a compositing instruction,
   and compositing is not symmetric under a ground inversion: 7% white over near-black lifts a
   surface, 7% ink over near-white is nothing, and the two grounds signal elevation by different
   mechanisms entirely — dark raises with a lighter tint because shadows have too little contrast on
   near-black; light raises with a shadow and a hairline. No single alpha expresses both. That is the
   root cause of the light theme this tier replaces.

   THE RULE
   A component names a ROLE. Only this tier knows which theme is active. Nothing below it may.

   DARK IS FROZEN
   Every dark value below resolves byte-identically to what the app already paints, verified by
   tests/theme/compare.mjs against an immutable baseline. Where a dark value is genuinely new, it has
   no consumer yet, so it cannot move anything.

   NOTHING CONSUMES THESE YET. Declaring the tier is Phase 3; repointing ~939 use sites onto it is
   Phase 6. That separation is what makes this commit provably inert in dark.
   ═══════════════════════════════════════════════════════════════════════════════════════════════ */

:root {
    /* ── Tier 1 references. The only raw values here. Never referenced by a component. ───────── */
    --ref-shadow-base: 0, 0, 0;          /* light flips this to the violet-black 23, 20, 40 */
    /* ── The highlight channel, and why it is separate from --sf-rgb ──────────────────────────
       169 sites across 44 files paint `inset 0 1px 0 rgba(var(--sf-rgb), α)` — the glass top-edge
       catch. `--sf-rgb` is the SURFACE channel and correctly inverts to the ink colour in light, so
       every one of those renders as a **dark 1px scar across the top of every panel**: the exact
       inverse of a highlight, and a large part of why light cards read as flat rectangles rather
       than raised material.
       A highlight is not a surface. Light catches the top edge of a pane from above in *both*
       themes, so this channel stays white on both sides. Dark is byte-identical (255,255,255 either
       way); light stops scarring. The fix is one channel plus a mechanical rename at the use sites,
       which is why it belongs here rather than in 169 page decisions. */
    --hl-rgb: 255, 255, 255;
    --ref-accent: #7c3aed;
    --ref-accent-deep: #6d28d9;

    /* ── Ground and surface ladder ────────────────────────────────────────────────────────────
       The ladder goes BELOW the ground: light gains recession, which dark barely has, and recession
       is the replacement for every place dark used a *darker* glass fill. */
    --surface-sunken: rgba(255, 255, 255, 0.02);
    --surface-1: rgba(255, 255, 255, 0.04);
    --surface-2: rgba(255, 255, 255, 0.06);
    --surface-3: rgba(255, 255, 255, 0.08);
    --surface-4: rgba(255, 255, 255, 0.10);
    --surface-5: rgba(255, 255, 255, 0.14);

    /* ── Surface states. Applied as the element's own background — never as `opacity`, which
       composites its border and shadow along with it, and never as a second stacked layer, because
       stacked translucency compounds in OPPOSITE directions per theme. ──────────────────────── */
    --surface-hover: rgba(255, 255, 255, 0.05);
    --surface-active: rgba(255, 255, 255, 0.09);
    /* Hover rungs, one per surface step. Three separate Phase 6 bundles hit the same wall
       independently: a hover declared at α .06/.08/.10 maps by alpha onto --surface-2/3/4, which are
       OPAQUE white in light — so the hover goes *lighter* on a near-white card and simply stops
       existing. The alpha cannot be snapped to --surface-hover's .05 without moving dark, so each
       rung gets its own hover twin: dark keeps the site's exact alpha, light goes darker as it must.
       A hover that works in one theme and vanishes in the other is not a hover. */
    --surface-2-hover: rgba(255, 255, 255, 0.06);
    --surface-3-hover: rgba(255, 255, 255, 0.08);
    --surface-4-hover: rgba(255, 255, 255, 0.10);

    /* ── Alpha-pinned rungs ───────────────────────────────────────────────────────────────────
       Dark was authored off-ladder: ~150 panel fills sit at .03/.05/.07/.12 and similar, none of
       which match a rung. Snapping them would move dark by up to 5 RGB units, and dark may not move
       — so each common off-ladder alpha gets a rung of its own that pins the dark value exactly and
       authors a real light value.

       These are a debt, not a design: they exist only because the two themes must agree today. When
       the frozen-dark list is revisited they collapse into --surface-1..5 and disappear. Do not add
       new ones without an existing dark alpha forcing it. */
    --surface-a03: rgba(255, 255, 255, 0.03);
    --surface-a05: rgba(255, 255, 255, 0.05);
    --surface-a07: rgba(255, 255, 255, 0.07);
    --surface-a12: rgba(255, 255, 255, 0.12);

    /* ── Recesses ─────────────────────────────────────────────────────────────────────────────
       The rungs above are for a RAISED panel, and their light values are near-white because that is
       what "raised" means on a tinted ground. Four Phase 7 agents independently refused to convert
       nested wells to them, all reaching the same conclusion: a well tinted at 3% inside an opaque
       white card composites to a real recess today, and the rung would flatten it to nothing —
       there is no rung that goes *below* an opaque light card.

       They were right, and the refusals are why this family exists. In dark a recess and a raised
       panel are both "a bit of white over black" and one ladder serves both; in light they are
       opposite directions and need opposite tokens. Same alphas, pinned identically, so dark cannot
       tell the difference.

       --well-* are the deeper recesses: the sites painting `rgba(var(--ref-shadow-base), α)` as a
       SURFACE rather than a shadow — a settings segmented track, an error-page contact block, a
       ledger's active lane count. In dark that is black-on-black and nearly invisible; in light it
       is 12-28% of a violet-black over white, i.e. a mid-grey slab, and it is the single most
       repeated "why does this look unfinished" defect in the sweep. */
    --recess-a03: rgba(255, 255, 255, 0.03);
    --recess-a05: rgba(255, 255, 255, 0.05);
    --recess-a07: rgba(255, 255, 255, 0.07);
    --well-a12: rgba(0, 0, 0, 0.12);
    --well-a14: rgba(0, 0, 0, 0.14);
    --well-a18: rgba(0, 0, 0, 0.18);
    --well-a20: rgba(0, 0, 0, 0.20);
    --well-a22: rgba(0, 0, 0, 0.22);
    --well-a28: rgba(0, 0, 0, 0.28);

    /* The unlit half of a toggle. --control-knob is the LIT knob; the off knob was being painted
       with --ink-dim, which inverts to near-black in light and makes the default state of every
       toggle the darkest object on the page. Two agents asked for this token by name. */
    --control-knob-off: rgba(255, 255, 255, 0.72);

    /* Meter and progress tracks, pinned the same way. A track painted from the surface ladder turns
       white-on-white in light and stops being a track at all — the commonest single defect the
       Phase 6 sweep surfaced and could not fix without moving dark. */
    --control-track-08: rgba(255, 255, 255, 0.08);
    --control-track-10: rgba(255, 255, 255, 0.10);
    --surface-selected: rgba(124, 58, 237, 0.16);
    --surface-disabled: rgba(255, 255, 255, 0.03);

    /* ── Borders. Each pairs with a surface rung. In light that pairing is load-bearing: above
       --surface-3 there is no lightness headroom left, and the hairline plus the shadow are the only
       things separating three identical whites. ─────────────────────────────────────────────── */
    --border-subtle: rgba(255, 255, 255, 0.06);
    --border-default: rgba(255, 255, 255, 0.10);
    --border-strong: rgba(255, 255, 255, 0.18);
    --border-accent: rgba(167, 139, 250, 0.35);
    /* The top-edge light catch, and the fix for a 170-site defect: page CSS paints
       `inset 0 1px 0 rgba(var(--sf-rgb), α)`, whose channel flips to the ink colour in light and
       draws a dark scar across the top of every panel — the exact inverse of a highlight. */
    --border-highlight: rgba(255, 255, 255, 0.22);

    /* ── Ink. Light moves the BASE darker rather than pushing the bottom step under AA: the shipped
       light floor measures 4.22:1 in situ on the real card surface #e1def4, i.e. under AA on
       surfaces that carry a price. ──────────────────────────────────────────────────────────── */
    --text-display: #ffffff;
    --text-strong: rgba(255, 255, 255, 0.92);
    --text: #f1f1f1;
    --text-secondary: rgba(255, 255, 255, 0.72);
    --text-muted: rgba(255, 255, 255, 0.50);
    --text-faint: rgba(255, 255, 255, 0.38);
    /* Below the AA floor, named so it cannot be reached for by accident. Placeholders only. */
    --text-placeholder: rgba(255, 255, 255, 0.32);
    --text-on-accent: #ffffff;
    --text-inverse: #050309;

    /* ── Accent. Violet appears on the primary action, the focus ring, the selection and the logo.
       NOWHERE ELSE — every violet-tinted meter, heading and container border carried into light
       multiplies into a lavender field, because black absorbs violet and white does not. ─────── */
    --accent-solid: #7c3aed;
    --accent-solid-hover: #8b5cf6;       /* light INVERTS this: darker on hover, not lighter */
    --accent-soft: #a78bfa;
    --accent-text: #c4b5fd;
    --accent-tint: rgba(124, 58, 237, 0.12);
    --accent-tint-strong: rgba(124, 58, 237, 0.18);
    --accent-hairline: rgba(167, 139, 250, 0.35);
    /* The 3px leading-edge selection bar, mandatory companion to --surface-selected: a tint alone
       reads weaker in light than dark's UNSELECTED state, and a bar survives glare and CVD. */
    --accent-bar: #a78bfa;
    --focus-ring: #a78bfa;
    --focus-ring-rgb: 167, 139, 250;
    /* The channel control edges are drawn in. Dark lifts an edge with white; light seats it with
       navy. Same mechanism, opposite direction — which is why this is a channel and not a colour. */
    --edge-rgb: 255, 255, 255;
    /* Used whenever the ring lands on an accent-filled or glass surface — you cannot ring accent
       violet with accent violet (WCAG 2.2 SC 2.4.11). */
    --focus-ring-inner: #050309;
    --focus-halo: rgba(124, 58, 237, 0.30);
    /* There is deliberately NO --glow. Dark's bloom did three separable jobs and each gets its own
       substitute: focus -> --focus-ring, selection -> --accent-bar, emphasis -> --elevation-*. */

    /* ── Status. A fourth hue, for the neutral "notice / awaiting" meaning the blue-channel sites
       and the awaiting-* week events should have been consuming all along. ──────────────────── */
    --st-info: #60a5fa;
    --st-info-soft: rgba(96, 165, 250, 0.12);
    --st-info-line: rgba(96, 165, 250, 0.35);

    /* ── Scrims. A scrim's job is to RECEDE what is behind it. Today one family advances the page in
       violet and another advances it in blue; neither dims. These three are the whole fix. ───── */
    --scrim-soft: rgba(0, 0, 0, 0.40);
    --scrim: rgba(0, 0, 0, 0.55);
    --scrim-strong: rgba(0, 0, 0, 0.65);

    /* ── Elevation. Light shadow is a violet-black, never pure black: black over a coloured ground
       desaturates as well as darkens, which is the washed grey that reads as dirty. ──────────── */
    --elevation-0: none;
    --elevation-1: 0 1px 2px rgba(0, 0, 0, 0.35);
    --elevation-2: 0 4px 24px rgba(0, 0, 0, 0.4);
    --elevation-3: 0 12px 40px rgba(0, 0, 0, 0.45);
    --elevation-accent: 0 0 24px rgba(124, 58, 237, 0.18);
    --elevation-inset-top: inset 0 1px 0 rgba(255, 255, 255, 0.22);
    --elevation-inset-bottom: inset 0 -1px 0 rgba(255, 255, 255, 0.04);

    /* ── Weight, value-pinned ─────────────────────────────────────────────────────────────────
       1 420 font-weight declarations are literals shared by both themes, so "sweep 700 out of the
       light theme" is a dark regression by construction and 1 420 light-only overrides is not a
       real option. This tier is the prerequisite, exactly as --r-* and --t-* were.

       PINNED BY VALUE, NOT BY ROLE, AND ON PURPOSE. A codemod cannot know whether a given 600 is a
       card title or a column header, so the first pass is a pure rename: every literal becomes the
       rung of the same number. Dark is byte-identical, light is byte-identical, and the harness
       proves both — which is the strongest gate this programme ever gets, and it is only available
       while the values still match.

       These are deliberately declared ONCE here rather than per-theme. The light clamp
       (650/700/750/800 -> 600, and the semantic ladder below) lands in WP-8 as a handful of
       overrides in the light block. Until then the tier is pure indirection.

       650 and 750 are illusions: no system face carries them, so they snap to the nearest real
       weight and always have. They are pinned so the sweep can see them, not because they are rungs.

       The histogram this replaces:
         600 x 646   700 x 554   650 x 118   500 x 51   400 x 28   300 x 10   750 x 8   800 x 5 */
    --fw-300: 300;
    --fw-400: 400;
    --fw-500: 500;
    --fw-600: 600;
    --fw-650: 650;
    --fw-700: 700;
    --fw-750: 750;
    --fw-800: 800;

    /* ── Weight, semantic. The vocabulary the app moves to in WP-8. ───────────────────────────
       400 is the default: body, cells, inputs, helper text, and page titles — weight moves
       inversely to size in every system measured, so a large title does not also need to be heavy.
       The label rung is optical compensation at <=13px, NOT emphasis. The strong rung is ONE PER
       CONTAINER: one per row (the identity cell), one per card (its title), one per metric block
       (the number). Two strongs in one row means one of them is wrong. */
    --fw-body: 400;
    --fw-label: 600;
    --fw-strong: 700;

    /* ── Icon masks ───────────────────────────────────────────────────────────────────────────
       A raw OS emoji in the interface is drawn by the operating system, not by this design system:
       full-colour on Windows and Android, a different silhouette on macOS and iOS. One data row
       ends up rendered by the platform.

       A mask rather than an <img> or a background-image, because a mask takes `currentColor` — the
       glyph is then exactly the colour of the text beside it in both themes, and it inherits every
       ink change for free. Inline data URI, so there is no network request and nothing to fail on
       a shop tablet with no signal. Theme-invariant: this is a shape, not a colour. */
    --icon-lock: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Crect x='4' y='11' width='16' height='10' rx='2'/%3E%3Cpath d='M8 11V7a4 4 0 0 1 8 0v4'/%3E%3C/svg%3E");
    /* The edit affordance. `✎` (U+270E) is a text-presentation codepoint that Windows and Android
       still resolve through the emoji font, so the same character arrives as a flat glyph on one
       machine and a full-colour pencil on another — one control in the interface drawn by the
       operating system rather than by this design system. Masked through `currentColor`, it is
       exactly the colour of the label beside it on every platform.

       ONE declaration, not two, and that is deliberate: this is a SHAPE, and shape is
       theme-invariant. It sits on the base block beside --icon-lock and both themes inherit it. The
       colour never lives here at all — `currentColor` through the mask means the glyph takes whatever
       ink the element already has, in either theme, with nothing to keep in step. */
    --icon-pencil: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M12 20h9'/%3E%3Cpath d='M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z'/%3E%3C/svg%3E");

    /* ── Meter fills ──────────────────────────────────────────────────────────────────────────
       A progress bar is a meaningful graphical object, so it owes 3:1 under SC 1.4.11 — NOT the
       4.5:1 that text owes. Reusing the status text tokens for a bar therefore overshoots: light's
       --st-expected is #8a5500 at 5.72:1, which is correct for an amber WORD on a card and reads as
       olive-brown when it is 6px of solid fill.

       These are the bar-weight members of the same families: bright enough to read as amber, green
       and red at a glance across a workshop, and measured against the inset track they sit in
       rather than against white, because the track is what surrounds them.
         warn #c2740a 3.34:1 · ok #059669 3.47:1 · bad #dc2626 4.45:1
       Dark keeps the gradients its bars were authored with; these are consumed light-side only. */
    --meter-ok: #34d399;
    --meter-warn: #fbbf24;
    --meter-bad: #f87171;
    /* Two intermediate rungs for the invoice ageing ramp, which needs steps BETWEEN warn and bad.
       Declared rather than computed: `color-mix()` needs Chrome/WebView 111+, and an unsupported
       value invalidates the whole declaration — so on an older bay-tablet WebView the bar would
       render with no fill at all. That is the same reasoning that banned @layer here. */
    --meter-warn-55: rgba(251, 191, 36, 0.55);
    --meter-warn-80: rgba(251, 191, 36, 0.80);

    /* ── The donut ramp ───────────────────────────────────────────────────────────────────────
       Five literals lived in DonutChart.vue as var() fallbacks, so the ramp could not invert: step 1
       is the LARGEST slice and measured 1.05:1 in light — the biggest number in the chart was the
       arc nobody could see. Declared here so it flips like everything else.
       These are the dark values, transcribed from the component's fallbacks. */
    --chart-ramp-1: #e0d5ff;
    --chart-ramp-2: #b9a3ff;
    --chart-ramp-3: #9576ee;
    --chart-ramp-4: #7a5cd8;
    --chart-ramp-5: #6a55c0;

    /* The floor inside an input. Light draws a 1px inset so a white field on a white card still has
       a bottom edge; dark has no use for it — the field is already lighter than its surround, and an
       inset dark line there reads as a crack. Declared in both themes because it is consumed by the
       shared field recipe, and `none` is the honest dark value rather than an absence. */
    --field-floor: none;

    /* ── Glass, two grades. Dark works at 0.07 because it tints near-black; on white that is adding
       white to white — no edge, and no contrast floor for the pane's own text. --glass-bg above is
       left untouched and still holds dark's composed value; these are the names Phase 6 moves to. */
    --glass-fill: rgba(255, 255, 255, 0.07);
    --glass-fill-chrome: rgba(255, 255, 255, 0.07);
    --glass-edge: rgba(255, 255, 255, 0.14);
    --glass-edge-chrome: rgba(255, 255, 255, 0.14);
    --glass-blur-radius: 24px;
    --glass-saturate: 160%;
    /* Composed, so no page spells the filter itself. Deliberately NOT named --glass-blur: that name
       already holds the whole filter at 20 consumers, and re-pointing it at a length would emit
       `backdrop-filter: 24px` — invalid, silently dropped, glass gone with no error. */
    --glass-filter: blur(var(--glass-blur-radius)) saturate(var(--glass-saturate));

    /* ── Glass filter rungs, value-pinned ─────────────────────────────────────────────────────
       147 sites spell `backdrop-filter` as a literal, in 12 distinct values — only 8 of them match
       --glass-filter above. Snapping the other 139 to one canonical blur would move dark on every
       panel in the app, so each real value gets a rung that pins it exactly. The name records the
       blur radius and the saturation, so nobody "tidies" 28/170 into 24/160 later.

       These are declared ONCE here, not per-theme, and that is deliberate: the adoption pass must
       be provably zero-change in BOTH themes. Light sets every one of them to `none` in WP-4, in a
       single block, which is where 147 sites of glass actually die.

       -veil- rungs are the plain blurs with no saturation: scrims, sheet backdrops, skeleton
       placeholders. Same treatment, different job. */
    --glass-filter-32-180: blur(32px) saturate(180%);
    --glass-filter-32-160: blur(32px) saturate(160%);
    --glass-filter-28-170: blur(28px) saturate(170%);
    --glass-filter-28-160: blur(28px) saturate(160%);
    --glass-filter-26-150: blur(26px) saturate(150%);
    --glass-filter-20-160: blur(20px) saturate(160%);
    --glass-filter-10-140: blur(10px) saturate(140%);
    --glass-veil-3: blur(3px);
    --glass-veil-4: blur(4px);
    --glass-veil-6: blur(6px);
    --glass-veil-7: blur(7px);
    --glass-veil-8: blur(8px);
    --glass-veil-10: blur(10px);
    --glass-veil-12: blur(12px);
    --glass-veil-16: blur(16px);
    --glass-filter-chrome: blur(32px) saturate(160%);
    /* prefers-reduced-transparency and @supports-not fallback, for BOTH grades. */
    --glass-opaque: #0d0b12;

    /* ── Orbs. `screen` only ever lightens, so on a light ground the identity is mathematically
       erased — the orbs composite to roughly ΔE 1.6 from the page and simply vanish. `multiply` is
       screen's exact dual. This is the most consequential token in the block. ────────────────── */
    --orb-blend: screen;
    --orb-1: #7c3aed;
    --orb-2: #a78bfa;
    --orb-3: #4c1d95;
    --orb-4: #c084fc;
    /* Channel twins. A radial orb fades to its own hue at zero alpha, not to `transparent` — the two
       are equivalent in premultiplied gradient interpolation but not in every engine this app has to
       run on, and dark may not move on a maybe. */
    --orb-1-ch: 124, 58, 237;
    --orb-2-ch: 167, 139, 250;
    --orb-3-ch: 76, 29, 149;
    --orb-4-ch: 192, 132, 252;
    --orb-blur-scale: 1;

    /* ── Controls. The named home for the defect class where a toggle knob or a meter fill is
       painted with an INK token: ink legitimately inverts, so anything borrowing it as a fill
       inverts too and becomes the darkest object on a light page. ───────────────────────────── */
    --control-knob: #f1f1f1;
    --control-track: rgba(255, 255, 255, 0.14);

    /* ── Categorical identity. Six slots hashed from a booking key so a chip keeps its hue across
       re-renders. The dark column is DotPalette verbatim — lifted out of two duplicated JS array
       literals and 14 bare `r, g, b` triplets that every colour grep in the app walks past. Giving
       them a home is the only way a sweep can ever see them. ────────────────────────────────── */
    --cat-1: #8b5cf6;
    --cat-2: #2dd4bf;
    --cat-3: #f59e0b;
    --cat-4: #3b82f6;
    --cat-5: #34d399;
    --cat-6: #f43f5e;

    /* ── Week-calendar event colours ──────────────────────────────────────────────────────────
       These lived in JobsDesktop.css as twelve bare `r, g, b` triplets — token-SHAPED, so they read
       as already consolidated, but unresolvable by var() in a colour context, which is how every
       colour audit in this app walked straight past them. The consequence in light: every event on
       the shop's daily-driver page kept its dark colour, measured at 1.4:1 on a white card.

       They cannot be themed where they were: JobsDesktop.css is injected by usePageStyle, and an
       `html[data-theme]` rule inside an injected sheet can never match (CSS nesting compiles it to a
       descendant of the page scope, and `html` has no ancestors). So the declarations move here and
       the consumption sites are left untouched.

       EDGE is the 2px status rule and carries the meaning; WASH is the card tint behind the text.
       In dark both are bright, because the text on them is near-white. In light the edge goes DARK
       (it is the status mark) while the wash goes PALE (the text on it is near-black) — the two
       halves move in opposite directions, which is precisely what a single channel flip cannot do
       and why these were broken. */
    /* ── Freeze tokens ────────────────────────────────────────────────────────────────────────
       A freeze token exists to fix a LIGHT defect whose obvious fix would move dark.

       Both sites below are ink defects. Repointing them at the app text ladder is the semantically
       right answer and is what a later pass should do — but each would shift dark by 10–13 units per
       channel, and two of the Settings steps would change alpha as well, which is a deliberate
       re-shaping of dark's ink ramp rather than a no-op. That is not this programme's licence.

       So the dark value is pinned to exactly what ships today and only the light value is authored.
       Dark cannot move; light stops being broken. When the frozen dark list is revisited these
       collapse into --text-*.

       Settings: --set-ink resolves through --c-indigo-f5f3f9 to #5400b6 at HSL saturation 1.00 in
       light — a fully saturated violet serving as the body neutral of every Settings sheet, with two
       steps under AA at 3.44:1 and 2.78:1. app.css:222 names this exact trap in prose.

       Message drafts: the draft textarea's own ink measures 1.12:1 in light. The mechanic cannot
       read the message they are about to send. Highest-severity light defect in the audit. */
    --settings-ink: #f5f3f9;
    --settings-ink-2: rgba(245, 243, 249, 0.72);
    --settings-ink-3: rgba(245, 243, 249, 0.58);
    --settings-ink-4: rgba(245, 243, 249, 0.49);
    /* The UNSELECTED half of a segmented control, which is text a shop reads to know what its own
       settings say. On ink-3 it measured 4.40:1 at 12px in light — under AA, on the language row and
       on F-skatt. It gets its own token rather than a bump to ink-3, because ink-3 paints eight other
       things and only this one is small text on a raised well.
       DARK IS BYTE-IDENTICAL TO ink-3 ON PURPOSE: dark measured fine and must not move, so the value
       below is the same one it already resolved to, and dark cannot change by construction. */
    --settings-quiet-ink: rgba(245, 243, 249, 0.58);
    --draft-ink: #e7e5e4;

    --wev-booked-edge: 196, 181, 253;
    --wev-booked-wash: 139, 92, 246;
    --wev-progress-edge: 251, 191, 36;
    --wev-progress-wash: 217, 119, 6;
    --wev-pickup-edge: 96, 165, 250;
    --wev-pickup-wash: 37, 99, 235;
    --wev-checkin-edge: 203, 213, 225;
    --wev-checkin-wash: 100, 116, 139;
    --wev-finished-edge: 52, 211, 153;
    --wev-finished-wash: 16, 185, 129;
    --wev-queue-edge: 251, 191, 36;
    --wev-queue-wash: 245, 158, 11;
}

/* ── The light half. Candidate C ("Architectural"), chosen by the shop owner 2026-08-09: opaque
   panels, hierarchy carried by a decisive shadow ladder and paired hairlines, orbs strong but living
   in the gutters and behind the chrome, where they never sit under body text. ────────────────── */
html[data-theme="light"] {
    --ref-shadow-base: 20, 25, 40;
    --ref-accent: #3a64ed;
    --ref-accent-deep: #2851d9;

    /* C's ground is the deepest of the three candidates on purpose: it is what gives an opaque white
       panel something to be lighter THAN. Chroma stays at 0.008 — the current theme's failure is
       chroma at high lightness, where the same absolute chroma that reads as "tinted dark grey" at
       L=10% reads as unmistakable lavender at L=95%. */
    --surface-sunken: #e4e6eb;
    --surface-1: #fafbfd;
    --surface-2: #fcfcfe;
    --surface-3: #ffffff;
    --surface-4: #ffffff;
    --surface-5: #ffffff;

    --surface-hover: #e4e6eb;            /* light goes DARKER — there is no headroom above white */
    --surface-active: #e4e7ef;
    /* One step down from the surface each sits on, so a hover reads as a press rather than a wash. */
    --surface-2-hover: #f1f2f7;
    --surface-3-hover: #edeff4;
    --surface-4-hover: #e8eaf0;

    /* Alpha-pinned rungs. Light values are real surfaces, not tints: an opaque panel on a tinted
       ground is what candidate C is built on, and it is what a 3% white over near-black was always
       trying and failing to say on this side of the inversion. */
    --surface-a03: #fafbfd;
    --surface-a05: #fcfcfe;
    --surface-a07: #ffffff;
    --surface-a12: #f1f2f7;

    /* Recesses go DOWN from the card, which is the direction a light theme actually has spare. Each
       step sits below --surface-1 (#fafafd) rather than at or above it, so a well inside a white
       card still reads as a well. */
    --recess-a03: #f0f1f5;
    --recess-a05: #ecedf2;
    --recess-a07: #e8eaef;
    --well-a12: #e6e8ed;
    --well-a14: #e2e4ea;
    --well-a18: #dcdee6;
    --well-a20: #d9dbe3;
    --well-a22: #d5d8e0;
    --well-a28: #cdd0da;

    /* Visible against a pale track without becoming the loudest thing on the page. */
    --control-knob-off: rgba(20, 25, 40, 0.38);

    /* Bar-weight status. See the dark block for why a meter does not reuse the status TEXT tokens:
       a bar owes 3:1, not 4.5:1, so the text values overshoot and read as olive-brown, washed pink
       and bottle green when laid down as 6px of solid fill. Measured on the inset track (#f3f6f8),
       which is what actually surrounds them: 3.34 / 3.47 / 4.45. */
    --meter-warn: #c2410c;
    --meter-ok: #059669;
    --meter-bad: #dc2626;
    /* The ageing ramp's middle rungs, composited over white rather than left as alphas so the bar
       reads the same on a card and on the canvas. See the dark block for why these are declared
       instead of mixed at runtime. */
    --meter-warn-55: #dd9779;
    --meter-warn-80: #ce673d;

    /* The donut ramp, inverted. One hue (OKLCH 293°, zero rotation) with lightness and chroma walked
       so the ORDER survives the ground flip — step 1 stays the most prominent arc instead of becoming
       the palest. Every step ≥3:1 on the worst light card: 11.72 · 8.65 · 6.00 · 4.25 · 3.00.
       Values from docs/design/research/private-tokens-and-fallbacks.md §2.4. */
    --chart-ramp-1: #300b65;
    --chart-ramp-2: #491993;
    --chart-ramp-3: #632dbf;
    --chart-ramp-4: #7d3cee;
    --chart-ramp-5: #8e66f1;

    /* 2% ink, and it is deliberately almost nothing: enough that a white field on a white card has a
       floor, not enough to read as a shadow. It was spelled as a literal at five sites across three
       cluster files before this token existed. */
    --field-floor: inset 0 1px 2px rgba(0, 0, 0, 0.02);

    /* Restated, not inherited. This is the one channel that deliberately does NOT flip: light
       catches the top edge of a pane from above in both themes, so a highlight stays white on both
       grounds. The parity gate cannot tell a deliberate share from a forgotten one — and a forgotten
       one is the single commonest way a light theme silently keeps a dark value — so it is declared
       on both sides and the reason is written down. */
    --hl-rgb: 255, 255, 255;

    /* A track has to be visibly recessed from the surface it sits in — going lighter is not
       available, so it goes darker. */
    --control-track-08: rgba(20, 25, 40, 0.12);
    --control-track-10: rgba(20, 25, 40, 0.14);
    /* Quieter than the first pass. The 3px --accent-bar is what carries selection — it survives
       glare and colour-blindness where a wash does not — so the wash only has to say "this row, not
       that one", not shout it. */
    --surface-selected: rgba(47, 79, 208, 0.07);
    --surface-disabled: rgba(20, 25, 40, 0.04);

    --border-subtle: rgba(20, 25, 40, 0.08);
    --border-default: rgba(20, 25, 40, 0.13);
    --border-strong: rgba(20, 25, 40, 0.22);
    --border-accent: rgba(47, 79, 208, 0.24);
    --border-highlight: rgba(255, 255, 255, 0.95);

    /* Base rgb(15, 12, 28), not the shipped rgb(36, 27, 61). The span is 0.92 -> 0.60 = 0.32 against
       today's 0.19, which is why nothing recedes on the current light pages — and it is bought by
       moving the TOP down, not by pushing the bottom under AA. */
    --text-display: rgba(12, 16, 28, 1);
    --text-strong: rgba(12, 16, 28, 0.96);
    --text: rgba(12, 16, 28, 0.92);
    --text-secondary: rgba(12, 16, 28, 0.78);
    --text-muted: rgba(12, 16, 28, 0.66);
    --text-faint: rgba(12, 16, 28, 0.60);
    --text-placeholder: rgba(12, 16, 28, 0.42);
    --text-on-accent: #ffffff;
    --text-inverse: #eff0f5;

    /* ── Accent, re-derived 2026-08-10 ────────────────────────────────────────────────────────
       The first pass used `#6d28d9` — Material's tone rule applied to the brand violet, correct on
       contrast (7.10:1) and wrong to the eye. Two reasons, and they compound:

       1. THE SAME HUE READS DIFFERENTLY PER GROUND. On near-black a saturated pixel is the only
          light source in its neighbourhood, so the eye reads it as EMISSION — glow, depth, premium.
          On near-white the same pixel is darker than its surround, so it reads as PIGMENT, and
          pigment at high chroma reads as plastic. That is the whole distance between "premium on
          black" and "default purple on white", and it is a property of the ground, not of the value.
       2. CHROMA BUDGET SCALES INVERSELY WITH THE LIGHTNESS OF THE SURFACE IT SITS ON. The correction
          the colour research prescribes is to REDUCE CHROMA, not to lighten — lightening is what
          produced the lavender wash this whole programme exists to remove.

       So light's accent keeps the hue and spends ~30% less chroma: deeper, quieter, and it stops
       shouting on a white card. 6.6:1 on the light ground, comfortably past AA.

       The other half of the fix is SCOPE, below and throughout: violet earns the primary action, the
       focus ring, the selection and the logo. Nothing else. An accent used for status, for meters,
       for chips and for the commonest data value on the busiest page is not an accent — it is the
       page's colour, and it is why light read as a lavender field even where nothing was broken. */
    /* ── The light theme's accent is BLUE, and dark's stays violet. Deliberate divergence. ──────
       The two grounds do not treat a saturated hue the same way. On near-black a violet pixel is the
       only light source in its neighbourhood, so the eye reads it as emission — that is why the dark
       theme's violet reads as lit and expensive. On near-white the same pixel is darker than its
       surround, so it reads as pigment, and violet-as-pigment reads as plastic no matter how well it
       is tuned. Reducing its chroma helped and did not solve it.
       So light takes a deep indigo-blue: the hue that carries "trustworthy instrument" on paper, and
       the convention every billing and finance surface already speaks. 7.5:1 on the light ground.
       Two collisions came with it and both are handled below: --st-info moved out of blue into teal,
       and the pickup week-event followed it, because an action and a state must never be the same
       colour family. */
    --accent-solid: #2f4fd0;
    --accent-solid-hover: #2540ad;
    --accent-soft: #2f4fd0;
    --accent-text: #2a48be;
    /* Tints halved again and pulled toward neutral. A tint at light-theme alpha is decoration; the
       leading bar is what actually carries selection (see --accent-bar), so the wash can be quiet. */
    --accent-tint: rgba(47, 79, 208, 0.045);
    --accent-tint-strong: rgba(47, 79, 208, 0.08);
    --accent-hairline: rgba(47, 79, 208, 0.18);
    --accent-bar: #2f4fd0;
    /* cyan-500, MEASURED. Stripe focuses in CYAN and interacts in BLUE — two different hues on
       purpose, so a focused primary does not read as merely more primary, and one ring works on a
       red, a green and a blue control alike. */
    --focus-ring: #067ab8;
    --focus-ring-rgb: 6, 122, 184;
    --edge-rgb: 42, 47, 69;   /* gray-800: control edges are drawn in navy, never pure black */
    --focus-ring-inner: #ffffff;
    --focus-halo: rgba(47, 79, 208, 0.18);

    --st-info: #0e7490;
    --st-info-soft: rgba(14, 116, 144, 0.09);
    --st-info-line: rgba(14, 116, 144, 0.30);

    --scrim-soft: rgba(20, 25, 40, 0.32);
    --scrim: rgba(20, 25, 40, 0.42);
    --scrim-strong: rgba(20, 25, 40, 0.55);

    /* Top of the documented 10-15% band. C's rungs are what let the ladder read at a glance: in the
       desaturated check its steps separate (ΔY 13.6 / 18.4 / 24.4) where the softer candidates
       cluster and their cards dissolve into the ground along the top edge. */
    /* ── WP-4: elevation goes border-first, and the mid tier is DELETED ───────────────────────
       Level 2 was the shadow on every content card. It is now `none`, and the keyline does the
       separating. Five independent systems converge on this and it is the clearest single marker
       of a Stripe/Linear-grade light UI.

       Level 1 survives as CONTACT — 3px blur, 6% ink. It is what a hover is allowed to be.
       Level 3 survives for things that genuinely float: modals, popovers, overlays. Note it is
       navy PLUS black, two layers; a single-layer rgba(15,23,42,0.03) is below the visible
       threshold and renders as literally nothing.

       --elevation-accent is `none`. Light has no glow token: emphasis is border, weight and ink.
       Both inset rungs are `none`. Stripe uses a border, not a lit edge — and in light the inset
       rungs were painting a dark scar along the top of every panel, which is a highlight run
       backwards.

       THE TRAP THIS CREATES, named because nothing errors when it bites: a surface with a fill but
       no border now has NO BOUNDARY AT ALL. In dark it separated as a translucent tint over
       near-black; here it is white on off-white and the element is simply gone. Every surface that
       carried an elevation must carry a border. That is WP-6, and D6 finds them mechanically. */
    --elevation-0: none;
    --elevation-1: 0 3px 6px rgba(23, 23, 23, 0.06);
    /* ── C0: level 2 comes BACK, as contact rather than lift ──────────────────────────────────
       It was deleted on the finding that Stripe, Linear, Carbon, Polaris and Notion all separate
       with a border and give content cards no shadow. That finding is real — for ROWS inside a
       table, where a shadow per row is the card-stack look this rebuild set out to kill.

       It was over-applied to every surface. Stripe's own dashboard CARDS do carry a shadow; it is
       simply so slight that it reads as contact with the page rather than as float. Deleting it
       left the card with exactly one separation cue, and §--keyline explains what that cost.

       Two layers at 5% and 4%, 2px and 3px blur: below the threshold where anything looks lifted,
       above the threshold where the eye stops registering an edge. Rows still take none — that is
       what --elevation-0 and a keyline divider are for. */
    /* Softer and deeper than a contact shadow. The brief asked for depth rather than tight borders,
       and it is the right call for light: a hairline box around every object is what made the theme
       read as wireframe. A card should sit ON the page, not be drawn onto it — so the near layer is
       a 3px seat and the far layer a wide, very low-opacity spread that reads as air beneath. */
    /* Three layers, in Stripe's own order: a hairline RING the colour of the canvas, then a tight
       black key shadow, then a wide blue-violet ambient one. The ambient tint is the detail that
       matters — rgba(60,66,87,…) shares the hue of the page, so the shadow reads as the card
       displacing the surface it sits on rather than as grey smoke under a sticker. Drawn entirely in
       box-shadow, so the card carries no border and takes no layout box for its edge. */
    --elevation-2: 0 0 0 1px rgba(227, 232, 238, 1), 0 1px 2px rgba(0, 0, 0, 0.06), 0 4px 12px rgba(60, 66, 87, 0.08);
    --elevation-3: 0 30px 45px -30px rgba(50, 50, 93, 0.25), 0 18px 36px -18px rgba(0, 0, 0, 0.1);
    --elevation-accent: none;
    --elevation-inset-top: none;
    --elevation-inset-bottom: none;

    /* Grade B is OPAQUE in C, and that is a measured decision rather than a stylistic one: a 0.86
       white fill composites to #fefefe over this ground — 2.5 luminance points, 0.9 after a card
       tint — so the pane is indistinguishable from an opaque one while still paying the full
       backdrop-filter cost on a shop tablet. Glass is chrome only. */
    --glass-fill: var(--surface);
    --glass-fill-chrome: var(--surface);
    --glass-edge: var(--keyline);
    --glass-edge-chrome: var(--keyline-strong);

    /* ── WP-4: all 15 pinned filter rungs go to none — this is where the glass actually dies ──
       152 sites route through these. Setting only --glass-filter would turn off 8 of them and
       leave 144 blurred, which is precisely how a rebuild ships looking half-finished. The audit
       caught that; every rung declared in :root needs a light counterpart here.

       Removing the blur is not a cost. A backdrop-filter forces a full-surface repaint on every
       scroll frame, and the bay tablet pays for it forty times a shift to sample a ground that,
       in light, is a flat off-white with nothing to refract. */
    --glass-filter: none;
    --glass-filter-32-180: none;
    --glass-filter-32-160: none;
    --glass-filter-28-170: none;
    --glass-filter-28-160: none;
    --glass-filter-26-150: none;
    --glass-filter-20-160: none;
    --glass-filter-10-140: none;
    --glass-veil-3: none;
    --glass-veil-4: none;
    --glass-veil-6: none;
    --glass-veil-7: none;
    --glass-veil-8: none;
    --glass-veil-10: none;
    --glass-veil-12: none;
    --glass-veil-16: none;
    --glass-blur: none;
    --glass-blur-radius: 16px;
    --glass-saturate: 120%;
    /* Restated rather than inherited. The expression is identical to dark's and resolves correctly
       from the two tokens above without this line — but token-parity.mjs reports any colour-bearing
       token declared in one theme and not the other as a leak, and it is right to: that asymmetry is
       the single most common way a light theme silently keeps a dark value. Declaring it in both is
       cheaper than teaching the checker an exception, and it documents that the sharing is
       deliberate. */
    --glass-filter: blur(var(--glass-blur-radius)) saturate(var(--glass-saturate));
    --glass-filter-chrome: blur(16px) saturate(120%);
    --glass-opaque: #ffffff;

    --orb-blend: multiply;
    --orb-1: #eceff8;
    --orb-2: #ecf0f9;
    --orb-3: #e9edf8;
    --orb-4: #f0f2fa;
    --orb-1-ch: 236, 239, 248;
    --orb-2-ch: 236, 240, 249;
    --orb-3-ch: 233, 237, 248;
    --orb-4-ch: 240, 242, 250;
    --orb-blur-scale: 2.2;

    --control-knob: #ffffff;
    --control-track: rgba(20, 25, 40, 0.14);

    --cat-1: #475569;
    --cat-2: #0e7490;
    --cat-3: #a46900;
    --cat-4: #4d7c0f;
    --cat-5: #047857;
    --cat-6: #be123c;

    /* Week-calendar events. The edge and the wash move in OPPOSITE directions here, which is the
       whole reason a channel flip could never fix them: the edge is the status mark and goes dark so
       it reads against a pale card, while the wash is the tint behind near-black text and goes pale
       so the text stays legible on it. Each edge is its status's light value; each wash is that
       hue's pale step. */
    /* Freeze tokens, light half. Same values the app text ladder carries — the freeze is only about
       not disturbing dark, not about light settling for less. */
    --settings-ink: rgba(12, 16, 28, 0.92);
    --settings-ink-2: rgba(12, 16, 28, 0.78);
    --settings-ink-3: rgba(12, 16, 28, 0.66);
    --settings-ink-4: rgba(12, 16, 28, 0.60);
    /* Light goes DARKER, which is the only direction with headroom here (§8.3). 0.78 is the ladder's
       existing ink-2 rather than a new value invented for this row. */
    --settings-quiet-ink: rgba(12, 16, 28, 0.78);
    --draft-ink: rgba(12, 16, 28, 0.92);

    /* BOOKED IS NEUTRAL IN LIGHT, and this is the single biggest reason the calendar stopped
       reading as a violet field. `booked` is the DEFAULT status — on an ordinary week most jobs are
       simply booked — so painting it in the brand violet made the accent the most common data value
       on the shop's busiest page. An accent that is everywhere is not an accent.
       A slate edge says "scheduled, nothing to do about it" perfectly well, and it hands violet back
       to the primary action. Dark keeps its violet: on near-black the same colour is one lit block
       among many rather than a field. */
    --wev-booked-edge: 100, 116, 139;
    --wev-booked-wash: 226, 232, 240;
    --wev-progress-edge: 138, 85, 0;
    --wev-progress-wash: 253, 230, 138;
    --wev-pickup-edge: 14, 116, 144;
    --wev-pickup-wash: 207, 250, 254;
    /* Warmed off the booked slate so the two neutrals stay separable. */
    --wev-checkin-edge: 120, 113, 108;
    --wev-checkin-wash: 231, 229, 228;
    --wev-finished-edge: 4, 120, 87;
    --wev-finished-wash: 167, 243, 208;
    --wev-queue-edge: 164, 105, 0;
    --wev-queue-wash: 252, 211, 77;
}

/* The page title's accent half goes neutral in light — a system decision, ratifying a call one
   cluster made locally so every page answers it the same way.
   `.purple-gradient` paints the second word of a page heading in a violet gradient. On near-black
   that reads as a lit wordmark; on white the same two stops are a pigment bar across the top of the
   page, and it is the largest violet object on most screens. The ration is: primary action, focus,
   selection, logo. A page heading is not the wordmark — the eyebrow badge above it already carries
   the brand — so in light the heading is simply ink, and violet is left to mean "you can press
   this". Dark keeps the gradient exactly as it is. */
html[data-theme="light"] .purple-gradient {
    background: none;
    -webkit-background-clip: initial;
    background-clip: initial;
    -webkit-text-fill-color: currentColor;
    color: var(--text);
}

/* ── Light-only corrections to page rules ──────────────────────────────────────────────────────
   Everything below fixes a LIGHT defect in a rule that must not change in dark. It lives here rather
   than in the page sheet for two reasons: `html[data-theme="light"] .foo` is (0,2,0) against a
   page's (0,1,0), so it wins on specificity regardless of the fact that page sheets are injected
   later; and an `html[data-theme]` rule inside a usePageStyle sheet can never match at all when that
   sheet is pane-scoped (CSS nesting makes it a descendant of the page scope). See the rulebook §1.

   Each of these was found by looking at a rendered page in light, not by reading a diff. */

/* The week calendar's booking blocks.
   Their shadow is `rgba(var(--ch-indigo-060210), 0.4)` — a channel that flips to 229,226,248 in
   light, so every booking casts a PALE LAVENDER HALO instead of a shadow, and the week grid reads as
   glowing blobs rather than a ledger. The hover then adds a 22px bloom of the block's own *wash*,
   which in light is the pale member of the pair — an invisible glow on a pale ground, and four times
   the alpha the plan allows a light glow (§3.5: under 0.18 and under 8px, or it reads as a smudge).
   Dark keeps both; light gets a real shadow and a real hover. */
html[data-theme="light"] .week-ev {
    box-shadow:
        inset 3px 0 0 rgba(var(--wev-edge), 0.85),
        var(--elevation-1);
}
html[data-theme="light"] .week-ev:hover {
    box-shadow:
        inset 3px 0 0 rgb(var(--wev-edge)),
        var(--elevation-2);
}

/* The week grid's day columns.
   Four rules in JobsDesktop.css build the week view out of ADDITIVE LIFTS, and their own comments
   say why: "a grey lift over this ground turned the panes muddy", "the white lift comes first and
   the violet sits on top of it". Both sentences are true of a near-black ground and neither has a
   light-theme translation — `--sf-rgb` inverts, so the white lift becomes a DARK lift, and the
   violet radial then sits on top of it. Rendered in light that made today's column the loudest
   object in the grid, marking an empty Sunday, over seven columns of violet wash.

   The ruling: in light a day column is distinguished by RECESSION, not by addition. Light gains a
   rung below the ground that dark barely has (plan §3.2), and recession is exactly what replaces a
   lift when there is no headroom left above white. So the panes step down instead of up, today is
   marked by an accent EDGE rather than an accent field, and the 38px violet bloom behind today's
   date is deleted — there is no glow token in light, and the one legitimate light glow is capped at
   0.18 alpha and 8px blur.

   Dark keeps all four rules exactly as written. */
html[data-theme="light"] .week-track {
    background: rgba(var(--ref-shadow-base), 0.022);
}
html[data-theme="light"] .week-track.weekend,
html[data-theme="light"] .week-track.past {
    background: rgba(var(--ref-shadow-base), 0.05);
}
html[data-theme="light"] .week-track.today {
    background: rgba(var(--ch-violet-7c3aed), 0.045);
    box-shadow: inset 2px 0 0 var(--accent-bar);
}
html[data-theme="light"] .week-col-head.today .wc-num {
    text-shadow: none;
    color: var(--accent-text);
}

/* The non-working day's hover. Base is 0.05 (below); this rule is a flat 0.12, so in dark the hover
   goes LIGHTER (less black on black) and in light it goes darker by 2.4× — the same control reading
   as "recede" in one theme and "advance hard" in the other, and a bigger step than any other row
   hover in the app. One rung above the base is what a hover is. */
html[data-theme="light"] .cal-day.is-off:hover {
    background: rgba(var(--ref-shadow-base), 0.08);
}

/* The drop-off arrival button copies the primary gradient but sets `color: var(--ink)`, which is
   near-BLACK in light — dark ink on a deep violet fill. The app's primary action would then have
   three different inks across three pages. Text on an accent fill is --text-on-accent, always. */
html[data-theme="light"] .do-arrival-action.go {
    color: var(--text-on-accent);
}

/* ── Calendar: the non-working ground ──────────────────────────────────────────────────────────
   `--cal-off` is the flat wash over weekend columns, closed hours and days outside the month. It is
   declared in src/lib/calendarTokens.ts as a fraction of the shadow — dark subtracts light at 0.22,
   because a white wash was invisible against the animated violet ground.

   That single number cannot serve both themes. Over near-black, 0.22 of the shadow is barely
   perceptible; over the light ground it composites to ≈ rgb(194,191,204) — a heavy mid-grey slab
   across a third of the month grid, and the loudest object on the shop's daily-driver page.

   A light override used to live in calendarTokens.ts and was removed on 2026-08-09 as dead code: it
   was an `html[data-theme="light"]` rule inside a usePageStyle sheet, which CSS nesting compiles to a
   descendant of the page scope, so it could never match (RC-11). The removal was correct. Re-homing
   the intent here was the other half of that change, and it was missed — caught by the Phase 7
   consistency audit, from a screenshot rather than a diff.

   It has to live in app.css because this is the only place a real theme selector works, and it wins
   over the page sheet on SPECIFICITY (0,2,0 against the page's 0,1,0) rather than on order — which
   matters, since page sheets are injected after this file.

   0.05, not 0.22: the wash must recede a few luminance points below the ground, not fifty. Changing
   the 0.22 itself would move dark, which is not on the table. */
html[data-theme="light"] .cal-page {
    --cal-off: rgba(var(--ref-shadow-base), 0.05);
}

/* ── The ten tokens that had NO light value at all ──────────────────────────────────────────────
   Not mistuned — undeclared. A token declared in dark and absent from light does not fall back to
   something sensible; it falls back to THE DARK VALUE, silently. So every difficulty chip and every
   unread badge in the app has been painting its dark colour on a white page.

   Nine of the ten are one ramp. A job's skill tier is not a status — an Expert job is not a breach —
   so the ramp keeps its own hues and never borrows the status trio. The triple below preserves the
   dark ramp's structure (neutral slate -> violet -> pushed toward magenta) and deliberately puts
   --diff-2 away from the accent so the tier ramp and the brand do not collide.

   This is the one part of this commit that changes light rendering, and it changes it from wrong to
   declared. Dark is untouched: every value below lives only in the light block. */
html[data-theme="light"] {
    /* #94a3b8 was left here when --diff-2 and --diff-3 were darkened for light — a copy-paste miss
       rather than a decision, and the one value the token spec bans by name twice. It renders as
       TEXT in `.diff-badge.t1` (JobDetailDesktop.css:1360) and `.fwm-seg.t1.on`, and measures
       2.33:1 on its OWN soft fill — worse than the 2.56:1 on white that got it banned. A rendered
       AA failure on a live page, not a theoretical one.

       #5d6b80 is this theme's canonical muted ink and clears it at 4.62:1 on the same fill while
       staying the lightest rung of the ladder (5.41 · 7.58 · 14.63 on white). The soft and line
       companions follow the new base so the badge keeps one hue rather than two. */
    --diff-1: #5d6b80;
    --diff-1-soft: rgba(93, 107, 128, 0.12);
    --diff-1-line: rgba(93, 107, 128, 0.32);
    --diff-2: #475569;
    --diff-2-soft: rgba(71, 85, 105, 0.10);
    --diff-2-line: rgba(71, 85, 105, 0.30);
    --diff-3: #1e293b;
    --diff-3-soft: rgba(30, 41, 59, 0.10);
    --diff-3-line: rgba(30, 41, 59, 0.30);
    --unread-count: #b91c1c;
}


/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   THE TOUCH LAYER — GEOMETRY, BOTH THEMES
   ───────────────────────────────────────────────────────────────────────────────────────────────
   MOVED HERE FROM public/theme-light/_base.css, 2026-08-12. The derivation of every number below
   is still written out in full at the end of that file (§ THE TOUCH LAYER); this block carries the
   rules and the tokens, that block carries the argument. Do not re-derive; read it.

   WHY IT MOVED. The floor was authored inside a `html[data-theme="light"]` file, so it existed in
   LIGHT ONLY — and dark is the default theme, the one Capacitor wraps, and therefore the one that
   is actually running on the bay tablet and the Android phone. Measured at HEAD with `THEME=dark`,
   `FLOOR=44`:

       tablet   light: 11 of 12 routes clear    dark: 10 of 12 FAIL   (~40 controls)
       phone    light: 12 of 12 routes clear    dark:  6 of 12 FAIL   (`.lx-tab` at 18px)

   That is not a regression — no floor existed in either theme before this programme. It is a
   feature that landed on the wrong half of a fork. A 13px control under a glove is unusable
   whichever theme is painting it, so the floor is not a light-theme decision at all: it is the
   app's, and it belongs in the app's sheet.

   This obeys the rule the light rebuild now runs on: GEOMETRY IS SHARED, PAINT IS LIGHT-ONLY.
   `tests/theme/capture.mjs` records paint properties only — colour, background-color, box-shadow,
   outline-color, opacity, filter — and never geometry, so a `min-height` here cannot move the
   frozen dark baseline, while a stray colour would still be caught. Nothing in this block names a
   colour, and nothing in it may.

   WHAT DID NOT COME WITH IT, and why:
     - The `.btn-astra*` floor overrides. They exist to beat light's OWN `min-height: 36px` button
       recipe, which scores (0,2,1) and outranks the `:where()` floor. Dark has no such rule — its
       buttons are padding-sized — so the generic floor below reaches them unaided. A light-only
       problem keeps a light-only fix.
     - `.crail`'s no-hover `opacity: 1`. That is PAINT. Moving it would move the dark baseline and
       needs an explicit re-baseline, not a quiet edit.
   ═══════════════════════════════════════════════════════════════════════════════════════════════ */

:root {
    /* ── Density: heights are authored, vertical padding is DERIVED ────────────────────────────
       Author the height and the rows agree by construction; author the padding and you get four
       competing row heights, which is what these tokens were introduced to end.

       Horizontal padding is CONSTANT across every density. Only vertical compresses; a row that
       gets shorter should not also get narrower, or columns stop aligning between densities.

       THESE WERE DECLARED IN A THEME FILE AND ARE NOW SHARED. That mattered in dark even before
       the touch work: `src/lib/listLedgerStyle.ts` reads them as `var(--row-h-comfy, 48px)` and
       `var(--row-pad-x, 16px)` — fallbacks written precisely BECAUSE the tokens were undefined in
       dark. Every value below is transcribed at the fallback the shared consumers already use, so
       defining them here resolves to exactly what dark already rendered (`.lx-row` 48px,
       padding-x 16px, verified before and after). Only the per-shell rungs further down move dark,
       and moving it is the point. */
    --row-h-compact: 32px;      /* mouse-only dense tables */
    --row-h: 40px;              /* the default */
    --row-h-comfy: 48px;        /* roomy desktop */
    --row-h-coarse: 64px;       /* the bay tablet, gloved */
    --row-pad-x: 16px;

    /* 64px is not padding for its own sake. ISA-101 / IEC 61131 set a 15mm minimum (~57 CSS px) for
       industrial touch and 20-25mm for gloved hands; 48px is ~12.5mm, BELOW the standards floor.
       And the gap does separate work from the height: with a glove's contact patch the dominant
       error is the adjacent-mistap, not the miss, so a 64px row with no gap is worse than a 56px
       row with 12px of it. */
    --row-gap-coarse: 12px;

    /* Control geometry. */
    --ctl-h: 32px;
    --ctl-h-lg: 40px;

    /* ── The touch floors ──────────────────────────────────────────────────────────────────────
       44 and not 48 by measurement, not decree: `/settings` on phone was the only view in the app
       that already audited clean, and the only one declaring a touch height (`--set-ctl-h: 44px`).
       44 is the number this codebase already ships and passes on, and it agrees with SC 2.5.5 (AAA)
       and Apple's HIG. Inventing 48 would have moved the one clean view for no measured reason. */
    --tap-min: 44px;

    /* The primary and the destructive action. Not "bigger because it matters" — bigger because a
       mistap on it is the expensive one, and it is always paired with --tap-sep. */
    --tap-primary: 56px;

    /* The minimum air between two adjacent targets. With a glove the dominant error is hitting the
       NEIGHBOUR, and only separation defends against that. Applied per cluster, never blanketed —
       `button + button { margin-left }` prises apart segmented controls and stepper pairs, which
       are adjacent BY DESIGN. */
    --tap-sep: 8px;
}


/* ── 1. THE CONTROL FLOOR ──────────────────────────────────────────────────────────────────────
   Every control, on any coarse-pointer device, in BOTH themes. `min-height` rather than `height`,
   so a control that is already taller is untouched and a multi-line one still grows.

   IT IS IN A MEDIA QUERY, NOT SCOPED TO `.page[data-shell]`, and that is required rather than
   stylistic: modals, drawers and popovers teleport to <body> and so have no `.page` ancestor to be
   scoped by. A media query reaches them; an attribute selector cannot.

   `:where()` IS LOAD-BEARING — DO NOT UNWRAP IT. At normal specificity this selector scored (0,1,2)
   and beat every single-class control rule in the app, so wherever a component had deliberately
   asked for MORE than 44px the floor overwrote it DOWNWARD: `.wqt-chip` 56 -> 44 (its own comment
   cites the gloved industrial floor), `.cx-chip` 48 -> 44, `.lx-row` 48 -> 44, and 125 rules asking
   for >= 46px outranked. `:where()` takes zero specificity, so the whole selector scores (0,0,0)
   and loses to everything. That is exactly right for a floor: it applies where nothing else has an
   opinion, and it never argues with a component that has one. The corollary is that a control which
   sits BELOW 44px because a page sheet says so is NOT reachable from here — it is fixed in that
   page sheet, unprefixed, and the sweep that found them is in the commit alongside this.

   `:where()` needs Chrome 88 (this repo already ships `:has()`, Chrome 105, at four sites). If an
   old WebView drops it, the rule it drops is this floor — degrading to the pre-existing state
   rather than to something broken, which is the acceptable direction.

   HEIGHT ONLY, and the omission of `min-width` is a finding rather than an oversight. A blanket
   `min-width: 44px` was written here first and rendered: on /jobs at tablet it grew the two 26x26
   mini-calendar arrows into 44x76 slabs and overflowed the command bar until the month title
   collided with a stepper whose glyph had been pushed out of its own box. The audit went green on
   both. Height is the dimension that actually fails on a text control — a 22px search field is
   short, not narrow, and raising it costs no horizontal room. Width fails only on SQUARE ICON
   buttons, and there the fix is a layout decision on a compact grid, not a floor.

   Checkboxes and radios are deliberately EXCLUDED: the box is a glyph, and sizing it to 44px would
   draw a 44px tickbox rather than give it a 44px target. That target has to come from the label. */
@media (pointer: coarse) {
    :where(
        button,
        select,
        textarea,
        input:not([type="checkbox"]):not([type="radio"]):not([type="hidden"]),
        [role="button"],
        [role="tab"],
        [role="switch"]
    ) {
        min-height: var(--tap-min);
    }
}


/* ── 2. THE DENSITY LADDER, RE-RUNG PER TOUCH SHELL ────────────────────────────────────────────
   The highest-leverage rule in the block, and it adds no selectors at all: every existing
   `min-height: var(--row-h)` becomes gloved on the tablet, and every padding DERIVED from it
   (`calc((var(--row-h) - 20px) / 2)`) rescales with it, with nothing new to maintain.

   Scoped to `.page[data-shell='…']` rather than to a width media query because both touch shells
   are `pointer: coarse` and only `data-shell` distinguishes them — and because `data-shell` is what
   the APP itself decides the shell with (`MainLayout.vue:14`, off `useViewport`). A width query
   would be a second, disagreeing opinion about which shell is mounted. Rows live inside `.page`, so
   this scope reaches everything it needs to; controls do not, which is why §1 is a media query.

   IN DARK these rungs reach `src/lib/listLedgerStyle.ts` and the light page sheets' shared
   consumers. Measured on /vehicles at tablet: `.lx-row` 48 -> 64px, which is the intended move —
   the ledger row is the single most-tapped target on the boards and 48px is below the industrial
   floor the tablet is judged against.

   TABLET — the bay, at arm's length, with gloves on. The coarse rung IS the ordinary rung here, and
   the mouse-only compact rung has no meaning on this shell at all. */
.page[data-shell='tablet'] {
    --row-h-compact: var(--row-h-comfy);        /* 32 -> 48. A mouse-only density, on a touch shell */
    --row-h: var(--row-h-coarse);               /* 40 -> 64. The gloved rung, finally consumed */
    --row-h-comfy: var(--row-h-coarse);         /* 48 -> 64 */
    --ctl-h: var(--tap-min);                    /* 32 -> 44 */
    --ctl-h-lg: var(--tap-primary);             /* 40 -> 56 */
}

/* PHONE — a finger, but a 412px viewport and no glove. The floor applies; the GLOVED rung does not,
   because 64px rows on a 412px screen buy accuracy nobody needed and spend a third of the screen to
   do it. This is the one place the two touch shells legitimately part company, and it is a
   resolution argument rather than an input one. */
.page[data-shell='phone'] {
    --row-h-compact: var(--tap-min);            /* 32 -> 44 */
    --row-h: 48px;                              /* 40 -> 48. One step of air over the floor */
    --row-h-comfy: 56px;                        /* 48 -> 56 */
    --ctl-h: var(--tap-min);                    /* 32 -> 44 */
    --ctl-h-lg: var(--tap-primary);             /* 40 -> 56 */
}

/* ══ THE 'LOW' GRAPHICS TIER ══════════════════════════════════════════════════════════════════
   Dark's second cost lever, and the one that actually changes what you see. Read
   src/services/graphics.ts first — it carries why the two levers are separate.

   MEDIUM stilled the orbs, which killed the per-frame backdrop invalidation: a `backdrop-filter`
   pane caches its blurred backdrop until the backdrop changes, and drifting orbs changed it 60
   times a second. What medium could NOT fix is the cost paid while SCROLLING — there the pane moves
   over a static backdrop, so its cached snapshot is invalid however still the ground is, and all
   ~187 glass surfaces re-blur per scrolled frame. Only removing `backdrop-filter` fixes that, and
   removing it is necessarily visible. That is this tier, and it is why it is not the default.

   ── The honest trade ──
   The panes go OPAQUE, they do not go blur-less-but-still-translucent. A 7%-white film with no blur
   behind it shows the ground sharply through every card and drops the pane's own text onto a moving
   gradient — that is the lazy version of this tier and it reads as broken rather than as cheaper.
   Opacity is what keeps it clean, and text contrast in fact IMPROVES.

   The colour is `--glass-opaque`, which this block is the first consumer of. It was declared in
   both themes for the `prefers-reduced-transparency` / `@supports not` fallback and then never
   wired to anything, so the value is already the house answer to "this glass cannot blur" and is
   not being invented here.

   ── What survives ──
   The violet ground does. `.glow-field`'s radial-gradient is a static paint the compositor rasters
   once and never revisits, so it costs nothing to keep and it is what stops this tier from reading
   as a generic dark theme. Only the four ORBS go (BackgroundGlow.vue). Shadows stay: they are
   cached paint, not per-frame work, and they are what gives a now-flat card its depth back.

   NOT tiered, deliberately: the modal scrims. Every veil consumer was checked and each carries a
   real 0.45-0.72 dark fill of its own, so dropping the blur leaves a genuine scrim rather than a
   transparent modal — but those alphas are per-site literals rather than tokens, so a tier cannot
   reach them. Unblurred they read slightly busier. That is the known edge of this tier.

   ── Scoping ──
   `html[data-theme="dark"]` is part of the selector and is load-bearing, NOT belt-and-braces. The
   tier is stored per user and the Settings control is hidden in light, so a mechanic who picks low
   and then switches to light still carries `data-gfx="low"` on <html>. Without the theme in the
   selector this block would then opaque LIGHT's glass to dark's #0d0b12 — a black card on a white
   page. Specificity (0,2,1) clears both `:root` (0,1,0) and `html[data-theme="light"]` (0,1,1),
   and the light block cannot be reached in the first place because the theme will not match. ══ */
html[data-theme="dark"][data-gfx="low"] {
    /* Every blur rung to `none`, which is where 147 sites of glass actually die. Same single-block
       treatment the light theme uses in WP-4, for the same reason: no page spells its own filter,
       so no page has to be edited. */
    --glass-blur: none;
    --glass-filter: none;
    --glass-filter-32-180: none;
    --glass-filter-32-160: none;
    --glass-filter-28-170: none;
    --glass-filter-28-160: none;
    --glass-filter-26-150: none;
    --glass-filter-20-160: none;
    --glass-filter-10-140: none;
    --glass-filter-chrome: none;
    --glass-veil-3: none;
    --glass-veil-4: none;
    --glass-veil-6: none;
    --glass-veil-7: none;
    --glass-veil-8: none;
    --glass-veil-10: none;
    --glass-veil-12: none;
    --glass-veil-16: none;

    /* The fills. All five names are dark's one glass surface under different aliases (--surface and
       --tile-fill are declared AS --glass-bg at the top of this file); they move together or the
       app grows two different card colours. */
    --glass-bg: var(--glass-opaque);
    --glass-fill: var(--glass-opaque);
    --glass-fill-chrome: var(--glass-opaque);
    --surface: var(--glass-opaque);
    --tile-fill: var(--glass-opaque);

    /* The keyline has to carry the whole edge now. At 0.14 it was a hint on a pane the blur had
       already separated from the ground; against a flat fill on a flat ground it is the only thing
       saying where the card stops, so it comes up a step. Same hue, no new token. */
    --glass-border: rgba(255, 255, 255, 0.20);
    --glass-edge: rgba(255, 255, 255, 0.20);
    --glass-edge-chrome: rgba(255, 255, 255, 0.20);
    --keyline: rgba(255, 255, 255, 0.20);
}
