/*
 * SkyPane companion service — hand-written stylesheet, no build step, no
 * framework, no web font, no icon font (06-UI-SPEC.md Design System).
 * 06.3-CONTEXT.md's D-01 supersedes 06-CONTEXT.md's D-21 for the companion
 * app: this stylesheet now carries its own full visual identity (colour,
 * type, spacing, card treatment, dashboard layout), while staying entirely
 * separate from the physical frame's own design system — the frame's
 * vendored fonts and PALETTE_RGB values must still never be pulled in here.
 *
 * Accent is reserved to selection/primary-action/wayfinding affordances
 * (06.6.1-UI-SPEC.md Color section), and to nothing else — everything
 * else interactive uses neutral gray, never a repurposed accent colour.
 * The full current list, kept exhaustive on purpose so a future reader
 * can tell an intended use from an accidental one: primary
 * (`type="submit"`) buttons, links, the focused-input outline, the
 * active sidebar/dropdown/tab-bar link indicator and its tinted pill
 * (22-14-PLAN.md Task 1 widens this one entry to its third rendering —
 * the bottom tab bar reuses the identical 12% wash, accent text and
 * semibold weight, so this is one more consumer of an already-listed
 * use, not a new accent consumer and not a new entry), the
 * hamburger toggle's active state, a stat tile's icon tint and top
 * accent when no ok/warn/error status applies, the skip link's fill,
 * the selected runway card's border and check glyph, the native
 * `accent-color` of radios/checkboxes AND (25-05-PLAN.md Task 2 — a
 * genuine broadening rather than a clarification, recorded because
 * this list is kept exhaustive on purpose) of the wake-interval
 * range input, which inherits it from the same global `input,
 * select` declaration and is a selection affordance of exactly the
 * kind this reservation is for, a `<summary>` disclosure's marker
 * (260902-ep7: this is a genuine broadening, not a clarification — the
 * marker pseudo-element was already on this list; its own label text
 * was not, and is now added too), the neutral (non-anomaly) banner's
 * left edge, and the favicon. 06.6.4.1.1-05: a genuine broadening, not a
 * clarification — the selected theme chip's border and check glyph
 * (.theme-chip--selected, .theme-chip__check) join the selected runway
 * card's own border/check-glyph pair above, the same accent use on a
 * second selectable-card component. 06.6.4.1.1-06 (developer checkpoint
 * follow-up): both selected cards also gained a 12%-accent background
 * wash (`color-mix(in srgb, var(--color-accent) 12%, transparent)`,
 * `.runway-card--selected` / `.theme-chip--selected .theme-chip__body`)
 * — a third accent property on the same two already-listed components,
 * not a new component. quick task 260904-bbi (the strong treatment must
 * follow the user's LIVE choice, not the saved config): the border,
 * wash and check glyph on both components are RE-KEYED from the
 * server-rendered `--selected` class to a live `:has(input:checked)`
 * selector, inside `@supports selector(:has(*))` — the same two
 * components, the same three accent properties, now driven by a live
 * signal rather than a stale one. This is a re-keying of an
 * already-listed use, not a new accent consumer. The server `--selected`
 * class is demoted to a quiet "this is what is saved" marker (a dashed
 * ring plus an English "Current" tag) that deliberately uses NO accent
 * at all — a muted-text mix and a dashed edge only — so it adds nothing
 * to this list. Do not broaden this list without updating
 * 06.6.1-UI-SPEC.md's Color section.
 *
 * 30-07-PLAN.md Task 1 (CFG-85): the Aspect card's palette chip
 * (`.palette-chip`, `@supports selector(:has(*))` block, far below) is a
 * NEW SELECTOR consuming the ALREADY-LISTED selected-selectable-card use
 * above (`.theme-chip`/`.runway-card`'s own border/check-glyph/
 * background-wash) — the identical accent treatment, at a smaller box,
 * for a third selectable-card component. Not a new use; naming the
 * selector here rather than duplicating the list entry.
 *
 * The same commit also closes a gap this list's own prose has recorded
 * as open since Phase 23 (see the sketch-findings-skypane skill's Colour
 * section, "the pointer above is currently POINTING AT A LIST THAT IS NO
 * LONGER EXHAUSTIVE"): two genuine Phase 23 accent consumers were added
 * without ever being named here, re-verified still present and still
 * unlisted immediately before this edit. (1) `.switch[aria-checked=
 * "true"] .switch__track { background: var(--color-accent); border-
 * color: var(--color-accent); }` (23-07) — a switch's on-state, an
 * author fill on a custom button and therefore NOT covered by the
 * existing native-`accent-color` entry above. (2) the
 * `@keyframes skypane-row-arrive` wash — `color-mix(in srgb,
 * var(--color-accent) 22%, transparent)` (23-08) — the newly-arrived
 * Flights row's draining wash, an ARRIVAL signal distinct from the
 * selected-card wash entry's SELECTION signal, a use this list has
 * never carried. Both genuine broadenings, recorded because this list
 * is kept exhaustive on purpose.
 *
 * 22-04-PLAN.md Task 2 (C2, 22-UI-SPEC.md §1 "Accent reservation — the
 * delta this phase makes"): this list LOSES two entries and gains none.
 * The Frame strip's two switch buttons ("Switch off"/"Switch on",
 * "Turn off"/"Turn on") are `type="submit"` and used to be covered by
 * the "primary buttons" entry above — a `.frame-strip__cell button`
 * rule (equal (0,1,1) specificity to `button[type="submit"]`, placed
 * after it in source order, the same mechanism `.logout-form button`
 * already uses) now repaints them with the ordinary quiet-button wash
 * instead, so "primary buttons" no longer includes them.
 *
 * The arithmetic, CORRECTED IN PLACE by 22-16-PLAN.md's closing sweep
 * because the premise it rested on was wrong. This paragraph used to
 * read "06.6.4.1-04 already removed the Disconnect button's accent fill
 * as a specificity BUG fix ... this task's own removal is the first of
 * the two genuine losses this phase makes". THE FIRST HALF WAS FALSE
 * WHEN IT WAS WRITTEN and stayed false for another eleven plans: the
 * Disconnect fill was still live, because `.calendar-disconnect-btn`
 * was (0,1,0) against `button[type="submit"]`'s (0,1,1), so the primary
 * accent fill won outright and this app's one DESTRUCTIVE control
 * rendered as the page's primary CTA. 22-15-PLAN.md Task 1 (T2) is what
 * actually removed it, by element-qualifying the selector to
 * `button.calendar-disconnect-btn` — equal (0,1,1) specificity, decided
 * by source order (see that rule's own comment, far below).
 *
 * What the correction does and does not move: the COUNT above is
 * unchanged, because the Disconnect fill was never a deliberate entry
 * on this list to begin with — it was a specificity accident, not a
 * reservation. What moves is WHERE the two losses land. Both are this
 * phase's: 22-04-PLAN.md Task 2 repainting the Frame strip's two switch
 * buttons (the sentence above), and 22-15-PLAN.md Task 1 clearing the
 * Disconnect fill. 22-05-PLAN.md's own settings-form work makes no
 * change to this list at all. After this phase, `.stat-tile--accent`'s
 * own top rail is the Frame strip's ONLY remaining accent, and the
 * page's accent has returned to Save/the page's one primary action —
 * this sentence states the result; it does not itself remove anything
 * from the list above, since "primary buttons" is still correct for
 * every OTHER submit button in the app.
 *
 * Colour-separation contract (heading-color-consistency debug session):
 * `--color-accent` must stay measurably distinguishable from every
 * `--color-status-*` signal colour, in BOTH themes. This was prose-only
 * for three phases and named only the accent-vs-warn pair, which is
 * exactly how 06.6.2's WCAG-AA accent darkening was able to move the
 * accent to within dE76 22.9 of `--color-status-error` unnoticed. It is
 * now executable: companion/contrast_check.py's `perceptual_distance()`
 * / `hue_separation()` and companion/test_contrast_check.py's
 * separation section enforce it on every accent/status pair. Change any
 * of these tokens and that test tells you whether the signals still
 * read apart — do not rely on the comments below alone.
 *
 * Border is structural only — the sidebar/content divider, fieldset
 * borders, table borders, the mobile-nav dropdown's own edge, and (since
 * 06.6.4, D-03) every card's resting edge too. It is never used as an
 * interactive-state signal and never substitutes for the accent or
 * status colours. 06.6.1 (D-02) had traded the card hairline for a
 * resting shadow instead; 06.6.4 (D-03) reverses that specific trade —
 * all six card components (.stat-tile, .page-section,
 * .battery-trend-section, .runway-card, .history-card, .login-card) carry
 * a --color-border hairline at rest and reveal --shadow-card-hover only
 * on hover/focus-within.
 *
 * 06.6.2 (D-14, UXA-04): the accent value changed for WCAG AA — light
 * mode's --color-accent is now #B13F16 (was #E8622C), still hue-separated
 * (~16°) from --color-status-warn's golden amber (~32°), so the same
 * signal-confusion guarantee above holds at the new value. Three surface
 * levels now exist in both themes — --color-canvas (page background),
 * --color-dominant (primary card/control surface), --color-secondary
 * (secondary/elevated/nav surface) — replacing the prior two-level model
 * where --color-dominant did double duty as both the page background and
 * the card surface.
 *
 * Theme resolution (CFG-09): `prefers-color-scheme` supplies the default;
 * an explicit `data-ui-theme="light"`/`data-ui-theme="dark"` attribute on
 * <html> (set by companion/layout.py's page_shell(), posted to by the
 * theme form) overrides it. `data-ui-theme="auto"` intentionally has no
 * override rule of its own, so the media query keeps governing.
 *
 * Zero external references anywhere in this file: no CSS url function,
 * no custom font-face declaration, no remote asset of any kind. This
 * stays true even after 06.6.1 added a serif font family and card
 * shadows — --font-serif is a system font stack, not a web font.
 */

:root {
  /* Spacing scale (06-UI-SPEC.md, multiples of 4) */
  --space-xs: 4px;
  --space-sm: 8px;
  --space-md: 16px;
  --space-lg: 24px;
  --space-xl: 32px;
  --space-2xl: 48px;
  --space-3xl: 64px;

  /* Typography scale: exactly 4 sizes, 2 weights.
   * 06.6.4.1.1 (D-10): --font-page-title-size 30px -> 32px and
   * --font-heading-size 20px -> 22px, widening the gap between the page
   * title and section-heading tiers so they read as two distinct rungs
   * of a three-tier ladder rather than the same weight at nearly the
   * same size — see .page-title and .text-heading below for the paired
   * weight/tracking changes. */
  --font-label-size: 14px;
  --font-body-size: 16px;
  --font-heading-size: 22px;
  --font-page-title-size: 32px;
  --weight-regular: 400;
  --weight-semibold: 600;

  --font-ui: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  /* 06.6.1 (D-03): headings-only warm serif. A system stack on purpose —
   * the file's zero-external-reference constraint forbids loading a web
   * font, and that stays literally true with this token.
   *
   * 06.6.4.1.1 (D-12, UIR-20): reordered to lead with "Iowan Old Style"
   * and Charter. Georgia-first meant every Mac and iPhone rendered
   * Georgia even though both Iowan Old Style and Charter are bundled on
   * those platforms and were the sketch's own intended reading — Georgia
   * was only ever meant to be the fallback for platforms that lack them.
   * Windows (which bundles neither Apple font) now falls through to
   * Palatino Linotype, then Georgia, exactly as it did before this
   * reorder. This is still a system stack — no @font-face, no remote
   * font reference of any kind — the file's zero-external-reference
   * rule is untouched by this change. */
  --font-serif: "Iowan Old Style", Charter, "Palatino Linotype", Georgia, "Book Antiqua", serif;

  /* 06.6.1 (D-02): mode-independent structural tokens. --radius-card
   * deliberately revises 06.3's "0-4px sharp/minimal" rule — softer
   * rounding is part of D-02's visible-relief direction, not a
   * contradiction of the earlier spec. */
  --radius-card: 10px;
  --radius-control: 8px;

  /* 23-01-PLAN.md Task 1 (D3, CFG-32): the app's ENTIRE motion budget —
   * two durations, and deliberately no third. Mode-independent, so they
   * sit with the radii above rather than with the themed colours below.
   * "Zero new custom properties" was a stated achievement of Phases 20
   * and 21, so spending two here is a deliberate, bounded exception:
   * every animated surface Phase 23 adds takes its duration from one of
   * these two, and a plan that believes it needs a third has to say so
   * in its own SUMMARY instead of inlining a literal. Neither token is
   * a timing function — an easing token would be a third custom
   * property and the budget is two, so easing is written at the call
   * site. companion/test_companion_app.py enforces the token-only rule
   * on every animation duration in this file.
   *
   * REACTION motion: a state change the user just caused and is
   * watching for confirmation of, so it must read as the control
   * responding rather than as an animation playing. Intended surfaces:
   * the Flights detail row and its rotating chevron (23-08), the save
   * bar's entrance and its counting label (23-09), selection scale, the
   * wash fade, the preview crossfade and the dialog entrances (23-10),
   * and the fades on a region that refreshed itself (23-06) — those are
   * self-initiated but somebody IS waiting to read the new value, which
   * is what puts them here and not below. 180ms is the top of the range
   * that still feels instant on a press. */
  --motion-fast: 180ms;
  /* AMBIENT motion: the one loop the page runs on its own with nobody
   * waiting on it — the live/paused indicator's breathing dot (23-05,
   * D14 and D22), the sole consumer of the keyframes block defined
   * further down. An order of magnitude longer than the reaction
   * duration on purpose: a status dot cycling at reaction speed strobes,
   * and a strobing dot in the corner of a page somebody leaves open all
   * day is precisely the complaint a motion budget exists to prevent. */
  --motion-slow: 2s;

  /* Light mode (default) */
  --color-canvas: #F7F4EF;
  --color-dominant: #FFFFFF;
  --color-secondary: #EEE8DE;
  --color-border: #DFD7C8;
  /* 06.6.2 (D-14, UXA-04): terracotta/red-orange, hue ~16°, deliberately
   * separated from --color-status-warn's golden amber (~32°) so an
   * accent-coloured control and a warning dot never read as the same
   * signal at a glance. Darkened from 06.6.1's #E8622C to meet WCAG AA
   * text contrast (UXA-04) — do not shift this toward a more golden
   * orange without re-verifying that hue separation, and do not revert
   * to #E8622C without re-verifying contrast (companion/test_contrast_check.py
   * pins both). */
  --color-accent: #B13F16;
  --color-accent-hover: #963610;
  /* The foreground for text/glyphs painted ON an accent fill (primary
   * buttons, the skip link). Previously these rules borrowed
   * --color-dominant, the card-surface token, which happened to resolve
   * to the right value in both themes — a coincidence, not a contract,
   * and one that would break silently the day a card surface is
   * retinted. Same value, now named for the job it does. */
  --color-on-accent: #FFFFFF;
  --shadow-card: 0 1px 3px rgba(18, 21, 27, 0.06), 0 1px 2px rgba(18, 21, 27, 0.04);
  --shadow-card-hover: 0 4px 12px rgba(18, 21, 27, 0.08), 0 2px 4px rgba(18, 21, 27, 0.05);
  --color-text: #17191F;
  --color-status-ok: #16A34A;
  --color-status-warn: #D97706;
  /* Crimson, hue ~345. Was #DC2626 (hue 0) until the
   * heading-color-consistency debug session measured it at only dE76
   * 22.9 / 15.9 degrees from --color-accent — closer than the
   * accent-vs-warn pair (28.6 / 16.3) that 06.6.1's D-04 examined and
   * accepted, which is why a primary-action button and an error edge
   * read as the same brick red on screen. Moving error to crimson
   * restores separation (dE76 29.3 / 30.5 degrees) WITHOUT touching the
   * accent, so UXA-04's WCAG AA contrast fix stays intact; it also
   * raises this token's own worst-case contrast on the three light
   * surfaces from 3.96 to 5.16, so it is now safe as text and not only
   * as a fill. Enforced by companion/test_contrast_check.py's
   * separation section — do not shift this back toward pure red. */
  --color-status-error: #BE123C;

  /* 10-05-PLAN.md Task 2: the default (data-ui-theme="auto" / no
   * override) case — follow the OS, matching today's
   * prefers-color-scheme-driven default block above. Without this, a
   * browser paints native form-control chrome (e.g. this page's first
   * <input type="time">) using its own light/dark heuristic instead of
   * the OS scheme the rest of this default case already follows. */
  color-scheme: light dark;
}

@media (prefers-color-scheme: dark) {
  :root {
    --color-canvas: #0C0F14;
    --color-dominant: #151922;
    --color-secondary: #1C222D;
    --color-border: #2A3040;
    --color-accent: #FF8A5C;
    --color-accent-hover: #FF9E77;
    /* Dark-mode accent fills are light, so the on-accent foreground
     * flips to the dark card surface's value — again the same value
     * these rules already resolved to via --color-dominant, now named. */
    --color-on-accent: #151922;
    --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
    --shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.35), 0 2px 4px rgba(0, 0, 0, 0.2);
    --color-text: #F1F3F6;
    --color-status-ok: #4ADE80;
    --color-status-warn: #FBBF24;
    /* Dark-theme counterpart of the light crimson above (same rose
     * family, shifted lighter/more saturated the way every other
     * --color-status-* token shifts between themes). Was #F87171, which
     * sat at dE76 22.3 / 16.9 degrees from the dark accent #FF8A5C —
     * the same collision as light mode, and worse in relative terms
     * since dark's accent-vs-warn pair is a comfortable 47.0 apart.
     * Now dE76 32.2 / 25.6 degrees. */
    --color-status-error: #FB7185;
  }
}

/* CFG-09 explicit overrides — take precedence over prefers-color-scheme. */
html[data-ui-theme="light"] {
  --color-canvas: #F7F4EF;
  --color-dominant: #FFFFFF;
  --color-secondary: #EEE8DE;
  --color-border: #DFD7C8;
  --color-accent: #B13F16;
  --color-accent-hover: #963610;
  --color-on-accent: #FFFFFF;
  --shadow-card: 0 1px 3px rgba(18, 21, 27, 0.06), 0 1px 2px rgba(18, 21, 27, 0.04);
  --shadow-card-hover: 0 4px 12px rgba(18, 21, 27, 0.08), 0 2px 4px rgba(18, 21, 27, 0.05);
  --color-text: #17191F;
  --color-status-ok: #16A34A;
  --color-status-warn: #D97706;
  --color-status-error: #BE123C;
  /* 10-05-PLAN.md Task 2: this explicit CFG-09 override can disagree with
   * the OS's own prefers-color-scheme, and without color-scheme a
   * browser paints native form-control chrome (e.g. the first
   * <input type="time"> on this page, Settings' new Quiet hours group)
   * using the OS scheme instead — producing a dark, hard-to-see picker
   * icon on this light fill whenever the two disagree. */
  color-scheme: light;
}

html[data-ui-theme="dark"] {
  --color-canvas: #0C0F14;
  --color-dominant: #151922;
  --color-secondary: #1C222D;
  --color-border: #2A3040;
  --color-accent: #FF8A5C;
  --color-accent-hover: #FF9E77;
  --color-on-accent: #151922;
  --shadow-card: 0 1px 3px rgba(0, 0, 0, 0.3), 0 1px 2px rgba(0, 0, 0, 0.2);
  --shadow-card-hover: 0 4px 12px rgba(0, 0, 0, 0.35), 0 2px 4px rgba(0, 0, 0, 0.2);
  --color-text: #F1F3F6;
  --color-status-ok: #4ADE80;
  --color-status-warn: #FBBF24;
  --color-status-error: #FB7185;
  /* Same reason as the light override above, inverted: without this, a
   * browser whose OS scheme is light paints the time-picker icon dark on
   * this dark-mode --color-secondary fill. */
  color-scheme: dark;
}

/* D-19: a single global reduced-motion override every transition in this
 * file (and every later plan's new transitions) inherits for free — no
 * later plan needs its own reduced-motion rule for a simple colour/
 * border/shadow transition. Placed after the token blocks, before the
 * universal box-sizing reset. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

/* The ONE gap the block above does not reach — recorded here, because
 * this is where the next reader will come looking for it (23-01-PLAN.md
 * Task 1, D3/CFG-32; measured in 23-RESEARCH.md's Risk 3 and confirmed
 * in this project's own harness Chromium). This is a NOTE, not a rule:
 * nothing is declared here and nothing above is edited.
 *
 * `*, *::before, *::after` matches ELEMENTS. The view-transition
 * pseudo-elements — the `::view-transition` root and the
 * `::view-transition-group()` / `::view-transition-old()` /
 * `::view-transition-new()` tree beneath it — form a separate
 * pseudo-element tree that none of those three selectors match. A
 * cross-document view transition is therefore NOT switched off by the
 * override above, and shipping one without its own opt-out would hand a
 * reduced-motion user full-page cross-fades with no way to escape them.
 *
 * That gap is CLOSED, by the rule immediately below (23-04-PLAN.md
 * Task 1, D10/CFG-33) — no longer by a future plan. It wraps the
 * navigation at-rule in a `prefers-reduced-motion: no-preference` media
 * query rather than zeroing these pseudo-elements' duration, and the
 * wrapper is the better shape for one reason: it prevents the
 * transition being SET UP at all, instead of setting one up and then
 * running it fast. A near-zero duration is not the same thing as none —
 * the same reasoning `.js .mobile-nav`'s narrow `none` override further
 * down this file already rests on. */

/* D10/CFG-33 (23-04-PLAN.md Task 1): cross-document view transitions —
 * the whole feature, and the only enhancement in Phase 23 that a
 * scripts-blocked visitor gets in full. No script, no framework, no
 * fallback branch, no feature detection and no markup change: the
 * browser snapshots the outgoing document and cross-fades it into the
 * incoming one, and a browser that has not implemented this simply
 * ignores the at-rule and navigates exactly as it did before. Baseline
 * "limited" as of 2026-09-13 (webstatus.dev, re-read in
 * 23-RESEARCH.md): Chrome/Edge 126+, Safari/iOS 18.2+, and NOT Firefox,
 * which therefore sees today's instant navigation and nothing else
 * different. Both of the design system's reference devices — the
 * Android at 360px and the iPhone at 390px — are covered.
 *
 * WHY THE MEDIA WRAPPER, AND WHY IT IS NOT BELT-AND-BRACES. It is the
 * opt-out the block above genuinely cannot provide, not a second copy
 * of it. Source: CSS View Transitions Module Level 2, §8.3.1 — "as per
 * default behavior, the [navigation] rule can be nested inside a
 * conditional group rule such as @media or @supports" — and this exact
 * nested form was verified to parse and to be RETAINED in the CSSOM in
 * this project's own harness Chromium (151.0.7922.34), not assumed from
 * the prose. Wrapping it means that under reduced motion the
 * transition is never SET UP; the alternative (an `animation: none`
 * override on the pseudo-elements) would set one up and then run it at
 * a zeroed duration, which is strictly weaker AND is the dead-code-
 * not-a-safety-net pattern `references/accessibility-contrast.md`
 * records. There is deliberately no such override anywhere in this
 * file.
 *
 * SAME-ORIGIN ONLY, by spec — stated here rather than left as an
 * unstated assumption: the browser only runs a transition between two
 * same-origin documents. Nothing in this app links off-origin from a
 * page carrying this at-rule, so no additional gate is needed; if one
 * is ever added, it degrades to an ordinary navigation on its own.
 *
 * THE THREE NAMES BELOW MUST BE UNIQUE PER RENDERED DOCUMENT, and that
 * is the one way this feature fails silently: a name that matches twice
 * makes the browser drop the transition with no error anywhere. Read
 * this before "simplifying" any of the three selectors — companion/
 * layout.py puts TWO navigation landmarks into EVERY authenticated
 * document simultaneously, the sidebar's own vertical one and the
 * bottom tab bar, hidden from one another only by the 960px media query
 * further down this file. (23-RESEARCH.md and this plan both say three,
 * counting the preferences panel's copy — that one was REMOVED rather
 * than emptied by 22-14, so the hazard is two-fold today and would be
 * three-fold again the moment a panel-level landmark returns.) A name
 * hung on a class those share, or on the bare `nav` element, is
 * therefore declared once in this file and resolves twice in the
 * document, and the whole cross-fade vanishes. The sidebar is targeted
 * by `.dashboard-sidebar`, its own `<aside>` class, rendered exactly
 * once. `.page-title` is page_header()'s single `<h1>`, and
 * `.preview-frame__image` is Home's frame picture and the app's only
 * render site of that class. companion/test_browser_ux.py counts the
 * COMPUTED value on every authenticated route, because only a browser
 * can see a selector matching twice — and that is not hypothetical:
 * mutating `.dashboard-sidebar` to `nav` was measured here, and it
 * resolves to `nav.sidebar-nav` and `nav.tab-bar` on all six routes
 * while every source-level scan in this repository stays green.
 *
 * Each name is declared in a rule of its own on purpose. In particular
 * `.preview-frame__image` shares its white-backing/hairline/radius
 * declarations with `.now-showing__image`, `img.recent-flight__thumb`
 * and `img.history-card__thumb` in one four-selector rule below, and
 * the thumbnails render once PER ROW — putting a name on that shared
 * rule would collide dozens of times on Flights. */
@media (prefers-reduced-motion: no-preference) {
  @view-transition {
    navigation: auto;
  }
}

.dashboard-sidebar {
  view-transition-name: skypane-sidebar;
}

.page-title {
  view-transition-name: skypane-title;
}

.preview-frame__image {
  view-transition-name: skypane-picture;
}

/* The app's FIRST and ONLY keyframes block (23-01-PLAN.md Task 1,
 * D3/CFG-32). This stylesheet carried zero before this plan; this one
 * is the whole vocabulary, and companion/test_companion_app.py fails if
 * a second one ever appears.
 *
 * ONE block, TWO consumers, on purpose. D14's "breathing dot" on the
 * relative-time ticker and D22's "pulsing dot" on the live/paused
 * indicator are the same animation described twice in 22-AUDIT.md;
 * 23-RESEARCH.md names two near-identical keyframe blocks as the
 * specific failure to avoid, and that failure is the entire reason this
 * single definition exists. The name carries the app's own prefix for
 * the same reason — a generic `pulse` is exactly the name a later plan
 * redefines without noticing.
 *
 * It cycles OPACITY and nothing else. Never `transform: scale()` on a
 * dot: these indicators are ~6px, so scaling one repaints a layout box
 * sixty times a second for a movement nobody can see. The floor is
 * 0.35 rather than 0 — a dot that vanishes outright reads as a
 * rendering fault, not as a heartbeat.
 *
 * DEFINED here, CONSUMED by 23-05. This plan declares the animation on
 * no selector at all, and that is safe by construction: the guard
 * asserts every animation reference resolves to a block defined in this
 * file, so an unused definition costs nothing and a dangling reference
 * cannot ship. Its duration must come from `var(--motion-slow)` at the
 * call site; the guard rejects a bare literal there. */
@keyframes skypane-pulse {
  0%, 100% {
    opacity: 1;
  }
  50% {
    opacity: 0.35;
  }
}

/* 23-06-PLAN.md Task 2 (D1/CFG-35, spending 23-01's D3/CFG-32 budget):
 * the app's SECOND keyframes block, and the argument for it rather than
 * a bare assertion that it was needed.
 *
 * 23-01 declared one block and said a plan that believes it needs
 * another has to say so. This one does: `skypane-pulse` cycles opacity
 * between 1 and 0.35 forever, and what a refreshed picture needs is a
 * ONE-SHOT ramp from 0 to 1. Reusing the pulse would mean running an
 * infinite animation for one iteration and accepting its 0.35 floor as
 * the starting opacity, which is a different visual (the picture would
 * appear at a third of its weight rather than from nothing) and a
 * misuse of a block whose own comment says it is the AMBIENT loop. Two
 * blocks, two motions, each named for what it does.
 *
 * It is a fade and nothing else: no transform, no scale. The picture is
 * a 600x800 image and animating its geometry would repaint a large box
 * on a phone for an effect nobody asked for.
 *
 * Its duration comes from `var(--motion-fast)` at the call site below —
 * 23-01's REACTION token, which names "the fades on a region that
 * refreshed itself (23-06)" among its own intended surfaces. Somebody
 * IS waiting to read the new value, which is what puts it there rather
 * than on the ambient token. companion/test_companion_app.py's motion
 * guard rejects a bare literal there and requires every animation name
 * to resolve to a block defined in this file; both hold. */
@keyframes skypane-fade-in {
  from {
    opacity: 0;
  }
  to {
    opacity: 1;
  }
}

/* 23-08-PLAN.md Task 1 (D7/CFG-37, spending 23-01's D3/CFG-32 budget):
 * the app's THIRD keyframes block, argued for rather than assumed, the
 * way the second one was.
 *
 * The two existing blocks are both about OPACITY: one cycles it forever
 * (the ambient pulse), one ramps it once from nothing (the fade). What a
 * newly-arrived row needs is neither. Fading a table row in from zero
 * opacity would make a line of text materialise out of the page
 * background — the row was not absent a moment ago in any sense the
 * reader experienced, it simply was not there yet — and running the
 * pulse would leave it flashing forever. What says "this one just
 * landed" is a wash of colour on the row that drains away, leaving the
 * row exactly as every other row looks. So: a background tint, and
 * nothing else moves.
 *
 * IT FADES TO A TRANSPARENT FORM OF ITS OWN COLOUR, not to `transparent`
 * the keyword, and that is a correctness point rather than a style one.
 * The final keyframe is the last thing painted before the animation
 * hands the element back to its ordinary computed style; a final value
 * of `transparent` would be the same PAINT as the row's real background
 * only for rows whose real background is already nothing. Ending on the
 * same colour at zero alpha means the last animated frame and the first
 * un-animated one are the same pixels, so there is no snap at the end
 * on a row that has a background of its own (the hover tint, a future
 * zebra, a card surface).
 *
 * No new colour literal: `--color-accent` at this file's own
 * color-mix-with-transparent idiom, the same shape every wash in this
 * stylesheet already uses.
 *
 * Its duration comes from `var(--motion-slow)` at the call site below.
 * That is 23-01's AMBIENT token and the choice is deliberate: nobody
 * pressed anything and nobody is waiting: the page ran this on its own
 * to report something that happened elsewhere. A 180ms flash on a row
 * the reader was not looking at would be over before their eye reached
 * it, which is the same as no signal at all.
 *
 * UNDER REDUCED MOTION THIS DOES NOT WASH, and there is no block here
 * saying so — the global override earlier in this file already zeroes
 * animation-duration for every element, and a per-rule block would be
 * the dead-code-not-a-safety-net pattern accessibility-contrast.md
 * records a verdict on. The stylesheet's live reduce-block count stays
 * at two. The outcome under that preference is correct rather than
 * merely acceptable: the new row is still at the top of the list, which
 * is itself the information. */
@keyframes skypane-row-arrive {
  from {
    background-color: color-mix(in srgb, var(--color-accent) 22%, transparent);
  }
  to {
    background-color: color-mix(in srgb, var(--color-accent) 0%, transparent);
  }
}

/* 23-09-PLAN.md Task 1 (D3/CFG-32, spending 23-01's budget): the app's
 * FOURTH keyframes block, argued for rather than assumed, the way the
 * second and third were.
 *
 * The three existing blocks are an opacity CYCLE (the ambient pulse), an
 * opacity RAMP (the fade a refreshed region spends) and a background
 * WASH (a row that just landed). None of them moves anything. What the
 * save bar needs is the one motion none of them describes: it has to
 * ARRIVE — travel a short distance into the position it then keeps —
 * because that is the difference between a control the reader watches
 * appear and one that was apparently always there and they simply had
 * not noticed. The fade alone would have made a bar carrying the only
 * Save button on the page materialise out of the page background.
 *
 * WHY AN ANIMATION RATHER THAN A TRANSITION, which is the real decision
 * here. The bar goes from `display: none` (its server-rendered `hidden`
 * attribute) to displayed, and a transition has no previous computed
 * value to start from across that change — it needs an @starting-style
 * entry, which is exactly what 23-08 wrote for the Flights detail row.
 * That was right there and is wrong here: @starting-style is newer than
 * this component's audience, so a transition would move for some
 * visitors and silently do nothing for the rest. That is the shape of
 * failure 23-01's interpolate-size ban exists to stop, and it is not a
 * smaller failure because the property involved is Baseline-newer
 * rather than Chromium-only. An animation runs for everyone, and an
 * element that is `display: none` runs none of it, so the
 * scripts-blocked reader — for whom this bar never appears at all — is
 * reached by nothing here either.
 *
 * TRANSFORM AND OPACITY, AND NOTHING ELSE, and that ban is a
 * correctness rule rather than a performance one. This component's own
 * history is why: a bar stranded at an intermediate SIZE is a blocked
 * save, and this app has already shipped a blocked save once (B1). The
 * `.js .mobile-nav` override further down this file is the recorded
 * precedent for a size-interpolating transition stranding a computed
 * value when its duration is forced to 0.01ms. Neither property here
 * can strand anything: with no fill mode the element is handed straight
 * back to its own computed style the instant the animation ends, and
 * that style has never mentioned either property.
 *
 * The travel is `--space-md`, the file's own spacing token, and it is
 * DOWNWARD-origin on purpose: the bar is pinned to the bottom of the
 * viewport at both breakpoints, so arriving from below it is the
 * direction it would come from if it were a physical object sliding
 * into place, and it never crosses the content it is about to sit over.
 *
 * Its duration comes from `var(--motion-fast)` at the call site. That is
 * 23-01's REACTION token by its own definition — the user just made an
 * edit and is watching for the confirmation that it can be saved — and
 * 23-01's own comment already names "the save bar's entrance" among the
 * surfaces it was chosen for. The guard rejects a bare literal there.
 *
 * UNDER REDUCED MOTION THIS DOES NOT SLIDE, and there is no block here
 * saying so — the global override earlier in this file already zeroes
 * animation-duration for every element, and a per-rule block would be
 * the dead-code-not-a-safety-net pattern accessibility-contrast.md
 * records a verdict on. The stylesheet's live reduce-block count stays
 * at two. The outcome under that preference is correct rather than
 * merely tolerable: the bar is simply there, at full opacity, in its
 * final position, which is what it did before this plan. */
@keyframes skypane-bar-arrive {
  from {
    opacity: 0;
    transform: translateY(var(--space-md));
  }
  to {
    opacity: 1;
    transform: translateY(0);
  }
}

* {
  box-sizing: border-box;
}

body {
  margin: 0;
  font-family: var(--font-ui);
  font-size: var(--font-body-size);
  font-weight: var(--weight-regular);
  line-height: 1.5;
  background: var(--color-canvas);
  color: var(--color-text);
  /* UXA-01's guaranteed fix, verified by the audit's own real-browser
   * measurement (1440x900, populated History: 1425px document client width
   * vs 1712px document scroll width). No matter what layout quirk exists
   * deep in the tree, the document itself can never scroll horizontally
   * once this is set. .data-table-wrap's own overflow-x: auto below is
   * what still gives a genuinely wide table its own discoverable, scoped
   * horizontal scroll — do not remove either half. */
  overflow-x: hidden;
}

.mono {
  font-family: var(--font-mono);
  font-weight: var(--weight-regular);
}

/* Typography roles */
.text-label {
  font-size: var(--font-label-size);
  font-weight: var(--weight-regular);
  line-height: 1.4;
}

.text-body {
  font-size: var(--font-body-size);
  font-weight: var(--weight-regular);
  line-height: 1.5;
}

/* 06.6.4.1.1 (D-10): stays weight-regular on purpose — the section-heading
 * tier must NOT gain a weight of its own, because that is what keeps it
 * reading as a distinct middle rung between the semibold .page-title
 * above it and the semibold nested-card-title tier below it (see the
 * .page-section--nested > h2 rule further down this file). A regular-
 * weight middle tier between two semibold tiers is the whole ladder;
 * making all three semibold would collapse it back into "one heading
 * weight at different sizes". `legend` also consumes
 * --font-heading-size via the shared serif rule above — that is fine
 * and intentional, not an oversight. */
.text-heading {
  font-size: var(--font-heading-size);
  font-weight: var(--weight-regular);
  line-height: 1.25;
}

/* quick task 260901-re6: the single muted sentence that now sits
 * directly under each Settings group heading (Theme/Runway/Diagnostic
 * LED), replacing the old description-above / helper-below paragraph
 * pair. Deliberately COMPOSES with .text-label rather than restating
 * it — .text-label already supplies var(--font-label-size), so the
 * markup always carries both classes together (`class="text-label
 * section-caption"`) and this rule adds only colour: the file's
 * existing 70% color-mix muted strength (.data-table-wrap thead, the
 * filter-bar hint, the empty-state copy), not a new one. The absence of
 * a margin declaration here is a decision, not an omission: unlike the
 * `h1, h2, h3, .text-heading` heading-rhythm rule below, this role
 * keeps the UA default paragraph margin, because the description and
 * helper paragraphs it replaces already relied on that default and the
 * validated sketch was measured against it. Do not "complete" this rule
 * by adding a margin. */
.section-caption {
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* D-07 (19-07-PLAN.md, A-25): the field-level message a rejected
 * Settings save renders directly under the offending control.
 * Deliberately COMPOSES with .text-label the same way .section-caption
 * above does — the markup always carries both classes together
 * (`class="field-error text-label"`), so this rule adds only colour
 * plus a small top margin, reusing the existing --color-status-error
 * token (both themes already declare it) and the existing --space-xs
 * token — no new custom property, no new colour literal. The top
 * margin (rather than .section-caption's own no-margin decision) is
 * what makes the message read as attached to the control immediately
 * above it instead of colliding with the next sibling's own spacing. */
.field-error {
  color: var(--color-status-error);
  margin: var(--space-xs) 0 0;
}

/* quick task 260901-tsa (finding B): pairs a section heading with an
 * inline, baseline-aligned muted description — Health's Screen/Server &
 * data headings. Declares NO margin of its own, deliberately: the <h2>
 * inside still carries the heading-rhythm rule's own `margin: 0 0
 * var(--space-sm)` below, and a flex item's own margin still
 * contributes to the flex line's box, so the gap below this whole row
 * is exactly the gap that existed below the bare <h2> before this
 * wrapper existed — the unchanged spacing is the intent, not an
 * omission.
 *
 * 06.6.4.1.1-03 (D-19, UIR-24) makes this rule mobile-first. The
 * original single-rule form (baseline row plus `flex-wrap: wrap`) let
 * the muted caption wrap onto its own line below 960px, but as a
 * hanging "— …" line under the heading — an orphan, not a clean stack.
 * The base rule below is now the mobile default (a real column stack,
 * `gap: 2px` since a stacked heading+caption pair needs far less
 * separation than a baseline-aligned inline pair); the
 * `@media (min-width: 960px)` block (below, where `.stat-tile__caption`'s
 * neighbours also live) restores the original inline baseline row once
 * there is room for it. `display: flex` stays a literal declaration on
 * THIS rule — a harness check in test_status_pages.py indexes it — so
 * the desktop override only needs to change direction/alignment/gap,
 * never re-declare `display`. `flex-wrap: wrap` is kept even in this
 * column form: it is harmless (a column layout has no cross-axis line
 * for a lone child to wrap onto today), and keeping it is what this
 * rule's own comment above already describes, rather than removing a
 * declaration this edit has no stated reason to touch. */
.section-intro {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  flex-wrap: wrap;
  gap: 2px;
}

/* Zeroes the row's own <p> margin. Deliberately NOT added to
 * .section-caption above — that rule's own comment states its lack of a
 * margin declaration is a decision, because in its Settings context the
 * caption is an ordinary block sibling relying on the UA paragraph
 * margin the validated sketch was measured against. That reasoning is
 * context-specific, so the zeroing belongs here, on the container where
 * the flex row owns the layout, not as a new declaration inside
 * .section-caption, which would silently change every Settings group at
 * the same time. Child combinator, not a descendant selector: only the
 * row's own direct paragraph is being laid out. */
.section-intro > p {
  margin: 0;
}

/* 06.6.1 (D-03): headings move onto var(--font-serif). This is a
 * headings-only treatment — body text, table cells, form controls, nav
 * links and monospace content stay on --font-ui, never var(--font-serif).
 * That boundary is the whole substance of D-03.
 *
 * The exhaustive serif set is: h1, h2, h3, .text-heading, `legend`,
 * .page-title, .site-title (which .sidebar-title is always co-classed
 * with, and is why .sidebar-title needs no rule of its own) — minus the
 * nested-card-title override further down this file (the rule pairing
 * .page-section--nested's own h2 with .battery-trend-section's), which
 * overrides this rule's family to sans for that one tier (06.6.4.1.1
 * D-09). 06.6.4.1.1 (D-13) also retires the one Label-role exception
 * this list used to carry —
 * .stat-tile__caption is no longer serif, no longer a Label-role
 * exception of any kind, and moved onto the unified 12px uppercase label
 * voice instead (see its own rule in the stat-tile section below). This
 * serif set is genuinely exhaustive again: do not extend it further, and
 * do not let this comment's own allow-list drift out of date the way the
 * stale one it replaces did — a stale allow-list in a comment is exactly
 * the defect the heading-color-consistency debug session was caused by.
 *
 * `legend` is in the selector below, and this is the fix, not a
 * cosmetic addition: `legend` is a section-heading role — it is the
 * accessible group name for a <fieldset>, and config_page.py's D-06
 * deliberately uses it INSTEAD of an <h2> so a group is never named
 * twice. It was never added to this selector, so it kept the browser's
 * sans-serif and the file's own --weight-semibold, which put a
 * sans-semibold `<legend>Diagnostic LED</legend>` directly above a
 * serif-regular `<h2 class="text-heading">Poll</h2>` at the identical
 * 20px size on the Config page. Same role, same size, two families and
 * two weights. If a heading role is ever added to this file, it belongs
 * in this selector — that is the whole contract. */
h1,
h2,
h3,
legend,
.text-heading {
  font-family: var(--font-serif);
  font-weight: var(--weight-regular);
  letter-spacing: -0.01em;
}

/* Heading rhythm. Without this, headings fall through to UA defaults,
 * which are em-based and per-element — and .text-heading is applied
 * both to real <h2> elements (Health/Preview/Config sections) and to
 * <p> elements (layout.empty_state(), airlines_page, history_page), so
 * one visual heading role got 0.83em margins on an h2 and 1em margins
 * on a p. Stating the rhythm once, in the same selector that states the
 * family, is what keeps the role's spacing independent of which tag it
 * happens to land on. */
h1,
h2,
h3,
.text-heading {
  margin: 0 0 var(--space-sm);
}

/* 06.6.2 (D-15): the page-title role — distinct from the 22px
 * .text-heading section-heading role, used only by layout.page_header()'s
 * <h1 class="page-title">.
 *
 * 06.6.4.1.1 (D-10): grows from 30px to 32px, gains its own semibold
 * weight and tighter -0.015em tracking (overriding the shared serif
 * rule's regular weight and -0.01em), and line-height loosens slightly
 * to 1.15 for the bigger size. This is the top rung of the three-tier
 * ladder — semibold here, regular on .text-heading, semibold again on
 * the nested-card-title tier — is what makes all three read as distinct
 * levels instead of one heading weight repeated at different sizes. */
.page-title {
  font-family: var(--font-serif);
  font-size: var(--font-page-title-size);
  font-weight: var(--weight-semibold);
  line-height: 1.15;
  letter-spacing: -0.015em;
  margin: 0 0 var(--space-xs);
}

.page-header__purpose {
  margin: 0 0 var(--space-lg);
}

/* Quick task 260903-peo (UIR-18): the persistent-liveness-note wrapper.
 * Spacing only — no border, no fill, no shadow; this is a header line,
 * not a card. Block-level by element choice (a <p>), which is the whole
 * point: it keeps `.page-header`'s in-flow children block-level end to
 * end (<h1>, this wrapper, <p class="page-header__purpose">), so the
 * renderer never has to synthesize an anonymous block box for a
 * stranded inline child the way the bare pill span once did (see
 * 260902-ep7's comment below). `.page-header .refresh-pill`'s
 * descendant selector still matches the pill nested inside this
 * wrapper — position: absolute takes it out of this element's flow
 * entirely, so the wrapper's own height is dictated by the note text
 * alone. */
.page-header__freshness {
  margin: 0 0 var(--space-xs);
}

/* 260902-ep7: `.page-header` establishes a containing block for the
 * pill's own out-of-flow rule further down this file. Everything below
 * the divider is about WHY that pill is taken out of flow at all.
 *
 * The mechanism the pill's own out-of-flow gap used to leave behind:
 * .page-header is a plain block box. On Health it has three children —
 * a block <h1>, an inline-level <span class="refresh-pill"> (its own
 * rule declares `display: inline-flex`, which is inline-LEVEL, not
 * block-level), and a block <p>. CSS block layout cannot mix an
 * inline-level child directly between two block-level siblings in one
 * block container, so the renderer wraps the stranded span in an
 * anonymous block box of its own. That anonymous box generates a line
 * box exactly as tall as its content — the pill's own 20px — and
 * `.refresh-pill[hidden]`'s `visibility: hidden` (kept below,
 * deliberately) suppresses PAINTING that box, never its LAYOUT, so the
 * 20px line box is consumed on every render whether or not the pill is
 * showing. Combined with .page-title's own 4px margin-bottom and the
 * line box's own half-leading, that produced the measured 28px gap
 * between Health's <h1> and its purpose sentence, against Airlines'
 * 4px (Airlines' .page-header has only the <h1> and <p> — two children,
 * no stranded inline-level sibling, no anonymous block box). Before
 * 260902-chc the same three-line stack existed but the middle line held
 * a visible Refresh link, so the space was visually motivated; 260902-chc
 * replaced that link with an invisible pill and the space became
 * unexplained.
 *
 * Fix: take the pill out of normal flow entirely with `position:
 * absolute`, scoped to a page-header-scoped pill rule further down this
 * file (see that rule's own comment for the offsets and the rejected
 * alternatives).
 * An out-of-flow box reserves no line box and participates in no flow
 * at all, so this preserves 260902-chc's real goal (revealing the pill
 * shifts nothing in the header) STRICTLY MORE STRONGLY than the
 * reserved-line-box mechanism it replaces — the no-shift-on-reveal
 * guarantee becomes structural instead of depending on the hidden box's
 * height matching the visible box's height. */
.page-header {
  position: relative;
  margin-bottom: var(--space-lg);
}

/* 06.6.1 (D-01): the wordmark moves from the Display role to the Heading
 * role — crafted typography instead of a default bold sans, with no logo
 * asset introduced. The retired Display role's own two consumers,
 * .stat-tile__value and .runway-card__number, moved on in 06.6.4 (D-09) to
 * the Emphasis role instead — Body size plus semibold, the same
 * existing-size-made-bold pattern .data-table th already uses for its own
 * emphasis. --font-display-size was retired along with them, restoring the
 * scale block above to its own claimed four sizes.
 *
 * 06.6.4.1.1 (D-11): grows to 24px serif semibold, -0.01em tracking
 * (replacing the prior 0.02em). The 24px is a deliberate LITERAL, not a
 * token, and stays that way on purpose: this file's stated scale is
 * exactly four content sizes, the retired --font-display-size was also
 * 24px and was deliberately retired by 06.6.4 (D-09) to keep the scale
 * honest, and the wordmark is a brand role rather than a content role —
 * it does not belong in the four-size content scale any more than the
 * documented sub-scale exception tier (11/12/13px on .data-table th,
 * .filter-bar__count, etc.) does. Resurrecting a fifth content token
 * just to name this one literal would misrepresent the wordmark as a
 * content-scale citizen it isn't. .sidebar-title needs no rule of its
 * own; it is always co-classed with .site-title. */
.site-title {
  font-size: 24px;
  font-weight: var(--weight-semibold);
  font-family: var(--font-serif);
  line-height: 1.2;
  letter-spacing: -0.01em;
}

/* Visible focus outline on every focusable element — never removed without
 * a replacement (accessibility floor, D-22).
 *
 * T15 (22-UI-SPEC.md §5 contract 2, 22-15-PLAN.md Task 1): `summary`
 * joins the list. It is a NATIVE interactive element — keyboard
 * focusable, Enter/Space operable, already carrying the touch-target
 * register's kept 44px entry — and it was the one such element in the
 * app with no visible focus state at all, which is a hole in this
 * floor rather than a styling gap. It is added here, to the one rule
 * that owns the floor, rather than given a treatment of its own. */
a:focus-visible,
button:focus-visible,
input:focus-visible,
select:focus-visible,
summary:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

a {
  color: var(--color-accent);
  transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}

a:hover {
  color: var(--color-accent-hover);
}

/* Nav bar (header). 06.6.1 (D-00/D-06, Reconciliation 2): the horizontal
 * `.nav-bar`/`.nav-tab` strip that used to render here below 960px is
 * removed outright, not merely hidden — the hamburger dropdown below
 * replaces its role at that breakpoint. Prior history, preserved for the
 * record: `.nav-bar`'s `overflow-x: auto` zeroed its own automatic
 * minimum width per CSS Flexbox 4.5, so it silently lost all its width to
 * `.theme-form` (which had no such minimum override) instead of wrapping
 * onto its own row — that collapsed the strip to ~26px on a 375px
 * viewport, hiding every tab but a sliver of "Config". Commit `b90ed88`
 * fixed that specific collapse (`flex: 1 1 200px` on `.nav-bar`,
 * `flex-wrap: wrap` here). Real-device testing during 06.5-03 then found
 * the horizontally-scrollable strip itself — even correctly sized — hid
 * most tabs behind an undiscoverable swipe (only 3-4 of 5 tabs visible at
 * once on a real phone). Rather than tuning the flex sizing a second
 * time, this phase removes the pattern the bug lived in. `.mobile-nav`
 * relies on `flex-wrap` here to drop onto its own full-width row below
 * the title/toggle row — it stays in normal document flow (see
 * `.mobile-nav` below) rather than being taken out of it, which is what
 * lets its `max-height` transition actually push page content down
 * instead of overlaying it. An earlier version of this rule used
 * `position: absolute` on `.mobile-nav` for that same goal; absolute
 * positioning removes an element from flow entirely, so it could only
 * ever overlay, never push — a real defect found on real-device
 * verification (06.6.1-06-SUMMARY.md), fixed here. */
.site-header {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-lg);
  padding: var(--space-lg);
  background: var(--color-secondary);
  margin-bottom: var(--space-xl);
}

/* Hamburger toggle (06.6.1 D-06). Not type="submit", so the
 * button[type="submit"] accent-fill rule does not reach it and no
 * defensive override is needed here — if a later change ever makes it a
 * submit button, this rule would lose, the same trap
 * `.theme-form .theme-option` already documents. State is styled off
 * aria-expanded, not a duplicate CSS class, so the visual state and the
 * accessibility state are physically the same attribute and cannot
 * drift. */
.site-nav-toggle {
  margin-left: auto;
  min-height: 44px;
  min-width: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  background: transparent;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  color: var(--color-text);
  cursor: pointer;
  transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}

.site-nav-toggle[aria-expanded="true"] {
  color: var(--color-accent);
  border-color: var(--color-accent);
}

/* Dropdown panel. `flex-basis: 100%` (not `position: absolute` — see
 * .site-header's comment above) is what forces this onto its own
 * full-width row below the title/toggle row within the wrapping flex
 * header, while staying in normal flow. The clipping half of this
 * component (max-height/overflow/transition) has moved to the `.js
 * .mobile-nav` rule below — UXA-02/UXA-12's joint fix. Without the `.js`
 * class ever present (no JavaScript, or the script failed to load), this
 * base rule alone renders the panel in its natural, unclipped, fully
 * visible document flow — this is UXA-12's floor. The two rules must
 * never be edited independently. */
.mobile-nav {
  flex-basis: 100%;
  background: var(--color-secondary);
  box-shadow: var(--shadow-card-hover);
  border-bottom-left-radius: var(--radius-card);
  border-bottom-right-radius: var(--radius-card);
}

/* max-height (not display or height: auto) is used because neither of the
 * latter can be transitioned; overflow: hidden is what makes the
 * collapsed state actually hide its contents rather than merely clip them
 * visually. Scoped under `.js` (added unconditionally by
 * nav-dropdown.js's first statement) so the clipping only ever applies
 * once client-side script has proven it is running — nav-dropdown.js's
 * own `panel.hidden` toggling (UXA-02's floor) is what removes the closed
 * panel from the accessibility tree and tab order once JS confirms it is
 * closed; this CSS rule alone only ever governs the visual/animated
 * state. */
.js .mobile-nav {
  max-height: 0;
  overflow: hidden;
  transition: max-height .22s ease;
}

/* T11 (22-14-PLAN.md Task 2): the ONE max-height for the open dropdown.
 * A second declaration used to sit ~4900 lines further down and win
 * silently at 640px; it is deleted, not re-tuned. This value is
 * MEASURED, not guessed: with the destination links gone the panel holds
 * the state reminder, the language and theme switches and Sign out, and
 * its content measures 165px in French at 390px —
 * the language where the segments and the Sign out label are longest.
 * The cap is rounded up from that to leave room for a longer future
 * footer string without clipping it. */
.js .mobile-nav--open {
  max-height: 320px;
}

@media (prefers-reduced-motion: reduce) {
  .js .mobile-nav {
    transition: none;
  }
}

/* ===================================================================
 * 25-01-PLAN.md Task 2 (CFG-46) — THE .js GATE, AND THE SHARED CONTROL
 * VOCABULARY THE WHOLE OF PHASE 25 IS BUILT ON.
 *
 * Placed here, immediately after the two `.js .mobile-nav` rules above,
 * because those are this file's other consumers of the same `.js` class
 * (added unconditionally to <html> as nav-dropdown.js's very first
 * statement) and all three read as one group. Placement is for
 * READABILITY only: every selector below is unique to this block, so
 * nothing here wins or loses on source order.
 *
 * --- THE DIRECTION, AND WHY THE REVERSE IS A DEFECT ----------------
 *
 * D-09 (22-CONTEXT.md) is a locked decision: an affordance that cannot
 * work without script does not RENDER without script. The gate
 * therefore HIDES BY DEFAULT and is revealed under `.js` — never the
 * other way round.
 *
 * Writing it the other way round (render by default, hide under `.js`)
 * fails twice. It flashes a dead control on every single load, in the
 * window between first paint and the script running. And when a script
 * genuinely fails to run — blocked, 404, a parse error — `.js` is never
 * added at all, so the dead control does not merely flash, it stays on
 * screen permanently with nothing behind it. This file has already paid
 * that bill once: 19-10's `.js`-keyed fallback-hide rule (the block
 * immediately below this one, which now keys on two liveness markers
 * instead) hid the only working Save button whenever an unrelated
 * script failed, and the fix was to stop keying on `.js` at all. That
 * rule's own attribute name is deliberately NOT repeated in this
 * paragraph: companion/test_config_page.py locates it by the FIRST
 * occurrence of that literal in this file, so quoting it here would
 * point the guard at a comment.
 *
 * `display: none` specifically, and it is not interchangeable with
 * `visibility: hidden` or `opacity: 0`. Those two leave the element in
 * the layout AND in the tab order, so a keyboard user with scripts
 * blocked can tab straight into a control that cannot do anything. Out
 * of the layout and out of the tab order is the whole requirement.
 *
 * --- THE REVEAL, AND WHY IT IS ONE RULE ---------------------------
 *
 * The revealed display value comes from `--js-gate-display`, which a
 * consumer sets on its own rule (`--js-gate-display: flex;`) with no
 * `.js`-scoped selector of its own. Without that indirection every
 * consumer would have to write its own `.js .thing { display: flex; }`
 * pair and the gate's direction would be re-decided five times. The
 * fallback is `block`, which is what an unstyled <div> is anyway.
 * =================================================================== */
.js-gate {
  display: none;
}

.js .js-gate {
  display: var(--js-gate-display, block);
}

/* The shared hit area for a small control, in the RELOCATED category of
 * `references/control-density.md`'s touch-target register — a 22x22px
 * visual box with a `::before` synthesizing a real 44x44 pointer/touch
 * target (22 + 11 + 11 = 44), which is a hit-area GAIN, never a trade.
 *
 * Every value here is `.copy-btn`'s own, reused VERBATIM rather than
 * re-chosen: the 22x22 box, the zero padding, the inline-flex centring,
 * the 6px radius, the negative-11px inset and the 14px glyph
 * `.copy-btn .icon` already scopes its SVG down to. `.row-toggle` did
 * exactly this in 22-09 and its own comment says so; this is the third
 * consumer of one register entry, not a fourth set of numbers. A
 * harness asserts every one of those declarations EQUAL to
 * `.copy-btn`'s and recomputes the 44 from the declared box and inset,
 * so a future density pass that shrinks one and forgets the other
 * fails rather than drifts.
 *
 * NO FOCUS RULE HERE, AND THAT IS DELIBERATE. The global
 * `a/button/input/select/summary:focus-visible` floor near the top of
 * this file already paints every native interactive element, and a
 * handle in this phase is a real <button>. A plan adding its own focus
 * ring to a control is duplicating a solved problem and will end up
 * with a second, subtly different ring. (For the record, so the next
 * reader does not go looking in the wrong place: that floor is a
 * top-level rule, NOT part of the one `@supports selector(:has(*))`
 * block — what lives inside the feature query is the DELEGATED focus
 * ring for cards wrapping a visually-hidden radio, which is a different
 * mechanism for a different markup shape.)
 *
 * No background, border or content of its own on the `::before`: it is
 * a pointer target, not a painted surface. */
.control-hit-area {
  width: 22px;
  height: 22px;
  padding: 0;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 6px;
  background: transparent;
}

.control-hit-area::before {
  content: "";
  position: absolute;
  inset: -11px;
}

.control-hit-area .icon {
  width: 14px;
  height: 14px;
}

/* The continuous-value control's three-part naming convention, shared
 * by 25-04's quiet-hours dial and 25-05's wake-interval slider:
 *
 *   .value-control           the wrapper, and the positioning context
 *   .value-control__track    the box companion/static/value-controls.js
 *                            measures the pointer against
 *   .value-control__handle   the grabbable, focusable <button>
 *
 * There is deliberately no `__readout` class. A control's printed value
 * is a TIME VALUE and this file already has exactly one role for that
 * (`.time-value`, C5's consolidation of four competing treatments,
 * carrying `font-variant-numeric: tabular-nums` so a changing digit
 * never reflows its neighbours). A readout wears that class. Adding a
 * fourth numeric voice here would be reopening C5.
 *
 * Only the declarations that are genuinely SHARED live here — each
 * control's own geometry (a track's length, a dial's diameter, where a
 * handle sits for a given fraction) is that control's plan's, and
 * guessing it now would be inventing numbers with nothing to measure
 * them against. The two that ARE shared:
 *
 * `touch-action: none` is load-bearing and easy to omit: without it the
 * browser's own panning gesture claims a press-and-drag on every touch
 * device, `pointermove` never reaches value-controls.js, and the
 * control is simply immovable on a phone — with no error anywhere and
 * every string-comparison check still green. It is declared on the
 * wrapper AND on the handle, because the handle is the element a drag
 * actually starts on and `touch-action` does not inherit.
 *
 * `position: relative` on the wrapper and the track is what an
 * absolutely-placed handle is measured against; without it the handle
 * would resolve against the nearest positioned ancestor somewhere else
 * on the page.
 *
 * The painted position arrives as the `--value-fraction` custom
 * property, written by value-controls.js from the value it just read
 * back off the native input — so the geometry stays in the stylesheet
 * and the script stays out of the layout business. With scripts
 * blocked the property is simply never set, which is correct: the
 * control it positions is inside a `.js-gate` and is not rendered at
 * all. */
.value-control {
  position: relative;
  touch-action: none;
}

.value-control__track {
  position: relative;
}

.value-control__handle {
  position: absolute;
  touch-action: none;
  /* 28-02-PLAN.md (CFG-73, Bug B): every OTHER transitioned property the
   * base `button` rule lists, minus `transform`. A handle's polar
   * position is a REPRESENTATION OF A VALUE, not an affordance that
   * should ease anywhere — easing it means that for ~150ms after every
   * value change the handle is drawing a position the control does not
   * hold, the same class of lie as 27-02's stale arc, in geometry
   * instead of in a sentence. The position now tracks `--value-fraction`
   * instantly instead of lerping through the chord. The hover/press
   * colour feedback below is genuinely an affordance and keeps its
   * easing. */
  transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease;
}

/* ===================================================================
 * 25-04-PLAN.md Task 2 (CFG-48) — THE QUIET-HOURS DIAL: A SERVER-DRAWN
 * 24 h RING, AND THE FIRST CONSUMER OF THE VOCABULARY ABOVE.
 *
 * Placed here, immediately after the three shared names, because this
 * is the component that gives them a subject. Placement is for
 * READABILITY only — every selector below is unique to this component.
 *
 * WHAT IS PAINTED HERE IS NOT AN AFFORDANCE. The ring, its arc and its
 * four anchor-hour labels are rendered by the SERVER on every render
 * and are outside the `.js` gate entirely; only the two drag handles
 * are gated. That split is the whole design: with scripts blocked the
 * visitor still sees a correct picture of the saved window and can
 * still type both times into the two native time inputs below it.
 *
 * NO ACCENT ANYWHERE IN THIS COMPONENT. The header comment's
 * accent-reservation list is exhaustive and a dial is not on it. What
 * this drawing says, it says in INK — the same choice the airfield
 * diagram further down this file already made, and for the same
 * reason.
 *
 * NO `fill` AND NO `stroke-width` DECLARED BELOW, deliberately. Both
 * ride as presentation ATTRIBUTES on the emitted shapes: a CSS
 * stroke-width of any specificity beats a presentation attribute and
 * would flatten the geometry companion/pages/config_page.py derives
 * from its own constants, and a CSS `fill: none` beside an attribute
 * already saying `none` is the inert restatement 25-03 measured,
 * deleted and wrote this sentence about.
 * =================================================================== */
.quiet-dial {
  /* The positioning context the four hour labels and (once script is
   * running) the two gated handles resolve against. Without it they
   * would resolve against whatever ancestor happens to be positioned
   * somewhere else on the page. */
  position: relative;
  /* The drawing's own intrinsic size, restated so the block that holds
   * it is exactly as wide as the ring and `margin: auto` therefore
   * centres the ring rather than a wider box. Both this and the radius
   * below are pinned by a harness against
   * companion/pages/config_page.py's own QUIET_DIAL_SIZE and
   * QUIET_DIAL_RADIUS: the handle has to land on the stroke the emitter
   * drew, and two numbers that have to agree and live in two files
   * agree until one of them is edited. */
  width: 176px;
  /* The ring's stroke centre line, which is the circle the handles ride
   * on. Declared here rather than inside the handle's own transform so
   * the one number is written once. */
  --quiet-dial-radius: 78px;
  margin: 0 auto var(--space-sm);
}

/* `display: block` and nothing else. An <svg> is replaced-inline by
 * default, which puts a baseline descender gap under it — real vertical
 * space this card would then carry below a drawing that has already
 * ended. A `width: 100%` / `height: auto` pair was written here, then
 * measured to be inert (the canvas carries its own intrinsic 128x128
 * attributes and its parent is 128px wide) and deleted rather than left
 * behind with a comment claiming it mattered. */
.quiet-dial__ring {
  display: block;
}

/* The whole 24 hours: structural ink, the same token every hairline
 * card edge in this file already uses, so the day recedes behind the
 * window drawn on top of it without a second rule and inverts with the
 * theme for free. */
.quiet-dial__day {
  stroke: var(--color-border);
}

/* The quiet window itself, and the subject of the whole drawing: full
 * ink. Painted from --color-text rather than a literal, which is what
 * makes it correct in dark mode — the token inverts and the shape
 * follows. It out-reads both the day ring it is drawn on and the four
 * labels that orient it, which is the reading order this card wants: the
 * window first, then where it sits in the day. */
.quiet-dial__arc {
  stroke: var(--color-text);
}

/* 27-02-PLAN.md Task 2 (CFG-62) — THE ARC, REPAINTED FROM THE PAIR,
 * ONCE SCRIPT HAS PROVEN IT IS RUNNING.
 *
 * `.js` is added to <html> unconditionally by nav-dropdown.js's own
 * first statement (the same gate the `.js .mobile-nav` rules above
 * already rely on) — so this rule is live the instant ANY script has
 * run, before companion/static/value-controls.js has painted anything
 * at all. That is deliberate: the server ALSO writes
 * --quiet-start-fraction/--quiet-sweep-fraction inline on `.quiet-dial`
 * itself, from the same span that draws the presentation attributes
 * below, so at rest this rule and the server's own `stroke-dasharray`/
 * `transform` describe the identical picture. Only once a drag or a
 * preset moves the pair does this rule diverge from the presentation
 * attributes underneath it — which it is always free to do, because a
 * CSS declaration of any specificity beats one, exactly as
 * quiet_dial_svg()'s own docstring already records.
 *
 * THE PRESENTATION ATTRIBUTES THIS OVERRIDES ARE NEVER TOUCHED. They
 * are still the saved value and still the no-JS floor; this rule only
 * ever draws OVER something already correct.
 *
 * NO `pathLength` HERE, though 27-02-PLAN.md's own action text asked
 * for one — measured, not assumed: `pathLength="1"` on this circle
 * while its `stroke-dasharray` stayed in the SAME real user-unit values
 * `draw.unit_circle_dash_array()` already emits reinterprets those
 * numbers as a SECOND, PATHLENGTH-SCALED coordinate system, and a
 * headless-browser measurement of exactly this ring (r=78, the
 * 23:00→07:00 dasharray) painted a second, spurious dash on the far
 * side of the ring that neither the server nor this stylesheet asked
 * for. `--quiet-dial-radius` already exists on `.quiet-dial` (the
 * handle transform above already reads it) and needs no `pathLength` to
 * turn a fraction into a real dasharray length, so the circumference is
 * computed here instead — 2π, spelled out because CSS has no built-in
 * constant for it.
 *
 * `transform-box`/`transform-origin` are declared explicitly rather
 * than left at their default: a CSS `rotate()` on an SVG shape rotates
 * about the shape's OWN geometry only once told to, and the emitter's
 * own presentation-attribute form (`rotate(deg cx cy)`,
 * _QUIET_DIAL_TWELVE_OCLOCK_DEG's own Python constant restated as
 * `-90deg` below) already rotates about the circle's centre — matching
 * that without matching its EXACT resolved matrix is fine, because nothing
 * downstream reads the translation component (a decoder that recovers
 * the angle through atan2 never touches it, verified against this rule
 * on this tree). */
.js .quiet-dial .quiet-dial__arc {
  transform-box: fill-box;
  transform-origin: center;
  transform: rotate(calc(-90deg + var(--quiet-start-fraction, 0) * 1turn));
  stroke-dasharray:
    calc(var(--quiet-sweep-fraction, 0) * var(--quiet-dial-radius) * 6.283185307)
    calc(var(--quiet-dial-radius) * 6.283185307);
}

/* The four anchor hours, absolutely placed inside the ring's hole
 * rather than around its outside — outside would grow this block past
 * the 128px the parent declares and reopen the horizontal-overflow
 * question at 360px. Muted to the same strength as the arc: they orient
 * the drawing, they are not part of its reading. */
.quiet-dial__hour {
  position: absolute;
  /* 30% is this file's own existing CONTEXT strength — the same one the
   * airfield diagram further down uses for the runways a card is not
   * about — because that is exactly what these are: they place the arc,
   * they are not part of its reading. A harness asserts they stay
   * weaker than the arc. */
  color: color-mix(in srgb, var(--color-text) 30%, transparent);
}

/* The four quarter turns. Each label is placed by its OWN edge and then
 * pulled back by half of itself on the axis it has to centre on, which
 * is what makes the numeral's centre land on the ring's axis rather
 * than its leading edge. */
.quiet-dial__hour--0 {
  top: 16%;
  left: 50%;
  transform: translateX(-50%);
}

.quiet-dial__hour--6 {
  top: 50%;
  right: 13%;
  transform: translateY(-50%);
}

.quiet-dial__hour--12 {
  bottom: 16%;
  left: 50%;
  transform: translateX(-50%);
}

.quiet-dial__hour--18 {
  top: 50%;
  left: 13%;
  transform: translateY(-50%);
}

/* The window in words, under the drawing it describes. A <p> arrives
 * with a 1em margin on both sides; the top one would open a gap between
 * a picture and its own caption, so it is zeroed and the bottom one
 * spends an existing spacing token to separate the pair from the preset
 * row below. Centred, because it reads as the ring's caption and a
 * left-aligned caption under a centred drawing reads as a stray
 * sentence. The element wears the shared numeric voice (tabular figures)
 * through its own class in the markup, so no type is declared here. */
.quiet-dial__readout {
  margin: 0 0 var(--space-md);
  text-align: center;
}

/* 25-04-PLAN.md Task 3 (CFG-48) — THE TWO DRAG HANDLES, AND THE ONLY
 * PART OF THIS COMPONENT BEHIND THE `.js` GATE.
 *
 * The layer is one per handle, each covering the ring exactly, so the
 * pointer angle value-controls.js measures is measured about the ring's
 * own centre. It is a FRAME AND NEVER A TARGET: `pointer-events: none`
 * here (and on the measurement box inside it) is what stops the upper
 * of the two stacked layers from swallowing every press meant for the
 * ring or for the other handle. Without it the whole dial would steer
 * whichever end happened to be emitted last, from any press anywhere on
 * it, with nothing else in this codebase noticing.
 *
 * `inset: 0` rather than a repeated width/height: the layer is the
 * drawing's box by definition, and restating 128px twice more is two
 * more numbers to keep in step. */
.quiet-dial__handles {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

.quiet-dial__handle-track {
  position: absolute;
  inset: 0;
  pointer-events: none;
}

/* The grabbable end itself. Its `position: absolute` and its
 * `touch-action: none` both come from the shared .value-control__handle
 * rule above, and its 22x22 box with the 44x44 pointer target
 * synthesised by a -11px inset ::before comes from the shared
 * .control-hit-area rule above that — three classes on one element, each
 * contributing what it owns. A harness pins the SOURCE ORDER of those
 * two shared rules, because they both declare `position` at equal
 * specificity and the absolute one has to win.
 *
 * POLAR PLACEMENT WITHOUT ANY TRIGONOMETRY, and that is why the
 * transform reads the way it does. Starting from the ring's centre:
 * pull the button back by half of itself so its own centre sits there,
 * rotate the frame by the value's fraction of a turn, walk outwards to
 * the ring's stroke line, then rotate the same amount BACK so the
 * button is carried round the circle without being spun on the way.
 * The alternative is a CSS sin()/cos() pair, which buys nothing here and
 * costs a browser-support argument.
 *
 * The fraction falls back to 0 rather than being required: an unset
 * custom property in a calc() invalidates the whole declaration, which
 * would drop the centring translate too and leave the handle in the
 * corner of the ring.
 *
 * Repainted rather than inherited: .control-hit-area is transparent and
 * borderless by design (it is a hit target for a glyph, and the Copy
 * button supplies its own glyph), so a handle wearing it and nothing
 * else would be invisible. Canvas fill with a full-ink edge reads as a
 * grip in both themes from one rule, and uses no accent — the header
 * comment's accent-reservation list is exhaustive and a dial is not on
 * it. */
.quiet-dial__handle {
  top: 50%;
  left: 50%;
  transform:
    translate(-50%, -50%)
    rotate(calc(var(--value-fraction, 0) * 1turn))
    translateY(calc(-1 * var(--quiet-dial-radius)))
    rotate(calc(var(--value-fraction, 0) * -1turn));
  pointer-events: auto;
  background: var(--color-canvas);
  border: 1px solid var(--color-text);
  border-radius: 50%;
}

/* THE ONE PLACE THIS COMPONENT OVERRIDES THE SHARED HIT-AREA REGISTER,
 * and it is a GAIN rather than a trade: 22 + 12 + 12 = 46 against the
 * shared entry's 22 + 11 + 11 = 44.
 *
 * The shared inset synthesises EXACTLY the floor, with nothing to spare,
 * which is correct for every consumer placed on the pixel grid. This
 * handle is not: it is carried round a circle by a rotate/translate
 * pair, so it lands at a FRACTIONAL pixel position and the browser snaps
 * its hit region to the grid — measured on this tree at 43x43 with the
 * shared inset, one pixel under a floor the declared box clears. One
 * extra pixel per side survives that snapping in either direction.
 *
 * Declared here rather than by loosening the shared rule, because every
 * other consumer of that rule really is grid-aligned and does not need
 * it. */
.quiet-dial__handle::before {
  inset: -12px;
}

/* ===================================================================
 * 25-05-PLAN.md Task 2 (CFG-49/CFG-52) — D18'S WAKE-INTERVAL SLIDER.
 *
 * TWO DECLARATIONS, AND THE SHORTNESS OF THIS BLOCK IS THE POINT. The
 * control is a NATIVE <input type="range">, so the browser supplies the
 * track, the thumb, the drag, the keyboard model and the accessible
 * value; the global `input, select` rule above already supplies the
 * 44px floor, the padding, the surface, the 1px structural edge, the
 * radius and the `accent-color` every other native control in this app
 * wears. Re-declaring any of that here would be re-choosing values the
 * file has already chosen.
 *
 * IT DELIBERATELY DOES NOT WEAR `.value-control`. That class is the
 * wrapper vocabulary for a control whose absolutely-placed handle is
 * positioned from `--value-fraction` — it declares `position: relative`
 * as that handle's containing block and `touch-action: none` so a touch
 * drag is not claimed by the browser's panning gesture. A native range
 * has no such handle to contain, and `touch-action: none` on an
 * ancestor of one would be this plan taking a position on a gesture
 * the browser already implements, with no way to measure the result in
 * a headless harness. Both declarations would be inert at best here,
 * and the second could be worse than inert. (Recorded rather than left
 * silent: a later reader will notice the phase's other control uses the
 * shared class and wonder.)
 *
 * WHY `width: 100%` IS THE ONE GEOMETRY DECLARATION: a range input's
 * intrinsic width is about 129px in this engine, which at the 360px
 * floor would be a third of the card and a sweep of the whole 60..3600
 * band in about 120 pixels. It is a full-width control or it is not
 * worth having.
 *
 * The wrapper needs no `--js-gate-display`: `block` is the gate's own
 * fallback and a <div> is block anyway, so declaring it would be inert.
 * =================================================================== */
.wake-slider {
  margin-top: var(--space-sm);
}

.wake-slider__input {
  width: 100%;
}

/* Unified save bar (06.6.4.1 D-04, retargeted by 19-10-PLAN.md D-09/
 * A-27, then again by 22-01-PLAN.md Task 2 D-01/B1): hides the form's
 * always-rendered bottom Save settings button once dirty-state.js has
 * PROVEN its own replacement bar is actually live, so the dirty-state
 * bar is the sole visible save affordance rather than both showing at
 * once. This used to key on `.js` — added to <html> unconditionally as
 * `nav-dropdown.js`'s very first statement (the same mechanism the
 * `.js .mobile-nav` rules above still rely on) — but `.js` says nothing
 * about whether dirty-state.js itself succeeded, so any failure in that
 * other, unrelated file left this fallback button hidden with no
 * working replacement: no way to save settings at all (A-27).
 * `.dirty-ready` was the first fix: added by dirty-state.js itself, only
 * once it confirmed its bar's own root element and count node exist. That
 * turned out to still be element PRESENCE, not proven liveness (B1) —
 * every field attached to the form only via `form=` lived outside it, so
 * the bar's own listeners never fired for them, and the fallback still
 * hid while the replacement bar silently never worked for those fields.
 * This rule now requires a SECOND, narrower marker too: `.dirty-shown`,
 * written only inside dirty-state.js's own updateBar() function, in the
 * branch that actually reveals the bar with real content — never at
 * script-init time. The fallback button hides if and only if BOTH
 * classes are present, i.e. only once a working replacement has
 * genuinely displayed at least once. Without JS, or if either of
 * dirty-state.js's own guards fails, at least one class never appears,
 * this rule never matches, and the bottom button remains the only way to
 * save (D-06 graceful degradation, the D-09 no-way-to-save fix, and now
 * B1's proven-liveness fix).
 *
 * 27-03-PLAN.md (2026-09-15, CFG-64): SUPERSEDED above, not withdrawn —
 * the account above was correct for a page whose only save feedback was
 * the bar it describes, and this paragraph does not erase that history.
 * Auto-save (27-04) retires the bar entirely; with no dirty state left
 * to represent, there is nothing left for the two narrowing marker
 * classes above to mean on THIS rule, and nothing left for them to
 * prove the liveness of. The floor this rule used to carry is kept a
 * different way now: render()'s emission of the attribute
 * STATIC_SAVE_FALLBACK_ATTR names is unconditional on every scope
 * (companion/pages/config_page.py, verified at the source by
 * 27-03-PLAN.md Task 1), so there is no render-time branch left for a
 * hiding rule to guard against. Hiding this button is now a plain
 * script-presence decision — the same one the file's other progressive-
 * enhancement rules already make — rather than a proof that a specific
 * replacement bar succeeded.
 *
 * 28-08-PLAN.md Task 2 (CFG-77/CFG-78), 2026-09-16: the RULE below is
 * RETIRED (not the comment above it, which stays, third layer on the
 * same block) — the developer asked for the pre-Phase-27 dirty bar
 * back (ROADMAP.md's Phase 28 addendum), and the button this rule used
 * to hide with `.js` is now the bar's OWN visible Save, relocated into
 * its markup (companion/pages/config_page.py's render(), Task 1). Its
 * visibility is the bar's own `hidden` attribute and nothing else —
 * `.dirty-bar[hidden] { display: none; }`, below, near the restored
 * `.dirty-bar` base rule — never a second, independent hide mechanism
 * for the same button, which is exactly the orphan CFG-78 forbids.
 * `_style_css_references_static_save_fallback_attr()` is retargeted to
 * assert NO rule selector anywhere in this file still contains
 * `data-static-save-fallback` (companion/test_config_page.py). */

/* 27-04-PLAN.md Task 3 (D-04/CFG-63): the auto-save status region that
 * replaces the retired dirty bar — companion/pages/config_page.py's own
 * _save_status_region_html(), a sibling of `.text-label` in role but not
 * in markup (it needs a hook of its own for dirty-state.js's lookup, so
 * it carries both classes at the render site rather than this rule
 * duplicating .text-label's own three declarations).
 *
 * NOT fixed, NOT an overlay, NOT sticky: the overlay drawer CFG-31
 * already refused stays refused, and a second floating save affordance
 * is exactly the defect this plan's own objective names as the reason
 * the bar is gone. `margin-top`/`margin-bottom` are the only geometry
 * this rule owns — everything else about its position in the page comes
 * from where render() places it in the document (beside the page's own
 * heading, before the Frame strip and the form).
 *
 * No color declared: at rest it is empty text, and while it holds a
 * word it is exactly as loud as any other .text-label caption on the
 * page — a save should read as confirmed, not alarmed, and this app's
 * accent/status colours are reserved for primary actions and real
 * warnings (22-UI-SPEC.md §1), neither of which this region ever is.
 *
 * SUPERSEDED by 28-08-PLAN.md Task 2 (CFG-77/CFG-78), 2026-09-16: the
 * region this comment describes is deleted along with the RULE below
 * (companion/pages/config_page.py's own superseded comment at the
 * former SAVE_STATUS_* constants' site has the full account). The
 * comment stays, legible, because it describes a real element that
 * really existed for one phase — but there is no `.save-status`
 * element left to style, so the RULE — not the comment — is gone. The
 * `.dirty-bar` restored below is fixed-positioned again, which is
 * exactly what makes this block's own "NOT fixed, NOT an overlay, NOT
 * sticky" conclusion false again too; see the five superseded blocks
 * further down this file (each named 28-08 in its own margin) for that
 * half of the story. */

/* 22-14-PLAN.md Task 2 (X9/D-10): `.mobile-nav__nav`'s own rule is
 * DELETED, not left as a dead selector — the element it styled no
 * longer exists in any rendering (companion/layout.py's
 * _mobile_nav_html() emits the state reminder and the footer only).
 * `.mobile-nav__link` below survives and is REUSED by the tab bar's
 * "More" sheet at the foot of this file, which is why that rule's 44px
 * floor is still load-bearing. */

/* The 44px floor was D-22's original guarantee for this rule. D-05
 * (06.6.4-04) was a desktop compactness pass whose trade belonged only on
 * the desktop sidebar renderer (.sidebar-link, below) — but the same edit
 * also landed here by mistake. This selector is the mobile-only nav below
 * the 960px breakpoint with no compensating override anywhere in the
 * file, so the trade reached the one place it had no argument: a phone
 * has no pointer-driven compactness case, and a 32px row at Label size is
 * a worse tap target than the 44px/Body-size row it replaced. Quick task
 * 260902-qkm restored the floor on the developer's report that it made
 * phone navigation harder. The explicit --font-ui is still D-03's
 * boundary stated in code: the sketch prototype rendered these links in
 * serif at 18px and that detail was deliberately not carried into the
 * contract — nav links stay sans-serif here. */
.mobile-nav__link {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  min-height: 44px;
  padding: 0 var(--space-sm);
  color: var(--color-text);
  text-decoration: none;
  font-size: var(--font-body-size);
  font-family: var(--font-ui);
  border-radius: var(--radius-control);
  transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}

/* Same tinted-pill active idiom .sidebar-link--active already uses (D-05),
 * so the two navigation renderings signal "you are here" identically.
 * This is now the single active cue — the left rule this modifier used to
 * declare is dropped, and the icon plus the accent text colour are what
 * carry the signal alongside the pill. */
.mobile-nav__link--active {
  color: var(--color-accent);
  background: color-mix(in srgb, var(--color-accent) 12%, transparent);
  border-radius: var(--radius-control);
  font-weight: var(--weight-semibold);
}

/* D-05: a bare hover selector here would outrank .mobile-nav__link--active
 * (equal specificity, later in source) and replace the active tint with a
 * neutral wash the moment the pointer crossed it — :not()-scoping is what
 * keeps the two states from ever colliding. */
.mobile-nav__link:not(.mobile-nav__link--active):hover {
  background: color-mix(in srgb, var(--color-text) 4.5%, transparent);
}

/* 06.6.2-05 (D-17): the theme picker's home, now grouped with the
 * Sign out control (_logout_form_html()) inside one footer region,
 * separated from the links by a hairline — this is where it relocates
 * now that it is no longer squeezed into the header row, which is what
 * removes D-00's root cause rather than re-fixing it. Supersedes the
 * mobile theme-form-only spacing/border rule this same wrapper owned
 * before the logout control existed. */
.mobile-nav__footer {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
  padding: var(--space-sm);
  border-top: 1px solid var(--color-border);
}

/* .site-header { display: none } inside the >=960px block below already
 * removes the toggle and the dropdown along with their parent header — no
 * additional hide rule is needed here, and none should be added. */

/* Health nav-tab notification dot (06.6.1). Emitted with the existing
 * .dot.dot--error classes (plan 04), so it inherits the circle shape, the
 * destructive colour and the vertical alignment from .dot/.dot--error and
 * only overrides size and spacing — deliberately reuses .dot--error
 * rather than introducing a fourth status colour. margin-right: 0
 * cancels .dot's own trailing margin because nothing follows it. */
.nav-notification {
  width: 8px;
  height: 8px;
  margin-left: var(--space-xs);
  margin-right: 0;
}

/* Screen-reader-only utility, carries the Health nav label's " —
 * attention needed" suffix. Must never be given display: none or
 * visibility: hidden — either would remove the text from the
 * accessibility tree and defeat its only purpose. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* quick task 260902-l9w: this utility's own 1px sizing above is inert on
 * an `<input>` or `<select>`, because a `min-height`/`min-width` from
 * another rule is a floor applied at layout time, not a cascade
 * competitor — the same mechanism `.settings-checkbox input[type="checkbox"]`
 * and `.filter-bar__field input` (below) already clear for their own
 * elements, and their comments already spell out. This is the third
 * site needing the same clearing, and the reason it was missed twice
 * already: those two are visible controls someone was actively
 * resizing, this one is invisible by design, so nobody measured it
 * until a real mobile browser painted a stray 44px radio dot over the
 * Runway picker (config_page.py's runway_fieldset()). Clearing the
 * floor here is safe specifically because this input has no hit area
 * of its own — the wrapping `.runway-card` <label> is the real
 * activation target and exceeds 44px in both axes on every viewport, so
 * this is delegation, not a WCAG 2.5.5 trade (see the touch-target
 * floor register above the `button` rule, which lists the global
 * `input, select` rule below as one of the three that KEPT the floor —
 * that rule is deliberately untouched here). This rule wins over it on
 * specificity (a class plus a type selector beats a bare type
 * selector), independent of source order. The `select.visually-hidden`
 * half is a forward guard only; there is no `<select>` in this codebase
 * today. */
input.visually-hidden,
select.visually-hidden {
  min-height: 0;
  min-width: 0;
  appearance: none;
  -webkit-appearance: none;
}

/* 06.6.2-05 (UXA-10): the skip-to-content link — the first focusable
 * element in <body> (layout.page_shell()). Visually hidden off-screen
 * until it receives keyboard focus, at which point it becomes the
 * first visible, high-contrast element on the page so a keyboard user
 * can bypass the nav entirely. */
.skip-link {
  position: absolute;
  top: -40px;
  left: 0;
  background: var(--color-accent);
  color: var(--color-on-accent);
  padding: var(--space-sm) var(--space-md);
  z-index: 10;
  border-radius: 0 0 var(--radius-control) 0;
}

.skip-link:focus {
  top: 0;
}

/* D-06 (06.6.4-04): the shared segmented-control container. D-06a's
 * premise check (this plan's Task 2) confirmed _theme_form_html() already
 * wraps all three option buttons in exactly this one <form> element, so
 * the container role is implemented entirely here — no markup change. */
.theme-form {
  display: flex;
  align-items: center;
  gap: 2px;
  padding: 2px;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
}

/* Doubled `.theme-form .theme-option` (specificity 0,2,0) rather than the
 * bare class (0,1,0): theme-option buttons are `<button type="submit">`,
 * so the later `button[type="submit"]` rule (0,1,1) would otherwise win
 * on `background` regardless of source order, painting every theme
 * button — active or not — with the same accent fill and making the
 * active indicator below effectively invisible. This is also why
 * `box-shadow: none` below is load-bearing rather than decorative: plan
 * 02 gave every submit button an inset top highlight, and without this
 * override each segment would render as a raised chip inside the new
 * bordered container instead of a flush segment. */
.theme-form .theme-option {
  height: 28px;
  /* Without flex: 1 1 auto the three intrinsic-width buttons pack to the
   * left of the now full-width bordered container, reading as a box
   * containing buttons rather than as one control — D-06's whole point. */
  flex: 1 1 auto;
  padding: 0 12px;
  background: transparent;
  border: none;
  box-shadow: none;
  border-radius: 6px;
  /* 70% (not D-06's suggested 60%) to match this file's one existing
   * muted-secondary strength exactly — the same 0.7 the merged-cell and
   * history-card secondary rules use — so the app has a single muted read
   * rather than two, with more contrast headroom on the sidebar surface. */
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
  font-size: 13px;
  font-weight: var(--weight-regular);
  cursor: pointer;
  transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}

/* Same tinted-pill active idiom the nav links use (D-05) — byte-for-byte
 * the same three properties, which is what D-06 means by reusing rather
 * than reinventing the active language. No border-color to clear: the
 * per-segment border is gone, the container now owns the boundary. */
.theme-form .theme-option--active {
  background: color-mix(in srgb, var(--color-accent) 12%, transparent);
  color: var(--color-accent);
  font-weight: var(--weight-semibold);
}

/* :not()-scoped at the doubled specificity above, for the same reason as
 * the nav hovers: an unscoped/lower-specificity hover here could never
 * even reach these buttons past `button[type="submit"]`, but scoping
 * against the active modifier keeps the pattern uniform and is what the
 * pre-existing doubled-specificity comment above already tells a future
 * author to do if a hover state is ever wanted here. */
.theme-form .theme-option:not(.theme-option--active):hover {
  background: color-mix(in srgb, var(--color-text) 4.5%, transparent);
}

/* B7/C3 (22-AUDIT.md, 22-10-PLAN.md Task 2): the missing HALF of the
 * pair above. Clicking a language or theme segment reloads the page with
 * the pointer still over the now-ACTIVE segment; `button[type="submit"]:
 * hover` is (0,2,1) and beats `.theme-form .theme-option--active`'s
 * (0,2,0), so the segment took the primary accent FILL while keeping the
 * active rule's accent TEXT — accent on accent, measured rgb(150,54,16)
 * behind rgb(177,63,22). "FR"/"EN" was unreadable until the pointer
 * moved.
 *
 * This is a POSITIVE RESTORE rule, not a re-scope: (0,3,0) beats the
 * submit hover, and it restates the active pill's own two properties
 * rather than inventing a hover appearance. The values come from the
 * wash register verbatim — the same 12% accent mix the active rule above
 * uses, which is also the app's shared tinted-pill idiom. 22-AUDIT.md's
 * own "12-18% wash" is a suggestion; references/control-density.md's
 * register is the contract, so no new percentage is introduced here.
 *
 * Why this cannot be one unscoped `.theme-form .theme-option:hover`
 * instead: at (0,3,0) it would equal this rule's specificity and, placed
 * later, would paint the 4.5% text wash over the active segment's own
 * tint the moment the pointer crossed it. The :not()-scoped pair is the
 * documented correct shape. */
.theme-form .theme-option--active:hover {
  background: color-mix(in srgb, var(--color-accent) 12%, transparent);
  color: var(--color-accent);
}

/* Read-only theme status (06.6.3 D-04) — a plain <div> replacing the old
 * <fieldset> in this slot; a bare <div> inherits none of fieldset's box
 * styling, so padding/margin-bottom are restated explicitly here to match
 * what the removed fieldset rule used to provide, avoiding a visual
 * collapse to zero spacing.
 *
 * That was never the whole story, and it's why this wrapper shipped with a
 * visible gap (quick task 260901-qif): restating fieldset's spacing gave
 * the Theme, Runway and Diagnostic LED groups their padding and margin
 * back, but never the card treatment every `.page-section` on this same
 * page carries, so all three groups rendered as unbounded canvas-coloured
 * regions directly beside a properly-carded Poll section. This rule is now
 * matched declaration-for-declaration against `.page-section` below — same
 * `--color-dominant` surface, same `--color-border` hairline at rest, same
 * `--radius-control` corners, same no-resting-shadow, same hover/
 * focus-within reveal. The two must be edited together if either
 * changes. */
.theme-status {
  padding: var(--space-md);
  margin-bottom: var(--space-lg);
  background: var(--color-dominant);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  box-shadow: none;
}

.theme-status:hover,
.theme-status:focus-within {
  border-color: transparent;
  box-shadow: var(--shadow-card-hover);
}

.theme-status__row {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
}

.theme-swatch {
  display: inline-flex;
  gap: var(--space-xs);
}

/* The chip's fill is an inline style carrying a raw e-ink PALETTE_RGB
 * value (config_page._palette_hex()), not a theme token — so it is the
 * one colour in this file the stylesheet cannot reason about. The
 * hairline is what keeps a chip visible when its palette entry happens
 * to be near the card surface it sits on: the panel palette includes
 * white, and today's single registered theme just happens not to use
 * it. Structural border, structural token. */
.theme-swatch__chip {
  width: 16px;
  height: 16px;
  border: 1px solid var(--color-border);
  border-radius: 4px;
  display: inline-block;
}

/* Dashboard sidebar (D-02, >=960px only). Base state is hidden — the
 * 960px @media block below turns it on. Using `display` (rather than a
 * transparency or CSS-visibility approach) keeps the hidden copy out of
 * the tab order and the accessibility tree at mobile width, so a phone
 * user tabbing through the page never lands on an invisible link and the
 * page never exposes two navigation landmarks at once. */
.dashboard-sidebar {
  display: none;
}

.sidebar-nav {
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
}

.sidebar-link {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  height: 32px;
  padding: 0 var(--space-sm);
  color: var(--color-text);
  text-decoration: none;
  font-size: var(--font-label-size);
  letter-spacing: 0.02em;
  border-radius: var(--radius-control);
  transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}

.sidebar-link__icon {
  flex: none;
}

/* D-05 (06.6.4-04): the left rule D-17 kept as a secondary cue is
 * dropped outright — the tinted pill is now the sole "you are here"
 * signal, with the icon and the accent text colour carrying it alongside.
 * A second cue (the old left rule) doubled the signal without adding
 * information; a single signal is what D-05 asks both nav renderers to
 * agree on. */
.sidebar-link--active {
  color: var(--color-accent);
  background: color-mix(in srgb, var(--color-accent) 12%, transparent);
  border-radius: var(--radius-control);
  font-weight: var(--weight-semibold);
}

/* D-05: :not()-scoped for the same reason as .mobile-nav__link's hover
 * rule above — an unscoped hover here would outrank the active modifier
 * and erase the tint the instant the pointer crossed the active tab. */
.sidebar-link:not(.sidebar-link--active):hover {
  background: color-mix(in srgb, var(--color-text) 4.5%, transparent);
}

/* WR-01 fix: the generic `a { color }` / `a:hover { color }` rule
 * (specificity 0,1,1) outranks the single-class .sidebar-link/
 * .mobile-nav__link selectors above (0,1,0) on `color`, so without this
 * pair the D-05 :not()-scoping above only ever protected `background` —
 * hovering silently shifted both inactive and active nav-link text to
 * the accent-hover colour regardless of source order. These two rules
 * restore the intended per-state text colour at matching (0,2,0)
 * specificity: inactive links stay neutral text on hover, and the
 * active link keeps its single accent cue instead of drifting to
 * --color-accent-hover. */
.sidebar-link:hover,
.mobile-nav__link:hover {
  color: var(--color-text);
}

.sidebar-link--active:hover,
.mobile-nav__link--active:hover {
  color: var(--color-accent);
}

/* 06.6.1 (D-02/D-04, Reconciliation 1): top/bottom padding is promoted to
 * 2xl (48px, was zero top / lg bottom) for the airier global direction.
 * Sides deliberately stay at lg (24px), not the spec table's 3xl — 64px of
 * side padding on a 375px phone viewport would leave a 247px content
 * column, directly contradicting D-22's mobile floor. The 2xl/3xl pair the
 * spec asks for instead lands on .dashboard-main below (the >=960px
 * content-column level the spec actually names). */
.page-content {
  padding: var(--space-xl) var(--space-lg) var(--space-2xl);
}

/* Status dot: a 12px circle plus a Label-size text description */
.dot {
  display: inline-block;
  width: 12px;
  height: 12px;
  border-radius: 50%;
  margin-right: var(--space-xs);
  vertical-align: middle;
}

.dot--ok {
  background: var(--color-status-ok);
}

.dot--warn {
  background: var(--color-status-warn);
}

.dot--error {
  background: var(--color-status-error);
}

/* 21-04-PLAN.md Task 2 (D-03): a fourth, additive entry in this same
 * dot vocabulary — "off" is a neutral, everyday state (Screen off,
 * Quiet hours off), never a problem, so it gets its own muted-grey
 * dot rather than reusing warn/error. Reuses the exact color-mix
 * value .quick-action--off's own left-edge rule already uses — no
 * new colour literal. */
.dot--off {
  background: color-mix(in srgb, var(--color-text) 30%, transparent);
}

/* 23-05-PLAN.md Task 2 (D14/D22/CFG-34): the FIFTH consumer of the
 * neutral "off" dot rule immediately above, and the first and only
 * consumer of the one animation block this stylesheet defines
 * (23-01-PLAN.md Task 1). It is that rule's existing neutral treatment
 * PLUS motion — no new dot rule, no new dot class, no new colour, no
 * accent and no status colour. (The dot class-name occurrence count in
 * this file is pinned by companion/test_status_pages.py, which greps
 * the RAW source, so this paragraph deliberately does not spell the
 * modifier out. A comment is not a rule and must not be counted as
 * one.) A refresh loop that is listening is not a device verdict and
 * must not borrow one's paint.
 *
 * It shares the contract every other consumer of that rule shares,
 * stated in .claude/skills/sketch-findings-skypane/SKILL.md: a neutral,
 * everyday state that is not a problem. Screen off, Quiet hours off,
 * freshness.js's Paused/Reconnecting badge — and now a live/paused
 * refresh indicator, plus a countdown that has reached its instant and
 * is simply waiting. Plan 23-11 records this consumer in the design
 * system.
 *
 * NOT a dot modifier, deliberately: companion/static/relative-time.js
 * puts this same class on a <time> element whose countdown has run out,
 * so the class has to name the motion rather than the component. Two
 * consumers, one rule, one animation block.
 *
 * UNDER REDUCED MOTION THIS DOES NOT BREATHE, and there is no block
 * here saying so. The global `*, *::before, *::after` override earlier
 * in this file already zeroes animation-duration for every element, and
 * a per-rule block would be the dead-code-not-a-safety-net pattern
 * `accessibility-contrast.md` records a verdict on. The stylesheet's
 * live reduce-block count stays at two and
 * companion/test_companion_app.py fails if it moves. */
.is-breathing {
  animation: skypane-pulse var(--motion-slow) ease-in-out infinite;
}

/* 23-06-PLAN.md Task 2 (D1/CFG-35): the class companion/static/
 * freshness.js puts on a refreshed picture, and ONLY when the picture
 * genuinely changed — that file compares the image's own `src` before
 * adding it. A fade on every swap would flash the page every 45 seconds
 * to say nothing, which is worse than no fade at all: motion that
 * carries no information is the thing a motion budget exists to stop.
 *
 * It names the MOTION, not the component, for the same reason
 * `.is-breathing` above does: the next region that refreshes itself
 * spends this same rule rather than writing a second one.
 *
 * `ease-out` at the call site because easing is not a token (23-01
 * spent exactly two custom properties and easing was deliberately not
 * one of them). One iteration, no fill mode: the element's own opacity
 * is 1 before and after, so there is nothing to hold.
 *
 * UNDER REDUCED MOTION THIS DOES NOT FADE, and there is no block here
 * saying so — the global override earlier in this file already zeroes
 * animation-duration for every element, and a per-rule block would be
 * the dead-code-not-a-safety-net pattern accessibility-contrast.md
 * records a verdict on. The stylesheet's live reduce-block count stays
 * at two. */
.is-fading-in {
  animation: skypane-fade-in var(--motion-fast) ease-out;
}

/* 23-08-PLAN.md Task 1 (D7/CFG-37): the class companion/static/
 * freshness.js puts on a row whose identity was NOT in the set it knew
 * before the swap — a detection that arrived while the page was open,
 * and only that.
 *
 * ONE class, both renderings. Flights renders every flight twice (a
 * desktop `<tr>` and a phone `<li>`) and the diff marks whichever
 * carries the identity, so a single rule serves both rather than two
 * rules that would have to be kept in step.
 *
 * It names the MOTION, not the component, the same way `.is-breathing`
 * and `.is-fading-in` above do: the next list that wants to say "this
 * one is new" spends this rule instead of writing a fourth block.
 *
 * `ease-out` at the call site because easing is not a token (23-01 spent
 * exactly two custom properties and easing was deliberately not one of
 * them). One iteration, no fill mode: the row's own background is its
 * ordinary one before and after, so there is nothing to hold. */
.is-new-row {
  animation: skypane-row-arrive var(--motion-slow) ease-out;
}

.dot-label {
  font-size: var(--font-label-size);
  vertical-align: middle;
}

/* 21-04-PLAN.md Task 2 (D-03): the nav's state-only reminder — a plain
 * link, no button, no form (the phase 18 audit's "no actions in the
 * nav" finding). Sits between the brand and the primary nav list in
 * both the sidebar and the mobile dropdown (nav_status_html()'s own
 * two call sites). Full-strength --color-text on the sidebar surface
 * — the same pairing .sidebar-link's own unmodified colour rule
 * already relies on site-wide (contrast measured and pinned in
 * companion/test_contrast_check.py).
 *
 * B10 (22-14-PLAN.md Task 2): was `display: block` — an inline run with
 * no white-space control, which the audit measured wrapping MID-PHRASE
 * at 207x48px ("Screen on · Quiet hours" / "on"; in French "Heures /
 * calmes activées"). Each state is now its own `.nav-status__segment`
 * with `white-space: nowrap`, inside a wrapping flex row: the line may
 * break BETWEEN the two states, never inside one. French's longest
 * segment ("Heures calmes désactivées") fits the 240px sidebar's own
 * content width, which is what makes the wrapping row a real fallback
 * rather than a second way to produce the same defect. */
.nav-status {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  /* Column gap only. A row gap would be paid ON TOP of the two wrapped
   * lines in the 240px sidebar, where the French pair cannot fit on one
   * line (98px + 165px + the separator against a 207px content column)
   * — measured at 52px with a symmetric gap, against B10's own 48px
   * ceiling, and 48px with this one. */
  gap: 0 var(--space-xs);
  padding: 0 var(--space-sm) var(--space-sm);
  color: var(--color-text);
  text-decoration: none;
}

.nav-status__segment {
  white-space: nowrap;
  /* inline-flex, not a bare inline run: the 12px `.dot` inside carries
   * `vertical-align: middle`, which grows each line box past the 14px
   * text's own 1.4 leading. In the 240px sidebar, where the French pair
   * needs two lines, that surplus put the reminder at 48.19px against
   * B10's own 48px ceiling — measured, then removed by aligning the dot
   * as a flex item instead of as an inline baseline participant. */
  display: inline-flex;
  align-items: center;
}

/* Scoped to the ANCHOR (B10): on Home the reminder renders as a <span>
 * with no href at all — companion/layout.py's nav_status_html() — and a
 * non-link that underlines under the pointer would claim an
 * interactivity it does not have, which is the same defect as the
 * "go to Home" label this plan removes, one layer down. */
a.nav-status:hover {
  text-decoration: underline;
}

/* No horizontal margin any more: the flex `gap` above owns the spacing
 * between the separator and both segments, in one place. */
.nav-status__sep {
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* Banners: a near-neutral card surface, a hairline border and a 3px
 * coloured left edge, appear only when there is something to say (no
 * "all clear" banner — its absence is the signal).
 *
 * 06.6.4 (D-04) replaced the prior 10%-wash treatment: colour is now a
 * punctual signal carried by exactly two things — the 3px left edge and
 * a small `::before` status dot — rather than a tinted background
 * region. The surface is the same --color-dominant card token every
 * card in this file now rests on (06.6.4-01, D-03), so a banner reads
 * as "a card that happens to have a coloured edge and a dot", not as
 * its own distinct tinted zone competing with the accent-filled primary
 * button for attention. Body-text contrast on that surface is pinned by
 * an automated check (companion/test_contrast_check.py's live_pairs,
 * 06.6.4-03) rather than argued in prose the way the wash's rationale
 * used to be — a token change here now fails a test, not a reading. */
.banner {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: 8px;
  padding: var(--space-sm) var(--space-md);
  margin-bottom: var(--space-lg);
  background: var(--color-dominant);
  border: 1px solid var(--color-border);
  border-left: 3px solid var(--color-accent);
  font-size: var(--font-body-size);
}

/* The status dot: a presentational pseudo-element, not a reuse of the
 * .dot component above. The banner builders (companion/layout.py's
 * flash_banner()/anomaly_banner()) emit a single <div> whose only child
 * is escaped plain text — no dot element exists in the markup, and
 * D-04a forbids touching banner content, so the dot has to live in CSS.
 * `flex: none` is required or the dot collapses under the flex
 * container's default shrink-to-fit behaviour. */
.banner::before {
  content: "";
  flex: none;
  width: 6px;
  height: 6px;
  border-radius: 50%;
  background: var(--color-accent);
}

/* Reads --color-status-error, not the former --color-destructive. Those
 * two tokens held identical values in all four token blocks while being
 * used interchangeably for one concept — `.banner--anomaly` and
 * `.page-section.banner--anomaly` took --color-destructive while
 * `.dot--error`, `.stat-tile--error` and `.stat-tile--error
 * .stat-tile__icon` took --color-status-error — so "change the error
 * colour" silently meant "remember to change two tokens, in four token
 * blocks". The duplicate is removed rather than aliased: one concept,
 * one token. */
.banner--anomaly {
  border-left-color: var(--color-status-error);
}

.banner--anomaly::before {
  background: var(--color-status-error);
}

/* 06.6.2-06 (UXA-14): the warning-only counterpart to .banner--anomaly,
 * so a warning-level Health state (e.g. only a corroboration
 * disagreement, device/pipeline both fresh) renders a distinct,
 * less-alarming treatment instead of always the maximal error red. */
.banner--warn {
  border-left-color: var(--color-status-warn);
}

.banner--warn::before {
  background: var(--color-status-warn);
}

/* The flash banner is the neutral/confirmation case and keeps the base
 * .banner accent treatment. The rule is kept (rather than deleted as
 * redundant) so all three severities are visibly enumerated in one
 * place — a reader adding a fourth severity sees the pattern to follow. */
.banner--flash {
  border-left-color: var(--color-accent);
}

.banner--flash::before {
  background: var(--color-accent);
}

/* Anomaly-banner category pill (D-07). A flex child of the already-
 * flattened .banner row above (06.6.4 D-04), not a new container —
 * `flex: none` so it never shrinks inside that row. Deliberately
 * neutral-tinted rather than severity-tinted: the banner's own left edge
 * and dot already carry the severity signal, and a second coloured token
 * per pill would dilute it. Declarations were once kept literally
 * identical to .airline-card__chip's (Airlines gallery section, later in
 * this file) so a future consolidation into one shared class would be a
 * mechanical edit — quick task 260902-v2v (UIR-03) breaks that identity
 * by exactly one declaration (`min-width: 0`, below): the banner pill
 * lives in a wrapping flex row that must survive a 375px viewport
 * without overflowing, while the chip's own row does not need to wrap.
 * A future consolidation now needs to carry that one divergence, not a
 * mechanical copy. */
/* 06.6.4.1.1 (D-13): converges onto the one unified 12px uppercase
 * label voice — see .stat-tile__caption's own comment below for the
 * full contract this and every other label-shaped rule in the file now
 * shares. Edited in place, declarations only; this rule's own position
 * in source order is load-bearing for the .refresh-pill precedence
 * check below and must not move. */
.banner__pill {
  display: inline-flex;
  align-items: center;
  height: 20px;
  padding: 0 8px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  background: color-mix(in srgb, var(--color-text) 6%, transparent);
  border: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent);
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
  flex: none;
  min-width: 0;
}

/* T13 (22-15-PLAN.md Task 2): a consumer of this file's
 * `[hidden]`-versus-`display` guard, joining `.refresh-pill[hidden]` and
 * `.login-reveal[hidden]`. The rule above
 * declares `display: inline-flex`, and an author-stylesheet `display`
 * always beats the user-agent stylesheet's `[hidden] { display: none }`
 * regardless of source order — so the moment any `.banner__pill` is
 * hidden with the native `hidden` attribute it would render anyway.
 * freshness.js's neutral loop-state badge is the first such consumer;
 * the guard belongs on the component, not on that one badge, because
 * the collision is a property of the base rule.
 *
 * `display: none`, not the `visibility: hidden` `.refresh-pill[hidden]`
 * deliberately uses: that pill is absolutely positioned and its sibling
 * comment explains it must keep reserving nothing; this badge is an
 * in-flow inline child of the freshness line and must take no space at
 * all when the loop is healthy. */
.banner__pill[hidden] {
  display: none;
}

/* quick task 260902-v2v (UIR-03): the anomaly banner's count-and-noun
 * lead text ("2 warnings:") must never itself break mid-phrase once
 * .banner gains flex-wrap above — wrapping the row is what fixes the
 * 375px overflow, but a lead phrase that can also break internally would
 * read as a stray line of one or two words above the wrapped pills. */
.banner__label {
  white-space: nowrap;
}

/* Health's auto-refresh pill (260902-chc, Option B of the validated
 * Health Auto-Refresh Sketch) — a second call site of .banner__pill's
 * own visual primitive, declared identically apart from two differences:
 * a small icon-to-text gap (this pill's icon has no flex-row sibling to
 * borrow spacing from, unlike the banner's lead text + pills row), and
 * no `flex: none` (.banner__pill needs that only because it is a flex
 * child of the banner row; 260902-ep7 takes this pill out of
 * .page-header's flow entirely instead — see the page-header-scoped
 * pill rule below — so it is not a flex child of anything and needs no
 * `flex` declaration of its own). This is exactly the "future consolidation
 * into one shared class is a mechanical edit" the comment above already
 * anticipated. */
.refresh-pill {
  display: inline-flex;
  align-items: center;
  height: 20px;
  padding: 0 8px;
  gap: 4px;
  border-radius: 999px;
  font-size: 11px;
  font-weight: var(--weight-regular);
  background: color-mix(in srgb, var(--color-text) 6%, transparent);
  border: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent);
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* Without this, the native `hidden` attribute companion/static/
 * freshness.js relies on to hide the pill at rest and between refresh
 * cycles has no effect: the author stylesheet's `display: inline-flex`
 * above always beats the user-agent `[hidden] { display: none }`
 * regardless of source order — the exact collision `.banner__pill[hidden]`'s
 * own comment already documents, at a second call site.
 *
 * This rule satisfies that requirement, then makes one further,
 * deliberate choice `.banner__pill[hidden]` does not: it hides by
 * VISIBILITY, not display, so the pill's own line box stays reserved and
 * revealing it shifts nothing in the page header — the same
 * reserved-space reasoning `.battery-readout`'s own min-height comment
 * already established, applied here to an element that now reveals
 * itself on a repeating cycle instead of once. That matters more here
 * than it does for a banner pill that only ever reveals in direct
 * response to the user's own action: this pill reveals itself on a
 * timer with no user action at all — a layout shift on reveal would
 * read as a recurring twitch in the header, not a one-off.
 * `visibility: hidden` removes the element from the accessibility tree
 * exactly as `display: none` does, so the no-ARIA-role decision in
 * health_page.py's freshness_html comment is unaffected by this
 * choice. */
.refresh-pill[hidden] {
  visibility: hidden;
}

/* .icon's 20px square base equals this pill's own 20px height, so an
 * unscoped icon would overflow its container — the same collision class
 * the `.stat-tile svg` / `.battery-trend-section svg:not(.icon)`
 * comments elsewhere in this file already document, at a third call
 * site. */
.refresh-pill .icon {
  width: 14px;
  height: 14px;
}

/* 260902-ep7 (BUG 1): scoped to `.page-header` deliberately, never to the
 * bare `.refresh-pill` — this positioning is specific to sitting inside a
 * page header next to a title, not a property of the pill component
 * itself. Offsets derived, not guessed: `.page-header` declares no
 * padding and no border, so its content box starts exactly at the <h1>'s
 * own line box top. That line box is --font-page-title-size (30px) at
 * line-height 1.2 = 36px tall; the pill is 20px tall; (36 - 20) / 2 = 8,
 * which centres the pill vertically against the page title's own line.
 * `right: 0` puts it flush with the header's right edge. Re-derive this
 * if either --font-page-title-size or .refresh-pill's height moves.
 *
 * Rejected alternatives:
 *   - Flex/grid `.page-header`: the pill would still be a flex/grid ITEM
 *     occupying its own row in a column layout, so the 20px band
 *     survives; making it a row instead needs a markup change to keep
 *     the purpose sentence on its own line.
 *   - `float: right` on the pill: does remove it from block-level
 *     stacking, but the following paragraph's line boxes shorten around
 *     the float, so the purpose sentence would wrap around the pill
 *     instead of reading as a title-adjacent status indicator.
 *   - Restructuring layout.page_header() to wrap the title and pill in a
 *     flex row: touches a shared component every authenticated page
 *     renders, adds a wrapper element to four pages that will never
 *     carry a pill, and risks any `.page-header > h1`-shaped selector.
 *     Zero markup change (this rule) is preferable and available.
 *
 * Collision risk: an absolutely positioned pill can overlap its
 * siblings. Health is the only page passing freshness_html today
 * (companion/layout.py's page_header() call sites — confirmed from
 * source), and its title is the single word "Health", so at the
 * narrowest real header width there is no collision. Scoping this rule
 * to inside .page-header (not the bare .refresh-pill class) means a
 * future non-header pill call site cannot inherit this positioning by
 * accident. Re-check condition: a future page pairing a LONG title with
 * a pill. */
.page-header .refresh-pill {
  position: absolute;
  top: 8px;
  right: 0;
}

/* Forms and fieldsets */
fieldset {
  border: 1px solid var(--color-border);
  padding: var(--space-md);
  margin-bottom: var(--space-lg);
}

/* Size and padding only. The family/weight/letter-spacing come from the
 * `h1, h2, h3, legend, .text-heading` serif rule near the top of this
 * file — and this rule must NOT restate font-weight, which is what it
 * used to do (--weight-semibold). Both selectors are bare `legend`
 * (0,0,1), so at equal specificity the later one wins, and this rule is
 * the later one: declaring a weight here silently beat the serif rule's
 * --weight-regular no matter what that rule said. That is precisely how
 * `<legend>` ended up as the only section heading in the app rendering
 * at a different weight from its siblings. */
legend {
  font-size: var(--font-heading-size);
  padding: 0 var(--space-xs);
}

label {
  display: block;
  font-size: var(--font-label-size);
  margin-bottom: var(--space-sm);
}

/* The 1px edge uses --color-border, the structural-line token, matching
 * .site-nav-toggle / .logout-form button. It used
 * to use --color-secondary — the control's own fill colour — so the
 * field had a border in name only and read as a flat block, while the
 * quiet buttons beside it on the same form were properly outlined. Two
 * conventions for one "quiet control edge" role; this is the one the
 * file's own header comment describes ("border is structural only"). */
input,
select {
  min-height: 44px;
  min-width: 44px;
  padding: var(--space-sm);
  background: var(--color-secondary);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  color: var(--color-text);
  font-size: var(--font-body-size);
  font-family: var(--font-ui);
  /* D-06: every native radio/checkbox site-wide renders with the
   * terracotta accent instead of browser-default blue. */
  accent-color: var(--color-accent);
  transition: background-color .15s ease, border-color .15s ease, color .15s ease;
}

/* 06.6.4 (D-01c) — 44px touch-target floor register, exhaustive audit.
 * Gave the floor up this phase: text buttons (this rule, 30px — D-01),
 * desktop sidebar nav links (.sidebar-link, 32px — D-05, plan 06.6.4-04),
 * theme-picker segments (.theme-form .theme-option, 28px — D-06, plan
 * 06.6.4-04), and the filter input (.filter-bar__field input, 32px —
 * D-08, plan 06.6.4-05). The icon button (.copy-btn, D-02) is
 * deliberately NOT in this list — it keeps a genuine 44x44 hit area via
 * a `::before` pseudo-element, no accessibility trade-off. Kept the
 * floor: the hamburger toggle (.site-nav-toggle), the global native
 * `input, select` rule directly above, the disclosure `summary` rule, and
 * the mobile dropdown nav link (.mobile-nav__link) — D-05's compaction
 * reached this selector by mistake (it is mobile-only, with no desktop
 * compactness argument to trade against) and quick task 260902-qkm
 * restored its 44px floor. None of these are named by CONTEXT.md, all
 * must stay intact. Criterion: WCAG 2.5.8 (Level AA, 24px) is met
 * everywhere, including every selector that gave up the floor; it is
 * 2.5.5 (Level AAA, 44px) that was consciously traded away on the four
 * named selectors only, with the developer's recorded acceptance
 * (06.6.4-CONTEXT.md Provenance note). */
button {
  height: 30px;
  padding: 0 12px;
  font-size: 13px;
  font-weight: var(--weight-semibold);
  letter-spacing: -0.01em;
  font-family: var(--font-ui);
  border-radius: 6px;
  cursor: pointer;
  /* 06.6.4 (D-01b): near-invisible at rest, revealed on hover below.
   * Expressed through the file's own color-mix(in srgb,
   * var(--color-text) N%, transparent) idiom (see .sidebar-link--active,
   * .banner) instead of CONTEXT.md's light-only rgba literals.
   * --color-text resolves near-black in light mode and near-white in
   * dark mode, so this one declaration produces a dark wash on light
   * surfaces and a light wash on dark surfaces with no per-theme
   * override block — numerically equivalent to CONTEXT.md's light-mode
   * values and closes D-01b's own open "dark-mode analogue needed"
   * question. */
  background: rgba(23, 25, 31, 0.045); /* fallback for browsers without color-mix() (WR-02) */
  background: color-mix(in srgb, var(--color-text) 4.5%, transparent);
  border: 1px solid rgba(23, 25, 31, 0.09); /* fallback for browsers without color-mix() (WR-02) */
  border: 1px solid color-mix(in srgb, var(--color-text) 9%, transparent);
  color: var(--color-text);
  transition: background-color .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease, transform .15s ease;
}

/* Primary-action buttons (Save settings, Trigger poll now, Save LED
 * Setting, Sign in) — accent fill. Theme-picker buttons are also
 * type="submit" but are excluded via the higher-specificity
 * `.theme-form .theme-option` rule above, which is what actually keeps
 * them out of this accent treatment, not their absence from this
 * selector's match set. 06.6.4 (D-01a): a subtle inset top highlight
 * plus a soft drop shadow reads as "lit and slightly raised" instead of
 * a flat fill; border-color is explicitly neutralized since the base
 * `button` rule above now paints a text-tinted hairline that would
 * otherwise sit on top of the accent fill and read muddy. Both shadow
 * value pairs are theme-neutral black/white alphas by design — no
 * dark-mode variant is needed. */
button[type="submit"] {
  background: var(--color-accent);
  color: var(--color-on-accent);
  border-color: transparent;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.14), 0 1px 2px rgba(18,21,27,.14);
}

/* Not a bare .theme-option:hover: .theme-form .theme-option (0,2,0) is
 * what currently keeps theme buttons out of this accent fill (see the
 * comment above that rule), and a lower-specificity hover rule here would
 * lose to this selector and repaint them — the exact 06.3-REVIEW.md
 * finding this file already carries a comment about. If a theme-button
 * hover state is wanted, write it as .theme-form .theme-option:hover.
 * 06.6.4 (D-01a): deeper shadow pair on hover, border-color repeated so
 * the primary button's edge stays neutralized even once the quiet-hover
 * rule below (equal specificity) exists. */
button[type="submit"]:hover {
  background: var(--color-accent-hover);
  border-color: transparent;
  box-shadow: inset 0 1px 0 rgba(255,255,255,.16), 0 2px 5px rgba(18,21,27,.18);
}

/* 06.6.4 (D-01b): every button — primary and quiet alike — depresses 1px
 * while pressed. Placed here, before button:disabled below, so a
 * disabled button's own treatment stays the later, winning declaration
 * set.
 *
 * 28-02-PLAN.md (CFG-73, Bug B): excludes `.value-control__handle` from
 * this depress. That class is absolutely positioned from
 * `--value-fraction` (see its own rule above) — a `transform` declared
 * here would REPLACE that position rather than nudge it, because CSS
 * `transform` replaces wholesale, it never composes. Before this fix,
 * `.quiet-dial__handle` (a real `<button>` wearing that shared class)
 * measured 78px from the dial's centre — correct, on the ring — at
 * press, collapsing to 14-16px (the centre) within 90-150ms as the base
 * `button` rule's `transition: transform .15s ease` animated the
 * deleted position, then recovering to 78px roughly 200ms after
 * release. The exclusion is written against the SHARED class, not the
 * dial's own, so any future second consumer of `.value-control__handle`
 * is covered by construction rather than by a list someone has to
 * maintain. Every other button in the app keeps matching this rule and
 * still depresses exactly as before. */
button:not(.value-control__handle):active {
  transform: translateY(1px);
}

/* 06.6.2-05 (D-17): Sign out (_logout_form_html()) is a quiet/ghost
 * action, not a primary call to action — it must not inherit the
 * accent fill above. `.logout-form button` and `button[type="submit"]`
 * share equal (0,1,1) specificity, so — same precedent
 * `.theme-form .theme-option`'s comment above documents for its own
 * higher-specificity override — this rule is placed after
 * `button[type="submit"]` in source order, which is what actually wins
 * the cascade here, not merely coincidental proximity. 06.6.4 (D-01b):
 * restates the base rule's quiet wash/border explicitly (equal
 * specificity to the base rule too, same source-order reasoning) and
 * adds `box-shadow: none` — this is a submit button and would otherwise
 * inherit button[type="submit"]'s new inset highlight, making Sign out
 * read as a primary action. */
.logout-form button {
  background: color-mix(in srgb, var(--color-text) 4.5%, transparent);
  border: 1px solid color-mix(in srgb, var(--color-text) 9%, transparent);
  box-shadow: none;
  color: var(--color-text);
}

/* 27-04-PLAN.md (D-04/D-06/CFG-63): the dirty save bar that used to
 * render here — the docked/floating-card styling history (06.6.3 D-03,
 * quick tasks 260901-re6/260901-s5o), its `[hidden]`-vs-`display`
 * guard, and its Cancel button's quiet-wash override — is deleted
 * wholesale along with the markup that carried it (companion/pages/
 * config_page.py's dirty_bar_html(), retired by that same plan's Task
 * 3). The `.js [data-static-save-fallback]` rule above still hides the
 * always-rendered bottom Save settings button once script runs — that
 * floor is untouched — and `.save-status`, further down this file, is
 * what replaces the bar as the visible save affordance. See
 * companion/static/dirty-state.js's own header for the full account of
 * what auto-save replaced and why.
 *
 * CORRECTION, recorded here rather than silently fixed: the paragraph
 * below claimed the `skypane-bar-arrive` @keyframes block was "ALSO
 * deleted". It was not — verified live against the shipped file at
 * 28-08 planning time, the block survives at this file's top, and
 * companion/test_config_page.py's own
 * _skypane_bar_arrive_keyframes_survive_unreferenced (added by this
 * same 27-04 plan) says so explicitly: kept ON PURPOSE, unreferenced,
 * specifically so the file's pinned @keyframes count would not need to
 * move for a component that might return. It has now returned — see
 * the restored `.dirty-bar` rule immediately below, and that check's
 * own retarget (28-08-PLAN.md Task 2) for the other half of this story.
 * The `skypane-bar-arrive` @keyframes block this rule used to reference
 * is ALSO deleted — its own site (near this file's top) records the
 * removal and this file's total @keyframes count (still 4, unchanged by
 * this plan) is what a harness pins, not this comment.
 *
 * SUPERSEDED by 28-08-PLAN.md Task 2 (CFG-77/CFG-78), 2026-09-16: the
 * developer asked for the pre-Phase-27 dirty bar back (ROADMAP.md's
 * Phase 28 addendum). `.dirty-bar`, its `[hidden]` override and
 * `.dirty-bar__cancel`'s quiet-wash override are ALL RESTORED below,
 * verbatim from `6dea46a` except where noted in their own comments
 * (re-measured clearance, Cancel's type="reset" change, and the CSS
 * consequence that change has — or does not have — on the wash
 * override). `.js [data-static-save-fallback]` above is retired in
 * turn (see that rule's own superseded comment); `.save-status` below
 * loses its RULE the identical way. */
.dirty-bar {
  display: flex;
  align-items: center;
  gap: var(--space-md);
  padding: var(--space-md);
  background: var(--color-dominant);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-card);
  margin-top: var(--space-md);
  box-shadow: var(--shadow-card-hover), 0 12px 32px rgba(18, 21, 27, 0.16);
  /* 23-09-PLAN.md Task 1 (D3/CFG-32): the entrance, REUSED rather than
   * reinvented — this is the SAME `skypane-bar-arrive` @keyframes block
   * 27-04 deliberately kept unreferenced for exactly this moment (see
   * this rule's own superseded comment above), not a new block. The
   * file's pinned @keyframes count therefore stays at 4, re-verified by
   * RUNNING rather than assumed — the SUMMARY states the number.
   *
   * It fires when the bar's own `display` changes from `none`, which is
   * the only way this element is ever revealed (companion/static/
   * dirty-state.js sets `bar.hidden = false`, and the `[hidden]`
   * override immediately below is what makes that attribute mean
   * anything against the `display: flex` above). `ease-out` at the call
   * site because easing is not a token — 23-01 spent exactly two custom
   * properties and easing was deliberately not one of them. Its
   * duration comes from `var(--motion-fast)`, 23-01's own REACTION
   * token: the user just made an edit and is watching for confirmation
   * that it can be saved. Under reduced motion this does not slide —
   * the global `@media (prefers-reduced-motion: reduce)` override
   * earlier in this file already zeroes animation-duration for every
   * element, so no per-rule block is needed here either. */
  animation: skypane-bar-arrive var(--motion-fast) ease-out;
}

/* Without this, the native `hidden` attribute dirty-state.js relies on to
 * hide the bar on load has no effect: the author-stylesheet `display: flex`
 * above always beats the user-agent stylesheet's `[hidden] { display: none }`
 * regardless of source order, so the bar would render visible even when
 * the form isn't dirty. Same collision class as the `.stat-tile svg`/
 * `.mobile-nav` cases elsewhere in this file — a higher-specificity
 * selector for the `[hidden]` case is required whenever a component's own
 * base rule sets `display`. */
.dirty-bar[hidden] {
  display: none;
}

/* `.dirty-bar__save` needs no rule of its own — it is a bare
 * `<button type="submit">`, already accent-filled by `button[type="submit"]`
 * above.
 *
 * 28-08-PLAN.md Task 2 (CFG-77, Cancel's type change), 2026-09-16: Cancel
 * is now a `<button type="reset" form="settings-form">` (companion/pages/
 * config_page.py, Task 1's own BLOCKER 4), not `6dea46a`'s
 * `<button type="button">`. Checked live: this file declares no
 * `button[type="reset"]` rule and no other type-qualified `button` rule
 * that would reach it — the base, unqualified `button` rule above (the
 * 4.5%/9% quiet wash) already governs a `type="reset"` button with
 * nothing competing, so this override is no longer load-bearing the way
 * it was for `type="button"` either (that reasoning turns out to have
 * been stale even before this plan — `button[type="submit"]` never
 * matched `type="button"` to begin with). It is restored anyway,
 * unchanged, for the same "explicit is better than implicit" precedent
 * `.logout-form button`/`.frame-strip__cell button` already establish —
 * restating the wash and neutralizing `box-shadow` here means a reader
 * auditing this button never has to prove a negative by reading every
 * OTHER rule in the file first. */
.dirty-bar__cancel {
  background: color-mix(in srgb, var(--color-text) 4.5%, transparent);
  border: 1px solid color-mix(in srgb, var(--color-text) 9%, transparent);
  box-shadow: none;
  color: var(--color-text);
}

/* 22-04-PLAN.md Task 2 (C2, T-22-13): the Frame strip's two switch
 * buttons ("Switch off"/"Switch on", "Turn off"/"Turn on") are
 * `<button type="submit">` and would otherwise inherit the primary
 * accent fill above. `.frame-strip__cell button` and
 * `button[type="submit"]` share equal (0,1,1) specificity — an element
 * selector plus a class selector on the same side either way — so this
 * rule is placed after `button[type="submit"]` in source order, which
 * is what actually wins the cascade here (the exact `.logout-form
 * button` precedent above, never raised further: a higher specificity
 * would start beating class-scoped controls this app also relies on).
 * Restates the base `button` rule's own quiet wash/border explicitly
 * (never a new wash value — 4.5%/9% is the existing register) and
 * neutralizes `box-shadow` for the identical reason `.logout-form
 * button` does: this is a submit button and would otherwise inherit
 * the primary inset highlight. */
.frame-strip__cell button {
  background: color-mix(in srgb, var(--color-text) 4.5%, transparent);
  border: 1px solid color-mix(in srgb, var(--color-text) 9%, transparent);
  box-shadow: none;
  color: var(--color-text);
}

/* 06.6.4 (D-01b): the quiet-button hover reveal. Placement here is
 * load-bearing: this rule MUST sit after `.logout-form button` (0,1,1)
 * above in source order. `button:hover` is itself (0,1,1) — equal to
 * `.logout-form button`, so source order is what decides the winner
 * there. Without this placement the quietest control in the app would
 * have no hover feedback. The primary button is unaffected — its own
 * hover rule above carries higher specificity and restates the
 * neutralized border explicitly.
 * 22-04-PLAN.md Task 2: `.frame-strip__cell button` (0,1,1) above is
 * ALSO equal specificity to this rule, and is placed before it in
 * source order too, so the strip's own switch buttons get the same
 * hover wash for free, exactly like the quiet button above. */
button:hover {
  background: color-mix(in srgb, var(--color-text) 7.5%, transparent);
  border-color: color-mix(in srgb, var(--color-text) 14%, transparent);
}

/* Icon-only compact action button (06.6.3 D-23, e.g. the copy buttons in
 * History/Health). 06.6.4 (D-02) replaces the old "inherits the base
 * button rule's 44x44 floor unmodified" mechanism, which no longer
 * exists now that the base `button` rule has no min-height/min-width —
 * this is a 28x28 visual box with a real 44x44 pointer/touch hit area
 * synthesized by the `::before` pseudo-element's negative inset below,
 * no accessibility trade-off (unlike D-01's button-density reduction).
 * This is the pattern to reuse for any future icon-only button, rather
 * than reintroducing a minimum-size floor. `border: none` and a
 * transparent background keep the icon from reading as a filled control
 * now that the base rule paints a wash and a hairline; `display:
 * inline-flex` centres the SVG glyph explicitly, since a fixed 28x28 box
 * with zero padding needs it (unlike the base rule, which deliberately
 * avoids `display` — native buttons already centre a text label without
 * it). History's merged Callsign/Hex cell (`_callsign_hex_cell()`)
 * renders two of these in one cell, separated by the inline separator
 * span and the hex value itself (roughly 50px), so the two enlarged hit
 * areas do not overlap; a future layout placing two icon buttons closer
 * than 16px apart would need to revisit the inset below. */
.copy-btn {
  width: 22px;
  height: 22px;
  padding: 0;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 6px;
  background: transparent;
}

/* D-02 follow-up (06.6.4-06 checkpoint): the global 20px .icon glyph
 * nearly filled the original 28px box (4px of padding per side), which
 * read as "the whole button is too big" rather than a quiet inline
 * affordance. Scoped down to 14px here only — every other .icon consumer
 * (nav, stat tiles, banners) keeps the standard 20px. */
.copy-btn .icon {
  width: 14px;
  height: 14px;
}

/* The invisible 44x44 hit area: 22 (visual box) + 11 on each side = 44,
 * centred on the glyph. Pointer target only — no background, border or
 * content of its own. */
.copy-btn::before {
  content: "";
  position: absolute;
  inset: -11px;
}

/* 06.6.4 (D-02): hover wash, the same text-based color-mix idiom as the
 * base quiet-hover rule above, at D-02's specified 7% strength. Comes
 * after that base `button:hover` rule in source order (the icon-button
 * rules already sit later in the file, verified rather than assumed). */
.copy-btn:hover {
  background: color-mix(in srgb, var(--color-text) 7%, transparent);
}

/* A-37/D-20: the "Copied" label rendered beside the icon on a
 * successful copy. Hidden at rest — .copy-btn's box stays exactly
 * 22x22px, icon-only, unchanged from before this pair of rules existed. */
.copy-btn__label {
  display: none;
}

/* Positioned to overflow .copy-btn's own fixed-size box (via its
 * existing `position: relative`) rather than growing it: the button
 * must keep its 22x22px footprint so the ::before 44x44 hit area and
 * the table's column widths never shift for the ~1.5s a confirmation
 * is visible. Colour and label-voice values (12px/semibold/uppercase/
 * 0.06em, this file's existing label-voice register — see `.data-table
 * th` for another instance; `.frame-colours__panel-legend`, the
 * instance this comment used to point to, is retired by 30-07-PLAN.md
 * Task 2, CFG-85) are reused verbatim; --color-status-ok
 * is the existing token, no new custom property, no accent use. */
.copy-btn--copied .copy-btn__label {
  display: inline;
  position: absolute;
  left: 100%;
  top: 50%;
  transform: translateY(-50%);
  margin-left: var(--space-xs);
  white-space: nowrap;
  font-size: 12px;
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--color-status-ok);
}

/* Disabled/cooldown state (CFG-07/D-17): reduced opacity, not-allowed
 * cursor, no separate colour needed. */
button:disabled {
  opacity: 0.5;
  cursor: not-allowed;
}

/* Runway row (quick task 260901-qif): a layout-only wrapping flex
 * container introduced around just the three `.runway-card` elements —
 * it carries no visual treatment of its own, only the cards keep theirs.
 * Its `gap` is now the single owner of both inter-card and wrapped-row
 * spacing; `.runway-card`'s own former `margin-bottom` was retired below
 * to avoid double-spacing (a 16px margin stacked on top of this 8px
 * gap). Before this wrapper, the cards were bare `display: block`
 * siblings of `.theme-status`'s heading and both paragraphs, so each
 * card took a full line and the group rendered as three stacked
 * full-width bars instead of a row.
 *
 * 29-04-PLAN.md Task 1 (CFG-80): this rule's SECOND consumer is gone.
 * quiet_hours_group()'s preset row used to borrow this exact class (see
 * the two comments below, kept in place rather than silently corrected,
 * that still say otherwise) — it now has its own dedicated
 * `.quiet-preset-row` rule immediately below this one, styled as a
 * segmented control rather than a wrapping flex row. `.runway-row` is
 * therefore back to its original one consumer, the runway picker. */
.runway-row {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
}

/* 29-04-PLAN.md Task 1 (CFG-80) — THE QUIET-HOURS PRESET ROW, A
 * SEGMENTED CONTROL WITH NO SELECTED STATE.
 *
 * Same bordered-container-plus-borderless-segment idiom `.theme-form`/
 * `.theme-option` already use for the language/theme footer switches
 * (a hairline border and radius on the container, segments with no
 * border of their own) — but this control keeps NO active/selected
 * modifier, deliberately: these three buttons are momentary actions
 * that WRITE a value into the two time fields below, not a persistent
 * choice, so there is nothing to keep visually "current" the way a
 * theme or language choice is. That is also why this rule adds no
 * accent consumer and needs no change to style.css's header-comment
 * accent-reservation list — a control with no selected state has no
 * accent-filled state to reserve one for.
 *
 * The hairline divider between segments is a `border-left` on every
 * button but the first, reusing the same `--color-border` hairline
 * every card edge in this file already uses — not `.theme-form`'s own
 * `gap`-only separation, because these buttons keep the app's ordinary
 * quiet-button treatment (padding, background wash) rather than
 * `.theme-option`'s flush segment styling, and a gap alone would read
 * as three loose buttons rather than one control.
 *
 * Quick task 260922-jiy: `margin-bottom: var(--space-md)` separates this
 * row from the Debut/Fin fields below it, matching `.quiet-dial__readout`'s
 * own `margin: 0 0 var(--space-md)` one component above — the same gap
 * stacked twice so the card reads as one rhythm rather than two. */
.quiet-preset-row {
  display: flex;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  overflow: hidden;
  margin-bottom: var(--space-md);
}

.quiet-preset-row button {
  flex: 1 1 0;
  min-width: 0;
  border: none;
  border-radius: 0;
}

.quiet-preset-row button:not(:first-child) {
  border-left: 1px solid var(--color-border);
}

/* 29-04-PLAN.md Task 1 (CFG-80) — START AND END, ONE ROW.
 *
 * Two tracks at the card's own width, from the existing spacing scale —
 * no new token. `minmax(0, 1fr)` on both (not a bare `1fr`) so a long
 * error message inside either column can wrap instead of forcing the
 * grid wider than the card, the same overflow guard `.data-table`'s own
 * `min-width: max-content` note elsewhere in this file exists to avoid
 * the opposite failure of.
 *
 * A NARROW-VIEWPORT FALLBACK IS DECLARED, and it is not pre-emptive —
 * it is required by a number already on record in THIS file.
 * `.config-form input[type="time"] { min-width: 9rem }` (144px) is a
 * hard floor on each native time input, independent of anything this
 * rule declares. At the 360px contract floor, this card's own inner
 * content width is 360 − 2×24 (`.page-content`'s side padding,
 * `--space-lg`) − 2×16 (`.theme-status`'s own padding below 960px,
 * `--space-md`) = 280px. Two 144px inputs plus even the smallest
 * column gap already need 2×144 + 8 = 296px — 16px MORE than the
 * 280px available, before the "Start "/"End " label text or either
 * twin has taken any width at all. Two columns cannot fit at the
 * contract floor, arithmetically, from numbers already declared in
 * this file — no live-browser measurement was available in this
 * worktree to refine the exact breakpoint further (see the plan's own
 * SUMMARY for the open follow-up), so the threshold below is set with
 * deliberate headroom above the bare 392px the same arithmetic gives
 * at the wider `--space-lg` (24px) gap this rule actually uses
 * (2×144 + 24 + 80px of combined padding = 392px), rather than cut
 * exactly to it. Below the threshold the row degrades to the ORIGINAL
 * stacked layout (one track), never a broken two-column overflow. */
.quiet-times-row {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: var(--space-sm) var(--space-lg);
}

@media (max-width: 479.98px) {
  .quiet-times-row {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* Runway picker (06.4/06.6.3 D-05): selectable card, number prominence +
 * optional airport diagram. Supersedes the old .runway-option/
 * .runway-option__number/.runway-option__image rules (06.4) — removed
 * outright here, not left as unreferenced bloat, matching 06.6.1-05's own
 * precedent for a redesign superseding a prior rule set. The entire
 * <label> is the hit target/card; `position: relative` anchors the
 * visually-hidden native radio absolutely positioned inside it. */
.runway-card {
  display: block;
  position: relative;
  background: var(--color-dominant);
  /* Hairline --color-border edge at rest, no resting shadow — 06.6.4
   * (D-03) supersedes 06.6.1's shadow-at-rest treatment (the border used
   * to be transparent, reserving a width only the now-deleted dark-mode
   * card-edge override coloured in). The shadow now appears only on
   * hover/focus-within, below. SUPERSEDED (quick task 260904-bbi): this
   * comment used to say "the --selected modifier still replaces this
   * hairline with its own real 2px accent border" — that was the
   * defect. `--selected` is server-computed from the SAVED config and
   * never updates when the user clicks a different card without
   * reloading, so the border stayed on the saved card even after
   * another one was chosen. The strong border is now driven by live
   * `:has(input:checked)` state (see the @supports block after the
   * .theme-chip group below); `--selected` alone still replaces the
   * hairline as the no-:has() FALLBACK (identical result, just not the
   * live-state mechanism), and the hover rule below is scoped so it can
   * never reach a selected-at-rest card either way (D-03a; its live-
   * state transfer is documented in the @supports block). */
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  box-shadow: none;
  padding: var(--space-md);
  /* quick task 260901-qif: traded for flex sizing now that .runway-row
   * (above) owns inter-card spacing via its own `gap`. .runway-card has
   * exactly one emitter in the repository, runway_fieldset() (confirmed
   * by grep), so retiring this declaration cannot affect another
   * consumer. Grow-and-shrink from a 150px basis with a 140px floor lays
   * the three cards out in one wrapping row instead of one per line.
   *
   * SUPERSEDED by 22-10-PLAN.md Task 2 (B9). The 150px basis plus the
   * 140px floor is exactly what produced the measured 2 + 1 orphan at
   * 390px: a 308px content column fits two 150px cards and wraps the
   * third alone, which then grew to the full 308x217. A zero basis with
   * no minimum makes the three cards structurally incapable of
   * overflowing their line — three items whose hypothetical main size is
   * 0 always share one flex line — so there is no width at which they
   * can wrap 2 + 1 again. At 1280px this is a no-op: the old rule
   * already distributed the free space equally, so all three cards
   * measure 271.3px before and after.
   *
   * `.runway-row` itself is deliberately NOT given `flex-wrap: nowrap`,
   * which is what 22-UI-SPEC.md §2's B9 row prescribes — SUPERSEDED-in-
   * reasoning by 29-04-PLAN.md Task 1 (CFG-80): this paragraph used to
   * name a SECOND consumer, `quiet_hours_group()`'s three-preset row
   * (whose long labels, e.g. "Night (23:00-07:00)", needed to wrap on a
   * phone), as the reason `.runway-row` itself keeps wrapping rather
   * than being fixed to `nowrap` here. That second consumer is gone —
   * the preset row now has its own `.quiet-preset-row` rule and short,
   * non-wrapping labels — so `.runway-row` is back to its original one
   * consumer, but the conclusion (fix B9 on the card, not the row) is
   * unaffected and kept as written. */
  flex: 1 1 0;
  min-width: 0;
  cursor: pointer;
  /* 23-10-PLAN.md Task 1 (D3/CFG-32) — SELECTION ANSWERS, AND WHY THIS
   * DECLARATION IS ON THE BASE RULE AND NOT IN A FEATURE QUERY.
   *
   * Read this before reaching for `@supports`. The strong selected
   * treatment for this component is driven by live `:has(input:checked)`
   * state, which lives inside the ONE feature-query block further down,
   * and the file is pinned at exactly one such block by two checks in
   * companion/test_config_page.py. The obvious way to animate a rule
   * that lives inside a feature query is to write a second feature
   * query — Phase 15's D-05 did exactly that and had it retired, and
   * 23-RESEARCH.md names it as the single largest threat to that count
   * in the whole of Phase 23.
   *
   * It is not needed, and the reason is one sentence: a transition is a
   * property of the ELEMENT, not of the state. Declared here, on the
   * base rule, it animates each property however the state that changes
   * it is reached — through `:has(input:checked)` inside the query on a
   * browser that supports `:has()`, and through the server-rendered
   * `--selected` fallback outside it on a browser that does not. One
   * declaration, both mechanisms, identical result. The same reason
   * covers the hover/focus-within reveal above for free.
   *
   * The four properties named are the four that actually change on
   * selection (the scale, the accent edge, the inset ring and the 12%
   * wash); `transition: all` is deliberately not used, since it would
   * also animate the layout properties this component's own geometry
   * contract depends on. `var(--motion-fast)` is the phase's REACTION
   * token: a selection is a state change the user just caused and is
   * watching for confirmation of. No per-rule reduced-motion block is
   * added — the single global `@media (prefers-reduced-motion: reduce)`
   * override near the top of this file already zeroes every transition
   * duration in it, and a second one here is the dead code
   * `references/accessibility-contrast.md` records rather than a safety
   * net. */
  transition: transform var(--motion-fast) ease,
    border-color var(--motion-fast) ease,
    box-shadow var(--motion-fast) ease,
    background-color var(--motion-fast) ease;
}

/* Scoped with :not(.runway-card--selected) so this rule is structurally
 * incapable of matching a selected card, independent of source order or
 * specificity — the selected card's own 2px accent border (below) can
 * never be cleared by hovering it (D-03a). */
.runway-card:not(.runway-card--selected):hover,
.runway-card:not(.runway-card--selected):focus-within {
  border-color: transparent;
  box-shadow: var(--shadow-card-hover);
}

/* T6 (22-AUDIT.md, 22-UI-SPEC.md §2's T6 row, 22-15-PLAN.md Task 1) —
 * THE BORDER WIDTH IS NOW CONSTANT AT 1px IN EVERY STATE, and the
 * selection weight moves to an inset ring. The paragraph below used to
 * claim the opposite was safe; it was wrong, and the correction is
 * recorded here rather than the claim quietly deleted.
 *
 * What the old claim missed: `box-sizing: border-box` keeps an
 * element's own CONTENT box from growing when its border thickens — it
 * does not keep the element's own OUTER box from growing, because
 * border-box sizing only fixes the outer box where a `width` or a
 * `flex-basis` resolves to a definite length. `.runway-card` is
 * `flex: 1 1 0` in a wrapping row (B9, 22-10-PLAN.md Task 2), so its
 * outer width is DISTRIBUTED free space plus its own border: the saved
 * card measured 98.67px against its siblings' 96.66/96.67px at 390px —
 * exactly 2px wider, exactly the audit's reported symptom. Siblings do
 * shift, and a selected card is a different size from an unselected
 * one. companion/test_browser_ux.py carried a STATED allowance for that
 * difference, naming this plan as the one that deletes it; it is
 * deleted.
 *
 * The replacement signal: `box-shadow: inset 0 0 0 2px` paints a 2px
 * ring just inside the padding box. It occupies no layout space at all
 * — a box-shadow never contributes to any box — so selection is now
 * geometrically free. The border stays 1px and only RECOLOURS to the
 * accent, which is what it already did; the 12% wash and the check
 * glyph are untouched. The result reads slightly heavier than the old
 * 2px border (1px accent edge plus a 2px accent ring), which is on the
 * right side of 06.6.4.1.1-06's own "the selected element is very hard
 * to see" report.
 *
 * Every rule in this file that painted a 2px selected-state border is
 * converted the same way in this one commit, including the two dashed
 * "this is what is saved" markers inside the @supports block below —
 * a 2px dashed ring against 1px siblings is the identical defect
 * wearing a different dash pattern.
 *
 *
 * 06.6.4.1.1-06 (developer checkpoint follow-up): the background wash
 * below is an ADDITION to the border above, not a replacement — the
 * border and the always-present check glyph (`.runway-card__check`)
 * both stay exactly as they were. The developer reported that, across
 * the whole site, "the selected element is very hard to see" — a thin
 * 2px border and a small stroke-only check glyph against the same
 * `--color-dominant` card surface every unselected sibling also uses.
 * The fix reuses this app's own already-established "this is the
 * current selection" language instead of inventing a new one:
 * `.theme-form .theme-option--active` and the nav-pill active-link rules
 * (`.sidebar-link--active`, `.mobile-nav__link--active`) all use this
 * same `color-mix(in srgb, var(--color-accent) 12%, transparent)`
 * background wash, and none of them were reported as hard to notice.
 * Safe to add here: `.runway-card` has no separate body wrapper, but its
 * only other child content is `.runway-card__number` (plain text) and
 * the optional `.runway-card__image` (an airport-diagram PNG on two of
 * the three cards) — a 12%-opacity accent tint does not obscure either.
 * The hover-exclusion guard above (`:not(.runway-card--selected)`)
 * still cannot reach a selected card after this addition, since it adds
 * a new property to this same already-excluded selector rather than
 * touching the hover rule itself. */
/* 23-10-PLAN.md Task 1 (D3/CFG-32): the scale is declared on BOTH the
 * `--selected` fallback here and the live `:has(input:checked)` rule
 * inside the feature query, at the SAME value — the identical parity
 * contract T6 already holds for the border and the ring, and for the
 * identical reason: a browser without `:has()` must render a
 * selected card the same size as a browser with it.
 *
 * A `transform` and not a width, a padding or a border, and that is the
 * whole point: a transform paints at a different size without
 * participating in layout at all, so T6's defect (a selected card 2px
 * wider than its siblings, measured at 98.67px against 96.66px at
 * 390px, pushing them along the row) is structurally incapable of
 * recurring through it. companion/test_browser_ux.py measures the
 * layout box through offsetWidth rather than
 * getBoundingClientRect() precisely because that distinction is the
 * thing being asserted. 1.02 is punctuation, not a bounce: the wash's
 * fade is the primary signal. */
.runway-card--selected {
  border-color: var(--color-accent);
  box-shadow: inset 0 0 0 2px var(--color-accent);
  background: color-mix(in srgb, var(--color-accent) 12%, transparent);
  transform: scale(1.02);
}

.runway-card__number {
  font-size: var(--font-body-size);
  font-weight: var(--weight-semibold);
  line-height: 1.2;
}

.runway-card__image {
  display: block;
  max-width: 100%;
  height: auto;
  margin-top: var(--space-sm);
}

/* The check glyph's markup exists on every card, but only ever visually
 * shown on the selected one — its visibility follows selection state
 * (the server-rendered `--selected` modifier) with no second conditional
 * needed at the markup level. */
.runway-card__check {
  position: absolute;
  top: var(--space-sm);
  right: var(--space-sm);
  color: var(--color-accent);
  display: none;
}

.runway-card--selected .runway-card__check {
  display: inline-flex;
}

/* Theme picker chip (06.6.4.1.1-05, sketch 004 variant B — the
 * developer-confirmed winner, UIR-01/UIR-02). This is a second
 * selectable-card component, deliberately placed as a neighbour of
 * .runway-card above because it reuses that component's mechanism
 * verbatim rather than inventing a new one: a visually-hidden native
 * radio inside the <label>, and an always-present check glyph shown
 * only when selected.
 *
 * SUPERSEDED (quick task 260904-bbi): this comment used to claim the
 * mechanism was "a server-computed --selected modifier (never a
 * client-side :has() trick) driving a real 2px accent border" — that
 * reasoning is now DISPROVEN. `--selected` is computed once, server-
 * side, from the SAVED config; clicking a different chip changes the
 * live `:checked` radio but never re-renders the page, so the strong
 * border/wash/check-glyph treatment stayed on the SAVED chip instead of
 * following the user's live click — the exact defect a developer
 * checkpoint follow-up found. The strong treatment is now driven by
 * live `:has(input:checked)` state instead, inside the
 * `@supports selector(:has(*))` block placed immediately after this
 * group (see that block's own comment for the specificity arithmetic
 * and the fallback story). `--selected` keeps two honest jobs: it still
 * truthfully marks the SAVED value, and it is now the no-`:has()`
 * FALLBACK, painting the identical strong treatment in browsers that
 * don't support `:has()`. It is NOT related to .theme-form .theme-option
 * below, which is a different component entirely — the segmented
 * light/dark UI-theme control in the nav footer, not a colour-theme
 * picker.
 */
.theme-chip-grid {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-sm);
}

.theme-chip {
  display: block;
  position: relative;
  width: 160px;
  /* The margin reset below and the font-size declaration are load-
   * bearing overrides of the global `label` rule (which gives every
   * label an 8px bottom margin and shrinks its text to Label size) —
   * without them every chip would carry an extra bottom margin on top
   * of the grid's own `gap`, and the name/swatch text below would
   * render at the wrong scale. */
  margin-bottom: 0;
  font-size: var(--font-body-size);
  background: var(--color-dominant);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  box-shadow: none;
  /* Clips the preview <img> to the chip's own rounded corners. */
  overflow: hidden;
  cursor: pointer;
  /* 23-10-PLAN.md Task 1 (D3/CFG-32): the same base-rule transition
   * `.runway-card` above carries, for the same reason — see that rule's
   * own comment for why this is NOT wrapped in a second
   * `@supports selector(:has(*))` block and must never be. The wash
   * itself is scoped to `.theme-chip__body` on this component (the
   * preview band must never be tinted), so its own fade is declared
   * there rather than here; this rule animates the three properties
   * that change on the chip itself. */
  transition: transform var(--motion-fast) ease,
    border-color var(--motion-fast) ease,
    box-shadow var(--motion-fast) ease;
}

/* T6's one chip-specific addition (22-15-PLAN.md Task 1). An inset
 * box-shadow is painted on an element's own background/border layer, so
 * it sits BENEATH that element's children — and `.theme-chip__preview`
 * is a full-bleed `width: 100%` image occupying the chip's top 56px.
 * Without this overlay the selection ring would be hidden along the
 * whole preview band and visible only around the body, which reads as a
 * rendering fault rather than as a selected chip. `.runway-card` needs
 * nothing equivalent: its own image sits inside `var(--space-md)` of
 * padding and never reaches the ring.
 *
 * `box-shadow: inherit` is the entire mechanism, and it is why this is
 * ONE unconditional rule rather than a duplicate of every selected-state
 * rule below. The pseudo-element simply redraws whatever box-shadow the
 * chip itself currently computes, above the image instead of below it,
 * so every state — selected, saved-but-not-live, hovered — tracks
 * automatically and can never drift out of step with the rule that
 * actually declares it.
 *
 * The chip's own `overflow: hidden` clips this overlay, which is what
 * keeps the inherited OUTER hover shadow (`--shadow-card-hover`, on an
 * unselected hovered chip) from painting a second, smaller copy of
 * itself: an outer shadow drawn on a padding-box-sized overlay falls
 * entirely outside that overlay and is clipped away. Only the inset
 * ring survives, which is the one thing this overlay exists to carry.
 *
 * `pointer-events: none` so the overlay can never intercept a click
 * meant for the label; the check glyph and the saved-state `::after`
 * badge are both positioned later in tree order and so still paint
 * above it. */
.theme-chip::before {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: inherit;
  pointer-events: none;
  box-shadow: inherit;
}

/* Scoped with :not(.theme-chip--selected), the same D-03a property
 * .runway-card documents above: hovering a selected chip must never be
 * able to clear its accent border, independent of source order. */
.theme-chip:not(.theme-chip--selected):hover,
.theme-chip:not(.theme-chip--selected):focus-within {
  border-color: transparent;
  box-shadow: var(--shadow-card-hover);
}

/* T6 (22-15-PLAN.md Task 1): constant 1px border, selection carried by
 * the inset ring — see `.runway-card--selected`'s own comment above for
 * the full diagnosis and the measurement. `.theme-chip` is a fixed
 * `width: 160px` rather than a flex child, so its own outer box did NOT
 * grow; what it did instead was eat 1px of its 160px on each side, so
 * the selected chip's preview band and body were 2px narrower than its
 * neighbours' and the swatch row reflowed. Same defect, same fix, and
 * the ring is drawn INSIDE the chip's `overflow: hidden` clip so it
 * follows the rounded corners exactly as the border does. */
.theme-chip--selected {
  border-color: var(--color-accent);
  box-shadow: inset 0 0 0 2px var(--color-accent);
  /* 23-10-PLAN.md Task 1 (D3/CFG-32): the fallback half of the scale
   * parity pair — see `.runway-card--selected`'s comment above for the
   * full reasoning, and note this component's OWN T6 history: the chip
   * is a fixed `width: 160px`, so its outer box never grew; what the
   * 2px border did instead was eat 1px of that 160px on each side, so
   * the selected chip's preview band and body were 2px narrower than
   * its neighbours' and the swatch row reflowed. A transform cannot do
   * that either — it scales the painted result and leaves every box,
   * inner and outer, exactly where it was. */
  transform: scale(1.02);
}

/* 06.6.4.1.1-06 (developer checkpoint follow-up): same background-wash
 * addition as `.runway-card--selected` above, and for the same reason —
 * see that rule's comment for the full diagnosis. Scoped to
 * `.theme-chip__body` specifically, NOT the whole `.theme-chip`, because
 * unlike `.runway-card` this component has a real rendered preview band
 * (`.theme-chip__preview`, the theme's own cropped panel render) sitting
 * above the body — a wash across the whole chip would sit half-behind
 * that photo-like image and visibly tint it, undermining the very thing
 * the chip exists to show (the theme's real, undithered colours). The
 * body area (name + swatch dots) has no such constraint and is the
 * region that reads as "this chip's own info panel," matching how the
 * wash reads as the "selected" surface on `.theme-form .theme-option`
 * and the nav-pill links. */
.theme-chip--selected .theme-chip__body {
  background: color-mix(in srgb, var(--color-accent) 12%, transparent);
}

/* 56px is sketch 004 variant B's own validated band height.
 * object-fit: cover absorbs the small ratio difference between the
 * served 320x120 PNG and the chip's own ~156px-wide box, so the band
 * never letterboxes (the UIR-07 defect). */
.theme-chip__preview {
  display: block;
  width: 100%;
  height: 56px;
  object-fit: cover;
}

.theme-chip__body {
  display: block;
  padding: var(--space-sm);
  /* 23-10-PLAN.md Task 1 (D3/CFG-32): the wash FADES rather than cuts.
   * It is declared here and not on `.theme-chip` because the wash
   * itself is scoped here — the preview band above it is the theme's
   * own real, undithered render and a wash across it would tint the one
   * thing the chip exists to show. Base rule, one declaration, both the
   * live `:has(input:checked)` treatment and the `--selected` fallback:
   * see `.runway-card`'s own transition comment for why that removes
   * any need for a second feature query. The colour is unchanged — this
   * animates the existing
   * `color-mix(in srgb, var(--color-accent) 12%, transparent)` in, it
   * introduces no new colour and no new accent consumer. */
  transition: background-color var(--motion-fast) ease;
}

.theme-chip__name {
  display: block;
  /* 13px is the documented sub-scale exception tier, not a new content
   * size. */
  font-size: 13px;
  font-weight: var(--weight-semibold);
  line-height: 1.2;
  margin-bottom: var(--space-xs);
}

.theme-chip__swatches {
  display: flex;
  gap: var(--space-xs);
  align-items: center;
  /* quick task 260904-bbi: real-browser measurement caught this row's
   * own bounding box (a block-level flex container defaults to filling
   * the full available width of .theme-chip__body, even though its two
   * 12px dots only occupy the left ~28px of it) geometrically
   * intersecting the new bottom-right "Current" tag's box, even though
   * no visible pixel ever collides (the tag sits in the row's own empty
   * right-hand space). width: fit-content shrinks the box to its actual
   * rendered content with zero visual change (the dots already render
   * flush-left via the default flex-start justification) and clears the
   * measured collision outright. */
  width: fit-content;
}

/* Carries over .theme-swatch__chip's own comment reasoning: the fill is
 * an inline style holding a raw panel PALETTE_RGB value, the one colour
 * in this file the stylesheet cannot reason about, and the structural
 * hairline is what keeps a white swatch visible on a white card
 * surface. */
.theme-chip__dot {
  width: 12px;
  height: 12px;
  border-radius: 3px;
  border: 1px solid var(--color-border);
  display: inline-block;
}

/* The check glyph's markup exists on every chip, but only ever visually
 * shown on the selected one — matches .runway-card__check's own
 * mechanism directly above. */
.theme-chip__check {
  position: absolute;
  top: var(--space-sm);
  right: var(--space-sm);
  color: var(--color-accent);
  display: none;
}

.theme-chip--selected .theme-chip__check {
  display: inline-flex;
}

/* Touch target: .theme-chip is ~160px wide and roughly 100px tall, far
 * above the 44px floor, and the visually-hidden radio inside it is
 * exempt-by-delegation exactly as quick task 260902-l9w recorded for the
 * Runway radios above. This adds no entry to the traded-floor register. */

/* Live-selection layer (quick task 260904-bbi) — re-keys the strong
 * "this is your selection" treatment of BOTH selectable-card components
 * above (.theme-chip, .runway-card) from the server-rendered --selected
 * class to live :has(input:checked) state, and demotes --selected to an
 * honest, quiet "this is what is saved" marker for when the saved value
 * is no longer the live choice.
 *
 * Diagnosis: --selected is computed once, server-side, from the SAVED
 * config (config_page.py:269/:374) and painted at first response. It
 * never changes when the user clicks a different chip/card client-side
 * — dirty-state.js only snapshots/compares form values for the dirty
 * bar, it never touches this class. Every selected-state rule keying
 * off --selected alone therefore keeps painting the SAVED value as
 * selected even after the user has visibly chosen something else — the
 * defect a developer checkpoint follow-up found.
 *
 * Specificity arithmetic (verified, not to be re-derived): :has(X) and
 * :not(X) each take the specificity of their most specific argument.
 * .theme-chip--selected is (0,1,0). .theme-chip:has(input:checked) is
 * (0,1,0)+(0,1,1) = (0,2,1), beating the fallback.
 * .theme-chip--selected:not(:has(input:checked)) is also (0,2,1) — equal
 * specificity to the rule above is NOT a conflict, because the two
 * selectors are structurally incapable of matching the same element (one
 * requires a checked descendant, the other forbids one). The existing
 * hover guard `.theme-chip:not(.theme-chip--selected):hover` (above,
 * unchanged) is (0,3,0), which OUTRANKS (0,2,1) and matches the newly-
 * clicked chip (which lacks --selected) — it would clear that chip's
 * live-state border on hover if left as the only guard. The fix is a
 * POSITIVE restore rule below, on the checked-chip's own hover state:
 * (0,2,1)+(0,1,0) = (0,3,1), which wins. Do NOT instead rewrite the base
 * guard's :not(.theme-chip--selected) to :not(:has(...)) — an
 * unsupported :has() invalidates the WHOLE selector list it appears in,
 * dropping the hover reveal entirely in non-supporting browsers, which
 * would violate the fallback story below. Identical reasoning and rule
 * shape apply to .runway-card.
 *
 * Fallback (no-:has() browsers): every live-state rule lives inside this
 * @supports selector(:has(*)) block. A browser without :has() support
 * evaluates the feature query as false and skips this entire block,
 * leaving the pre-existing --selected-keyed rules above (unchanged, not
 * touched by this task) as the sole selected-state mechanism — exactly
 * today's shipped strong treatment, never unstyled. This mirrors the
 * file's own WR-02 fallback idiom (a plain declaration immediately
 * followed by an enhanced color-mix() form, e.g. :1387-1389) one level
 * up, at the selector rather than the value.
 *
 * Placement note: this block sits here, right after the .theme-chip
 * group, purely for READABILITY (both components' live-state rules read
 * as one group). This file does have genuine source-order-dependent
 * rules elsewhere — this is NOT one of them; every rule inside this
 * block already wins its target element on specificity alone, verified
 * above. */
@supports selector(:has(*)) {
  /* T6 (22-15-PLAN.md Task 1): constant 1px border plus the inset ring,
   * matching the `--selected` fallback rule above declaration for
   * declaration — the two mechanisms must stay visually identical or a
   * browser without `:has()` gets a different-sized chip. */
  .theme-chip:has(input:checked) {
    border-color: var(--color-accent);
    box-shadow: inset 0 0 0 2px var(--color-accent);
    /* 23-10-PLAN.md Task 1 (D3/CFG-32): the live half of the scale
     * parity pair, matching `.theme-chip--selected`'s own value exactly.
     * NOTHING ELSE about this plan's change lives inside this block —
     * the property that animates it is declared on `.theme-chip`'s base
     * rule, outside this query, which is what keeps the file's
     * feature-query count at one. */
    transform: scale(1.02);
  }

  .theme-chip:has(input:checked) .theme-chip__body {
    background: color-mix(in srgb, var(--color-accent) 12%, transparent);
  }

  .theme-chip:has(input:checked) .theme-chip__check {
    display: inline-flex;
  }

  /* D-03a, transferred to live state: a positive restore rule, not a
   * re-scoped guard — see the specificity arithmetic above.
   *
   * T6 (22-15-PLAN.md Task 1), reconciled IN THE SAME COMMIT as the
   * rule it guards. This used to declare `box-shadow: none`, whose only
   * job was to suppress the hover elevation shadow the (0,3,0) guard
   * above would otherwise paint. Once selection IS a box-shadow, `none`
   * stops being a suppression and becomes an erasure: the selection
   * ring would vanish the instant a pointer crossed a selected chip.
   * The ring is restated here instead, which suppresses the hover
   * elevation exactly as before (box-shadow is a single property, so
   * declaring the ring replaces the elevation outright) while keeping
   * the selection signal. */
  .theme-chip:has(input:checked):hover,
  .theme-chip:has(input:checked):focus-within {
    border-color: var(--color-accent);
    box-shadow: inset 0 0 0 2px var(--color-accent);
  }

  /* Quiet "this is what is saved" marker: accent-free, so it can never
   * read as a second selection. Reuses the file's established 70%
   * muted-text mix rather than inventing a new strength — no new wash
   * percentage either, since this ring has no background at all. */
  /* T6 (22-15-PLAN.md Task 1): 1px, not 2px. A dashed 2px ring against
   * 1px siblings is the same layout shift wearing a different dash
   * pattern — a chip can be saved-but-not-live at the very moment its
   * neighbour is live-selected, so both states have to be geometrically
   * free or the grid still moves. `box-shadow: none` is explicit here
   * and load-bearing: this rule must CLEAR the accent ring the
   * `--selected` fallback rule above paints, and a `border` shorthand
   * cannot reach a box-shadow. The dashed 1px muted edge is a
   * deliberately quiet marker with no accent in it at all, exactly as
   * before. */
  .theme-chip--selected:not(:has(input:checked)) {
    border: 1px dashed color-mix(in srgb, var(--color-text) 70%, transparent);
    box-shadow: none;
    /* 23-10-PLAN.md Task 1 (D3/CFG-32): clears the selection scale for
     * the same reason the two declarations above clear the ring and the
     * next rule clears the wash — a chip can be SAVED at the very
     * moment its neighbour is the LIVE choice, and two scaled chips
     * would claim two selections. */
    transform: none;
  }

  .theme-chip--selected:not(:has(input:checked)) .theme-chip__body {
    background: transparent;
  }

  .theme-chip--selected:not(:has(input:checked)) .theme-chip__check {
    display: none;
  }

  /* content: "Current" — a CSS pseudo-element, deliberately not a real
   * <span> (config_page.py keeps a zero diff). The authoritative
   * selection state for assistive technology is the native radio's own
   * checked property inside a real radio group, already announced
   * natively; the pending-vs-saved distinction is separately carried in
   * real, announced DOM text by the dirty bar ("Theme changed · Save
   * settings · Cancel"). This tag is a supplementary VISUAL cue for a
   * sighted user comparing 16 dense chips against the one saved chip —
   * a real <span> would inject a redundant announcement into exactly 1
   * of 16 chips for zero accessibility gain, and would force
   * retargeting test_config_page.py's exact-count markup assertions.
   * Bottom-right, not top-right: top-right on this component sits over
   * .theme-chip__preview, the rendered panel image — the identical
   * constraint that already scopes this chip's selected-wash to
   * .theme-chip__body rather than the whole chip.
   *
   * 22-10-PLAN.md Task 1 (T10, 22-AUDIT.md): the text is no longer the
   * hard-coded English literal it was — `attr(data-current-label)` reads
   * a translated string companion/pages/config_page.py renders onto the
   * saved chip/card (CURRENT_BADGE_LABEL/CURRENT_BADGE_ATTR). Only the
   * English moved; the four points above are unchanged and the
   * pseudo-element stays a pseudo-element. A chip with no such attribute
   * renders an empty string, which is the correct degradation: the
   * attribute is emitted only on the one chip this selector can match.
   *
   * 22-14-PLAN.md Task 2, Rule 1 auto-fix: the paragraph above used to
   * open with a stray comment TERMINATOR — the 22-10 note was appended
   * after the original block's own closing marker instead of inside it.
   * Everything from that marker to the next brace parsed as part of the
   * selector below, so THIS ENTIRE RULE WAS SILENTLY DROPPED by every
   * browser and the saved-chip "Current" badge never rendered at all.
   * Found while measuring B10 in a real browser, which is the only
   * place a CSS parse error of this shape is visible: no
   * string-comparison harness can see it, because the file still
   * CONTAINS every declaration it is asked about. A structural guard
   * pinning the file at zero stray terminators ships in
   * companion/test_status_pages.py alongside this fix. */
  .theme-chip--selected:not(:has(input:checked))::after {
    content: attr(data-current-label);
    position: absolute;
    bottom: var(--space-sm);
    right: var(--space-sm);
    font-size: 12px;
    font-weight: var(--weight-semibold);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: color-mix(in srgb, var(--color-text) 70%, transparent);
  }

  /* T6 (22-15-PLAN.md Task 1): constant 1px border plus the inset ring,
   * matching `.runway-card--selected` above declaration for
   * declaration. */
  .runway-card:has(input:checked) {
    border-color: var(--color-accent);
    box-shadow: inset 0 0 0 2px var(--color-accent);
    background: color-mix(in srgb, var(--color-accent) 12%, transparent);
    /* 23-10-PLAN.md Task 1 (D3/CFG-32): the live half of this
     * component's scale parity pair — see `.runway-card--selected` for
     * the reasoning, and `.runway-card`'s base rule for the property
     * that animates it, which is deliberately NOT in here. */
    transform: scale(1.02);
  }

  .runway-card:has(input:checked) .runway-card__check {
    display: inline-flex;
  }

  /* T6 (22-15-PLAN.md Task 1): the same positive-restore reconciliation
   * the chip's own hover rule above documents in full — `none` would
   * now erase the selection ring rather than suppress the hover
   * elevation. */
  .runway-card:has(input:checked):hover,
  .runway-card:has(input:checked):focus-within {
    border-color: var(--color-accent);
    box-shadow: inset 0 0 0 2px var(--color-accent);
  }

  /* T6 (22-15-PLAN.md Task 1): 1px dashed, and an explicit
   * `box-shadow: none` to clear the fallback rule's accent ring — see
   * the chip's own saved-state rule above for the full reasoning. */
  .runway-card--selected:not(:has(input:checked)) {
    border: 1px dashed color-mix(in srgb, var(--color-text) 70%, transparent);
    box-shadow: none;
    background: transparent;
    /* 23-10-PLAN.md Task 1 (D3/CFG-32): clears the selection scale — see
     * the chip's own saved-state rule above. */
    transform: none;
  }

  .runway-card--selected:not(:has(input:checked)) .runway-card__check {
    display: none;
  }

  /* Top-right here (unlike the chip's bottom-right): .runway-card has no
   * preview band occupying that corner — its only other content is a
   * plain-text number and an optional airport-diagram image below it.
   *
   * 22-10-PLAN.md Task 1 (T10): attribute-driven, exactly like the
   * chip's own badge above — see that rule's comment for the full
   * reasoning. */
  .runway-card--selected:not(:has(input:checked))::after {
    content: attr(data-current-label);
    position: absolute;
    top: var(--space-sm);
    right: var(--space-sm);
    font-size: 12px;
    font-weight: var(--weight-semibold);
    text-transform: uppercase;
    letter-spacing: 0.06em;
    color: color-mix(in srgb, var(--color-text) 70%, transparent);
  }

  /* T15 (22-UI-SPEC.md §5 contract 1, 22-15-PLAN.md Task 1). A selected
   * chip or card had a focus state PIXEL-IDENTICAL to rest: the real
   * focus target is the visually-hidden native radio inside the label,
   * whose own outline is off-screen and unseeable, and both components'
   * `:focus-within` rules are `:not(--selected)`-scoped precisely so
   * they can never repaint a selected card. So keyboard focus on the
   * one card that matters was invisible.
   *
   * The values are the GLOBAL focus-visible floor's own, copied
   * verbatim from the `a/button/input/select:focus-visible` rule near
   * the top of this file — not a new treatment, and deliberately an
   * `outline` rather than another box-shadow so it cannot collide with
   * T6's selection ring on the same property. `outline-offset: 2px`
   * puts it clear of the chip's `overflow: hidden` clip (an element's
   * own outline is never clipped by its own overflow).
   *
   * `:focus-visible` on the inner radio, not `:focus-within`: a mouse
   * click on a label focuses the radio, and a ring on every pointer
   * selection would be noise. This is the distinction the rest of the
   * file's focus rules already draw.
   *
   * It lives INSIDE this one feature-query block because it needs
   * `:has()`, and companion/test_config_page.py pins this file at
   * EXACTLY ONE such block. A browser without `:has()` keeps today's
   * behaviour — the same accepted-degradation framing every other rule
   * in this block already carries, and `:has()` has been Baseline
   * widely-available since December 2023. */
  .runway-card:has(input:focus-visible),
  .theme-chip:has(input:focus-visible) {
    outline: 2px solid var(--color-accent);
    outline-offset: 2px;
  }

  /* 30-07-PLAN.md Task 1 (CFG-85): the Aspect card's palette chip's
   * selected state, joining this block rather than opening a second one
   * (the file's own pinned block-count convention,
   * _exactly_one_has_feature_query_block_survives()). Three
   * consequences, matching `.theme-chip`'s own pattern rule for rule:
   * accent border-colour plus the inset ring on the chip itself; the
   * 12% accent wash on `.palette-chip__name` (this component's own
   * equivalent of `.theme-chip__body` — the swatch itself is
   * deliberately excluded, for the identical reason `.theme-chip__
   * preview` is: it is real, undithered ink colour, and a wash would
   * misrepresent it); and the check glyph switching to `inline-flex`,
   * matching `.theme-chip__check`'s own mechanism. No scale/transform is
   * added here (unlike `.theme-chip`'s own first rule) — 30-UI-SPEC.md
   * names exactly these three consequences for this component and no
   * fourth, and a fourth would also need a new `transition` declared on
   * this component's own base rule to animate, which is out of this
   * plan's scope. A browser without `:has()` support keeps the chip
   * unstyled-but-functional on selection — the same accepted degradation
   * every other rule in this block already carries. */
  .palette-chip:has(input:checked) {
    border-color: var(--color-accent);
    box-shadow: inset 0 0 0 2px var(--color-accent);
  }

  .palette-chip:has(input:checked) .palette-chip__name {
    background: color-mix(in srgb, var(--color-accent) 12%, transparent);
  }

  .palette-chip:has(input:checked) .palette-chip__check {
    display: inline-flex;
  }

  /* 30-07-PLAN.md Task 1 (CFG-85): the "Comme les départs" leading
   * option's checked state — a re-keyed consumer of the already-listed
   * `.theme-form .theme-option--active` idiom (12% accent wash, accent
   * text, semibold weight), not a new accent use (30-UI-SPEC.md Color
   * section, item 2). `:has()` is required for the identical structural
   * reason `.theme-chip`/`.palette-chip` above need it: the radio is a
   * DESCENDANT of the `<label>` this rule must repaint, not a preceding
   * sibling a plain `:checked ~` selector could reach. The check-glyph
   * markup this control's own `<label>` carries
   * (`.theme-chip__check`, reused verbatim from that component per
   * `_same_as_departures_chip_html()`'s own markup) is deliberately NOT
   * toggled visible here — 30-UI-SPEC.md names only the wash/text/weight
   * consequences for this control, and inventing a fourth is out of this
   * plan's scope. */
  .leading-option:has(input:checked) {
    background: color-mix(in srgb, var(--color-accent) 12%, transparent);
    color: var(--color-accent);
    font-weight: var(--weight-semibold);
  }

  /* 28-08-PLAN.md Task 2 (CFG-77/CFG-78), 2026-09-16: the bar's own
   * content-clearance, and the ONE genuinely new problem this
   * restoration has that `6dea46a` never did — the OLD clearance rules
   * were scoped to `.dirty-ready`, a class dirty-state.js wrote onto
   * `<html>` only once it had proven its own bar live, so a
   * scripts-blocked visitor (for whom that class never gets written)
   * reserved no space, but also never SAW the bar — it was `hidden`
   * for them. Both premises change at once (Task 1's own no-JS-floor
   * inversion): the bar renders VISIBLE without script now, so a
   * scripts-blocked visitor needs the clearance too, and `.dirty-ready`
   * — never written for them — would silently fail exactly that case.
   *
   * `:has()` is the fix, joining THIS block rather than opening a
   * second one (the file's own pinned block-count convention, restated
   * above): `.dashboard-main:has(.dirty-bar)` /
   * `.has-tab-bar .page-content:has(.dirty-bar)` are TRUE the instant
   * the bar exists in the DOM, with zero script needed to prove
   * anything live first — the server already only ever emits
   * `.dirty-bar` on a settings page (Task 1), so "the bar exists" and
   * "clearance is owed" are the same fact by construction. A page that
   * renders no bar (`/health`, `/flights`, Home) matches neither
   * selector and gains no dead space, at either breakpoint, with
   * scripts running or blocked — a browser without `:has()` support
   * simply reserves no clearance and the bar may cover the last
   * element, the same accepted degradation every other rule in this
   * block already carries.
   *
   * Both figures below are MEASURED, not guessed and not copied from
   * `6dea46a` (its own 88px/144px pair was measured against a bar with
   * DIFFERENT content — a seeded count span and a differently-styled
   * Cancel button — fifteen months before this restoration): rendered
   * via a real Chromium tab, scripts BLOCKED (the binding case), at
   * both breakpoints, in BOTH languages, on `/display` AND `/device`,
   * reading `.dirty-bar`'s own `getBoundingClientRect().height`.
   *
   * >=960px: 64px, IDENTICAL across English/French and both routes (the
   * bar is `width: fit-content` and does not wrap at this width) — plus
   * `var(--space-lg)` (24px) above the viewport foot, 88px total,
   * already on the 8px spacing grid. `.dashboard-main:has(.dirty-bar)`
   * is (0,2,0), beating the base `.dashboard-main` rule's (0,1,0)
   * regardless of source order (this block sits textually BEFORE that
   * rule), so no `@media (min-width: 960px)` wrapper is needed here —
   * specificity alone decides it. */
  .dashboard-main:has(.dirty-bar) {
    padding-bottom: calc(var(--space-2xl) + 88px);
  }

  /* CORRECTED, 28-10-PLAN.md Task 3 (CFG-77/CFG-78) — a Rule 1 (direct,
   * unavoidable consequence) fix found while mutation-testing this
   * file's own tab-bar/last-in-flow-element overlap check against a
   * REAL, scripts-enabled, scrolled-to-bottom page: the original 70px
   * figure below (superseded, kept for its own record) was measured
   * with the count span EMPTY, the SERVER-rendered initial state — not
   * the populated, scripts-enabled state a real edit produces. The bar
   * WRAPS at this width once the section-naming text plus Enregistrer
   * no longer fit on one row (`flex-wrap: wrap`, this file's own
   * `.dirty-bar` phone-width rule below), dropping Annuler to a second
   * row and growing the bar's own height well past 70px. MEASURED, not
   * guessed, via a real Chromium tab, scripts enabled, scrolled to the
   * true document bottom: a single changed section reaches 122px
   * (English "Frame colours changed"/French "Couleurs du cadre
   * modifié"), and THREE changed sections — the realistic worst case on
   * this scope — reach 134px in BOTH languages ("Frame colours, Runway,
   * and Quiet hours changed" / "Couleurs du cadre, Piste et Heures
   * calmes modifié"). 134px (bar) + 56px (tab bar) + `var(--space-md)`
   * (16px, the bar's own bottom anchor) + `var(--space-md)` (16px,
   * cushion above the bar, this rule's own original allowance) = 222px,
   * rounded UP to 224px on the 8px grid.
   *
   * <960px, WAS 70px/88px total (see above): superseded — that figure
   * only ever covered the empty-bar, pre-edit state, and this rule's
   * OWN `.has-tab-bar .page-content:has(.dirty-bar)` is what has to
   * clear the tallest state a real edit reaches, not the shortest.
   *
   * `.has-tab-bar .page-content:has(.dirty-bar)` is (0,3,0) — higher
   * than both `.page-content`'s own base rule (0,1,0) AND
   * `.has-tab-bar .page-content`'s own tab-bar-only clearance (0,2,0),
   * further down this file, near the tab bar's own block — so this
   * rule wins regardless of which is declared later in source, unlike
   * `6dea46a`'s own T7 clearance, which needed EQUAL specificity and a
   * later source position to win. The extra `.has-tab-bar` qualifier is
   * not decorative: it is what keeps this rule from ALSO matching a
   * hypothetical future page that renders `.dirty-bar` without the tab
   * bar, which no route in this app does today, but a bare
   * `.page-content:has(.dirty-bar)` would silently assume forever. */
  .has-tab-bar .page-content:has(.dirty-bar) {
    padding-bottom: calc(
      var(--space-2xl) + 176px + env(safe-area-inset-bottom, 0px));
  }
}

/* 21-05-PLAN.md Task 1 (D-06/D-09): the retired arrivals-override
 * checkbox's own reveal rule and its "Arrivals theme" direction label
 * (both scoped to the now-deleted checkbox markup) are removed
 * outright — the Frame colours card's own usage panels are collapsed
 * by theme-preview.js at load instead (D-08's no-JS floor: the server
 * never emits `hidden` on any panel). This @supports block used to
 * survive for one more, unrelated rule below (the Calendar-card
 * fusion) — 21-07-PLAN.md Task 3 (D-13/R-08/Pitfall 2) retires that
 * rule too, and with it the whole block: the Calendar card and its
 * disconnect form are real nested/sibling DOM now, not two visually-
 * fused siblings, so no feature-query fallback is needed for either
 * any more. The whole-file pinned block count (companion/test_config_
 * page.py) moves from 2 to 1 as a direct result — see that check's
 * own history comment for the exact before/after. */

/* Shared Settings-page checkbox normalization (quick task 260901-qif,
 * generalised 10-05-PLAN.md Task 2): now used by both the Diagnostic LED
 * group and the Quiet hours group, so this is a single rule serving two
 * consumers rather than a duplicated one, per 10-UI-SPEC.md's CSS reuse
 * note. `summary`'s own idiom reused verbatim: a centred flex row with a
 * 44px `min-height`. The 44px is deliberate — a native checkbox's label
 * is already a click target, so putting the floor here means the
 * control's real activation target stays 44px tall while its painted box
 * (below) shrinks to 16px. This is a relocation of the floor, exactly
 * like `.copy-btn`'s synthesized `::before` hit area — NOT a fourth
 * entry for the 44px floor register's opt-out list above the base
 * `button` rule; see that comment, which cross-references this rule.
 * `gap`, not the literal space in the markup, is what separates the box
 * from its text once the label is a flex container — this flex row is
 * only possible because this rule overrides the global block-level
 * `label` rule. */
.settings-checkbox {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  min-height: 44px;
}

/* Undoes the global `input, select` rule for this one control, declaration
 * by declaration: a 16px square box, both of that rule's 44px minimums
 * cleared to zero, padding cleared to zero, and the surface/edge/corner
 * rounding all explicitly reset the way `.filter-bar [data-filter-clear]`
 * resets its own inherited button chrome. That global rule is written for
 * text inputs and selects and was reaching this element only because it
 * carried no class of its own — that is the entire reason it used to
 * paint as an oversized filled box. The two cleared minimums are
 * load-bearing for the same reason `.filter-bar__field input`'s own
 * cleared minimum is: a browser never lets a plain width or height shrink
 * an element below a minimum another rule sets unless that minimum is
 * itself cleared, so without them the 16px sizing below is inert.
 * `flex: none` is required for the same reason `.icon` / `.banner::before`
 * state theirs — without it the 16px box shrinks under the flex row's
 * default shrink behaviour. This rule deliberately neither clears nor
 * restates the native-control tint (`accent-color`) the global rule
 * declares once site-wide (D-06) — restating it here would create a
 * second place one value lives, the exact failure mode this file's
 * --color-destructive comment already documents. */
.settings-checkbox input[type="checkbox"] {
  width: 16px;
  height: 16px;
  min-height: 0;
  min-width: 0;
  padding: 0;
  background: none;
  border: none;
  border-radius: 0;
  flex: none;
}

/* Data table: quiet uppercase label header, one row surface, hover-only
 * row cue. Wrapped in .data-table-wrap (scrolls horizontally, never
 * crops) instead of letting a wide table (History's timestamp/callsign/
 * hex/airline/type columns, Airlines' unresolved-prefix table) overflow
 * the viewport on phone widths — this wrap now stands on its own (the
 * horizontal .nav-bar it used to be paired with is gone, see the header
 * comment above). */
/* quick task 260902-w4t (UIR-04): a CSS-only, pointer-inert, theme-aware
 * hint that a table has more columns off to the side, for the widths
 * where History's own table (Type+Airline, Corroboration, etc.) still
 * overflows the wrap above even after this task's copy fixes.
 *
 * `background-attachment` was chosen over a `mask-image` fade because
 * `.data-table` above declares `width: 100%`, so an unconditional
 * right-edge mask would fade the last column of a table that already
 * fits at a given width — exactly the regression this fix must avoid.
 * The four-layer stack below sidesteps that: two OPAQUE cover gradients
 * (listed first, so they paint on top) use `background-attachment:
 * local` — meaning they scroll WITH the table's own content — while two
 * SHADOW gradients underneath use `background-attachment: scroll` —
 * meaning they stay fixed to the wrap's viewport. When the table's
 * content is exactly as wide as the wrap (nothing to scroll), the local
 * covers and the scroll shadows land in the same place and the covers
 * fully occlude the shadows, by geometry, not by a media query or a
 * JS measurement. Only once the content is wider than the wrap does
 * scrolling move the covers out from over the shadows, revealing them
 * at whichever edge still has more content. This needs no pseudo-
 * element, so it can never intercept a pointer event or interfere with
 * the wrap's own native scrolling.
 *
 * The shadow colour is `color-mix(in srgb, var(--color-text) 15%,
 * transparent)` — the same muted-mix idiom `.section-caption` above and
 * the `.copy-btn` washes already use — never a hardcoded grey, so both
 * themes render a correctly-toned shadow with no per-theme value to
 * keep in sync. The cover colour is the surface this wrap actually
 * paints onto: `--color-canvas` here (History's table sits directly on
 * the page background), with a `--color-dominant` override immediately
 * below for the one case (Health's registry/readings/stats tables) where
 * a wrap genuinely sits inside a `.page-section`/`.battery-trend-section`
 * card instead — verified by reading health_page.py, not assumed. */
.data-table-wrap {
  overflow-x: auto;
  max-width: 100%;
  background-image:
    linear-gradient(to right, var(--color-canvas), transparent 24px),
    linear-gradient(to left, var(--color-canvas), transparent 24px),
    radial-gradient(farthest-side at 0 50%,
      color-mix(in srgb, var(--color-text) 15%, transparent), transparent),
    radial-gradient(farthest-side at 100% 50%,
      color-mix(in srgb, var(--color-text) 15%, transparent), transparent);
  background-repeat: no-repeat;
  background-size: 24px 100%, 24px 100%, 12px 100%, 12px 100%;
  background-position: left, right, left, right;
  background-attachment: local, local, scroll, scroll;
}

/* The one place a .data-table-wrap genuinely sits inside a card surface
 * instead of directly on the page background (health_page.py's
 * _registry_table_html(), the battery readings disclosure table, and
 * _stats_table_html() all render inside a .page-section or
 * .battery-trend-section card) — the cover gradients above must match
 * that surface or they would read as a visible coloured band instead of
 * an invisible cover. History's own table is unaffected: it is returned
 * directly by render(), never inside a .page-section. */
.page-section .data-table-wrap,
.battery-trend-section .data-table-wrap {
  background-image:
    linear-gradient(to right, var(--color-dominant), transparent 24px),
    linear-gradient(to left, var(--color-dominant), transparent 24px),
    radial-gradient(farthest-side at 0 50%,
      color-mix(in srgb, var(--color-text) 15%, transparent), transparent),
    radial-gradient(farthest-side at 100% 50%,
      color-mix(in srgb, var(--color-text) 15%, transparent), transparent);
}

.data-table {
  width: 100%;
  min-width: max-content;
  border-collapse: collapse;
}

/* quick task 260901-uzi (finding 2): opts exactly one table — the
 * Resolution-statistics table, health_page.py::_stats_table_html() —
 * out of the no-crop floor above.
 *
 * The mechanism, in full: the base rule above and this one are both
 * single-class selectors landing on the same <table> element, so their
 * specificity is equal and SOURCE ORDER is the only thing deciding which
 * minimum width wins. This rule must therefore stay after the base one
 * in this file — moving it above would silently restore the overflow
 * with nothing else in the file to notice. A stylesheet guard in
 * test_status_pages.py pins that ordering directly, rather than leaving
 * it to this comment alone.
 *
 * No fixed pixel floor is added to the Description column here, even
 * though the sketch this page came from used one. A minimum wider than
 * the narrowest container simply re-creates the same overflow one
 * breakpoint down — the exact defect being fixed — and it is not needed:
 * the table's other two columns (a short source label and a count) have
 * small max-content widths, so the table's own auto layout already
 * gives the description column the large majority of the available
 * width and wraps it there. The wrap's own overflow-x: auto remains the
 * backstop for any residue at extreme widths, used as the safety net it
 * was written to be, not as the normal state.
 *
 * The boundary: this releases exactly one table. The floor stays in
 * force for History's wide table and for the unresolved-prefix registry,
 * and .stat-tile's own min-width: 0 comment still describes a real,
 * unrelated failure mode that depends on it. */
.data-table--prose {
  min-width: 0;
}

/* quick task 260902-v2v (UIR-13): scoped to the same opted-out table as
 * the rule immediately above, and placed immediately after it — the
 * Source column (a short label like "Fresh lookup"/"Airline only") was
 * wrapping onto two lines at desktop widths once the table's own
 * no-crop floor was released above. Source order matters here for the
 * same reason it matters above: nothing else in this file re-scopes
 * .data-table--prose, so this is a plain child-selector addition, not a
 * specificity race, but it still reads more clearly living beside the
 * rule it depends on rather than elsewhere in the file. */
.data-table--prose th:first-child,
.data-table--prose td:first-child {
  white-space: nowrap;
}

/* Quick task 260913-cz6 — the SECOND table released from the no-crop
 * floor above, and (like the first) it arrived by headless measurement
 * rather than by eye. Health's battery readings table lives inside a
 * closed-by-default `details.readings-disclosure`, so nothing in this
 * repo's sweeps had ever measured it: at a 390px viewport the table
 * measured 432px (FR) / 369px (EN) inside a 308px `.data-table-wrap`,
 * overflowing by 124px and giving that wrap its own horizontal
 * scrollbar. The Timestamp column alone took 302px (FR), holding the
 * one-line concise form "31 juil. 08:00 (il y a 44 j)", against 130px
 * for the four-digit millivolt column. `document.documentElement
 * .scrollWidth` stayed exactly 390px throughout, open or closed —
 * the WRAPPER scrolls, not the page — which is precisely why every
 * page-level overflow assertion in the suite was structurally blind
 * to it.
 *
 * This is the same measured cause as B12 (the floor sizes every column
 * to its widest UNWRAPPED line) but deliberately NOT the same remedy,
 * and `references/data-density.md`'s stacked-cell warning is neither
 * loosened nor invoked here. The registry's stacked-cell treatment was
 * measured on THIS table first and rejected on its own numbers: stacking
 * only lowers the floor from 432px to 297px, which clears 390px by
 * exactly zero and still overflows at 360px (297 against 278) and at
 * 320px (297 against 238) — a fix already broken on a common Android
 * width. It would also force two lines at 1280px, where the wrap has
 * 830px to spare. `table.data-table--registry`'s selector is therefore
 * left alone rather than widened to cover both tables.
 *
 * Releasing the floor is right here and was right to REJECT for the
 * registry, for a reason specific to each table's columns. The floor
 * exists to stop short OPAQUE CODES cropping; the registry has two such
 * columns (prefix, example callsign) with no safe internal break point.
 * This table has exactly two columns: a four-digit integer, which is a
 * single token with no break opportunity at all, so the floor is inert
 * for it; and a timestamp whose every break opportunity is a real word
 * boundary. Measured at 360px and 390px in BOTH languages, the released
 * line breaks on the exact semantic boundary — "31 juil. 08:00" then
 * "(il y a 44 j)" — because the space before the parenthesis is the
 * only mid-string break opportunity and French already carries a
 * non-breaking space inside "44 j". At 768px and 1280px the timestamp
 * stays on ONE line, so this costs no row height where there is room.
 * The floor protects nothing here that is not already safe, while
 * forbidding the one break that makes the table fit.
 *
 * Source order matters for the same reason it does for the rule above,
 * and the `table` qualifier additionally wins on specificity. */
table.data-table--readings {
  min-width: 0;
}

/* 06.6.4 (D-07): the header used to fill a solid --color-secondary
 * block; it now reads as a quiet uppercase label instead — no
 * background at all, just a bottom hairline. The 70% text color-mix is
 * this file's one muted-secondary strength (same value the theme
 * segments and merged-cell parts use), not a new value invented here.
 * This rule and .data-table-wrap th immediately below MUST always be
 * edited together: they carry equal (0,1,1)/(0,2,0)-class specificity
 * and the sticky rule sits later in source order, so leaving it on the
 * old filled background would silently repaint the header the moment a
 * long table scrolls, defeating this rule's own change.
 *
 * quick task 260902-dng (bug 2, closes 260901-uzi Finding 5 candidate
 * (a)): top padding was zero, which is the real mechanism behind the
 * developer's real-Safari header-clipping report. Sticky positioning
 * itself never clips text — `.data-table-wrap th` immediately below
 * paints an OPAQUE BACKGROUND over this rule's own padding box; with
 * zero top padding, that opaque box began exactly at the glyph tops, so
 * a row scrolling underneath a stuck header reached the ascenders
 * before any background covered them. Whether that reads as visible
 * clipping depends on where a font's ascent metric sits inside its own
 * line box — genuinely different between WebKit and Blink, which is
 * exactly why 260901-uzi's Finding 5 reproduced in the developer's real
 * Safari and not in a Chromium-based measurement tool, and why that
 * task declined to fix it blind. Real top padding extends the opaque
 * background above the glyphs by a fixed pixel amount, so the fix does
 * not depend on any font's ascent metric in any engine — not a
 * WebKit-only patch.
 *
 * The new top value mirrors the existing bottom value (10px) rather
 * than inventing one or reaching for a --space-* token: the bottom
 * value is already a bare 10px literal in this same rule, and swapping
 * it to a token (8px or 16px) to "use a token" would change a value
 * nobody reported a problem with, on four tables at once, as a rider on
 * an unrelated fix. Symmetric padding also centres the header's text in
 * its own band and leaves the bottom value — and therefore this
 * header's optical relationship to its own border-bottom hairline —
 * byte-identical to before.
 *
 * Blast radius (shared rule, deliberately not Health-scoped): History's
 * table, the Health battery-readings table, the Resolution-statistics
 * table, and the hand-rolled unresolved-prefix registry table (which
 * emits `<table class="data-table">` directly) all re-space together by
 * 10px of header height. That is intended — a uniform improvement
 * shipped on the shared rule, not a Health-only patch — which is why it
 * belongs here rather than on a Health-scoped override.
 *
 * This closes 260901-uzi's Finding 5 candidate (a). Candidate (b) — the
 * sticky header background's own token, currently --color-canvas,
 * flagged as never live-validated against the in-card tables — was left
 * open here for a later one-declaration decision.
 *
 * T4 (22-15-PLAN.md Task 1) CLOSES candidate (b) by making it moot
 * rather than by deciding it. The `.data-table-wrap th` rule that
 * carried both the sticky claim and that background is deleted below:
 * the wrapper has no height, so the header could never stick, so there
 * was never a stuck header for any token to paint. Nothing about THIS
 * rule changes — only the open question that used to point at the rule
 * below it. */
/* 06.6.4.1.1 (D-13): 11px -> 12px, converging onto the one unified label
 * voice this file now states once, on .stat-tile__caption's own comment
 * below. Every other declaration here already matched that voice. */
.data-table th {
  font-size: 12px;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
  text-align: left;
  padding: 10px var(--space-md);
  border-bottom: 1px solid var(--color-border);
}

/* T4 (22-AUDIT.md, 22-UI-SPEC.md §2's T4 row, 22-15-PLAN.md Task 1):
 * the sticky desktop table header is REMOVED, rule and all.
 *
 * It could never engage. Sticky positioning resolves against the
 * nearest scrollport, which for these cells is `.data-table-wrap` — and
 * that wrapper declares `overflow-x: auto` with NO height and no
 * `max-height`, so it has no vertical scrollport for a header to stick
 * inside. The page scrolls instead, and the page is not the sticky
 * containing block here. Six years of file history describe a behaviour
 * no browser has ever produced: the header simply scrolled away with
 * its table, exactly as if the rule were absent.
 *
 * Removing the claim rather than making it true, deliberately. Giving
 * the wrapper a `max-height` would put a second, nested vertical
 * scrollbar inside a page that already scrolls, on four tables at once,
 * as a rider on a defect report that asked for nothing of the sort.
 * Sticky day headers are Phase 23's D7 and will be specified there.
 * Removing a lie beats adding a second one.
 *
 * The rule's `background: var(--color-canvas)` goes with it. That
 * declaration existed ONLY to stop body rows showing through a stuck
 * header, and quick task 260901-uzi's finding 5 candidate (b) — "is
 * --color-canvas the right token here, against the in-card tables?" —
 * is therefore MOOT, not merely deferred: there is no stuck header to
 * paint. Recorded for the closing design-system sweep (22-16), which
 * marks references/data-density.md's sticky-header entry SUPERSEDED and
 * retires candidate (b) with it. */

.data-table td {
  padding: 13px var(--space-md);
  border-bottom: 1px solid var(--color-border);
}

/* 21-03-PLAN.md Task 1 (D-15, 21-UI-SPEC.md §F): scoped to Flights' own
 * table only — no other .data-table consumer (Health, Airlines) gains
 * this modifier. Six columns (5 data + the "More" toggle) at 8px
 * horizontal padding each = 96px of padding, leaving a 784px content
 * budget inside the real 880px column (1280 - 240 sidebar - 32 gap -
 * 128 .dashboard-main padding) — tokens only, no literal px value, so
 * this rule tracks --space-sm/--space-md if either token ever changes. */
table.data-table--flights td,
table.data-table--flights th {
  padding: var(--space-sm) var(--space-sm);
}

/* 21-03-PLAN.md Task 3 (D-15) — Rule 1 auto-fix found by the headless
 * measurement step: .data-table's own `min-width: max-content` rule
 * (a deliberate earlier "no-crop" floor, quick task 260901-uzi) sizes
 * every column to its widest UNWRAPPED line. The When/Flight cells'
 * primary+separator+secondary spans render inline by default (the
 * shape _merged_cell() already produces everywhere else in this app),
 * so a run like "AFR1380 · Air France · A320" wants ~400px on one
 * line — blowing straight through the 880px column budget regardless
 * of the padding rule above. Scoped to this table only: the primary
 * and secondary lines become genuinely STACKED (block-level), so each
 * column's own max-content width is the WIDER of its two lines, not
 * their concatenation, matching this task's own "two stacked lines,
 * never an inline suffix" requirement. The inline middle-dot
 * separator has no role once the two parts sit on separate lines, so
 * it is hidden here — never deleted from the DOM, so a future change
 * that un-scopes this rule finds the separator markup still intact. */
table.data-table--flights .cell-primary,
table.data-table--flights .cell-secondary {
  display: block;
}

table.data-table--flights .cell-inline-sep {
  display: none;
}

/* 22-12-PLAN.md Task 2 (B12) — the SECOND consumer of the stacked-cell
 * exception above, and it arrived the same way the first one did: by
 * headless measurement, not by eye. Health's unresolved-prefix table
 * measured 886px (EN) / 1026px (FR) inside an 830px `.data-table-wrap`
 * at a 1280px viewport, because `.data-table`'s `min-width: max-content`
 * no-crop floor sizes each column to its widest UNWRAPPED line and the
 * First seen / Last seen cells' one-line form ("1 août 08:00 (il y a
 * 42 j)") wanted 251px of ink EACH — 564px of the budget for two of six
 * columns. Shortening the French headers alone was measured to be
 * incapable of closing that gap; stacking is what makes each column's
 * max-content width the wider of its two lines instead of their
 * concatenation.
 *
 * `references/data-density.md`'s warning still stands and is not being
 * loosened here: this is NOT license to stack any other merged cell
 * "for consistency". It is the same local fix for the same measured
 * cause, applied to the second table in this app that demonstrably has
 * it, and it stays scoped to that table by its own modifier class. */
table.data-table--registry .cell-primary,
table.data-table--registry .cell-secondary {
  display: block;
}

table.data-table--registry .cell-inline-sep {
  display: none;
}

/* 21-03-PLAN.md Task 2 (D-15/R-12, 21-UI-SPEC.md §F): the expandable
 * detail row's own styling — tokens only, no new colour literal. The
 * no-JS floor (D-15, locked) is a VISIBLE detail row by default; the
 * collapsing class below is added only by companion/static/
 * flight-rows.js at load, never rendered server-side. */
.flight-detail-row td {
  background: color-mix(in srgb, var(--color-text) 3%, transparent);
  padding: var(--space-md);
}

/* 27-08-PLAN.md Task 3 (CFG-70): `margin-bottom` closes the gap between
 * this grid and whatever follows it in the same reveal cell (the
 * callsign's own copy button, standalone rather than inside a `<dl>`
 * item — companion/pages/history_page.py's `_flight_detail_row_html()`).
 * MEASURED at the prior 0-margin value: the grid's bottom edge sat
 * roughly 4px above that trailing button's own top edge, well inside
 * both controls' own synthesized 11px reach — the later element (the
 * trailing button, later in document order) wins that overlap outright,
 * so the EARLIER control (the grid's own last-row copy button) measured
 * a real 34x26 against its declared 44x44: full reach left/right/up,
 * almost none down. `var(--space-lg)` (24px) reopens a real gap between
 * the two 11px reaches (22px needed) with room to spare, the same
 * `--space-lg`-over-the-exact-minimum margin this file's carousel pager
 * gap rule already banked for the identical reason. */
.flight-detail-row__grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
  gap: var(--space-sm) var(--space-md);
  margin: 0 0 var(--space-lg);
}

.flight-detail-row__grid dt {
  margin: 0;
}

.flight-detail-row__grid dd {
  margin: 0 0 var(--space-xs);
}

/* D-15's own no-JS floor: visible by default. flight-rows.js adds
 * this class to every .flight-detail-row at load, mirroring
 * nav-dropdown.js's own "document.documentElement.className += ' js'"
 * convention for a component-scoped hide instead of a page-wide one.
 *
 * 23-08-PLAN.md Task 2 (D3/CFG-32): THIS DECLARATION IS THE DELIBERATE
 * CHOICE, and it stays exactly as it is. The height animation below is
 * ONE-DIRECTIONAL: opening animates, closing is instant. The
 * alternative D3 allows — keeping the row present and driving
 * `display` through `transition-behavior: allow-discrete` so it leaves
 * the flow only once the collapse finishes — was considered and
 * declined, for one reason that outranks the nicer closing animation:
 * for the whole of that transition, and for any browser that does not
 * support the property, the row's links, buttons and copy controls are
 * still in the tab order and still in the accessibility tree. A
 * keyboard user tabbing past a "closed" row would land inside it, and a
 * screen reader would read out a row nobody can see. `display: none` is
 * the only end state that removes an element from BOTH, with no support
 * question attached, and it is the property companion/test_view_pages.py
 * and companion/test_browser_ux.py both assert. A row that shuts
 * instantly is a smaller loss than a row that is secretly still there. */
.flight-detail-row--collapsed {
  display: none;
}

/* 23-08-PLAN.md Task 2 (D3/CFG-32): the detail row opens with HEIGHT.
 *
 * WHY THERE IS A WRAPPER AT ALL, since that is the first thing a reader
 * of this rule will wonder. The obvious place for the animation is the
 * row, and the row cannot carry it: a <tr> is `display: table-row`, so
 * it is not a grid container and has no row track to grow; and
 * `display` is a DISCRETE property, so it jumps rather than
 * interpolates no matter what is declared. So the animation lives one
 * level down, inside the <td>, on a wrapper
 * companion/pages/history_page.py renders for exactly this purpose.
 *
 * The mechanism is `grid-template-rows` between `0fr` and `1fr` — a
 * single-track grid whose track grows from nothing to its content's own
 * height, with the inner element clipping what does not fit yet. It is
 * the mechanism 23-RESEARCH.md's Baseline table picks for this job, and
 * the reason is the two things it is NOT: not a guessed `max-height`
 * (which either clips tall content or animates through empty space on
 * short content, and here the content varies per row — a flight with no
 * hex and no runway has half the rows of one with both), and not
 * `interpolate-size`/`calc-size()`, which are Chromium-only and would
 * animate for some visitors while silently doing nothing for the rest.
 * 23-01's own guard bans both by name.
 *
 * `min-height: 0` on the inner element is load-bearing and not
 * defensive: a grid item's automatic minimum size is its content's, so
 * without it the track never actually reaches 0fr and the row would
 * simply appear at full height with a pointless transition attached.
 *
 * SCOPED TO THE CLASS flight-rows.js ADDS TO <html> ITSELF, which is
 * the no-JS floor rather than tidiness. With scripts blocked every
 * detail row is rendered open and stays open — that is D-15, locked —
 * and an unscoped @starting-style would then run this entry animation
 * on every one of up to fifty rows at first paint, unfolding the whole
 * page in front of a reader who has touched nothing. Unscoped, it is
 * motion nobody asked for; scoped, a scripts-blocked page declares no
 * transition at all.
 *
 * The @starting-style block is what makes the OPENING animate: an
 * element going from `display: none` to displayed has no previous
 * computed value to transition from, so without a declared starting
 * value the transition is one that can never run. Where it is not
 * supported the row simply appears, which is exactly what it did
 * before this plan — the animation is additive and degrades to today.
 *
 * `var(--motion-fast)`: 23-01's REACTION token, which names this
 * surface in its own comment. Somebody pressed the control and is
 * watching for it to answer.
 *
 * UNDER REDUCED MOTION THIS DOES NOT GROW, and there is no block here
 * saying so — the global override at the top of this file already
 * drives every transition-duration to 0.01ms, and a per-rule block
 * would be the dead-code-not-a-safety-net pattern
 * accessibility-contrast.md records a verdict on. The stylesheet's live
 * reduce-block count stays at two. */
.flight-rows-live .flight-detail-row__reveal {
  display: grid;
  grid-template-rows: 1fr;
  transition: grid-template-rows var(--motion-fast) ease-out;
}

@starting-style {
  .flight-rows-live .flight-detail-row__reveal {
    grid-template-rows: 0fr;
  }
}

/* 27-08-PLAN.md Task 3 (CFG-70): `padding-left` on THIS element, not on
 * `.flight-detail-row td` two rules up (that rule's own `padding`
 * declaration is shadowed by `table.data-table--flights td`'s higher-
 * specificity `var(--space-sm)` padding, verified from source rather
 * than assumed — editing the shadowed rule would be a change with no
 * effect). This element is the one whose `overflow: hidden` (needed for
 * the height-collapse animation two rules up) clips anything painted
 * past its own edge, including a synthesized `::before` pointer target
 * belonging to a descendant flush against that edge — which the first
 * grid column's copy buttons are, at the 8px `table.data-table--flights
 * td` leaves them. `var(--space-md)` (16px) clears the 11px reach those
 * buttons need with a few pixels to spare. This DOES move the row's
 * rendered content 16px further right than the table's own 8px cell
 * padding alone would (24px total inset instead of 8px) — a visible,
 * deliberate trade rather than a free one: touching `table.data-table--
 * flights td` instead would have re-spaced every cell in the table
 * (header and every summary row too) for a defect that lives in one
 * component. */
.flight-detail-row__reveal-inner {
  overflow: hidden;
  min-height: 0;
  padding-left: var(--space-md);
}

/* 22-09-PLAN.md Task 1 (X5, 22-UI-SPEC.md §2): the row-toggle's FIRST
 * rule block — the control shipped in Phase 21 with markup only and no
 * CSS of its own, riding the base `button` rule's 30px geometry with a
 * visible "More"/"Plus" label. X5 removes that label (fifty text
 * buttons leaving the scan path is the whole point of the fix), which
 * is exactly what dissolves `references/control-density.md`'s recorded
 * rejection of the icon-only pattern for this control ("unlike
 * .copy-btn, the row-toggle carries visible text"). `.row-toggle`
 * therefore moves from the touch-target register's TRADED-AWAY category
 * to its RELOCATED category, and plan 22-16 marks that rejection
 * SUPERSEDED in place with this reasoning.
 *
 * Every value below is `.copy-btn`'s own, reused verbatim rather than
 * re-chosen: the 22x22 visual box, the 6px radius, the `::before`
 * negative 11px inset that synthesizes a real 44x44 pointer/touch hit
 * area (22 + 11 + 11 = 44), the 7% hover wash, and the 14px glyph size
 * `.copy-btn .icon` already scopes its SVG down to. The control is
 * BIGGER than the 30px text button it replaces, not smaller.
 *
 * The glyph rotates with the state. 22-09 left it with no transition
 * of its own and named D3/Phase 23's motion budget as the plan that
 * would add one; 23-08-PLAN.md Task 2 is that plan, and the transition
 * is below.
 *
 * `transform` by name rather than `all`: the rotation is the only thing
 * that changes here, and `all` would animate every property a future
 * edit happens to add to this rule, including ones nobody chose to
 * animate. `var(--motion-fast)` because this is a control answering a
 * press.
 *
 * NO per-rule reduced-motion block, and that was pre-approved rather
 * than assumed: references/control-density.md:78 says in advance that
 * "the global prefers-reduced-motion block already covers a plain
 * transform for free, and a per-rule block here would be dead code, not
 * a safety net." The global block at the top of this file drives every
 * transition-duration to 0.01ms, so under that preference the chevron
 * simply is where it should be. The stylesheet's live reduce-block
 * count stays at two. */
.row-toggle {
  width: 22px;
  height: 22px;
  padding: 0;
  position: relative;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: none;
  border-radius: 6px;
  background: transparent;
}

.row-toggle::before {
  content: "";
  position: absolute;
  inset: -11px;
}

.row-toggle:hover {
  background: color-mix(in srgb, var(--color-text) 7%, transparent);
}

.row-toggle__glyph {
  display: inline-block;
  font-size: 14px;
  line-height: 1;
  transition: transform var(--motion-fast) ease;
}

.row-toggle[aria-expanded="true"] .row-toggle__glyph {
  transform: rotate(180deg);
}

/* 22-09-PLAN.md Task 2 (X5, 22-UI-SPEC.md §2): the day separator row.
 * Thirty-six to fifty rows used to run together with nothing marking
 * where one Paris day ended and the next began.
 *
 * NOT sticky, deliberately and by instruction: sticky day headers are
 * D7/Phase 23, and T4 in this same phase removes the app's one broken
 * sticky-positioning claim rather than adding a second one. This rule
 * therefore declares no positioning of any kind — the phrase is spelled
 * out here rather than quoted as a declaration on purpose, so this
 * plan's own acceptance grep for the file's sticky-declaration count
 * keeps measuring declarations and not prose about them.
 *
 * `--color-canvas` is the page ground the table sits on (the same token
 * the retired sticky-header note named), so the separator reads as a
 * gap in the list rather than as another data row; the label voice
 * comes from the `.text-label` class the <th> already carries plus the
 * uppercase/tracking/70%-muted register `.data-table th` itself uses —
 * no new muted strength, no new size. */
.flight-day-row th {
  background: var(--color-canvas);
  text-align: left;
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* The pointer cursor for the whole-row click, keyed ONLY on the class
 * companion/static/flight-rows.js adds to each summary row at load —
 * the same component-scoped class-at-load idiom
 * .flight-detail-row--collapsed above uses, and for the same reason: a
 * scripts-blocked page must never show a pointer over a row that does
 * nothing. This is the file's only `cursor: pointer` on a table row. */
.flight-row--clickable {
  cursor: pointer;
}

/* quick task 260902-bl2 (bug 1): the Resolution-statistics table's
 * Description column measured full-strength --color-text with an empty
 * classList — not a rule written wrong, a missing hook: before this,
 * layout.data_table() had a column-role class only for the monospace
 * role (mono_columns), so a table whose middle column holds prose had no
 * class for any rule to target at all.
 *
 * The value is this file's existing 70% color-mix muted strength — the
 * same literal .section-caption, .data-table th, .filter-bar__count and
 * .battery-readout__detail already carry. The sketch this table came
 * from names a --color-text-muted token; .cell-primary's own comment
 * below already decided in writing that no such token exists in this
 * file, and none is introduced here — this is the file's one muted
 * strength, reused, not a fourth value or a new token.
 *
 * color, not opacity: .cell-secondary reaches this same 70% read via
 * opacity, but .data-table td above declares its own border-bottom
 * hairline, and opacity would fade that separator along with the text on
 * exactly these four rows and nowhere else in the table.
 *
 * Two things deliberately not carried from the sketch's own td.desc
 * rule. First, a fixed min-width — declined, because .data-table--prose's
 * own comment above already argues a pixel floor on this exact column
 * re-creates the horizontal overflow that modifier was written to
 * remove, one breakpoint down. Second, any selector scoping beyond the
 * class itself: the CLASS is the scope — `desc` is emitted only where a
 * caller opts in via data_table()'s desc_columns keyword, exactly as
 * `mono` is, and the stats table is the only caller that does; History's
 * table and the unresolved-prefix registry hand-roll their own markup
 * and never reach this builder at all.
 *
 * test_contrast_check.py's existing "muted detail text on card surface"
 * pairs already pin this exact foreground/background combination in
 * both themes — no new pair is needed here. */
.data-table td.desc {
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* 06.6.4 (D-07): a clean bottom edge — the last row carries no trailing
 * separator line. */
.data-table tbody tr:last-child td {
  border-bottom: none;
}

/* 06.6.4 (D-07): a row answers the pointer with a subtle tint instead of
 * alternating-row zebra striping (removed below). Same 4% text
 * color-mix strength D-07 specifies and every other hover wash in this
 * phase uses. */
.data-table tbody tr:hover {
  background: color-mix(in srgb, var(--color-text) 4%, transparent);
}

/* 06.6.4 (D-07): the .data-table tr.row-alt zebra-stripe rule that used
 * to live here is removed outright — one row surface plus the hover
 * tint above replaces it. The three server-side sites that still
 * compute and emit the row-alt class name (companion/layout.py's
 * data_table(), companion/pages/history_page.py, companion/pages/
 * airlines_page.py) are deliberately left untouched: the class becomes
 * inert markup with no visual or functional effect, and removing it
 * would mean three Python edits and three harness updates for zero
 * user-visible gain — an intentional decision, not a half-finished
 * refactor. */

/* History's merged-cell parts (06.6.1, D-06/data-density.md). No
 * --color-text-muted token exists in this stylesheet, and the sketch's
 * pattern that named one is deliberately not carried over — inventing a
 * new token, or hard-coding a grey, would be wrong in one of the two
 * themes. opacity on the existing --color-text gives the same muted read
 * in both modes with no per-theme value to keep in sync. The 13px size on
 * .cell-secondary is a legitimate secondary-data exception to the 4-size
 * scale, scoped to this one class.
 *
 * 06.6.4.1.1-03 (D-18a, UIR-26): font-size 12px -> 13px, opacity
 * 0.7 -> 0.75. 12px at 70% opacity on the dark surface measured at the
 * edge of comfortable — this raises both the size and the effective
 * contrast of History's secondary hex text a step, without inventing a
 * new muted-text strength (still the same opacity-on-`--color-text`
 * idiom, just a slightly stronger mix on this one class). */
.cell-primary {
  font-family: var(--font-mono);
  font-weight: var(--weight-semibold);
}

.cell-secondary {
  font-family: var(--font-mono);
  font-size: 13px;
  opacity: 0.75;
}

.cell-inline-sep {
  opacity: 0.7;
  margin: 0 var(--space-xs);
}

/* quick task 260902-w4t (UIR-05): the unresolved-airline link
 * (history_page.py's UNRESOLVED_LINK_CLASS) was reading as glued to the
 * airline text it follows - the separator span above already uses
 * `space-xs` on both sides, so this link takes the next step up
 * (`space-sm`) to read as a distinct affordance rather than another
 * inline part of the cell. Follows .nav-notification's own
 * `margin-left: var(--space-xs)` precedent for spacing an appended
 * inline element; uses an existing token, no new one introduced. */
.cell-unresolved-link {
  margin-left: var(--space-sm);
}

/* History mobile card list (06.6.3, D-07) — the compact-card
 * representation that replaces the cropped horizontal-scroll table below
 * 960px. History's own markup renders both `.history-cards` and its
 * desktop `.data-table-wrap` together, so `.history-cards ~
 * .data-table-wrap` (a sibling-combinator, not the bare `.data-table-wrap`)
 * scopes this toggle to that pair specifically — Airlines' and Health's
 * tables also reuse `.data-table-wrap` but never render a `.history-cards`
 * sibling, so they are never hidden by this rule. */
.history-cards {
  list-style: none;
  margin: 0;
  padding: 0;
}

.history-cards ~ .data-table-wrap {
  display: none;
}

.history-card {
  background: var(--color-dominant);
  /* Same 06.6.4 (D-03) hairline-at-rest contract as .runway-card above:
   * a real --color-border edge at rest, no resting shadow, with the
   * shadow revealed only on hover/focus-within below. */
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  box-shadow: none;
  padding: var(--space-md);
  margin-bottom: var(--space-sm);
}

.history-card:hover,
.history-card:focus-within {
  border-color: transparent;
  box-shadow: var(--shadow-card-hover);
}

/* 29-03-PLAN.md Task 2 (CFG-83, 2026-09-17 audit P1 second paragraph):
 * the flex row let its two children renegotiate their share of the
 * line at 390px — "l'horodatage et l'indicatif se disputent aussi la
 * même ligne dans certaines cartes" — because a flex row with no fixed
 * track never guarantees which side gives way first. The two-track
 * grid below pins the timestamp's track to its own content width
 * (`auto`) and gives every remaining pixel to the callsign/hex track
 * (`minmax(0, 1fr)`, free to shrink to zero and wrap/ellipsis within
 * itself) — the timestamp can never be squeezed again. `white-space:
 * nowrap` on `.history-card__time` below is the half that makes the
 * pin meaningful: without it, a timestamp column pinned to `auto`
 * would still be free to wrap internally onto a second line.
 * `justify-content` is meaningless on a two-track grid whose second
 * track is `auto` (there is no leftover space to distribute), so it is
 * removed rather than kept as dead weight. */
.history-card__primary {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--space-sm);
}

/* The callsign/hex side of the primary line renders as ONE OR TWO bare
 * sibling spans (`.cell-primary`, and — only in the hex-with-no-
 * callsign branch — a following `.cell-secondary` note), both direct
 * children of `.history-card__primary` with no wrapper of their own
 * (companion/pages/history_page.py's `_history_cards_html()`). Both
 * must land in the grid's FIRST track and stack there, never spill
 * into the timestamp's second track — scoped to this one parent, never
 * to the bare `.cell-primary`/`.cell-secondary` classes themselves,
 * which `_history_table_html()`'s desktop cells also use with no grid
 * of their own to conflict with. */
.history-card__primary > .cell-primary,
.history-card__primary > .cell-secondary {
  grid-column: 1;
}

/* opacity 0.7, matching .cell-secondary / .cell-inline-sep above. This
 * card and that table cell render the SAME secondary data (History's
 * merged Hex / Type·Airline parts) — the card below 960px, the table
 * above it — so a second muting strength here meant the identical
 * content read at two different weights purely as a function of
 * viewport width. One secondary-data role, one opacity. */
/* 22-09-PLAN.md Task 2 (X5): the flex `gap` is retired here in favour
 * of the real `.cell-inline-sep` middle dot the markup now carries
 * between the route and the state ("ORY → JFK · Departing", which used
 * to run together). That separator brings its own `--space-xs` margins,
 * so keeping the gap as well would space this one line differently from
 * every other separated pair in the app. */
.history-card__secondary {
  display: flex;
  opacity: 0.7;
  margin-top: var(--space-xs);
}

/* 22-09-PLAN.md Task 2 (X5): the card's airline line — the name and the
 * artwork the phone card used to drop entirely (both were reachable
 * only by opening the disclosure, so the card showed strictly less
 * about a flight than the desktop row for the same data). */
.history-card__airline {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  margin-top: var(--space-xs);
}

.history-card__airline-name {
  font-size: var(--font-label-size);
}

/* 29-03-PLAN.md Task 2: `margin-left: auto` was the FLEX mechanism that
 * pushed this span to the line's trailing edge — the grid above places
 * it in its own track instead, so the auto margin is dead weight now
 * (and, left in place, would fight the grid's own alignment). `white-
 * space: nowrap` is new: a wrapped clock is the exact "retours
 * visuellement maladroits" the audit photographed, and pinning the
 * track to `auto` (above) only stops the track's WIDTH from being
 * squeezed — nothing stops the text inside it from wrapping unless
 * this is also declared. */
.history-card__time {
  font-size: var(--font-label-size);
  white-space: nowrap;
}

/* 23-08-PLAN.md Task 2 (D7/CFG-37): the disclosure is the card's WHOLE
 * content now, not a strip under it, so the gap that used to separate
 * it from the airline line above is gone — that line is inside it. The
 * gap moved to the body, below. */
.history-card__details {
  margin-top: 0;
}

/* The card's face, promoted to be the <summary> of the <details> the
 * card already contained. A tap anywhere on the card opens it, through
 * the native disclosure and with no script involved at all — which is
 * why this is a restructure of what the summary IS rather than a new
 * mechanism, and why it holds with scripts blocked.
 *
 * THE NEGATIVE MARGIN IS THE "ANYWHERE" PART. `.history-card` carries
 * `padding: var(--space-md)`, and without this the summary would stop
 * at the padding's inner edge — a tap on the card's own margin of
 * breathing space would land on the <li> and do nothing, which is the
 * kind of near-miss that reads as a broken control rather than as a
 * boundary. Pulling the summary out by exactly that padding and
 * re-declaring it here makes the tappable box the card's whole face,
 * with the inner spacing unchanged to the eye.
 *
 * `color: inherit` overrides the global `summary` rule's
 * `--color-accent`. That rule is right for a summary whose text IS its
 * label; here the summary's text is the flight's own data, and painting
 * a callsign, a route and an airline name in the accent colour would
 * claim every one of them as a control. The chevron below keeps the
 * accent, so the affordance is still accent-coloured while the data is
 * not — the same split `.tab-bar__link` already makes for its own
 * summary.
 *
 * `min-height: 0` overrides the global 44px floor, which would
 * otherwise fight this box's own content height; the card face is
 * several lines tall and clears 44px many times over. */
.history-card__summary {
  margin: calc(var(--space-md) * -1);
  padding: var(--space-md);
  align-items: center;
  color: inherit;
  min-height: 0;
}

/* The shared chevron, moved to the trailing edge. `order` rather than a
 * second pseudo-element: the global `summary::before` already draws it,
 * already rotates it on open and already carries the reduced-motion
 * reasoning, so this rule only changes where it sits. On a card whose
 * face is three stacked lines, a leading chevron would indent every one
 * of them; a trailing one reads as the card's own affordance.
 *
 * `flex: none` and the rotation stay the base rule's. Only the accent
 * is restated, because `currentColor` now resolves to the inherited
 * text colour set above rather than to the summary's own accent. */
.history-card__summary::before {
  order: 1;
  margin-right: 0;
  margin-left: var(--space-md);
  color: var(--color-accent);
}

/* The face's three stacked lines are one flex item, so the chevron sits
 * beside the block rather than between its lines. `min-width: 0` is the
 * usual flex-item floor: without it a long callsign or route would
 * refuse to shrink and push the chevron off the card. */
.history-card__face {
  flex: 1 1 auto;
  min-width: 0;
}

/* The gap the disclosure's own margin used to provide, moved to the
 * body it actually separates. */
.history-card__details > dl {
  margin-top: var(--space-md);
}

/* The one-hop resolve link, kept on the card face and OUT of the
 * summary (companion/pages/history_page.py's own comment holds the
 * reasoning). Its `.cell-unresolved-link` left margin exists to space
 * it from the airline name it used to follow INLINE; on its own line
 * that margin is a stray indent, so it is zeroed here rather than the
 * shared class being changed for the desktop cell that still needs it. */
.history-card__resolve {
  margin-top: var(--space-sm);
}

.history-card__resolve .cell-unresolved-link {
  margin-left: 0;
}

/* 29-03-PLAN.md Task 1 (CFG-83): the "Afficher plus" / "Show more"
 * reveal's own container. `.flights-more` is ALWAYS rendered
 * (history_page.py's `_show_more_html()`, empty when nothing remains —
 * see that function's own comment for why an empty container must
 * still exist), so `:empty` is what keeps a page with nothing left to
 * reveal from paying for this rule's own top margin. No new custom
 * property, no new colour, no new size — `--space-md` is the same
 * token `.history-card` already uses for its own padding. */
.flights-more {
  display: flex;
  justify-content: center;
  margin-top: var(--space-md);
}

.flights-more:empty {
  display: none;
}

/* Health's own generic mobile card-list mechanism (quick task 260903-ghy,
 * UIR-10/UIR-11) — the SIBLING of History's page-scoped `.history-card*`
 * vocabulary above, not a merge with it: consolidating the two into one
 * shared vocabulary is deliberately DEFERRED here, not forgotten — the
 * two Health tables this mechanism serves have genuinely different
 * per-table content shapes (one stacked-prose card, one two-line-plus-
 * disclosure card), so a shared base class buys little today.
 *
 * These cards render INSIDE a `.page-section page-section--nested` card
 * (health_page.py's `_registry_section()`/`_stats_table_html()` callers),
 * never directly on the page background the way History's `.history-card`
 * does — putting a second bordered/shadowed surface on top of that
 * existing card surface would be the elevation-on-elevation defect
 * 06.6.4 (D-03) already walked back once (and quick task 260902-iag's own
 * reversal on this very page). `.data-card` therefore carries no fill, no
 * radius and no shadow at rest or on hover — only the same row-hairline
 * rhythm `.data-table td`'s own `border-bottom` (and
 * `.data-table tbody tr:last-child td`'s clean last-row edge) already
 * give the table rows these cards replace.
 *
 * `.data-card__desc` repeats this file's one 70% muted-text color-mix
 * literal (the same one `.data-table td.desc` above carries) rather than
 * joining that rule's own selector list — test_status_pages.py pins
 * `.data-table td.desc {` as an exact literal string it indexes by, so
 * appending a second selector there would break that lookup.
 *
 * quick task 260902-w4t's four-layer `background-attachment` scroll-edge
 * shadow on `.data-table-wrap` is untouched by any of this — it remains
 * the desktop/tablet safety net for every table on this page, including
 * the one (battery readings) this task does not touch at all. */
.data-cards {
  list-style: none;
  margin: 0;
  padding: 0;
}

.data-cards ~ .data-table-wrap {
  display: none;
}

.data-card {
  padding: var(--space-sm) 0;
  border-bottom: 1px solid var(--color-border);
}

.data-card:last-child {
  border-bottom: none;
}

.data-card__primary {
  display: flex;
  align-items: baseline;
  gap: var(--space-sm);
  justify-content: space-between;
}

.data-card__value {
  margin-left: auto;
}

/* Mirrors `.data-table th`'s own quiet uppercase label tier above — the
 * same font-size, weight, letter-spacing, transform and 70% muted
 * color-mix literal, copied by VALUE (not by selector), so a card's
 * field label reads at the same quiet tier as the table header it
 * replaces. 06.6.4.1.1 (D-13): 11px -> 12px, moved in the same edit as
 * .data-table th above — a harness check pins these two font-size
 * declarations string-equal, so they must always move together. */
.data-card__label {
  font-size: 12px;
  font-weight: var(--weight-semibold);
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

.data-card__secondary {
  margin-top: var(--space-xs);
  display: flex;
  gap: var(--space-sm);
}

.data-card__desc {
  margin: var(--space-xs) 0 0;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

.data-card__details {
  margin-top: var(--space-sm);
}

/* Phase 13 (13-04-PLAN.md Task 1, D-12): the resolve section's own
 * sighting-context definition list on Airlines. The original rule
 * declared nothing but this block's own bottom margin, leaving the list
 * on browser defaults to match Health's registry disclosure.
 *
 * B5 (22-11-PLAN.md Task 1): SUPERSEDED in that one respect — the audit
 * recorded the `<dl>` as "hand-indented", which is what the UA default
 * `dd { margin-inline-start: 40px }` paints: five labels at the left
 * edge with five values stranded 40px in, on their own lines. The five
 * pairs become two real columns instead, label then value, so a
 * household member reads "Last seen  15:49 (2d ago)" on one line. This
 * is a layout correction to an existing rule, not a new class family
 * and not a new token: both gaps come from the existing xs/sm scale,
 * and the `dt`/`dd` children keep every type declaration their own
 * `text-label`/`text-body` classes already give them. `auto 1fr` sizes
 * the label column to the longest label ("Example callsign") once, so
 * the values share one left edge. The `dt`/`dd` selector is scoped
 * inside `.resolve-context`, so this file still declares no bare
 * `dl`/`dt`/`dd` rule and Health's list is untouched. */
.resolve-context {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--space-xs) var(--space-sm);
  align-items: baseline;
  margin: 0 0 var(--space-md);
}

/* Quick task 260921-n2n Task 2: the developer's 2026-09-21 report
 * ("une bonne part de ces infos ne servent à rien") traced to this
 * exact collision, the same one named above at `.banner__pill[hidden]`,
 * `.refresh-pill[hidden]` and `.dirty-bar[hidden]`. panel-lookup.js
 * already sets the native `hidden` attribute correctly
 * (`resolveContext.hidden = !count;`), but the rule above declares
 * `display: grid` with no `[hidden]` guard, and an author `display`
 * declaration always beats the user-agent stylesheet's
 * `[hidden] { display: none }` regardless of source order — so every
 * ordinary illustration lightbox painted five empty label/value pairs
 * (Prefix / First seen / Last seen / Times seen / Example callsign)
 * instead of nothing at all.
 *
 * This uses the additive `[hidden]`-suffix form, like the three
 * siblings above, rather than rewriting the base rule's selector to
 * `:not([hidden])` the way the lightbox forms further below do
 * (`.lightbox__replace:not([hidden])` et al.). Both idioms are
 * correct; the choice here is not an oversight. The base rule carries
 * five declarations that all belong to the visible state — grid
 * columns, gaps, alignment, margin — and Phase 22's B5 deliberately
 * laid that rule out as a single block. Moving the whole selector to
 * `:not([hidden])` would restructure a rule this file already treats
 * as settled, to change nothing about what it looks like when visible.
 * A two-line additive guard changes nothing about the visible state at
 * all, which is the smaller change for the same fix. */
.resolve-context[hidden] {
  display: none;
}

.resolve-context dt,
.resolve-context dd {
  margin: 0;
}

/* Phase 13 (13-04-PLAN.md Task 1, D-13): the resolve section's Step A
 * name field wrapper. Same token as the sighting-context block above,
 * no other property. */
.resolve-name-field {
  margin-bottom: var(--space-md);
}

/* Phase 13 (D-10): Health's per-row Resolve deep link on the mobile
 * registry card. The link inherits the page's default anchor styling
 * deliberately — no colour, no size — so the project's exhaustive
 * accent-reservation list (style.css's own header comment) is not
 * extended for it. */
.data-card__action {
  margin-top: var(--space-xs);
}

/* Filter bar (06.6.3 D-20) — shared by History and Airlines, driven by
 * list-filter.js; inert without JS (the full unfiltered table/card list
 * stays completely usable if the script fails to load). */
.filter-bar {
  display: flex;
  gap: var(--space-sm);
  align-items: center;
  margin-bottom: var(--space-md);
  flex-wrap: wrap;
}

/* Wraps the filter <input> + its decorative icon-search glyph so the icon
 * renders inside the input's visual bounds rather than beside it. */
.filter-bar__field {
  position: relative;
}

/* 06.6.4 (D-08): 14px, not the .icon rule's 20px default. The glyph is
 * positioned 8px from the left edge, so at 14px it ends at 22px and the
 * input's own 26px left padding below leaves a clean 4px gap; at the
 * base 20px it would end at 28px, two pixels past where the text
 * begins. A 14px glyph is also the proportionate size for a 32px field
 * with a 13px label. This rule outranks both the base .icon sizing rule
 * and the inline width/height attributes the icon builder emits, so no
 * Python change is needed. */
.filter-bar__field .icon {
  position: absolute;
  left: var(--space-sm);
  top: 50%;
  transform: translateY(-50%);
  pointer-events: none;
  opacity: 0.6;
  width: 14px;
  height: 14px;
}

/* 06.6.4 (D-08): a 32px transparent field, scoped to this wrapper's
 * descendant input only — the global native input/select rule below
 * keeps its 44px floor for every other form field in the app. The
 * `min-height: 0` is load-bearing: the global rule's 44px minimum
 * outranks any plain `height` declaration here regardless of
 * specificity (a browser never lets a lower min-height shrink an
 * element below a higher one from another rule unless the higher one is
 * itself cleared), so without this line the 32px height above is inert
 * and D-08 silently does nothing. */
.filter-bar__field input {
  height: 32px;
  min-height: 0;
  padding: 0 10px 0 26px;
  font-size: 13px;
  background: transparent;
  border: 1px solid var(--color-border);
}

/* WR-03 fix: border-color used to substitute for the accent colour here,
 * directly contradicting the header's "border is structural only ...
 * never substitutes for the accent" contract. Brought into line with
 * the file's existing "focused-input outline" accent use instead (see
 * .sparkline-hit:focus-visible below for the same idiom). */
.filter-bar__field input:focus {
  outline: 2px solid var(--color-accent);
  outline-offset: 1px;
}

/* 06.6.4 (D-08): shrunk and muted to the same 70% text color-mix
 * strength the table header and theme segments use — one muted
 * strength across the app, not a fourth value invented here.
 * 06.6.4.1.1 (D-13): already the right size and muted strength; adds
 * the weight/transform/tracking that complete the one unified label
 * voice (see .stat-tile__caption's own comment below for the full
 * contract). */
.filter-bar__count {
  font-size: 12px;
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
  white-space: nowrap;
}

/* 06.6.4 (D-08): the shared Clear-control rule. Targets the
 * [data-filter-clear] attribute both companion/pages/history_page.py's
 * <button type="button" data-filter-clear> and companion/pages/
 * airlines_page.py's <a href="#..." data-filter-clear> already emit —
 * not a new shared class, which would have needed a Python edit on both
 * page modules. The specificity here comfortably outranks both the base
 * button rule and the base anchor rule, and History's Clear is not a
 * submit button so the primary-fill rule never reaches it. Every
 * declaration below undoes a specific base-button property (size,
 * weight, letter-spacing, shadow, radius, min dimensions) so the button
 * variant and the link variant land on pixel-identical output. Do not
 * change either page's Clear element type — see D-16's read-only
 * constraint on Airlines. */
.filter-bar [data-filter-clear] {
  height: auto;
  min-height: 0;
  min-width: 0;
  padding: 0;
  background: none;
  border: none;
  box-shadow: none;
  border-radius: 0;
  font-family: var(--font-ui);
  font-size: 12px;
  font-weight: var(--weight-regular);
  /* 22-09-PLAN.md Task 3 (B11), Rule 1 auto-fix: `line-height` is one
   * more UA button property this rule's own stated contract ("every
   * declaration below undoes a specific base-button property ... so the
   * button variant and the link variant land on pixel-identical
   * output") had not actually undone. A <button> takes the UA's
   * `line-height: normal` (14px at this font size) while Airlines' <a>
   * variant inherits the body's 1.5 (18px) — measured in a real browser
   * at 390px, the two converged controls were 4px apart in height and
   * History's Clear sat 2px lower than the count beside it. Inheriting
   * makes both variants the same height, which is what the rule
   * promised, and is what lets the count and Clear share one top inside
   * the centre-aligned .filter-bar__meta group. */
  line-height: inherit;
  letter-spacing: normal;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
  text-decoration: underline;
  text-underline-offset: 2px;
  cursor: pointer;
}

.filter-bar [data-filter-clear]:hover {
  color: var(--color-text);
}

/* Phase 14 (14-03-PLAN.md Task 2, D-11): the clickable manual-resolution
 * summary. It used to declare "the identical property list
 * .filter-bar [data-filter-clear] uses above, byte-for-byte" — a 12px
 * underlined bare link sitting BELOW the filter bar, because
 * 06.6.4.1-UI-SPEC.md's Autonomous Decision 4 put it outside the bar and
 * a scoped selector therefore could not reach it.
 *
 * X7 (22-11-PLAN.md Task 2): SUPERSEDED, premise and all. The audit
 * found it read as neither a control nor a caption ("1 manual
 * resolutions" as a 12px link), and the reason is that it was drawn as
 * body prose while behaving as a filter. It moved INSIDE the filter bar
 * and now wears `.airline-card__chip` verbatim in the markup — the same
 * label voice the cards' own chips wear, which is the vocabulary this
 * page already uses for a small categorical token. The whole copied
 * property list is deleted rather than forked: everything it used to
 * restate now comes from the chip class, and this rule is reduced to the
 * one thing a chip does not carry because a chip is not interactive —
 * a hover state.
 *
 * `.manual-summary:hover` (0,2,0) outranks the base `button:hover`
 * (0,1,1), so the chip wash deepens on hover instead of the base
 * button's. The wash values are the chip's own 6% -> 12% pair, the same
 * step `.calendar-disconnect-btn` takes; no new strength is invented. */
.manual-summary:hover {
  background: color-mix(in srgb, var(--color-text) 12%, transparent);
  color: var(--color-text);
}

/* Empty state */
.empty-state {
  padding: var(--space-lg);
  text-align: center;
}

/* 22-12-PLAN.md Task 1 (D-08's C1, 22-UI-SPEC.md §1 Typography / §2 X8):
 * the in-tile variant of the block above, emitted only by
 * layout.empty_state(..., compact=True). The type change itself is NOT
 * here — the compact form composes the already-existing `.text-body
 * .widget-verdict` and `.text-label .widget-detail` pairs, which is the
 * whole reason no new type tier had to be invented. This rule therefore
 * declares nothing but box: a tile already supplies its own padding, so
 * a second 24px inset inside it read as a floating island, and the
 * centring that is right for a full-card empty state fights the tile's
 * own left-aligned label/verdict/detail column. Zero padding plus the
 * inherited left alignment is what makes the empty tile occupy exactly
 * the verdict and detail slots of X8's one-tile anatomy. */
.empty-state--compact {
  padding: 0;
  text-align: left;
}

/* The two child rules the compact variant needs, and the only two rules
 * `.empty-state__heading` / `.empty-state__body` have ever had in this
 * file — both classes existed as bare markup hooks until now, with all
 * of their treatment coming from the co-classed `.text-*` tier. Scoped
 * to the compact wrapper so the full-card form stays byte-identical.
 *
 * The heading takes the semibold weight `.text-body` does not declare,
 * landing it on the Emphasis role (16px sans semibold) — the same
 * treatment a tile's verdict row wears, reached through the empty
 * state's own class rather than by borrowing `.widget-verdict`, which
 * would claim a judgement an empty state does not make. The margins
 * match `.widget-verdict`'s own rhythm so a compact empty state and a
 * filled tile sit on the same baseline grid. The body needs no rule of
 * its own: `.text-label` sizes it and `.section-caption` supplies this
 * file's single 70% muted strength. */
.empty-state--compact .empty-state__heading {
  margin: 0 0 var(--space-xs);
  font-weight: var(--weight-semibold);
}

.empty-state--compact .empty-state__body {
  margin: 0;
}

/* Native <details>/<summary> disclosures (06.6.3, shared) — first use in
 * this file (grep confirms zero prior details/summary rules). Used by the
 * Battery readings table (D-08) and the History mobile per-card detail
 * region (D-07). The 44px hit-target floor matches every other
 * interactive element's floor (D-22); both marker rules are required
 * since Safari supports only the -webkit- form on <summary> specifically,
 * unlike <li>. */
/* quick task 260902-ep7 (BUG 3): `color: var(--color-accent)` added.
 * Before this, the element's own label text (not just its ::marker,
 * below) declared no colour at all and inherited full-strength
 * `--color-text`, so a disclosure control was visually indistinguishable
 * from static text except by hovering over it. A <summary> is a
 * disclosure CONTROL — the same interaction class as this file's
 * already-listed "links" accent use — which is the reasoning that
 * admits it to the accent-reservation list in the file header above.
 * `cursor: pointer`, the 44px hit-target floor and the flex centring
 * below are unchanged and were already correct.
 *
 * Deliberately declined: the validated sketch also declares
 * `font-size: var(--font-label-size)` on its two summaries
 * (`summary.readings-summary` / `details.more-details summary`). Not
 * applied here — a size change was not the reported defect, is not what
 * makes the element read as interactive, and would land on all three
 * real disclosure call sites at once as an unrequested rider (health_page
 * .py's readings table and Corroboration "More details", history_page
 * .py's mobile per-card "More details" — verified by grep). Recorded
 * here so the delta from the sketch stays visible rather than
 * forgotten. */
summary {
  cursor: pointer;
  min-height: 44px;
  display: flex;
  align-items: center;
  color: var(--color-accent);
}

summary::marker {
  color: var(--color-accent);
}

summary::-webkit-details-marker {
  color: var(--color-accent);
}

/* T3 (22-AUDIT.md, 22-UI-SPEC.md §5 contract 3, 22-15-PLAN.md Task 1).
 * The two marker rules directly above have had no effect since the
 * `summary` rule gained `display: flex`: a flex container generates no
 * `::marker` at all, so every <details> in the app lost its open/closed
 * indicator and a disclosure was distinguishable from static text only
 * by its accent colour. The rules are kept rather than deleted — they
 * are correct for any future non-flex summary and cost nothing — and
 * this explicit chevron restores the indicator for the flex one.
 *
 * This RESTORES the `<summary>` disclosure-marker entry already on the
 * accent-reservation list in this file's header comment. It is not a
 * new accent consumer and the list does not change: `currentColor`
 * resolves to the `summary` rule's own `--color-accent` wherever that
 * colour is in force, and to the inherited colour where a call site
 * deliberately overrides it (`.tab-bar__link` sets `--color-text`, so
 * the tab bar's chevron is text-coloured, not accent-coloured, which is
 * what an inactive tab must be).
 *
 * Pure geometry, no glyph: two 2px edges of an empty box rotated 45
 * degrees. Deliberately not a text character — a `content` string is
 * exactly the hard-coded-English hazard T10 had to unpick, and an arrow
 * glyph's rendering varies by installed font.
 *
 * `flex: none` is load-bearing: the chevron is a flex ITEM of the
 * summary row, and without it a long label would shrink the box to a
 * sliver. `margin-right` rather than a `gap` on the parent, so no
 * existing summary's own internal spacing changes.
 *
 * The rotation is a `transform` and the fade is a `transition`, both
 * already covered by the single global `@media (prefers-reduced-motion:
 * reduce)` block at the top of this file. NO per-rule reduced-motion
 * block is added here: references/accessibility-contrast.md's "What to
 * Avoid" names that as dead code, not a safety net. */
summary::before {
  content: "";
  flex: none;
  width: 6px;
  height: 6px;
  margin-right: var(--space-sm);
  border-right: 2px solid currentColor;
  border-bottom: 2px solid currentColor;
  transform: rotate(-45deg);
  transition: transform .15s ease;
}

/* Child combinator, not a descendant one: a <details> nested inside
 * another one must never rotate its parent's marker. (0,1,2) against
 * the (0,0,2) base above. */
details[open] > summary::before {
  transform: rotate(45deg);
}

.readings-disclosure {
  margin-top: var(--space-md);
}

/* Stat tile: a card with a status-coloured top border. `min-width: 0` is
 * the first declaration and is load-bearing — a grid item's default
 * minimum size is its content's intrinsic width, and the Battery-trend
 * tile contains a .data-table carrying `min-width: max-content` (see
 * above), deliberately, so tables never crop. Without `min-width: 0` that
 * intrinsic width propagates up and stretches the tile's whole
 * dashboard-grid track far past the 240px floor, pushing sibling tiles
 * off-grid and forcing horizontal scroll on the page itself. With it, the
 * existing .data-table-wrap { overflow-x: auto } absorbs the width
 * instead, which is exactly what that wrapper is for. */
/* 06.6.4 (D-03) supersedes 06.6.1's D-02 shadow-at-rest direction: the
 * hairline outline is back, and the resting shadow is gone — the shadow
 * now appears only on hover/focus-within, below. Do not restore both at
 * once; the two are mutually exclusive states, not additive.
 *
 * quick task 260902-gjj (Task 3): CORRECTED — the claim this comment
 * used to make here, "the status-coloured top border is unaffected
 * either way: it draws from a different property (border-top) than the
 * general border, so the two always coexist without fighting each
 * other," was FALSE. `.stat-tile:hover, .stat-tile:focus-within` below
 * is (0,2,0) and declares `border-color: transparent` — a SHORTHAND that
 * expands to all four `border-*-color` longhands, including
 * `border-top-color`. Each `.stat-tile--{status}` modifier below was
 * (0,1,0), so on hover/focus-within it lost to the hover rule and the
 * status-coloured top border resolved to transparent — a latent defect,
 * pre-existing and not introduced by this task, worth fixing here
 * because Task 2 of this same quick task extends this exact pattern to
 * two page-level cards and cites this rule's own (false) reasoning as
 * precedent. The modifiers below are now written in the doubled
 * (0,2,0) form, matching the fix `.battery-trend-section`/`.page-section`
 * already use (see those rules' own status-modifier block) — a harness
 * check pins the doubled form and the required after-hover source
 * order. */
.stat-tile {
  min-width: 0;
  padding: var(--space-md);
  background: var(--color-dominant);
  border: 1px solid var(--color-border);
  border-top: 3px solid var(--color-accent);
  border-radius: var(--radius-control);
  box-shadow: none;
  transition: box-shadow .15s, transform .15s;
}

/* :focus-within alongside :hover on purpose: several tiles now contain
 * focusable children (the sparkline hit targets from Phase 06.5), and a
 * keyboard user should get the same lift affordance a mouse user gets.
 *
 * 22-04-PLAN.md Task 2 (T9): this declaration used to clear the resting
 * hairline the moment the shadow appears, on all four edges, "so the
 * two treatments never overlap (D-03)" — that stops being true of the
 * TOP edge as of this task: `border-inline-color`/`border-block-end-color`
 * (side-and-bottom only) replace the four-edge `border-color: transparent`
 * shorthand, leaving `border-block-start` — a modifier tile's own
 * status/accent rail (`.stat-tile--ok/--warn/--error/--accent` above) —
 * intact through hover/focus-within instead of erasing it. The two
 * treatments still never overlap on the three edges this rule still
 * clears; only the top rail is now exempt, deliberately.
 *
 * `:not(.frame-strip)` scopes the WHOLE hover/focus-within reveal (the
 * border change and the lift alike) away from the Frame strip: the
 * strip is a control area, not a browsable card, and must not lift 1px
 * or lose its own top rail just because a button inside it is hovered
 * or focused. */
.stat-tile:not(.frame-strip):hover, .stat-tile:not(.frame-strip):focus-within {
  border-inline-color: transparent;
  border-block-end-color: transparent;
  box-shadow: var(--shadow-card-hover);
  transform: translateY(-1px);
}

.stat-tile.stat-tile--ok {
  border-top-color: var(--color-status-ok);
}

.stat-tile.stat-tile--warn {
  border-top-color: var(--color-status-warn);
}

.stat-tile.stat-tile--error {
  border-top-color: var(--color-status-error);
}

.stat-tile.stat-tile--accent {
  border-top-color: var(--color-accent);
}

/* CURRENT CONTRACT (06.6.4.1.1, D-13): this rule is no longer a serif
 * exception at all. It now states the one unified 12px uppercase label
 * voice every label-shaped element in the app shares — sans, 12px,
 * semibold, uppercase, 0.06em tracking, the file's existing 70%
 * color-mix muted strength — the same declarations `.data-table th`,
 * `.data-card__label`, `.filter-bar__count`, `.banner__pill` and
 * `.airline-card__chip` all now carry too (each edited in the same
 * plan). No serif text remains at Label size anywhere in the companion.
 *
 * SUPERSEDED (06.6.1 through quick task 260902-iag): this rule used to be
 * the one named Label-role serif exception in the file — the entire
 * premise of the two adjudications below was a four-role scale in which
 * this caption was serif and 14px, disagreeing or agreeing in WEIGHT with
 * a 16px/20px nested card title. D-13 retires that whole premise: there
 * is no longer a separate serif Label voice to weigh against a card-title
 * tier, so the weight-adjudication question the two blocks below argued
 * over no longer arises. Kept below, not deleted, because it is the
 * evidence trail for how this rule got here.
 *
 * SUPERSEDED (quick task 260902-dng, Task 3): this rule used to promote
 * the caption to --weight-semibold, keeping the 14px size and the serif
 * family untouched. Reasoning, kept readable rather than deleted:
 * 260901-uzi's own finding 4 had just demoted `.page-section--nested >
 * h2` (the nested card title — "Battery trend", "Unresolved prefixes",
 * "Resolution statistics") from 20px regular to 16px SEMIBOLD, which
 * created a collision this task's brief asked to resolve — hypothesis
 * (ii): the caption and the nested card title are both this file's "name
 * of a card" role, and until that promotion they rendered at two
 * different weights (14px regular serif vs 16px semibold serif). D-09's
 * four-size scale ruled out a size fix, so weight was the only lever;
 * promoting the caption made the two "card name" roles agree on weight
 * while the unchanged 14/16 size gap kept signalling a tile's name as
 * subordinate to a section-level card's own name.
 *
 * SUPERSEDED (quick task 260902-iag): re-adjudicated the promotion above
 * when 260901-uzi's finding 4 was itself reverted (see
 * `.page-section--nested > h2`'s own comment). The promotion's ONLY
 * stated reason was matching the nested card title's weight; reverting
 * that title to regular did not merely remove the reason, it inverted
 * it — a semibold 14px caption next to a regular 20px card title would
 * have recreated the same weight-vs-size inversion in the opposite
 * direction. Verdict at the time: REVERTED, back to the plain Label role
 * it held before 260902-dng.
 *
 * D-13 supersedes both adjudications above with a different move
 * entirely: rather than choosing whether this caption should match the
 * nested card title's weight, it retires the caption's own serif Label
 * identity and puts it on the same uppercase label voice every other
 * label-shaped element in the app already needed to converge on. */
.stat-tile__caption {
  display: flex;
  align-items: center;
  gap: var(--space-xs);
  margin: 0 0 var(--space-sm);
  font-family: var(--font-ui);
  font-size: 12px;
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* Icon section (06.6.1 Component Patterns — icon <defs> block). A
 * display:none sprite container still resolves <use> references, which is
 * the whole point of the technique: this rule hides the definitions
 * without disabling them. */
.icon-defs {
  display: none;
}

/* Plan 04 also emits explicit width/height attributes on each <svg class
 * ="icon"> instance, so an icon is correctly sized even if this rule is
 * ever lost — belt and braces, matching how the battery sparkline already
 * carries both attributes and a CSS override (see .battery-trend-section
 * svg below). */
.icon {
  width: 20px;
  height: 20px;
  flex: none;
  vertical-align: middle;
}

/* Icon tint: because the SVG instances use stroke="currentColor" with
 * fill="none", a single `color` property drives the icon's stroke with no
 * second colour mapping to keep in sync with the top-border modifiers
 * above. The icon and the top accent read from the same modifier class,
 * which is what makes them incapable of disagreeing. */
.stat-tile__icon {
  color: var(--color-accent);
}

.stat-tile--ok .stat-tile__icon {
  color: var(--color-status-ok);
}

.stat-tile--warn .stat-tile__icon {
  color: var(--color-status-warn);
}

.stat-tile--error .stat-tile__icon {
  color: var(--color-status-error);
}

.stat-tile__value {
  font-size: var(--font-body-size);
  font-weight: var(--weight-semibold);
  line-height: 1.2;
}

/* Quick task 260903-peo (UIR-14): the pipeline tile's new second content
 * line. Declares layout only (a small top margin, tightening the
 * default <p> spacing so the two lines read as one tile, not two
 * paragraphs) — the muted-text tier itself is `.text-label
 * .section-caption` (co-classed alongside this on the same element,
 * the file's existing 70% color-mix muted strength), reused rather than
 * a new type tier invented here. `.battery-readout__detail` was
 * considered and rejected: it declares the same muted strength, but its
 * class name's `battery-readout` substring collides with
 * BATTERY_READOUT_ID and would false-positive two pre-existing "no
 * battery readout" regression guards on every fresh install, since this
 * tile renders unconditionally. */
.stat-tile__meta {
  margin: var(--space-xs) 0 0;
}

/* quick task 260901-tsa (finding C): the Device/Pipeline tiles now hold
 * a concise_timestamp_html() <span class="mono"> directly inside this
 * paragraph. .mono (above) sets `font-weight: var(--weight-regular)` on
 * that span directly — a rule that targets an element beats a weight
 * the element would otherwise inherit, regardless of the two selectors'
 * equal specificity — so without this line .stat-tile__value's Emphasis
 * promotion is silently inert on both timestamp tiles: the span renders
 * regular no matter what the parent asks for. `inherit` is what lets
 * the parent's semibold weight through while leaving .mono's font-family
 * intact (the whole reason the span exists: stable digit widths in a
 * value that changes on hover). Matches .filter-bar__field input's own
 * precedent for a narrow, scoped opt-out of a global rule for one
 * container's descendants — scoped to .stat-tile__value on purpose:
 * mono values elsewhere in this file keep .mono's regular weight.
 *
 * quick task 260901-uzi: the battery readout's value span joins this
 * same selector list rather than getting a rule of its own — it needs
 * exactly the same scoped opt-out, for exactly the same reason: an
 * Emphasis-weight container holding a .mono descendant whose own rule
 * would otherwise pin it back to regular. This is one role, so it stays
 * one rule; .stat-tile__value stays first in the list so the existing
 * cross-file harness guard that locates this rule by that literal still
 * finds it. */
.stat-tile__value .mono,
.battery-readout .mono {
  font-weight: inherit;
}

/* quick task 260901-uzi (finding 3): the readout's trailing detail —
 * recedes to the Label size and regular weight, using this file's
 * existing 70% color-mix muted strength (.section-caption, .data-table
 * th, .filter-bar__count already use it — not a fourth value invented
 * here). Addresses the developer's actual complaint (too bold, too big,
 * not sober): the readout keeps exactly one emphasised element, the
 * figure (.battery-readout__value above, carrying the .mono rule's
 * reach-through), and everything trailing it recedes — this page's own
 * validated sketch's arrangement.
 *
 * companion/test_contrast_check.py's live_pairs did not already pin
 * this muted-on-card-surface pair, so this task adds it there rather
 * than shipping an under-contrast trailing detail unguarded (light
 * #5D5E62 on #FFFFFF, dark #AFB2B6 on #151922 — both this same 70%
 * strength composited over --color-dominant). */
.battery-readout__detail {
  font-size: var(--font-label-size);
  font-weight: var(--weight-regular);
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* No `.stat-tile svg { width:100%; height:auto }` rule here (deliberate,
 * not an oversight). That rule existed pre-06.6.1 to shrink the battery
 * sparkline's fixed width="300" height="60" SVG to fit inside a tile —
 * but 06.6.1's own plan 03 moved the sparkline out of .stat-tile entirely
 * (see .battery-trend-section svg below, which now owns that job). Left
 * in place, the old rule's `svg` element selector (specificity 0,1,1)
 * unintentionally matched plan 04's new `<svg class="icon">` elements
 * too, beating `.icon`'s own sizing (specificity 0,1,0) regardless of
 * source order and stretching every icon to fill its tile — a real
 * defect found on real-device verification (06.6.1-06-SUMMARY.md).
 * `.icon`'s explicit width/height (both the CSS rule and icon_html()'s
 * own belt-and-braces HTML attributes) are sufficient without it. */

/* Battery-trend section (06.6.1): moves out of .dashboard-grid entirely
 * into its own full-width card, same hairline-at-rest/shadow-on-hover
 * contract as .stat-tile (06.6.4 D-03). One deliberate omission vs.
 * .stat-tile still stands — do not "complete" the pattern by adding it
 * back: no hover lift/transform (it contains an interactive chart, and
 * lifting the container under the pointer while the user is aiming at an
 * 8px-radius chart hit target is actively unhelpful). The shadow reveal
 * added in 06.6.4 changes no geometry, so the transform omission's
 * reasoning is untouched by that change.
 *
 * quick task 260902-gjj (ISSUE 2): REVERSED — the other omission this
 * comment used to list here, no border-top status accent, was recorded
 * as "unrelated to this plan and still correct (this is not a status
 * tile — it carries no ok/warn/error verdict of its own)". That claim
 * was FALSE as written: companion/pages/health_page.py's
 * _battery_section() computes a real battery_status() verdict and
 * renders it inside this very section — the verdict was drawn as an
 * in-body status_dot() badge instead of a card edge, not absent. 06.5-
 * CONTEXT.md's own D-01 reference note already expected "06.3's 3px
 * top-border-by-status treatment" to apply to this content, before
 * 06.6.1-03 moved it out of .stat-tile and the border-by-status
 * treatment never followed. This task restores that original intent —
 * see the shared status-modifier rules below .page-section's own hover
 * rule, and layout.card_status_class(). */
.battery-trend-section {
  padding: var(--space-md);
  background: var(--color-dominant);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  box-shadow: none;
  min-width: 0;
  margin-bottom: var(--space-2xl);
}

.battery-trend-section:hover,
.battery-trend-section:focus-within {
  border-color: transparent;
  box-shadow: var(--shadow-card-hover);
}

/* This selector shrinks the sparkline canvas to the section's own
 * width. It used to also need `:not(.icon)` to exclude the section
 * heading's own `icon-battery` `<svg class="icon">` (06.6.1 plan 04/06
 * gap fix) from a bare `svg` element selector, reproducing the exact
 * `.stat-tile svg` collision already fixed above (06.6.1-06-SUMMARY.md)
 * in a second location this rule was never updated for. SUPERSEDED
 * (quick task 260902-j8w): that heading glyph is gone — removed at the
 * developer's own instruction, it was the only glyph inside any Health
 * `<h2>` — so `:not(.icon)` now matches nothing in this section and is
 * vestigial. It is retained anyway, as belt-and-braces against any
 * future `.icon` element landing inside `.battery-trend-section`: three
 * assertions in test_status_pages.py key on this exact selector string
 * as a literal (one of them scanning every `@media` block), and the
 * specificity note two sentences below is stated against this precise
 * selector form, so tidying the guard away would break all three for a
 * reason unrelated to any change actually being made. Specificity note,
 * still load-bearing: this selector is (0,2,1), which out-specifies any
 * bare `.sparkline__canvas` class rule (0,1,0) — the canvas's size is
 * declared HERE ONLY; a second declaration on the class selector would
 * lose a silent specificity fight rather than override anything.
 *
 * HISTORY (kept as evidence the fixed-viewBox design this replaces
 * could never have worked here, not as a live constraint — nothing
 * below this paragraph governs the current rule):
 *
 * quick task 260902-dng (bug 1) found that `height: auto` let the
 * browser's default `xMidYMid meet` scaling multiply every SVG-user-unit
 * value by `containerWidth / viewBoxWidth`, unbounded above — a measured
 * 2.53x blow-up (846px rendered / a 334-wide viewBox) with 10px labels
 * rendering at ~25px. That task derived this file's real container-width
 * range from its own layout rules (not just the one width anyone
 * happened to measure): at >= 960px, `.dashboard-shell`'s 240px column
 * plus a `--space-xl` (32px) gap leaves `viewport - 272` for the main
 * column; `.dashboard-main` caps at `min(1440px, 100%)` and adds
 * `--space-2xl`/`--space-3xl` (48/64px) padding; this section's own
 * `--space-md` (16px) padding plus its 1px border subtracts 34 more.
 * Below 960px, `.page-content`'s `--space-xl`/`--space-lg` (32/24px)
 * padding leaves `viewport - 48`, then the same 34 for this section's
 * own padding/border. Evaluated: 375px viewport -> 293px (the narrowest
 * real container); 959px -> 877px; **960px -> 526px** (a real
 * discontinuity — the container DROPS as the sidebar appears);
 * 1280px -> 846px (matches the live measurement exactly); >= 1568px ->
 * 1278px (the 1440px max-width caps it). Real range: 293px to 1278px, a
 * 4.36x spread, non-monotonic in viewport width. (All figures above are
 * as measured at quick task 260902-dng's own time, when this section's
 * padding was `--space-md` (16px) at every width. 06.6.4.1.1-03 (D-15)
 * later raised this section's own padding to `--space-lg` (24px) at
 * >= 960px only, so the "34" subtracted above becomes 50 (24px x2 +
 * the 1px border x2) for every >= 960px figure in this walkthrough —
 * ~16px narrower than shown. The below-960px figures are unaffected:
 * this section's padding there is still `--space-md`, still 34. Nothing
 * about this recomputation changes the paragraph's own conclusion —
 * width is still non-monotonic and still ranges over a multi-x spread —
 * which is why the historical numbers above are left as originally
 * measured rather than silently rewritten.)
 *
 * quick task 260902-dng's own fix bounded the scale at 1.0 (a fixed CSS
 * height equal to the SVG's own height attribute, capping
 * `min(containerWidth/viewBoxWidth, cssHeight/viewBoxHeight)` at 1.0) —
 * correct as far as it went, but it left the chart under-filling its
 * card at most widths in the range above (a 366-unit-wide plot area
 * inside an 846px-wide card at 1280px, for instance). quick task
 * 260902-ep7 (BUG 4) evaluated and rejected a breakpoint-aware dual
 * canvas as the fix for that: it would break battery-trend.js outright
 * (two `<svg>` in the DOM means `document.querySelectorAll(".sparkline-
 * hit")` returns 2N points, half inside a `display: none` subtree where
 * `.focus()` silently fails, plus two conflicting `tabindex="0"` Tab
 * stops), and even setting that aside, each breakpoint band still spans
 * a wide ratio of this range (293-877px below 960px, 526-1278px above
 * it) so neither canvas would fill its own band's full range either.
 *
 * The fix actually taken: delete the viewBox entirely (see
 * health_page.py's `battery_sparkline_svg()`). With no viewBox there is
 * no scaling transform of any kind — 1 SVG user unit IS 1 CSS pixel, and
 * percentage geometry resolves against this element's own real rendered
 * size. "The chart fills its card" becomes a property of the layout,
 * not a tuned number, and the entire class of defect quick task
 * 260902-dng fixed (and this bug report's own "doesn't fill the card"
 * complaint) becomes structurally unreachable rather than merely
 * bounded. `preserveAspectRatio="none"` (the validated Merged Health
 * Sketch's own strategy, already rejected once by 260902-dng for
 * distorting glyphs/circles at narrow widths under a fixed viewBox) is
 * now simply MOOT — there is no scaled canvas left for it to apply to.
 *
 * **New hazard this design introduces, in place of the old scale-bound
 * one — read this before touching `height` below:** every point
 * coordinate `battery_sparkline_svg()` emits is a PERCENTAGE OF THIS
 * DECLARED HEIGHT. The height must be declared exactly once, here, and
 * must never be varied inside a `@media` block — a responsive height
 * would silently move every point on the chart with no other visual
 * signal that anything broke. A harness check in test_status_pages.py
 * pins this: it asserts the height is declared on this selector and
 * declared nowhere else, especially not inside `@media`. */
.battery-trend-section svg:not(.icon) {
  width: 100%;
  height: 160px;
  overflow: visible;
  display: block;
}

/* 06.6.4 (D-03) removes the D-14 dark-mode card-edge override that used
 * to live here (the six-selector list: .stat-tile, .page-section,
 * .battery-trend-section, .runway-card, .history-card, .login-card).
 * Two reasons, both load-bearing:
 *
 * 1. Redundant — every one of those six base rules now carries a real
 *    `--color-border` hairline in every theme (D-03), which is exactly
 *    what this override existed to synthesize for dark mode alone.
 *
 * 2. Actively wrong if left in place — the `html[data-ui-theme="dark"]`
 *    attribute selector outranks a bare `.stat-tile:hover` on
 *    specificity. Leaving it would have pinned the border back on
 *    during hover for any user who explicitly picked dark mode via the
 *    theme picker, while `prefers-color-scheme` dark users saw the
 *    correct cleared-border hover behaviour — a silent per-theme
 *    divergence in exactly the signal D-03a depends on reading
 *    correctly. Removing both blocks outright, rather than reworking
 *    their specificity, is what makes hover behave identically across
 *    both theme-selection mechanisms. */

/* Dashboard grid (D-02): shares the same auto-fit/minmax idiom as
 * .illustration-grid below (this file's responsive-grid idiom), with a
 * wider 240px floor. 06.6.1 promoted the gap (the horizontal/vertical GAP
 * between tiles inside one grid row) from lg (24px) to xl (32px) per
 * D-02/D-04's airier-layout direction — SUPERSEDED by 06.6.4.1.1-03
 * (D-14, UIR-25): the 32px-in-grid gap and .page-section's own 24px
 * stacked card-to-card gap (below) were two different values for the
 * same "sibling card" relationship, an inconsistency, not a deliberate
 * reading — collapsed back onto the one stacked value (`--space-lg`,
 * 24px) so every sibling-card gap in the file agrees. No breakpoint is
 * needed for the grid itself: it collapses to one column on narrow
 * viewports on its own.
 *
 * quick task 260902-ep7 (BUG 2): `margin-bottom` is a DIFFERENT thing
 * from the `gap` above — it is the space AFTER this whole grid, before
 * whatever card follows it in the same section, not the space between
 * this grid's own tiles. That margin used to be `var(--space-2xl)`
 * (48px), the same token `.battery-trend-section` below uses for its
 * own margin-bottom. Both of `.dashboard-grid`'s two real call sites
 * (verified by grep: health_page.py::render()'s Screen section's
 * single-tile grid, and its Server & data section's three-tile grid) are
 * followed by ANOTHER card in the SAME section — the battery-trend card
 * after Screen's grid, the two migrated .page-section cards after Server
 * & data's grid — never by a new section heading. So both of this rule's
 * two uses were same-section, card-to-card gaps, not section
 * transitions, and both were using the section-transition value.
 *
 * This file's own real "two cards, same section, one after another"
 * precedent is `.page-section`'s own `margin-bottom: var(--space-lg)`
 * (24px, below) — the gap that already separates the Unresolved-prefixes
 * card from the Resolution-statistics card, both inside the single
 * Server & data section. Retargeting `.dashboard-grid` to that same
 * value makes the two-role split explicit and, going forward, load-
 * bearing:
 *   - Same-section, card-to-card rhythm: `--space-lg` (`.dashboard-grid`
 *     now, `.page-section` already).
 *   - Section-to-section separation: `--space-2xl`
 *     (`.battery-trend-section`'s own margin-bottom, below — closes the
 *     Screen section before the Server & data heading — UNCHANGED by
 *     this edit, it is a genuine section transition).
 * Before this change both roles shared one token, which is exactly why
 * the Screen section's single Device tile and the battery-trend card
 * below it read with a section-sized gap between two cards that belong
 * to the same section.
 *
 * This does NOT undo quick task 260901-tsa's finding E, immediately
 * above at the Screen-section call site in health_page.py: that finding
 * was that a bare `.stat-tile` outside any `.dashboard-grid` sat FLUSH
 * (zero gap) against the card below it, because `.stat-tile` itself
 * declares no margin — the fix was wrapping it in a grid so it inherited
 * A gap at all. That wrapper stays and is still correct; only the SIZE
 * of the gap it now contributes changes, from the section-transition
 * value to the card-to-card value. A harness check in
 * test_status_pages.py pins both roles as a pair, so a future "harmonise
 * these two margins" edit cannot silently re-flatten the split. */
.dashboard-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
  gap: var(--space-lg);
  margin-bottom: var(--space-lg);
  /* 06.6.3 (UXA-06) deliberately set this row's cross-axis alignment so
   * unequal-height tiles would NOT stretch to the tallest sibling —
   * Health's shorter Device/Pipeline tiles kept their own intrinsic
   * height next to a taller Corroboration tile. That was the stated
   * intent, not an accident, and this comment used to say so alone.
   *
   * quick task 260901-uzi: REVERSED. The developer measured the shipped
   * result live in one Server & data row — Pipeline 107.7px, Corroboration
   * 261.8px, Resolution-rate 140.4px, all in the same grid row — and
   * asked for the opposite of what UXA-06 chose. This is a reversal of
   * UXA-06, not a fix of it: the direction only ever runs one way, the
   * two shorter tiles grow to match the row's tallest sibling. The
   * Corroboration tile — naturally tallest, since it carries three
   * status rows plus a <details> disclosure — is never shrunk or
   * clipped by this, which is what makes stretching the safe direction
   * here. Stretching is CSS Grid's own default and this declaration
   * could be removed instead of restated, but it is kept explicit —
   * greppable, and pinned by a stylesheet guard in
   * test_status_pages.py — so the reversal is visible in a diff instead
   * of invisible as a deletion.
   *
   * Blast radius, verified: `dashboard-grid` is emitted from exactly two
   * places in the whole codebase, both inside
   * health_page.py::render() (the Screen section's single-tile grid and
   * the Server & data section's three-tile grid) — so this reversal
   * reaches Health and nothing else.
   *
   * Do not confuse this with the OTHER cross-axis declaration in this
   * file, inside the `@media (min-width: 960px)` `.dashboard-shell` rule
   * below: a different selector doing a different job. D-21's sticky
   * sidebar depends on that one staying non-stretched, and it is
   * untouched by this reversal. */
  align-items: stretch;
}

/* Page section card treatment: gives the below-breakpoint stacked
 * sections the same card treatment as the dashboard's tiles.
 *
 * "The same card treatment" now means literally the same, including the
 * edge: 06.6.4 (D-03) gives this rule the same hairline-at-rest,
 * shadow-on-hover contract every other card in this file carries — a
 * real `--color-border` edge at rest, no resting shadow, with the shadow
 * revealed only on hover/focus-within below. */
.page-section {
  padding: var(--space-md);
  margin-bottom: var(--space-lg);
  /* The card surface, like every other card in this file. This rule had
   * no `background` at all, which was correct under the pre-06.6.2
   * TWO-level surface model — back then --color-dominant was the page
   * background, so a card needed no fill to sit on it. 06.6.2 split the
   * levels (--color-canvas page / --color-dominant card /
   * --color-secondary elevated) and updated .stat-tile,
   * .battery-trend-section, .runway-card, .history-card and .login-card
   * to declare the card surface explicitly — this rule was missed, so
   * Config's Poll/LED sections and both of Preview's sections rendered
   * as shadowed canvas-coloured blocks while every other card on screen
   * was on --color-dominant. */
  background: var(--color-dominant);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  box-shadow: none;
}

.page-section:hover,
.page-section:focus-within {
  border-color: transparent;
  box-shadow: var(--shadow-card-hover);
}

/* quick task 260902-gjj (ISSUE 2): card-level status accent, shared by
 * .battery-trend-section and .page-section (specifically the
 * Unresolved-prefixes card — the Resolution-statistics card gets no
 * modifier at all; see layout.card_status_class()'s own docstring for
 * why an absent modifier, not an accent fallback, is the correct "no
 * verdict" signal here). Reuses the exact tokens .stat-tile's own
 * modifiers already use — no new colour value.
 *
 * The doubled selector form (`.battery-trend-section.battery-trend-
 * section--ok`, no space — one class immediately followed by another)
 * is deliberate, not decoration: both `.battery-trend-section` and
 * `.page-section` declare a `border` SHORTHAND above, which resets
 * `border-top-color`/`border-top-width`. A single-class modifier
 * selector is (0,1,0), tying the base rule's own (0,1,0) and leaving the
 * outcome to source order — the exact cascade hazard
 * `.page-section.banner--anomaly` above already documents and solves the
 * same way. The doubled form is (0,2,0) and wins deterministically.
 *
 * Placement after both hover rules above is equally load-bearing, and is
 * the part a future "tidy the modifiers up next to their base rule" edit
 * is most likely to break: `.battery-trend-section:hover`/`:focus-within`
 * and `.page-section:hover`/`:focus-within` are ALSO (0,2,0) and declare
 * `border-color: transparent` — a shorthand that includes
 * `border-top-color`. At equal specificity the LATER rule wins, so this
 * block must stay after both hover rules or the status colour vanishes
 * the moment the card is hovered. That is not hypothetical:
 * `.battery-trend-section:focus-within` fires on every chart hit target a
 * keyboard user focuses, so a status border that lost to hover would
 * blink out during ordinary chart traversal. A harness check pins this
 * source order. */
.battery-trend-section.battery-trend-section--ok,
.page-section.page-section--ok {
  border-top: 3px solid var(--color-status-ok);
}

.battery-trend-section.battery-trend-section--warn,
.page-section.page-section--warn {
  border-top: 3px solid var(--color-status-warn);
}

.battery-trend-section.battery-trend-section--error,
.page-section.page-section--error {
  border-top: 3px solid var(--color-status-error);
}

/* CURRENT CONTRACT (06.6.4.1.1, D-09 — the third and final state of this
 * round trip): nested card titles render sans-semibold at the Body size
 * (16px) — the bottom rung of a real three-tier ladder: 32px semibold
 * `.page-title` / 22px regular `.text-heading` / 16px semibold sans here.
 * This is quick task 260901-uzi's own finding-4 demotion, re-applied
 * deliberately after quick task 260902-iag reverted it. The full history
 * below (both SUPERSEDED blocks) is kept because this looks like a
 * flip-flop and isn't one — read it before touching this rule again.
 *
 * Why re-applying the same change is not undoing the same reversal:
 * 260901-uzi demoted this ONE tier in isolation, against a 20px section
 * heading it then matched too closely once the page-title tier above it
 * hadn't itself moved — the developer's own screenshot comparison against
 * Settings' 20px `Runway` heading found that isolated demotion wrong, and
 * 260902-iag reverted it. Here it lands as the deliberately-built bottom
 * rung of the full audit ladder (06.6.4.1.1 D-10 grew the page title to
 * 32px semibold and kept the section heading at a *regular*-weight 22px
 * specifically so a semibold card title below it would read as a third
 * tier, not a repeat) — validated on a full Health composition in sketch
 * 005 (variant B), not eyeballed in isolation. The developer confirmed
 * this explicitly, aware of the prior reversal (CONTEXT D-09). This is
 * the third state of the round trip, not a mistake to second-guess.
 *
 * SUPERSEDED (quick task 260902-iag, reverting 260901-uzi finding 4):
 * this rule used to carry exactly one declaration, the bottom margin,
 * and stated no typography at all — a nested card heading was
 * deliberately the plain `.text-heading` treatment every other section
 * heading in the app got, on the theory that the card boundary itself
 * (the `--color-border` hairline, the `--color-dominant` surface, the
 * 16px padding `.page-section`/`.battery-trend-section` declare) already
 * signalled "this heading and its content are one grouped unit", the
 * same signal every Settings group relies on. That reasoning held only
 * as long as the section heading above it was itself undifferentiated
 * from the page title — once D-10 gave the page title its own semibold
 * weight, a regular-weight section heading and a regular-weight nested
 * title were free to look identical again without a real hierarchy
 * problem resurfacing, which is what let 260901-uzi's original demotion
 * be revisited rather than re-litigated from scratch.
 *
 * SUPERSEDED (quick task 260901-uzi finding 4, itself later reverted by
 * 260902-iag above before being re-applied by this rule): the original
 * demotion from 20px serif regular to 16px semibold (the Emphasis role,
 * `.stat-tile__value`'s own tier). Reasoning at the time: Health rendered
 * two structural levels — the section headings ("Screen", "Server &
 * data") and the cards nested inside them ("Battery trend", "Unresolved
 * prefixes", "Resolution statistics") — at one identical 20px tier, and a
 * card title subordinate to its own section heading was rendering as that
 * heading's equal.
 *
 * Not taken, named here so a later edit has to argue with a written
 * decision rather than discover an apparent omission: the validated
 * Merged Health Sketch's own `.wide-card__caption` card-title role, a
 * 14px muted uppercase eyebrow label — a genuine third answer to this
 * hierarchy question this app has never shipped. Declined again here for
 * the same reason it was declined before: the ladder already has a
 * distinct label voice (D-13, `.stat-tile__caption` et al.) and giving
 * the card-title tier its own separate uppercase treatment would create
 * a second, competing label-like voice instead of reusing the existing
 * Emphasis role.
 *
 * The margin-bottom declaration is UNCHANGED across all three states of
 * this round trip (quick task 260902-bl2, bug 2): 16px (`--space-md`),
 * independently justified — a deterministic heading-to-content gap,
 * unrelated to the font-size/weight question above. The two cards
 * migrated by 06.6.4.1-04 measured wildly different heading-to-content
 * gaps in a real browser, and the cause was that nothing in this file
 * had ever governed that gap — it was whatever the composition of the
 * heading-rhythm rule's own `margin: 0 0 var(--space-sm)` and an unstated
 * UA paragraph margin happened to produce. The value comes from the
 * validated sketch's own card-title role (`.wide-card__caption`), which
 * declares the same medium space below every card title unconditionally,
 * in both cards — this is that declaration, not a middle value chosen to
 * split the difference. Longhand only: the heading-rhythm rule above
 * still owns the zeroed top and sides, and a `margin` shorthand here
 * would restate three values it does not mean to change. Paired with the
 * `> p.text-body` rhythm rule immediately below, the heading now owns
 * the whole gap and no following element contributes any of its own, so
 * the gap is the same 16px regardless of whether a paragraph, a table
 * wrapper, a filter bar or an empty state comes next.
 *
 * `letter-spacing: normal` clears the shared serif rule's `-0.01em`
 * tracking, which is tuned for large serif text and reads wrong applied
 * to 16px sans. There is no icon in this heading at any point in this
 * history to worry about sizing against — quick task 260902-j8w removed
 * the one glyph that ever lived inside a Health `<h2>` (the battery
 * icon), independently of and before either reversal above.
 *
 * 20-04-PLAN.md Task 1 (D-12, 20-UI-SPEC.md §C): `.theme-status--nested
 * > h2` joins this selector list as a one-line, additive parity fix —
 * `theme_fieldset()`/`runway_fieldset()`/`display_group()`/
 * `quiet_hours_group()` wrap in `.theme-status`, not `.page-section`, so
 * a group card placed under a Display supersection needed this
 * extension to render its own `<h2>` at this same 16px sans-semibold
 * bottom rung instead of the 22px serif tier. No `.theme-status` wrapper
 * is converted to `.page-section` — 20-UI-SPEC.md §C explicitly rejects
 * that alternative for its blast radius (every existing test asserting
 * `.theme-status` markup). Runway/Theme/LED on Device keep their
 * current, un-nested 22px-serif heading treatment — this selector only
 * ever matches a `.theme-status` that also carries `--nested`.
 *
 * SUPERSEDED by 28-04-PLAN.md (CFG-72), CARRIED IN by 28-08-PLAN.md
 * Task 2 (this plan edits zero CSS of its own for CFG-72, but is the
 * last writer of this file in the phase and therefore the only plan
 * that CAN correct a sentence this file's own later work falsified):
 * "Runway/Theme/LED on Device keep their current, un-nested 22px-serif
 * heading treatment" is now FALSE. 28-04 wrapped every Device settings
 * card with the nested modifier (CFG-72's title-consistency fix), so
 * those headings now DO render through this same rule, at this same
 * 16px sans-semibold bottom rung — the rule below needed NO edit for
 * that to become true, which is exactly why 28-04 itself touched no
 * CSS: this selector was already correct, sitting there waiting for a
 * consumer that hadn't arrived yet. */
.page-section--nested > h2,
.battery-trend-section > h2,
.theme-status--nested > h2 {
  font-family: var(--font-ui);
  font-size: var(--font-body-size);
  font-weight: var(--weight-semibold);
  letter-spacing: normal;
  margin-bottom: var(--space-md);
}

/* quick task 260902-bl2 (bug 2): direct-child prose immediately under one
 * of the three nested-card headings above owns the sketch's own
 * intervening-prose rhythm — the same `.read-only-note` role the
 * validated sketch declares (zero above, the small space below) — rather
 * than falling through to the UA `margin-block: 1em` this file has never
 * stated for `.text-body` (see that rule's own comment: no margin is a
 * deliberate decision there, not an omission, because Settings' own
 * `.section-caption` role relies on the same UA default in a different
 * context; the zeroing belongs here, on the container where these three
 * cards' own layout is owned, not inside `.text-body` itself — the same
 * precedent `.section-intro > p` sets a few hundred lines above this one,
 * for the same reason, at a different structural position).
 *
 * Both the child combinator and the `.text-body` qualifier are
 * load-bearing. The child combinator keeps this off paragraphs inside a
 * nested `.empty-state` (not siblings of any heading, so unreachable
 * through it regardless). The qualifier keeps it off `.battery-readout`
 * — a direct-child `<p>` of `.battery-trend-section` that carries no
 * `text-body` class and already declares its own `margin: 0 0
 * var(--space-sm)` above; a bare `> p` selector would silently take that
 * ownership away.
 *
 * No catch-all is added for "whatever follows the heading" — deliberately
 * not needed: every other element type that can sit directly under one
 * of these three headings (the table wrapper, the filter bar, the empty
 * state, the readout, the chart, the disclosure) already carries no top
 * margin of its own. A markup check in test_status_pages.py pins the
 * allowlist of element types instead, so a future element carrying a UA
 * top margin fails a test rather than silently reintroducing the
 * inconsistency this task fixes. */
.page-section--nested > p.text-body,
.battery-trend-section > p.text-body {
  margin: 0 0 var(--space-sm);
}

/* health_page.py's source-fault block combines page-section with banner
 * banner--anomaly. .page-section's `border` shorthand above resets all
 * four sides, including the left edge banner--anomaly needs error-
 * coloured — at equal (0,1,0) specificity the later rule wins regardless
 * of which one "means" to own that edge. This combined selector (0,2,0)
 * restores that left border deterministically, independent of source
 * order.
 *
 * The width is restored here too, not just the colour, and narrowed to
 * 3px in 06.6.4 (D-04a) to match the base .banner rule's new edge
 * width. The `border` shorthand resets border-left-WIDTH as well, and
 * only the colour was ever put back — so without this restatement the
 * source-fault block, the single most severe state this page can show,
 * would draw a 1px alert edge while every other anomaly banner draws a
 * 3px edge. One signal, two widths, decided by which rule happened to
 * sit later in the file. Both properties stay restated together; they
 * must stay together.
 *
 * The background is restated here for the same cascade reason: this
 * block is both a card and a banner, and .page-section's own
 * `background: var(--color-dominant)` above sits later in the file than
 * .banner--anomaly's own rule, so at equal (0,1,0) specificity the
 * plain card rule would win regardless of source order without this
 * (0,2,0) override. 06.6.4 (D-04) retired the severity wash in favour
 * of the same --color-dominant card surface every banner now uses, so
 * this restatement no longer settles a colour conflict — it only
 * guarantees the block cannot silently drift onto a different surface
 * than the banners it shares a severity language with, should a future
 * edit change .page-section's own background and forget this sibling.
 *
 * Two opt-outs, both new in 06.6.4 (D-04a): Task 1 made the base
 * .banner rule a centred flex row with a `::before` status dot, and
 * both reach this selector by inheritance. The source-fault block
 * contains a heading and several stacked paragraphs (see
 * companion/pages/health_page.py's source-fault section), not a single
 * line of banner text — without `display: block` it would reflow into
 * one horizontal row with the dot as its first flex item, and the
 * block already carries its own heading plus this 3px error edge, so
 * it needs no dot at all. Both are scoped exceptions that preserve
 * D-04a's "container treatment only, never banner content" boundary —
 * the block's own content is untouched, only two inherited container
 * properties are turned back off for this one combined selector. */
.page-section.banner--anomaly {
  display: block;
  border-left: 3px solid var(--color-status-error);
  background: var(--color-dominant);
}

.page-section.banner--anomaly::before {
  display: none;
}

/* Airlines illustration gallery (D-14, 06.6.4.1-UI-SPEC.md §7.1). This
 * file's own responsive-grid idiom: an auto-fill grid with a 200px
 * column floor and the roomier lg gap, collapsing to fewer columns
 * automatically on mobile widths with no extra breakpoints needed. Each
 * tile carries a name and a chip row in addition to its image and
 * caption. */
.illustration-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: var(--space-lg);
}

/* X7 (22-11-PLAN.md Task 2, 22-UI-SPEC.md §2): exactly two cards per row
 * on a phone. The auto-fill idiom above is the right default on a wide
 * screen but collapses to ONE column inside a 342px content column
 * (342 < 2 x 200 + 24), which is what made this page 5800px tall at
 * 390px — one airline per screenful. A FIXED two-column template below
 * 960px is therefore not a tuning of the floor but a different rule:
 * `minmax(0, 1fr)` (never `1fr` alone) is what lets a column shrink
 * below its content's intrinsic width, which is what a 450x132
 * illustration inside it needs.
 *
 * At 390px: (342 - 24) / 2 = 159px per card, and the served 450x132
 * frame scales to about 159x47 — still legible as a livery swatch,
 * which is the whole job of a card in this grid. The page height
 * roughly halves. */
@media (max-width: 959.98px) {
  .illustration-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

/* Quick task 260922-jiy: separate the Compagnies gallery from the
 * "Compagnies non identifiees" gap-strip card below it, spending the
 * same D-14/UIR-24 sibling-card gap (--space-lg) every other stacked
 * pair in this app already uses. This is declared as a top margin on
 * the FOLLOWING .page-section, not a bottom margin on .illustration-grid
 * itself, because .illustration-grid is load-bearing on TWO elements:
 * the outer gallery grid above, and the gap-strip's own inner grid
 * (which wears illustration-grid--gap alongside it, see that rule's
 * comment). A margin on the shared base class would also push the
 * strip's own unresolved-callsign cards away from the overflow line
 * below them, which is not wanted. The adjacent-sibling selector is
 * what confines the gap to the outer gallery: the inner grid is always
 * followed by a <p>, never by a .page-section. */
.illustration-grid + .page-section {
  margin-top: var(--space-lg);
}

/* The 06.6.4 D-03 hairline-at-rest/shadow-on-hover card contract,
 * verbatim — do not "restore" a resting shadow on this component. */
.airline-card {
  background: var(--color-dominant);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  box-shadow: none;
  padding: var(--space-sm);
}

/* Phase 14 (14-03-PLAN.md Task 2, D-12): .airline-card was always a
 * <div> before this phase; a gap card's whole outer element is now a
 * real <a> and needs the block layout + link-reset a <div> got for
 * free. Do not touch the :hover/:focus-within rule below — an <a>
 * receiving focus already matches :focus-within per spec. */
a.airline-card {
  display: block;
  color: inherit;
  text-decoration: none;
}

.airline-card:hover,
.airline-card:focus-within {
  border-color: transparent;
  box-shadow: var(--shadow-card-hover);
}

/* 260902-req-02: the ratio matches companion/illustration_normalize.py's
 * ILLUSTRATION_TARGET_SIZE exactly — every served illustration is already
 * normalized to that pixel size, so this reserves the same box for a
 * browser that ignores the <img>'s own width/height attributes.
 * object-fit: contain (not cover) mirrors the normalization's own
 * fit-inside discipline: nothing here may ever crop an aircraft.
 *
 * quick task 260902-v2v (UIR-07): `height: auto` is load-bearing, not
 * decorative. airlines_page.py's `<img width="..." height="...">`
 * attributes map to presentational height; with both dimensions already
 * resolved from the attributes, `aspect-ratio` above is ignored
 * entirely and every card rendered the full box-height regardless of
 * the card's actual (narrower) width — the letterboxing this fixes.
 * `height: auto` un-resolves the height so `aspect-ratio` can compute it
 * from the rendered width instead. The width/height attributes on the
 * <img> element itself must stay: they are still what reserves the
 * correct box for `loading="lazy"` before the image itself loads.
 *
 * quick task 260904-e92 (UIR-08): the frame was halved (900x263 ->
 * 450x132) to cut served bytes — see illustration_normalize.py's own
 * comment for the full ratio-derivation reasoning. `height: auto`
 * remains load-bearing for exactly the same reason stated above: it is
 * independent of which pixel pair the aspect-ratio declaration names. */
.airline-card__image {
  width: 100%;
  height: auto;
  aspect-ratio: 450 / 132;
  object-fit: contain;
  display: block;
  border-radius: var(--radius-control);
  margin-bottom: var(--space-sm);
}

/* Phase 14 (14-03-PLAN.md Task 2, D-02): the gap card's reserved-box
 * placeholder — a dashed border (distinct from every solid-bordered
 * card, reads as a *slot*, not damaged art) and a var(--color-canvas)
 * fill one level down inside this card's own var(--color-dominant)
 * surface, so it reads as a hole punched in the card. aspect-ratio and
 * margin-bottom are string-identical to .airline-card__image's above,
 * so grid rows stay aligned whether a card holds art or a gap. No new
 * token, no accent use. aria-hidden="true" and empty in the markup —
 * the card's own visible, announced callsign text is the real content. */
/* 22-07-PLAN.md Task 2 (B18) note: home_page.py's own recent-flight
 * thumbnail placeholder (`.recent-flight__thumb--placeholder`, defined
 * further down this file) deliberately does NOT join this selector as
 * a shared list — companion/test_status_pages.py (owned by a sibling
 * plan this wave, not editable here) pins the literal standalone
 * selector text ".airline-card__placeholder {" as its own complete
 * rule body carrying all five of this rule's declarations. Its dashed
 * border/radius/canvas-fill values are instead reused BY VALUE (the
 * exact same var() tokens, no new literal) on that rule's own
 * definition, matching this rule's own comment below ("copied verbatim
 * rather than inventing a second 'no image' treatment") — the real fix
 * for the B18 weight mismatch is giving the REAL thumbnail this same
 * treatment (see img.recent-flight__thumb below), not touching this
 * rule. */
.airline-card__placeholder {
  aspect-ratio: 450 / 132;
  border: 1px dashed var(--color-border);
  border-radius: var(--radius-control);
  background: var(--color-canvas);
  margin-bottom: var(--space-sm);
  display: block;
}

/* quick task 260902-tli: the click-to-enlarge trigger wrapping
 * .airline-card__image above. Block-level, full-width and height: auto
 * so it exactly cages the image it wraps; zero padding/border/
 * background so the base `button` rule's 30px-tall/12px-padded/
 * bordered box model contributes nothing to this control's shape — the
 * wrapped image alone determines it. */
.airline-card__zoom {
  display: block;
  width: 100%;
  height: auto;
  padding: 0;
  border: none;
  background: none;
  border-radius: var(--radius-control);
  cursor: zoom-in;
}

/* Both rules below are load-bearing, not redundant: the base
 * `button:hover`/`button:active` rules above are (0,1,1)-specificity
 * (one pseudo-class, one type selector) and would otherwise still apply
 * here, re-painting a background/border wash and a 1px press-depress
 * onto a control that must stay visually inert except for the zoom
 * cursor. `.airline-card__zoom:hover`/`:active` match or exceed that
 * specificity and sit later in source order, so these neutralizers win.
 * The press-depress in particular is deliberately removed, not just
 * muted: a full-width image nudging 1px against the card's own border
 * reads as a layout glitch, not a button press. This control keeps the
 * global :focus-visible outline — that is not touched here. */
.airline-card__zoom:hover {
  background: none;
  border-color: transparent;
}

.airline-card__zoom:active {
  transform: none;
}

/* quick task 260902-tli: an earlier version of this feature disabled the
 * zoom trigger's pointer path on narrow portrait viewports via a
 * `@media (max-width: 959px) and (orientation: portrait) { pointer-
 * events: none; }` block — a misreading of the developer's own request.
 * "En mode paysage" meant the ENLARGED VIEW should present the wide
 * illustration in a landscape-style layout (which `.lightbox--wide`'s
 * own sizing below already does, regardless of the phone's physical
 * rotation), not that the click itself should be gated behind the
 * device's reported orientation. Removed outright — corrected on the
 * same live developer test that first exercised it, before that
 * (wrong) gate ever shipped to a real device. The trigger now works
 * unconditionally at every viewport size, matching "desktop always
 * works" and extending it to "phone always works" too. */

/* Emphasis role, not a new type role. */
.airline-card__name {
  font-size: var(--font-body-size);
  font-weight: var(--weight-semibold);
  margin: 0 0 var(--space-xs);
}

.airline-card__chips {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs);
}

/* Matches .data-table th's quiet-label tier above (not a fifth content
 * type role). Neutral-tinted, not accent-tinted, so this file's own
 * header-comment accent-reservation list stays exhaustive and unchanged.
 * 06.6.4.1.1 (D-13): 11px -> 12px, gains the uppercase/semibold/tracking
 * declarations that complete the one unified label voice — see
 * .stat-tile__caption's own comment for the full contract. */
.airline-card__chip {
  display: inline-flex;
  align-items: center;
  height: 20px;
  padding: 0 8px;
  border-radius: 999px;
  font-size: 12px;
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  background: color-mix(in srgb, var(--color-text) 6%, transparent);
  border: 1px solid color-mix(in srgb, var(--color-text) 12%, transparent);
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* Phase 06.5 (D-02): interactive battery-trend chart hit targets and
 * readout line. */

/* quick task 260902-ep7 (BUG 4): the chart's outer wrapper —
 * health_page.py's battery_sparkline_svg() returns this grid, not a bare
 * <svg>, now. The `auto` first column is the entire point: the browser
 * measures the REAL rendered width of the widest Y-axis label in
 * .sparkline__y below and reserves exactly that — this is what replaces
 * _AXIS_LEFT_GUTTER, a Python-side ESTIMATE of that same width, deleted
 * along with _AXIS_BOTTOM_STRIP in health_page.py. The second row (the
 * X-axis label row, below the canvas) does the same job for the bottom
 * strip, sized by its own content height rather than a second estimate. */
.sparkline {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: var(--space-sm);
  row-gap: var(--space-xs);
}

/* Column 1, row 1 (explicit — the canvas and the X-label row below are
 * auto-placed into the remaining cells; see .sparkline's own comment and
 * .sparkline__x's below). A flex column stretched to the grid row's own
 * height (grid's default `align-items: stretch`, which for row 1 means
 * this element's height equals the canvas's own declared height, in
 * .battery-trend-section svg:not(.icon)) with justify-content:
 * space-between, so document order (max label, then min label — see
 * battery_sparkline_svg()) places max at the top and min at the bottom,
 * each one's optical centre landing on the level it names by the same
 * half-line-box arithmetic health_page.py's _SPARKLINE_VERTICAL_INSET_
 * PERCENT comment derives. */
.sparkline__y {
  grid-column: 1;
  grid-row: 1;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: flex-end;
}

/* Column 2 explicit; the grid auto-places this into row 2, the only open
 * cell left in that column once the canvas (auto-placed into row 1,
 * column 2 — the sole remaining cell once .sparkline__y claims column 1)
 * occupies row 1. A flex row with the same space-between as .sparkline__y,
 * so document order (oldest label, then newest — see
 * battery_sparkline_svg()) places oldest on the left, newest on the
 * right, aligned under their own X ticks. */
.sparkline__x {
  grid-column: 2;
  display: flex;
  justify-content: space-between;
}

/* THE AREA UNDER THE TREND LINE (CFG-41/CFG-45, 24-05-PLAN.md Task 1).
 * `currentColor` at a reduced `fill-opacity` is the whole mechanism and
 * the reason there is no second colour here: the area takes the LINE's
 * own resolved colour (`.sparkline-line` below is `stroke: currentColor`
 * against the same inherited `color`), so light and dark mode are
 * correct by exactly the mechanism the line already is, and a palette
 * change moves both together or neither.
 *
 * NOT a <linearGradient>. A gradient would be the nicer fade and it can
 * only be referenced as `fill="url(#id)"` — health_page.py's
 * `battery_sparkline_svg()` carries a standing guarantee, asserted
 * against its own return value, that it emits no `url(` at all (D-09,
 * no external reference of any kind). A flat translucent fill keeps that
 * guarantee unweakened; a gradient would have required relaxing a
 * security-shaped assertion for decoration.
 *
 * 0.14 is the measured floor, not a taste value: it is the lowest
 * opacity at which the composited area separates from the card surface
 * by more than the ~2% luminance difference the eye stops resolving at
 * this size in BOTH themes (dark mode is the binding side — the card is
 * nearly black and the ink nearly white, so the same alpha buys less
 * separation there). companion/test_browser_ux.py measures the
 * composited result against the card's own resolved background rather
 * than trusting this number.
 *
 * No `stroke` declaration, deliberately: the SVG initial value is
 * already `none`, nothing in this file sets a `stroke` on an ancestor
 * for it to inherit, and a declaration that changes nothing is a comment
 * pretending to be code. The area's edge IS `.sparkline-line`, drawn
 * over it. */
.sparkline-area {
  fill: currentColor;
  fill-opacity: 0.14;
}

/* The trend line: one <line> per consecutive pair of points (percentages
 * are not permitted inside a <polyline>'s points list, which is why this
 * is n - 1 line segments rather than one polyline). stroke-linecap:
 * round makes consecutive segments' joins read as one continuous line
 * rather than mitred gaps. */
.sparkline-line {
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
  fill: none;
}

/* Drawn axis lines and ticks (quick task 260902-ep7, BUG 4 — 06.6.4.1-
 * UI-SPEC.md §5.3's "no tick marks or axis lines are required" was a
 * stated FLOOR, not a prohibition; this is a permitted extension of it,
 * and that section is updated to say so). `--color-border` is this
 * file's existing "structural only, never an interactive-state signal"
 * token (file header comment) — precisely what a chart axis is, so no
 * new colour value is introduced. If this proves too faint against the
 * card surface in the live-browser pass, the recorded fallback is this
 * file's existing `color-mix(in srgb, var(--color-text) 12%, transparent)`
 * edge value (already used by .banner__pill and .refresh-pill), not a
 * newly invented one. */
.sparkline-axis {
  fill: var(--color-border);
}

/* Cosmetic marker only — never receives pointer events, or it would steal
 * them from the larger .sparkline-hit target painted at the same
 * coordinates. */
.sparkline-dot {
  fill: currentColor;
  pointer-events: none;
}

/* THE CURRENT READING'S MARK (CFG-41, 24-05-PLAN.md Task 2): the newest
 * plotted point, drawn instead of — never as well as — its cosmetic dot.
 * `currentColor` for the same reason `.sparkline-dot` above uses it: the
 * mark is the same ink as the line it terminates, one step larger, so it
 * reads as "this is where the series is now" rather than as a second
 * colour with its own meaning. No status token here on purpose: the ring
 * gauge beside this chart already carries the ok/warn/error verdict about
 * the battery, and a chart point that also judged it would be a second
 * opinion about one number.
 *
 * NEITHER `r` NOR `pointer-events` IS DECLARED, and both omissions are
 * mechanism. `r` is emitted as a presentation attribute from
 * health_page.py's own _SPARKLINE_MARK_RADIUS_PX (a CSS declaration of
 * any specificity would beat it, which is the trap `.drawing-ring-track`
 * records at length). `pointer-events: none` is `.sparkline-dot`'s, and
 * it is not repeated here because it is INHERITED: the value set on a
 * sibling does not reach this element, but the mark sits at the same
 * coordinates as its own `.sparkline-hit` target, which is emitted after
 * it and therefore paints — and hit-tests — on top. Measured, not
 * assumed: hovering the last point still resolves to the hit target. */
.sparkline-mark {
  fill: currentColor;
  pointer-events: none;
}

/* THE DRAWN LOW-BATTERY THRESHOLD (CFG-41, T-24-05-B). A filled rect,
 * like the axis chrome above and for the same stroke-centring reason,
 * but deliberately NOT `.sparkline-axis`: the axis is structure and
 * spends `--color-border`, this file's "structural only, never an
 * interactive-state signal" token. A threshold is a judgement about the
 * device, so it spends the app's existing status-warn token instead —
 * already defined for both themes and already pinned against the card
 * surface as a UI component by companion/contrast_check.py's
 * STATUS_WARN_ON_CARD_PAIRS, so no new colour pair is introduced. Accent
 * stays reserved (this file's header comment lists its five uses; a
 * chart threshold is not one, the same reasoning `.sparkline-hit:hover`
 * records). */
.sparkline-threshold {
  fill: var(--color-status-warn);
}

/* The threshold's label, in its own full-width row at the bottom of the
 * chart's grid. `1 / -1` is load-bearing rather than tidy, and measured
 * rather than asserted: without it the browser auto-places this into
 * column 1 — the `auto` column sized to the widest Y-axis label — and
 * that column grows to fit a whole sentence instead. Measured at 360px
 * with `grid-column: auto`, the canvas drops from 229.97px to roughly
 * 109px inside the same 278px grid, with no overflow and no other
 * visible signal that anything broke. companion/test_browser_ux.py
 * measures the canvas's share of the grid for exactly that reason; the
 * first version of that check did not, and this declaration could be
 * deleted with every assertion still green. */
.sparkline__legend {
  grid-column: 1 / -1;
}

/* `inline-flex` is what makes the swatch exist at all: a bare inline
 * <span> ignores width and height, so the swatch would compute to a
 * zero-sized box and the legend would describe a colour it never showed.
 * Measured in companion/test_browser_ux.py rather than trusted —
 * `display: inline` here takes the swatch to 0.00x11.00.
 *
 * A `flex: none` on the swatch was written here first, on the reasoning
 * that a flex item shrinks when its line is tight and French is the
 * tight case. It was REMOVED after measuring: at 360px the legend's line
 * is 161px inside a 278px row in both languages, so there is no
 * overflow to shrink against and the declaration changed nothing in
 * either. A property whose comment claims to prevent something it
 * demonstrably does not prevent is worse than no property, so it is
 * gone. If the copy ever grows past its row, the assertion that catches
 * it is already in place — the swatch's measured box.
 *
 * The type is not a new tier: 10px / line-height 1.2 / the 70% text mix
 * are exactly `.sparkline-axis-label`'s own values, restated here (the
 * same way `.drawing-axis-label` restates them) because this label sits
 * in the same micro-label tier beside them. A chart label at a different
 * size would be a new type tier and needs an argument, not a value. */
.sparkline-legend {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  font-size: 10px;
  font-weight: var(--weight-regular);
  line-height: 1.2;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* The same token as `.sparkline-threshold` above, and the same 1px
 * height as the rect that is drawn — the swatch IS the line, shown at
 * legend size, so the legend cannot come to describe a colour or a
 * weight the chart does not use. */
.sparkline-swatch {
  width: 12px;
  height: 1px;
  background: var(--color-status-warn);
}

/* A transparent fill is still opaque for SVG hit-testing, which is exactly
 * why this enlarged, invisible circle works as the real tap/click target
 * (06.5-RESEARCH.md Pattern 2). */
.sparkline-hit {
  fill: transparent;
  cursor: pointer;
}

/* Deliberately var(--color-text), not var(--color-accent): this file's
 * header comment reserves accent to exactly five uses, and a chart-point
 * highlight is not one of them. */
.sparkline-hit:hover,
.sparkline-hit--active {
  fill: var(--color-text);
  fill-opacity: 0.15;
}

/* This one does use accent, because it is the header's already-listed
 * "focused-input outline" use — not a violation of the reservation above. */
.sparkline-hit:focus-visible {
  outline: 2px solid var(--color-accent);
  outline-offset: 1px;
}

/* Battery-chart axis labels (D-09, §5.3). quick task 260902-ep7 (BUG 4):
 * these are now HTML <span> elements inside the .sparkline__y/.sparkline__x
 * grid rows above, not SVG `text` nodes — so `color` sets the visible
 * ink now, not `fill` (was `fill` before this task; the same property
 * rename health_page.py's own axis-label markup already made). 10px
 * reuses this file's already-documented sub-scale exception tier
 * (.data-table th at 12px, after 06.6.4.1.1 D-13 moved it up from 11px;
 * .cell-secondary separately at 13px, after 06.6.4.1.1 D-18a moved IT up
 * from 12px), one step further down because chart-axis micro-labels sit
 * below secondary table text in the hierarchy — explicitly not a fifth
 * content type role.
 * `line-height: 1.2` is added because health_page.py's
 * _SPARKLINE_VERTICAL_INSET_PERCENT arithmetic depends on this being a
 * known number (half the label's line box), not an inherited value. */
.sparkline-axis-label {
  font-size: 10px;
  font-weight: var(--weight-regular);
  line-height: 1.2;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* Phase 24 (CFG-39, 24-01-PLAN.md Task 3): the SHARED drawing
 * vocabulary. Four new server-rendered SVG drawings arrive across this
 * phase's later plans — a battery ring gauge, a battery area chart, a
 * day timeline and a check-in regularity grid — and every one of them
 * paints through the classes below, emitted from companion/draw.py's own
 * named class constants. A check in companion/test_companion_app.py
 * asserts that every class that module can emit resolves to a selector
 * here, because a class that exists in Python and nowhere in CSS paints
 * nothing at all, and nothing else in this codebase would notice.
 *
 * Placed HERE, beside the .sparkline* block above rather than at the end
 * of the file, because that block is this idiom's existing consumer and
 * has been since 06.5. It is deliberately left untouched by this plan —
 * plan 24-05 owns the battery chart's own appearance. A FIFTH
 * near-identical block is the specific failure this one exists to
 * prevent: five drawings, five ways for a shape to end up invisible in
 * one theme.
 *
 * The paint idiom, copied rather than re-derived: currentColor plus a
 * theme token. The SVG inherits `color` from its container, the
 * container's colour is a token, and the drawing is correct in BOTH
 * themes with no second rule and no media query. A literal colour value
 * would be correct in one theme only — a defect, not a polish item —
 * and it would be invisible to companion/test_contrast_check.py
 * besides, which is why companion/draw.py refuses to emit one at all.
 *
 * This block introduces no colour value and no colour pair. Structural
 * ink is --color-border, this file's existing "structural only, never an
 * interactive-state signal" token, the same one .sparkline-axis uses;
 * status colouring uses the three --color-status-* tokens already
 * defined for both themes. It introduces no accent use either, and this
 * phase does not broaden the header comment's reservation list: a chart
 * mark is not on that list, the same reasoning .sparkline-hit:hover
 * above already records for a chart-point highlight.
 *
 * It also declares no transition and no animation. This phase's drawings
 * do not move; the motion budget stays exactly where phase 23 left it. */

/* The label grid: an auto-sized label column beside the canvas, a label
 * row beneath it. This is the mechanism that keeps a time series' labels
 * OUTSIDE the SVG, which makes viewBox overflow unreachable for those
 * drawings rather than merely avoided, and keeps every label at a
 * constant CSS size instead of shrinking with the box.
 *
 * `minmax(0, 1fr)` on the canvas column is load-bearing, not decoration:
 * a plain `1fr` takes its content's min-content width as a floor, so the
 * canvas could not shrink at the 360px minimum viewport and the page
 * body would grow the horizontal scrollbar CFG-45 forbids. Same columns,
 * same gaps as .sparkline above, copied on purpose so a second drawing
 * never invents a third grid. */
.drawing {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: var(--space-sm);
  row-gap: var(--space-xs);
}

/* Column 1, row 1 explicit; the canvas and the x-label row auto-place
 * into the two cells left open, exactly as .sparkline__y/.sparkline__x
 * do. A flex column with space-between, so document order top-to-bottom
 * places the first label at the top of the canvas and the last at the
 * bottom, each one's optical centre on the level it names — which is
 * what the caller's own vertical inset arithmetic (draw.percent_y()'s
 * `inset_percent`) is derived from. */
.drawing__y {
  grid-column: 1;
  grid-row: 1;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: flex-end;
}

/* Column 2 explicit; auto-placed into row 2, the only cell left in that
 * column. A flex row with the same space-between, so document order
 * left-to-right places oldest under the left edge and newest under the
 * right. */
.drawing__x {
  grid-column: 2;
  display: flex;
  justify-content: space-between;
}

/* THE SIZE ROUTE, and the reason every drawing must have one:
 * companion/layout.py's icon_html() docstring records the trap that an
 * <svg> carrying neither a size attribute nor a CSS rule renders at the
 * SVG default 300x150 and blows the layout apart. This is the
 * percentage-scheme half of the answer — a canvas with no viewBox, sized
 * from CSS. The viewBox half is .drawing__figure below.
 *
 * The height is a custom property with a default rather than a bare
 * length, so a drawing that needs a different height sets it on its own
 * wrapper instead of adding a second rule that would then have to win a
 * specificity fight with this one. Every percentage y-coordinate a
 * drawing emits is a percentage OF this height, which is why it is
 * declared once, here, and must never be varied inside a media query: a
 * responsive height would silently move every point on the drawing with
 * no other visual signal that anything broke. That hazard is the same
 * one `.battery-trend-section svg:not(.icon)` above carries, stated
 * again because this rule now carries it for four more drawings.
 *
 * `overflow: visible` lets an axis tick poke outside the canvas into the
 * grid's own column gap, which is where .sparkline's y ticks already
 * live. */
.drawing__canvas {
  display: block;
  width: 100%;
  height: var(--drawing-canvas-height, 160px);
  overflow: visible;
}

/* The viewBox half of the size route, for an intrinsically aspect-locked
 * mark (a ring gauge, a grid cell). It deliberately sets NO width and NO
 * height: draw.unit_canvas() emits intrinsic width/height attributes in
 * CSS pixels alongside the viewBox, and a `width: 100%` here would
 * override them, stretch the box and letterbox the aspect-locked shape
 * inside it. `display: block` is the whole rule — an inline <svg>
 * otherwise sits on the text baseline and reserves a descender's worth
 * of space beneath itself. The two canvas classes are separate for the
 * same reason draw.py's two coordinate schemes are two separately-named
 * helper families: one class with a modifier is how the two get mixed
 * inside one drawing. */
.drawing__figure {
  display: block;
}

/* Structural ink: axis lines and ticks, drawn as filled rects rather
 * than stroked lines. An axis-aligned integer-width filled rect has no
 * stroke-centring and no half-pixel rounding to reason about, and a rect
 * can pair a percentage position with an absolute size, which no stroked
 * line can and which the percentage scheme needs for every tick. */
.drawing-axis {
  fill: var(--color-border);
}

/* A stroked trend segment or arc. `fill: none` is declared explicitly
 * and is not a tidy-up: it is the "deliberately unfilled" half of the
 * rule that every drawn shape must have an explicit fill route, so this
 * shape cannot fall back to the SVG default fill — which is black,
 * correct in one theme and invisible in the other. stroke-linecap: round
 * makes consecutive segments read as one continuous line rather than
 * mitred gaps. */
.drawing-line {
  fill: none;
  stroke: currentColor;
  stroke-width: 2;
  stroke-linecap: round;
}

/* A filled mark: a data point, a tick, a grid cell. currentColor, so it
 * follows whatever colour its container sets — including the three
 * status modifiers below. */
.drawing-mark {
  fill: currentColor;
}

/* An axis label, an HTML <span> outside the canvas rather than an SVG
 * text node, so `color` sets the visible ink and the label stays the
 * same size at every container width. 10px and line-height 1.2 are not
 * new numbers: they are exactly .sparkline-axis-label's, which sits in
 * this file's already-documented sub-scale exception tier and whose
 * line-height the callers' inset arithmetic depends on being a known
 * value rather than an inherited one. A drawing label at a different
 * size would be a new type tier and needs an argument, not a value. */
.drawing-axis-label {
  font-size: 10px;
  font-weight: var(--weight-regular);
  line-height: 1.2;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* Status colouring for a drawing that carries an ok/warn/error verdict.
 * These set `color` rather than `fill`, so every currentColor shape
 * beneath them follows with no per-shape rule — and so a drawing can put
 * the modifier on a group or on a single shape without two different
 * mechanisms. The tokens are the app's existing status tokens, already
 * defined for both themes and already contrast-pinned against the card
 * surface, so no new colour pair is introduced here. */
.drawing--ok {
  color: var(--color-status-ok);
}

.drawing--warn {
  color: var(--color-status-warn);
}

.drawing--error {
  color: var(--color-status-error);
}

/* THE DAY BAND (CFG-42, 24-06-PLAN.md Task 1): the day the frame has
 * had, drawn at its real width — a frame for the whole day, a shaded
 * span for the configured quiet-hours window, and one mark per
 * check-in, all emitted by draw.day_band().
 *
 * THREE RULES FOR THREE KINDS OF STATEMENT, and the split is the
 * drawing's meaning rather than its decoration. The frame is the day
 * itself; the shaded span is a window the device HONOURS; a mark is
 * something that HAPPENED. Paint all three from one token and the band
 * says those are the same kind of fact.
 *
 * THE SHADED SPAN IS STRUCTURAL INK, NOT A STATUS. Quiet hours is a
 * setting working as configured — not a warning, not an error, and
 * certainly not accent (this file's header comment keeps an exhaustive
 * list of accent's reserved uses and a shaded time window is not on
 * it). --color-border is the structural token .drawing-axis and
 * .drawing-ring-track already spend, for the same reason.
 *
 * The frame takes the SAME token at 40%, so the two can never disagree
 * about their hue and the span reads as "more of the same surface"
 * rather than as a second colour. 40 rather than a smaller share
 * because the pair has to stay distinguishable in BOTH themes against
 * the card behind them, which companion/test_browser_ux.py composites
 * and measures rather than asserting from this file. */
.drawing-band {
  fill: color-mix(in srgb, var(--color-border) 40%, transparent);
}

.drawing-band-span {
  fill: var(--color-border);
}

/* A check-in. currentColor, so it is the page's own text ink and moves
 * with the theme by the same route every other drawing's data does —
 * and so it stays legible where it crosses the shaded span, which is
 * where most of a night's check-ins land. */
.drawing-band-mark {
  fill: currentColor;
}

/* The page-level half: the section wrapper Home renders the band
 * inside. Two declarations, and neither is decoration —
 * companion/test_browser_ux.py measures both, because a property that
 * no check can see is a property a later reader trusts instead of
 * measuring.
 *
 * The height override is the whole reason this wrapper exists.
 * .drawing__canvas's height is a custom property with a default
 * precisely so a drawing that needs a different one sets it on its own
 * wrapper instead of adding a second rule that would then have to win a
 * specificity fight. 24px is a band, not a chart: it carries no y axis
 * at all, every mark spans its full height, and at the 160px default
 * the day would be a block. */
.day-band {
  --drawing-canvas-height: 24px;
}

/* The hour labels beneath the band. `space-between` on three
 * equal-width labels puts the first flush left, the last flush right
 * and the middle one centred — which is exactly where the band's own
 * scale puts 00:00, 24:00 and 12:00, so the labels and the marks are
 * placed by one scale and not two. The same idiom, and the same
 * reasoning, as .drawing__x above; it is not reusing that class because
 * this row is not inside .drawing's grid and `grid-column: 2` would be
 * an inert declaration here. */
.day-band__hours {
  display: flex;
  justify-content: space-between;
  margin-top: var(--space-xs);
}

/* THE CHECK-IN REGULARITY GRID (CFG-43, 24-07-PLAN.md Task 1): one cell
 * per day of OBSERVED check-in regularity, emitted by
 * draw.regularity_grid() into Health's Screen section.
 *
 * FOUR RULES FOR FOUR STATES, and the fourth is why this block exists at
 * all instead of reusing .drawing--ok/--warn/--error above. Those three
 * carry a VERDICT about a measured interval. The fourth carries the
 * ABSENCE of one — the record says nothing about that day, and
 * 24-RESEARCH.md Risk 1 is that a log range the ingest lost is
 * indistinguishable from a device that did not wake, so that state must
 * never borrow a verdict's colour. It takes --color-border, this file's
 * structural-ink token (the same one .drawing-axis, .drawing-ring-track
 * and .drawing-band already spend), precisely so it cannot read as a
 * mild warning.
 *
 * EACH MODIFIER SETS `color` AND NOTHING ELSE, which is what lets ONE
 * rule paint two different kinds of element: the SVG cell follows it
 * through .drawing-cell's `fill: currentColor`, and the HTML legend
 * swatch beneath the grid follows the same declaration through its own
 * `background: currentColor`. A legend whose swatches could disagree
 * with the cells they explain is worse than no legend at all, and this
 * is that guarantee expressed as a cascade rather than as a convention.
 *
 * No new colour value and no new token: the three verdicts use the app's
 * existing --color-status-* tokens, already defined for both themes.
 * companion/test_browser_ux.py measures all four RESOLVED colours in
 * both themes and asserts they stay pairwise separated by the app's own
 * MIN_SIGNAL_PERCEPTUAL_DISTANCE — because four states that collapse to
 * three in dark mode is a defect no source scan can see. */
.drawing-cell {
  fill: currentColor;
}

.drawing-cell--on-cadence {
  color: var(--color-status-ok);
}

.drawing-cell--late {
  color: var(--color-status-warn);
}

.drawing-cell--missing {
  color: var(--color-status-error);
}

.drawing-cell--none {
  color: var(--color-border);
}

/* The grid's own wrapper. The two declarations below are the ONLY thing
 * standing between this drawing and a horizontal page scrollbar on a
 * narrow screen, and they are a deliberate, scoped exception to
 * .drawing__figure's own "declare no width" rule — read that rule's
 * comment first, because the exception only holds with BOTH halves.
 *
 * That rule forbids `width: 100%` because it would stretch an
 * aspect-locked shape inside a letterboxed box. `max-width: 100%` paired
 * with `height: auto` does the opposite: the intrinsic attributes still
 * decide the size wherever there is room (the grid is drawn at exactly
 * the 278px a Health card offers at the 360px floor, so this rule is
 * INERT at every supported width), and below that the viewBox scales the
 * whole drawing down uniformly — cells stay square, the grid stays
 * inside its card. Measured: at 320px, out of contract but still
 * measured by this repository, the canvas takes the card's 238px and the
 * page does not scroll sideways; without these two declarations it keeps
 * its 278px and the page does. */
.check-in-grid {
  width: max-content;
  max-width: 100%;
}

.check-in-grid .drawing__figure {
  max-width: 100%;
  height: auto;
}

/* The date labels beneath the grid — the same idiom and the same
 * reasoning as .day-band__hours above, and not reusing that class for
 * the same reason it did not reuse .drawing__x: this row is not inside
 * that grid. `space-between` puts the oldest day's label under the first
 * column and the newest day's under the last, which is where the grid's
 * own scale puts those two cells, because draw.grid_cell_size() leaves
 * the canvas exactly as wide as the card this row measures itself
 * against. One scale, two kinds of element. */
.check-in-grid__scale {
  display: flex;
  justify-content: space-between;
  margin-top: var(--space-xs);
}

/* The key. Four states painted in four colours need their four names in
 * text: colour is the only thing distinguishing the cells from each
 * other, and colour alone is not a reading. It wraps because four
 * labelled swatches do not fit on one 278px line in either language —
 * measured, not assumed. */
.check-in-key {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs) var(--space-md);
  margin-top: var(--space-sm);
}

/* `align-items: center` is deliberately ABSENT, and like the swatch's
 * own missing `flex: none` above this is a measurement. It was declared
 * here; removing it changed no rendered box, because the swatch is 12px
 * tall and .drawing-axis-label's line box is 10px x 1.2 = 12px too, so
 * cross-start and centre are the same place. A check now measures the
 * two centres against each other (it passed with and without the
 * declaration, which is what proved the declaration inert rather than
 * the check blind) — so the day either of those two sizes moves, the
 * alignment becomes visible and that check is what says so. */
.check-in-key__item {
  display: inline-flex;
  gap: var(--space-xs);
}

/* The swatch takes its colour from the SAME .drawing-cell--* modifier
 * the cell it explains does — see this block's own header. `background`
 * rather than `fill` only because this one is an HTML <span> and not an
 * SVG shape; the declaration it follows is the identical one. */
/* `flex: none` is deliberately ABSENT, and its absence is a measurement
 * rather than an oversight. It was declared here first, on the ordinary
 * reasoning that a flex item with a fixed box needs protecting from
 * being shrunk; removing it changed nothing at 360px in either language,
 * because each key item is its own inline-flex box whose content fits,
 * so this swatch is never the thing a flex line has to take width from.
 * A declaration no check can see is a declaration a later reader trusts
 * instead of measuring, so it is gone. The 12px box below IS measured,
 * in both languages, at the 360px floor. */
.check-in-key__swatch {
  width: 12px;
  height: 12px;
  background: currentColor;
}

/* THE RING GAUGE (CFG-40, 24-04-PLAN.md Task 1): a full-circumference
 * track plus a value arc, emitted by draw.ring_gauge() and called at two
 * sizes — large in Health's battery section, small in Home's battery
 * tile. ONE emitter, two call sites; these two rules are its paint and
 * nothing else.
 *
 * NEITHER RULE DECLARES A stroke-width, AND THAT OMISSION IS THE WHOLE
 * MECHANISM rather than a gap somebody should close. draw.ring_gauge()
 * emits stroke-width as a presentation ATTRIBUTE scaled from the ring's
 * own size, and a CSS declaration of any specificity beats a
 * presentation attribute — so a stroke-width here would flatten both
 * sizes to one thickness, make the small ring's stroke proportionally
 * twice as thick as the large one's, and turn the size parameter back
 * into the CSS-only "small variant" CFG-40 exists to forbid. Do not add
 * one. The same goes for `r`, `cx` and `cy`, which are geometry for the
 * same reason.
 *
 * No gradient and no tick marks, here or in the emitter: the reading is
 * a linear estimate printed with an "approximately" marker beside this
 * drawing, and a gauge that implied calibration would out-claim the
 * number it sits next to. No accent either — a battery verdict is not on
 * this file's header comment's reserved accent list, the same reasoning
 * `.sparkline-hit:hover` already records. */

/* The unfilled remainder. --color-border is this file's structural-ink
 * token, the same one .drawing-axis and .sparkline-axis already spend,
 * and it is deliberately NOT currentColor: the track must stay
 * structural when the status modifier on the <svg> turns the value arc
 * amber or rose, or the ring would read as entirely full in a warning
 * state. */
.drawing-ring-track {
  fill: none;
  stroke: var(--color-border);
}

/* The reading. currentColor, so the .drawing--ok/--warn/--error modifier
 * the page sets on the <svg> from the battery_state it ALREADY computed
 * reaches this arc and nothing else — never a second judgement about the
 * same number.
 *
 * `stroke-linecap: butt` is declared explicitly even though it is the
 * SVG initial value, because the comment is the point: a round cap adds
 * half a stroke width of ink at EACH end of the arc, so at the small
 * size a 5% reading would draw roughly 12% of the circle and the picture
 * would disagree with the number printed beside it — which is the exact
 * failure CFG-40 is about, arriving through a line cap. */
.drawing-ring-value {
  fill: none;
  stroke: currentColor;
  stroke-linecap: butt;
}

/* The ring lives INSIDE .battery-trend-section on Health, where
 * `.battery-trend-section svg:not(.icon)` above matches every descendant
 * <svg> and would stretch this aspect-locked figure to 100% width and a
 * flat 160px. That rule is the CHART's size route and is left exactly as
 * it is — plan 24-05 owns the chart, and a harness check in
 * test_status_pages.py pins its single declared height.
 *
 * `width: auto; height: auto` restores draw.unit_canvas()'s own
 * intrinsic width/height attributes rather than reaching the SVG default
 * — measured in Chromium before it was written here (72x72 and 36x36
 * back, not 300x150 and not 150x150), because the reasoning could have
 * gone either way and the 300x150 trap is the one layout.icon_html()'s
 * docstring already records.
 *
 * The `:not(.icon)` filters NOTHING — a ring is never an icon. It is
 * there so this selector carries one more class than the rule it has to
 * outrank, making it win on SPECIFICITY rather than on source order: two
 * equally-specific rules would leave the ring's size depending on which
 * block a later edit happens to move first. */
.battery-trend-section svg.drawing__figure:not(.icon) {
  width: auto;
  height: auto;
}

/* Reserves a fixed-height line so the first reveal never shifts the page
 * (06.5-RESEARCH.md, Open Question 1's recommendation — the layout-jump
 * pitfall on mobile). min-height stays in `em` so it scales with the
 * larger size below on its own, and the no-layout-jump guarantee still
 * holds now that the chart sits below rather than above: the reveal
 * text is fixed-width by construction (a 4-digit millivolt figure plus
 * a fixed-length ISO timestamp, in a monospace face via .mono), so a
 * reveal cannot change where the line wraps and therefore cannot shift
 * the chart now below it.
 *
 * quick task 260901-tsa (finding D): promoted to the Emphasis role —
 * Body size plus semibold — now that health_page.py moves this element
 * ahead of the chart as the section's scannable headline number. This
 * is Emphasis, not the validated sketch's literal 28px: 28px is the
 * Display role, which 06.6.4 (D-09, see the .site-title comment above)
 * retired outright along with --font-display-size when .stat-tile__value
 * and .runway-card__number both moved to Emphasis, restoring this
 * file's claimed four-size scale — matching the mockup literally here
 * would resurrect a deliberately-retired role and re-break that claim.
 * Emphasis is this project's current answer to "make one number the
 * scannable one", and it is the same role the .stat-tile__value tiles
 * on this very page now use. The semibold weight here beats .mono's
 * regular by SOURCE ORDER, not by specificity — both are single-class
 * selectors landing on this element, and this rule is later in the
 * file — so this rule must stay after .mono
 * (companion/test_status_pages.py's cross-file guard pins that fact).
 * The margin moved from top-only to bottom-only, using --space-sm: the
 * spacing this element owns moved with it when it moved from below the
 * chart to above it. */
/* 24-04-PLAN.md Task 2 (CFG-40): the row that puts the battery ring
 * beside the readout. Emitted ONLY when there is a ring to place, so a
 * device with no reading renders the readout exactly as it did before
 * this wrapper existed.
 *
 * `align-items: center` is the one declaration here that is doing work
 * on the figure: a flex row's default `stretch` would pull the
 * aspect-locked <svg> to the row's full height and letterbox it, which
 * is the distortion `.drawing__figure`'s own comment exists to prevent.
 *
 * WHAT IS DELIBERATELY NOT HERE, because it was written, measured and
 * found inert: `flex: none` on the figure and `min-width: 0` on the
 * readout. Both are the reflexive flex-row incantations, and the
 * reasoning for both sounded right — an aspect-locked figure must not
 * shrink; a flex item's automatic minimum size floors it at its
 * min-content width and grows the page a scrollbar. Measured at 360px
 * with a 60-character unbreakable run forced into the readout: the
 * figure stayed exactly 72px wide with and without `flex: none` (a
 * replaced element's own automatic minimum size already floors it), and
 * documentElement.scrollWidth was the identical 707px with and without
 * `min-width: 0` (nothing in the ancestor chain clips, so the run
 * overflows either way). Dead declarations with confident comments are
 * worse than none — they are what a later reader trusts instead of
 * measuring. The bottom margin below IS load-bearing: it moves the
 * readout's own 8px gap to the chart onto this new wrapper, so the
 * spacing between the readout and the sparkline is unchanged. */
.battery-readout-row {
  display: flex;
  align-items: center;
  gap: var(--space-md);
  margin-bottom: var(--space-sm);
}

.battery-readout-row > .battery-readout {
  margin-bottom: 0;
}

.battery-readout {
  font-size: var(--font-body-size);
  font-weight: var(--weight-semibold);
  min-height: 1.4em;
  margin: 0 0 var(--space-sm);
}

/* Desktop dashboard shell (D-02, 06.3-UI-SPEC.md Layout Contract). This is
 * the file's second and last width breakpoint. companion/layout.py's
 * page_shell() renders navigation copies (the mobile hamburger dropdown
 * inside .site-header and the vertical .dashboard-sidebar nav) and both
 * theme-form copies into every response, because a stdlib HTTP server has
 * no viewport information at render time — this block is what chooses
 * between them. The losing copy is removed with `display: none`, which
 * takes it out of the layout flow, the tab order and the accessibility
 * tree together — that is what keeps a keyboard user from landing on
 * invisible nav links and keeps exactly one navigation landmark exposed
 * at any given width. Below 960px nothing here applies: the page renders
 * fluid single column, header hamburger toggle + push-down dropdown
 * (06.6.1 D-06, see the header comment above), no horizontal-scroll
 * fallback needed since the strip that used to require one is gone. */
@media (min-width: 960px) {
  .dashboard-shell {
    display: grid;
    grid-template-columns: 240px minmax(0, 1fr);
    column-gap: var(--space-xl);
    align-items: start;
  }

  /* D-21: position: sticky + top: 0 keeps the sidebar reachable while
   * scrolling a long page (e.g. Health) instead of scrolling out of
   * view with the rest of the row. Requires `.dashboard-shell`'s own
   * `align-items` (still `start`) above — a stretched grid item cannot
   * visibly stick, since it is already sized to the full row height. */
  .dashboard-sidebar {
    display: flex;
    flex-direction: column;
    gap: var(--space-lg);
    min-height: 100vh;
    padding: var(--space-lg) var(--space-md);
    background: var(--color-secondary);
    border-right: 1px solid var(--color-border);
    position: sticky;
    top: 0;
  }

  /* 06.6.2-05 (D-17): the sidebar's theme picker and Sign out control,
   * grouped into one footer region pinned to the bottom of the flex
   * column and separated by a hairline — supersedes the previous bare
   * sidebar theme-form-only `margin-top: auto;` rule, which owned this
   * same bottom-pinning before the logout control existed. */
  .sidebar-footer {
    margin-top: auto;
    display: flex;
    flex-direction: column;
    gap: var(--space-sm);
    padding-top: var(--space-md);
    border-top: 1px solid var(--color-border);
  }

  /* The sidebar carries the title, the nav and the theme toggle at this
   * width, so the entire header row is removed rather than just its nav —
   * leaving the header's title visible alongside the sidebar's would
   * duplicate it on screen. The sidebar replaces the horizontal nav-bar
   * here; a supplement was considered and rejected. */
  .site-header {
    display: none;
  }

  /* The cap and `margin: 0 auto` centre the content column; the 3xl
   * horizontal padding is the 64px minimum side gutter, so content never
   * runs edge-to-edge on an ultra-wide monitor. 3xl serves as the main
   * column's horizontal padding rather than as a separate outer gutter
   * element: expressing the cap and the gutter as two independent
   * mechanisms would need an extra wrapper element in page_shell(), and
   * this single-element form delivers the same visible result. Below the
   * breakpoint the base .page-content padding rule above governs
   * unchanged. 06.6.1 (D-02/D-04, Reconciliation 1): vertical padding is
   * now 2xl (48px) on both top and bottom — this is where the UI-SPEC's
   * 2xl/3xl promotion actually lands, at the >=960px content-column
   * level the spec names, rather than on the base .page-content rule. */
  .dashboard-main {
    min-width: 0;
    width: 100%;
    max-width: min(1440px, 100%);
    margin: 0 auto;
    padding: var(--space-2xl) var(--space-3xl);
  }

  /* Config page (D-01, 06.6.4.1): the >=960px two-column grid that used
   * to sit here (`.config-form { display: grid; grid-template-columns:
   * 1fr 1fr; ... }`, plus its `fieldset`/`> button` grid-column pairing)
   * is removed outright, not re-tuned. The Runway fieldset's five
   * top-level elements were never wrapped for a two-column layout, so the
   * grid was the actual root cause of Settings' broken Runway section;
   * plan 03 of this phase wraps that group instead. Normal block flow
   * already produces the single-column stack every width needs — this is
   * a supersede of the two-column idiom, not a narrower breakpoint or a
   * fixed wrap. Do not reintroduce a `.config-form` column rule here. */

  /* 27-04-PLAN.md (D-04/CFG-63): the dirty-state bar's own >=960px
   * fixed-position rule — the sticky-to-fixed history (quick task
   * 260901-re6), the measured left/right/max-width geometry against
   * `.dashboard-shell`'s own column track, the width: fit-content
   * follow-up, and the T7/D-10 z-index-over-the-tab-bar escape clause —
   * is deleted wholesale along with the component it positioned. So is
   * the `.dirty-ready .dashboard-main` content-clearance rule that used
   * to reserve 88px of bottom padding for it: `.dirty-ready` is no
   * longer written by any script (companion/static/dirty-state.js's own
   * header records the account), so the rule was dead code twice over
   * even before this deletion. `.save-status`, further down this file,
   * sits in normal document flow beside the page's own heading and
   * needs no fixed positioning, no z-index and no content-clearance
   * rule of its own — see that rule's own comment.
   *
   * SUPERSEDED by 28-08-PLAN.md Task 2 (CFG-77/CFG-78), 2026-09-16: the
   * developer asked for the bar back. The OFFSETS below port unchanged
   * from the deleted rule — `.dashboard-shell`'s 240px column and
   * `--space-xl` gap are both confirmed unchanged (verified live at
   * planning time, this file's own `.dashboard-shell` rule) — but the
   * CLEARANCE is NOT ported; it is re-measured from scratch further
   * down this file, inside the `@supports selector(:has(*))` block,
   * against a bar whose CONTENT (the restored six-word/count-span/
   * two-button markup) postdates the 88px figure this comment used to
   * cite by fifteen months. `.save-status` above loses its RULE, not
   * its comment, the identical way. */
  .dirty-bar {
    position: fixed;
    bottom: var(--space-lg);
    left: calc(240px + var(--space-xl) + var(--space-md));
    right: var(--space-md);
    width: fit-content;
    max-width: calc(min(1440px, 100%) - var(--space-md) * 2);
    margin: 0 auto;
    z-index: 30;
  }

  /* History's mobile/desktop toggle (06.6.3 D-07), inverted at this
   * breakpoint: cards hide, the desktop table (scoped via the same
   * sibling combinator as the base rule above) becomes visible. */
  .history-cards {
    display: none;
  }

  .history-cards ~ .data-table-wrap {
    display: block;
  }

  /* Health's mobile/desktop toggle (quick task 260903-ghy), the inverse
   * pair for `.data-cards` above — the same sibling-combinator mechanism
   * History's own pair uses, inverted at this breakpoint. */
  .data-cards {
    display: none;
  }

  .data-cards ~ .data-table-wrap {
    display: block;
  }

  /* Quick task 260903-peo (UIR-17): History's desktop copy buttons hide
   * at rest and reveal on row hover/focus-within — desktop-only by
   * construction (inside this 960px block), so the three copy buttons
   * inside each `.history-card`'s mobile `<details>` disclosure keep
   * today's always-visible behaviour below the breakpoint, untouched.
   *
   * `[data-copy-value]` is the discriminator, not the bare `.copy-btn`
   * class: `history_page.py::_view_panel_button_html()` also renders
   * `class="copy-btn"` (it deliberately reuses the same 22px-visual/
   * 44px-hit-area shape) but carries `data-view-panel-src`/
   * `data-view-panel-caption`, never `data-copy-value` — a bare
   * `.copy-btn` selector here would incorrectly hide the always-visible
   * View-panel/eye trigger too.
   *
   * `opacity: 0` + `pointer-events: none` at rest, NEVER `visibility:
   * hidden` or `display: none`: both of those remove the element from
   * the tab order and the accessibility tree, so a copy button hidden
   * that way could never receive focus, `:focus-within` could never
   * fire, and a keyboard-only user would lose the copy affordance
   * entirely — strictly worse than the always-visible state this
   * replaces. `opacity: 0` keeps the element focusable, in the tab
   * order and in the accessibility tree; `pointer-events: none` is what
   * stops an invisible control from being clickable, and both are
   * restored together on `tr:hover` and `tr:focus-within` below. No
   * `prefers-reduced-motion` override is added here: the global one at
   * this file's own token block (D-19) already covers this plain
   * `opacity` transition for free.
   *
   * Verified from source: neither Health's nor Airlines' own reuse of
   * `.data-table-wrap` renders any `[data-copy-value]` element, so this
   * rule cannot affect either page. */
  .data-table tbody tr [data-copy-value] {
    opacity: 0;
    pointer-events: none;
    transition: opacity .15s;
  }

  .data-table tbody tr:hover [data-copy-value],
  .data-table tbody tr:focus-within [data-copy-value] {
    opacity: 1;
    pointer-events: auto;
  }

  /* 06.6.4.1.1-03 (D-15, UIR-25): desktop cards get more breathing room —
   * 16px under a large serif title on a 64px page gutter read as tight
   * boxes inside a roomy page. Raises padding to `--space-lg` (24px) at
   * this >= 960px breakpoint only; each selector's own base rule above
   * keeps `--space-md` (16px), which is D-15's "mobile keeps its current
   * density" half — a harness check in test_status_pages.py pins both
   * halves of this pair together.
   *
   * `.theme-status` is included because its own rule comment (above,
   * where it is declared) states it is "matched declaration-for-
   * declaration against `.page-section`" and the two must be edited
   * together — splitting them here would break that stated contract.
   *
   * `.battery-trend-section` is included even though D-15's own text
   * names only `.page-section`/`.theme-status`. It is a sibling card on
   * this same Health page, and leaving it at 16px beside two 24px
   * neighbours would introduce exactly the kind of visible inconsistency
   * this direction pass exists to remove. This is an EXTENSION of D-15,
   * not a reinterpretation of it — flagged here, and flagged again for
   * the plan-06 developer checkpoint, as a deliberate scope addition
   * rather than a silent one.
   *
   * `--space-lg` (24px) rather than a bare `20px`: D-15's own range is
   * "20-24px", and 24px is the value already on the token scale, so this
   * rule introduces no new spacing literal.
   *
   * Widening `.battery-trend-section`'s padding here is safe by
   * construction with respect to the chart hazard documented above this
   * block, at the sparkline canvas's own selector (the section's `svg`,
   * excluding the heading icon): that rule's `height` is declared
   * exactly once, outside every `@media` block, and this rule touches
   * only `padding` — the chart's declared height never varies. Only the
   * chart's container WIDTH narrows slightly, which is explicitly safe:
   * the no-viewBox design (see that rule's own comment) makes 1 SVG unit
   * equal 1 CSS pixel, so "the chart fills its card" is
   * a property of the layout, not a tuned number that this padding
   * change could invalidate. */
  .page-section,
  .theme-status,
  .battery-trend-section {
    padding: var(--space-lg);
  }

  /* 06.6.4.1.1-03 (D-16, UIR-25/UIR-14): Health's Screen section wraps
   * its single "Device last checked in" tile in a `.dashboard-grid`, so
   * that one tile stretches the grid's full ~1040px column and reads as
   * ~80% empty. Fixes the TEXT, deliberately not the page grid: the tile
   * keeps its full card width (no restructure into a two-up row, the
   * higher-risk option UIR-25 also raised), and only the caption/value
   * line length is bounded to a readable measure. */
  .stat-tile__caption,
  .stat-tile__value {
    max-width: 60ch;
  }

  /* 06.6.4.1.1-03 (D-19 desktop half, UIR-24): restores the inline
   * baseline row above this breakpoint. The mobile-first base rule
   * (above, where `.section-intro` is declared) now stacks the heading
   * and its muted caption in a column by default — this override is what
   * brings back the original side-by-side reading once there is enough
   * width for it not to wrap into a hanging "— …" orphan line (the exact
   * defect UIR-24 found at 375px). */
  .section-intro {
    flex-direction: row;
    align-items: baseline;
    gap: var(--space-sm);
  }
}

/* 06.6.2-07 (UXA-03): the dedicated login shell's own component pair —
 * .login-shell/.login-card are not reused by any other page. A centered,
 * bounded card at desktop width; a full-width card with comfortable
 * padding on mobile, via the same responsive min()/width:100% shape used
 * elsewhere in this file rather than a media query. */
.login-shell {
  min-height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-lg);
}

.login-card {
  width: 100%;
  max-width: 360px;
  background: var(--color-dominant);
  /* Same 06.6.4 (D-03) hairline-at-rest/shadow-on-hover contract as every
   * other card in this file — a real --color-border edge at rest, no
   * resting shadow, shadow revealed only on hover/focus-within below.
   * Included deliberately, not by oversight: excluding the login card
   * would have left the pre-auth page as the one card in the app still
   * carrying resting relief. */
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  box-shadow: none;
  padding: var(--space-xl);
}

.login-card:hover,
.login-card:focus-within {
  border-color: transparent;
  box-shadow: var(--shadow-card-hover);
}

/* 22-13-PLAN.md Task 1 (X3, 22-UI-SPEC.md §3.2): the login card's own
 * FORM geometry. The .login-shell/.login-card pair above has existed
 * since 06.6.2-07, but nothing in this file had ever styled the two
 * controls inside it, which is the whole of X3's measured defect: the
 * field took the global `input` rule's 44px floor while the button took
 * the base `button` rule's 30px / 6px-radius register, so a 225x44 r8
 * field sat beside a 68x30 r6 button 7px lower, and on a phone the
 * button wrapped glued underneath at a 0px gap.
 *
 * Stacked, not one row: label -> field -> message (when there is one)
 * -> primary. Both controls take the full content column, so at 390px
 * neither is narrower than the other. */
/* 22-13-PLAN.md Task 2 (X3): the field's own box, which exists only so
 * the show-password toggle can sit inside its right edge. Rendered on
 * every branch, with or without a toggle — the modifier class below is
 * what reserves room, and companion/static/login-card.js is the only
 * thing that ever adds it. */
.login-form__field {
  position: relative;
  display: block;
}

.login-form__input {
  display: block;
  width: 100%;
  /* An explicit height, not the global `input` rule's `min-height`
   * floor: the primary below must be able to state the SAME number, and
   * a floor plus a fixed height cannot be compared by a reader (or by
   * the browser harness) without resolving the padding too. The global
   * `* { box-sizing: border-box }` reset makes this the border-box
   * height, so the global rule's own --space-sm padding stays inside
   * it. */
  height: 44px;
}

/* The error-coloured field edge. Gated before shipping, exactly as
 * 22-UI-SPEC.md §3.2 requires and as .status-card__headline--warn's own
 * precedent binds: --color-status-error against the three surfaces this
 * field is ever seen on measures 6.29 / 5.16 / 5.73 in light mode
 * (#BE123C on #FFFFFF card, #EEE8DE field fill, #F7F4EF canvas) and
 * 6.53 / 5.93 / 7.13 in dark (#FB7185 on #151922 / #1C222D / #0C0F14) —
 * every one of them past WCAG_AA_UI_COMPONENT (3.0), so the border
 * ships. companion/test_contrast_check.py pins all six.
 *
 * Attribute-selected, not class-selected, on purpose: the visual state
 * and the announced state are then physically the same attribute and
 * cannot drift — the same reasoning .site-nav-toggle[aria-expanded]
 * already carries. Colour is never alone either way: the message under
 * the field names the problem and the next action. */
.login-form__input[aria-invalid="true"] {
  border-color: var(--color-status-error);
}

/* 22-13-PLAN.md Task 2 (X3): room for the toggle, reserved ONLY once
 * the toggle is actually there. companion/static/login-card.js appends
 * this modifier at load — the same per-script class-at-load idiom
 * theme-preview.js and flight-rows.js established — so a page with
 * scripts blocked has no unexplained empty gutter down the right of its
 * password field.
 *
 * --space-xl (32px), not 22-UI-SPEC.md §3.2's own suggested 40px: that
 * figure is not a token, and §1 of the same document forbids any new
 * off-scale literal beyond the three it enumerates (none of which is
 * this). 32px clears the toggle's real reach — a 22px box inset
 * --space-sm (8px) from the edge occupies the last 30px — and the
 * visible glyph is centred in that box, so the typed value clears the
 * mark by roughly 9px. */
.login-form__field--with-toggle .login-form__input {
  padding-right: var(--space-xl);
}

/* The toggle itself. Every dimension it has comes from `.copy-btn`,
 * which it carries as its first class: the 22x22 visual box, the
 * transparent no-border fill, the `::before { inset: -11px }` that
 * synthesizes a real 44x44 hit area, the hover wash, and the 14px glyph
 * box via `.copy-btn .icon`. This rule adds POSITION and nothing else —
 * no width, no height, no radius, no font size, so there is no second
 * icon-button size in the file to keep in step.
 *
 * `top: 0; bottom: 0; margin-block: auto` rather than a
 * `transform: translateY(-50%)`, deliberately: the base `button:active`
 * rule presses every button in this app 1px with its own `transform`,
 * and a centring transform here would be overwritten by it, making the
 * toggle jump to the top of the field on every press. Absolute
 * centring by auto margins leaves that 1px press working exactly as it
 * does everywhere else.
 *
 * These rules sit AFTER `.copy-btn` in source order, which is what lets
 * this equal-specificity `position: absolute` win over its
 * `position: relative`. Do not move either block. */
.login-reveal {
  position: absolute;
  top: 0;
  bottom: 0;
  right: var(--space-sm);
  margin-block: auto;
}

/* Without this, the server-rendered `hidden` attribute has no effect and
 * the whole no-JS floor collapses: `.copy-btn`'s own `display:
 * inline-flex` is an author-stylesheet declaration and beats the
 * user-agent `[hidden] { display: none }` regardless of source order, so
 * a browser with scripts blocked would render a show-password button
 * that does nothing at all — the exact defect this control was built to
 * avoid. Same collision class, and the same fix, as `.banner__pill[hidden]`
 * and `.refresh-pill[hidden]` above: a component whose base rule sets
 * `display` always needs its own higher-specificity `[hidden]` selector.
 * Found by companion/test_browser_ux.py's own scripts-blocked pass, not
 * by inspection. */
.login-reveal[hidden] {
  display: none;
}

/* The pressed state, in the file's own quiet color-mix wash idiom at
 * `.copy-btn:hover`'s own 7% strength — no new colour, no accent (this
 * is not a main action). Attribute-selected so the visual state and the
 * announced state are physically the same attribute, as with
 * `.site-nav-toggle[aria-expanded]` and the error border above.
 * Colour is not alone: the glyph itself swaps filled/hollow with the
 * state, and the field's own value visibly changes. */
.login-reveal[aria-pressed="true"] {
  background: color-mix(in srgb, var(--color-text) 7%, transparent);
}

/* The glyph is a text character, not an SVG: login_shell() emits no
 * ICON_DEFS_HTML sprite and this plan may not add one. `.copy-btn .icon`
 * already sizes this span to 14x14; these three declarations only
 * centre the character inside that box, which an SVG would not have
 * needed. */
.login-reveal__glyph {
  display: flex;
  align-items: center;
  justify-content: center;
  line-height: 1;
}

/* The primary. A named, scoped exception to the base `button` rule's
 * 30px / 6px-radius register, for three reasons that all have to hold
 * together — see .claude/skills/sketch-findings-skypane/references/
 * control-density.md's touch-target register, which this joins in the
 * KEPT category, not the traded-away list:
 *   1. 44px RESTORES the WCAG 2.5.5 (AAA) floor here rather than
 *      trading it — a floor gain, the same reasoning quick task
 *      260902-qkm used to restore .mobile-nav__link's own 44px;
 *   2. matching the field's height AND radius is what makes the pair
 *      read as one object, which is C4's new composition rule;
 *   3. this is the app's only pre-auth surface, with no density
 *      argument to trade the floor against.
 *
 * The full-width accent fill is correct HERE and only here, and is not
 * a contradiction of B15 (which removed the app's other one, 22-10):
 * the login card is a single-purpose pre-auth surface with exactly one
 * action and no competing content, so `Sign in` IS the page's single
 * main action — which is precisely what C2 reserves the accent for.
 * This satisfies C2, it does not breach it.
 *
 * margin-top is the ONE medium spacing token separating the two
 * controls; with a message present, .field-error's own --space-xs
 * margin-top attaches the message to the field above it and this
 * token then separates the message from the primary. */
.login-card button[type="submit"] {
  width: 100%;
  height: 44px;
  border-radius: var(--radius-control);
  font-size: var(--font-body-size);
  margin-top: var(--space-md);
}

/* History lightbox (D-20, §8.3) — a native <dialog> element, styled by
 * class. box-shadow: var(--shadow-card-hover) at rest is this phase's
 * one deliberate exception to the hairline-at-rest contract every other
 * card in this file carries: this is a floating modal overlay, not a
 * page-flow card, so its resting state should already read as elevated.
 * width: calc(...) keeps the dialog off the viewport edge on a phone
 * without a separate media-query block.
 *
 * quick task 260902-tli (bug found live, dark mode): `color` is now set
 * explicitly. A <dialog> promoted to the top layer via showModal() does
 * not reliably inherit `color` from its DOM ancestors the way a normal
 * in-flow element does — every browser tested here fell back to its own
 * UA-stylesheet black regardless of the page's actual theme, so
 * .lightbox__caption/.lightbox__note (which set no color of their own,
 * relying on inheritance) rendered black-on-near-black in dark mode.
 * Declaring `color` here, on the dialog itself, is what the child rules'
 * inheritance actually resolves against. */
.lightbox {
  background: var(--color-dominant);
  color: var(--color-text);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-card);
  padding: var(--space-lg);
  max-width: 480px;
  width: calc(100% - var(--space-lg) * 2);
  box-shadow: var(--shadow-card-hover);
  /* 23-10-PLAN.md Task 2 (D3/CFG-32): the dialog ARRIVES — it fades and
   * zooms in rather than appearing whole.
   *
   * ONE rule, TWO dialogs. History's panel lightbox and the Airlines
   * gallery's `.lightbox--wide` variant are the same component under
   * two classes, so the entrance is declared here and reaches both;
   * companion/test_view_pages.py pins that count so a second, drifting
   * copy cannot appear for the wide variant.
   *
   * THE CLOSE IS INSTANT, ON PURPOSE, and this is the one thing to read
   * before "finishing" this rule with an exit animation.
   * `transition-behavior: allow-discrete` on `display` would keep the
   * element rendered while it faded out — and for the whole of that
   * fade, plus permanently in every browser that has not implemented
   * the property, a modal that has been closed would still be in the
   * top layer, still in the tab order, still in the accessibility tree,
   * and still hit-testable across the entire viewport. That is not a
   * cosmetic regression: it is an invisible sheet that swallows every
   * click on the page beneath it (T-23-36). 23-08-PLAN.md Task 2 set
   * this app's precedent for the same trade on the Flights detail row
   * ("a row that shuts instantly is a smaller loss than a row that is
   * secretly still there"); a modal raises the stakes rather than
   * lowering them. So `display` is deliberately absent from the list
   * below, `close()` ends the dialog outright, and
   * companion/test_browser_ux.py hit-tests the viewport centre
   * immediately after close with no settle wait at all.
   *
   * `::backdrop` is deliberately NOT animated either, and that is a
   * reduced-motion fact rather than a matter of taste: the global
   * override near the top of this file matches
   * `*, *::before, *::after`, which are ELEMENT selectors —
   * `::backdrop` is none of them, exactly as this file already records
   * for the view-transition pseudo-element tree. An animated backdrop
   * would be motion a reduced-motion visitor has no way to switch off.
   * The dialog itself is an element, so the global block covers this
   * transition for free and no per-rule block is added.
   *
   * `@starting-style` is Baseline newly (2024-08-06: Chrome 117,
   * Firefox 129, Safari 17.5) and verified to parse in this project's
   * own harness Chromium. A browser without it simply has no
   * before-change style to transition from, so the dialog opens
   * instantly — exactly what it did before this rule existed. */
  opacity: 0;
  transform: scale(0.96);
  transition: opacity var(--motion-fast) ease-out,
    transform var(--motion-fast) ease-out;
}

.lightbox[open] {
  opacity: 1;
  transform: none;
}

@starting-style {
  .lightbox[open] {
    opacity: 0;
    transform: scale(0.96);
  }
}

/* 14-08 on-glass fix follow-up (2026-09-06, code review CR-01): caught
 * after the fact — this rule has the exact same author-CSS-beats-
 * [hidden] specificity tie the "14-08 on-glass fix" comments above
 * already document and fixed for .lightbox__replace/.lightbox__
 * resolve-name/.lightbox__delete and .lightbox__replace-zone/.resolve-
 * upload-zone, missed here because this element was never gap-mode's
 * concern until D-02 added panel-lookup.js's `image.hidden = true`
 * branch this same phase. `:not([hidden])` closes it the same way. No
 * hard visible defect was observed in this session's own browser
 * testing (an <img> with no src and no intrinsic size collapsed toward
 * invisible regardless), but relying on that is exactly the kind of
 * browser-implementation-defined behaviour this project does not
 * accept elsewhere — fixed for the same correctness reason as its
 * three siblings, not left as an inconsistent exception. */
.lightbox__image:not([hidden]) {
  width: 100%;
  height: auto;
  display: block;
  border-radius: var(--radius-control);
  margin-bottom: var(--space-sm);
}

/* Both elements already carry text-label mono / text-body utility
 * classes from plan 05's markup, so neither rule here sets a font-size
 * or a colour — margin only. */
.lightbox__caption {
  margin: 0 0 var(--space-xs);
}

.lightbox__note {
  margin: var(--space-sm) 0 0;
}

/* quick task 260902-tli: airlines_page.LIGHTBOX_NOTE is deliberately the
 * empty string (developer's own call, on a live test, that the Airlines
 * lightbox needed no note at all) — the element itself still has to
 * exist, since panel-lookup.js's shared guard clause requires it, so an
 * empty one collapses to zero visible space instead of leaving a bare
 * top-margin gap. History's own note is never empty, so this rule never
 * touches it. */
.lightbox__note:empty {
  display: none;
}

/* Phase 14 (14-03-PLAN.md Task 2): the new optional <h2> (text-filled
 * per click) and D-10's superseded explanation copy the .lightbox__note
 * :empty idiom above exactly — empty for the modes/states that don't
 * use them, never a bare-margin gap. */
.lightbox__heading:empty {
  display: none;
}

.lightbox__manual-note:empty {
  display: none;
}

/* No scrim token exists in this file; a raw rgba value here is
 * consistent with this file's existing raw-rgba shadow values, so this
 * is not a new token. */
.lightbox::backdrop {
  background: rgba(0, 0, 0, 0.5);
}

/* B5 (22-11-PLAN.md Task 1, 22-UI-SPEC.md §2): the dialog's ONE action
 * row. Before this, Close was the dialog's bare last child and the
 * resolve form's Save sat inside the form above it, so the two painted
 * as two stacked block rows — the audit's own "Save/Close buttons
 * stacked on two lines". `justify-content: space-between` is what puts
 * the quiet Close at the left edge and the primary Save at the right;
 * when the Save is hidden (every non-gap mode, panel-lookup.js mirrors
 * the form's own state) Close simply stays where it already was.
 *
 * C4, as §4 states it: these two read as SEPARATE OBJECTS on a shared
 * row, not as one object, so they are centre-aligned and each keeps its
 * own registered geometry. No height, no min-height and no radius is
 * declared here on purpose — forcing a shared height would be the
 * one-object rule applied to the wrong composition. This is a layout
 * row, not a button family: both children keep the element selectors
 * (`button`, `button[type="submit"]`) they already matched. */
.lightbox__actions {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm);
  margin-top: var(--space-md);
}

/* quick task 260902-tli: the Airlines gallery's wide lightbox variant.
 * Equal specificity to .lightbox (one class each), so this must come
 * later in source order to win — it does, as the last rule in this
 * section.
 *
 * quick task 260904-e92 (UIR-08): this cap exists so the dialog hugs the
 * illustration's own native width and never upscales it — the value
 * stays pinned to illustration_normalize.ILLUSTRATION_TARGET_WIDTH by a
 * harness cross-file check
 * (_lightbox_wide_max_width_matches_illustration_target_width). Since
 * the frame was halved for UIR-08 (900 -> 450), this cap now sits BELOW
 * .lightbox's own 480px base max-width — an inversion of the original
 * "wide variant is wider than the base" intent this rule shipped with.
 * That inversion is DELIBERATE, not a bug to "fix" back to 900 or bump
 * to clear the base: `.lightbox--wide .lightbox__image` uses
 * `width: auto`, so it never upscales to fill a wider dialog regardless
 * of this cap's value, and serving a second, larger illustration size
 * just for the lightbox is exactly the multi-size mechanism this
 * feature deliberately does not build (DP-3). The developer accepted
 * the measured consequence when choosing this frame size: the desktop
 * enlarged view shrinks from ~852px to ~402px (mobile is unaffected —
 * .lightbox's own `calc(100% - var(--space-lg) * 2)` width cap, ~327px,
 * already binds before either max-width does). Do not reintroduce a
 * larger `.lightbox--wide` max-width "to look bigger"; the illustration
 * itself is smaller now, on purpose. */
.lightbox--wide {
  max-width: 450px;
}

/* width: auto here, not the base .lightbox__image rule's width: 100%,
 * is deliberate: combined with max-height below, width: 100% + height:
 * auto would squash the aspect ratio once the image hits the height
 * cap. max-height: 60vh is what keeps the whole dialog — image,
 * caption, note, Close — on screen without scrolling on a landscape
 * phone. */
.lightbox--wide .lightbox__image {
  width: auto;
  max-width: 100%;
  max-height: 60vh;
  margin-left: auto;
  margin-right: auto;
}

/* quick task 260903-btu: the replace form relocated from the per-card
 * disclosure into this shared dialog. `border-top: 1px solid
 * var(--color-border)` is this file's structural-divider role for that
 * token (never an interactive-state signal) — it separates the form
 * from the viewing content above it, the same job a rule boundary plays
 * elsewhere in this file. The `label` rule below carries forward the
 * retired disclosure's own sub-scale label tier verbatim (13px,
 * color-mix at 70% muted) — the same tier `.airline-card__chip`/
 * `.data-table th` already use — so this introduces no new size and no
 * new colour token. `min-width: 0` on the form and `max-width: 100%` on
 * the file input are the same fix `.stat-tile` documents above: without
 * them, a long uploaded filename's intrinsic content width can push the
 * dialog wider than intended. Neither the native file input (kept at
 * its own global 44px floor) nor the submit button (kept at the global
 * 30px accent-fill primary treatment, already an enumerated member of
 * this file's header accent-reservation list) is restyled here — no new
 * accent use.
 *
 * quick task 260903-df3, SUPERSEDED: `display: flex; align-items:
 * center; gap: var(--space-sm);` and the `border-top` hairline divider
 * above are dropped. `.lightbox__replace` is demoted to a thin layout
 * wrapper — the new `.lightbox__replace-zone` below is a dashed-edge
 * framed box that now carries the visual separation the hairline used
 * to carry, and a hairline rule 16px above a dashed box read as
 * double-fencing. Restoring the hairline (and the old flex row) is a
 * one-line revert if the developer prefers it after Task 3's
 * real-browser check. */
/* Phase 14 (14-03-PLAN.md Task 2): .lightbox__resolve-name and
 * .lightbox__delete added to this selector's existing group — the
 * identical "one action block inside the dialog" spacing treatment
 * this rule already declares, matching this file's own
 * .lightbox__replace-zone, .resolve-upload-zone precedent below
 * (extend the selector, never duplicate the declaration).
 *
 * 14-08 on-glass fix (2026-09-06, D-03 real-browser check): each of
 * these three forms is exactly the element panel-lookup.js's
 * openFromTrigger() sets `.hidden` on per mode
 * (resolveNameForm/replaceForm) or per manual (deleteForm). An
 * unconditional `display: block` here has the SAME specificity as the
 * UA stylesheet's own `[hidden] { display: none }` rule and, being the
 * later author rule, wins the tie — so `hidden = true` stopped hiding
 * anything the moment Phase 14 started toggling these three at
 * runtime (this rule's `.lightbox__replace` half predates Phase 14 and
 * was never exercised as a bug before, since nothing ever set `hidden`
 * on it). Confirmed live: opening a gap card showed the replace AND
 * delete forms alongside the name form, all three simultaneously
 * `hidden=true` in the DOM yet rendering `display:block`. Scoping the
 * selector to `:not([hidden])` keeps every declaration and every
 * existing selector exactly as this comment already describes them —
 * it only ever matches when the attribute is absent, so it can never
 * fight `[hidden]` again. */
.lightbox__replace:not([hidden]),
.lightbox__resolve-name:not([hidden]),
.lightbox__delete:not([hidden]) {
  display: block;
  padding-top: var(--space-md);
  min-width: 0;
}

/* quick task 260903-df3: "Option 2 — Framed action area". A bordered
 * zone whose stacked, centred contents read top-to-bottom as upload
 * glyph -> bold label -> requirements hint -> file picker -> Upload
 * button. `--radius-control` (8px) is correct here, not `--radius-card`
 * (10px): the card set uses `--radius-control`, and `--radius-card` is
 * reserved for floating overlays — the enclosing `.lightbox` already
 * owns that role (see references/control-density.md). The background is
 * a light tint of the secondary surface, written with this file's own
 * WR-02 fallback idiom (a plain declaration immediately followed by the
 * color-mix() one, see `button` above). */
/* Phase 13 (13-04-PLAN.md Task 1, D-03): Airlines' Step B upload-step
 * zone is added as a second selector to this rule and the five below
 * it, rather than a duplicated rule — it reuses the exact
 * `.lightbox__replace-zone` visual treatment verbatim, per
 * 13-UI-SPEC.md's own instruction. No declaration inside any of the six
 * changes.
 *
 * 14-08 on-glass fix (2026-09-06): `.resolve-upload-zone` is exactly
 * the element panel-lookup.js sets `.hidden` on per mode
 * (`resolveUploadZone.hidden = (mode !== "needs-artwork")`) — an
 * unconditional `display: flex` here wins its specificity tie against
 * the UA stylesheet's `[hidden] { display: none }` (same reasoning as
 * the `.lightbox__replace`/`.lightbox__resolve-name`/`.lightbox__delete`
 * rule above), so the upload zone stayed visible in every mode
 * regardless of `hidden`, confirmed live in the same browser pass.
 * `.lightbox__replace-zone` itself is never given `hidden` directly —
 * it is nested inside `.lightbox__replace`, whose own now-scoped rule
 * above hides it as an ancestor — so `:not([hidden])` here is a no-op
 * for it today, added only to keep both selectors in this shared rule
 * on the same footing, matching this rule's own "extend the selector,
 * never duplicate the declaration" convention. */
.lightbox__replace-zone:not([hidden]),
.resolve-upload-zone:not([hidden]) {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-sm);
  padding: var(--space-lg) var(--space-md);
  border: 1px dashed var(--color-border);
  border-radius: var(--radius-control);
  text-align: center;
  min-width: 0;
  background: var(--color-secondary); /* fallback for browsers without color-mix() (WR-02) */
  background: color-mix(in srgb, var(--color-secondary) 55%, var(--color-dominant));
}

/* A muted text tint, not the accent, is deliberate: this file's header
 * accent-reservation list is exhaustive and this glyph is not on it, so
 * tinting it accent would require extending that list first. Size comes
 * from the existing `.icon` rule (20x20) plus icon_html()'s own
 * width/height attributes — no size restated here. */
.lightbox__replace-zone .lightbox__replace-icon,
.resolve-upload-zone .lightbox__replace-icon {
  color: rgba(23, 25, 31, 0.55); /* fallback for browsers without color-mix() (WR-02) */
  color: color-mix(in srgb, var(--color-text) 55%, transparent);
}

/* quick task 260903-df3, SUPERSEDED: replaces the old `.lightbox__replace
 * label` rule (13px, color-mix at 70% muted — removed, its selector's job
 * is now done by this rule instead). 14px-semibold is this design
 * system's existing Emphasis pattern ("existing size made bold", 06.6.4
 * D-09 / `.data-table th`) — no new size, no new weight. `margin-bottom:
 * 0` is load-bearing: the global `label` rule's `margin-bottom:
 * var(--space-sm)` would otherwise stack on top of this zone's own flex
 * `gap`. */
.lightbox__replace-zone label,
.resolve-upload-zone label {
  font-size: var(--font-label-size);
  font-weight: var(--weight-semibold);
  color: var(--color-text);
  margin-bottom: 0;
}

/* The retired label rule's own sub-scale muted tier, carried forward
 * verbatim rather than invented — the tier `.airline-card__chip` /
 * `.data-table th` already occupy. */
.lightbox__replace-hint {
  margin: 0;
  font-size: 13px;
  color: rgba(23, 25, 31, 0.7); /* fallback for browsers without color-mix() (WR-02) */
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* Neutralises the box the global `input, select` rule gives this one
 * instance, so the picker button below stays legible: without this, a
 * bordered secondary-filled input inside a secondary-tinted frame
 * containing a secondary-filled button would be three nested boxes of
 * the same colour. `min-height: 44px` stays inherited — do not zero it;
 * the touch-target floor register in references/control-density.md
 * records native input/select as having KEPT the 44px floor, and nothing
 * here justifies spending it. `max-width: 100%` is retained from the
 * pre-existing rule for the reason its own comment above already gives
 * (a long filename's intrinsic width pushing the dialog wider). This
 * also recolours the browser's own "no file chosen" status text via the
 * same 70%-muted value the hint above uses. */
.lightbox__replace-zone input[type="file"],
.resolve-upload-zone input[type="file"] {
  background: transparent;
  border: 0;
  padding: 0;
  min-width: 0;
  max-width: 100%;
  font-family: var(--font-ui);
  font-size: 13px;
  color: rgba(23, 25, 31, 0.7); /* fallback for browsers without color-mix() (WR-02) */
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* The first ::file-selector-button rule in this file, scoped to this
 * zone only — a bare `input[type="file"]::file-selector-button` rule
 * would reach any future file input anywhere in the app. Paints the
 * browser's own picker button as this app's quiet control, restating
 * the base `button` geometry since the pseudo-element does not inherit
 * it. Two things worth recording: (i) the 30px height lands in the
 * existing 06.6.4 D-01 button-density register — WCAG 2.5.8 (AA, 24px)
 * is still met, no new touch-target trade; (ii) this rule uses
 * `var(--radius-control)` (8px) where the global `button` rule uses a
 * literal 6px — deliberate, so the picker button's corners agree with
 * the zone frame it sits inside, not an oversight. This paints the
 * browser's own control; it does not hide or replace the native picker
 * behaviour or the browser's own localized status text.
 *
 * quick task 260903-df3 round trip: the resting background was
 * originally a flat `var(--color-secondary)` fill, found during Task
 * 3's real-browser dark-mode check to read as barely-visible text with
 * no perceptible button shape — in dark mode, this zone's own tint
 * (`color-mix(in srgb, var(--color-secondary) 55%, var(--color-dominant))`,
 * above) lands almost on top of plain `--color-secondary`, since
 * `--color-dominant` (#151922) is darker than `--color-secondary`
 * (#1C222D) in the dark palette — the opposite of light mode, where
 * `--color-dominant` (#FFFFFF) is lighter than `--color-secondary` and
 * the mix reads as visibly brighter than the button sitting on it. Fixed
 * by mixing the resting fill toward `--color-text` instead of using it
 * flat — the same idiom the base quiet `button` rule already uses for
 * its own resting wash (see `button` above) — at a lighter 4% than the
 * hover rule's existing 7% below, so hover still reads as a visible step
 * up. Mixing toward `--color-text` (near-white in dark mode, near-black
 * in light mode) pushes the button's fill away from the zone's own
 * dominant-diluted tint in both themes, unlike mixing toward
 * `--color-dominant`, which would only have made the dark-mode collapse
 * worse. */
.lightbox__replace-zone input[type="file"]::file-selector-button,
.resolve-upload-zone input[type="file"]::file-selector-button {
  height: 30px;
  padding: 0 12px;
  margin-right: var(--space-sm);
  font-family: var(--font-ui);
  font-size: 13px;
  font-weight: var(--weight-semibold);
  letter-spacing: -0.01em;
  border-radius: var(--radius-control);
  background: var(--color-secondary); /* fallback for browsers without color-mix() (WR-02) */
  background: color-mix(in srgb, var(--color-text) 4%, var(--color-secondary));
  border: 1px solid var(--color-border);
  color: var(--color-text);
  cursor: pointer;
  transition: background-color .15s ease, border-color .15s ease;
}

.lightbox__replace-zone input[type="file"]::file-selector-button:hover,
.resolve-upload-zone input[type="file"]::file-selector-button:hover {
  background: var(--color-secondary); /* fallback for browsers without color-mix() (WR-02) */
  background: color-mix(in srgb, var(--color-text) 7%, var(--color-secondary));
}

/* =====================================================================
 * 25-07-PLAN.md Task 1 (CFG-51/D19): the drag-and-drop affordance and
 * the framing preview, layered over the two upload zones above.
 *
 * Every element styled below is emitted by
 * companion/pages/airlines_page.py's _upload_drop_html() INSIDE 25-01's
 * `.js` gate, so with scripts blocked none of it is in the layout or in
 * the tab order and the visitor sees exactly the form the six rules
 * above already paint. `--js-gate-display: flex` is how this consumer
 * picks its own revealed display without writing a `.js`-scoped
 * selector of its own — see the gate's own comment for why that
 * indirection exists.
 *
 * NO `:hover` RULE ANYWHERE IN THIS BLOCK, and that is the point. The
 * drag state is carried by `[data-upload-drop-active]`, an attribute
 * companion/static/panel-lookup.js sets and removes, because a state
 * that exists only under `:hover` is invisible on a phone — the same
 * ground CFG-28 used when it moved this app's tooltips out from behind
 * hover. No new animation at-rule, no transition and so no per-rule
 * reduced-motion block: the state change is instantaneous, which is the
 * one motion budget nobody has to police. (The at-rule's own keyword is
 * deliberately not spelled out here — several guards in this tree locate
 * a rule by the FIRST occurrence of a literal in this file, and a
 * comment that quotes one points the guard at prose.)
 *
 * NO COLOUR LITERAL ANYWHERE IN THIS BLOCK either — every value is a
 * theme token or a color-mix() over one, and each WR-02 fallback falls
 * back to the token at full strength rather than to a hard-coded rgba(),
 * which can only ever raise contrast, never lower it. */
/* NO `width: 100%` HERE, AND ITS ABSENCE IS A MEASUREMENT RATHER THAN
 * an oversight. This wrapper is a block-level <section> inside the
 * upload form, so it already fills that form's content box; removing
 * the declaration moved the rendered width by 0.00px at 1280 and left
 * it exactly equal to the form's inner width, while a
 * comment-only null mutation moved the same measurement by 0.01px. A
 * declaration that cannot be told from the null control is dead code,
 * which is what `references/accessibility-contrast.md` already says
 * about per-rule reduced-motion blocks. The preview box below DOES
 * declare one, and there the same mutation moved the rendering. */
.upload-drop {
  --js-gate-display: flex;
  flex-direction: column;
  gap: var(--space-sm);
  padding: var(--space-sm);
  border-radius: var(--radius-control);
}

/* The drag-over state. A background wash on the wrapper plus the
 * preview frame going solid: two signals rather than one, because the
 * wash alone is a small step against this zone's own tint. */
.upload-drop[data-upload-drop-active] {
  background: var(--color-secondary);
}

/* The hint and the framing-preview caption share one voice — the same
 * sub-scale muted tier `.lightbox__replace-hint` above already occupies,
 * restated against tokens rather than reusing that class, so
 * test_status_pages.py's replace-zone contract keeps matching exactly
 * one `<p class="lightbox__replace-hint">` per zone. */
.upload-drop__note {
  margin: 0;
  font-size: 13px;
  color: var(--color-text); /* fallback for browsers without color-mix() (WR-02) */
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* The reserved box. `aspect-ratio` is fed by the `--upload-preview-ratio`
 * custom property _upload_drop_html() writes inline from
 * companion/illustration_normalize.py's own ILLUSTRATION_TARGET_WIDTH /
 * ILLUSTRATION_TARGET_HEIGHT — DELIBERATELY WITH NO FALLBACK VALUE. A
 * `var(--upload-preview-ratio, 450 / 132)` here would keep the box the
 * right shape even if the inline property stopped being rendered, which
 * is a fallback masking the deletion of the live value rather than a
 * safety net. `margin: 0` is load-bearing: this is a <figure>, and the
 * UA stylesheet gives it `margin: 1em 40px`. */
.upload-drop__preview {
  margin: 0;
  width: 100%;
  aspect-ratio: var(--upload-preview-ratio);
  overflow: hidden;
  border: 1px dashed var(--color-border);
  border-radius: var(--radius-control);
  background: var(--color-dominant);
}

.upload-drop[data-upload-drop-active] .upload-drop__preview {
  border-style: solid;
  border-color: var(--color-text);
}

/* `:not([hidden])` is not decoration. The preview <img> is rendered
 * `hidden` until a file is chosen, and a bare `display: block` here
 * would win its specificity tie against the UA stylesheet's
 * `[hidden] { display: none }` and paint an empty image box forever —
 * the identical trap the `.resolve-upload-zone:not([hidden])` rule
 * above documents for the zone itself.
 *
 * `object-fit: contain` shows the WHOLE chosen image inside the frame it
 * will occupy. That is the honest thing to draw: the server may crop
 * differently and this preview is not the authority — which is exactly
 * why no canvas crop was written to make it one. */
.upload-drop__image:not([hidden]) {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: contain;
}

/* The refusal message. Its text is server-rendered onto the wrapper and
 * only copied into this element by the script, so it is already
 * translated; the element is present and empty at rest, and `:empty`
 * collapses it — the same idiom `.lightbox__note:empty` already uses,
 * and the reason no `hidden` attribute is juggled here. */
.upload-drop__message {
  margin: 0;
  font-size: 13px;
  font-weight: var(--weight-semibold);
  color: var(--color-status-error);
}

.upload-drop__message:empty {
  display: none;
}

/* 06.6.4.1.1-03 (D-18b, UIR-26): buttons grow to 14px/36px specifically
 * below 960px — a phone-only extension of 06.6.4 D-01's 30px desktop
 * button decision, NOT a reopening of it. This is the file's FIRST
 * `max-width` media query, and that is deliberate, not incidental, for
 * three load-bearing reasons — all recorded here because a future edit
 * is most likely to "simplify" exactly one of them away:
 *
 * 1. Accessibility direction, not a trade. The base `button` rule
 *    above (see its own 44px-touch-target-floor register comment) is a
 *    documented, developer-accepted WCAG 2.5.5 (AAA, 44px) trade for
 *    desktop density; WCAG 2.5.8 (AA, 24px) stays met everywhere. This
 *    override moves buttons from 30px toward — not all the way to —
 *    that AAA floor, specifically on the viewport class (phone) where
 *    the density argument for 30px never applied in the first place. No
 *    control anywhere loses height as a result of this rule.
 *
 * 2. Specificity stays untouched. The selector below is a bare `button`
 *    at (0,0,1) — the exact same specificity as the base rule it
 *    overrides. Adding a `:not()` qualifier here (e.g. to try to reach
 *    "only bare buttons") would RAISE specificity and start winning
 *    against every class-scoped control that declares its own geometry:
 *    `.theme-form .theme-option` (28px segments), `.copy-btn` (its
 *    synthesized 44px hit area via a `::before` pseudo-element),
 *    `.airline-card__zoom`, and the file-selector-button pseudo-element
 *    immediately above. All four must keep their own sizes untouched by
 *    this rule, and staying at (0,0,1) is what guarantees that — not a
 *    scoping selector that would need to be kept in sync with every new
 *    button-shaped component added later.
 *
 * 3. Source position, not specificity, is what makes this win. Neither
 *    this rule nor the base `button` rule adds specificity beyond
 *    (0,0,1), so the LAST one in source order wins the cascade — the
 *    same technique this file's own `.banner__pill` ordering already
 *    relies on. Placing this block after every other `button` rule in
 *    the file, including the `@media (min-width: 960px)` block above,
 *    is therefore the mechanism, not decoration. A harness check in
 *    test_status_pages.py pins this source-order relationship
 *    explicitly, in addition to the declared values.
 *
 * `959.98px` rather than a bare `959px`: avoids a one-device-pixel gap
 * on fractional-DPI viewports where 959px and 960px can both fail to
 * match the same physical width. */
@media (max-width: 959.98px) {
  button {
    height: 36px;
    font-size: 14px;
  }
}

/* Phase 15 D-10: the rules add-form's three-field column-stack layout
 * (15-UI-SPEC.md's Add-Form Shape) — a column stack on every viewport, no
 * side-by-side arrangement at any breakpoint, matching every existing
 * settings group (each a single control or a vertical stack). No new
 * spacing token, no new colour, no new type role, no new button/form-
 * control geometry: the Add rule button, every Delete button, the text
 * input and both <select> elements all inherit their existing site-wide
 * treatment unmodified. This phase adds zero entries to this file's
 * header accent-reservation list. */
.rule-add-form {
  display: flex;
  flex-direction: column;
  gap: var(--space-md);
}

.rule-add-form__field {
  display: flex;
  flex-direction: column;
}

/* B15 (22-AUDIT.md, 22-10-PLAN.md Task 2): "Connect calendar"/"Replace"
 * measured 830x30 at 1280px and 308px on a phone — the only full-width
 * primary button in the app, where every other primary is content-width.
 * No rule ever declared a width: the button is a direct child of this
 * COLUMN flex container, whose default `align-items: stretch` stretched
 * it across the cross axis. `align-self: flex-start` opts this one child
 * out of that stretch, which both sizes it to its content and leaves its
 * left edge equal to the field's above it. `width: auto` is the explicit
 * statement of the resulting intrinsic width.
 *
 * Scoped away from `.rule-add-form--inline` (the rules add-form), where
 * the container is a ROW and `align-self` would instead pull the Add
 * button off that row's `align-items: center` baseline.
 *
 * Accent is KEPT. B15's fix column is about geometry only, and greying
 * this button would be a second, unasked change — 22-UI-SPEC.md §1's
 * accent-reservation delta lists it as "kept, width only". */
.rule-add-form:not(.rule-add-form--inline) > button[type="submit"] {
  align-self: flex-start;
  width: auto;
}

/* B14 (22-10-PLAN.md Task 2): the normalised 24h value rendered beside a
 * native `<input type="time">`, which formats itself from the browser's
 * locale ("11:00 PM") rather than from the stored value ("23:00"). Inline
 * beside its control, in the label voice `.text-label` already supplies —
 * this rule adds only the spacing that keeps it off the input's edge. */
.field-inline-value {
  margin-left: var(--space-xs);
  white-space: nowrap;
}

/* 29-04-PLAN.md Task 2 (CFG-80): the `[hidden]`-versus-`display` guard,
 * joining `.refresh-pill[hidden]`, `.dirty-bar[hidden]`, `.resolve-
 * context[hidden]` and `.login-reveal[hidden]` above — the same trap
 * quick task 260921-n2n's `.resolve-context[hidden]` guard fixed: an
 * author `display` declaration on the base rule always beats the user-
 * agent stylesheet's `[hidden] { display: none }`, regardless of source
 * order, so setting `hidden` on an element governed by an undefended
 * base rule hides nothing at all. The base rule above declares NO
 * `display` of its own (only `margin-left`/`white-space`), so this guard
 * is defence in depth rather than a fix for an active collision — added
 * anyway, matching this stylesheet's own posture on every one of the
 * four siblings named above, because a future declaration on the base
 * rule (a `display: inline-flex` for some new layout need, say) would
 * silently reopen exactly this trap without it. */
.field-inline-value[hidden] {
  display: none;
}


/* =====================================================================
 * Phase 18 — companion audit / UX refactor.
 *
 * Everything below is appended, in source order after every earlier
 * rule, so it wins on position wherever specificity ties. Sections:
 *   1. Grouped navigation ("Advanced" label in the sidebar/dropdown)
 *   2. Page-header screen caption ("Screen: Plane frame")
 *   3. Home page widgets (status tiles, quick actions, now-showing,
 *      recent flights)
 *   4. Audit fixes: theme chips two-per-row on phones, chip-name
 *      ellipsis, settings input widths (the truncated "Uses server
 *      default" placeholder), the save bar pinned on phones too, the
 *      filter bar's Clear control staying on the count's line, and the
 *      airline-card placeholder link reset.
 * No new tokens; every colour is an existing token or an existing
 * color-mix wash strength. No new accent consumer: the quick-action
 * "on" state uses the status-ok colour, not the accent.
 * ===================================================================== */

/* 1. Grouped navigation ------------------------------------------------ */
.nav-group {
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
  margin-top: var(--space-md);
  padding-top: var(--space-sm);
  border-top: 1px solid var(--color-border);
}

.nav-group__label {
  display: block;
  padding: 0 var(--space-sm);
  margin-bottom: var(--space-xs);
  font-size: 12px;
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* 22-14-PLAN.md Task 2 (T11, X9/D-10): the `.mobile-nav__nav .nav-group`
 * override and the SECOND open-state max-height (640px) that used to sit
 * here are both DELETED. The dropdown no longer holds nav links or a
 * group label, so the first selector matched nothing; and the second
 * silently overrode the 420px declaration beside `.js .mobile-nav`
 * above it, which is T11's whole finding — two values for one state,
 * only one of them ever in effect. There is now exactly one open-state
 * declaration in this file, pinned by measurement against the reduced
 * French content at 390px. `.nav-group`/`.nav-group__label` above stay: the desktop
 * sidebar still renders the Advanced group. */

/* 2. Page-header screen caption --------------------------------------- */
.page-header__screen {
  margin: 0 0 var(--space-xs);
  font-size: 12px;
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* 3. Home page --------------------------------------------------------- */
.home-section {
  margin-bottom: var(--space-lg);
}

/* D4's HERO (CFG-44, 24-08-PLAN.md Task 1): Home's top read as ONE thing
 * — the shared Frame strip, the three status tiles with their battery
 * ring, and the day band — instead of three stacked blocks.
 *
 * THE COMPOSITION IS PROXIMITY, AND IT IS A MEASUREMENT. The three parts
 * sit one --space-md (16px) apart inside this container and the
 * container sits one --space-lg (24px) above the picture row beneath it:
 * bound tighter than they are separated, which is the whole of the
 * claim and is asserted in companion/test_browser_ux.py as two numbers
 * rather than left to the eye. `display: grid` is what makes `gap` mean
 * anything here — under `display: block` the gap is inert and, with the
 * children's own margins zeroed below, the three parts would touch.
 *
 * NO SURFACE OF ITS OWN, deliberately. Two of the three parts are
 * already cards (.frame-strip is an accent .stat-tile, .day-band is a
 * .page-section) and the third is a grid of three more, so a background
 * and a border here would be a card holding cards. The alternative —
 * taking .day-band's own card away so the hero could carry one — was
 * rejected on a measured ground and not a stylistic one: the band's
 * canvas measures 278px at the 360px floor precisely because that card
 * pads it, and draw.py's DAY_BAND_MIN_MARK_SPACING_PERCENT is derived
 * from that number. Widening the canvas would silently invalidate the
 * spacing constant that keeps two check-in marks readable as two.
 *
 * ONE COLUMN AT EVERY WIDTH, and the multi-column behaviour belongs to
 * the parts (the tiles go one-across to three-across, the strip's cells
 * column to row). A hero that split into columns of its own would have
 * to put the band in one of them, and the numbers refuse it: this
 * container measures 880.00px at a 1280px viewport (read off Chromium
 * directly, not inferred from a mutation's own message — a first draft
 * of this comment read 928 off MC8's child offset, which was that
 * child's LEFT EDGE and not this box's width, and was wrong by 48px).
 * A one-third column of 880 less this rule's own 16px gap is 288px —
 * against the 310px a band CARD needs to keep its canvas at 278 (278
 * plus .page-section's own 2 x --space-md of padding), so the canvas
 * would come out at 256. The band would be NARROWER inside a wide
 * desktop hero than it is at the 360px floor, which is the "fits by
 * shrinking its parts" failure this requirement exists to refuse. */
.home-overview {
  display: grid;
  gap: var(--space-md);
  margin-bottom: var(--space-lg);
}

/* The parts' own bottom margins, surrendered to the container. Each of
 * .frame-strip, .home-section and .page-section carries
 * `margin-bottom: var(--space-lg)` of its own, and a grid item's margin
 * does not collapse into the gap — it ADDS to it, so without this rule
 * the hero's internal rhythm would be 40px and its own bottom margin
 * 48px. One owner of the spacing, which is what "one composition"
 * means in CSS.
 *
 * THE DOUBLED SELECTOR IS LOAD-BEARING AND WAS MEASURED, not a
 * precaution. `.home-overview > *` is (0,1,0) — the universal selector
 * contributes nothing — which TIES `.frame-strip`'s own (0,1,0)
 * `margin-bottom: var(--space-lg)` and leaves the outcome to source
 * order; that rule is declared ~360 lines further down this file, so it
 * won. Measured in Chromium before the fix: the strip and the status
 * tiles sat 40.00px apart at 360px while the tiles and the band sat at
 * the intended 16, because `.home-section` and `.page-section` are both
 * declared ABOVE this rule and lost while `.frame-strip` did not. The
 * doubled form is (0,2,0) and wins deterministically wherever the parts'
 * own rules happen to sit — the same cascade hazard, and the same fix,
 * that `.page-section.banner--anomaly` and the
 * `.battery-trend-section` status borders already document above. The
 * universal child is kept rather than a hand-list of the three parts, so
 * a fourth part added to the hero later surrenders its margin too. */
.home-overview.home-overview > * {
  margin-bottom: 0;
}

.home-section > .text-heading {
  margin-top: 0;
}

/* B2 (22-07-PLAN.md Task 2): explicit, greppable align-items: stretch —
 * already the inherited value from the shared .dashboard-grid rule
 * above (a class this element also carries), but restated here rather
 * than left implicit, matching that rule's own "kept explicit" idiom.
 * The audit's measured 112/112/131px on a phone was primarily this
 * grid's single column stacking three unequal-content rows (the
 * Flight-data tile's own extra re-embedded-verdict line, B2's other
 * half, fixed in Task 1) — this declaration is the belt to that
 * fix's own suspenders. */
.home-status-grid {
  margin-bottom: var(--space-sm);
  align-items: stretch;
}

/* 24-04-PLAN.md Task 3 (CFG-40): the Battery tile's ring, BESIDE its two
 * text lines rather than above them. Emitted only when there is a ring,
 * so a device with no reading renders this tile exactly as it did before
 * this wrapper existed.
 *
 * Beside, not stacked, for a measured reason: `.dashboard-grid` stretches
 * every tile in a row to the tallest sibling, so a taller Battery tile
 * does not merely grow itself — it pushes the whole row down. The ring's
 * box (36px) is smaller than the two text lines it sits next to (24px
 * verdict + 4px gap + 19.6px detail = 47.6px, measured at the 360px
 * floor), so this row contributes no height at all.
 *
 * `align-items: center` is what keeps the aspect-locked <svg> at its own
 * emitted size — a flex row's default `stretch` would pull it to the
 * row's full height and letterbox it. No `flex: none` and no
 * `min-width: 0` here either, for the reason measured and recorded
 * against `.battery-readout-row` above: both were written, both were
 * measured at 360px, and both changed nothing. */
.stat-tile__gauge {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
}

.widget-verdict {
  margin: 0 0 var(--space-xs);
  font-weight: var(--weight-semibold);
}

.widget-detail {
  margin: 0;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

.home-columns {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--space-lg);
}

.home-columns > .home-section {
  margin-bottom: 0;
}

/* 21-04-PLAN.md Task 3 (D-04): the picture/recent-flights row's own
 * 3:2 desktop split — D-04's "the picture column is the wider one,
 * about 3:2". This compound selector (specificity 0,2,0) outranks the
 * ambient `.home-columns` >=960px equal-split rule above at every
 * width, so this row stays one column through 1023px and only then
 * becomes 3fr/2fr — never the generic 1fr/1fr split. Scoped by the
 * compound class so no other `.home-columns` consumer anywhere else
 * in the app is affected. */
.home-columns.home-picture-row {
  grid-template-columns: 1fr;
}

@media (min-width: 1024px) {
  .home-columns.home-picture-row {
    grid-template-columns: 3fr 2fr;
    /* flex-start, not the bare `start` keyword: test_status_pages.py
     * pins the whole file to exactly one remaining declaration of that
     * bare keyword's own align-items value (.dashboard-shell's own
     * D-21 sticky sidebar) — the same precedent the deleted .home-hero
     * rule already followed for this exact reason. Grid treats
     * flex-start as an alias of that value, so the effect here — the
     * shorter column never stretching to match the taller one's own
     * height — is identical. */
    align-items: flex-start;
  }
}

.quick-actions {
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
}

.quick-action--on {
  border-left-color: var(--color-status-ok);
}

.quick-action--off {
  border-left-color: color-mix(in srgb, var(--color-text) 30%, transparent);
}

.quick-action--neutral {
  border-left-color: var(--color-border);
}

.quick-action__label {
  display: inline-flex;
  align-items: center;
  gap: var(--space-xs);
  font-size: 12px;
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

.quick-action__icon {
  flex: none;
}

.quick-action__state {
  font-weight: var(--weight-semibold);
}

.quick-action__form {
  flex: none;
  margin: 0;
}

/* 23-07-PLAN.md Task 1 (D2/CFG-36): RETARGETED IN PLACE. This rule used
 * to read `.quick-action__form button { min-width: 96px; }` — a floor
 * that existed so the strip's two ACTION-labelled buttons ("Switch off",
 * "Turn on", and their longer French wordings) held one width between
 * them as the wording changed with the state. Those buttons are gone:
 * the control is now a real role="switch" whose state lives in
 * aria-checked and whose width is its own track, so a text-driven
 * minimum has nothing left to stabilise and would only stretch the
 * switch's hit box sideways past its own paint. The 44px floor below is
 * what replaces it, and it is a touch-target contract rather than a
 * typographic one. */
.quick-action__form {
  display: flex;
  align-items: center;
}

/* --- The switch (D2/CFG-36, 23-07-PLAN.md Task 1/2) -------------------
 *
 * One component, three consumers: the Frame strip's Screen and Quiet
 * hours cells and the Device page's Diagnostic LED card. The element is
 * a <button type="submit"> inside a real <form>, so everything here is
 * appearance only — with this stylesheet missing entirely the control is
 * still a button that posts, which is the same floor the markup keeps.
 *
 * 44x44 is met DIRECTLY on the button rather than traded for a padded
 * label: the track itself is 52x32, and the button's own padding takes
 * the hit box past the WCAG 2.5.5 floor in both axes at every width
 * including 360px. It is the button, not the track, that is focusable
 * and pressable, which is why the floor lives here.
 *
 * The state is NEVER carried by colour alone: the thumb's position
 * changes, aria-checked changes, and the translated state word beside it
 * changes. The track colour is the fourth signal, not the first — rule 2
 * of the contrast gate, and the reason this needs no per-theme override.
 *
 * Both transitions spend var(--motion-fast). A switch is the definition
 * of motion somebody is waiting on: the flip has to land under the
 * finger before the request that confirms it has even left. */
.switch {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  min-width: 44px;
  min-height: 44px;
  height: auto;
  padding: var(--space-xs);
  background: none;
  border: 0;
  border-radius: var(--radius-control);
}

/* The global button:hover/button:active treatments are a wash and a
 * press-scale written for a labelled button; on a switch they would
 * paint a box around a control whose whole visual is the track inside
 * it. The track's own colour change is the feedback. */
.switch:hover,
.switch:active {
  background: none;
  border: 0;
  transform: none;
  box-shadow: none;
}

.switch__track {
  position: relative;
  display: block;
  width: 52px;
  height: 32px;
  border-radius: 16px;
  background: var(--color-secondary);
  border: 1px solid var(--color-border);
  transition: background-color var(--motion-fast) ease, border-color var(--motion-fast) ease;
}

.switch__thumb {
  position: absolute;
  top: 3px;
  left: 3px;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--color-dominant);
  box-shadow: var(--shadow-card);
  transition: transform var(--motion-fast) ease;
}

.switch[aria-checked="true"] .switch__track {
  background: var(--color-accent);
  border-color: var(--color-accent);
}

.switch[aria-checked="true"] .switch__thumb {
  transform: translateX(20px);
}

/* The settings-page switch row (the Diagnostic LED card). The strip's
 * own cells already have a state-plus-control row from
 * `.frame-strip__row--state`; a settings card has no such grid, so this
 * is the one rule that gives the state word and the switch the same
 * side-by-side relationship inside `.theme-status`. `space-between`
 * rather than a fixed gap: at 360px the state word stays left and the
 * switch stays at the edge the thumb travels toward. */
.settings-switch-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-md);
}

/* The unconfirmed window. companion/static/quick-switch.js marks the
 * REGION (not the control) while its fetch is in flight, which is the
 * same marker companion/static/freshness.js's swap already skips — one
 * attribute doing both jobs, so a control that looks settled can never
 * be a control the refresh loop is still willing to repaint. Deliberately
 * subtle: the flip has already happened and is almost always right; a
 * spinner here would make a fast action feel slow, which is why D2's
 * "300ms spinner" clause is implemented as this state and no fixed
 * delay. */
[data-pending] .switch__track {
  opacity: 0.7;
}

/* --- The failure toast (D2/CFG-36, V7) -------------------------------
 *
 * Transient, never a banner: the state it reports on has already rolled
 * back to the truth in the switch itself, so this is an announcement
 * rather than a standing condition, and a permanent block would outlive
 * the thing it describes.
 *
 * Anchored at the TOP of the viewport on purpose. The bottom edge used
 * to be claimed by two `position: fixed` components — `.tab-bar`
 * (z-index 20) and the dirty save bar (z-index 30) — and stacking a
 * third there would have meant re-deriving their geometry. The save bar
 * is retired outright by 27-04-PLAN.md (CFG-63); `.tab-bar` is now the
 * bottom edge's only fixed occupant, but this toast stays anchored at
 * the top regardless — repositioning it was no part of what that plan
 * changed. The top edge is free at every breakpoint. `z-index: 40`
 * continues that same declared order upward rather than inventing a
 * parallel one.
 *
 * The empty element is rendered on every page and is not `hidden`: a
 * live region added to the accessibility tree at announce time is one
 * screen readers routinely miss. `opacity: 0` plus `pointer-events:
 * none` keeps it invisible and unclickable while leaving it in the tree,
 * which `visibility: hidden` or `display: none` would not. */
.quick-toast {
  position: fixed;
  top: var(--space-md);
  left: 50%;
  z-index: 40;
  max-width: calc(100vw - 2 * var(--space-lg));
  padding: var(--space-sm) var(--space-md);
  border-radius: var(--radius-control);
  background: var(--color-text);
  color: var(--color-canvas);
  box-shadow: var(--shadow-card-hover);
  font-size: 13px;
  font-weight: var(--weight-semibold);
  opacity: 0;
  pointer-events: none;
  transform: translate(-50%, -8px);
  transition: opacity var(--motion-fast) ease, transform var(--motion-fast) ease;
}

.quick-toast.is-visible {
  opacity: 1;
  transform: translate(-50%, 0);
}

/* 22-04-PLAN.md Task 2 (B13): every Frame-strip cell — both switch cells
 * and the update cell alike — shares this ONE wrapper: padding, hairline,
 * radius and surface, plus a fixed three-row internal grid (label, then
 * state plus control, then caption; layout.py's _frame_strip_cell_html()
 * is the one Python builder for all three). A cell with nothing to put
 * in a row still renders that row's own (empty) track, which is what
 * keeps all three cells' rows lined up — today (before this task) the
 * Screen cell puts its button on the label row while the Quiet-hours
 * cell pushes it to its own row, which is why the three cells measure
 * 119/165/69px tall instead of one shared height. `.quick-action`'s own
 * `--on/--off/--neutral` left-edge colour below stays exclusive to the
 * two switch cells (added below, on the SAME element, never here) — the
 * update cell never carries `.quick-action` at all, so it can never
 * paint a control-state signal it does not have. */
.frame-strip__cell {
  display: grid;
  grid-template-rows: auto auto auto;
  row-gap: var(--space-sm);
  padding: var(--space-sm) var(--space-md);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  background: var(--color-canvas);
}

/* The switch cells' own control-state left edge (D-01) — `.quick-action`
 * is added alongside `.frame-strip__cell` on the SAME element; only the
 * border WIDTH lives here, the colour comes from `--on`/`--off`/
 * `--neutral` above, matching the pre-restructure contract exactly. */
.frame-strip__cell.quick-action {
  border-left-width: 4px;
}

/* Row 2 (state plus control): state text and the switch's own <form>
 * side by side, matching the pre-restructure `.quick-action` row's own
 * layout — now scoped to one grid row instead of the whole cell. Every
 * cell (including the update cell, whose row 2 is the headline
 * paragraph alone) shares this rule; it is a no-op for a cell with only
 * one child in this row. */
.frame-strip__row--state {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-md);
  min-width: 0;
}

/* 27-08-PLAN.md Task 2 (CFG-69): the Quiet hours caption's own link to
 * the schedule fields (companion/layout.py's frame_strip_html(), the
 * quiet cell's one write site). `.text-link` supplies the colour and
 * underline; this class supplies the FLOOR — a bare inline `<a>` hit-
 * tests to its own text line-height, well under 44px, and this sits in
 * the same dense strip cell as a switch (the geometry that produced the
 * 30x45 pager and the 43x43 handle elsewhere in this file). `display:
 * inline-flex` plus `min-height` is the native-input idiom this file
 * already uses for a floor that does not depend on font metrics, rather
 * than a padding value tuned to one line-height that a copy change could
 * silently undercut. No `::before` synthesis: unlike the small icon
 * controls in the shared register above, this control's own text is
 * already wider than 44px, so there is nothing to synthesize on that
 * axis. Block-level in its own right (the delay sentence above it is a
 * `<p>`, so this `<a>` starts its own line by construction) — it is a
 * second, independent affordance in the caption row, not a run-on
 * continuation of the delay sentence. */
.frame-strip__schedule-link {
  display: inline-flex;
  align-items: center;
  min-height: 44px;
}

/* The Frame strip (D-01, D-02, D-05; 22-04-PLAN.md Task 2, B13) — one
 * shared surface holding the Screen switch, the Quiet hours switch and
 * the next-update headline, atop Home and Display alike
 * (layout.frame_strip_html()). Reuses .stat-tile/.stat-tile--accent
 * verbatim for the card surface itself (padding, border-radius,
 * hairline, accent top-border) — this rule adds only the flex layout
 * that lays the three cells out side by side; each cell's own chrome
 * now comes from `.frame-strip__cell` above, not from this container.
 * `align-items: stretch` (was `center`) is B13's own fix: all three
 * cells now report equal offsetHeight at every width, because a
 * flex-stretched item's own internal grid can only ever be as tall as
 * its OWN three rows demand at minimum — never shorter than its
 * siblings. */
.frame-strip {
  margin-bottom: var(--space-lg);
}

.frame-strip__cells {
  display: flex;
  flex-wrap: wrap;
  align-items: stretch;
  gap: var(--space-lg);
}

.frame-strip__cell.quick-action,
.frame-strip__cell--update {
  flex: 1 1 220px;
}

@media (max-width: 699.98px) {
  .frame-strip__cells {
    flex-direction: column;
    align-items: stretch;
  }
  .frame-strip__cell.quick-action,
  .frame-strip__cell--update {
    flex-basis: auto;
  }
}

.now-showing {
  margin: 0;
}

/* 20-04-PLAN.md Task 1 (20-UI-SPEC.md §B): .preview-frame__image joins
 * this selector for its shared declarations only (the white backing
 * behind a rendered panel PNG, the hairline, the radius) — extending
 * this rule rather than restating the same white-backing literal on a
 * second rule introduces no new colour literal. .now-showing__image's
 * own extra sizing (the 360px cap, the auto margin) moves to its own
 * rule immediately below so .preview-frame__image is never affected by
 * it.
 *
 * 22-07-PLAN.md Task 2 (B18): `img.recent-flight__thumb` (the REAL
 * thumbnail <img> only — the tag qualifier is deliberate, not
 * decorative: the dashed placeholder variant is a <span> that ALSO
 * carries the bare .recent-flight__thumb class for its shared 40x40
 * sizing below, and without the `img` qualifier this selector would
 * tie with `.airline-card__placeholder, .recent-flight__thumb--
 * placeholder`'s own dashed-border rule at equal specificity, letting
 * source order — not intent — decide which border/background wins on
 * the placeholder span) joins here too — one more selector, no new
 * colour literal, no new radius — so a real illustration carries the
 * SAME white-backing/hairline/radius weight as the frame picture and
 * the theme preview. Its own width:40px/height:40px/object-fit:cover/
 * border-radius further down this file (declared AFTER this rule,
 * higher specificity via the same `img.` qualifier) still wins for
 * sizing; only the border/background/display are new here. */
.now-showing__image,
.preview-frame__image,
img.recent-flight__thumb,
img.history-card__thumb {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  background: #ffffff;
}

.now-showing__image {
  max-width: 360px;
  margin: 0 auto var(--space-sm);
}

/* 22-09-PLAN.md Task 2 (X5): the Flights phone card's own thumbnail
 * sizing — the Airlines gallery's 450x132 served frame in a fixed
 * 56px-tall box with `contain` fitting, so a wide livery panel is
 * letterboxed rather than cropped. Declared AFTER the shared rule
 * above and with the same `img.` qualifier, exactly like
 * `img.recent-flight__thumb`'s own sizing rule, so it wins for width/
 * height while the white backing, the hairline and the radius still
 * come from that one shared rule — one rule, an additional selector,
 * no new colour literal. */
img.history-card__thumb {
  height: 56px;
  width: auto;
  max-width: 100%;
  object-fit: contain;
}

.now-showing__caption {
  text-align: center;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

.recent-flights {
  list-style: none;
  margin: 0 0 var(--space-sm);
  padding: 0;
}

/* 20-04-PLAN.md Task 1 (D-17.2, 20-UI-SPEC.md §B): gains a fourth,
 * leading 40px thumbnail column — was 3 columns (callsign, detail,
 * time). The sub-960px override further down this file is retargeted
 * to match. */
/* Phase 21 polish (D-04): the list now lives in the narrower right-hand
 * column of .home-columns.home-picture-row at every width, so the
 * four-column desktop layout (which starved the detail column down to
 * one character per line and let the nowrap time overlap the callsign)
 * is retired: thumbnail / callsign / time on the first row, the detail
 * line under them, at every width — the former sub-960px shape. */
/* Quick task 260913-dgh: the row's layout MECHANISM changes from grid to
 * a wrapping flex line, because the callsign was being starved to
 * nothing and a grid row has nowhere to put what does not fit.
 *
 * Measured cause. The template was a fixed 40px thumbnail track, then
 * `minmax(0, 1fr)` for the callsign, then `auto` for the time. The time
 * holds the clock and the relative age under the one-line contract
 * B18 set below, so its min-content and max-content widths are the same
 * number and that `auto` track resolves to one rigid figure at EVERY
 * width — 155.1px in French, 147.4px in English. The thumbnail track is
 * a literal. So the callsign track is the only one that can yield, and
 * its explicit 0 floor means it yields the whole way: measured in a real
 * Chromium against the seeded fixture, the callsign box was 10.9px (FR)
 * / 18.6px (EN) for 57.8px of content at a 320px viewport, and 50.9px
 * (FR) for the same 57.8px at 360px — a common Android width. That 0
 * floor IS the defect. A flex item's automatic minimum size holds a
 * line at min-content for free; `minmax(0, 1fr)` is precisely the
 * declaration that opts out of it.
 *
 * No media query can express the condition, because the viewport does
 * not predict the row: the row is 238px inside a 320px viewport, 278px
 * at 360px, 308px at 390px, 686px at 768px — and back DOWN to 292.4px
 * at 1280px, where the desktop sidebar and the two-column picture row
 * narrow it again. A container query can read that width, and it was
 * measured here rather than dismissed. It works, and it is still
 * rejected, on its own numbers: it needs a hard-coded threshold, and
 * the content that threshold has to clear grows without a ceiling —
 * layout.relative_age_text()'s day bucket has no upper bound, so the
 * age string only ever gets wider. Measured with the age grown to
 * "il y a 365 j", the 1280px callsign track falls to 57.5px for 57.8px
 * of content: the DESKTOP starves too, and was only ever ~7px away. Any
 * threshold that covers a 292.4px desktop row therefore charges the
 * extra line at 1280px unconditionally, in both languages, forever.
 *
 * The wrapping flex line asks the question that actually matters — does
 * this content fit on one line — instead of naming a width at which it
 * might not. Line breaking runs on the items' own content widths, so
 * the time drops to a second line exactly where there is no room for it
 * and nowhere else. Measured after the change, callsign box against
 * callsign content, both languages: 57.8 for 57.8 at 320, 360, 390, 768
 * and 1280 — clipped at no width in either language — with the second
 * line appearing only at 320px (both languages) and at 360px in French.
 * With the age stressed to "il y a 365 j" the same rule wraps at 1280px
 * in French and stays on one line at 1280px in English, which is the
 * per-language, per-content answer no fixed threshold can give.
 *
 * Three things this deliberately is NOT, each measured first:
 *   - NOT a min-content floor on the callsign track. That stops the
 *     clipping, but the row then grows to 284.9px inside a 238px column
 *     and Home's horizontal scrollbar comes straight back
 *     (documentElement.scrollWidth 343 FR / 335 EN against 320) — the
 *     exact B11 defect quick task 260913-bjy closed.
 *   - NOT a release of the time's one-line contract. Dropping that
 *     under a container query changed the resolved tracks by literally
 *     nothing, because a grid `auto` track is sized from max-content
 *     and never consults the min-content such a release lowers. It
 *     would have shipped inert.
 *   - NOT an unconditional second line for the time. That fits
 *     everywhere and charges every row 23.6px of extra height at 768px,
 *     where there are 686px of room and nothing to fix.
 *
 * `.status-row` further down this file already ships this exact
 * combination (flex, wrapping, baseline-aligned, one shared gap pair) —
 * this row joins an existing pattern rather than inventing one. The
 * visual order still differs from source order (the detail line paints
 * under the callsign and time, and is the third child): that divergence
 * is unchanged from the grid placement it replaces, so the reading
 * order a screen reader announces is exactly what it announced
 * before. */
.recent-flight {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-xs) var(--space-md);
  align-items: baseline;
  padding: var(--space-sm) 0;
  border-bottom: 1px solid var(--color-border);
}

.recent-flight:last-child {
  border-bottom: 0;
}

.recent-flight__callsign {
  font-weight: var(--weight-semibold);
}

.recent-flight__detail,
.recent-flight__time {
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* The detail line keeps the full-width-underneath placement the grid
 * gave it, expressed in the new mechanism: a 100% flex basis puts it
 * alone on its own line at every width, and the `order` bump keeps it
 * last so the time still shares the FIRST line with the callsign
 * wherever that line has room (the time is the fourth child in source
 * order, the detail the third, so without the bump flex would paint
 * them the other way round). The inline-start margin reproduces the old
 * `grid-column: 2` start exactly — the thumbnail's own 40px box plus
 * the row's column gap — so the detail still never runs back under the
 * thumbnail. The basis is allowed to shrink so that margin comes out of
 * the line rather than overflowing it. */
.recent-flight__detail {
  overflow-wrap: anywhere;
  order: 1;
  flex: 1 1 100%;
  margin-inline-start: calc(40px + var(--space-md));
}

/* B18 (22-07-PLAN.md Task 2): the recent-flight time is one line — the
 * clock and the relative age (joined by .cell-inline-sep) can never
 * wrap onto a second line. `overflow-wrap: anywhere` used to be
 * shared with .recent-flight__detail above (written for that column's
 * own long airline/route text, not this short clock+age pair) and is
 * dropped here rather than merely overridden, since the two mechanisms
 * fight: overflow-wrap can still force a mid-word break to avoid
 * overflowing a width cap even under white-space: nowrap.
 *
 * Quick task 260913-bjy (B11's missing fourth surface, after 22-09 on
 * Flights, 22-11 on Airlines and 22-12 on Health): the PERCENTAGE WIDTH
 * CAP this rule also carried is deleted, because it could only ever be
 * a lie to the layout engine. This item sits in .recent-flight's third,
 * content-sized `auto` track, so that track resolves to the item's own
 * max-content width — and a percentage cap then resolves against that
 * same content-derived width, clamping the box to 60 per cent of
 * exactly the content it was meant to bound. Under white-space: nowrap
 * nothing can reflow into the smaller box, so the relative age simply
 * painted the other 40 per cent outside it. Measured at EVERY width
 * from 320px to 1440px, in both languages and both themes, always the
 * same ratio: clientWidth 88px against a scrollWidth of 147px in
 * English, 93px against 155px in French. Below 960px that overflow
 * escaped the VIEWPORT too and gave Home a horizontal scrollbar
 * (390px viewport: documentElement.scrollWidth 411 FR / 408 EN); at
 * 960px and above it still escaped the CARD (the age painting to 1253
 * against a row ending at 1191 at 1280px), which is why nine plans of
 * desktop review never saw it. With the cap gone the track IS the line:
 * the one-line intent above is preserved verbatim, and the age sits
 * inside the card at every width companion/test_browser_ux.py measures.
 *
 * A wrapping flex container was measured here FIRST and rejected on the
 * measurement, not on taste: the `auto` track always grows to
 * max-content before the neighbouring minmax(0, 1fr) track yields, so
 * flex-wrap never engaged at any width from 320px up and would have
 * shipped inert — the same class of dead declaration the sticky-header
 * entry in the design-system notes was retired for.
 *
 * Quick task 260913-dgh, two corrections to the present tense above,
 * neither of which touches the one-line contract this rule exists for.
 * (1) This item no longer sits in a grid track at all — the row is a
 * wrapping flex line now, for the reasons measured in full above the
 * .recent-flight rule — so the grid-only end-alignment this rule used
 * to declare is replaced by an auto inline-start margin, which pushes
 * the item to the right of whichever flex line it lands on. That is the
 * same paint position on the first line, and it keeps the time
 * right-aligned on its own line when it wraps. (2) The rejected
 * wrapping-flex experiment recorded above was a flex container INSIDE
 * this item, wrapping the clock away from the age, and it stays
 * rejected for the reason given. The row above is a different thing
 * entirely: it wraps this whole item away from the callsign, never
 * inside it, so the clock and the age still cannot be split. */
.recent-flight__time {
  text-align: right;
  margin-inline-start: auto;
  white-space: nowrap;
}

/* 20-04-PLAN.md Task 1 (D-17.2): the thumbnail's own 40x40 box.
 * object-fit: cover absorbs the artwork's own aspect ratio. The shared
 * white-backing/hairline declarations now live on the `img.recent-
 * flight__thumb` rule above (22-07-PLAN.md Task 2, B18); this rule's
 * OWN selector is tag-qualified the same way (`img.`, specificity
 * 0-1-1, matching that rule's) so it still overrides that rule's
 * width:100%/height:auto for the real thumbnail by source order alone
 * — a bare `.recent-flight__thumb` selector here would be a LOWER
 * specificity than the shared rule's `img.` qualifier and would lose
 * regardless of being declared later. `.recent-flight__thumb--
 * placeholder` (the dashed <span> variant) joins this same rule for
 * its own matching 40x40/radius/flex box; its own dashed border/
 * canvas-fill live on the separate rule immediately below, reusing
 * `.airline-card__placeholder`'s exact values (never a new literal) —
 * which is what makes a missing thumbnail match the real ones in
 * weight (B18). */
img.recent-flight__thumb,
.recent-flight__thumb--placeholder {
  width: 40px;
  height: 40px;
  border-radius: var(--radius-control);
  object-fit: cover;
  flex: none;
}

.recent-flight__thumb--placeholder {
  border: 1px dashed var(--color-border);
  background: var(--color-canvas);
}

@media (min-width: 960px) {
  .home-columns {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  }
}

/* 4. Audit fixes ------------------------------------------------------- */

/* Settings text-like inputs: the number/time/url fields had no width
 * rule at all and sat at the UA's intrinsic width — which is what
 * truncated "Uses server default" to "Uses se". */
.config-form input[type="number"],
.config-form input[type="url"],
.config-form input[type="text"] {
  width: 100%;
  max-width: 28rem;
}

.config-form input[type="time"] {
  min-width: 9rem;
}

/* B17 (22-AUDIT.md, 22-10-PLAN.md Task 3): the wake-interval field held
 * a 448px-wide box for a 3-4 digit number, inherited from the global
 * `input, select` rule's own sizing on this page. `8ch` is a CONTENT-FIT
 * width in character units — the correct unit for "sized for its
 * content", and one of the exactly three off-scale literals
 * 22-UI-SPEC.md §1 permits, alongside the safe-area inset and the tab
 * bar's 56px. The 96px minimum keeps the box usable in a narrow or
 * condensed font where 8ch would collapse.
 *
 * DELIBERATELY NO HEIGHT. The global `input, select` rule's 44px
 * `min-height` is the touch-target register's "kept" entry for
 * `<input type="number">` (11-03-PLAN.md), and `width` does not touch
 * `min-height`, so that entry still holds verbatim after this rule. Do
 * not add a height here.
 *
 * Selected by `name`, not by id: this file carries no id selector at all
 * and should not gain its first one here. `.config-form input[name=
 * "..."]` is (0,2,1) — EQUAL to the `.config-form input[type="number"]`
 * rule directly above, and placed immediately after it, which is this
 * file's own documented equal-specificity-plus-source-order mechanism
 * (`.logout-form button`). Raising specificity
 * instead would start beating rules this one has no business beating.
 *
 * It must live HERE, beside that rule, rather than up with the other
 * field-layout rules: the phase-18 `width: 100%` above is what it
 * narrows, and splitting the two apart is how the next reader ends up
 * re-deriving why a `width` declaration two thousand lines earlier has
 * no effect. NOTE for the phase-closing sweep: that phase-18 rule exists
 * so "Uses server default" is not truncated to "Uses se"; in a 96px box
 * this field's placeholder truncates again whenever no interval is
 * stored. B17's "sized for its content" is the later, narrower decision
 * and wins for this one field, but the interaction is real. */
.config-form input[name="wake_interval_s"] {
  width: 8ch;
  min-width: 96px;
}

/* Theme-chip names must never wrap and stagger the row. */
.theme-chip__name {
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}

/* Phase 18's A-18 fix: the filter bar's count and Clear control stay
 * together on one line instead of Clear wrapping alone underneath.
 *
 * SUPERSEDED in mechanism by 22-09-PLAN.md Task 3 (B11) — this is A-18
 * REGRESSING A SECOND TIME, named as such so a future reader sees it has
 * now broken twice. Two `nowrap` siblings in one `flex-wrap: wrap`
 * container do not wrap as a unit: `nowrap` stops a break INSIDE each
 * one, and nothing stopped the flex container breaking BETWEEN them, so
 * at 390px Clear still dropped alone onto its own line. The pair is
 * wrapped in one `.filter-bar__meta` group below instead, which is a
 * single flex item and therefore moves whole or not at all.
 *
 * `.filter-bar__count`'s own `margin-left: auto` was KEPT here by
 * 22-09-PLAN.md Task 3, with a stated expiry: "Airlines and Health still
 * render a bare count with no wrapper (plans 22-11 and 22-12 adopt the
 * same group element on their own filter bars), and removing it here
 * would un-right-align both of them."
 *
 * RETIRED by 22-12-PLAN.md Task 3, which is the plan that landed the
 * third and last of those three adoptions. All three filtered pages now
 * render their count inside `.filter-bar__meta` (grep: exactly three
 * `.filter-bar__count` call sites, in history_page.py, airlines_page.py
 * and health_page.py, each one a child of that group), and that group
 * carries the auto margin itself. Inside a content-sized flex item there
 * is no free space for a second auto margin to absorb, so the
 * declaration had become genuinely inert rather than merely redundant —
 * this removal changes no rendered pixel, and 22-11-SUMMARY.md's own
 * note named this plan as the one that could honestly make it. If a
 * fourth filter bar is ever added, wrap its pair in the group; do not
 * reinstate this rule. */

.filter-bar [data-filter-clear] {
  white-space: nowrap;
}

/* B11 (22-09-PLAN.md Task 3, 22-UI-SPEC.md §2): the count + Clear group.
 * ONE flex item holding both controls, so they wrap as a unit and Clear
 * can never drop alone onto its own line again. The auto left margin
 * moves here with them (it is what pushes the pair to the end of the
 * bar, the role it played on the count alone before).
 *
 * Written page-agnostic on purpose: Airlines (22-11) and Health (22-12)
 * adopt it by adding this same wrapper element around their own count
 * and Clear, with NO per-page variant. Both pages' Clear controls stay
 * converged on the existing `.filter-bar [data-filter-clear]` rule
 * (06.6.4 D-08) — that selector is not forked here, and no second clear
 * class is introduced. */
.filter-bar__meta {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  white-space: nowrap;
  margin-left: auto;
}

/* A manually-resolved airline with no artwork yet renders the same
 * dashed placeholder a gap card does; and the zoom trigger, when it is
 * an <a>, must not paint link styling over its contents. */
a.airline-card__zoom {
  color: inherit;
  text-decoration: none;
}

@media (max-width: 959.98px) {
  /* Two chips per row on a phone instead of a 36-row single column. */
  .theme-chip {
    width: calc(50% - var(--space-sm) / 2);
  }

  /* 27-04-PLAN.md (D-04/CFG-63): the save bar this rule used to pin to
   * the phone viewport's bottom corner — GEOMETRIC SEPARATION FIRST
   * against the tab bar (22-14-PLAN.md Task 3, D-10/T7), then a belt-
   * and-braces z-index: 30 above the tab bar's 20 — is retired outright,
   * along with the desktop rule that carried this one's full stated
   * reason. `.save-status`, further down this file, is not fixed to the
   * viewport at either breakpoint, so it needs no phone-side pin at
   * all — see that rule's own comment for why.
   *
   * T7's phone-side content clearance is NOT declared here. It has to
   * beat `.has-tab-bar .page-content`, which targets the same element at
   * the same (0,2,0) specificity, so it is declared AFTER it — in the
   * tab bar's own block at the foot of this file, next to the rule it
   * supersedes rather than 900 lines away from it.
   *
   * SUPERSEDED by 28-08-PLAN.md Task 2 (CFG-77/CFG-78), 2026-09-16: the
   * bar is back. GEOMETRIC SEPARATION FIRST, restored unchanged: the
   * offset below already gains the tab bar's 56px height so the two
   * never overlap in the normal case, arithmetic rather than a stacking
   * assurance. The z-index stacking order is the belt-and-braces half,
   * for a bar that wraps taller than its own gap: `z-index: 30`, the
   * SAME single value the desktop rule declares (one value across both
   * breakpoints, above the tab bar's 20), because the save bar is the
   * user's ACTIVE TASK and the tab bar is ambient chrome — a save bar
   * covering a tab is acceptable, a tab bar covering Save is not. This
   * file's `.dirty-ready`-scoped clearance rule is NOT restored (see
   * the tab bar's own block, further down this file, for why the
   * scoping mechanism itself had to change, not just its figure). */
  .dirty-bar {
    position: fixed;
    left: var(--space-md);
    right: var(--space-md);
    bottom: calc(var(--space-md) + 56px + env(safe-area-inset-bottom, 0px));
    margin: 0;
    z-index: 30;
    flex-wrap: wrap;
  }

  /* 22-04-PLAN.md Task 2: `.quick-action` no longer establishes its own
   * flex row (that layout moved onto `.frame-strip__row--state`, one
   * row inside the cell's own grid) — this sub-960px `flex-wrap: wrap`
   * override is retired as dead alongside it, not left as stale CSS
   * with no element left to match its old flex-row context. */
}

/* Recent flights on a phone: callsign and time share the first row and
 * the detail line takes the full width underneath, instead of three
 * columns fighting over 340px.
 *
 * 20-04-PLAN.md Task 1 (D-17.2): the leading 40px thumbnail column
 * (added to the base .recent-flight rule above) stays even at this
 * width; only the middle content/detail column collapses into one, and
 * the detail line's own grid-column start moves from 1 to 2 so it never
 * runs back under the thumbnail. */
/* (Phase 21 polish: the sub-960px recent-flight override that used to
 * live here is now the base rule above — one shape at every width.) */

/* =====================================================================
 * Phase 20 — companion suggestions from the audit: bilingual FR/EN,
 * Display regrouped, Home redesigned, live theme preview, notifications,
 * simple mode (20-04-PLAN.md). This is the phase's one CSS plan — every
 * rule below (plus the .theme-status--nested/.recent-flight/.quick-action
 * edits made in place above) ships the whole phase's markup dependency,
 * using only the tokens already declared at the top of this file. No new
 * custom property, no new colour literal, no new accent consumer.
 * Comment banners below cite the 20-UI-SPEC.md section each block ships
 * (§A-K).
 * ===================================================================== */

/* --- 20-UI-SPEC.md §A: the status-row primitive (D-21) -----------------
 * layout.status_row(label, verdict, detail, state) — Home's three status
 * rows (Frame/Battery/Flight data) and the Calendar status row share
 * this one primitive. The dot reuses .dot/.dot--ok|warn|error verbatim
 * (declared above) — no new class, no new colour. */
.status-row {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--space-sm);
  padding: var(--space-xs) 0;
}

/* .status-row__label is the SEVENTH member of the shared 12px/uppercase/
 * semibold/0.06em/70%-muted label voice this file's .stat-tile__caption
 * comment names in full (.stat-tile__caption, .data-table th,
 * .data-card__label, .filter-bar__count, .banner__pill,
 * .airline-card__chip) — written out here as its own selector exactly
 * like the other six, not merged into a shared rule with them, and none
 * of those six rules' own declarations are touched by this addition. The
 * layout-only half (flex/min-width) composes with the label-voice
 * declarations in one rule, matching how .quick-action__label already
 * combines its own layout declarations with the same label-voice tier. */
.status-row__label {
  flex: 0 0 auto;
  min-width: 96px;
  font-size: 12px;
  font-weight: var(--weight-semibold);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

@media (max-width: 959.98px) {
  /* Below 960px the label wraps freely instead of reserving a fixed
   * column no phone-width row has room for. */
  .status-row__label {
    min-width: 0;
  }
}

.status-row__verdict {
  font-weight: var(--weight-semibold);
}

/* Reuses .widget-detail's exact declaration (declared above), not a new
 * muted-text token. */
.status-row__detail {
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* 21-04-PLAN.md Task 3 (D-04): the phase-20 Home hero row (.home-hero,
 * a two-column picture/status-card grid) is deleted outright —
 * replaced by the strip -> three tiles -> .home-columns.home-picture-
 * row shape below. .preview-frame and its own children (Home's
 * picture figure — the builder 24-08-PLAN.md Task 1 renamed to
 * `_current_picture_html()`, body unchanged, so that D4's hero above is
 * the only thing on that page meaning "hero") survive; only the
 * two-column wrapper around it is gone.
 *
 * Naming caution (20-UI-SPEC.md §B): companion/test_view_pages.py's own
 * guard against the string "preview-frame" is scoped to history_page's
 * own render() output only, not a site-wide ban — this class is correct
 * as named on Home. */
.preview-frame {
  margin: 0;
}

/* 23-10-PLAN.md Task 3 (D3/CFG-32): THE PICTURE HOLDS ITS PLACE BEFORE
 * IT ARRIVES, and this rule is the reservation rather than the
 * decoration.
 *
 * MEASURED, not assumed. At 360px this box was already reserved: the
 * rule above gives the image `width: 100%`, which is a definite size, so
 * the ratio the markup's own width="600" height="800" attributes supply
 * resolves a height before the bytes land (312 x 415.3 before, 312 x
 * 415.3 after). At 1280px it was NOT. The desktop rule below used to say
 * `width: auto`, and an image with no content yet and no definite size
 * in either axis has an intrinsic size of ZERO however well-known its
 * ratio is — so the picture measured 2 x 2 before the render arrived and
 * 380 x 506 after it. Home's whole right-hand column jumped ~500px on
 * every desktop load. That is the defect this plan's own skeleton would
 * otherwise have decorated instead of fixed.
 *
 * `aspect-ratio: 3 / 4` is declared EXPLICITLY rather than left to the
 * attributes, for two reasons. It makes the reservation a guarantee
 * instead of a coincidence — the box no longer depends on the ratio of
 * whatever bytes eventually arrive, so a render that ever ships at a
 * different size cannot move the page. And because this file's universal
 * reset makes every box `border-box`, a declared ratio applies to the
 * BORDER box, which is what makes the desktop width below resolve to
 * exactly the 60vh cap rather than to 60vh minus the hairline.
 * `object-fit: contain` is its safety net: with the ratio pinned, an
 * image of some other shape letterboxes onto the white backing this
 * class already carries instead of stretching.
 *
 * The desktop `width` is the same 60vh cap the max-height below states,
 * written as a definite width the ratio can be resolved from:
 * height 60vh at 3:4 is width 45vh, and `min()` keeps the container's
 * own 100% as the other bound exactly as `max-width` did. Both caps are
 * kept below rather than deleted — they are now belt-and-braces for a
 * bitmap of some other shape, and stating the cap once in each axis is
 * what makes the calc() above readable as "the 60vh cap, in the other
 * axis". */
.preview-frame__image {
  aspect-ratio: 3 / 4;
  object-fit: contain;
}

/* 23-10-PLAN.md Task 3 (D3/CFG-32): the skeleton itself — ONE rule, the
 * two surfaces in this app whose box is reserved for an image that has
 * not arrived. Second only to the reservation above, which is the part
 * that actually prevents the shift; this is what stops the reserved box
 * being a blank hole.
 *
 * IT IS THE IMAGE'S OWN BACKGROUND, and that placement is the entire
 * mechanism rather than a styling choice. `background-image` paints
 * above the element's `background-color` (the white backing
 * `.preview-frame__image` already shares, and the chip band's own
 * server-rendered per-theme colour) and BELOW the decoded bitmap, so the
 * skeleton is hidden by the image's own arrival — the image's real load
 * state, with no script, no timer and no class to toggle. A timer either
 * flashes on a fast connection or lies on a slow one, and a class needs
 * a script this app deliberately does not add for a decoration. With
 * scripts blocked this behaves identically, because both halves are CSS
 * and markup.
 *
 * IT DOES NOT SHIMMER, AND THAT IS A DECISION RATHER THAN AN OMISSION.
 * 23-10-PLAN.md asks for a pulse on 23-01's keyframes at the slow token,
 * and the placement that makes the skeleton auto-hide is exactly the
 * placement that makes a pulse impossible: `skypane-pulse` cycles
 * OPACITY, and opacity belongs to the element, so an animation on this
 * <img> would go on breathing the DECODED PICTURE, forever, on a page
 * somebody leaves open all day — the precise complaint 23-01's own
 * keyframes comment says a motion budget exists to prevent. The
 * alternatives were considered and are worse: a pulsing overlay on the
 * container is covered by this image's own opaque background from the
 * first frame and never visible; an overlay ABOVE the image can never
 * learn that the image arrived, because CSS has no loaded state to
 * select on; and an infinite animation that keeps running behind a
 * loaded picture is compositor work with nothing to show for it. So the
 * sheen is a static gradient: it reads as a placeholder, it costs
 * nothing, it disappears the instant the picture paints, and it spends
 * no keyframes at all — the file's four stay four.
 *
 * No new colour: the two stops are this file's own muted-text
 * color-mix idiom at two strengths, so the sheen sits on whatever
 * backing each surface already has. */
.preview-frame__image,
.theme-chip__preview {
  background-image: linear-gradient(
    110deg,
    color-mix(in srgb, var(--color-text) 4%, transparent) 0 40%,
    color-mix(in srgb, var(--color-text) 9%, transparent) 50%,
    color-mix(in srgb, var(--color-text) 4%, transparent) 60% 100%
  );
}

/* .preview-frame__image's shared declarations (the white backing,
 * hairline, radius) are declared once, above, as a second selector on
 * .now-showing__image's own rule — see that rule's own comment. Only
 * the desktop-only cap below is unique to this class. */
@media (min-width: 960px) {
  .preview-frame__image {
    width: min(100%, calc(60vh * 3 / 4));
    max-width: 100%;
    max-height: 60vh; /* D-17's explicit desktop-only cap */
    margin: 0 auto;
  }
}

.preview-frame__caption {
  margin-top: var(--space-sm);
  text-align: center;
}

/* 2px micro-spacing exception (20-UI-SPEC.md Spacing Scale) — the first
 * of the three places this phase reuses the established hairline-
 * adjacent tier; the compact chip's own two uses are in §G below. */
.preview-frame__flight {
  display: block;
  margin-top: 2px;
}

.status-card__headline {
  font-size: var(--font-body-size);
  font-weight: var(--weight-semibold);
  margin: 0 0 var(--space-md);
}

/* 20-UI-SPEC.md's Typography section specified `color:
 * var(--color-status-warn)` directly on this modifier for the overdue
 * "Expected since" headline — a genuinely new use of a status colour as
 * body text (every existing status use is a dot, a border or a card
 * edge). 20-04-PLAN.md Task 3 gated that specific choice through
 * companion/contrast_check.py's WCAG_AA_NORMAL_TEXT (4.5) check before
 * shipping: measured at this file's own :root hex literals for
 * --color-status-warn as text against --color-dominant (the card
 * surface .status-card sits on), light measured 3.19:1 — BELOW the
 * threshold — while dark measured 10.54:1, comfortably above it. Per
 * the gate's own documented fallback, the threshold was not weakened
 * and no new colour was introduced: this modifier stays on
 * --color-text in both themes, and the overdue state is carried by the
 * wording itself ("Expected since…" vs "Next update ≈…", home_page.py)
 * plus a leading warn dot — never by this text's own colour. See
 * companion/contrast_check.py's STATUS_WARN_ON_CARD_PAIRS and
 * companion/test_contrast_check.py's Section 4 for the pinned
 * regression pair. */
.status-card__headline--warn {
  color: var(--color-text);
}

/* 21-04-PLAN.md Task 3 (D-04): .status-card__rows had exactly one
 * consumer (the deleted _status_card_html()'s own <div>) and is
 * deleted with it; .status-card__headline/--warn above survive — the
 * strip now reuses both verbatim for its own next-update line.
 *
 * C6, 22-04-PLAN.md Task 2: the Phase 21 polish override on the update
 * cell's own headline element (`d9e0338`) — it set that one element's
 * font-size to the heading-size custom property and a tighter
 * line-height — promoted this line to the 22px Section-heading size, and
 * is retired outright: it competed with the 22px regular serif section
 * headings beside it. `.status-card__headline` above returns to its own
 * 16px semibold Emphasis base for every consumer, strip included. Its
 * intent — being the most prominent thing IN the strip — still holds
 * without the override, because the switch cells' own state text
 * (`.quick-action__state`) is Body-size REGULAR, not semibold. */

/* C5, 22-04-PLAN.md Task 2: the one time-value role, replacing four
 * treatments this phase found (14px grey label on Home, 16px bold mono
 * on Health, 14px mono in tables, mono under the picture) with one
 * shared rule. `--font-ui` (never mono — mono stays reserved for
 * identifiers: callsign, ICAO24 hex, the masked calendar URL) plus
 * `font-variant-numeric: tabular-nums` so a changing clock/countdown
 * digit never reflows its neighbours. No new token, no new family, no
 * new size: `--font-body-size`/`--font-label-size` and `--weight-
 * regular`/`--weight-semibold` are the same four-size/two-weight scale
 * every other rule in this file already draws from. `.time-value` alone
 * is the supporting-value shape (label-size, regular); `.time-value--
 * primary` is the primary-value shape (body-size, semibold) — a
 * modifier, not a second role. The relative age accompanying either
 * shape is a SIBLING element, not a variant of this class, at the
 * file's own existing 70% muted color-mix() strength
 * (`.time-value__age`, matching `.quick-action__label`'s own use of the
 * identical mix). This plan applies the primary shape to the Frame
 * strip's own next-wake clock text only; later plans (22-06, 22-07,
 * 22-09, 22-12) adopt it at their own render sites. */
.time-value {
  font-family: var(--font-ui);
  font-variant-numeric: tabular-nums;
  font-size: var(--font-label-size);
  font-weight: var(--weight-regular);
}

.time-value--primary {
  font-size: var(--font-body-size);
  font-weight: var(--weight-semibold);
}

.time-value__age {
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* --- 21-UI-SPEC.md §E: the merged Calendar card (D-13/D-14, R-08/R-09)
 * ------------------------------------------------------------------------
 * 21-07-PLAN.md Task 3: retires the two-<form> visual-fusion trick
 * (20-04-PLAN.md's own :has()-keyed corner-squaring rule, deleted
 * above, and its own trailing-card shape rule for the disconnect
 * form's outer wrapper, deleted from here) now that Calendar is
 * genuinely one nested card (companion/pages/config_page.py's merged
 * calendar_group(), 21-07-PLAN.md Task 1) plus a real, data-only
 * sibling <form> that carries no visible surface of its own — nothing
 * left to fuse.
 *
 * .calendar-actions lays out the Replace disclosure and the Disconnect
 * button on one line, right-aligned relative to each other via
 * space-between (baseline-aligned, matching a <details><summary> next
 * to a <button> — neither is a taller block the other must vertically
 * centre against). .calendar-actions--solo (Claude's Discretion,
 * additive) is the drift-only state's own single-button variant, where
 * space-between would otherwise place a lone flex child at the START
 * of the row instead of the right-aligned position D-14 asks for. */
.calendar-actions {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-md);
  margin: var(--space-sm) 0 0;
}
.calendar-actions--solo {
  justify-content: flex-end;
}

/* R-09: the small grey Disconnect button — this app's bare <button>
 * idiom carries the quiet, near-invisible-at-rest wash already; this is
 * the one additive rule block that also shrinks it (30px/12px, R-09's
 * own instruction) and keeps it off the accent-filled primary
 * treatment `button[type="submit"]` would otherwise give it. No
 * `.btn`/`.btn--secondary`/`.btn--small` family exists anywhere in this
 * file — R-09 explicitly names this the new class pair to introduce,
 * not an existing family to reuse.
 *
 * T2 (22-AUDIT.md, 22-15-PLAN.md Task 1). The paragraph above described
 * an intent this selector could not deliver. The bare class was (0,1,0)
 * and `button[type="submit"]` is (0,1,1) — an element selector plus an
 * attribute selector — so the primary accent fill won outright, and the
 * DESTRUCTIVE Disconnect control rendered as the page's primary CTA
 * while every declaration below was dead. Config's Disconnect is the
 * only `type="submit"` consumer (Airlines' per-card Replace and
 * History's picture control are both `type="button"`), so the defect
 * was invisible on two of the three call sites.
 *
 * The fix is the mechanism this file already uses in three places
 * (`.logout-form button`, `.frame-strip__cell button`, `.config-form
 * input[name="wake_interval_s"]`): EQUAL
 * specificity plus source order. `button.calendar-disconnect-btn` is
 * (0,1,1) — equal to the primary rule — and this block sits thousands
 * of lines after it, so source order decides and the grey treatment
 * wins. Deliberately NOT a `:where()` wrapper around the primary
 * rule's own selector: dropping that rule to (0,0,0) would surrender
 * the accent fill to every
 * competing zero-specificity rule in the file at once (22-UI-SPEC.md
 * §2's T2 row states this prohibition directly).
 *
 * `box-shadow: none` is new and load-bearing for the same reason
 * `.logout-form button`/`.frame-strip__cell
 * button` each carry it: this is a submit button and would otherwise
 * keep the primary rule's inset highlight even once the fill is
 * repainted, which is what made the control read as raised.
 *
 * Airlines' per-card Replace control (once (0,2,0) via `.airline-card
 * .calendar-disconnect-btn`, display/width/margin only) was removed
 * outright by 29-01-PLAN.md/CFG-81 — the illustration dialog owns
 * Replace now — and its now-unreachable positioning rule was deleted as
 * orphaned dead code by WR-01 (29-REVIEW.md). Only Config's Disconnect
 * button, History's picture control and Flights' Show-more anchor (see
 * CR-01 below) consume this class today. */
button.calendar-disconnect-btn {
  min-height: 30px;
  padding: 4px var(--space-sm);
  font-size: 12px;
  color: var(--color-text);
  background: color-mix(in srgb, var(--color-text) 6%, transparent);
  border: 1px solid color-mix(in srgb, var(--color-text) 20%, transparent);
  border-radius: var(--radius-control);
  box-shadow: none;
}
/* (0,2,1), equal to `button[type="submit"]:hover` and later in source —
 * the same arithmetic as the base rule above, one pseudo-class up on
 * both sides. The border-color and box-shadow are restated here because
 * the primary hover rule declares both, and the base rule above cannot
 * reach a hovered element the primary hover rule also matches. */
button.calendar-disconnect-btn:hover,
button.calendar-disconnect-btn:focus-visible {
  background: color-mix(in srgb, var(--color-text) 12%, transparent);
  border-color: color-mix(in srgb, var(--color-text) 20%, transparent);
  box-shadow: none;
}

/* CR-01 (29-REVIEW.md): history_page.py's _show_more_html() emits the
 * Flights "Show more" reveal as an `<a class="calendar-disconnect-btn">`,
 * not a `<button>`. The element-type-qualified `button.calendar-
 * disconnect-btn` rule above can never match an `<a>` tag regardless of
 * class, so it rendered as a bare underlined link. An anchor has no
 * `button[type="submit"]` specificity fight to win, so a plain class
 * selector (0,1,0) is enough here — no element-type qualifier, no
 * source-order dependency. */
a.calendar-disconnect-btn {
  display: inline-block;
  min-height: 30px;
  padding: 4px var(--space-sm);
  font-size: 12px;
  color: var(--color-text);
  background: color-mix(in srgb, var(--color-text) 6%, transparent);
  border: 1px solid color-mix(in srgb, var(--color-text) 20%, transparent);
  border-radius: var(--radius-control);
  text-decoration: none;
}
a.calendar-disconnect-btn:hover,
a.calendar-disconnect-btn:focus-visible {
  background: color-mix(in srgb, var(--color-text) 12%, transparent);
  border-color: color-mix(in srgb, var(--color-text) 20%, transparent);
}

/* D-13: the masked feed URL (host + "…", companion/pages/config_page.
 * py's _masked_calendar_url()) renders in the same monospace token
 * this file already reserves for machine-derived strings (matching
 * --font-mono's existing consumers), never the base body font — a
 * visual cue that this line is a derived fragment, not prose. */
.calendar-masked-url {
  font-family: var(--font-mono);
  margin: var(--space-sm) 0 0;
}

/* R-08/Pitfall 2: the disclosure summary reads as a link now that it
 * sits beside a real button on the same line, rather than the default
 * bold <summary> treatment — this file's plain prose links have never
 * needed a dedicated class before (every existing <a> inherits the
 * base accent-coloured rule directly), so .text-link is new, but
 * trivial: one declaration, reusing --color-accent verbatim. */
.text-link {
  color: var(--color-accent);
  text-decoration: underline;
}

/* --- 20-UI-SPEC.md §F: "Match by" segmented radios, and the rule rows
 * (D-15b, D-15c) -------------------------------------------------------
 * Structural Note 5 / this phase's own resolution: native radios styled
 * as the existing .theme-form/.theme-option segmented control, chosen
 * over three JS-driven <button>s requiring rule-form.js for full
 * functionality — the zero-degraded-state path, which is why no
 * rule-form.js exists this phase. The radio itself is hidden via the
 * existing `.visually-hidden` utility in the markup (input.visually-
 * hidden, declared above, already clears the 44px floor for it); this
 * rule only styles the adjacent <label>, at .theme-option's own
 * 28px/13px geometry, with the same 12%-accent active pill now driven
 * by :checked instead of a --active class toggle. */
.theme-form input[type="radio"] + label {
  height: 28px;
  /* T12 (22-AUDIT.md, 22-10-PLAN.md Task 1): the global `label` rule
   * near the top of this file gives EVERY label `margin-bottom:
   * var(--space-sm)`. This rule never reset it, so each 28px segment
   * carried an 8px tail inside the 2px-padded `.theme-form` container —
   * measured 42px tall where 34px is the control's real height, with the
   * segments floating against the container's top edge instead of
   * filling it. `.theme-chip` above resets the identical global margin
   * for the identical reason; this is that reset, for the segmented
   * control. */
  margin-bottom: 0;
  flex: 1 1 auto;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  padding: 0 12px;
  border-radius: 6px;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
  font-size: 13px;
  font-weight: var(--weight-regular);
  cursor: pointer;
  transition: background-color .15s ease, color .15s ease;
}

.theme-form input[type="radio"]:checked + label {
  background: color-mix(in srgb, var(--color-accent) 12%, transparent);
  color: var(--color-accent);
  font-weight: var(--weight-semibold);
}

.theme-form input[type="radio"]:not(:checked) + label:hover {
  background: color-mix(in srgb, var(--color-text) 4.5%, transparent);
}

/* The global :focus-visible floor (declared above) targets `input`
 * directly, but this input is visually hidden off-screen, so its own
 * outline would be invisible too. Moving the ring onto the label keeps
 * keyboard focus visible for this one control. */
.theme-form input[type="radio"]:focus-visible + label {
  outline: 2px solid var(--color-accent);
  outline-offset: 2px;
}

/* X6 (22-AUDIT.md "rules add-form segmented control + input
 * right-aligned, 'Add rule' far right"), 22-10-PLAN.md Task 1.
 *
 * ROOT CAUSE, measured rather than assumed: this modifier never reset
 * `.rule-add-form`'s own `flex-direction: column` (above). In a COLUMN
 * flex container the cross axis is horizontal, so `align-items:
 * flex-end` pushed every child — the segmented control, the value
 * input and the Add button — to the RIGHT edge of an 830px form, each
 * on its own line. There is, and was, no `margin-left: auto` anywhere
 * on this form; the 22-AUDIT.md/22-UI-SPEC.md reading that there was
 * one is corrected here.
 *
 * `flex-direction: row` is therefore the actual fix, and `align-items:
 * center` is the composition rule C4 states: these four controls read
 * as SEPARATE objects sharing a row (not as one field-plus-its-action),
 * so they are centre-aligned and each keeps its own registered
 * geometry. Neither the 28px segment nor the 44px native-input floor is
 * touched here — references/control-density.md protects both
 * explicitly, and a height-match would have had to break one of them. */
.rule-add-form--inline {
  display: flex;
  flex-direction: row;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-sm);
}

.rule-add-form--inline .rule-add-form__field--kind {
  flex: 0 0 auto;
}

.rule-add-form--inline input[type="text"] {
  max-width: 140px;
}

/* D-15e's suggested-from-recent chips: inert <button>s (with no JS they
 * do nothing but submit no form of their own either — matching the
 * segmented control's own "control degrades to inert, never to
 * invisible" convention above) reset down to plain inline text rather
 * than the base button's 30px quiet-wash treatment, since D-15e's own
 * text says "with no JS the chips are plain text". */
.rule-suggestions {
  margin: var(--space-sm) 0 0;
}

.rule-suggestion-chip {
  height: auto;
  padding: 0;
  background: transparent;
  border: none;
  border-radius: 0;
  font: inherit;
  font-weight: var(--weight-regular);
  color: inherit;
  text-decoration: underline;
  cursor: pointer;
}

.rule-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-sm);
}

.rule-row {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-sm);
  padding: var(--space-sm) var(--space-md);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  background: var(--color-dominant);
}

.rule-row__key {
  flex: 0 0 auto;
  min-width: 72px;
}

/* .rule-row__kind gets no rule of its own — it composes .banner__pill
 * verbatim and, per 20-UI-SPEC.md §F, must NOT be colour-coded. */

.rule-row__theme {
  flex: 1 1 auto;
}

.rule-row form {
  flex: none;
  margin: 0;
}

/* --- 20-UI-SPEC.md §G: the compact theme chip (D-14d, D-15b) -----------
 * Additive, size-only modifier on the existing .theme-chip base — every
 * selected-state mechanism (border, wash, check glyph, the live
 * :has(input:checked) layer, the fallback layer, all declared above)
 * applies automatically. No selected-state rule of any kind is added
 * here, which is the whole point of making this size-only. */
.theme-chip--compact {
  width: 104px;
}

.theme-chip--compact .theme-chip__preview {
  height: 36px;
}

.theme-chip--compact .theme-chip__body {
  padding: var(--space-xs);
}

/* 2px micro-spacing exception, second of the three this phase reuses
 * (the first is .preview-frame__flight above). Reuses .theme-chip__name's
 * existing no-wrap/ellipsis rule verbatim — declared once, further up
 * this file — rather than restating it here. */
.theme-chip--compact .theme-chip__name {
  font-size: 12px;
  margin-bottom: 2px;
}

/* 2px micro-spacing exception, third of the three. */
.theme-chip--compact .theme-chip__swatches {
  gap: 2px;
}

.theme-chip--compact .theme-chip__dot {
  width: 8px;
  height: 8px;
}

.theme-chip--compact .theme-chip__check {
  top: var(--space-xs);
  right: var(--space-xs);
}

.theme-chip--compact .theme-chip__check .icon {
  width: 14px;
  height: 14px;
}

/* --- 20-UI-SPEC.md §H: the live theme preview (D-22..D-24) -------------
 * 480px max-width matches .lightbox's own base max-width above — no new
 * dimension token. */
.theme-live-preview {
  margin: 0 0 var(--space-md);
  max-width: 480px;
}

.theme-live-preview__image {
  display: block;
  width: 100%;
  height: auto;
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  /* 23-10-PLAN.md Task 2 (D3/CFG-32): the live preview CROSSFADES when
   * the theme changes, rather than cutting to the new render.
   *
   * A single-element dissolve, not a second stacked image layer: this
   * component is one <img> the server sizes and
   * companion/static/theme-preview.js re-points, and a second layer
   * would mean a second element, a second src and a z-order to keep
   * right, for a difference nobody can see at 180ms. Fewer moving parts
   * is the house preference and that script is deliberately small.
   *
   * Declared on the BASE rule so the fade runs in BOTH directions from
   * one declaration — out on the class below, back in when it is
   * removed. The script never sets a duration, a delay or a timer of
   * its own; it adds and removes one class and listens for the events
   * that say the fade ended and the new frame arrived. Under reduced
   * motion the global override drives this to 0.01ms, which is a cut —
   * correct, and the script still works, because `transitionend` still
   * fires for a 0.01ms transition. */
  transition: opacity var(--motion-fast) ease;
}

/* The opacity-0 half of the crossfade. theme-preview.js owns this class
 * entirely and adds it at no other time; the class literal is pinned in
 * companion/test_config_page.py against both files at once, since
 * neither imports the other. */
.theme-live-preview__image--swapping {
  opacity: 0;
}

.theme-live-preview figcaption {
  margin-top: var(--space-sm);
  text-align: center;
}

/* --- 30-UI-SPEC.md: the "Aspect" card (CFG-85) --------------------------
 * Replaces the retired "Frame colours" card and the retired separate
 * Calendar card with one preview-plus-four-accordion-row tile. Zero new
 * colour tokens, zero new custom properties: every value below is an
 * existing spacing/radius/colour token, except the three literals this
 * plan argues individually (the palette grid's 64px track floor, the
 * swatch band's four geometry percentages, and the palette caption's
 * 10px/1.2 sub-scale exception — each argued beside its own
 * declaration, below). */

/* .aspect-card already gets its surface (--color-dominant), its resting
 * hairline and its responsive --space-lg/--space-md padding from
 * .page-section, declared far above — nothing here restates those. Only
 * the >=960px two-column layout is new: the live preview as the left
 * column, the four accordion rows stacked in the right column. Reuses
 * the file's existing 960px breakpoint and the exact 360px preview-
 * column floor `.frame-colours__layout` (retired) used to declare — not
 * a new literal, the identical value moved. `grid-template-rows: auto
 * repeat(4, auto)` names the card's own five real children explicitly
 * (the heading, then the four `.usage-row` elements in their own
 * COLOUR_USAGES-locked order) so the preview's own row-span below lands
 * on exactly the rows those four occupy, never a guessed count. */
@media (min-width: 960px) {
  .aspect-card {
    display: grid;
    grid-template-columns: minmax(360px, 1fr) minmax(0, 1fr);
    grid-template-rows: auto repeat(4, auto);
    column-gap: var(--space-lg);
  }

  .aspect-card > h2 {
    grid-column: 1 / -1;
  }

  .aspect-card__preview {
    grid-column: 1;
    grid-row: 2 / -1;
  }
}

/* No `min-width: 0` companion rule here, unlike the retired
 * `.frame-colours__layout`/`.frame-colours__usage-panel` pair above (a
 * carried-over concern from Task 3's own re-pin instruction, stated
 * explicitly rather than silently dropped): that pair's whole reason to
 * exist was a NOWRAP scroll-snap strip forcing a <fieldset>'s UA
 * `min-inline-size: min-content` to ~2000px. Nothing in this layout is
 * nowrap, and `.theme-live-preview__image` already declares `width:
 * 100%; height: auto` — never `width: auto`, which would size to the
 * image's own intrinsic pixels — so no grid cell here is ever forced
 * wide by unshrinkable content. */

/* One `.usage-row` per `<details name="aspect-rows">`, in COLOUR_
 * USAGES' locked order. A border-top divider between rows, never on the
 * first, is the only visual boundary a closed row needs — matches the
 * developer-approved sketch's own accordion markup verbatim
 * (.planning/sketches/006-aspect-tile-accordion-vs-segments/index.html
 * lines 104-105). */
.usage-row {
  border-top: 1px solid var(--color-border);
}

.usage-row:first-of-type {
  border-top: none;
}

/* --secondary (the Rules row): one notch quieter, per 30-UI-SPEC.md's
 * Structural Contract — regular weight instead of semibold, ONE
 * declaration, never a second treatment. The developer-approved
 * sketch's own throwaway CSS additionally mutes this row's whole summary
 * to a new 85% colour-mix strength; skipped here to keep this a
 * one-declaration modifier and spend zero new colour literals. */
.usage-row--secondary .usage-row__name {
  font-weight: var(--weight-regular);
}

/* The row's own <summary> inherits the global 44px floor and the global
 * 6px rotated-border chevron (the `summary`/`summary::before` rules far
 * below) as-is — this rule adds only the row's own vertical rhythm and
 * layout around that inherited chevron, never a second one.
 *
 * `color: inherit` follows `.history-card__summary`'s own established
 * "override only what differs" idiom for a `<summary>` inside a card:
 * the global `summary` rule paints every `<summary>` in
 * `--color-accent`, and 30-UI-SPEC.md's Color section is explicit that
 * this accordion row's own summary must not be one of this card's
 * accent consumers. Overriding to `inherit` (full-strength card text),
 * matching `.history-card__summary`'s own choice, spends zero new
 * tokens. Every OTHER `<summary>` inside this card (the calendar's
 * "Replace the feed URL"/"How it works" disclosures, the rule-add
 * disclosure, "How rules combine") is UNCHANGED and stays on the global
 * accent default — the file's own already-listed, app-wide
 * `<summary>`-marker accent use, not a new one — because the child
 * combinator below scopes this override to ONLY the row's own top-level
 * summary. */
.usage-row > summary {
  color: inherit;
  gap: var(--space-md);
  padding: var(--space-md) 0;
}

/* The chevron specifically must read as quiet, never accented (30-UI-
 * SPEC.md Color section: "the accordion <summary> chevron is not
 * accented"). The developer-approved sketch's own throwaway CSS mutes
 * it with a literal 50% colour-mix strength that exists nowhere else in
 * this real file — spending that literal here would be a new colour
 * strength this plan's own "zero new colour literal" floor forbids. The
 * established 70% muted-text mix, already used 17+ times in this file,
 * reads exactly as quiet and spends an existing idiom instead. */
.usage-row > summary::before {
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

.usage-row__name {
  font-size: var(--font-body-size);
  font-weight: var(--weight-semibold);
}

.usage-row__meta {
  font-size: var(--font-label-size);
  font-weight: var(--weight-regular);
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* Sizing only — `.palette-swatch` (below) already contributes the shape
 * (position, overflow clip, radius). `--space-xl` is 32px, the exact
 * pixel value the sketch's own `.usage-row__swatch` declares as a bare
 * literal — spent here as the existing token instead. */
.usage-row__swatch {
  width: var(--space-xl);
  height: var(--space-xl);
  flex-shrink: 0;
}

/* --- The palette grid (rows 1-3; unrelated to and never touching the
 * rule-add form's own compact `.theme-chip-grid`, which keeps rendering
 * `.theme-chip--compact` chips verbatim) --------------------------- */

/* `minmax(64px, 1fr)` is a track-size literal, the same category as
 * `.theme-chip--compact`'s own `width: 104px` two rules below — not a
 * spacing value, and 30-UI-SPEC.md's own Spacing Scale exceptions table
 * argues it explicitly. No `overflow-x`, no `scroll-snap-type`, no
 * `flex-wrap: nowrap`, no fixed child width — the literal thing CFG-85
 * retires, confirmed absent by this plan's own inline verify script. */
.palette {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(64px, 1fr));
  gap: var(--space-sm);
  padding: var(--space-xs) 0 var(--space-md);
}

.palette-chip {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-xs);
  padding: var(--space-xs);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  background: var(--color-dominant);
  cursor: pointer;
  position: relative;
}

/* Fills the grid column's own resolved width; `aspect-ratio: 1` keeps
 * the swatch square at every width without a second literal. */
.palette-chip__swatch {
  width: 100%;
  aspect-ratio: 1;
}

/* Shape only — the inline `background` (the real ink hex) arrives from
 * the server; this rule contributes no colour. Also the row-summary
 * swatch's own base rule (see `.usage-row__swatch` above, a
 * sizing-only modifier of this same class). */
.palette-swatch {
  position: relative;
  overflow: hidden;
  border-radius: var(--radius-control);
}

/* Geometry from 30-UI-SPEC.md's locked Swatch Rendering Contract, not a
 * spacing value — argued there, not chosen here. */
.palette-swatch__band {
  position: absolute;
  top: 33%;
  height: 34%;
  left: 0;
  right: 0;
}

/* 10px/1.2 is the one typographic exception this plan spends: it WIDENS
 * the existing sub-scale exception tier `.sparkline-axis-label`/
 * `.drawing-axis-label` already occupy at this exact size/line-height —
 * a deliberate per-selector literal convention this file's own skill
 * reference already records, not a new size and not a new
 * `--font-micro-size` token. */
.palette-chip__name {
  font-size: 10px;
  line-height: 1.2;
  text-align: center;
  color: color-mix(in srgb, var(--color-text) 70%, transparent);
}

/* Hidden at rest, matching `.theme-chip__check`'s own mechanism exactly
 * — the live `:has(input:checked)` rule (this file's one feature-query
 * block, above) switches it to `inline-flex`. */
.palette-chip__check {
  display: none;
  position: absolute;
  top: var(--space-xs);
  right: var(--space-xs);
  color: var(--color-accent);
}

/* --- "Comme les départs" leading option (Arrivals/Calendar rows) ------ */

/* `grid-column: 1 / -1` is load-bearing, not decorative:
 * `_palette_grid_html()` interpolates this `<label>` as the FIRST CHILD
 * of the same `.palette` grid its 18 theme chips populate (30-UI-
 * SPEC.md's own `leading_html` seam), so without an explicit full-row
 * span it would render as one more 64px grid cell beside the first chip
 * rather than the full-row-width control 30-UI-SPEC.md's Structural
 * Contract describes — confirmed against the developer-approved
 * sketch's own identical declaration. */
.leading-option {
  grid-column: 1 / -1;
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  min-height: 44px;
  padding: var(--space-sm) var(--space-md);
  border: 1px solid var(--color-border);
  border-radius: var(--radius-control);
  cursor: pointer;
  position: relative;
}

.leading-option__name {
  flex: 1 1 auto;
}

/* --- The rules row's own nested "+ Add rule" disclosure --------------- */

/* Its own <summary> keeps the global 44px floor and the global accent
 * colour (an already-listed, app-wide `<summary>` use, not a new one) —
 * spacing only. */
.rule-add {
  margin-top: var(--space-sm);
}

.rule-add[open] > summary {
  margin-bottom: var(--space-sm);
}

/* D-09: the leading "Same as departures" chip — reuses .theme-chip's
 * own selectable-card shape with no rendered preview image/swatches
 * (never a fabricated theme-coloured preview for "no override"). */
.theme-chip__body--placeholder {
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 120px;
}

/* B9 fallback (22-10-PLAN.md Task 2, 22-AUDIT.md's own second option).
 *
 * Below 360px the three equal columns stop being the right shape: the
 * content column is ~238px at 320px, giving each card ~73px with 32px of
 * padding inside it, and `.runway-card__number`'s longest label
 * ("Runway 3 (07/25)") then paints past the card's own border
 * (scrollWidth > clientWidth, measured). At 390px — the audit's own
 * measurement viewport — the same cards measure ~97px and the label
 * wraps cleanly inside them, which is why this fallback starts below
 * 360 and not at the 960px breakpoint.
 *
 * The fallback is the audit's vertical radio LIST, never a re-wrap: a
 * 2 + 1 orphan is the defect B9 exists to remove, and reintroducing it
 * one breakpoint lower would be the same defect at a smaller size. A
 * 100% basis inside the row's existing `flex-wrap: wrap` puts one card
 * per line with no change to `.runway-row` — 29-04-PLAN.md Task 1
 * (CFG-80) retired the second consumer this sentence used to name
 * (`quiet_hours_group()`'s preset row moved to its own `.quiet-preset-
 * row` rule); `.runway-row` keeps wrapping regardless, for its one
 * remaining consumer, the runway picker itself. */
@media (max-width: 359.98px) {
  .runway-card {
    flex: 1 1 100%;
  }
}

/* ===================================================================== *
 * The bottom tab bar (X9, 22-14-PLAN.md Task 1, 22-UI-SPEC.md §3.1)     *
 * ===================================================================== *
 *
 * D-10 (22-CONTEXT.md) locks X9's mechanism to a bottom tab bar and
 * forbids the alternative the audit's own fix column had also offered:
 * `.claude/skills/sketch-findings-skypane/references/mobile-navigation
 * .md` carries a locked REJECTED verdict on an absolute-positioned
 * overlay for the PRIMARY nav, established by real-device testing during
 * 06.6.1-06 — an out-of-flow panel can only ever cover content, never
 * push it, which is why `.mobile-nav` above is in-flow via
 * `flex-basis: 100%`. Nothing in this block touches that rule or that
 * verdict. The dropdown still pushes; it just has far less to push with
 * now that destinations live down here.
 *
 * The measured defect: opening the hamburger shoved a 390x844 viewport
 * about 420px down — a near-full-screen push — to reach any page. Every
 * everyday destination is now one tap away at ZERO page shift.
 *
 * `display: none` is declared here, outside the media query, and only
 * lifted below 960px. Stated that way round on purpose: a desktop that
 * never matches the query below can never show the bar, whatever later
 * rules arrive. The >=960px sidebar is untouched.
 */
.tab-bar {
  display: none;
}

@media (max-width: 959.98px) {
  /* 959.98px, not 959px: the same fractional boundary the sub-960px
   * rules above already use, so a fractional-CSS-pixel viewport cannot
   * land in a one-device-pixel gap with neither nav visible.
   *
   * Surface/shadow: `--color-secondary` (the nav surface `.mobile-nav`
   * already uses), a single TOP hairline, and `--shadow-card-hover` at
   * REST. The resting shadow is a floating-overlay exception to the
   * card hairline-at-rest contract, the same exception `.mobile-nav`
   * and `.lightbox` already carry, for the same reason: this genuinely
   * floats over the content it covers.
   *
   * NO border radius, deliberately. `--radius-card` is for floating
   * elements that do NOT touch the viewport edge; this bar is anchored
   * to three of them, and rounding an edge-anchored box leaves slivers
   * of page showing through its corners. Getting that pairing backwards
   * is the easiest mistake available here.
   *
   * 56px is one of the three off-scale literals 22-UI-SPEC.md §1 admits
   * for this phase (the safe-area inset below is the second); it is the
   * tab ROW's height, with the inset added as padding underneath so the
   * row itself never shrinks on a device with a home indicator.
   *
   * `z-index: 20` puts the bar above ordinary page content. It used to
   * sit below the save bar's own 30 (27-04-PLAN.md, CFG-63, retired that
   * component outright); the value is left unchanged rather than
   * reclaimed, since nothing about this component's own stacking needs
   * moved. */
  .tab-bar {
    display: flex;
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 20;
    padding-bottom: env(safe-area-inset-bottom, 0px);
    background: var(--color-secondary);
    border-top: 1px solid var(--color-border);
    box-shadow: var(--shadow-card-hover);
  }

  /* Five equal cells: `flex: 1 1 0` (not `1 1 auto`) so the widest label
   * cannot buy itself a wider tab. At 390px that is 78x56px per cell,
   * which clears the WCAG 2.5.5 44px floor DIRECTLY in both axes — this
   * is a met-directly touch target, not a traded-away one, because
   * nothing was traded for it.
   *
   * `.tab-bar__link` is also the `<summary>` of the "More" `<details>`
   * below, which is why this rule declares `color` explicitly: the
   * global `summary` rule paints its own text with `--color-accent`
   * (a disclosure-control affordance), and an inactive tab must read as
   * an inactive tab, not as the current page. (0,1,0) beats (0,0,1). */
  .tab-bar__link {
    flex: 1 1 0;
    min-width: 0;
    height: 56px;
    display: flex;
    color: var(--color-text);
    text-decoration: none;
    font-family: var(--font-ui);
  }

  /* The active/hover tint is painted on this INNER wrapper, not on the
   * cell, so the pill is inset from the cell edge while the cell keeps
   * its full 78x56px tap area. */
  /* 2026-09-17 audit (CFG-82): the pill's 8px horizontal margin on each
   * side left only 62px of a 78px cell for "Compagnies", which needs
   * 65px at this label's 11px size — the label truncated with an
   * ellipsis in a real browser. Halved to `calc(var(--space-xs) / 2)`
   * (2px) so the label fits; the vertical component stays at
   * `var(--space-xs)` deliberately, unchanged. The margin lives on this
   * INNER pill, never on `.tab-bar__link` (the 78x56px cell above), so
   * the tap area this comment already describes is unaffected by the
   * shrink. */
  .tab-bar__pill {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: var(--space-xs);
    margin: var(--space-xs) calc(var(--space-xs) / 2);
    border-radius: var(--radius-control);
    transition: background-color .15s ease, color .15s ease;
  }

  /* 11px regular, sentence case, and explicitly NOT the label voice —
   * no `text-transform`, no `letter-spacing`. A nav destination is a
   * destination, not a label, and shouting four of them at the foot of
   * every phone page would make the bar the loudest thing on screen.
   * The size joins this file's existing sub-scale exception tier beside
   * `.sparkline-axis-label` (10px) and `.cell-secondary`; it is not a
   * fifth entry in the four-size type scale. */
  .tab-bar__label {
    font-size: 11px;
    font-weight: var(--weight-regular);
    line-height: 1;
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }

  /* The app's ONE active-signal idiom, verbatim: the same 12% accent
   * wash, accent text and semibold weight `.sidebar-link--active`,
   * `.mobile-nav__link--active` and `.theme-option--active` already
   * share. Not a fourth treatment — a third consumer of the third. */
  .tab-bar__link--active {
    color: var(--color-accent);
    font-weight: var(--weight-semibold);
  }

  .tab-bar__link--active .tab-bar__pill {
    background: color-mix(in srgb, var(--color-accent) 12%, transparent);
  }

  /* :not()-scoped for exactly the reason `.mobile-nav__link`'s own hover
   * rule above already states, and which `references/control-density.md`
   * names as a specific failure: a bare hover selector here would be
   * equal specificity and later in source, so it would erase the active
   * tint the instant the pointer crossed the current tab. */
  .tab-bar__link:not(.tab-bar__link--active):hover .tab-bar__pill {
    background: color-mix(in srgb, var(--color-text) 4.5%, transparent);
  }

  /* "More" is a native <details>. It is the cell itself, so it takes the
   * same `flex: 1 1 0` share as the four links, and `position: relative`
   * only to anchor its own sheet. */
  .tab-bar__more {
    flex: 1 1 0;
    min-width: 0;
    display: flex;
    position: relative;
  }

  .tab-bar__more > .tab-bar__link {
    flex: 1 1 auto;
  }

  /* T3, decided deliberately rather than inherited (22-15-PLAN.md Task
   * 1). `.tab-bar__link` is ALSO the <summary> of the "More" details,
   * so the global `summary::before` chevron lands here too — and
   * 22-UI-SPEC.md §3.1 and §5 contract 3 both require that it does:
   * "More" is a disclosure and must say so.
   *
   * Taken OUT OF FLOW, which is the whole decision. In flow the chevron
   * is a flex item beside `.tab-bar__pill` inside a 78x56px cell, and
   * it would compress the centred icon-and-label stack the other four
   * tabs render at full width — a visibly narrower fifth cell for a
   * 6px marker. Absolutely positioned against `.tab-bar__more`'s own
   * already-declared `position: relative`, it costs the layout nothing:
   * the cell stays 78x56px, the tap area is untouched, and the stack
   * stays centred. The `margin-right` and `flex` the base rule sets are
   * cleared for the same reason.
   *
   * The rotation is INVERTED against the base rule, and that is not a
   * style choice: this sheet opens UPWARD (see the panel rule below),
   * so the base rule's right-when-closed / down-when-open convention
   * would point the marker away from the panel it controls. Closed
   * points up, at the space the sheet will occupy; open points down, at
   * the bar the sheet folds back into. Still one transform, still
   * covered by the one global reduced-motion block, still no per-rule
   * block. */
  .tab-bar__more > .tab-bar__link::before {
    position: absolute;
    top: 7px;
    right: 12px;
    margin-right: 0;
    transform: rotate(-135deg);
  }

  .tab-bar__more[open] > .tab-bar__link::before {
    transform: rotate(45deg);
  }

  /* The sheet opens UPWARD from the bar.
   *
   * This `position: absolute` is NOT a reversal of the rejected-overlay
   * verdict recorded in `references/mobile-navigation.md`. That verdict
   * is about the PRIMARY nav's push-versus-overlay behaviour on a
   * component that must be able to push page content down; this is a
   * two-item secondary sheet anchored to a bar that is already fixed,
   * pushes nothing, and never could. Recorded here as well as in
   * companion/layout.py's `_tab_bar_html()` so a future reader meets the
   * distinction wherever they arrive first.
   *
   * Rows reuse `.mobile-nav__link` verbatim (44px, 16px, `--font-ui`)
   * rather than re-declaring that geometry — see that rule and quick
   * task 260902-qkm's note on why its 44px floor is load-bearing.
   *
   * Radius on the top corners only: the sheet sits flush on the bar and
   * its right edge meets the viewport edge, so a full `--radius-card`
   * would round two corners that touch something. */
  .tab-bar__more-panel {
    position: absolute;
    bottom: 100%;
    right: 0;
    min-width: 180px;
    display: flex;
    flex-direction: column;
    padding: var(--space-sm);
    background: var(--color-secondary);
    border: 1px solid var(--color-border);
    border-top-left-radius: var(--radius-card);
    border-top-right-radius: var(--radius-card);
    box-shadow: var(--shadow-card-hover);
  }

  /* The last card must never sit under the bar. Scoped to the
   * server-rendered `.has-tab-bar` body class (companion/layout.py's
   * TAB_BAR_BODY_CLASS), which is present only when the bar really
   * rendered — so the 404 and the preview-image error pages, which
   * render no bar, reserve no space for one. */
  .has-tab-bar .page-content {
    padding-bottom: calc(
      var(--space-2xl) + 56px + env(safe-area-inset-bottom, 0px));
  }

  /* 27-04-PLAN.md (D-04/CFG-63): T7's phone-side second clearance rule
   * (22-14-PLAN.md Task 3) — 144px, MEASURED across both languages, of
   * EXTRA bottom padding so the page could clear both the tab bar's row
   * AND the save bar floating above it — is deleted along with the bar
   * itself. `.has-tab-bar .page-content` above, clearing the tab bar
   * alone, is the only clearance a phone-width settings page needs now:
   * `.save-status` sits in normal document flow and claims no fixed
   * space of its own.
   *
   * SUPERSEDED by 28-08-PLAN.md Task 2 (CFG-77/CFG-78), 2026-09-16: the
   * bar is back, and its own EXTRA clearance is restored too — but NOT
   * here. `.has-tab-bar .page-content:has(.dirty-bar)`, inside this
   * file's one `@supports selector(:has(*))` block (near this file's
   * top, beside the restored `.dashboard-main:has(.dirty-bar)`
   * desktop rule), is the new declaration site, deliberately NOT beside
   * this rule the way `6dea46a`'s own T7 clearance was. That old
   * placement existed only to win an EQUAL-specificity, later-source-
   * order fight against `.has-tab-bar .page-content` immediately above
   * — `:has()` gives the new rule higher specificity instead ((0,3,0)
   * against this rule's (0,2,0)), so it wins regardless of where in the
   * file it is declared, and it is declared where the file's `:has()`
   * block-count convention requires it to live. 86px re-measured
   * (70px bar + var(--space-md)), rounded up to 88px on the 8px grid —
   * see that rule's own comment for the measurement method and why it
   * differs from this paragraph's own historical 144px. */
}
