/* Light-only structural base — the vocabulary every cluster maps onto.
   ═════════════════════════════════════════════════════════════════════════════════════════════
   WHY THIS FILE EXISTS

   The other seven files in this directory are per-cluster and fix per-cluster problems. This one
   holds what is true across all of them: the shared shapes of a Stripe-grade light UI, so that
   seven files (and any future eighth) are not each inventing a badge, a chip and a row height.

   It loads BEFORE the cluster files, so a cluster can still override deliberately. It loads AFTER
   app.css, so it can consume every token declared there.

   WHY IT IS STRUCTURE AND NOT JUST COLOUR

   Light is no longer a re-skin of dark; it is a different design on the same DOM. Dark builds depth
   with tint over near-black and carries state in coloured text and glow. Light builds depth with
   occlusion — an off-white canvas, white surfaces, a 1px keyline — and carries state in a filled
   badge with a shape-distinct icon. Those are different MECHANISMS, and a mechanism is expressed in
   geometry and spacing, not only in hex values.

   Every rule here is prefixed html[data-theme="light"], so dark cannot match by construction. That
   is what makes a second visual system safe to author: `compare.mjs` proves, declaration by
   declaration, that dark did not move.

   THE COST, STATED HONESTLY: this is a second layout system to maintain. A new page now needs a
   light structural pass, not merely tokens that resolve. That is the deliberate trade.

   RULES FOR ANYONE EDITING THIS FILE
   - No !important. `html[data-theme="light"] .foo` is (0,2,0) and already beats a page's (0,1,0).
     Against a two-class page selector, add a class or an element to stay ahead.
   - No colour literals. Every colour is a token from app.css, or it does not belong here.
   - Nothing that should be true in BOTH themes. That goes in the page sheet or app.css.
   ═════════════════════════════════════════════════════════════════════════════════════════════ */

html[data-theme="light"] {
    /* ── Density and control geometry MOVED to public/app.css, 2026-08-12, and deleted here.
       `--row-h-compact/--row-h/--row-h-comfy/--row-h-coarse/--row-pad-x/--row-gap-coarse/--ctl-h/
       --ctl-h-lg` are LENGTHS, and a length declared in a theme file forks the two themes by
       construction. They already did: `src/lib/listLedgerStyle.ts` reads `var(--row-h-comfy, 48px)`
       and `var(--row-pad-x, 16px)` with fallbacks written precisely because these were undefined in
       dark. The values are transcribed unchanged, so light resolves exactly as before. See
       § THE TOUCH LAYER — GEOMETRY, BOTH THEMES at the end of app.css. */

    /* --ctl-pad-x stays: tighter than dark, because a control in a dense light data view is a
       rectangle with a hairline, not a soft capsule. --r-control is already 4px in light. */
    --ctl-pad-x: 12px;

    /* ── THERE WERE TWO CANVASES, AND THE ONE YOU SEE IS NOT THE ONE THE TOKENS NAME ──────────
       MEASURED 2026-08-12, /vehicles, light:
           body            background-color  rgb(246,248,250)   (= --ground)
           .glow-field     background-color  rgb(227,232,238)   (= --canvas)
       `.glow-field` (BackgroundGlow.vue) is `position: fixed; inset: 0` and, in light, fully
       OPAQUE — the orbs are `display: none` and the gradient is replaced by a flat `var(--canvas)`.
       So it covers the viewport edge to edge and `--ground` is never seen anywhere in the app. Two
       tokens claimed the page ground; only one of them was ever painted.

       Dark has never had this problem, and says so in a comment beside app.css's own
       `--canvas: #050309`, which reads "= --ground". (Not quoted verbatim here: a nested comment
       terminator inside this block closes it early, the next 20 lines parse as one invalid
       declaration, and the `--ground` line below is swallowed with them. That is exactly what
       happened on the first attempt at this fix — css-parse-check.mjs PASSED, because the selector
       count was untouched; only a probe of the resolved value caught it.)
       The invariant is that they are THE SAME COLOUR. Light broke it by deriving --ground/--ground-deep
       mechanically from dark's gradient pair while --canvas was re-derived from Stripe's scale.

       Resolved in favour of --canvas, which is the value actually on screen, the value the two
       signed-off pages (Work Queue, Telefon/Ekonomi) were designed and accepted against, and the
       one --surface-inset / --tile-fill are stepped off. --ground becomes an alias of it rather
       than a second opinion, so body and canvas can no longer disagree.

       DELIBERATELY NOT DONE HERE: changing the VALUE. `--canvas: #e3e8ee` really does read blue-grey
       on a register, where no white surface is present to be lighter than it (see /vehicles at
       tablet). Moving it to a near-white also collapses `--surface-inset: #f3f6f8` (−3) and
       `--tile-fill: #f7fafc` (+1) into the canvas, i.e. it is a re-derivation of the whole ramp and
       a re-verification of two signed-off pages. That is a design call, not a defect fix.

       ALSO NOT FIXED, NOT OURS: index.html's inline pre-paint script carries a THIRD literal,
       `GROUND = { light: '#f0eff5' }`, feeding <meta name="theme-color"> and the Android splash.
       It is stale — no token has held #f0eff5 since WP-4. */
    --ground: var(--canvas);
    --ground-deep: var(--canvas);

    /* ── The eyebrow: 11px, uppercase, tracked, muted. This is what replaces a coloured section
       heading. Colour as a section label is a dark-theme device — on white it reads as a
       highlighter mark, and it spends the accent budget on something that is not an action. ─── */
    --eyebrow-size: 0.6875rem;
    --eyebrow-track: 0.06em;

    /* ── The page measure. ONE number for every page's centred column. ────────────────────────
       The app had three: 1560 on the registers and the Work Queue console (registers.css raised
       them there, and 1560 is the width the user has actually looked at and signed off), 1240 from
       app.css's --measure on the boards, and 1180 hard-coded into SettingsDesktop.css. Walking
       /vehicles -> /settings slid the page's left edge 190px, which reads as the shell moving
       rather than the content changing.

       It is NOT a light override of --measure itself: app.css's --measure is also the width of a
       wide modal (modalShellStyle.ts:87, .ibx-sheet, .rtx-add, .vsx-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 different decisions that happened to share a token. This one names only the page. ───── */
    /* --page-measure MOVED to public/app.css, 2026-08-11, and deleted here.
       The reasoning above is right and stays; the only thing wrong with it was the FILE. A length
       declared in a theme file forks the two themes by construction — it is why /customers measured
       170/1560 in light and 402/1096 in dark, and why five other pages sat 320-464px apart. It is
       now one value in app.css for both themes. */
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   1. STATUS BADGES — fill + tint border + dark ink + a MANDATORY shape-distinct icon
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   Contrast passes and colour vision fails. All four pairs clear AA at 6.37-9.45:1, but under
   deuteranopia success and danger collapse — fills at dE 0.027 and TEXT at dE 0.040. Two olives.
   In a male-dominated trade that is roughly 1 in 12 mechanics who cannot tell betald from avvisad.

   So the icon is not decoration, it is the second carrier, and it is mandatory. It must be
   SHAPE-distinct (check / cross / bang / dot) — four coloured dots would reintroduce the exact
   failure it exists to prevent.

   It is a ::before rather than markup for three reasons: it needs no change to 240 call sites, it
   cannot appear in dark, and generated content is not in the accessibility tree, so the word keeps
   sole ownership of the accessible name without an aria-hidden attribute anywhere.

   The fills sit at 1.03-1.15:1 against the canvas ON PURPOSE. A badge has no edge, only ink; the
   tint border is what gives it a boundary. Do not raise the fill to "make it visible". */
html[data-theme="light"] .badge,
html[data-theme="light"] .status-badge,
html[data-theme="light"] .st-badge {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    border-radius: 4px;              /* a rectangle, not a pill */
    padding: 2px 6px;
    font-size: 0.6875rem;
    font-weight: var(--fw-label);
    line-height: 1.4;
    letter-spacing: 0;
    text-transform: none;
    border: 1px solid transparent;
    background: var(--surface-inset);
    color: var(--ink-dim);
    border-color: var(--keyline);
    /* app.css:627 gives every badge `inset 0 1px 0 rgba(255,255,255,.1)` and a light-only block
       further down that file used to raise it to 0.6 — a lit top edge, which §"GRADIENT AND GLOW
       HYGIENE" below lists as "-> nothing". The stale light block is deleted; this reset is what
       stops dark's 10% white inset from surviving into light, and it has to be NAMED here because a
       superseding rule at equal specificity only overrides the properties it actually writes. */
    box-shadow: none;
}

/* ── THE PAGE EYEBROW IS A LABEL, NOT A STATUS ────────────────────────────────────────────────
   A bare `.badge` in this app is the page eyebrow and essentially nothing else: 20-odd page views
   render `<span class="badge">{{ t('…_badge') }}</span>` above the h1, and a grep of the source
   finds no dynamic `.badge` class binding anywhere — the only bare-badge uses that are NOT an
   eyebrow are `.badge.sent` on the invoice draft (a real status, restyled at (0,4,0) in money.css
   and therefore untouched by this rule) and the product stamp on the two public screens, restored
   immediately below.

   The treatment is the one the /vehicles author already reasoned out and then scoped to their own
   wrapper: "It is a label, not a status, so it loses the fill." Promoted here so the eyebrow reads
   the same on all six pages instead of five different ways. The per-page copies — the `.vx-header
   .badge` block in registers.css and the `.cx-eyebrow` fill in boards.css — are deleted.

   `display: inline-block` is load-bearing, not cosmetic: `::first-letter` needs a block container,
   and the shared rule above sets `inline-flex`. The case has to come from CSS because the strings
   are capitals in the DATA (localization.ts: 'VEHICLES', 'INLÄMNINGAR', 'ARBETSKÖ'), which
   `text-transform: none` cannot touch. */
html[data-theme="light"] .badge {
    display: inline-block;
    padding: 0;
    border: 0;
    border-radius: 0;
    background: none;
    box-shadow: none;
    color: var(--ink-muted);
    font-size: var(--eyebrow-size);
    letter-spacing: 0;
    text-transform: lowercase;
}

html[data-theme="light"] .badge::first-letter {
    text-transform: uppercase;
}

/* The exception, and it is the only one. `/login` and the pre-login hero render "PROMETHEUS AI
   v1.0" in a `.badge` — a product stamp on a marketing surface, not a page eyebrow, and it is not
   sitting above an h1 it would be labelling. It keeps the neutral chip the shared rule gives every
   other badge. Sentence-casing a version string would also mangle it ("Prometheus ai v1.0"). */
html[data-theme="light"] .login-wrapper .badge,
html[data-theme="light"] .hero .badge {
    display: inline-flex;
    padding: 2px 6px;
    border: 1px solid var(--keyline);
    border-radius: 4px;
    background: var(--surface-inset);
    color: var(--ink-dim);
    text-transform: none;
}

/* The glyph is attached ONLY to badges that carry a status class — never to a bare `.badge`.
   A bare `.badge` in this app is often a plain label chip (the page eyebrow uses one), and giving
   every one of them a leading dot puts a bullet in front of words that are not statuses. Caught on
   the first Work Queue screenshot: the page title chip rendered as "• ARBETSKÖ".

   The neutral/info dot therefore belongs to an explicit neutral class, not to the base selector. */
html[data-theme="light"] .badge.is-neutral::before,
html[data-theme="light"] .badge.neutral::before,
html[data-theme="light"] .badge.info::before,
html[data-theme="light"] .status-badge.is-neutral::before,
html[data-theme="light"] .st-badge.is-neutral::before {
    content: "•";
    font-size: 1em;
    line-height: 1;
}

html[data-theme="light"] .badge.is-ok::before,
html[data-theme="light"] .badge.ok::before,
html[data-theme="light"] .badge.success::before,
html[data-theme="light"] .badge.is-bad::before,
html[data-theme="light"] .badge.bad::before,
html[data-theme="light"] .badge.danger::before,
html[data-theme="light"] .badge.overdue::before,
html[data-theme="light"] .badge.is-warn::before,
html[data-theme="light"] .badge.warn::before,
html[data-theme="light"] .badge.pending::before {
    font-size: 1em;
    line-height: 1;
}

/* The glyphs land at ~10-12px inside an 11px badge. Below that they are a smudge, which is why the
   badge type floor is 11px and not 10px. */
html[data-theme="light"] .badge.is-ok::before,
html[data-theme="light"] .badge.ok::before,
html[data-theme="light"] .badge.success::before { content: "✓"; }

html[data-theme="light"] .badge.is-bad::before,
html[data-theme="light"] .badge.bad::before,
html[data-theme="light"] .badge.danger::before,
html[data-theme="light"] .badge.overdue::before { content: "✕"; }

html[data-theme="light"] .badge.is-warn::before,
html[data-theme="light"] .badge.warn::before,
html[data-theme="light"] .badge.pending::before { content: "!"; }

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   2. CHIPS AND TABS — rectangles and underlines, not capsules
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   --r-pill is for TRUE pills only. A filter chip in a dense board is a rectangle: capsules read as
   consumer-app, they waste horizontal room at the ends, and a row of nine of them is a row of nine
   lozenges rather than a control bar.

   An active tab is an underline, not a filled capsule. The underline is the strongest available
   "you are here" that spends no fill and no accent area. */
html[data-theme="light"] .chip,
html[data-theme="light"] .fc-chip,
html[data-theme="light"] .wq-pill,
html[data-theme="light"] .cx-chip,
html[data-theme="light"] .rt-chip,
html[data-theme="light"] .do-chip,
html[data-theme="light"] .dt-chip,
html[data-theme="light"] .dp-chip,
html[data-theme="light"] .vs-chip-btn {
    border-radius: var(--r-control);
    min-height: var(--ctl-h);
    padding: 0 var(--ctl-pad-x);
    font-size: 0.8125rem;
    font-weight: var(--fw-label);
    background: var(--surface);
    border: 1px solid var(--keyline-strong);
    color: var(--ink-dim);
}

/* ⚠ THE SELECTED CHIP HAD FOUR RECIPES AND LOAD ORDER WAS THE ONLY ARBITER — resolved 2026-08-12.
   The four, all at (0,3,1)ish, all matching overlapping sets of the same eight classes:

     A  _base.css, HERE            --surface-inset fill + --accent border, no bar      TINT ALONE
     B  _base.css § the buttons    --surface + a firmer ring, no fill, no bar          EDGE ALONE
     C  boards.css § 1             --surface-selected wash + --accent-hairline
                                   + `inset 3px 0 0 --accent-bar`                      THE HOUSE IDIOM
     D  settings.css § 5           --accent-solid FILL + --text-on-accent              A FILL

   C is the rulebook: selection is a quiet wash PLUS a 3px leading bar, never a fill, and never a
   tint on its own — the bar is what carries the state through glare and through CVD, and it does
   not multiply into a lavender field when nine chips are on screen. A and B were therefore both
   DEAD-ish and both LIVE-ish depending on the class: boards.css loads after _base.css, so C won
   every selector the two share, while `.chip.on` (named only here) kept A, `button.pb-chip.on`
   (0,3,2 — one extra element) outranked C and kept B, and settings.css loads LAST so D won
   `.pt-chip.on` and both `*-sort-btn.on`. One control, four selection idioms, decided by
   `index.html`. That is why `_base`'s stated rule "a chip is never a fill" read as dead.

   FIXED BY REMOVING THE LOSERS, NOT BY OUT-SPECIFYING THEM. Every selector this block shared with
   boards.css § 1 is deleted from here, so each class is now declared in exactly ONE place. Only
   `.chip.on` — the generic name, which boards.css does not carry — stays, restated in C's terms so
   the base vocabulary and the cluster agree by value rather than by cascade.

   NOT OURS, REPORTED: boards.css § 1 is C and is correct — left untouched. */
html[data-theme="light"] .chip.on {
    color: var(--text);
    background: var(--surface-selected);
    border-color: var(--accent-hairline);
    box-shadow: inset 3px 0 0 var(--accent-bar);
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   3. THE WRAPPER PANEL — deleted
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   A page-level rounded card floating on a ground is the single most consumer-app thing left in the
   layout. Stripe, Linear and Carbon all run content full-bleed and separate it with keylines and a
   header rule instead — the chrome disappears and the data is the page.

   Only the OUTER container is flattened. Cards inside it keep their surface, because now they are
   the only card on screen and they mean something again. */
html[data-theme="light"] .page-panel,
html[data-theme="light"] .glass-panel.page-shell,
html[data-theme="light"] .app-shell > .panel {
    background: transparent;
    border: 0;
    border-radius: 0;
    box-shadow: none;
    padding-left: 0;
    padding-right: 0;
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   4. EMPTY STATES — a line, not a hero
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   A centred block of large muted type in the middle of a pane is a marketing device. In an ops tool
   an empty state is a fact, stated where the data would have been. */
html[data-theme="light"] .state-msg {
    text-align: left;
    font-size: 0.8125rem;
    font-weight: var(--fw-body);
    color: var(--ink-muted);
    padding: 16px var(--row-pad-x);
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   5. FIGURES — tabular everywhere a number can be compared down a column
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   Scoped to money/time/count, not global: proportional figures are better in prose, and tnum in a
   sentence looks like a ransom note. */
html[data-theme="light"] .fig,
html[data-theme="light"] .money,
html[data-theme="light"] .amount,
html[data-theme="light"] .num {
    font-variant-numeric: tabular-nums;
    font-feature-settings: "tnum" 1;
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   PROMOTED FROM THE WORK QUEUE PROTOTYPE — the recipes every cluster maps onto
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   Work Queue was built end-to-end first and signed off as the target. Everything below is the part
   of it that is NOT specific to that page, lifted here so six cluster files cannot each invent a
   badge. That has already happened once in this codebase: `.purple-gradient` is violet in six page
   sheets and emerald in a seventh, and nobody caught it because there was no single declaration to
   diff against.

   HOW A CLUSTER FILE USES THIS
   1. Anything below with a GENERIC selector (element selectors, shared class names) already applies
      to your pages. Do not restate it.
   2. Anything that needs a page's own class name is a RECIPE — the comment says what to write. Add
      your selectors in YOUR cluster file, pointing at the same tokens. Never invent a value.
   3. If a page needs a shape this file has no recipe for, escalate. Do not improvise a literal.
   ═══════════════════════════════════════════════════════════════════════════════════════════════ */

/* ── FORM SURFACES — app-wide, generic, already done for every cluster ────────────────────────
   The single highest-value rule in the light theme, and it needs no per-page mapping because it
   keys on elements rather than classes.

   A grey fill on a white card is the universal signal for read-only, so a form of grey inputs reads
   as a form you cannot fill in. In dark the identical declaration is a LIFT — lighter than its
   surround — which is the inversion this whole rebuild exists to catch: one value meaning "raised"
   on one ground and "disabled" on the other.

   White fill, structural keyline, inset micro-shadow so the field still has a floor. Focus darkens
   the border and adds a 2px halo rather than glowing: a glow on white is a smudge. */
/* `input:not([type])` and `input[type="url"]` were BOTH missing from this list, and the omission is
   app-wide rather than a settings quirk: a typeless `<input>` does not match `input[type="text"]`,
   so any bare or url field in any cluster silently misses the light field treatment. On the settings
   sheet that produced a self-contradiction on one card — a number field rendering white and
   fillable, and the review-link field beside it staying transparent inside a grey wrapper, i.e.
   reading as read-only, which is the exact inversion this whole rule exists to kill. */
html[data-theme="light"] input:not([type]),
html[data-theme="light"] input[type="url"],
html[data-theme="light"] input[type="text"],
html[data-theme="light"] input[type="number"],
html[data-theme="light"] input[type="search"],
html[data-theme="light"] input[type="email"],
html[data-theme="light"] input[type="tel"],
html[data-theme="light"] input[type="password"],
html[data-theme="light"] input[type="date"],
html[data-theme="light"] input[type="time"],
html[data-theme="light"] textarea,
html[data-theme="light"] select {
    background: var(--surface);
    border: 1px solid var(--keyline);
    border-radius: var(--r-control);
    box-shadow: var(--field-floor);
    color: var(--ink);
}

html[data-theme="light"] input:hover:not(:disabled):not([type="checkbox"]):not([type="radio"]),
html[data-theme="light"] textarea:hover:not(:disabled),
html[data-theme="light"] select:hover:not(:disabled) {
    border-color: var(--keyline-strong);
}

html[data-theme="light"] input:focus-visible:not([type="checkbox"]):not([type="radio"]),
html[data-theme="light"] textarea:focus-visible,
html[data-theme="light"] select:focus-visible {
    outline: none;
    border-color: var(--accent);
    box-shadow: 0 0 0 2px var(--field-ring);
}

/* Grey no longer means "read-only" by default, so a field that IS disabled has to say so. */
html[data-theme="light"] input:disabled,
html[data-theme="light"] textarea:disabled,
html[data-theme="light"] select:disabled {
    background: var(--surface-inset);
    color: var(--ink-muted);
    box-shadow: none;
}

/* ── THE GRADIENT HEADING — shared vocabulary, so it lives here ───────────────────────────────
   `.purple-gradient` is declared independently by ~14 page sheets across every cluster. A bare rule
   for it in ONE cluster file is exactly the drift that already made this class violet in six sheets
   and emerald in a seventh, with nothing to diff against.

   In light the two stops resolve to #7c3aed → #6d28d9: a heavy pigment bar across the top of a
   white page. The heading is the most important text on its page, so it takes the ink that says so.

   `-webkit-text-fill-color` is the load-bearing line. It beats `color`, so setting `color` alone
   leaves the clipped gradient rendering and the fix looks applied while changing nothing. */
html[data-theme="light"] .purple-gradient {
    background: none;
    -webkit-background-clip: border-box;
    -webkit-text-fill-color: var(--ink);
    color: var(--ink);
}

/* ── THE SHARED SEARCH BOX ────────────────────────────────────────────────────────────────────
   `.list-search` is the search control on every register and board in the app. It was a grey capsule
   with no edge — the same "read-only" signal the field rule above exists to kill, applied to the
   control these pages are most often driven from.

   Here rather than in a cluster file because it is shared vocabulary: a rule for it living in one
   cluster is how a shared class drifts. */
html[data-theme="light"] .list-search {
    background: var(--surface);
    border: 1px solid var(--keyline);
    border-radius: var(--r-card);
    box-shadow: var(--field-floor);
}

html[data-theme="light"] .list-search:focus-within {
    border-color: var(--accent);
    box-shadow: 0 0 0 2px var(--field-ring);
}

html[data-theme="light"] .list-search .ls-ic {
    color: var(--ink-muted);
}

/* A FIELD INSIDE A FIELD — measured 2026-08-12, /vehicles at tablet, and visible in the render.
   `.list-search` is a flex row of [icon, input, clear]. The rule above frames the row; `.list-search`
   was ALSO listed in the bare-field treatment further down this file, and `.ls-input` — which
   declares `background: none; border: none` in `listSearchStyle.ts` — matched `input:not([type])`
   there too. Both won, so light painted a white ringed box, and then a second white ringed box
   inside it. Before/after computed, light:
       .list-search   #fff + 1px ring + padding 4px 7px 2px   ->   #fff + 1px --keyline + 10px 13px
       .ls-input      #fff + 1px ring + padding 4px 7px 2px   ->   transparent, no ring, no padding

   WHICH ONE YIELDS. The brief proposed the wrapper, by analogy with `.dlr-hours` in detail.css.
   Measured against DARK, that is the wrong half here: dark paints the WRAPPER (padding 10px 13px,
   border, fill) and leaves `.ls-input` transparent at padding 1px 2px. `.dlr-hours` is a wrapper
   whose only content IS the input — nothing is orphaned when it yields. This one also holds the
   magnifier and the clear button, and a wrapper that yields puts both of them OUTSIDE the field
   they belong to, forking light's search box from dark's for no gain. So the INPUT yields, and the
   two themes keep one structure: one framed capsule, icon and control inside it.

   Removing `.list-search` from the field list below also lets `listSearchStyle.ts`'s shared
   `padding: 10px 13px` resolve again, which is how the two themes come back to the same height. */
html[data-theme="light"] .list-search input.ls-input {
    padding: 1px 2px;
    background: none;
    background-color: transparent;
    box-shadow: none;
    border: 0;
    color: var(--ink);
    font-size: 0.875rem;
}

html[data-theme="light"] .list-search input.ls-input:focus {
    box-shadow: none;
    outline: 0;
}

/* ── THE PACE / VERDICT RULE — the most transferable decision on the board ────────────────────
   RECIPE. Applies wherever a page states a routine verdict on most of its rows: behind schedule,
   days waiting, capacity used, stock low.

   A colour that appears on nearly every row has stopped carrying information. Stripe's own stated
   position is that restricting colour is an accessibility measure rather than a taste, so the
   ORDINARY case goes neutral and the hue is kept for the case that is actually wrong:

       ordinary / behind  ->  background --surface-inset, border --keyline-strong, ink --ink-dim
       actually wrong     ->  --st-breach-soft / --st-breach-line / --st-breach
       good news          ->  --st-logged-soft / --st-logged-line / --st-logged

   There is also no good dark amber, and that is a fact about the hue: to clear 3:1 on a light track
   an amber must be darkened, and a darkened yellow IS mustard. This supersedes the original brief's
   "keep the amber" instruction, on the user's explicit later call after seeing it rendered. */

/* ── METERS AND PROGRESS BARS ─────────────────────────────────────────────────────────────────
   RECIPE. Point every bar TRACK at --surface-inset with a --keyline border, and every FILL at the
   --meter-* family. Never at the --st-* family: a bar owes 3:1 under SC 1.4.11, not the 4.5:1 that
   text owes, so the status text tokens overshoot and render as olive-brown, bottle green and washed
   pink when laid down as 6px of solid fill.

   Kill gradient fills. A two-stop gradient reads as a lit bar on near-black and as a smear on white.

   The tracks keep --r-pill and that is deliberate: on a 4-6px-tall bar a full round is the correct
   terminal shape, and a 4px radius on a 4px-tall bar is a rectangle with damaged corners. The rule
   is "no capsules for CONTROLS", not "no round anywhere". */

/* ── ONE PRIMARY PER PANE ─────────────────────────────────────────────────────────────────────
   RECIPE. Two filled buttons on one surface is no primary. Find the terminal action — the one that
   closes out the record — and leave it filled. Every other button on that surface becomes:

       background --surface · border 1px --keyline-strong · color --ink-dim · --fw-label
       hover: background --surface-inset · border-color --accent · color --accent

   Scope it to the container, not to the shared button class: the same class elsewhere is genuinely
   primary. A "link" that is really a <button> gets --ink-dim and takes --accent on hover only. */

/* ── TIMELINES AND ACTIVITY FEEDS ─────────────────────────────────────────────────────────────
   RECIPE. Solid discs of equal weight read as a column of blobs with nowhere for the eye to land.

       past events   6px, background --surface, box-shadow inset 0 0 0 1.5px --keyline-strong
       current/head  background --ink, box-shadow none        (usually :last-child)
       connector     1px --keyline

   The ring is an inset shadow rather than a border so the dot's box does not change size and the
   connector still meets it exactly. */

/* ── SECTION HEADINGS ─────────────────────────────────────────────────────────────────────────
   RECIPE. A coloured section heading is a dark device: on white it reads as a highlighter mark, and
   it spends the accent budget on something that is not an action and cannot be clicked. Every
   section heading becomes the eyebrow:

       font-size --eyebrow-size · --fw-label · uppercase · letter-spacing --eyebrow-track
       color --ink-muted

   Status still lives on each ROW's badge, which is where it belongs. A heading does not need to
   repeat it in hue. Counts and sums beside a heading are --ink-muted, never a status colour: a
   number is not a status. */

/* ── SCROLL REGIONS NEED AN EDGE ──────────────────────────────────────────────────────────────
   RECIPE, and the one that produces no error when missed. A list column that scrolls independently
   must have a surface AND a border: white on #f6f8fa with no hairline has no boundary at all, the
   element is simply invisible, and nothing throws. A scroll region with no edge also has no visible
   start or end, so a row half-clipped at the top rail looks like a rendering fault.

       background --surface · border 1px --keyline · border-radius --r-card

   Two such regions side by side BOTH keep their border. They are not cards arguing; they are two
   scroll regions, and each has to show where it begins. */

/* ── GRADIENT AND GLOW HYGIENE ────────────────────────────────────────────────────────────────
   RECIPE. In light, delete rather than restyle:
     · gradient fills on bars, chips, buttons and headers  -> one flat token
     · gradient hairlines that fade to transparent          -> flat --keyline
     · coloured glows / --elevation-accent                  -> nothing
     · lit top edges                                        -> nothing
   A rule that fades exists because a hard line reads as a scratch on near-black. On white it just
   looks like a rendering artefact. */

/* ── RAW OS EMOJI ─────────────────────────────────────────────────────────────────────────────
   RECIPE. An 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.

   The markup cannot be edited — those files are shared with dark — so suppress and replace:

       .the-span      { font-size: 0; display: inline-flex; align-items: center; }
       .the-span::after { content: ""; width: 12px; height: 12px;
                          background-color: currentColor;
                          mask-image: var(--icon-lock); mask-size: contain; mask-repeat: no-repeat; }

   `currentColor` through a mask is what keeps the glyph exactly the colour of the text beside it.
   Ask for a new --icon-* token rather than inlining a second data URI. */

/* ── C1 FOLLOW-ON: EMPTY STATES IN A PANE ─────────────────────────────────────────────────────
   `.lx-empty` is the shared ledger's empty state and it is `margin: auto` + centred. That was
   invisible while the panes were capped narrow; once a pane is 900px wide it becomes a paragraph
   floating in the middle of a large white rectangle, which reads as a page that failed to load.

   An empty state is a fact, stated where the data would have been. Work Queue already did this
   scoped to its own pane; it is shared vocabulary, so it belongs here for every cluster.

   THE BOX GEOMETRY OF THIS RULE HAS MOVED to listLedgerStyle.ts, unprefixed, for both themes.
   `margin: 0`, the two flex alignments, `text-align: left`, `min-height: 0` and the padding are all
   LENGTHS AND LAYOUT, and the argument for them — "an empty state is a fact, stated where the data
   would have been" — says nothing about which theme is on. Stating it here forked the same empty
   state into two components. MEASURED, `.lx-empty` at 1900:

       /work-queue        light  x=673  w=1122      dark  x=1058  w=352
       /phone-bookings    light  x=629  w=1230      dark  x=1043  w=382
       /vehicles          light  x=1238 w=492       dark  x=1370  w=207

   i.e. light states the fact at the top-left of the pane and dark floats a centred 350px paragraph
   in the middle of a 1200px rectangle — the "page failed to load" read, still live in dark on every
   ledger in the app.

   WHAT STAYS HERE is the icon, and only the icon: hiding a decorative 34px glyph is an ornament
   call, it is what `compare.mjs` actually records, and the dark baseline is immutable. */

html[data-theme="light"] .lx-sheet .lx-empty svg {
    display: none;
}

/* ── C1 FOLLOW-ON: A SEARCH BOX IS A CONTROL, NOT A DATA SURFACE ──────────────────────────────
   Raising the page measure to 1560px correctly gave the DATA the width. It also gave it to the
   search input, which stretched to the full measure — a 1560px-wide text field for a plate number.

   Width should follow content: a data surface earns the page, a control earns its content.

   THE RULE ITSELF MOVED OUT, 2026-08-11, and this note is what is left of it. The reasoning above is
   right and is theme-INDEPENDENT — a search box should be 420px because of what it holds, not
   because of what colour the page is. Living here, behind `html[data-theme="light"]`, it made the
   same control 420px in light and **1560px in dark**: a full-page-width text field for a plate
   number, in the theme nobody was looking at. A length in a theme file cannot be agreed on by
   construction, and this was one of five such forks found in the paint files.

   It is now `Customers.css` and `Vehicles.css`, unprefixed, governing both themes. Verified in dark
   after the move: the /vehicles search box measures 420px. */

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   C0 — REGION BOUNDARIES AND DENSE GRIDS
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   Two rules the system was missing entirely. Both are structural, not colour: no token value fixes
   either one, which is why raising the keyline alone would not have been enough.

   1. A REGION BOUNDARY NEEDS A LINE. On /jobs the white sidebar meets the grey canvas with nothing
      between them — the colour simply changes mid-page, which reads as a rendering seam rather than
      as two regions. In dark this never came up: both regions were near-black and the tint change
      WAS the boundary. In light two flat fills abutting is just an edge nobody drew.

   2. A DENSE GRID USES THE STRONG KEYLINE. A calendar month or a wide table is nothing but its
      lines: they are doing partitioning work, not terminating a card. At the card keyline they mush
      into one plane and the grid stops being a grid. */

/* Region seams: a persistent rail, a sidebar, a fixed column. The line goes on the region, not on
   the page, so a collapsed rail takes its boundary with it. */
html[data-theme="light"] .side-rail,
html[data-theme="light"] .jobs-sidebar,
html[data-theme="light"] .rail,
html[data-theme="light"] .lx-list,
html[data-theme="light"] .wq-ledger {
    border-right: 1px solid var(--keyline);
}

/* Dense grids: the calendar month, week strips, and any table that partitions rather than lists. */
html[data-theme="light"] .cal-grid,
html[data-theme="light"] .cal-cell,
html[data-theme="light"] .month-cell,
html[data-theme="light"] .wk-cell,
html[data-theme="light"] .cal-day,
html[data-theme="light"] .cal-week {
    border-color: var(--keyline-strong);
}

/* A card is a raised object again: surface, keyline, and the contact shadow restored in app.css.
   Scoped to the shared card vocabulary rather than applied to everything with a background, so a
   table row does not pick up a shadow and become a floating tile. */
html[data-theme="light"] .glass-panel,
html[data-theme="light"] .card,
html[data-theme="light"] .metric-card,
html[data-theme="light"] .stat-card,
html[data-theme="light"] .panel {
    box-shadow: var(--elevation-2);
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   C0.5 — OVERLAYS AND FLOATING CHROME
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   A whole class of surface was missed by every pass so far, because none of it appears in a
   screenshot until you open something: modals, sheets, scrims and fixed rails.

   THE SCRIM IS THE WORST OF IT, and it is a mechanism failure rather than a colour one. Backdrops
   are built on channel variables — `rgba(var(--ch-indigo-080512), 0.72)` — which flip with the
   theme. A scrim's entire job is to RECEDE what is behind it, and a channel that inverts turns it
   into a 72% PALE wash: the page bleeds through, the dialog floats in milk, and the thing the user
   is supposed to be reading has no ground of its own. `--scrim` already exists for exactly this and
   was simply never adopted.

   THE SHARED MODAL FRAME HAD NO LIGHT RULE AT ALL. `.mdl-sheet` was extracted because
   `.modal-panel` had been declared in THIRTEEN page stylesheets; the extraction fixed the geometry
   and nobody gave the result a light theme. Zero overrides before this block.

   FLOATING CHROME KEPT ITS DARK GEOMETRY. `.crail` is `position: fixed` at `border-radius: 18px`
   with `0 12px 44px rgba(…, 0.45)`. An earlier pass here recoloured its fill and border and left
   both — so it reads as a white blob stuck onto the page rather than a rail docked to its edge. */

/* 1. Scrims darken. Every one of them, whatever channel it was built on. */
/* `.mdl-backdrop` was MISSING from this list, and it is the one the shared modal frame actually
   ships — `modalShellStyle.ts:38`. The list named `.mdl-scrim`, which is plausible and wrong, so
   every dialog built on the shared shell (the booking sheet, check-in, found-work) kept its
   `rgba(var(--ch-blue-06080f), 0.66)` — a channel that INVERTS in light, turning a scrim into a 66%
   pale wash. The page bleeds through, the dialog floats in milk, and the thing you are meant to be
   reading has no ground of its own. Exactly the failure this block was written to fix, missed on a
   class name nobody checked against the markup. */
/* `.mdl-confirm` — the IN-SHEET confirm's own scrim (modalShellStyle.ts:290) — was missed by the
   same oversight, and it is worse than the backdrop because it lies on top of a sheet that is
   already white: `rgba(var(--ch-blue-06080f), 0.72)` measured `rgba(219, 228, 245, 0.72)` in light,
   so "are you sure you want to mark this picked up?" washed the whole storage sheet pale blue
   instead of receding it. Captured on /vehicle-storage before this line existed. */
html[data-theme="light"] .mdl-scrim,
html[data-theme="light"] .mdl-backdrop,
html[data-theme="light"] .mdl-confirm,
html[data-theme="light"] .modal-backdrop,
/* `.cnr-scrim` DELETED — dead selector. The string exists nowhere under `src/`; the cancelled-record
   dialog's real class is `.cnr-backdrop` (CanceledRecord.vue:2). It is not corrected in place
   because jobs.css §FJ.7 already styles `.cnr-backdrop` and `.cnr-sheet` in full, and adding a
   second owner here would be two rules for one surface with the later file winning silently. */
html[data-theme="light"] .sheet-backdrop,
html[data-theme="light"] .drawer-backdrop {
    background: var(--scrim);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
}

/* 2. One modal surface, shared frame and legacy alike. A dialog is the one thing in this theme that
   genuinely floats, so it keeps level 3 — the two-layer navy-plus-black recipe — where a card gets
   contact and a row gets nothing. */
/* `.cnr-panel` DELETED from this list for the same reason as `.cnr-scrim` above — no such class in
   the markup (it is `.cnr-sheet`), and jobs.css §FJ.7 owns the real one. */
/* A container has EITHER a border OR an elevation, never both. This rule shipped with both, which
   is why a dialog read as a boxed sticker rather than as something floating: `border: 1px solid
   var(--keyline)` PLUS `--elevation-3`. A dialog is the one object in this theme that genuinely
   floats and it already has a darkened scrim under it, so the shadow is the whole separation cue
   and the hairline is deleted. The 1px is kept and made transparent so the sheet's metrics do not
   move — the shell declares `border: 1px solid …` and a `border: 0` here would shift every dialog's
   content box by 2px on both axes. */
html[data-theme="light"] .mdl-sheet,
html[data-theme="light"] .modal-panel {
    background: var(--surface);
    border: 1px solid transparent;
    border-radius: var(--r-panel);
    box-shadow: var(--elevation-3);
}

html[data-theme="light"] .mdl-head,
html[data-theme="light"] .modal-title {
    border-bottom-color: var(--keyline);
}

/* ── The action bar is a hairline, not a band ────────────────────────────────────────────────────
   THE COMPLAINT THIS FIXES, verbatim: "that grey subtle fade thing at the bottom of this pop up …
   sharp grey contrast on top of white". Two separate mechanisms produced it and both are here.

   `.mdl-foot` (modalShellStyle.ts:179) fills with `var(--well-a14)`. In dark that is
   `rgba(0,0,0,0.14)` — a floor under the actions, which is what the comment there describes. In
   light `--well-a14` is the opaque `#e2e4ea`, so the same declaration paints a solid grey band
   across the bottom of a white sheet. Measured `rgb(226, 228, 234)` on the check-in sheet.

   A sticky action bar does not need a fill to read as a floor. The keyline above it and real
   padding are what the house does everywhere else, and it is what Stripe's own dialogs do. So the
   fill goes and the padding squares up to `--sp-5`, matching the body's own inset — the band was
   also doing the work of the space it was standing in for. */
html[data-theme="light"] .mdl-foot {
    background: var(--surface);
    border-top-color: var(--keyline);
    padding: var(--sp-5);
}

html[data-theme="light"] .modal-actions {
    border-top-color: var(--keyline);
}

/* The other mechanism: a sticky foot that masks the content scrolling under it with a GRADIENT, and
   fades to a raw colour primitive. `.wq-edit-foot` (WorkQueueDesktop.css:757) fades to
   `var(--c-blue-10131b)` — `#10131b` in dark, and `#d6dff1` in light, because the primitives in this
   codebase invert with the theme. Measured `linear-gradient(rgba(214,223,241,0) 0%, rgb(214,223,241)
   34%)` on the Takt dialog: a pale blue-grey band with a pale blue-grey fade above it, on white.
   This is THE surface the complaint was pointing at.

   A fade to the wrong colour is the same bug as the slab, so the fade fades to the SURFACE. It is
   the shell's foot idiom by another route: solid ground the colour of the sheet, one keyline, and
   the content passing cleanly under it. Owned here rather than in boards.css because it is dialog
   chrome and no light sheet claimed it — grep confirms `.wq-edit-foot` appears in no theme file. */
html[data-theme="light"] .wq-edit-foot {
    background: var(--surface);
    background-image: none;
    border-top: 1px solid var(--keyline);
}

/* The in-sheet confirm is a dialog on a dialog: it floats over its own scrim, so it takes the same
   treatment as `.mdl-sheet` — white, elevation, no hairline, the 4px ceiling.

   `modalShellStyle.ts:298` paints it `linear-gradient(160deg, var(--c-blue-1b1f2b),
   var(--c-blue-12151d))`. Those primitives invert: `#1b1f2b`/`#12151d` in dark, `#d2dbed`/`#d6dff0`
   in light. Nothing ever chose a blue-grey confirm box; it is a dark near-black read through the
   light half of a flipping channel, and this app's dark greys are violet-blue greys.

   theme-light/detail.css §11 had this class grouped with `.eqp-warn`, `.ckm-zone` and `.fwm-sec`
   under "a nested filled box inside a dialog that is already a surface" — correct for those three,
   wrong for this one, which is not nested inside anything but a scrim. It was landing
   `--surface-inset` (#f3f6f8) with no border and no shadow: a grey slab with no boundary. Removed
   from that group; this is the single owner. */
html[data-theme="light"] .mdl-confirm-box {
    background: var(--surface);
    background-image: none;
    border: 1px solid transparent;
    border-radius: var(--r-panel);
    box-shadow: var(--elevation-3);
}

/* 3. Floating chrome docks. It is fixed-position and genuinely sits over content, so it keeps an
   elevation — but contact, not the 44px lift it wore in dark, and the card radius rather than 18px. */
html[data-theme="light"] .crail {
    border-radius: var(--r-card);
    box-shadow: var(--elevation-2);
}

html[data-theme="light"] .crail:hover,
html[data-theme="light"] .crail:focus-within {
    box-shadow: var(--elevation-3);
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   THE LIGHT SHELL — a persistent navigation rail, and a content column beside it
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   The single biggest reason this theme has not read as Stripe is not colour and not spacing. It is
   that the app has no standing navigation. Stripe's dashboard is a fixed rail plus a bounded content
   column; ours is a hamburger in the corner over a full-bleed page. Every restyle underneath that
   was decorating the wrong shape.

   WHY THIS NEEDS NO MARKUP CHANGE, which is what makes it safe: `NavBar.vue` already renders the
   whole rail on desktop — brand, grouped links, icons, labels, active state. It is simply parked
   off-screen as a right-hand drawer at `transform: translateX(100%)` and revealed by a toggle. The
   structure has been there the entire time. Light stops hiding it; dark keeps its drawer, and the
   two themes disagree about chrome on purpose.

   The precedent is already in the codebase: `.page[data-shell='tablet']` pads 74px for the tablet's
   own rail. This is the same move at desktop width.

   The rail is CHROME, so it is quiet: surface, one keyline, no shadow, no glass. It should be the
   thing you stop noticing after a day. */
/* The drawer keeps dark's structure exactly — item height, label size, weight, spacing and the
   active treatment are all the page sheet's, untouched. Only the SURFACE is themed, because a
   near-black drawer on a white page is the one thing that cannot carry over. An earlier pass
   restyled the items here to make a Stripe-shaped rail; with navigation back in the drawer, that
   restyling has no reason to exist and every reason not to — two navigation looks to keep in step
   is exactly the drift this file was created to prevent. */
html[data-theme="light"] .side-nav {
    /* REVERTED. The rail was surfaced as a persistent left column because Stripe ships one; the
       user's call is that navigation stays in the drawer, identical to dark. Dark and light now
       agree on chrome and differ only in surface treatment — which is a cleaner split anyway: one
       navigation model, two skins, rather than two navigation models to keep in step.

       Only the drawer's SURFACE is themed here. Its geometry, position and open/close behaviour are
       the page sheet's, untouched. */
    background: var(--surface);
    border-left: 1px solid var(--keyline);
    box-shadow: var(--elevation-3);
}

/* The toggle is the drawer's door. With the rail standing open there is nothing to open. */
/* The toggle and its backdrop are the drawer's door and its scrim — both stay. */
html[data-theme="light"] .nav-toggle {
    background: var(--surface);
    border: 0;
    border-radius: var(--r-control);
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        0 1px 1px rgba(0, 0, 0, 0.08),
        0 0 0 0 transparent;
}

html[data-theme="light"] .nav-backdrop {
    background: var(--scrim);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
}

/* No rail to reserve for: the page runs full width behind the drawer, as in dark. */



/* ── The content column ───────────────────────────────────────────────────────────────────────
   Bounded and LEFT-ALIGNED against the rail, with the slack left on the right. Centring the column
   in the leftover space is the reflex and it is wrong here: it detaches the content from the
   navigation it belongs to and leaves two uneven margins instead of one intentional one.

   1080px is a reading measure for a data surface — wide enough for a table with a figure column,
   narrow enough that a row's identity and its number stay in one eyeful. This deliberately reverses
   the 1560px full-bleed applied earlier today, which moved AWAY from the reference. */
html[data-theme="light"] .page[data-shell='desktop'] .content {
    /* NO CAP. Three passes capped this — 1080, then 1400 — and on a 1900px monitor both left a dead
       gutter down the right third of every page. A reading measure is for PROSE. This is an
       operations console: the ledger and the record want the glass. The rail already gives the page
       its left edge and its structure, so the content simply uses what is left. */
    max-width: none;
    margin-left: 0;
    margin-right: auto;
    /* 20px page gutter — Stripe's --Chrome-paddingLeft, measured. Mine was 32-40 and it made every
       page feel like it started late. */
    padding-left: 20px;
    padding-right: 20px;
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   DEPTH, NOT OUTLINES — and never a box inside a box
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   The theme had drifted into drawing a 1px rectangle around everything, then a second rectangle
   around the thing inside it. Two greys, two edges, nested — which is exactly what makes a light UI
   read as a wireframe rather than as a surface. The fix is not a better grey; it is fewer edges.

   THE RULE, and it is a hard one: a container has EITHER a border OR an elevation, never both, and
   whatever is inside it has NEITHER. Depth comes from the outermost object only. Everything within
   it separates with space and a single hairline divider where a divider is genuinely needed.

   This supersedes the earlier "every surface must carry a border" instruction, which was written to
   stop surfaces vanishing when glass was removed. That was right at the time and it over-applied:
   the answer to a surface with no edge is an edge OR a shadow, and a card that has both, containing
   a panel that also has both, is four edges arguing about one object. */
html[data-theme="light"] .glass-panel,
html[data-theme="light"] .card,
html[data-theme="light"] .metric-card,
html[data-theme="light"] .stat-card,
html[data-theme="light"] .panel,
html[data-theme="light"] .lx-sheet,
html[data-theme="light"] .fx-card,
html[data-theme="light"] .tile {
    border: 0;
    box-shadow: var(--elevation-2);
}

/* A nested block inside one of those gets no frame at all. It is part of the card, not a second
   card: it separates with the inset ground and its own spacing. */
html[data-theme="light"] .lx-sheet .pc-lines,
html[data-theme="light"] .lx-sheet .pc-total,
html[data-theme="light"] .lx-sheet .pc-quote-text,
html[data-theme="light"] .fx-card .lad,
html[data-theme="light"] .fx-card .todo {
    border: 0;
    box-shadow: none;
}


/* ── THE RHYTHM, MEASURED ─────────────────────────────────────────────────────────────────────
   Five gaps, and the ordering is the actual rule rather than the numbers: they decrease
   monotonically down the hierarchy and never once increase.

       20  page gutter          --Chrome-paddingLeft
       20  title -> first block .bs-PageHeading margin
       16  card padding
       12  card -> card
        8  default flex gap     (16 of 34 literal gap declarations in the file)

   Everything here had been built at roughly double: 40 page, 32 sections, 20 card, 16 between. That
   is why the pages read as sparse rather than calm — generous spacing without a descending order is
   just distance, and distance is what makes a screen look empty rather than composed. */
html[data-theme="light"] .content .fx-card,
html[data-theme="light"] .content .tile,
html[data-theme="light"] .content .card,
html[data-theme="light"] .content .panel {
    padding: 16px;
    border-radius: var(--r-card);
}

html[data-theme="light"] .content .tiles {
    gap: 12px;
    margin-bottom: 20px;
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   THE CONTROL LAYER — transcribed from Stripe's shipped declarations, not inferred
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   Three mechanisms run through all of it, and they are why Stripe's controls feel solid where ours
   felt drawn:

   1. THE EDGE IS LIGHTING, NOT A BORDER. No control carries `border`. The edge is a 1px SPREAD
      shadow in navy at low alpha plus a 1px ambient drop. A border occupies layout and reads as
      ink; a spread shadow reads as a surface catching light at its rim.

   2. EVERY STATE CARRIES THE SAME NUMBER OF SHADOW LAYERS. Resting states pre-declare the layers
      they do not yet need at alpha 0, including a literal `0 0 0 0 transparent` placeholder. That
      is what lets `transition: box-shadow` INTERPOLATE instead of hard-cutting — and it is exactly
      the "silky" in the brief. Without it, box-shadow snaps.

   3. HOVER NEVER CHANGES COLOUR AND NOTHING EVER MOVES. `:hover` re-declares the identical
      background it already had; what changes is a shadow layer appearing. Across 427 hover rules,
      `transform` appears 15 times and all 15 are decorative illustrations — zero on buttons, rows,
      cards, inputs, tabs or nav.

   Timing: `.08s ease-in` on box-shadow and colour only, background excluded from the list. 80ms is
   faster than instinct and it is why the response reads as immediate rather than animated. */

/* ── Buttons ──────────────────────────────────────────────────────────────────────────────────
   MEASURED: padding 3px 8px · 14px/500 · line-height 1.6 · radius 4 → a 28px control. The
   horizontal padding is 8px, far tighter than instinct, and much of why a Stripe button reads as a
   control rather than as a call to action. */
html[data-theme="light"] .btn-astra-neutral,
html[data-theme="light"] .btn-astra-glass,
html[data-theme="light"] .wq-act,
html[data-theme="light"] button.pb-chip,
html[data-theme="light"] .pb-sort-btn,
html[data-theme="light"] .fc-sort-btn {
    /* 36px, NOT Stripe's measured 28. Two reasons to diverge deliberately: 28px is a mouse-only
       number and this app is also driven on a bay tablet, where it sits under every touch floor;
       and at 28 with 8px of side padding the control reads as flattened rather than pressable.
       The SHADOW machinery is what makes a Stripe button feel right, not its height — so the
       three-layer edge and the hover lift stay exactly as measured. */
    padding: 6px 16px;
    min-height: 36px;
    font-size: 0.875rem;
    font-weight: var(--fw-label);
    line-height: 1.6;
    border: 0;
    border-radius: var(--r-control);
    background-color: var(--surface);
    background-image: none;
    color: var(--ink-dim);
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        0 1px 1px rgba(0, 0, 0, 0.08),
        0 0 0 0 transparent;
    transition: box-shadow 0.08s ease-in, color 0.08s ease-in;
}

html[data-theme="light"] .btn-astra-neutral:hover:not(:disabled),
html[data-theme="light"] .btn-astra-glass:hover:not(:disabled),
html[data-theme="light"] .wq-act:hover:not(:disabled),
html[data-theme="light"] button.pb-chip:hover,
html[data-theme="light"] .pb-sort-btn:hover,
html[data-theme="light"] .fc-sort-btn:hover {
    background-color: var(--surface);
    color: var(--ink);
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        0 1px 1px rgba(0, 0, 0, 0.10),
        0 3px 9px rgba(var(--edge-rgb), 0.10);
}

/* Active runs the same machinery downward: every alpha drops BELOW resting and the deep layer
   collapses back to transparent, so the control presses into the page instead of flashing. */
html[data-theme="light"] .btn-astra-neutral:active:not(:disabled),
html[data-theme="light"] .wq-act:active:not(:disabled),
html[data-theme="light"] button.pb-chip:active {
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.12),
        0 1px 1px rgba(0, 0, 0, 0.04),
        0 0 0 0 transparent;
}

/* The selected filter — recipe B of the four described at § THE SELECTED CHIP above, brought onto
   the house idiom (C). It was right that a chip that is ON states a fact and never takes a fill; it
   was wrong that a firmer EDGE alone carries it, and its only real effect was to out-rank
   boards.css § 1 on `button.pb-chip.on` by one element and hand /phone-bookings a selection idiom
   no other board used. Same wash, same 3px leading bar as everywhere else; the ring is kept as the
   button-layer floor these controls share with their unselected siblings.

   `.fc-sort-btn.on` is dropped from the list — boards.css § 1 already names it, and one selector
   declared in two files is exactly the failure this block is fixing. */
html[data-theme="light"] button.pb-chip.on,
html[data-theme="light"] .pb-sort-btn.on {
    background-color: var(--surface-selected);
    color: var(--text);
    box-shadow:
        inset 3px 0 0 var(--accent-bar),
        0 0 0 1px rgba(var(--edge-rgb), 0.28),
        0 1px 1px rgba(0, 0, 0, 0.08);
}

/* The primary. Same geometry and the same machinery — only the fill differs, and the fill does NOT
   change on hover; the ambient layer deepens instead. */
html[data-theme="light"] .btn-astra:not(.btn-astra-neutral):not(.btn-astra-glass):not(.btn-astra-danger),
html[data-theme="light"] .wq-act.primary {
    padding: 6px 18px;
    min-height: 36px;
    font-size: 0.875rem;
    font-weight: var(--fw-label);
    border: 0;
    border-radius: var(--r-control);
    background-color: var(--accent);
    background-image: none;
    color: #fff;
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        0 1px 1px rgba(0, 0, 0, 0.10),
        0 0 0 0 transparent;
    transition: box-shadow 0.08s ease-in, color 0.08s ease-in;
}

html[data-theme="light"] .btn-astra:not(.btn-astra-neutral):not(.btn-astra-glass):not(.btn-astra-danger):hover:not(:disabled),
html[data-theme="light"] .wq-act.primary:hover:not(:disabled) {
    background-color: var(--accent);
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        0 1px 1px rgba(0, 0, 0, 0.30),
        0 4px 9px rgba(var(--edge-rgb), 0.20);
}

/* ── Fields ───────────────────────────────────────────────────────────────────────────────────
   Transcribed verbatim, and it overturns a widely-repeated belief: there is NO inset shadow on a
   Stripe input and no `border` property at all. The two cyan slots sit at alpha 0 while resting so
   the ring cross-fades up rather than appearing.

   The ring REPLACES the edge rather than adding to it — the navy hairline goes to alpha 0 as the
   cyan comes up, so a focused field has exactly as much edge as an unfocused one and nothing shifts
   by a pixel. Padding 4px 7px 2px is asymmetric on purpose: an optical correction for line-height
   1.6. */
/* `input:not([type])` and `input[type="url"]` were BOTH missing from this list, and the omission is
   app-wide rather than a settings quirk: a typeless `<input>` does not match `input[type="text"]`,
   so any bare or url field in any cluster silently misses the light field treatment. On the settings
   sheet that produced a self-contradiction on one card — a number field rendering white and
   fillable, and the review-link field beside it staying transparent inside a grey wrapper, i.e.
   reading as read-only, which is the exact inversion this whole rule exists to kill. */
html[data-theme="light"] input:not([type]),
html[data-theme="light"] input[type="url"],
html[data-theme="light"] input[type="text"],
html[data-theme="light"] input[type="number"],
html[data-theme="light"] input[type="search"],
html[data-theme="light"] input[type="email"],
html[data-theme="light"] input[type="tel"],
html[data-theme="light"] input[type="password"],
html[data-theme="light"] input[type="date"],
html[data-theme="light"] input[type="time"],
/* `select` was the third omission, and the same class of one: it is present in the EARLIER field
   list (border 1px + --field-floor) but was missing from this one, which is the list that actually
   defines the control layer. So a `<select>` kept a real border and an inset floor and pre-declared
   none of the focus-ring slots, i.e. it could not cross-fade a ring at all, while every text field
   beside it wore the spread-shadow edge. Two field types, two idioms, on the same row — seen on
   /vehicle-storage's `.vs-select`. The native arrow is drawn by the UA's default appearance, not by
   the border, so `border: 0` does not remove it. */
html[data-theme="light"] textarea,
/* `.list-search` was here and is deliberately gone: it is a WRAPPER, not a control, and listing it
   with the controls gave it a second, conflicting frame on top of the one at § THE SHARED SEARCH BOX
   — and, because that block's padding outranked `listSearchStyle.ts`, forked its height from dark's.
   See the note there. */
html[data-theme="light"] select {
    padding: 4px 7px 2px;
    border: 0;
    border-radius: var(--r-control);
    background-color: var(--surface);
    color: var(--ink-dim);
    font-size: 0.875rem;
    line-height: 1.6;
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.16),
        0 0 0 1px rgba(var(--focus-ring-rgb), 0),
        0 0 0 2px rgba(var(--focus-ring-rgb), 0),
        0 1px 1px rgba(0, 0, 0, 0.08);
    transition: box-shadow 0.08s ease-in, color 0.08s ease-in;
}

html[data-theme="light"] input:focus,
html[data-theme="light"] textarea:focus,
/* `.list-search:focus-within` removed with its resting-state twin above — the wrapper's focus state
   is the accent border + 2px ring at § THE SHARED SEARCH BOX, and two focus recipes on one element
   is how the resting frames came to disagree in the first place. */
html[data-theme="light"] select:focus {
    outline: 0;
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0),
        0 0 0 1px rgba(var(--focus-ring-rgb), 0.20),
        0 0 0 2px rgba(var(--focus-ring-rgb), 0.25),
        0 1px 1px rgba(0, 0, 0, 0.08);
}

/* MEASURED: Stripe declares no :hover on a text input anywhere. The resting state IS the hover
   state — a field you can type into does not need to announce itself as the pointer crosses it. */

/* ── Section headings: sentence case, not uppercase ───────────────────────────────────────────
   This reverses the eyebrow treatment used everywhere in this theme so far, and the evidence is
   blunt: the entire 3.6 MB stylesheet contains NINE letter-spacing declarations and not one of them
   is on a table header or a section label. Stripe's header is 11px, weight 500, SENTENCE CASE,
   gray-500, above a single hairline — and their current shell is actively shrinking the old 13px
   uppercase header to that. Uppercase tracked labels are the thing being retired, not the target.

   Sentence case also stops a Swedish interface shouting: VÄNTAR PÅ KUND is four words of caps on a
   row that is already carrying a badge. */
html[data-theme="light"] .lx-bucket .lx-bucket-name,
html[data-theme="light"] .fx-card-head,
html[data-theme="light"] .hero-title,
html[data-theme="light"] .tile-label,
html[data-theme="light"] .pc-sec-title,
html[data-theme="light"] .mdl-head .mdl-title,
html[data-theme="light"] .wq-mini-label {
    font-size: 0.6875rem;
    font-weight: var(--fw-label);
    text-transform: none;
    letter-spacing: 0;
    color: var(--ink-muted);
}

/* ── Rows and dividers ────────────────────────────────────────────────────────────────────────
   The divider is an INSET SHADOW, not a border — which is what lets a selection bar stack into the
   same declaration without disturbing layout or losing the rule. One colour, one weight, and the
   keyline is defined as the same value as the divider so a card edge and a row rule can never
   near-miss each other.

   And a count worth keeping: a Stripe table draws three kinds of line — card keyline, header rule,
   row divider — in one colour at one weight, with ZERO vertical column rules. Columns are separated
   by 16px of gutter and nothing else. That is the answer to "hairy". */
html[data-theme="light"] .lx-row,
html[data-theme="light"] .fj-row,
html[data-theme="light"] .led tr {
    border-bottom: 0;
    box-shadow: inset 0 -1px 0 0 var(--keyline);
    transition: background-color 0.15s ease;
}

html[data-theme="light"] .lx-row:hover {
    background-color: var(--surface-inset);
    box-shadow: inset 0 -1px 0 0 var(--keyline);
}

/* Selection stacks INTO the same shadow list rather than adding a border. */
html[data-theme="light"] .lx-row.picked {
    background-color: var(--surface-inset);
    box-shadow: inset 3px 0 0 0 var(--accent), inset 0 -1px 0 0 var(--keyline);
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   STATES GO DARKER — the rule, and the surfaces where it was violated
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   In dark there is headroom above the surface: a hover raises a near-black card by adding white, and
   the lift is visible because everything around it is darker. In light there IS no headroom. The
   page is #ffffff. A hover that resolves to white on a white page is not a weak state, it is NO
   state — the element gives no response to the pointer at all, and when the same declaration also
   serves :focus-visible, a keyboard user has no visible focus indicator either.

   That is not a colour to tune. `--glass-bg` is CORRECT in dark (a 7% white tint = a lift) and it
   became `var(--surface)` in light when glass went opaque, which is right for a resting surface and
   silently wrong for a state built on it. One token, two jobs, and only one of them survived the
   inversion.

   THE RULE: in light, every hover and focus state moves DOWN the ramp — --surface-inset for a tone
   step, --keyline-strong for an edge, an elevation for a lift. Never toward white.

   A SECOND FAULT on the same rows: `.more-tile` took an 18% wash of the accent channel on hover.
   Stripe's measured behaviour is that hover never changes colour — the background is re-declared
   identically and a shadow layer appears. A control that floods with accent on hover reads as
   selected rather than as pointed-at, and it spends the accent budget on a pointer position.

   Scoped to the shared components here; the Jobs phone board carries the same defect and is fixed
   in phone.css against this same rule. Both were found by reading, not by capture: the screenshot
   harness never drives :hover or :focus, so this whole class of defect is invisible to it. */
html[data-theme="light"] .more-row:hover,
html[data-theme="light"] .more-row:focus-visible,
html[data-theme="light"] .mm-close:hover {
    background: var(--surface-inset);
}

/* The tile keeps its own fill and firms its edge. It sits INSIDE a row that has just stepped down,
   so holding white is what makes it read as raised — the occlusion does the work the wash was
   trying to do with pigment. */
html[data-theme="light"] .more-row:hover .more-tile,
html[data-theme="light"] .more-row:focus-visible .more-tile {
    background: var(--surface);
    border-color: var(--keyline-strong);
}

/* ── Reduced motion — the one place not to copy Stripe ────────────────────────────────────────
   `prefers-reduced-motion` appears exactly ONCE in their 3.6 MB, guarding a single badge. Spinners,
   shimmers, shakes and modal overshoot all run unguarded. Their machinery is worth taking; the
   omission is not. */
@media (prefers-reduced-motion: reduce) {
    html[data-theme="light"] * {
        transition-duration: 1ms !important;
        animation-duration: 1ms !important;
    }
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   THE SHELL, INVERTED — tinted rail, white page, no cards
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   Two first-party findings overturn what was built here, and together they answer the original
   complaint — that a white card looks glued onto a grey page — by removing the condition entirely.

   1. THE RAIL IS THE TINTED SURFACE AND THE CONTENT IS WHITE. Measured: rail #F7FAFC, and
      `html,body{background:#fff}`. This theme had it exactly backwards — white rail, grey page — so
      every content surface had to fight its way off a grey ground. Inverted, the page IS the
      surface: nothing sits on anything, so nothing can look stuck to it.

   2. 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"; every table in fourteen first-party
      screenshots sits directly on the page. The one surviving container is a soft grey fill with NO
      border, used for stat tiles.

   So: content is white, sections are separated by space and a single hairline, and the only filled
   container left is a tile. That also retires the nested-box problem by construction — there is
   nothing left to nest. */
html[data-theme="light"] .page[data-shell='desktop'] {
    background: var(--surface);
}










/* Cards stand down. On a white page a bordered, shadowed box around content is the thing that made
   this read as a wireframe; sections now separate with space and one hairline. */
html[data-theme="light"] .content .fx-card,
html[data-theme="light"] .content .lx-sheet,
html[data-theme="light"] .content .glass-panel,
html[data-theme="light"] .content .lx-list {
    background: transparent;
    border: 0;
    box-shadow: none;
    border-radius: 0;
    padding-left: 0;
    padding-right: 0;
}

/* The one surviving container: a soft grey fill, no border, for stat tiles only. */
html[data-theme="light"] .content .tile {
    background: var(--tile-fill);
    border: 0;
    box-shadow: none;
    border-radius: var(--r-card);
    padding: 16px;
}

/* With the page white, the ledger and the record are separated by a rule rather than by two frames. */
html[data-theme="light"] .content .lx-split > .lx-list {
    border-right: 1px solid var(--keyline);
    padding-right: 20px;
}

html[data-theme="light"] .content .lx-split > .lx-sheet {
    padding-left: 20px;
}


/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   THE ADORNMENT RESERVE — one cause, six live sites, and why they are all in this file
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   THE MECHANISM. The Fields block above sets `padding: 4px 7px 2px` on every text-entry control:

       html[data-theme="light"] input[type="text"] { … }          (0,2,2)

   A page that puts an icon, a clear ×, a currency symbol or a unit INSIDE its field reserves the
   room for it with a large horizontal padding on the input and positions the adornment absolutely
   over that reserve. Every one of those page rules is written `.wrapper input` — (0,1,1) — and
   loses. The reserve evaporates, the adornment is drawn over the first or last word of the field's
   own text, and it happens in light ONLY, so a dark screenshot cannot see it. Two instances shipped
   and were fixed one at a time (`.bk-search` in jobs.css §2, `.vs-search-bar` in boards.css F2.1)
   before anyone swept for the rest.

   WHY HERE AND NOT IN THE CLUSTER FILES. This is not six page bugs, it is one rule in this file
   colliding with a layout idiom used everywhere — and two of the carriers (`.mdl-unit-wrap`, from
   the shared modal shell, and `.ckm-search-box`, from CheckinModal) are cross-cluster by
   construction. Splitting the answer across four cluster files is precisely the drift this file
   exists to prevent: the next agent would fix a seventh instance locally and never learn there was
   a list. The two earlier fixes stay where they are rather than being churned; they are
   cross-referenced above.

   WHAT IS RESTATED. The HORIZONTAL reserve only, as a longhand, so light keeps the measured 4/2
   vertical geometry of the control layer and the page keeps its room. Each value below is the
   value dark computes at the same element, read off `probe.mjs`, not guessed.

   SPECIFICITY. `html[data-theme="light"]` is (0,1,1); with one class plus `input[type=…]` each
   selector below lands at (0,3,2) or higher, clearing the (0,2,2) Fields rule on class count. A
   bare `.wrapper input` would TIE at (0,2,2) and win only on document order — inside one file that
   works, but it is a trap for anyone who later moves the rule, so the type is always spelled out.

   THE FULL SWEEP, INCLUDING WHAT NEEDED NOTHING (all three shells, driven, not read):
     · `.list-search` / `.ls-ic`   — icon is a FLEX SIBLING, not absolute. No reserve to lose.
     · `.cost-input-wrap`, `.qa-*` — same, flow layout with `order`. Safe.
     · `.rpp-money` (RoutinePricePane) — `<style scoped>`, so its selector carries `[data-v-*]` and
       lands at (0,3,1). It already outranks the Fields rule. Left alone deliberately.
     · `.tgp-chev` (TagPicker)      — absolute, but inside a `<button>`, not over an input.
     · `.setp-search-x::before`     — an invisible hit-area expander. Overlaps nothing legible.
     · `.duration-input input` (JobDetailDesktop.vue:704) — NOT FIXED, and not fixable here. Its own
       page injects `.modal-field input { padding: 10px 12px }` three lines later at the same
       (0,1,1), so the 34px reserve is already dead in DARK. Fixing it light-only would make the
       two themes disagree; fixing it in both moves dark. Reported, left alone. */

/* ── 1. /routines, the ledger's own search — the one the shop owner reported ───────────────────
   `.rt-search` (RoutinesDesktop.css:361) reserves `--sp-6` on the right for `.rt-search-clear`, the
   × that appears as soon as anything is typed. Measured in light: the × overlapped the query text
   by 21.3px. The same measurement in dark is 2.6px — the button's own padding box brushing the
   content edge, no glyph collision — which is what makes this light-only rather than pre-existing.
   Left padding is NOT restated: `--sp-3` there is ordinary padding, not a reserve, and 7px is the
   measured Stripe value the control layer is here to apply. */
html[data-theme="light"] .rt-searchwrap .rt-search {
    padding-right: var(--sp-6);
}

/* ── 2. /routines add-modal, the customer search ───────────────────────────────────────────────
   `.rt-search-box input` (RoutinesDesktop.css:799) — same × (`.rt-clear`, right `--sp-3`), same
   reserve. Latent rather than visible on a resting screenshot: the button is `v-if="custId"`, so it
   only appears once a customer has been PICKED, at which point it sits on their name. The same
   markup is used by InspectionBooking.vue:127. */
html[data-theme="light"] .rt-search-box input[type="text"] {
    padding-right: var(--sp-6);
}

/* ── 3. /routines add-modal, the price fields ──────────────────────────────────────────────────
   `.rt-cost input` (RoutinesDesktop.css:1506) reserves `--sp-6` on the LEFT for the currency symbol
   `.cur-adorn`, absolutely positioned at `left: --sp-3`.

   Scoped to `.cur-before` on purpose. In a symbol-trailing market (SEK) app.css:1023 already
   declares `.rt-cost.cur-after input { padding: 10px 34px 10px 12px }` at (0,3,1), which outranks
   the Fields rule on class count and is therefore ALREADY correct in light — restating it here
   would hand `cur-after` a 32px left gutter it has no adornment to fill. Only the leading-symbol
   markets ($, €) lost their reserve. */
html[data-theme="light"] .rt-cost.cur-before input[type="number"] {
    padding-left: var(--sp-6);
}

/* ── 4. CheckinModal, the customer search — FOUND, DELIBERATELY NOT FIXED ─────────────────────
   `.ckm-search-box input` (CheckinModal.vue:412) asks for `--sp-7` on the right to clear
   `.ckm-clear`. It reads like the same bug as §2 and it is not: the reserve was never in effect.
   Seven lines earlier the same file declares `.ckm-field input[type=text] { padding: var(--sp-3) }`
   (CheckinModal.vue:371) at (0,2,1), which outranks `.ckm-search-box input` at (0,1,1) — so DARK
   computes a flat 12px, measured, and the × already sits on the picked customer's name there.

   A light-only rule restoring 48px would make light the only theme where this field is correct,
   i.e. it would put the two themes into disagreement in order to look better. The fix belongs in
   CheckinModal.vue (raise the reserve selector above the field selector), which moves dark and is
   therefore not this task's to make. Raised in the report; this note exists so the next sweep does
   not re-discover it and "fix" it here. */

/* ── 5. The shared modal shell's unit field — the widest blast radius of them all ─────────────
   `.mdl-unit-wrap input` (modalShellStyle.ts:246) reserves `--sp-7` on the right for `.mdl-unit`,
   the "min" / "h" / "kr" printed inside the box so the unit can never wrap to its own line. Eight
   fields across the manual-booking sheet and /quote-draft-detail. Measured in light on the booking
   form: 20.5px of the unit sat on top of the digits; the same field in dark computes
   `12px 48px 12px 12px` and is clean.

   `:not(.bk-price)` is deliberate and its cost is understood — a `:not()` contributes its
   argument's specificity, taking this selector to (0,4,2). That is wanted: §6 needs to say
   something different about the same element, and an exclusion states the exception once instead of
   relying on a source-order tiebreak between two rules ten lines apart. */
html[data-theme="light"] .mdl-unit-wrap:not(.bk-price) input[type="number"] {
    padding-right: var(--sp-7);
}

/* ── 6. The booking sheet's price field ────────────────────────────────────────────────────────
   `.bk-price` (JobsDesktop.css:1877) is a `.mdl-unit-wrap` whose adornment is on the LEFT
   (`.bk-price-cur`, absolute at `left: --sp-3`), so it overrides the shell's right reserve back
   down and reserves `--sp-6` on the left instead. Dark computes `12px 12px 12px 32px`; light had
   lost the 32px and the symbol sat on the first digit by 12px.

   Only the left is restated. The right stays at the control layer's 7px rather than dark's 12px —
   there is no adornment on that side, and a light field with no reason to be padded wider than
   every other light field should not be.

   NOT FIXED, AND NOT A LIGHT BUG: `.bk-price-cur` is positioned at `left` unconditionally, so in a
   symbol-trailing market the "kr" renders on the LEFT of the number in BOTH themes —
   `span.cur-adorn.cur-after { order: 2 }` (app.css:985) is a flow-layout property and is inert
   against an absolutely-positioned element. That is a real defect and it is dark's too; it needs a
   page-sheet fix, not a light override. Raised in the report. */
html[data-theme="light"] .mdl-unit-wrap.bk-price input[type="number"] {
    padding-left: var(--sp-6);
}

/* ── 7. /job-detail phone, the admin-tail minutes field ────────────────────────────────────────
   `.jdp-duration-input input` (JobDetailPhone.css:431) reserves 34px on the right for
   `.jdp-duration-unit` ("min") at `right: 10px`. Unlike its desktop twin this one is genuinely
   light-only: the page's competing `.jdp-modal-field input` shorthand is declared EARLIER (line
   413) at the same (0,1,1), so the 34px stands in dark and only the Fields rule takes it away.
   34px is literal because the page's is — this field is not on the spacing scale. */
html[data-theme="light"] .jdp-duration-input input[type="number"] {
    padding-right: 34px;
}
/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   LIGHT THEME — THE APP SHELL CHROME
   Notification centre · nav drawer + toggle · floating bell · offline banner · wake strip · toasts
   ───────────────────────────────────────────────────────────────────────────────────────────────
   TARGET FILE: public/theme-light/_base.css, without exception. Every selector below belongs to a
   component mounted by src/layouts/MainLayout.vue (lines 4-12) on EVERY route — NavBar,
   NotificationCenter, ServerWaking, OfflineBanner. None of it is cluster-scoped, so none of it can
   live in a cluster file. VERIFIED, not assumed: `grep -rn 'ntf-\|nav-link\|offl\|\.wake'
   public/theme-light/` returns exactly four existing rules —
       _base.css:623  .side-nav        (surface only)
       _base.css:638  .nav-toggle      (resting control recipe only)
       _base.css:648  .nav-backdrop    (scrim — correct, untouched below)
       boards.css:1080 .ntf-badge      (box-shadow ring only)
   The boards.css entry is MISPLACED: the bell renders on every route, not just the boards. It should
   move into _base.css beside §A9 below. Its declaration is kept (only a ring, no conflict).

   THE FAULT THE USER FLAGGED, MEASURED
   `NotificationCenter.css:63` builds the panel out of `rgba(var(--ch-indigo-18102b), 0.97)`. That
   channel is a THEME-FLIPPING variable: `app.css:1275` redefines it in light as `219, 224, 240`.
   Probed live: `.ntf-panel background-color = rgba(219, 224, 240, 0.97)` — a saturated pale-blue
   slab where the design calls for white. The same channel builds `.ntf-toast` (line 227),
   `ServerWaking.css:.wake` and `OfflineBanner.css:.offl`, so one mechanism failure produced four
   blue surfaces. A panel is a SURFACE, not an accent; the fix is --surface everywhere, and the
   colour budget goes back to the unread dot and the badge, which carry real information.

   SPECIFICITY. Every rule is `html[data-theme="light"] …` = (0,2,0) minimum, so dark cannot match by
   construction, and each one is written one step above the page rule it must beat (checked
   individually — `.ntf-item.unread:hover` is (0,3,0), so its override here is (0,4,0)).
   No !important. No colour literals. No @layer. No color-mix().
   ═══════════════════════════════════════════════════════════════════════════════════════════════ */


/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   A. THE NOTIFICATION CENTRE
   ═══════════════════════════════════════════════════════════════════════════════════════════════ */

/* ── A1. The panel is a surface ───────────────────────────────────────────────────────────────
   CONFIRMED. Was rgba(219,224,240,0.97) + `0 16px 44px rgba(20,25,40,0.55)` + a lit top inset edge.
   Three dark devices at once: an accent-channel fill, a 44px navy lift, and a white catch-light that
   composites to NOTHING on a white surface (255 over 255).

   A dropdown panel is one of the few things in this theme that genuinely floats, so it keeps
   --elevation-3 — and per the either/or rule it therefore gets NO border. The 1px edge that remains
   is a spread SHADOW, not a border: light's --elevation-3 is two very soft, very offset layers
   (`0 30px 45px -30px` / `0 18px 36px -18px`), which reads as lift but draws no boundary at the top
   edge where the panel meets white. The spread layer is what gives it a rim. It occupies no layout,
   so nothing shifts.
   `backdrop-filter` is already `none` in light (--glass-filter-28-160, app.css:2241); restated so
   the intent survives a token edit. */
html[data-theme="light"] .ntf-panel {
    background: var(--surface);
    border: 0;
    border-radius: var(--r-panel);
    box-shadow: 0 0 0 1px rgba(var(--edge-rgb), 0.10), var(--elevation-3);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
}

/* ── A2. Header rule ──────────────────────────────────────────────────────────────────────────
   A header rule is one of the three lines a Stripe table is allowed. One colour, one weight — the
   same --keyline that draws the row dividers, so a header rule and a row rule can never near-miss. */
html[data-theme="light"] .ntf-head {
    border-bottom-color: var(--keyline);
    padding: 12px 14px;
}

/* ── A3. The panel title ──────────────────────────────────────────────────────────────────────
   CONFIRMED it was `rgb(0, 42, 182)` at 14.72px — a saturated blue heading, which is the single
   most expensive thing you can spend the accent budget on: it is not an action and cannot be
   clicked. It becomes the header label: 11px, --fw-label, sentence case, letter-spacing 0.
   Matches `.mdl-head .mdl-title` in _base.css:937, which is the same object in a modal.
   --ink-dim rather than --ink-muted so it stays the anchor of the strip: "Rensa alla" beside it is
   --ink-muted, and the title has to outrank it. */
html[data-theme="light"] .ntf-title {
    font-size: var(--eyebrow-size);
    font-weight: var(--fw-label);
    letter-spacing: 0;
    text-transform: none;
    color: var(--ink-dim);
}

/* ── A4. Master clear ─────────────────────────────────────────────────────────────────────────
   Colour was already right (--ink-muted, probed rgb(105,115,134)); only the type was off-scale.
   The destructive hover is --st-breach, which is the token the red it already used resolves to. */
html[data-theme="light"] .ntf-clearall {
    font-size: var(--eyebrow-size);
    font-weight: var(--fw-label);
    letter-spacing: 0;
    color: var(--ink-muted);
}

html[data-theme="light"] .ntf-clearall:hover {
    color: var(--st-breach);
}

/* ── A5/A6. Tabs are underlines ───────────────────────────────────────────────────────────────
   The mechanism was already correct — a 2px bottom border, never a filled capsule — so this is
   type and ink only. 11px / --fw-label / letter-spacing 0, sentence case (every source string in
   localization.ts:687-690 is already sentence case, so `text-transform: none` is NOT a no-op here;
   it is load-bearing against any future uppercase rule).

   Side padding drops 6px -> 4px for a real reason: four tabs with `flex: 1; white-space: nowrap`
   inside a 360px panel currently overflow, and `.ntf-panel { overflow: hidden }` CLIPS the fourth
   ("Fakturor"). Visible in the baseline capture. 11px type plus tighter padding buys the room back.
   This does not fully fix it — see the report; the panel width is the real constraint. */
html[data-theme="light"] .ntf-tabs {
    border-bottom-color: var(--keyline);
    padding: 4px 8px 0;
}

html[data-theme="light"] .ntf-tab {
    padding: 6px 4px 7px;
    font-size: var(--eyebrow-size);
    font-weight: var(--fw-label);
    letter-spacing: 0;
    text-transform: none;
    color: var(--ink-muted);
}

/* Ink only. Nothing moves, no fill appears. */
html[data-theme="light"] .ntf-tab:hover {
    color: var(--ink-dim);
}

/* The one place the accent is spent in this strip: the underline that says "you are here". */
html[data-theme="light"] .ntf-tab.active {
    color: var(--ink);
    border-bottom-color: var(--accent);
}

/* ── A7/A9. The counts keep their hue — and get legible ink ───────────────────────────────────
   A REAL CONTRAST DEFECT, not a taste call. Both counters set `color: var(--ink)` over
   `background: var(--unread-count)`. In light --unread-count is #b91c1c (app.css:2487) and --ink is
   #061b31 (app.css:~455): navy on dark red measures 2.68:1, under the 4.5:1 floor and under the 3:1
   large-text floor as well. Probed: `.ntf-badge color = rgb(6,27,49)` over `rgb(185,28,28)`.
   White on #b91c1c is 6.54:1. The hue stays — this is the one saturated mark on the surface and it
   carries a real number — but the ink has to be the one that can be read on it.

   --surface rather than a literal white, so the token stays the single source.
   Type comes up to the 11px floor (was 9.92px and 10.24px, both under it), with the box grown to
   match; radius drops to the 4px ceiling — the badge layer in _base.css:91 already states that a
   badge is a rectangle, not a pill. */
html[data-theme="light"] .ntf-tab-count {
    min-width: 18px;
    padding: 0 5px;
    border-radius: var(--r-control);
    background: var(--unread-count);
    color: var(--surface);
    font-size: var(--eyebrow-size);
    font-weight: var(--fw-label);
    line-height: 18px;
}

/* The `box-shadow` line came in from boards.css, where it had been misfiled: the bell is mounted by
   MainLayout on EVERY route, so a boards-cluster sheet was the wrong owner and the split meant the
   badge's geometry lived in one file and its ring in another. Folded into the rule it belongs to.

   THE ORDER TIE WAS CHECKED BEFORE THE MOVE, because _base.css loads FIRST and a rule that had been
   winning on document order would have died silently here: `grep -rn 'ntf-badge' public/ src/`
   returns exactly two light rules — this one and boards.css's — and boards.css declared ONLY
   box-shadow, so there is nothing at (0,2,0) or above left in any later sheet for this to lose to.
   NotificationCenter.css:35 is unprefixed at (0,1,0) and loses on specificity regardless of order.
   No specificity bump needed.

   Its own reasoning, kept verbatim: a saturated red disc directly on top of a dark glyph has no
   separation from it — the two shapes merge at the overlap. A 2px ring in the surface colour
   punches it out. */
html[data-theme="light"] .ntf-badge {
    min-width: 18px;
    height: 18px;
    padding: 0 5px;
    border-radius: var(--r-control);
    background: var(--unread-count);
    color: var(--surface);
    font-size: var(--eyebrow-size);
    font-weight: var(--fw-label);
    line-height: 18px;
    box-shadow: 0 0 0 2px var(--surface);
}

/* ── A8. The activity dot ─────────────────────────────────────────────────────────────────────
   Was #3a64ed (--c-indigo-a78bfa's light value), a second blue one step off the accent. There is
   one accent in this theme; a mark that means "there is something here" uses it. */
html[data-theme="light"] .ntf-tab-dot {
    background: var(--accent);
}

/* ── A10/A11. The action strip ────────────────────────────────────────────────────────────────
   "Markera dessa som lästa" is an ACTION, so it is allowed the accent — that is the whole of the
   accent's remaining job on this surface. "Rensa" is destructive and quiet until hovered. Both drop
   to the 11px label rung (were 11.68px, off-scale rather than wrong). */
html[data-theme="light"] .ntf-actions {
    padding: 6px 14px 2px;
}

html[data-theme="light"] .ntf-markall {
    font-size: var(--eyebrow-size);
    font-weight: var(--fw-label);
    letter-spacing: 0;
    color: var(--accent);
}

html[data-theme="light"] .ntf-markall:hover {
    color: var(--accent-hover);
}

html[data-theme="light"] .ntf-clear {
    font-size: var(--eyebrow-size);
    font-weight: var(--fw-label);
    letter-spacing: 0;
    color: var(--ink-muted);
}

html[data-theme="light"] .ntf-clear:hover {
    color: var(--st-breach);
}

/* ── A12-A15. Rows ────────────────────────────────────────────────────────────────────────────
   The list loses its 6px inset padding and the rows take it instead, so the divider spans the full
   panel width. A 10px-radius floating tile per row inside a panel is a box inside a box; a row in a
   list is a row, separated by one hairline.

   The divider is `inset 0 -1px 0 0 var(--keyline)`, never a border — which is exactly what lets the
   unread state stack a 3px leading bar into the SAME declaration without disturbing layout or
   losing the rule. Every state below restates the divider layer for that reason: box-shadow does
   not cascade layer-by-layer, so omitting it deletes the rule. */
html[data-theme="light"] .ntf-list {
    padding: 0;
}

html[data-theme="light"] .ntf-item {
    padding: 10px 14px;
    border-radius: 0;
    box-shadow: inset 0 -1px 0 0 var(--keyline);
}

/* States go DARKER. --surface-inset, never toward white — a hover resolving to --surface on a white
   panel paints nothing at all, and the same declaration serves :focus-visible. */
html[data-theme="light"] .ntf-item:hover {
    background: var(--surface-inset);
    box-shadow: inset 0 -1px 0 0 var(--keyline);
}

/* CONFIRMED FAULT: unread was `rgba(40, 81, 217, 0.1)` — a 10% wash of the accent across the whole
   row, i.e. the accent budget spent on a background, which is the panel's blue-slab problem in
   miniature. Selection/emphasis in this theme is TINT PLUS A 3px LEADING BAR, never tint alone:
   the tint alone is barely perceptible on white, and the bar is what actually reads. */
html[data-theme="light"] .ntf-item.unread {
    background: var(--surface-inset);
    box-shadow: inset 3px 0 0 0 var(--accent), inset 0 -1px 0 0 var(--keyline);
}

/* One step further down the ramp, so an unread row still answers the pointer. --surface-3-hover is
   #edeff4 in light and declared in both theme blocks (app.css:1654 / 2039), so parity holds. */
html[data-theme="light"] .ntf-item.unread:hover {
    background: var(--surface-3-hover);
    box-shadow: inset 3px 0 0 0 var(--accent), inset 0 -1px 0 0 var(--keyline);
}

/* ── A16. The severity dot — the one place hue is earned ──────────────────────────────────────
   It is the second carrier beside the row's text, it is 8px, and it is information. It keeps its
   hue; it just uses THIS theme's tokens rather than the dark family's light aliases.
   The read dot goes to --keyline-strong: a shape that is present but says nothing. */
html[data-theme="light"] .ntf-dot {
    background: var(--accent);
}

html[data-theme="light"] .ntf-item.warning .ntf-dot,
html[data-theme="light"] .ntf-toast.warning .ntf-dot {
    background: var(--st-expected);
}

html[data-theme="light"] .ntf-item.critical .ntf-dot,
html[data-theme="light"] .ntf-toast.critical .ntf-dot {
    background: var(--st-breach);
}

html[data-theme="light"] .ntf-item:not(.unread) .ntf-dot {
    background: var(--keyline-strong);
}

/* ── A17/A18. Row text ────────────────────────────────────────────────────────────────────────
   `.ntf-count` (the "×4" on a collapsed conversation) was --c-indigo-a78bfa = #3a64ed. A count is
   not a status and not an action; it is --ink-muted. */
html[data-theme="light"] .ntf-item-title {
    color: var(--ink);
}

html[data-theme="light"] .ntf-item-text,
html[data-theme="light"] .ntf-time {
    color: var(--ink-muted);
}

html[data-theme="light"] .ntf-count {
    color: var(--ink-muted);
    font-weight: var(--fw-label);
}

/* ── A19. The per-row "mark read" tick ────────────────────────────────────────────────────────
   The control recipe, at 20px. No border: the edge is a 1px spread shadow with
   `0 0 0 0 transparent` pre-declared so `transition: box-shadow` INTERPOLATES rather than snapping.
   Its old hover flooded with `rgba(var(--ch-violet-7c3aed), 0.25)` — a 25% accent wash on a
   20px button, which reads as selected rather than as pointed-at.
   MESSAGES TAB ONLY — it renders on `tab === 'messages' && r.unread` (NotificationCenter.vue:64). */
html[data-theme="light"] .ntf-read {
    border: 0;
    border-radius: var(--r-control);
    background: var(--surface);
    color: var(--ink-dim);
    line-height: 20px;
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        0 1px 1px rgba(0, 0, 0, 0.08),
        0 0 0 0 transparent;
    transition: box-shadow 0.08s ease-in, color 0.08s ease-in;
}

html[data-theme="light"] .ntf-read:hover {
    background: var(--surface);
    color: var(--ink);
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        0 1px 1px rgba(0, 0, 0, 0.10),
        0 3px 9px rgba(var(--edge-rgb), 0.10);
}

/* ── A20. Empty state ─────────────────────────────────────────────────────────────────────────
   Centred muted type in the middle of a pane is a marketing device. An empty state is a fact,
   stated where the data would have been — the same call _base.css:215 already makes for
   `.state-msg` and :454 for `.lx-empty`. */
html[data-theme="light"] .ntf-empty {
    padding: 16px 14px;
    text-align: left;
    font-size: var(--t-body);
    color: var(--ink-muted);
}

/* ── A21/A22. Toasts ──────────────────────────────────────────────────────────────────────────
   Same blue-slab channel, same 34px dark lift. The `border-left: 3px` severity bar is the ONE dark
   device here worth keeping — it is already the leading-bar idiom this theme uses for emphasis — so
   it is restated as an inset shadow layer instead, which keeps the either/or rule intact (the toast
   floats, so it takes the elevation and no border at all) and keeps the 3px out of layout.
   The severity variants are (0,2,0); these are (0,3,0) and must restate every layer. */
html[data-theme="light"] .ntf-toast {
    border: 0;
    border-radius: var(--r-control);
    background: var(--surface);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    box-shadow:
        inset 3px 0 0 0 var(--accent),
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        var(--elevation-3);
}

html[data-theme="light"] .ntf-toast.warning {
    box-shadow:
        inset 3px 0 0 0 var(--st-expected),
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        var(--elevation-3);
}

html[data-theme="light"] .ntf-toast.critical {
    box-shadow:
        inset 3px 0 0 0 var(--st-breach),
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        var(--elevation-3);
}

html[data-theme="light"] .ntf-toast-x {
    color: var(--ink-muted);
}

html[data-theme="light"] .ntf-toast-x:hover {
    color: var(--ink);
}

/* ── A23. The floating bell ───────────────────────────────────────────────────────────────────
   CONFIRMED: `box-shadow: rgba(20,25,40,0.35) 0 4px 16px, rgba(255,255,255,0.18) 0 1px 0 inset` at
   `border-radius: 12px` with a 1px navy border. That is a dark-mode lift — a 16px drop shadow at
   35% plus a white catch-light — sitting on a white page, where the catch-light composites to
   nothing and the drop reads as a smudge. Its sibling `.nav-toggle` 50px to the right was already
   fixed in _base.css:638 and the two chromes have been visibly disagreeing ever since.

   This is that identical declaration, so the pair matches by construction. Hover changes ink and
   deepens the ambient layer; nothing moves and no colour changes. */
html[data-theme="light"] .ntf-bell {
    border: 0;
    border-radius: var(--r-control);
    background: var(--surface);
    color: var(--ink-dim);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        0 1px 1px rgba(0, 0, 0, 0.08),
        0 0 0 0 transparent;
    transition: box-shadow 0.08s ease-in, color 0.08s ease-in;
}

html[data-theme="light"] .ntf-bell:hover {
    background: var(--surface);
    color: var(--ink);
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        0 1px 1px rgba(0, 0, 0, 0.10),
        0 3px 9px rgba(var(--edge-rgb), 0.10);
}

/* Open is a state, not an action: it takes a firmer edge and darker ink, never a fill. Same move
   as `.pb-chip.on` in _base.css:827. */
html[data-theme="light"] .ntf-bell.open {
    color: var(--ink);
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.28),
        0 1px 1px rgba(0, 0, 0, 0.08),
        0 0 0 0 transparent;
}

/* ── A25. THE FOCUS RING, AND WHY IT HAS TO BE RESTATED HERE ──────────────────────────────────
   Found by capture, not by reading, and it is a hazard this file CREATES rather than inherits.

   `.ntf-bell:focus-visible` (NotificationCenter.css:27) carries the app's inner ring as a
   `box-shadow`, and a pseudo-class weighs the same as a class: that selector is (0,2,0). My
   `html[data-theme="light"] .ntf-bell` above is ALSO (0,2,0). A tie is broken by document order —
   which in production favours the component (usePageStyle injects it at runtime, after every
   <link>), and in the injection harness favours mine. A focus indicator whose survival depends on
   which sheet happens to load last is not a focus indicator.

   Restated at (0,3,0), and again at (0,4,1) for the pointer-plus-keyboard case, where the :hover
   rule above would otherwise outrank a bare :focus-visible. The precedent is NavBar.css:203, which
   documents the identical trap on `.nav-link.active`. The `outline` half of the ring was never at
   risk — nothing here declares `outline`. */
html[data-theme="light"] .ntf-bell:focus-visible,
html[data-theme="light"] .ntf-bell:hover:focus-visible,
html[data-theme="light"] .ntf-bell.open:focus-visible {
    box-shadow: inset 0 0 0 2px var(--focus-ring-inner);
}

/* `.ntf-backdrop` is DELIBERATELY not given a scrim. It is `position: fixed; inset: 0` with no
   background in either theme (NotificationCenter.css:52) — a click-catcher for a dropdown, not a
   modal veil. A dropdown that dims the page it is annotating would be a behaviour change, and it
   would be a light-only one, which is worse. Left alone on purpose. */


/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   B. THE NAV DRAWER AND ITS TOGGLE
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   The drawer's SURFACE was already handled (_base.css:623) and the scrim is correct
   (_base.css:648, probed at rgba(20,25,40,0.42)). What was never done is the drawer's INTERIOR:
   every row inside it still wears dark's geometry and dark's accent wash. */

/* ── B1. The drawer floats, so it takes the elevation and NOT a border ────────────────────────
   _base.css:623 currently gives it BOTH `border-left: 1px solid var(--keyline)` AND
   --elevation-3. Depth is spent once, at the outermost thing that genuinely floats — and a drawer
   over a 42% scrim needs no hairline to find its edge. This AMENDS that block rather than adding a
   new one; when it lands in _base.css the `border-left` line there is simply deleted. */
html[data-theme="light"] .side-nav {
    /* `border-left: 0` -> `border-left-color: transparent`, 2026-08-11.
       `border: 0` removes the LAYOUT BOX as well as the paint, so the drawer's content column
       measured 208px in light against 207px in dark — the same class of one-sided geometry fork this
       pass exists to remove, just a small one. Measured: `.nav-link` 208 @x=1676 light, 207 @x=1677
       dark. `NavBar.css:85` declares that border for both themes and is correct; nothing needs to
       move there. Keeping the 1px and making it transparent preserves this block's intent exactly —
       a drawer over a 42% scrim needs no visible hairline — while the two themes measure the same. */
    border-left-color: transparent;
}

/* ── B2. The brand ────────────────────────────────────────────────────────────────────────────
   `letter-spacing: 2.5px` at 12.48px, and `.brand-ai` is a two-stop gradient clipped to text.
   Gradient hygiene: in light, delete rather than restyle. `-webkit-text-fill-color` is the
   load-bearing line — it BEATS `color`, so setting `color` alone leaves the clipped gradient
   rendering and the fix looks applied while changing nothing. Same trap _base.css:322 documents
   for `.purple-gradient`. The word AI is the one accent mark in the drawer's header. */
html[data-theme="light"] .nav-brand {
    font-size: var(--eyebrow-size);
    font-weight: var(--fw-label);
    letter-spacing: 0;
    color: var(--ink-dim);
    /* `margin-bottom: 20px` deleted — NavBar.css now sets the same 20px for both themes, and a
       light copy at (0,2,0) silently outranks it. Harmless today, and precisely how this whole
       divergence began: one value declared in two places, only one of which gets updated next. */
}

html[data-theme="light"] .brand-ai {
    background: none;
    -webkit-background-clip: border-box;
    background-clip: border-box;
    -webkit-text-fill-color: var(--accent);
    color: var(--accent);
}

/* ── B3. Section label ────────────────────────────────────────────────────────────────────────
   Probed at 9.28px — BELOW the 11px floor, which exists because this app is read at arm's length in
   a bay under glare — uppercase, tracked 1.5px. It becomes the sentence-case 11px label.
   `text-transform: none` is load-bearing, NOT a no-op: the source string is 'Hantering'
   (localization.ts:2832) and the uppercase is applied by CSS, so removing it restores the word. */
html[data-theme="light"] .nav-section-label {
    font-size: var(--eyebrow-size);
    font-weight: var(--fw-label);
    text-transform: none;
    letter-spacing: 0;
    color: var(--ink-muted);
    border-top-color: var(--keyline);
}

/* ── B4/B5. The rows ──────────────────────────────────────────────────────────────────────────
   12px radius (three times the ceiling) and a 1px transparent border reserving layout for an edge
   that only ever appears on hover. Rows in a rail are rows: 4px, no border, ink and a tone step. */
/* GEOMETRY MOVED OUT, 2026-08-11. `padding: 8px 12px` and `border: 0` lived here and made the
   light drawer 152px shorter than dark — `.nav-link` measured 37px in light against 46px in dark,
   9px of padding plus 2px of border, over sixteen rows.

   The visible symptom was "the light menu is shorter". The actual defect was on the other side:
   `.side-nav` is `position: fixed; height: 100%; overflow: visible`, so at 1280x900 DARK's drawer
   ran past the viewport and cut off Fakturor, Inställningar and Logga ut with no way to scroll to
   them. Light happened to fit. So the two themes were not "a bit different" — one of them was
   unusable, and that settles which way they converge: dark comes down, light does not go up.

   Row height, padding and the drawer's scroll now live in `src/components/NavBar.css`, unprefixed,
   for both themes. `border: 0` could NOT simply move there: dark COLOURS this border on hover and
   active, so removing the 1px in a shared sheet would move dark's paint — the one thing compare.mjs
   polices. It becomes `border-color: transparent` instead, which keeps light's intent (no visible
   edge, ever) while restoring the 1px the box model needs to measure the same as dark.

   `font-size` stays. The page sheet pins `line-height`, so the row is 40px whether the text is
   light's 14px or dark's 14.4px — the two themes keep their own type scale and still measure
   identically. */
html[data-theme="light"] .nav-link {
    border-color: transparent;
    border-radius: var(--r-control);
    font-size: var(--t-h2);
    /* NO font-weight. Deliberate, and verified on a capture: --fw-label (600) put sixteen
       600-weight rows in one drawer, which is the whole weight budget spent on navigation. The page
       sheet's `var(--fw-500)` is correct and is left to stand — 500 is not one of the rungs light
       collapses (app.css:1806 declares it once for both themes), so it needs no intervention. */
    color: var(--ink-dim);
    transition: background-color 0.08s ease-in, color 0.08s ease-in;
}

/* Darker, not lighter. The old value (`rgba(var(--sf-rgb), 0.07)`) happens to land close by
   accident of the channel inverting; --surface-inset says it on purpose. `border-color` in the page
   rule is now INERT — border-width is 0 — so the tone step is the whole response. */
html[data-theme="light"] .nav-link:hover {
    background: var(--surface-inset);
    color: var(--ink);
}

/* ── B6. The active row — the lit inset edge, and what replaces it ────────────────────────────
   `.nav-link.active` computes `inset 0 1px 0 rgba(255, 255, 255, 0.1)`: white at 10% laid on a
   white-ish surface. It composites to NOTHING. A lit top edge is a dark device — it exists because
   a hard line reads as a scratch on near-black — and on white it is either invisible or an
   artefact. Confirmed by probe; the declaration is present and paints zero.

   It also carried an 18% accent wash + an accent border + accent text, i.e. three carriers for one
   fact. Emphasis in this theme is TINT PLUS A 3px LEADING BAR: the tint gives the row its ground,
   the bar is what the eye actually finds, and the bar is an inset shadow so the row's box never
   changes size and the rail's left edge stays straight. (0,3,0) against the page rule's (0,2,0). */
html[data-theme="light"] .nav-link.active {
    background: var(--surface-inset);
    color: var(--ink);
    /* `border: 0` -> `border-color: transparent`, for the same reason as B4/B5 above. The leading
       bar is an inset shadow precisely so the row's box never changes size — and that only holds if
       the active row's box matches its siblings', which needs the 1px border width to survive here
       too. Dropping it would make the one selected row 2px shorter than every other row. */
    border-color: transparent;
    border-radius: var(--r-control);
    box-shadow: inset 3px 0 0 0 var(--accent);
}

/* ── B7/B8. Icons ─────────────────────────────────────────────────────────────────────────────
   `opacity: 0.8` is a dark-theme dimmer: it works against near-black and it drops a navy stroke's
   contrast on white for no gain. The icons are `stroke: currentColor`, so they already follow the
   row's ink ladder — the opacity was doing the ladder's job badly.
   The active icon's hard-coded `stroke: var(--c-indigo-a78bfa)` (#3a64ed) becomes the accent, so
   the drawer has exactly one blue in it. */
html[data-theme="light"] .nav-icon {
    opacity: 1;
}

html[data-theme="light"] .nav-link.active .nav-icon {
    stroke: var(--accent);
}

/* ── B9. The pending count ────────────────────────────────────────────────────────────────────
   UNVERIFIED — `.nav-count` renders only when `draftCount > 0` (NavBar.vue:75) and the probe
   returned NOT PRESENT IN DOM for this account. Reasoning stands on the rule: a teal capsule
   (#0f766e on a 16% teal fill) is a third colour family in a drawer that should have one, and a
   count is not a status. It becomes the neutral badge from _base.css §1 — inset fill, dim ink, a
   4px rectangle. If it must stay teal, that is a deliberate call to make with it on screen. */
html[data-theme="light"] .nav-count {
    border-radius: var(--r-control);
    background: var(--surface-inset);
    border: 1px solid var(--keyline-strong);
    color: var(--ink-dim);
    font-size: var(--eyebrow-size);
    font-weight: var(--fw-label);
}

/* ── B10. Log out ─────────────────────────────────────────────────────────────────────────────
   Destructive, so it is the one row allowed a status hue — and only on hover. --st-breach-soft is
   the measured 10% tint that pairs with --st-breach ink at AA on white. */
html[data-theme="light"] .logout-link {
    border-top: 1px solid var(--keyline);
    border-radius: var(--r-control);
}

html[data-theme="light"] .logout-link:hover {
    background: var(--st-breach-soft);
    color: var(--st-breach);
}

/* ── B11. The toggle's hover ──────────────────────────────────────────────────────────────────
   The resting state was fixed in _base.css:638; the HOVER was not, so pointing at the hamburger
   still fired `0 4px 20px rgba(20,25,40,0.4)` plus `0 0 12px rgba(40,81,217,0.2)` — a dark drop and
   a blue GLOW. Emission needs darkness to read as light; on white it is a smudge, and the light
   rulebook's gradient/glow hygiene says delete rather than restyle.
   The `border-color` in that hover rule is already inert (_base.css sets `border: 0`), which is
   trap 7: a property can lose by being inapplicable rather than by specificity.
   Byte-identical to the bell's hover above, which is the point — they sit 50px apart. */
html[data-theme="light"] .nav-toggle:hover {
    background: var(--surface);
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        0 1px 1px rgba(0, 0, 0, 0.10),
        0 3px 9px rgba(var(--edge-rgb), 0.10);
}

/* ── B12. The drawer's focus rings, restated for the same reason as A25 ───────────────────────
   Two of the rules above put a `box-shadow` where a focus ring already lives:

   · `.nav-link.active` — NavBar.css:203 restates the inner ring for exactly this reason ("so the
     current page's row is never the one that loses its focus indicator"), at (0,3,0). The leading
     bar above is also (0,3,0) and later, so it would take that ring back out. Restated at (0,4,0),
     and it now keeps BOTH: the current page's row does not stop looking current because you tabbed
     onto it, which is a small improvement on the original — that one dropped the active treatment.

   · `.nav-toggle:hover` — (0,3,0) above vs `.nav-toggle:focus-visible` at (0,2,0), so a focused
     hamburger that is also under the pointer loses its inner ring. (0,4,1) covers it. */
html[data-theme="light"] .nav-link.active:focus-visible {
    box-shadow: inset 3px 0 0 0 var(--accent), inset 0 0 0 2px var(--focus-ring-inner);
}

html[data-theme="light"] .nav-toggle:focus-visible,
html[data-theme="light"] .nav-toggle:hover:focus-visible {
    box-shadow: inset 0 0 0 2px var(--focus-ring-inner);
}

/* `.nav-backdrop` is already correct in _base.css:648 (--scrim, backdrop-filter none). Probed at
   rgba(20, 25, 40, 0.42). No rule here — restating it would be drift with extra steps. */


/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   C. THE OTHER TWO PIECES OF PERSISTENT CHROME
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   Both are mounted unconditionally by MainLayout.vue (lines 9 and 12) and both are built on the
   SAME `rgba(var(--ch-indigo-18102b), 0.97)` channel as the notification panel — which is why one
   channel inverting produced four blue surfaces rather than one. Fixing the panel alone would have
   left two of its siblings blue.

   §C1 is CONFIRMED on a real render — `OFFLINE=1` drives the banner in the live app, and the
   before/after pair is unambiguous: a pale-blue capsule with a dark blur halo and a retry button
   with no visible edge, against a white rectangle with an amber leading bar and a real control.
   §C2 is UNVERIFIED: the wake strip only mounts while a cold Icarus instance is booting, which the
   harness cannot stage. Its rules are the same three moves applied everywhere else in this file, on
   values read from source (ServerWaking.css:12, :47, :77). */

/* ── C1. The offline banner ───────────────────────────────────────────────────────────────────
   Its border was carrying the state (--st-expected-line / --st-logged-line), which is a border AND
   an elevation on a floating object. The state moves onto a 3px leading bar in the SOLID status
   token — stronger than the 30% line it replaces — and the dot beside it is the second carrier, so
   the fact is stated twice and neither is colour alone.
   `.offl[data-state='back']` is (0,2,0); this is (0,3,0). */
html[data-theme="light"] .offl {
    border: 0;
    border-radius: var(--r-card);
    background: var(--surface);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    box-shadow:
        inset 3px 0 0 0 var(--st-expected),
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        var(--elevation-3);
}

html[data-theme="light"] .offl[data-state='back'] {
    box-shadow:
        inset 3px 0 0 0 var(--st-logged),
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        var(--elevation-3);
}

/* Its retry button's hover resolves to `--surface-5`, which is #ffffff in light: a hover that
   paints NOTHING on a white banner, and the same declaration is all a keyboard user gets. The
   control recipe instead — ink darkens, ambient deepens, nothing moves. */
html[data-theme="light"] .offl-retry {
    border: 0;
    border-radius: var(--r-control);
    background: var(--surface);
    color: var(--ink-dim);
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        0 1px 1px rgba(0, 0, 0, 0.08),
        0 0 0 0 transparent;
    transition: box-shadow 0.08s ease-in, color 0.08s ease-in;
}

html[data-theme="light"] .offl-retry:hover:not(:disabled) {
    background: var(--surface);
    color: var(--ink);
    box-shadow:
        0 0 0 1px rgba(var(--edge-rgb), 0.10),
        0 1px 1px rgba(0, 0, 0, 0.10),
        0 3px 9px rgba(var(--edge-rgb), 0.10);
}

/* ── C2. The server-wake strip ────────────────────────────────────────────────────────────────
   Same slab, same 32px dark lift, plus a two-stop gradient on its progress fill and a #1d3995 blue
   title. A gradient bar reads as a lit surface on near-black and as a smear on white; a bar owes
   3:1 under SC 1.4.11 and one flat accent clears it. */
html[data-theme="light"] .wake {
    border: 0;
    border-radius: var(--r-card);
    background: var(--surface);
    backdrop-filter: none;
    -webkit-backdrop-filter: none;
    box-shadow: 0 0 0 1px rgba(var(--edge-rgb), 0.10), var(--elevation-3);
}

html[data-theme="light"] .wake-title {
    color: var(--ink);
    font-size: var(--t-body);
    font-weight: var(--fw-label);
}

html[data-theme="light"] .wake-hint,
html[data-theme="light"] .wake-secs {
    color: var(--ink-muted);
}

html[data-theme="light"] .wake-dot {
    background: var(--accent);
}

html[data-theme="light"] .wake-track {
    background: var(--surface-inset);
}

html[data-theme="light"] .wake-fill {
    background: var(--accent);
    background-image: none;
}

/* ── C3. Skeletons ────────────────────────────────────────────────────────────────────────────
   UNVERIFIED, and the specificity is why it is written this way. CardSkeleton.vue uses
   `<style scoped>`, so its own rule is `.sk-card[data-v-xxxxxxx]` = (0,2,0) — the same weight as a
   two-part light selector, and a tie is decided by source order, which for a runtime-injected SFC
   style against a <link> in index.html is not something to bet on. `.sk-grid .sk-card` is (0,3,0)
   and cannot tie.

   Only the radius is off-system: its fill (--surface-1 #fafbfd) and its 8%-navy edge already
   invert correctly, and a placeholder that lives for 300ms is not worth more than that.
   20px radius on a card in a 4px-ceiling theme is the whole defect. */
html[data-theme="light"] .sk-grid .sk-card {
    border-radius: var(--r-card);
}


/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   CheckinModal — moved out of boards.css F4
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   WHY THESE ARE HERE. `CheckinModal.vue` is imported by drop-offs (desktop/tablet/phone), vehicle
   storage AND job detail (JobDetailDesktop.vue, JobDetailPhone.vue) — five pages across three
   clusters. A rule for it written in boards.css is a rule the detail cluster cannot see it already
   has, which is the drift this file exists to stop. They were authored in boards.css §F4 against
   the OPENED modal (photographed with CLICK, since none of this appears in a resting screenshot)
   and moved here unchanged; the reasoning below is that section's, verbatim.

   TRAP THAT APPLIES TO EVERY SELECTOR BELOW. `CheckinModal.vue:8` is a `<Teleport to="body">` —
   its DOM is NOT inside `.vs-wrapper` or `.dropoffs-wrapper`. A rule scoped to either page root
   parses cleanly and matches NOTHING. Everything below is therefore scoped to the sheet's own
   class names and nothing else.

   THE MOVE'S OWN RISK, CHECKED RATHER THAN ASSUMED. _base.css loads BEFORE the cluster files, so
   any rule that had been winning a specificity TIE on boards.css's later document order would go
   silently dead on arrival here. Each was checked against every later file and then re-probed on
   the opened modal after the move:
     · `.ckm-zone-head`   (0,2,0) — no other file declares it. No tie.
     · `.ckm-field > label` (0,2,2) — CheckinModal's own is (0,1,1). No tie.
     · `.ckm-pill`        (0,2,0) — registers.css:511-512 declares `.ckm-pill:hover` and
       `.ckm-pill.on` at (0,3,0), and neither sets `border-radius`. Different property, higher
       specificity, no tie. That relationship is the reason the radius is ALL that is written here
       (see the note under it).
   None needed its specificity raised. */

/* ── COLOURED SECTION HEADINGS (was F4.1) ─────────────────────────────────────────────────────
   `.ckm-zone-head` is `color: var(--astra-accent-soft)` at `font-weight: 700; letter-spacing:
   0.12em; text-transform: uppercase` (CheckinModal.vue:360). That is the accent spent on something
   that is not an action and cannot be pressed — three of them across the top of the check-in modal,
   printed in the same blue as the "Checka in" button 300px below it.

   The strings are sentence case at source (`checkin_zone_who: 'Kund & fordon'`,
   localization.ts:3788-3790), so `none` is enough. */
html[data-theme="light"] .ckm-zone-head {
    font-size: var(--eyebrow-size);
    font-weight: var(--fw-label);
    text-transform: none;
    letter-spacing: 0;
    color: var(--ink-muted);
}

/* ── TRACKED UPPERCASE FIELD LABELS (was F4.2) ────────────────────────────────────────────────
   A dialog is where a Swedish interface shouts loudest: the labels are long and there are more of
   them per square inch than anywhere else in the app.

   `.mdl-label` — the SHARED modal frame's field label (modalShellStyle.ts:199) — is NOT in this
   rule, and checking that was worth the minute it cost: `detail.css:1274` already declares
   `html[data-theme="light"] .mdl-sheet .mdl-label` with these five declarations, character for
   character. Restating it here would be a duplicate that looks like coverage and is drift. */
html[data-theme="light"] .ckm-field > label {
    font-size: var(--eyebrow-size);
    font-weight: var(--fw-label);
    text-transform: none;
    letter-spacing: 0;
    color: var(--ink-muted);
}

/* ── THE CAPSULES (was F4.4) ──────────────────────────────────────────────────────────────────
   `.ckm-pill` (Behöver lyft / Wheel rack / Bay) is `--r-pill`. §2 above already made every board
   chip a rectangle; this one was missed because it only exists inside an overlay. */
html[data-theme="light"] .ckm-pill {
    border-radius: var(--r-control);
}

/* `.ckm-pill`'s STATE rules stop here, deliberately. `registers.css:511-512` already gives
   `.ckm-pill:hover` and `.ckm-pill.on` the applied-state idiom (`--surface-selected` plus an inset
   3px `--accent-bar`) — a better treatment than a flat inset fill, and one that already exists.
   Writing our own would also be DEAD CODE, and MORE dead after this move than before it: registers
   .css loads after _base.css as it loaded after boards.css, and at (0,3,0) it does not even need
   the tiebreak. Only the radius is written here, because that is the one property registers does
   not set. */

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   THE PAGE MEASURE — one left edge across the app
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   Six pages, three measures, three left edges (see --page-measure above for the numbers). These are
   the wrappers that were still on app.css's 1240 or on a literal; the two registers already reach
   1560 through `--lx-page` (registers.css), and the Work Queue console was already there.

   Light-only, deliberately: the same containment as the registers' C1 fix, so dark's layout cannot
   move and the immutable baseline stays clean while the wider composition is judged. If it survives
   review, 1560 goes to both themes as one justified re-baseline — at which point these four
   `max-width` lines and registers' `--lx-page` block collapse into a single change to --measure's
   page half. */
html[data-theme="light"] .cx-wrap,
html[data-theme="light"] .dropoffs-wrapper,
html[data-theme="light"] .vs-wrapper,
html[data-theme="light"] .set-measure {
    max-width: var(--page-measure);
}

/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   THE SHARED ERROR SURFACE — the one component on twenty routes, and it had had no light pass
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   `<ErrorPanel>` is rendered by roughly twenty pages; it is every page's error state. The only
   light rule it ever had was a fill correction filed under /settings, so everything structural
   about it was still dark's:

       border-radius: 12px      the ceiling in this theme is 4, and 4 is a ceiling, not a step
       border 1px + a fill      an edge AND a ground, on an object that is already inside a page
       x=40 w=1820              measured on a page whose own header was x=170 w=1560

   The third is the one that actually shows. Several pages render `<ErrorPanel>` as a direct child
   of the page root rather than inside the measure wrapper (Customers.vue:118, JobsDesktop.vue:7 and
   others), so when a read fails the notice is 260px WIDER than the page it is reporting about and
   starts 130px to the left of it — the one moment the user is being told something went wrong is
   the one moment the layout also breaks.

   Fixed here rather than in twenty templates: `max-width` plus auto margins puts it on the page
   measure wherever it is mounted, and is inert on the pages that already wrap it.

   ONE EDGE, NOT TWO: the fill stays and the border goes. A notice needs a ground — it is a thing
   that appeared, and space alone would not say so — and the fill is the quieter of the two ways to
   say it. Per "DEPTH, NOT OUTLINES" above, a container has a border OR a ground, never both.

   `width: 100%` is written WITH the auto margins and is load-bearing, caught on the render. Several
   of those parents are flex columns; an auto margin on a flex item overrides `stretch`, so the
   margins alone made the panel shrink-to-fit and float in the middle of the page (measured on
   /customers: x=737 w=425, against a header at x=170 w=1560). With an explicit 100% the box is no
   longer shrink-to-fit, the auto margins have nothing left to distribute inside a wrapper already
   at the measure, and it lands on the page's own left edge — while still centring on the pages that
   mount it at full viewport width. */
html[data-theme="light"] .errpanel {
    box-sizing: border-box;
    width: 100%;
    max-width: var(--page-measure);
    margin-left: auto;
    margin-right: auto;
    border: 0;
    border-radius: var(--r-card);
    background: var(--recess-a05);
}


/* ═══════════════════════════════════════════════════════════════════════════════════════════════
   THE TOUCH LAYER — one vocabulary for the two touch shells
   ═══════════════════════════════════════════════════════════════════════════════════════════════
   This block is the shared foundation for the phone and tablet passes. It exists because those two
   shells are ONE design problem wearing two resolutions: the same absent hover, the same finger,
   the same glove. Authoring them separately is what produced `.purple-gradient` in seven flavours,
   and the tablet has no stylesheet of its own to drift into — its rules land in these same cluster
   files, which is the other half of the reason this is decided once, here, first.

   WHAT THE NUMBERS ARE, AND WHY THEY ARE THESE NUMBERS

   Three different floors were in circulation and none was measured against this app: the handoff
   asserted ">=48px, 56px for primary", phone.css's own structural pass asserted 44px, and the
   density ladder above tops out at a 64px coarse row. They are reconciled here by measurement
   rather than by decree — `.claude/skills/run-app/touch-audit.mjs` reports every interactive
   element's real rendered box on a shell, and the evidence it produced was:

     - /settings on phone is the ONLY view in the app that audits clean, and it is the only view
       that declares a touch height: `SettingsPhone.css:237` sets `--set-ctl-h: 44px`. The number
       that already works here is 44, and it agrees with SC 2.5.5 (AAA) and with Apple's HIG.
     - Dark and light render /jobs identically small (22px search field, 26x26 calendar nav, 34px
       steppers). That is a PRE-EXISTING both-themes defect, not something light introduced, and it
       is on the app's default landing route.
     - `.btn-astra` is the one place light made touch WORSE: 36px in light against >=44px in dark on
       the same tablet. Divergence #1 ("buttons are 36px, not 28px") was argued for the bay tablet
       and then set below every touch floor there. 36px stays on desktop, which is where it was
       judged and signed off; it does not survive on a shell driven by a finger.

   So the floor is 44px, not 48px: it is the number this codebase already ships, already passes on,
   and can be raised to everywhere without contradicting a view that was verified at it. Inventing
   48 would have moved the one clean view for no measured reason.

   56px is kept for the primary and the destructive action only, and it earns its place from the
   ladder's own reasoning above: with a glove's contact patch the dominant error is the
   ADJACENT-MISTAP, not the miss. Height alone does not fix a mistap — separation does. So the
   control you must not hit by accident gets both, and `--tap-sep` is the second half of that pair
   rather than a spacing preference.

   WHY TWO DIFFERENT SCOPES BELOW, WHICH IS NOT AN INCONSISTENCY

   1. The control floor is in `@media (pointer: coarse)`. It has to be, because modals, drawers and
      popovers TELEPORT TO <body> (trap #21) and so have no `.page[data-shell]` ancestor to be
      scoped by. A media query reaches them; an attribute selector cannot.
   2. The density ladder is scoped to `.page[data-shell='…']`, because it must distinguish phone
      from tablet — both are `pointer: coarse` — and because `data-shell` is what the APP itself
      decides the shell with (`MainLayout.vue:14`, off `useViewport`). A width-based media query
      would be a second, disagreeing opinion about which shell is mounted.

   Rows live inside `.page`, so scope 2 reaches everything it needs to. Controls do not, so scope 1
   is the one that has to be a media query. Both are prefixed `html[data-theme="light"]`, so dark
   cannot match either by construction.
   ═══════════════════════════════════════════════════════════════════════════════════════════════ */

/* ── WHERE THIS BLOCK NOW LIVES, 2026-08-12 ────────────────────────────────────────────────────
   `--tap-min` / `--tap-primary` / `--tap-sep`, the control floor (§1) and the per-shell density
   ladder (§2) MOVED to `public/app.css`, § THE TOUCH LAYER — GEOMETRY, BOTH THEMES, unprefixed.

   THE REASON, and it is the whole defect this file created: the floor was authored in a
   `html[data-theme="light"]` sheet, so it existed in light only — and DARK is the default theme and
   the one Capacitor ships to the bay tablet and the Android app. Measured with `THEME=dark`,
   `FLOOR=44`: 10 of 12 routes failed at tablet and 6 of 12 at phone, with `.lx-tab` at 18px, while
   light passed 11 and 12. A touch floor is not a theme's opinion; it is the app's.

   Everything below is what did NOT move, and each has a reason:
     - The `.btn-astra*` overrides in §1, which exist only to beat LIGHT's own 36px button recipe.
     - §2b, the square-icon-button inventory, which is a note rather than a rule.
     - §3, the no-hover `opacity: 1` on `.crail` — PAINT, so moving it would move the frozen dark
       baseline and needs an explicit re-baseline rather than a quiet edit.

   The derivation of every number is still written out above and stays here; app.css points back at
   it rather than restating it. ─────────────────────────────────────────────────────────────── */


/* ── 1. THE CONTROL FLOOR — the generic rule MOVED to app.css (see the note above). ──────
   What is left in this media query is the LIGHT-ONLY half: the overrides that exist because light
   declares its own button heights, and which therefore have no counterpart in dark. The generic
   `:where(button, select, …) { min-height: var(--tap-min) }` now lives in public/app.css and
   reaches both themes, with its full derivation and the load-bearing `:where()` argument carried
   across intact. */
@media (pointer: coarse) {
    /* A checkbox and a radio are deliberately EXCLUDED from the floor above: the box is a glyph, and
       sizing it to 44px would draw a 44px tickbox rather than give it a 44px target. The target has
       to come from the label that wraps it instead.
       NOT written here as `label:has(> input[type="checkbox"])` — but the reason is narrower than an
       earlier draft of this comment claimed, and the correction is worth keeping.

       That draft said `:has()` was banned outright alongside `@layer` and `color-mix()`. That is
       WRONG and it was caught in review: `:has()` is not in the documented trap list, and this
       codebase already ships it at four sites — `registers.css:1878`,
       `InvoiceDraftDetailDesktop.css:595`, and `RoutinesDesktop.css:1316` + :832, the first of which
       is a live BOTH-THEMES layout rule. Asserting a ban the repo does not observe would have sent
       the next reader to "fix" four working rules.

       The real distinction is what FAILS when the selector is not understood. `color-mix()` in a
       value invalidates the declaration and leaves the element unpainted; `@layer` drops every rule
       inside the block. An unsupported `:has()` drops only its own rule, so it is safe where it is
       an ENHANCEMENT and unsafe where it is load-bearing. A touch-target floor is load-bearing —
       silently not applying an accessibility floor on exactly the old devices most likely to be a
       shop's bay tablet is the wrong failure mode. Hence per-cluster, against the real class name,
       which is in the step-2 brief. */

    /* ── THE FLOOR DEFEATED ITSELF. This block is the fix, and it is the most important rule here.
       The generic floor above is `html[data-theme="light"] button` = (0,1,2). The button recipe
       earlier in this file is `html[data-theme="light"] .btn-astra-glass` = (0,2,1), and 2 classes
       beats 1 before the element count is ever compared — so its `min-height: 36px` WON, on both
       touch shells, for all six of its selectors. Trap #6, firing inside the touch layer whose whole
       job is to prevent exactly this.

       It was invisible three ways at once: the audit reported green because most of these controls
       are only reachable behind a click (the booking sheet's Avbryt/Registrera ny measured 36x77
       with `pointer: coarse` true); the number was 36 rather than something obviously wrong; and
       that recipe's own comment ARGUES for a touch floor — "28px is a mouse-only number and this app
       is also driven on a bay tablet, where it sits under every touch floor" — and then sets 36,
       which is also under every touch floor. A rule that states the right principle and misses it by
       8px reads as considered rather than broken.

       The selector list is copied EXACTLY, character for character, from that recipe rather than
       rewritten shorter. That ties the specificity on every one of the six, including the odd
       `button.pb-chip` at (0,2,2), and document order then decides — this block is at the end of the
       file. A tidier three-class version would score lower and silently lose, which is the same
       mistake one level up.

       Only `min-height` is named. Padding, type, fill, edge and the shadow machinery stay exactly as
       measured; 36px remains the DESKTOP button height and divergence #1 is untouched. Reaches
       `.wq-act`, `.pb-chip`, `.pb-sort-btn` and `.fc-sort-btn` in three other cluster files too. */
    html[data-theme="light"] .btn-astra-neutral,
    html[data-theme="light"] .btn-astra-glass,
    html[data-theme="light"] .wq-act,
    html[data-theme="light"] button.pb-chip,
    html[data-theme="light"] .pb-sort-btn,
    html[data-theme="light"] .fc-sort-btn {
        min-height: var(--tap-min);
    }

    /* The primary and the destructive action. The `:not()` chain is copied EXACTLY from the primary
       button's own selector earlier in this file, which scores (0,5,1) — trap #16: a comfortable
       three-class override scores (0,4,1) and silently loses. Matching the chain ties the score and
       document order decides, which is why this block is at the end of the file rather than beside
       the control layer it is extending. */
    html[data-theme="light"] .btn-astra:not(.btn-astra-neutral):not(.btn-astra-glass):not(.btn-astra-danger),
    html[data-theme="light"] .btn-astra-danger,
    html[data-theme="light"] [type="submit"] {
        min-height: var(--tap-primary);
    }

    /* SEPARATION IS NOT BLANKETED EITHER, for the same reason and it was cut after the same render.
       `button + button { margin-left: var(--tap-sep) }` was written here and it is wrong twice over:
       it prises apart segmented controls and stepper pairs, which are adjacent BY DESIGN and read as
       one object precisely because they touch, and it was the second contributor to the /jobs
       command-bar overflow above.

       `--tap-sep` stays as the token because the underlying rule is real — the gloved error is the
       adjacent-mistap, so the destructive control must not abut its neighbour. But "which adjacent
       pairs are two targets and which are one control" is not decidable from a sibling selector. It
       is decided per cluster, against the render, and the pair that always qualifies is a
       confirm/cancel next to a destructive action. That is in the step-2 brief. */
}


/* ── 2. THE DENSITY LADDER, RE-RUNG PER TOUCH SHELL — MOVED to public/app.css, unprefixed. ──
   The rungs are pure GEOMETRY (`--row-h*`, `--ctl-h*`), so keeping them here forked the two
   themes on every touch shell: light got 64px gloved ledger rows on the tablet and dark, the
   theme actually running on that tablet, kept 48px. The argument for the numbers is unchanged and
   travelled with them; only the selector lost its `html[data-theme="light"]` prefix. */

/* ── 2b. THE SQUARE ICON BUTTONS — measured, named, and deliberately NOT fixed here ────────────
   These are every control that fails the floor on its WIDTH as well as its height, i.e. the ones
   the rule above cannot reach. They are listed with their measured boxes so the cluster that owns
   each one has the evidence rather than the assertion, and they are not fixed centrally because
   each fix is a layout decision on a compact grid, not a token:

     .mini-cal-nav   26x26  (jobs.css)     the month picker's prev/next. A 44px arrow does not fit
                                           beside a 7-column mini grid without re-laying it out.
     .cmd-step       34x34  (jobs.css)     the command bar's date steppers; grew into the h1.
     .jp-nav-btn     34x34  (phone.css)    the phone week bar's prev/next.
     .errpanel-help  20x48  (_base/shared) short, not narrow — the height floor above fixes it.

   The honest note for whoever takes them: an invisible 44px hit area via a negative-inset ::after
   is the usual trick and it is NOT sufficient on this app's tablet. The bay argument is arm's
   length under glare — a 26px painted arrow with a 44px invisible target is still a 26px thing to
   AIM at. Growing the paint is the right answer here; it just costs a grid re-layout, which is
   exactly the work being handed over rather than guessed at.

   /jobs is the priority: it is the default landing route, it audits worst on both shells, and its
   small targets are PRE-EXISTING IN BOTH THEMES — dark measures 22px / 26x26 / 34x34 on the same
   controls. Fixing them in light alone is legitimate (light is a different design) but it will
   leave the two themes visibly apart on that page, so it is a call worth making deliberately. */


/* ── 3. NO HOVER — every affordance visible at rest ────────────────────────────────────────────
   Audited rather than assumed. `touch-audit.mjs` walks the CSSOM for `:hover` rules that turn
   something on (opacity / visibility / display / transform) and then checks whether the matching
   element is hidden AT REST — the exact shape of an affordance that does not exist on a touch
   shell. Across the touch shells it found ONE component: `.crail`, the context rail, whose head and
   row text sit at opacity 0 until hover.

   That component already ships a non-hover path (`.crail.is-open`, and `:focus-within`), so the
   affordance is reachable by touch today and this is a legibility fix rather than a rescue: at rest,
   on a shell that will never fire `:hover`, the rail should simply be readable.

   Stated as a floor on the resting state rather than as `opacity: 1`, so the component keeps its
   own open/closed distinction instead of being flattened into one permanent state. */
@media (pointer: coarse) {
    html[data-theme="light"] .crail .crail-head,
    html[data-theme="light"] .crail .crow-text {
        opacity: 1;
    }
}
