/* ============================================================
   Deployment Manager — the single stylesheet.

   Design system: shared FT canonical tokens (same set cicd-manager
   runs, sourced from ft-configs-ui). Steel-blue brand (primary
   #2b6eac, accent #1f4e7a), blue-tinted neutrals, status bg/text/
   border triplets. Typeface: Geist (Sans + Mono), self-hosted — no
   external font fetches, so this page makes zero third-party
   requests. WCAG 2.1 AA in both themes.

   This file covers BOTH pages: index.html (the app) and
   topology-wizard.html (the standalone Topology Builder, whose
   inline <style> block was folded in at the bottom). The wizard rides
   the same 14px root and type ramp as index.html — see the note above
   `body.wz-body` for what the fold changed.

   The purple gradient and the "Mission Control" console.css layer
   are both gone (PRODUCT.md anti-reference #1).
   ============================================================ */

@font-face { font-family: 'Geist'; font-style: normal; font-weight: 400; font-display: swap; src: url('../fonts/geist-sans-latin-400-normal.woff2') format('woff2'); }
@font-face { font-family: 'Geist'; font-style: normal; font-weight: 500; font-display: swap; src: url('../fonts/geist-sans-latin-500-normal.woff2') format('woff2'); }
@font-face { font-family: 'Geist'; font-style: normal; font-weight: 600; font-display: swap; src: url('../fonts/geist-sans-latin-600-normal.woff2') format('woff2'); }
@font-face { font-family: 'Geist'; font-style: normal; font-weight: 700; font-display: swap; src: url('../fonts/geist-sans-latin-700-normal.woff2') format('woff2'); }
@font-face { font-family: 'Geist Mono'; font-style: normal; font-weight: 400; font-display: swap; src: url('../fonts/geist-mono-latin-400-normal.woff2') format('woff2'); }
@font-face { font-family: 'Geist Mono'; font-style: normal; font-weight: 500; font-display: swap; src: url('../fonts/geist-mono-latin-500-normal.woff2') format('woff2'); }

/* ============================================================
   1. Tokens — light theme
   ============================================================ */
:root {
    /* --- Canonical FT tokens (HSL triplets) --- */
    --background: 0 0% 100%;
    --foreground: 0 0% 16.9%;
    --card: 0 0% 100%;
    --primary: 208.8 60% 42.2%;        /* #2b6eac */
    --primary-hover: 209 60% 36%;       /* #255e92 */
    --secondary: 209 60.8% 90%;        /* #d6e6f5 light blue */
    --secondary-strong: 209 55% 82%;   /* ~#b5cce2 */
    --muted: 210 20% 94%;
    --muted-foreground: 0 0% 40%;      /* #666; AA on white */
    --accent: 209 59.5% 30%;           /* #1f4e7a deep blue */
    --border: 210 16% 84%;
    --ring: 208.8 60% 42.2%;
    --page-chrome: 210 28% 95%;        /* ~#edf1f6 canvas, tinted toward brand */
    --radius: 8px;
    --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
    --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);

    /* status badge triplets (bg / text / border) */
    --status-success-bg: 145 63% 95%;
    --status-success-text: 149 46% 30%;
    --status-success-border: 148 45% 85%;
    --status-warning-bg: 40 96% 94%;
    --status-warning-text: 32 81% 33%;
    --status-warning-border: 40 70% 84%;
    --status-error-bg: 350 100% 96%;
    --status-error-text: 350 70% 38%;
    --status-error-border: 350 65% 88%;
    --status-info-bg: 209 61% 94%;
    --status-info-text: 209 60% 32%;
    --status-info-border: 209 45% 84%;

    /* --- semantic aliases --- */
    --bg-primary: hsl(var(--page-chrome));   /* page canvas + recessed input fill */
    --bg-secondary: hsl(var(--card));        /* cards, modals, panels */
    --surface-sunken: hsl(var(--muted));     /* passive panels, info boxes, table headers */
    --surface-interactive: hsl(var(--secondary));        /* secondary buttons, tabs — reads clickable */
    --surface-interactive-hover: hsl(var(--secondary-strong));
    --bg-tertiary: hsl(var(--muted));

    --text-primary: hsl(var(--foreground));
    --text-secondary: hsl(0 0% 32%);
    --text-muted: hsl(var(--muted-foreground));
    --border-color: hsl(var(--border));
    --border-strong: hsl(0 0% 70%);
    /* The boundary of an interactive control, kept apart from the
       decorative hairline above. WCAG 1.4.11 wants 3:1 against BOTH the
       control's own fill and the surface it sits on; the hairline measured
       1.28:1 in light and 1.45:1 in dark, so a 322-field form gave a
       low-vision operator no reliable cue where a box started. */
    --border-control: hsl(210 16% 58%);
    /* Default so the sticky offset resolves before _composeSyncStickyOffset
       measures the rail; the wizard view overrides it per breakpoint. */
    --cmp-sticky-top: 0px;

    --accent-primary: hsl(var(--primary));        /* solid primary fill; white text 5.35:1 */
    --accent-primary-hover: hsl(var(--primary-hover));
    --accent-text: hsl(var(--accent));            /* accent text/icon/border (8.5:1 on white) */
    --accent-tint: hsl(var(--secondary));         /* active tab / current selection */
    --accent-tint-strong: hsl(var(--secondary-strong));
    --accent-ring: hsl(var(--ring) / 0.40);

    /* semantic fills (white/ink text safe) + tints */
    --success: hsl(149 50% 30%);
    --success-bg: hsl(var(--status-success-bg));
    /* DM DEVIATION 1/3 from the canonical set (cicd: 35 92% 45%).
       .btn-warning / .toast-warning put ink #333 on this fill; measured
       4.42:1 at L=45%, a marginal WCAG AA miss. One lightness point
       lifts it to 4.62:1. Filed against cicd too — that is where the
       defect originates. */
    --warning: hsl(35 92% 46%);
    --warning-bg: hsl(var(--status-warning-bg));
    --danger: hsl(4 72% 47%);
    --danger-hover: hsl(4 72% 40%);
    --danger-bg: hsl(var(--status-error-bg));

    /* TEXT-SLOT counterparts of the three status FILL tokens above.
       --success / --warning / --danger are tuned to carry white (or ink
       #333) ON them. Putting the same token in a `color:` slot is a
       different job with a different bar, and several of them miss it:
       measured on the surfaces this app paints on,
         --success  as text: 3.66:1 (dark card)      FAIL
         --warning  as text: 2.74:1 (white)          FAIL
         --danger   as text: 2.79:1 (dark card)      FAIL
       The status tones already ship a text colour tuned for exactly this,
       and it clears 4.5:1 on every surface in BOTH themes (lowest measured
       4.96:1, --status-warning-text on the light sunken panel), so no
       per-theme override is needed — --status-*-text is itself themed.
       Rule: a fill token never appears in a `color:` slot. */
    --success-text: hsl(var(--status-success-text));
    --warning-text: hsl(var(--status-warning-text));
    --danger-text:  hsl(var(--status-error-text));
    --info: hsl(var(--accent));

    /* elevation — layered, brand-tinted depth. Three tiers: resting card,
       raised (interactive/hover), and overlay (modal/popover). */
    --shadow: 0 1px 2px hsl(208 28% 26% / 0.05), 0 3px 8px hsl(208 28% 26% / 0.07);
    --shadow-md: 0 2px 4px hsl(208 28% 24% / 0.06), 0 6px 16px hsl(208 30% 24% / 0.10);
    --shadow-hover: 0 10px 30px hsl(208 34% 22% / 0.16);

    --radius-lg: 14px;
    /* Motion (emil): color/background hovers use a quick ease; transforms and
       entrances use the stronger ease-out curve. All UI motion stays <300ms. */
    --transition: 0.18s ease;
    --press: 0.12s var(--ease-out);   /* :active press feedback */

    /* THE METRICS OF A FORM CONTROL, and they are a real token rather
       than a default written inside `var()`. `ui-select.css` (ft-ui-core)
       reads them so a dropdown is the same height as the input beside it;
       carrying the value only as a `var(…, 10px 14px)` fallback left the
       property UNDECLARED on every surface that does not override it,
       which is exactly what `design-system.spec.js`'s surface sweep
       fails on — sixteen of them, and it was right to. Operations
       narrows these on `.audit-field` / `.journal-field`. */
    --ui-control-pad: 10px 14px;
    --ui-control-font: 0.95rem;
    --focus-ring: 0 0 0 3px var(--accent-ring);

    --font-sans: 'Geist', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
    --font-display: var(--font-sans);
    --font-mono: 'Geist Mono', 'SFMono-Regular', 'Menlo', monospace;

    /* --- DM legacy aliases -------------------------------------------
       Names older DM rules and modules already reference. Kept as
       aliases (not new colours) so every var() in this repo resolves. */
    --bg-input: var(--bg-primary);
    --accent-color: var(--accent-text);
    --accent-success: var(--success);
    --accent-warning: var(--warning);
    --accent-danger: var(--danger);
    --warning-color: var(--warning);

    /* DM-ONLY TOKEN (cicd-manager has no equivalent) — a deliberate,
       allow-listed divergence, not drift.

       WHY IT EXISTS: `--success` is tuned as TEXT on a dark surface. In
       dark theme it is hsl(149 45% 42%), and white on that measures
       3.45:1 — a WCAG AA fail for a button fill. `--success-solid`
       darkens it to hsl(149 45% 32%) = 5.50:1 (dark) / hsl(149 50% 30%)
       = 5.78:1 (light, where it happens to coincide with --success).
       One name so .btn-success reads the same in both themes.

       Its only consumer, .btn-success, is PRE-EXISTING DEAD CODE: the
       class occurs in zero markup and did so in both stylesheets before
       this migration too. It is kept because the orphan-closure test
       requires every class family the old console.css owned to survive,
       and e2e/design-system.spec.js asserts the token is contrast-safe
       and that the class renders a real fill. Deleting one means
       deleting both, deliberately, as its own change.

       The cross-app token test allow-lists this key by name
       (APPROVED_DM_ONLY_TOKENS in e2e/design-system.spec.js). */
    --success-solid: hsl(149 50% 30%);

    /* DM-ONLY: CODE-SURFACE PALETTE — theme-invariant on purpose.
       `.yaml-out` and `.lab-terminal` are code grounds, not UI surfaces:
       xterm paints its own ANSI palette onto the terminal and the YAML
       preview carries syntax colour whose meaning must not flip with the
       app theme. A themed token here would repaint a dark code ground
       near-white in light mode and destroy both. So these four are
       declared once, outside the theme blocks, and never overridden.

       --code-bg is xterm's own background (js/compose-lab.js:602), so the
       CSS mount and the canvas xterm draws over it are the same colour —
       previously the mount was #000 and showed as a darker rim.
       Contrast on --code-bg, measured with tests/wcag.js:
         --code-key 12.83:1 · --code-str 13.28:1 · --code-num 9.63:1
       Allow-listed by name in APPROVED_DM_ONLY_TOKENS. */
    --code-bg:  #0d1117;
    --code-key: #8ce99a;
    --code-str: #ffd43b;
    --code-num: #74c0fc;

    /* DM-ONLY: rack status LEDs. They sit on the .host-server-rack bar,
       which is --accent-text and therefore themed, so the LEDs have to be
       themed too or they vanish against it. The previous hardcoded pale
       pair measured 5.77:1 / 5.57:1 on the light bar but 1.64:1 / 1.59:1
       on the dark one — invisible in dark theme. These are non-text UI
       indicators, so the bar is 3:1 (WCAG 1.4.11); light values keep the
       measured 5.77 / 5.57, dark values measure 3.73:1 / 3.17:1. */
    --rack-led-ok:   hsl(145 65% 72%);
    --rack-led-warn: hsl(40 90% 70%);

    /* DM-ONLY: the rack bar's "6 SVC" label. It sits on --accent-text and
       must therefore be themed with it. White measures 8.65:1 on the light
       (deep blue) bar and 2.47:1 on the dark (light blue) one — the dark
       value is an AA failure on 10.5px text, which is where this went
       wrong. The dark counterpart is a near-black blue: 6.72:1. */
    --rack-count-text: hsl(0 0% 100%);

    /* DM-ONLY: the peer-cluster / AI accent. It carries CATEGORY MEANING
       the FT palette cannot express — the FT set has no purple — and marks
       exactly three things, all "this belongs to a peer cluster":
         .svc-row .nm .tag.ai                (service-category tag)
         .chip[data-cluster="peer-cluster"]  (chip border-left)
         .chip .link-hint                    (linked-pair hint text)
       Re-using --accent-text would collapse them into the same category as
       load-balanced and standalone, which is what the colour distinguishes.

       CONTRAST, measured with tests/wcag.js. It is used as TEXT at
       .chip .link-hint, so the 4.5:1 bar applies, not 3:1:
         light hsl(288 42% 42%): 6.65:1 card / 5.82:1 sunken / 5.93:1 page
         dark  hsl(288 60% 70%): 4.85:1 card / 5.58:1 sunken / 6.57:1 page
       Lowest measured 4.85:1 — clears AA on every surface it paints on.

       Declared here rather than on body.wz-body so the cross-app union
       token test can actually see it; only the wizard consumes it. */
    --magenta: hsl(288 42% 42%);
}

/* ============================================================
   2. Tokens — dark theme
   Re-themes the triplets plus the colour aliases; type/shape/motion
   aliases stay theme-invariant on purpose.
   ============================================================ */
[data-theme="dark"] {
    --background: 0 0% 16.9%;
    --foreground: 0 1.7% 88.4%;
    --card: 0 0% 20%;
    --primary: 208.8 60% 46%;          /* a touch brighter on dark */
    --primary-hover: 208.8 62% 54%;
    --secondary: 208.7 60.5% 16%;      /* deep blue */
    --secondary-strong: 208.7 55% 22%;
    --muted: 0 0% 16.1%;
    --muted-foreground: 0 0% 62%;
    --accent: 209 70% 66%;             /* lighter blue for text on dark */
    --border: 0 0% 26%;
    --ring: 208.8 70% 60%;
    --page-chrome: 210 14% 11%;        /* tinted dark canvas */

    --status-success-bg: 150 35% 20%;
    --status-success-text: 145 55% 75%;
    --status-success-border: 149 28% 30%;
    --status-warning-bg: 35 50% 18%;
    --status-warning-text: 40 80% 72%;
    --status-warning-border: 38 38% 28%;
    --status-error-bg: 350 45% 20%;
    --status-error-text: 350 75% 78%;
    --status-error-border: 350 38% 30%;
    --status-info-bg: 209 45% 18%;
    --status-info-text: 209 65% 75%;
    --status-info-border: 209 38% 28%;

    --text-secondary: hsl(0 0% 70%);
    --border-strong: hsl(0 0% 38%);
    --border-control: hsl(0 0% 52%);

    --success: hsl(149 45% 42%);
    --warning: hsl(38 80% 50%);
    /* DM DEVIATION 2/3 (cicd: 4 75% 55%). .btn-danger / .toast-error put
       white on this fill; measured 4.15:1 at L=55%, a WCAG AA fail.
       L=51% gives 4.53:1 and stays lighter than --danger-hover below, so
       the hover relationship is preserved.
       DM DEVIATION 3/3 is --danger-hover, held at the canonical value on
       purpose: it must stay darker than the corrected base. */
    --danger: hsl(4 75% 51%);
    --danger-hover: hsl(4 75% 48%);

    --shadow: 0 1px 2px hsl(0 0% 0% / 0.35), 0 3px 8px hsl(0 0% 0% / 0.30);
    --shadow-md: 0 2px 4px hsl(0 0% 0% / 0.35), 0 6px 16px hsl(0 0% 0% / 0.40);
    --shadow-hover: 0 10px 30px hsl(0 0% 0% / 0.50);

    /* DM-ONLY TOKEN — see the light-theme block for the full rationale.
       This is the value the token exists FOR: dark `--success` is
       hsl(149 45% 42%) and white on it measures 3.45:1 (AA fail), while
       white on hsl(149 45% 32%) measures 5.50:1. Same hue and
       saturation, lightness lowered until the fill clears AA. */
    --success-solid: hsl(149 45% 32%);

    /* Declared in the dark block only — mirrors cicd-manager's second dark
       block verbatim. Nothing reads them in either app; the structure is
       copied rather than "fixed" so the two token sets stay identical. */
    --bg-warning: #3d3a1d;
    --text-warning: #e0c866;

    /* The rack bar (--accent-text) is a light blue here, so the LEDs
       invert to dark tones — 3.73:1 / 3.17:1, both clearing the 3:1
       non-text bar. The code-surface tokens are deliberately NOT
       overridden; see their block in :root. */
    --rack-led-ok:   hsl(149 60% 20%);
    --rack-led-warn: hsl(35 95% 24%);
    /* White on the light-blue dark bar measures 2.47:1. This measures
       6.72:1 on the same ground; see the :root block for the full note. */
    --rack-count-text: hsl(209 48% 12%);

    /* Lightened for the dark surfaces; see the :root block for the
       category rationale and the full measured contrast table. */
    --magenta: hsl(288 60% 70%);
}

/* ============================================================
   3. Reset & base
   ============================================================ */
html {
    font-size: 14px;
}

*, *::before, *::after {
    box-sizing: border-box;
    margin: 0;
    padding: 0;
}

/* A MODAL DIALOG CENTRES ITSELF, and the reset above is what stopped it.
   The browser centres `dialog:modal` with `inset: 0` plus `margin:
   auto`; `* { margin: 0 }` outranks a UA rule, so every modal `<dialog>`
   in this app pinned to the top-left corner of the viewport, half over
   the page header. Restored here rather than on the one dialog that
   exists today: the reset breaks the next one the same way, silently,
   and only when somebody opens it. */
dialog:modal {
    margin: auto;
}

body {
    font-family: var(--font-sans);
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
    text-rendering: optimizeLegibility;
    background: var(--bg-primary);
    color: var(--text-primary);
    line-height: 1.6;
    min-height: 100vh;
    transition: background var(--transition), color var(--transition);
}

/* App Container */
/* The sticky section row's own height plus the gap it sticks at,
   republished by app.js on every resize. Declared here so a screen
   rendered before that measurement still lays out. */
:root { --nav-h: 0px; }

/* Anchored jumps and `scrollIntoView` land BELOW the sticky section row. */
html { scroll-padding-top: calc(var(--nav-h, 0px) + 8px); }

.app {
    max-width: 1600px;
    margin: 0 auto;
    padding: 20px;
}

.main-content {
    display: block;
}

/* ============================================================
   4. Header
   ============================================================ */
.header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 18px 24px;
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow);
    margin-bottom: 16px;
}

.header-left {
    display: flex;
    align-items: baseline;
    gap: 16px;
    min-width: 0;
}

.logo {
    color: var(--text-primary);
    font-family: var(--font-display);
    font-size: 1.5rem;
    font-weight: 600;
    letter-spacing: -0.01em;
    margin: 0;
}

.header-subtitle {
    color: var(--text-muted);
    font-size: 0.9rem;
}

.header-right {
    display: flex;
    align-items: center;
    gap: 12px;
}

.header-link {
    color: var(--accent-text);
    text-decoration: none;
    padding: 6px 12px;
    border-radius: var(--radius);
    background: var(--surface-interactive);
    transition: background var(--transition), color var(--transition);
}

.header-link:hover {
    background: var(--surface-interactive-hover);
}

.header-link:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

.user-email {
    color: var(--text-secondary);
    font-size: 0.85rem;
}

.admin-badge {
    background: var(--warning);
    color: #333;
    font-size: 0.75rem;
    padding: 2px 8px;
    border-radius: 999px;
    font-weight: 600;
}

.theme-toggle {
    background: var(--surface-interactive);
    border: none;
    border-radius: 50%;
    width: 40px;
    height: 40px;
    cursor: pointer;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: background var(--transition), transform var(--press);
}

.theme-toggle:hover {
    background: var(--surface-interactive-hover);
}

.theme-toggle:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

.theme-toggle:active {
    transform: scale(0.94);
}

.theme-icon {
    font-size: 1.15rem;
    color: var(--text-primary);
}

/* ============================================================
   5. Tabs — One Voice: the current tab is a quiet tint + accent text
   + a 2px underline. Never a solid fill (that competes with the
   primary action on the page).
   ============================================================ */
.tabs {
    display: flex;
    gap: 4px;
    margin-bottom: 16px;
    /* The section row stays put while a screen scrolls, for the same
       reason the wizard stepper does: past the first viewport the
       operator otherwise has no way to leave the screen they are on
       without scrolling back to the top. Its measured height is
       published as `--nav-h` (app.js) so everything else that sticks to
       the top of the page starts BELOW it instead of under it. */
    position: sticky;
    /* Not flush with the window edge — the row keeps the same breathing
       space the page's own padding gives it while unscrolled. */
    top: 12px;
    /* Above the page's own sticky boxes (stepper 5, builder banner 6)
       and below the assistant launcher (900) and every overlay. */
    z-index: 20;
    /* The row is inset and rounded, so a scrolling card would show
       both above it and through its corners. The second shadow paints
       the page's own background over that strip, so what passes behind
       the row stays behind it. Offset plus spread is 16px — the whole
       strip it must cover (the 12px it sticks at) and no more, because
       anything wider eats into the header sitting above it while the
       page is unscrolled. */
    background: var(--bg-secondary);
    padding: 5px;
    border-radius: var(--radius-lg);
    border: 1px solid var(--border-color);
    box-shadow: var(--shadow), 0 -8px 0 8px var(--bg-primary);
}

.tab {
    /* `1 1 auto`, NOT `1` (which is `1 1 0`). Equal slots gave a
       six-letter tab and a twelve-letter one the same box, so the ink
       sat in wildly different amounts of padding — and the active pill,
       which fills its whole slot, made that visible: it looked
       arbitrarily wide beside a long label in an identical box. Growing
       from the CONTENT keeps every pill proportional to its own text
       while the row still fills the bar. (Asked about the top nav,
       2026-09-07.) */
    flex: 1 1 auto;
    /* The builder renders its tabs as links so they navigate; a link needs
       the centring and the underline suppression a button gave for free. */
    text-align: center;
    text-decoration: none;
    padding: 12px 24px;
    background: transparent;
    border: none;
    border-radius: var(--radius);
    cursor: pointer;
    font-family: inherit;
    font-size: 0.95rem;
    font-weight: 500;
    color: var(--text-secondary);
    box-shadow: inset 0 -2px 0 transparent;
    transition: background var(--transition), color var(--transition), box-shadow var(--transition);
}

.tab:hover {
    background: var(--surface-sunken);
    color: var(--text-primary);
}

.tab:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
}

.tab.active {
    background: var(--accent-tint);
    color: var(--accent-text);
    font-weight: 600;
    box-shadow: inset 0 -2px 0 var(--accent-text);
}

/* A group with no leaf this role has is not on the row at all. The
   `hidden` ATTRIBUTE, not the `.hidden` class: that class is owned by the
   per-leaf role gate and by training's own asynchronous authority. `.tab`
   sets no `display`, so the UA rule would already win — this is written
   down so a future `display` on `.tab` cannot silently un-hide a group. */
.tabs .tab[hidden] { display: none; }

/* ============================================================
   5b. The second level — the current group's leaves, as a segmented
   control above the screen.

   ONE VOICE, ONE VOLUME. `--accent-tint` is this system's current-
   selection fill and it belongs to the GROUP above (`.tab.active`);
   saying "current" twice at the same weight tells the reader nothing
   about which is which. So the segment reuses the QUIETEST of the three
   segmented idioms this app already has — `.csenv-server` (:3593), plain
   buttons with `role="tab"` + `aria-selected` whose current state is an
   accent border and accent text and no fill at all. Not
   `.compose-view-toggle` (:2382) and not the `.seg` radios (:9071):
   both carry the tint, and the radios cannot take `role="tab"`.
   ============================================================ */
.nav-seg-wrap {
    margin-bottom: 16px;
}

.nav-seg {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
}

.nav-seg-item {
    padding: 6px 12px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background: none;
    color: var(--text-secondary);
    font-family: inherit;
    font-size: 0.85rem;
    font-weight: 500;
    line-height: 1.2;
    cursor: pointer;
    transition: background var(--transition), color var(--transition),
                border-color var(--transition);
}

.nav-seg-item:hover:not([aria-selected="true"]) {
    background: var(--surface-sunken);
    color: var(--text-primary);
}

.nav-seg-item:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

.nav-seg-item:active {
    transform: translateY(1px);
    transition: transform var(--press);
}

/* Three signals, never colour alone: the border, the ink and the weight. */
.nav-seg-item[aria-selected="true"] {
    border-color: var(--accent-text);
    color: var(--accent-text);
    font-weight: 600;
}

.nav-seg-item:disabled {
    color: var(--text-muted);
    cursor: not-allowed;
}

/* Tab Content */
.tab-content {
    display: none;
}

.tab-content.active {
    display: block;
    animation: dm-fade-in 0.18s var(--ease-out);
}

@keyframes dm-fade-in {
    from { opacity: 0; transform: translateY(2px); }
    to   { opacity: 1; transform: translateY(0); }
}

/* ============================================================
   6. Cards
   ============================================================ */
.card {
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
    padding: 28px;
    box-shadow: var(--shadow);
}

.card h2 {
    margin-bottom: 24px;
    font-family: var(--font-display);
    font-size: 1.5rem;
    font-weight: 600;
    letter-spacing: -0.01em;
    color: var(--text-primary);
}

/* Header strip inside a card: title left, action cluster right.
   Orphaned when console.css owned it — re-authored on FT tokens. */
.card-header {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
    flex-wrap: wrap;
    padding-bottom: 16px;
    margin-bottom: 20px;
    border-bottom: 1px solid var(--border-color);
}

.card-header h2,
.card-header h3 {
    margin: 0;
    font-family: var(--font-display);
    font-weight: 600;
    letter-spacing: -0.01em;
    color: var(--text-primary);
}

.card-header h2 { font-size: 1.5rem; }
.card-header h3 { font-size: 1.15rem; }

.card-header-actions {
    display: flex;
    gap: 8px;
    align-items: center;
    flex-wrap: wrap;
}

/* A SMALL button in this toolbar keeps its quieter type and loses the
   smaller box: the occupancy chip is `btn-small` because it is
   secondary, and it came out 28.3px against Refresh's 38.0 — measured —
   which reads as a control that fell short of the row rather than one
   deliberately quieter. `stretch` takes the row's own height, so there
   is no number here to drift from `.btn`'s. */
.card-header-actions > .btn-small { align-self: stretch; }

/* Legacy alias for the modal shell — same treatment as .modal. */
.modal-content {
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-hover);
}

/* ============================================================
   7. Resource / metric summary tiles
   ============================================================ */
.resource-summary,
.summary-cards {
    display: flex;
    gap: 16px;
    margin-bottom: 24px;
    flex-wrap: wrap;
}

.resource-item,
.resource-card,
.summary-card {
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    padding: 16px 24px;
    display: flex;
    flex-direction: column;
    align-items: center;
    min-width: 120px;
    transition: border-color var(--transition), box-shadow var(--transition);
}

.resource-card:hover,
.summary-card:hover {
    border-color: var(--accent-text);
    box-shadow: var(--shadow-md);
}

.resource-label,
.summary-label {
    font-size: 0.75rem;
    color: var(--text-muted);
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

.resource-value,
.summary-value {
    font-family: var(--font-mono);
    font-variant-numeric: tabular-nums;
    font-size: 1.5rem;
    font-weight: 600;
    color: var(--text-primary);
}

/* Progress rails — same track/fill construction as the release tracker. */
.resource-bar {
    width: 100%;
    height: 8px;
    background: var(--surface-sunken);
    border-radius: 999px;
    margin-top: 8px;
    overflow: hidden;
}

/* Fill level is --fill (0..1), driven by transform, not width: width is a
   layout property, so animating it runs layout + paint + composite every
   frame; scaleX runs on the compositor alone. The track above is already
   `border-radius: 999px; overflow: hidden`, so the fill needs no radius of
   its own — and must not have one, since scaleX would squash it. */
.resource-bar-fill {
    --fill: 0;
    height: 100%;
    width: 100%;
    background: var(--accent-primary);
    transform: scaleX(var(--fill));
    transform-origin: left center;
    transition: transform 0.24s var(--ease-out);
}

.resource-bar-fill.bar-ok { background: var(--success); }
.resource-bar-fill.bar-warn { background: var(--warning); }
.resource-bar-fill.bar-danger { background: var(--danger); }

/* ============================================================
   8. Filter bar + search
   ============================================================ */
.filters-row {
    display: flex;
    gap: 12px;
    margin-bottom: 16px;
    flex-wrap: wrap;
    align-items: center;
}

.search-box {
    flex: 1;
    min-width: 200px;
}

.search-box input {
    width: 100%;
    padding: 10px 14px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background: var(--bg-primary);
    color: var(--text-primary);
    font-family: inherit;
    font-size: 0.95rem;
    transition: border-color var(--transition), box-shadow var(--transition);
}

.search-box input:focus,
.search-box input:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}

.filter-group {
    display: flex;
    gap: 8px;
    flex-wrap: wrap;
}

.filter-group select {
    padding: 10px 14px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background: var(--bg-primary);
    color: var(--text-primary);
    font-family: inherit;
    font-size: 0.9rem;
    cursor: pointer;
}

.filter-group select:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}

.refresh-controls {
    display: flex;
    gap: 12px;
    align-items: center;
}

.checkbox-label {
    display: flex;
    align-items: center;
    gap: 6px;
    cursor: pointer;
    font-size: 0.9rem;
    color: var(--text-secondary);
}

.checkbox-label input {
    cursor: pointer;
}

/* ============================================================
   9. Data tables
   ============================================================ */
.table-container {
    overflow-x: auto;
}

.data-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 0.9rem;
}

.data-table th,
.data-table td {
    padding: 12px;
    text-align: left;
    border-bottom: 1px solid var(--border-color);
}

.data-table th {
    background: var(--surface-sunken);
    font-weight: 600;
    color: var(--text-secondary);
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

.data-table th.sortable {
    cursor: pointer;
    user-select: none;
}

.data-table th.sortable:hover {
    background: var(--surface-interactive);
}

.data-table th.sortable:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
}

.data-table th.sortable::after {
    content: ' ↕';
    opacity: 0.4;
}

.data-table th.sortable.asc::after {
    content: ' ↑';
    opacity: 1;
    color: var(--accent-text);
}

.data-table th.sortable.desc::after {
    content: ' ↓';
    opacity: 1;
    color: var(--accent-text);
}

.data-table tbody tr {
    transition: background var(--transition);
}

.data-table tbody tr:hover {
    background: var(--surface-sunken);
}

.data-table tbody tr:last-child td {
    border-bottom: none;
}

/* Loading / empty / error rows — a table body is never a blank box. */
.data-table .loading,
.data-table .empty,
.data-table .error {
    text-align: center;
    color: var(--text-muted);
    padding: 32px 16px;
}

.data-table .loading::before {
    content: '';
    display: inline-block;
    width: 12px;
    height: 12px;
    margin-right: 8px;
    border: 2px solid currentColor;
    border-top-color: transparent;
    border-radius: 50%;
    animation: spin 0.6s linear infinite;
    vertical-align: -1px;
}

.data-table .empty::before {
    content: '○ ';
    color: var(--text-muted);
}

.data-table .error {
    color: var(--danger-text);
}

.data-table .error::before {
    content: '✕ ';
}

.data-table .actions,
.data-table .actions-cell {
    display: flex;
    gap: 6px;
    flex-wrap: wrap;
    align-items: center;
}

/* ============================================================
   10. Status badges — tint background + solid text + a glyph.
   Two signals, never colour alone (PRODUCT.md a11y target). The glyph
   set is carried over from the retired console.css layer.
   ============================================================ */
.badge {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    padding: 3px 10px;
    border-radius: 999px;
    font-size: 0.75rem;
    font-weight: 600;
    line-height: 1.4;
    white-space: nowrap;
    background: var(--surface-sunken);
    color: var(--text-secondary);
}

.badge::before {
    font-size: 0.85em;
    line-height: 1;
}

.badge-success, .badge-synced, .badge-healthy, .badge-running {
    background: var(--success-bg);
    color: var(--success-text);
}
.badge-success::before, .badge-synced::before,
.badge-healthy::before, .badge-running::before { content: '●'; }

.badge-warning, .badge-outofsync, .badge-progressing {
    background: var(--warning-bg);
    color: var(--warning-text);
}
.badge-warning::before, .badge-outofsync::before,
.badge-progressing::before { content: '◐'; }

.badge-danger, .badge-degraded, .badge-missing {
    background: var(--danger-bg);
    color: var(--danger-text);
}
.badge-danger::before, .badge-degraded::before,
.badge-missing::before { content: '✕'; }

.badge-info, .badge-static {
    background: hsl(var(--status-info-bg));
    color: hsl(var(--status-info-text));
}
.badge-info::before, .badge-static::before { content: '◆'; }

.badge-stopped {
    background: var(--surface-sunken);
    color: var(--text-secondary);
}
.badge-stopped::before { content: '○'; }

.badge-secondary {
    background: var(--surface-sunken);
    color: var(--text-secondary);
}

/* `muted` shares --surface-sunken with `.data-table tbody tr:hover`, so it
   VANISHED under the cursor — on the exact row the operator was pointing
   at. The ring gives it an edge on any ground without moving a token that
   two other components read.

   It stays glyph-free on purpose, unlike every status tone: `secondary`
   and `muted` are the two tones that carry a NAME rather than a state,
   and e2e/design-system.spec.js pins that. Where the SSH column uses it
   the meaning is in the label ("Not set", "—") and, on the card, in the
   sentence beside it — so it is never colour alone there either. */
.badge-muted {
    background: var(--surface-sunken);
    color: var(--text-secondary);
    box-shadow: inset 0 0 0 1px var(--border-color);
}

/* .badge-error predates the set: it rendered a solid danger fill with no
   glyph, i.e. colour-only and out of key with every other pill. Folded
   into the system here. */
.badge-error {
    background: var(--danger-bg);
    color: var(--danger-text);
    padding: 3px 10px;
    border-radius: 999px;
    font-size: 0.72rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.02em;
}
.badge-error::before { content: '✕'; }
/* `external` is a state — a service this environment consumes but does not
   run — so it needs a glyph like every other state. Colour alone fails
   anyone who cannot separate the tints. */
.badge-external::before { content: '◇'; }

/* Machine text — a literal an operator pastes into a terminal must be
   visually distinguishable from prose. */
.tag-badge,
.image-sha,
.service-tag,
.metric-cell,
.compose-image-tag {
    font-family: var(--font-mono);
    font-variant-numeric: tabular-nums;
}

.tag-badge {
    display: inline-block;
    padding: 2px 9px;
    border-radius: 999px;
    font-size: 0.75rem;
    background: var(--surface-sunken);
    color: var(--text-secondary);
}

.tag-latest { background: hsl(var(--status-info-bg));    color: hsl(var(--status-info-text)); }
.tag-dev    { background: var(--warning-bg);             color: var(--warning-text); }
.tag-custom { background: var(--accent-tint);            color: var(--accent-text); }

.image-sha {
    font-size: 0.72rem;
    color: var(--text-muted);
    margin-top: 4px;
}

.metric-cell {
    font-size: 0.85rem;
    white-space: nowrap;
    text-align: right;
    color: var(--text-secondary);
}

/* Stopped environment row */
.env-stopped {
    opacity: 0.75;
}

.env-stopped td {
    background: var(--danger-bg);
}

.env-stopped td:first-child::before {
    content: '○ ';
    color: var(--text-muted);
}

/* ============================================================
   11. Forms
   ============================================================ */
.form-section {
    margin-bottom: 32px;
    padding-bottom: 24px;
    border-bottom: 1px solid var(--border-color);
}

.form-section:last-child {
    border-bottom: none;
    margin-bottom: 0;
}

.form-section h3,
.form-section h4 {
    font-family: var(--font-display);
    font-size: 1.15rem;
    font-weight: 600;
    margin-bottom: 16px;
    color: var(--text-primary);
}

.form-row {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: 16px;
    margin-bottom: 16px;
}

.form-group {
    display: flex;
    flex-direction: column;
    gap: 6px;
}

.form-group label {
    font-size: 0.875rem;
    font-weight: 500;
    color: var(--text-secondary);
}

.form-group input,
.form-group select,
.form-group textarea {
    padding: 10px 14px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    font-family: inherit;
    font-size: 0.95rem;
    background: var(--bg-input);
    color: var(--text-primary);
    transition: border-color var(--transition), box-shadow var(--transition);
}

.form-group input:focus,
.form-group select:focus,
.form-group textarea:focus,
.form-group input:focus-visible,
.form-group select:focus-visible,
.form-group textarea:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}

.form-group input:disabled,
.form-group select:disabled,
.form-group textarea:disabled {
    background: var(--surface-sunken);
    cursor: not-allowed;
    opacity: 0.6;
}

/* Keyed on the attribute as well as the class: `aria-invalid` IS the
   state, so the border and what assistive tech announces cannot drift
   apart, and it reaches step 2's `.form-field` VIP input, which lives
   outside `.form-group`. */
.form-group input.error,
.form-group select.error,
input[aria-invalid="true"],
select[aria-invalid="true"],
/* ui-select hides the native control, so the border has to land on the
   trigger the operator can actually see. */
.ui-select.error .ui-select-trigger {
    border-color: var(--danger);
}

/* Checked state reads in the brand accent, not the OS default blue. */
input[type="checkbox"] {
    accent-color: var(--accent-primary);
}

input[type="checkbox"]:focus-visible,
input[type="radio"]:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

/* Present to assistive tech, absent from the design. Not `display:none`
   and not `hidden`, either of which removes it from the accessibility
   tree as well — which is the whole point of the class. */
.sr-only {
    position: absolute;
    width: 1px;
    height: 1px;
    padding: 0;
    margin: -1px;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
    border: 0;
}

.hint,
.form-help {
    font-size: 0.85rem;
    color: var(--text-muted);
    margin: 0;
}

.hint.error,
.form-help.error {
    color: var(--danger-text);
}

.form-help-secret { color: var(--warning-text); }

/* What the SELECTED option means, under a dropdown. Marked off from the
   field's own description above it, because it changes as the operator
   changes the choice and a line that moves needs to look like it belongs
   to the control. */
.form-help-option {
    padding-left: 0.6rem;
    border-left: 2px solid var(--border-color);
    color: var(--text-secondary);
}
.required-mark    { color: var(--danger-text); font-weight: 700; }
.text-muted       { color: var(--text-muted); }
.text-warning     { color: var(--warning-text); }
.muted            { color: var(--text-muted); }

.checkbox-group {
    flex-direction: row;
    align-items: center;
    gap: 8px;
}

.checkbox-group input[type="checkbox"] {
    width: 18px;
    height: 18px;
    cursor: pointer;
}

.checkbox-group label {
    cursor: pointer;
}

/* Wizard field grid (compose create) */
.form-grid {
    display: grid;
    /* `auto-fill`, not `auto-fit`. auto-fit COLLAPSES the empty tracks and
       stretches the survivors, so track width became a function of how many
       fields a group happened to have: one password input rendered 1316px
       wide, a two-field group 651px, and the identical control in a
       22-field group two rows down 318px. auto-fill keeps the tracks, so
       every field on the step is the same width. */
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 0.75rem 1rem;
}

/* Each field owns three bands — label, control, help — and they line up
   ACROSS the row, not only inside their own cell. Without that a two-line
   label drops its own control while the field beside it stays put, and a
   row reads as a staircase. `subgrid` hands the row heights up to the
   parent, so every label band in a row is as tall as the tallest label in
   that row and every control therefore starts on the same line. */
@supports (grid-template-rows: subgrid) {
    .form-grid > .form-field {
        display: grid;
        grid-row: span 3;
        grid-template-rows: subgrid;
        align-content: start;
    }
    /* Everything under the control — secret hint, description, per-option
       line — is ONE child, so the third band holds one grid item however
       many sentences a field carries. Pinning them individually put two
       items in the same cell, and two grid items in one cell do not stack:
       FTACS_MODE drew its description and its option line on top of each
       other. Claiming a row each would knock this field's three-row span
       out of step with its neighbours, which is the whole point of the
       span. */
    .form-grid > .form-field > .form-field-help {
        grid-row: 3;
    }
}

/* The band itself: normal flow, so several sentences stack. */
.form-field-help {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    min-width: 0;
}

.form-field {
    display: flex;
    flex-direction: column;
    gap: 0.25rem;
    /* A grid item's automatic minimum is its longest unbreakable word, and
       these labels are SCREAMING_SNAKE with no spaces in them:
       FTACS_SUBSCRIPTION_PARAMETER_ONLY_VALUE_CHANGE was wider than its
       track, so the field spilled over its neighbour and the label was
       clipped at the panel's edge. Without this every long-named variable
       breaks the column it sits in. */
    min-width: 0;
}

.form-field label {
    font-weight: 500;
    font-size: 0.875rem;
    color: var(--text-secondary);
    /* The label carries the name AND the origin chip. Let the pair wrap as
       a unit and let the name itself break, so a long one costs a second
       line instead of the column's integrity. */
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 0.15rem 0.35rem;
    min-width: 0;
    /* `break-word`, not `anywhere`: the renderer puts a <wbr> after every
       underscore, so there is a sensible break to take. `anywhere` let the
       browser split mid-word first and produced "..._VALUE_CHA / NGE". */
    overflow-wrap: break-word;
}

/* Checkboxes are the one control shorter than its label. Without this the
   box sits on the label's baseline and reads as belonging to the field
   above it — which is how MQTT_ENABLE ended up under its neighbour's
   name. */
.form-field-bool > label { align-items: center; }

.form-field input,
.form-field select,
.form-field textarea,
/* The deploy panel's secret rows and its "where it goes" paths are not
   `.form-field` — that class is a three-band subgrid for the wizard's
   column grid, and these are single lines. They still have to BE the
   same box, so they join the rule instead of restating padding, border,
   radius and background. Left out, they render as raw browser inputs:
   square corners, a hairline nobody chose, and a height that lines up
   with nothing beside it. */
.cdep-secret-row input,
.cdep-where input[type="text"],
/* The `.env` panel's fields and its search box are the same case, and
   were the same defect: they shipped as raw browser inputs — square
   corners, a 2px inset UA border, no padding — on the one screen in
   this app that writes to a customer's production file. Reported
   2026-08-28. */
.csenv-row input,
.csenv-filter,
/* The Activity tab's filter row is the same case a THIRD time. Restated
   locally it rendered a 34px text field beside a 40.6px dropdown and a
   38px button in one bottom-aligned row, with the two labels on
   different baselines. Joining the rule is the fix; restating it is how
   this defect keeps coming back. */
.act-field input,
.act-field select {
    /* Matches .ui-select-trigger. These rendered 34px tall while every
       dropdown beside them rendered 41px, so a text field never lined up
       with the control next to it in the same row. */
    padding: 10px 14px;
    /* A 1px hairline at 1.28:1 against the card is a decorative line, not a
       component boundary — WCAG 1.4.11 asks for 3:1 on the thing that tells
       you where a control begins. --border-control is that; --border-color
       stays the decorative one. */
    border: 1px solid var(--border-control);
    border-radius: var(--radius);
    background: var(--bg-input);
    color: var(--text-primary);
    font-family: inherit;
    font-size: 0.9rem;
    transition: border-color var(--transition), box-shadow var(--transition);
}

.form-field input:focus,
.form-field select:focus,
.form-field textarea:focus,
.form-field input:focus-visible,
.form-field select:focus-visible,
.form-field textarea:focus-visible,
.cdep-secret-row input:focus,
.cdep-secret-row input:focus-visible,
.cdep-where input[type="text"]:focus,
.cdep-where input[type="text"]:focus-visible,
.csenv-row input:focus,
.csenv-row input:focus-visible,
.csenv-filter:focus,
.csenv-filter:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}

/* A field the operator has been sent back to. Colour is not the only
   signal — the message under the control says what is wrong. */
.form-field input[aria-invalid="true"],
.form-field textarea[aria-invalid="true"],
.cdep-secret-row input[aria-invalid="true"],
.cdep-where input[type="text"][aria-invalid="true"],
.csenv-row input[aria-invalid="true"] {
    border-color: var(--danger-text);
}

.form-field input:disabled,
.form-field select:disabled {
    background: var(--surface-sunken);
    cursor: not-allowed;
    opacity: 0.6;
}

.form-field input[type="checkbox"] {
    align-self: flex-start;
    width: auto;
}

/* Boolean fields — a switch, not a naked checkbox.
   A tick in a 13px box is tone alone: it says nothing about which way is
   on, and it left a stray square floating in a column where every other
   field shows a full-width control. The track carries the position and the
   word carries the state (The Two-Signal Rule), and the pair sits in the
   same slot the inputs beside it occupy. Still a real checkbox underneath —
   keyboard, :focus-visible and assistive tech are the browser's, not
   ours. */
.switch {
    display: inline-flex;
    align-items: center;
    gap: 0.5rem;
    align-self: flex-start;
}

.switch input[type="checkbox"] {
    appearance: none;
    -webkit-appearance: none;
    flex: none;
    width: 36px;
    height: 20px;
    margin: 0;
    border: 1px solid var(--border-color);
    border-radius: 999px;
    background: var(--surface-sunken);
    cursor: pointer;
    position: relative;
    transition: background-color var(--transition), border-color var(--transition);
}

/* The knob. Its travel IS the second signal, so it moves even when the
   track colour is indistinguishable to the operator.

   The travel is a `transform`, not `left`. Animating `left` moves the knob
   on the layout thread — 60 frames of reflow for a 4px dot — and it is the
   one transition in this stylesheet that did it. The rendered result is
   identical: `left` stays pinned at 2px and the 16px is translated. */
.switch input[type="checkbox"]::after {
    content: "";
    position: absolute;
    top: 50%;
    left: 2px;
    width: 14px;
    height: 14px;
    border-radius: 999px;
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    transform: translate(0, -50%);
    transition: transform var(--transition), background-color var(--transition);
}

.switch input[type="checkbox"]:checked {
    background: var(--accent-primary);
    border-color: var(--accent-primary);
}

.switch input[type="checkbox"]:checked::after {
    transform: translate(16px, -50%);
    border-color: transparent;
}

.switch input[type="checkbox"]:hover:not(:disabled) {
    border-color: var(--border-strong);
}

.switch input[type="checkbox"]:active:not(:disabled)::after {
    width: 17px;
}

.switch input[type="checkbox"]:disabled {
    cursor: not-allowed;
    opacity: 0.6;
}

.switch-word {
    font-size: 0.85rem;
    color: var(--text-muted);
    min-width: 2ch;
}

.switch input[type="checkbox"]:checked ~ .switch-word {
    color: var(--text-primary);
    font-weight: 500;
}

/* ── Deploy: the secrets form ──────────────────────────────────────────
   Ten credentials, one row each. The row is name + box + the actions
   that fill it; the contract's two-to-three-line description sits behind
   the `?` because it is needed once in ten and cost ten paragraphs of
   height every time. Nothing here uses `.form-grid`: that grid is a
   three-band subgrid built to line rows up ACROSS columns, and this list
   is one column of single lines. */
.cdep-intro {
    font-size: 0.85rem;
    color: var(--text-muted);
    margin: 0 0 14px;
    line-height: 1.6;
}

.cdep-intro strong { color: var(--text-primary); font-weight: 600; }

/* Stop 0: what the last test install of this bundle did. It sits ABOVE
   the intro because it is the fact that decides whether to go on, and
   this screen's rule is "where you are, THEN what is next". Not an
   offer: accepting one would discard the in-flight deploy's whole
   in-memory state. Three signals as everywhere else - the sentence
   carries it, the weight and the tone only mark it. */
.cdep-lab-fact {
    font-size: 0.85rem;
    /* Weighted in EVERY state, including the quiet one. Measured in a
       browser: at secondary weight it sat directly above `.cdep-intro`
       at the same size and colour and read as that paragraph's first
       line, so the fact the screen exists to state was not seen before
       it was read. */
    font-weight: 600;
    margin: 0 0 12px;
    line-height: 1.6;
    color: var(--text-primary);
}

.cdep-lab-fact.is-passed { color: var(--success-text); }
/* The glyph sits on the sentence's baseline, like the card's. */
.cdep-lab-fact {
    display: flex;
    align-items: baseline;
    gap: 6px;
}

.cdep-lab-fact-icon {
    display: inline-flex;
    flex: 0 0 auto;
    align-self: center;
}

.cdep-lab-fact.is-failed { color: var(--danger-text); }
.cdep-lab-fact.is-stale  { color: var(--warning-text); }

.cdep-secrets {
    display: flex;
    flex-direction: column;
    gap: 14px;
}

.cdep-secret-head {
    display: flex;
    align-items: center;
    flex-wrap: wrap;               /* SERVER_SSL_KEY_STORE_PASSWORD + three
                                      owner names is wider than the panel at
                                      1280; the owner drops, the name does
                                      not get squeezed. */
    gap: 4px;
    margin-bottom: 4px;
    min-width: 0;
}

.cdep-secret-head label {
    font-family: var(--font-mono);
    font-size: 0.85rem;
    font-weight: 500;
    color: var(--text-secondary);
    letter-spacing: 0.01em;      /* small text reads wider, not tighter */
    /* SCREAMING_SNAKE has no spaces; let it wrap at the underscores
       rather than push the row's controls off the panel. */
    overflow-wrap: anywhere;
    min-width: 0;
}

.cdep-optional {
    font-size: 0.75rem;
    font-weight: 500;
    color: var(--text-muted);
    text-transform: lowercase;
    letter-spacing: 0.02em;
    margin-left: 6px;
}

/* A secret box that masks WITHOUT being a password field.
   `type="password"` is what makes Chrome offer to save the value, and
   these are a customer's server credentials, not the operator's login —
   see `composeSecretInputAttrs` in compose-state.js for why
   `autocomplete` cannot fix it. Applied by class so the reveal toggle
   stays a class toggle and the input never changes type. */
input.is-masked {
    -webkit-text-security: disc;
}

/* Secrets grouped by the file the value is written into — the axis the
   server `.env` editor already groups by, so an operator meets one shape
   for one question. The header is the whole explanation: a flat list
   sorted by name puts JWT_SECRET (ft-configs-service's own tokens) next
   to JWT_TOKEN_SECRET (the northbound-api/provision-api pair), and no
   description on either row separates them. */
.cdep-file-group + .cdep-file-group {
    margin-top: 20px;
    padding-top: 16px;
    border-top: 1px solid var(--border-color);
}

.cdep-file-head {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;               /* the path is machine text: let it drop */
    gap: 4px 10px;
    margin: 0 0 10px;
    font-size: 0.85rem;
    font-weight: 600;
}

/* Mono, like every other place in this app that shows a real path. */
.cdep-file-path {
    font-family: var(--font-mono);
    color: var(--text-primary);
    overflow-wrap: anywhere;
    min-width: 0;
}

.cdep-file-help {
    font-weight: 400;
    color: var(--text-muted);
}

/* Pushed to the end so the counts line up down the panel rather than
   trailing each header at a different x. */
.cdep-file-count {
    margin-left: auto;
    font-weight: 500;
    color: var(--text-muted);
    font-variant-numeric: tabular-nums;
}

/* WHOSE secret it is, in the head band beside the name — shown only
   where the group header does not already answer it. Same voice as
   `.cdep-optional`: this is identification, not an instruction. */
.cdep-secret-owner {
    font-size: 0.75rem;
    font-weight: 500;
    color: var(--text-muted);
    letter-spacing: 0.02em;
    overflow-wrap: anywhere;
    min-width: 0;
}

.cdep-secret-row {
    display: flex;
    align-items: center;
    gap: 6px;
}

/* What differs from every other box in the app: it holds machine text,
   so it is set in the mono face — an operator can tell a value they will
   paste into a terminal from a label describing it. */
.cdep-secret-row input {
    flex: 1 1 auto;
    min-width: 0;
    font-family: var(--font-mono);
    font-size: 0.85rem;
}

/* The fill control names what it does and keeps its icon: "Generate" and
   "Use shipped value" are different promises, and a shared glyph would
   hide that. Ghost, because this form's one strong control is Deploy. */
.cdep-fill {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    gap: 6px;
    white-space: nowrap;
}

/* The two per-field icon buttons. Sized to the input's own height so the
   row reads as one control strip rather than three stacked heights. */
.cdep-icon,
.cdep-why {
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 30px;
    height: 30px;
    padding: 0;
    border: 1px solid transparent;
    border-radius: 8px;
    background: transparent;
    color: var(--text-muted);
    cursor: pointer;
    transition: color var(--transition), background var(--transition),
                border-color var(--transition), transform var(--press);
}

.cdep-why {
    width: 22px;
    height: 22px;
}

@media (hover: hover) and (pointer: fine) {
    .cdep-icon:hover,
    .cdep-why:hover {
        color: var(--text-primary);
        background: var(--surface-sunken);
        border-color: var(--border-color);
    }
}

.cdep-icon:active,
.cdep-why:active { transform: scale(0.94); }

.cdep-icon:focus-visible,
.cdep-why:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 1px;
    color: var(--text-primary);
}

/* Pressed reveal is a STATE, not a hover: it stays lit while the value
   is on screen, so "this password is visible in this room" is legible
   without moving the pointer. */
.cdep-icon[aria-pressed="true"] {
    color: var(--accent-text);
    border-color: var(--accent-text);
}

.cdep-icon.is-copied { color: var(--success-text); }

.cdep-doc,
.cdep-note {
    margin: 6px 0 0;
    line-height: 1.5;
}

.cdep-error {
    margin: 6px 0 0;
    line-height: 1.5;
}

/* Several of these appear at once when a deploy is refused. The message
   fades in; the reflow under it does NOT — a transition on a layout
   property is forbidden here (tests/design-tokens.test.js), and pretending
   the rows below did not move would be the lie, not the honesty. Faster
   than the panes: it is a sentence under a field, not a screen. */
.cdep-error:not([hidden]) { animation: dm-fade-in 0.14s var(--ease-out); }

/* Confirmation that the box received something, on the box itself. The
   ring fades out over 900ms from an already-visible default: no size, no
   position, nothing that moves the ten rows below it. */
.cdep-secret.just-filled input {
    animation: cdep-filled 900ms var(--ease-out);
}

@keyframes cdep-filled {
    from { box-shadow: 0 0 0 3px var(--accent-ring); }
    to   { box-shadow: 0 0 0 3px transparent; }
}

.cdep-carry {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    flex-wrap: wrap;
    margin-top: 18px;
    padding-top: 14px;
    border-top: 1px solid var(--border-color);
}

.cdep-carry .form-help { margin: 0; max-width: 46ch; }

.cdep-carry .btn {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    white-space: nowrap;
}

@media (prefers-reduced-motion: reduce) {
    .cdep-secret.just-filled input { animation: none; }
}

/* The carry-off step: what is saved, what is not, before the values go
   where this screen cannot show them again. A row states its status in
   words — `is-unsaved` only adds weight to text that already says
   "not copied". */
.cdep-carry-list {
    list-style: none;
    margin: 0;
    padding: 0;
    max-height: 46vh;
    overflow-y: auto;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
}

.cdep-carry-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 9px 14px;
    font-size: 0.85rem;
}

.cdep-carry-row + .cdep-carry-row { border-top: 1px solid var(--border-color); }

.cdep-carry-name {
    font-family: var(--font-mono, ui-monospace, monospace);
    color: var(--text-primary);
    word-break: break-all;
}

.cdep-carry-state {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    white-space: nowrap;
    color: var(--text-muted);
}

.cdep-carry-row.is-copied .cdep-carry-state { color: var(--success-text); }
.cdep-carry-row.is-unsaved .cdep-carry-state {
    color: var(--danger-text);
    font-weight: 600;
}

/* Step 2 — one directory per server. The row is a label, the host it is
   on, the path, and the sentence saying what is there today. */
/* One server, one card. It was a run of paragraphs separated by a
   hairline: on a four-host deploy the path fields, the state lines and
   the `.env` questions ran together into one column of text, and the
   operator had to read to work out where one machine ended. A card is
   the same picture the rest of this app draws an object with. */
.cdep-wheres {
    display: flex;
    flex-direction: column;
    gap: 12px;
}

.cdep-where {
    padding: 14px 16px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
    background: var(--surface-sunken);
}

.cdep-where-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 10px;
    margin-bottom: 8px;
}

.cdep-where-head label {
    font-weight: 600;
    color: var(--text-primary);
    font-size: 0.9rem;
}

.cdep-where-host {
    font-family: var(--font-mono, ui-monospace, monospace);
    font-size: 0.75rem;
    color: var(--text-muted);
}

/* What differs from the shared box: it holds a path the operator may
   paste into a terminal, so it is set in the mono face at the same size
   as the secret rows — machine text reads as machine text throughout
   this panel. Everything else (padding, border, radius, background,
   focus ring) comes from the shared `input` rule above.

   `[type="text"]`, not every input in the row: the .env question below
   puts radios in the same container, and an unscoped rule gave them
   `width: 100%` and the box padding — the radio took the whole line and
   the sentence beside it measured 0px. */
.cdep-where input[type="text"] {
    width: 100%;
    font-family: var(--font-mono, ui-monospace, monospace);
    font-size: 0.85rem;
}

.cdep-where-note {
    display: flex;
    align-items: flex-start;
    gap: 6px;
    margin: 6px 0 0;
}

.cdep-where-note svg { flex: none; margin-top: 2px; }

/* Why the offered path is not the documented one. It was muted, on the
   reasoning that the operator acts on the state line above — which had
   it backwards: the line above describes the SERVER, this one says what
   this deploy is about to do differently from every guide and runbook
   the operator has. Everything that comes later — `cd
   /usr/local/ft-system`, the install-steps checklist,
   `collect-diagnostics.sh`, what support tells the customer to run —
   assumes the standard directory and will not find this install.

   Warning, not danger: nothing is broken and the tool chose correctly.
   The sentence carries the meaning and colour only weights it — and it
   now carries the `alert` mark as well, which it could not when this was
   written (the set had no warning glyph then; the repair panel added one
   and the stage rail uses it too, so it is the app's vocabulary now, not
   a sixteenth for one line). */
.cdep-where-moved {
    display: flex;
    align-items: flex-start;
    gap: 6px;
    margin: 4px 0 0;
    color: var(--warning-text);
    font-weight: 500;
}

.cdep-where-moved svg { flex: none; margin-top: 2px; }

/* State is carried by the SENTENCE; colour only weights it. `foreign`
   and `other-server` are the two the operator has to act on. */
.cdep-where-note.is-foreign,
.cdep-where-note.is-other-server { color: var(--danger-text); }
.cdep-where-note.is-ours { color: var(--success-text); }

.cdep-where-error { margin: 6px 0 0; }

/* Migrations against a database the customer runs themselves. The list
   is the whole screen — it is what the operator carries to the other
   database — so the step id and the file path are the two things set in
   mono and everything else is subordinate to them. */
.cdep-manual-steps {
    list-style: none;
    margin: 12px 0 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 10px;
}

.cdep-manual-step {
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    padding: 10px 12px;
    background: var(--bg-primary);
}

.cdep-manual-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 12px;
}

.cdep-manual-svc { color: var(--text-muted); font-size: 0.9em; }
.cdep-manual-desc { margin: 6px 0 0; }
.cdep-manual-path { margin: 6px 0 0; }


/* What happens to an `.env` that is already there. A fieldset because
   it is one question with two answers, and the legend IS the question —
   an operator reading only the bold half of each option still gets the
   difference. */
.cdep-env-choice {
    margin: 10px 0 0;
    padding: 12px 14px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background: var(--bg-secondary);
    /* A `fieldset` defaults to `min-inline-size: min-content`, which is
       not a length any flex child can shrink below — so the sentences
       inside refused to wrap and ran out of the dialog. Both are needed:
       the UA rule is on the logical property. */
    min-width: 0;
    min-inline-size: 0;
}

/* The QUESTION, and it reads as one: it was 0.75rem muted, riding the
   fieldset's own border, so the smallest and faintest text in the dialog
   was the sentence the two radios under it answer. */
.cdep-env-choice > legend {
    padding: 0 4px;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-primary);
}

.cdep-env-choice label {
    display: flex;
    align-items: flex-start;
    gap: 8px;
    font-size: 0.85rem;
    line-height: 1.45;
}

/* The text half takes the space the radio does not, and is allowed to
   be narrower than its content. Both halves of the pair matter:
   `min-width: 0` alone let it shrink to exactly 0 (measured), because
   nothing was telling it to grow. */
.cdep-env-choice label > span {
    flex: 1 1 auto;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 2px;
}

/* The outcome, then what it costs. Two lines rather than one paragraph:
   the operator is choosing between two RESULTS, and a result buried mid-
   sentence is answered by whichever radio was already ticked. */
.cdep-env-choice label strong {
    font-weight: 600;
    color: var(--text-primary);
}

.cdep-env-choice label em {
    font-style: normal;
    color: var(--text-secondary);
}

.cdep-env-choice label + label { margin-top: 8px; }

/* The radio keeps its own size and does not stretch to the paragraph
   beside it. */
.cdep-env-choice input[type="radio"] {
    flex: none;
    margin-top: 3px;
}

.cdep-env-choice strong { font-weight: 600; }


/* What is missing on the SERVER, as opposed to what is wrong with the
   path. Always rendered when it applies — unlike the path error it is
   not something the operator can type their way out of, so it is not
   revealed by pressing Continue. */
.cdep-where-docker {
    margin: 6px 0 0;
    padding-left: 21px;   /* aligns with the state line's text */
}




/* NO `.form-field-secret input { cursor: not-allowed }` here. The class
   marks a SECRET, not a disabled control, and the wizard's form is only
   one of the three places it renders: the deploy panel and the SSH panel
   put a working password box under the same class, and the rule painted
   both as refusing input the operator was required to give. Where the
   input really is disabled — `form-renderer.js` disables secrets in the
   variables form, since nothing here ever stores one — the `:disabled`
   rule above already supplies this exact background and cursor. */

/* NO `::after { content: ' *' }` here. The renderer already appends a
   `<span class="required-mark">` inside the label, and the pair rendered
   every required field as "MYSQL_DATABASE * *". The span is the one that
   stays: it carries a title attribute, so the mark is not mute. */

.form-field-vip input {
    border-color: var(--accent-text);
}

/* Sentence case, not the uppercase eyebrow it used to be: the headings are
   now human titles with a line of prose under them ("Database schema · The
   database, user and schema this service owns"), and uppercase turns a
   sentence into shouting. */
.form-group-title {
    margin: 1rem 0 0.5rem;
    font-size: 0.875rem;
    font-weight: 600;
    letter-spacing: 0;
    color: var(--text-secondary);
}

/* Form groups rendered as <details>/<summary> (compose wizard step 3):
   collapsed by default, summary chromed as a sub-section header. */
details.form-group { margin: 0.5rem 0; }
details.form-group > summary.form-group-title {
    cursor: pointer;
    list-style: none;
    margin: 0;
    padding: 0.45rem 0.6rem;
    background: var(--surface-sunken);
    border-radius: var(--radius);
    user-select: none;
    display: flex;
    align-items: baseline;
    gap: 0.5rem;
}
details.form-group > summary.form-group-title:hover {
    background: var(--surface-interactive);
}
details.form-group > summary.form-group-title:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}
details.form-group > summary.form-group-title::-webkit-details-marker {
    display: none;
}
details.form-group > summary.form-group-title::before {
    content: "";
    width: 0.42rem;
    height: 0.42rem;
    border-right: 1.5px solid currentColor;
    border-bottom: 1.5px solid currentColor;
    color: var(--text-muted);
    transform: rotate(-45deg);
    transition: transform var(--transition);
    display: inline-block;
}
details.form-group[open] > summary.form-group-title::before {
    transform: rotate(45deg);
}
details.form-group .form-group-count {
    text-transform: none;
    letter-spacing: 0;
    font-weight: 400;
    color: var(--text-muted);
}
details.form-group > .form-grid { margin-top: 0.6rem; }

/* Step 2's Load balancers section — the same heading-then-grid shape as
   the rule above, and the same 0.6rem, so the first role label is not
   flush against the sentence explaining the section. `.form-help` is
   `margin: 0` by design (it sits inside a field's help band everywhere
   else), so without this the label touches the paragraph above it. */
#composeLoadBalancers > .form-grid { margin-top: 0.6rem; }

/* One line under a group heading saying what the group is FOR. A heading
   alone ("SAML", "schema") names a box; this says why the box exists. */
.form-group-help {
    margin: 0.35rem 0 0;
    padding: 0 0.6rem;
    font-size: 0.85rem;
    color: var(--text-muted);
}

/* Where the value in the box came from. Quiet by construction — it rides
   the label, so it reads as a note on the field rather than as a second
   control. */
.form-field-origin {
    margin-left: 0.5em;
    font-family: var(--font-sans);
    font-size: 0.75rem;
    font-weight: 400;
    letter-spacing: 0;
    text-transform: none;
    color: var(--text-muted);
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    padding: 0.05em 0.4em;
}

/* The step's own lead: what the work is, then what the marks mean. */
.step-lead { margin: 0 0 1rem; }
.step-lead-text {
    margin: 0 0 0.6rem;
    font-size: 0.95rem;
    color: var(--text-secondary);
}
.step-lead-legend {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: 0.35rem 1.4rem;
    font-size: 0.85rem;
    color: var(--text-muted);
}
.step-lead-legend li {
    display: flex;
    align-items: baseline;
    gap: 0.4em;
}
.lg-chip {
    font-size: 0.75rem;
    color: var(--text-muted);
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    padding: 0.05em 0.4em;
}
.lg-chip-secret { color: var(--warning-text); border-color: var(--warning-text); }

/* The per-service card header keeps identity, work state and disclosure
   action stable in both states. The native summary is the only target. */
.cmp-card-primary { flex: 1 1 20rem; min-width: 0; }
.cmp-card-head { display: inline-flex; align-items: baseline; gap: 0.4rem; flex-wrap: wrap; }
/* An <h3> since the card became a real section for screen-reader
   navigation. Everything here is what <strong> used to give it, so the
   change is semantic only. */
.cmp-card-name {
    display: inline;
    margin: 0;
    font-family: var(--font-mono);
    font-size: inherit;
    font-weight: 700;
    color: inherit;
}
.cmp-card-role,
.cmp-card-count {
    display: block;
    font-weight: 400;
    text-transform: none;
    letter-spacing: 0;
}
.cmp-card-role {
    margin-top: 0.15rem;
    font-size: 0.85rem;
    color: var(--text-secondary);
}
.cmp-card-count {
    margin-top: 0.15rem;
    font-size: 0.75rem;
    color: var(--text-muted);
}
.cmp-card-meta {
    flex: 0 1 18rem;
    min-width: 10rem;
}
.cmp-card-status {
    display: inline-flex;
    align-items: center;
    width: max-content;
    padding: 0.12rem 0.5rem;
    border: 1px solid var(--border-color);
    border-radius: 999px;
    font-size: 0.75rem;
    font-weight: 600;
    line-height: 1.4;
}
.cmp-card-status-ready {
    color: var(--success-text);
    background: var(--success-bg);
}
.cmp-card-status-needs-input {
    color: var(--warning-text);
    background: var(--warning-bg);
}
.cmp-card-status-changed {
    color: var(--accent-text);
    background: var(--accent-tint);
}
.cmp-card-status-loading {
    color: var(--text-muted);
    background: var(--surface-sunken);
}
.cmp-card-disclosure {
    flex: 0 0 auto;
    margin-left: auto;
    display: inline-flex;
    align-items: center;
    gap: 0.55rem;
    color: var(--accent-text);
    font-size: 0.85rem;
    font-weight: 600;
}
.cmp-card-chevron {
    display: inline-flex;
    flex: 0 0 auto;
    width: 0.5rem;
    height: 0.5rem;
    border-right: 1.5px solid currentColor;
    border-bottom: 1.5px solid currentColor;
    transform: rotate(-45deg);
    transform-origin: center;
}
details.compose-service-form[open] .cmp-card-chevron { transform: rotate(45deg); }
details.compose-service-form > summary[data-disclosure-input="pointer"] .cmp-card-chevron {
    transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
}

/* Per-service <details> block on step 3 */
details.compose-service-form {
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    padding: 0.6rem 0.85rem;
    margin-bottom: 0.6rem;
    background: var(--bg-secondary);
}
details.compose-service-form > summary {
    cursor: pointer;
    list-style: none;
    display: flex;
    align-items: center;
    gap: 0.7rem 1rem;
    flex-wrap: wrap;
    min-height: 44px;
    margin: -0.6rem -0.85rem;
    padding: 0.65rem 0.85rem;
    border-radius: var(--radius);
    user-select: none;
}
details.compose-service-form > summary::-webkit-details-marker { display: none; }
details.compose-service-form > summary:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
    border-radius: var(--radius);
}
details.compose-service-form > summary:active {
    background: var(--surface-interactive-hover);
}
details.compose-service-form[open] {
    border-color: var(--accent-text);
}
details.compose-service-form[open] > summary {
    margin-bottom: 0;
    background: var(--accent-tint);
}
@media (hover: hover) and (pointer: fine) {
    details.compose-service-form > summary:hover {
        background: var(--surface-interactive);
    }
    details.compose-service-form[open] > summary:hover {
        background: var(--accent-tint-strong);
    }
}

/* "EXTERNAL (BYO)" marker on a service the customer brings themselves. */
.badge-external {
    background: var(--warning-bg);
    color: var(--warning-text);
}

.settings-section {
    margin-bottom: 32px;
    padding-bottom: 24px;
    border-bottom: 1px solid var(--border-color);
}

.settings-section:last-child {
    border-bottom: none;
}

.settings-section h3 {
    font-family: var(--font-display);
    font-weight: 600;
    margin-bottom: 16px;
}

.form-actions,
.manager-actions {
    margin-top: 24px;
    display: flex;
    gap: 12px;
}

.manager-actions {
    justify-content: flex-end;
    padding-top: 16px;
    border-top: 1px solid var(--border-color);
}

/* ============================================================
   12. Buttons
   ============================================================ */
.btn {
    /* Anchors styled as buttons are an established pattern here (Build
       topology, Cancel); they should not carry a link underline. */
    text-decoration: none;
    /* Explicit, because the element type and the glyph would otherwise
       decide the height: <button> resolves line-height `normal`, <a>
       inherits the body's 1.6, and a tall glyph grows `normal` again. One
       row was rendering 38 / 39 / 43.3px from identical padding. */
    line-height: 1.2;
    padding: 10px 20px;
    border: 1px solid transparent;
    border-radius: var(--radius);
    font-family: inherit;
    font-size: 0.95rem;
    font-weight: 500;
    cursor: pointer;
    transition: background var(--transition), color var(--transition),
                border-color var(--transition), box-shadow var(--transition),
                transform var(--press);
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
}

.btn:disabled {
    opacity: 0.5;
    cursor: not-allowed;
}

/* Press feedback (emil): the button physically yields to a click. */
.btn:active:not(:disabled) {
    transform: scale(0.97);
}

.btn:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

/* Async in flight — the control states it is busy instead of going quiet. */
.btn.is-loading::before,
.btn[data-loading="true"]::before {
    content: '';
    width: 12px;
    height: 12px;
    border: 2px solid currentColor;
    border-top-color: transparent;
    border-radius: 50%;
    animation: spin 0.6s linear infinite;
}

.btn-primary {
    background: var(--accent-primary);
    color: #fff;
    box-shadow: 0 1px 2px hsl(208 55% 26% / 0.30);
}

.btn-primary:hover:not(:disabled) {
    background: var(--accent-primary-hover);
    transform: translateY(-1px);
    box-shadow: var(--shadow-md);
}

.btn-secondary {
    background: var(--surface-interactive);
    color: var(--text-primary);
}

.btn-secondary:hover:not(:disabled) {
    background: var(--surface-interactive-hover);
}

/* Ghost — outlined secondary; a visible border at rest. */
.btn-ghost {
    background: transparent;
    color: var(--text-secondary);
    border-color: var(--text-muted);
}

.btn-ghost:hover:not(:disabled) {
    background: var(--surface-sunken);
    color: var(--text-primary);
    border-color: var(--text-secondary);
}

/* Ghost-danger — the AT-REST destructive affordance. Danger is visible
   at rest (red text + red border) but stays outlined, so it never
   out-shouts the in-flight primary while still reading unmistakably as
   destructive. The solid fill below is reserved for the final confirm.

   Text, border AND focus ring all use --danger-text, not --danger: the
   outlined treatment puts every one of them in a foreground slot on the
   surface behind the button, where --danger measures 2.79:1 on the dark
   card — under the 4.5:1 text bar and under WCAG 1.4.11's 3:1 bar for a
   control boundary and a focus indicator alike. --danger-text measures
   6.11:1 there and 7.43:1 on white. --danger stays where it belongs:
   the solid .btn-danger fill below. */
.btn-ghost-danger {
    background: transparent;
    color: var(--danger-text);
    border-color: var(--danger-text);
}

.btn-ghost-danger:hover:not(:disabled) {
    background: var(--danger-bg);
    color: var(--danger-text);
    border-color: var(--danger-text);
}

.btn-ghost-danger:focus-visible {
    outline: 2px solid var(--danger-text);
    outline-offset: 2px;
}

/* Solid danger — reserved for the final confirm inside a dialog. */
.btn-danger {
    background: var(--danger);
    color: #fff;
}

.btn-danger:hover:not(:disabled) {
    background: var(--danger-hover);
}

.btn-warning {
    background: var(--warning);
    color: #333;
}

.btn-warning:hover:not(:disabled) {
    opacity: 0.9;
}

.btn-success {
    background: var(--success-solid);
    color: #fff;
}

.btn-success:hover:not(:disabled) {
    filter: brightness(1.12);
}

.btn-info {
    background: var(--accent-primary);
    color: #fff;
}

.btn-info:hover:not(:disabled) {
    background: var(--accent-primary-hover);
}

.btn-small,
.btn-sm {
    padding: 6px 12px;
    font-size: 0.85rem;
}

.btn-large {
    padding: 14px 32px;
    font-size: 0.95rem;
}

.btn-row,
.btn-group {
    display: flex;
    gap: 8px;
    flex-wrap: wrap;
    align-items: center;
}

.btn-group .btn.active {
    background: var(--accent-tint);
    color: var(--accent-text);
    font-weight: 600;
}

/* ============================================================
   13. Segmented view toggles — same One Voice rule as .tab.active:
   tint + accent text + inset ring, never a solid fill.
   ============================================================ */
/* Named for the Environments board it was built for; that board is gone
   and the Topologies tab's Graphical/List switch uses it now. Renaming it
   is a separate change — the class is live, which is the only thing this
   comment has to make obvious. */
.compose-view-toggle,
.source-toggle-inline {
    display: inline-flex;
    align-items: stretch;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    overflow: hidden;
    background: var(--surface-sunken);
}
.compose-view-toggle button,
.source-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    gap: 0.35em;
    background: transparent;
    color: var(--text-secondary);
    border: none;
    line-height: 1.2;
    padding: 7px 14px;
    font-family: inherit;
    font-size: 0.85rem;
    font-weight: 500;
    cursor: pointer;
    transition: background var(--transition), color var(--transition), box-shadow var(--transition);
}

.compose-view-toggle button:not(:last-child),
.source-btn:not(:last-child) {
    border-right: 1px solid var(--border-color);
}

.compose-view-toggle button:hover:not(.active),
.source-btn:hover:not(.active) {
    background: var(--surface-interactive);
    color: var(--text-primary);
}

.compose-view-toggle button:focus-visible,
.source-btn:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
}

/* Fill plus weight carries the selection; the inset ring on top of the
   group's own border read as a doubled outline. */
.compose-view-toggle button.active,
.source-btn.active {
    background: var(--accent-tint);
    color: var(--accent-text);
    font-weight: 600;
}

.source-toggle {
    display: flex;
    gap: 0;
    margin-bottom: 12px;
}

.source-toggle button {
    flex: 1;
    padding: 7px 12px;
    border: 1px solid var(--border-color);
    background: var(--bg-secondary);
    cursor: pointer;
    font-family: inherit;
    font-size: 0.85rem;
    font-weight: 500;
    color: var(--text-secondary);
    transition: background var(--transition), color var(--transition), box-shadow var(--transition);
}

.source-toggle button:first-child {
    border-radius: var(--radius) 0 0 var(--radius);
}

.source-toggle button:last-child {
    border-radius: 0 var(--radius) var(--radius) 0;
}

.source-toggle button:hover:not(.active) {
    background: var(--surface-sunken);
    color: var(--text-primary);
}

.source-toggle button:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
}

.source-toggle button.active {
    background: var(--accent-tint);
    color: var(--accent-text);
    font-weight: 600;
}

.service-source-content {
    display: none;
}

.service-source-content.active {
    display: block;
}

/* ============================================================
   14. Services grid / cards
   ============================================================ */
.services-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
    gap: 16px;
}

.service-group {
    margin-bottom: 24px;
}

.service-group-title {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--accent-text);
    margin: 0 0 12px 0;
    padding-bottom: 8px;
    border-bottom: 2px solid var(--accent-text);
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

.service-group-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
    gap: 12px;
}

.group-header-row {
    background: var(--surface-sunken);
}

.group-header {
    font-weight: 600;
    color: var(--accent-text);
    padding: 10px 12px !important;
    font-size: 0.875rem;
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

.service-card {
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    padding: 16px;
    transition: border-color var(--transition), background var(--transition), box-shadow var(--transition);
}

.service-card:hover {
    border-color: var(--border-strong);
}

.service-card.selected {
    border-color: var(--accent-text);
    background: var(--accent-tint);
}

.service-card-header {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 12px;
}

.service-card-header input[type="checkbox"] {
    width: 20px;
    height: 20px;
}

.service-card-header label {
    font-weight: 600;
    cursor: pointer;
    flex: 1;
}

.service-card-body {
    display: none;
}

.service-card.selected .service-card-body {
    display: block;
}

/* ============================================================
   15. Searchable dropdown
   ============================================================ */
.searchable-dropdown {
    position: relative;
    width: 100%;
}

.searchable-dropdown-input {
    width: 100%;
    padding: 8px 12px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background: var(--bg-secondary);
    color: var(--text-primary);
    font-family: inherit;
    font-size: 0.9rem;
    transition: border-color var(--transition), box-shadow var(--transition);
}

.searchable-dropdown-input:focus,
.searchable-dropdown-input:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}

.searchable-dropdown-list {
    display: none;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    max-height: 200px;
    overflow-y: auto;
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    border-top: none;
    border-radius: 0 0 var(--radius) var(--radius);
    z-index: 100;
    box-shadow: var(--shadow-hover);
}

.searchable-dropdown-list.open {
    display: block;
}

.searchable-dropdown-item {
    padding: 8px 12px;
    cursor: pointer;
    font-size: 0.85rem;
    color: var(--text-primary);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.searchable-dropdown-item:hover {
    background: var(--surface-interactive);
}

.searchable-dropdown-item.selected {
    background: var(--accent-tint);
    color: var(--accent-text);
    font-weight: 600;
}

.searchable-dropdown-item.loading {
    color: var(--text-muted);
    cursor: default;
    font-style: italic;
}

.searchable-dropdown-item.loading:hover {
    background: none;
}

.tag-select {
    width: 100%;
    padding: 6px 10px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background: var(--bg-input);
    color: var(--text-primary);
    font-family: inherit;
    font-size: 0.85rem;
}

.tag-count {
    font-size: 0.75rem;
    color: var(--text-muted);
    margin-top: 4px;
}

.tag-warning {
    color: var(--warning-text);
    font-style: italic;
}

/* ============================================================
   16. Build status panel
   ============================================================ */
.build-status-panel {
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    padding: 16px;
    margin-top: 20px;
}

.build-status-panel h3 {
    margin-bottom: 12px;
}

.build-status-item {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 8px 0;
    border-bottom: 1px solid var(--border-color);
}

.build-status-item:last-child {
    border-bottom: none;
}

.build-status-item .service-name {
    font-weight: 500;
    min-width: 120px;
}

.build-status-item .status {
    flex: 1;
}

.build-status-item .spinner {
    width: 16px;
    height: 16px;
}

/* ============================================================
   17. Modals
   ============================================================ */
.modal-overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.5);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;
    opacity: 1;
    transition: opacity 200ms var(--ease-out);
}

.modal-overlay.hidden {
    opacity: 0;
    pointer-events: none;
}

.modal {
    display: flex;
    flex-direction: column;
    background: var(--bg-secondary);
    border-radius: var(--radius-lg);
    width: 90%;
    max-width: 500px;
    max-height: 90vh;
    overflow: hidden;
    box-shadow: var(--shadow-hover);
    transform: scale(1);
    transition: transform 200ms var(--ease-out);
}

.modal-large {
    max-width: 900px;
}

/* Live Test modal carries an xterm.js terminal — narrow widths wrap
   deploy.sh output (which is meant to render in a normal terminal) and
   make logs unreadable. Bump max-width so the embedded terminal has
   ~120 cols of room. */
.modal-xl {
    max-width: 1280px;
}

.modal-overlay.hidden .modal {
    transform: scale(0.96);
}

.modal-header,
.modal-footer {
    flex: none;
}

.modal-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    gap: 12px;
    padding: 16px 20px;
    border-bottom: 1px solid var(--border-color);
}

.modal-header h3 {
    margin: 0;
    font-family: var(--font-display);
    font-size: 1.15rem;
    font-weight: 600;
}

#logs-modal .modal-header h3 {
    flex: 1;
    text-align: center;
}

.modal-close {
    background: none;
    border: none;
    font-size: 1.5rem;
    color: var(--text-muted);
    cursor: pointer;
    padding: 4px;
    line-height: 1;
}

.modal-close:hover {
    color: var(--text-primary);
}

.modal-close:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
    border-radius: 4px;
}

.modal-body {
    padding: 20px;
    flex: 1 1 auto;
    min-height: 0;
    overflow-y: auto;
}

.modal-footer {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    align-items: center;
    gap: 12px;
    padding: 16px 20px;
    border-top: 1px solid var(--border-color);
}

.modal-footer .btn { white-space: nowrap; }

/* The offline-bundle dialog renders its actions into a slot div inside
   .modal-footer. The footer's own `gap` therefore applies to the SLOT,
   not to the buttons in it — two of them rendered flush against each
   other. The slot has to be the flex row. */
#composeImageBundleFoot {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: flex-end;
    gap: 12px;
    width: 100%;
}

/* Bundle-flavour dialog: three actions that must read as one row, with
   the download last. The wrapper widens the dialog itself, because at
   the 500px default the row wrapped and split the choice in two. */
.bundle-flavour-actions {
    display: flex;
    flex-wrap: nowrap;
    align-items: center;
    justify-content: flex-end;
    gap: 12px;
    width: 100%;
}

/* Keyed on the CARDS, not on one dialog's footer: any dialog offering
   this choice needs the room, and two 240px columns do not fit the 500px
   default. */
.modal:has(.choice-cards) {
    max-width: 660px;
}

/* Delivery choice: three actions on one row, the rehearsal last and
   filled. Same shape and the same reason as the bundle-flavour row —
   three buttons do not fit the 500px default, and a wrapped one reads
   as a second question rather than a third answer. */
.cdlv-actions {
    display: flex;
    flex-wrap: nowrap;
    align-items: center;
    justify-content: flex-end;
    gap: 12px;
    width: 100%;
}

.modal:has(.cdlv-actions) {
    max-width: 660px;
}

@media (max-width: 700px) {
    .cdlv-actions { flex-wrap: wrap; }
    .cdlv-actions .btn { flex: 1; }
}

@media (max-width: 700px) {
    .bundle-flavour-actions { flex-wrap: wrap; }
    .bundle-flavour-actions .btn { flex: 1; }
}

/* Environment modal (secondary shell) */
.env-modal-overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.6);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 1000;
    opacity: 1;
    transition: opacity 200ms var(--ease-out);
}

.env-modal-overlay.hidden {
    opacity: 0;
    pointer-events: none;
}

.env-modal {
    background: var(--bg-secondary);
    border-radius: var(--radius-lg);
    width: 95%;
    max-width: 900px;
    max-height: 90vh;
    display: flex;
    flex-direction: column;
    box-shadow: var(--shadow-hover);
    transform: scale(1);
    transition: transform 200ms var(--ease-out);
}

.env-modal-overlay.hidden .env-modal {
    transform: scale(0.96);
}

.env-modal-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 16px 24px;
    border-bottom: 1px solid var(--border-color);
}

.env-modal-header h3 {
    margin: 0;
    font-family: var(--font-display);
    font-size: 1.15rem;
    color: var(--text-primary);
}

.env-modal-tabs {
    display: flex;
    gap: 0;
    background: var(--surface-sunken);
    border-bottom: 1px solid var(--border-color);
}

.env-modal-tab {
    flex: 1;
    padding: 12px 20px;
    background: transparent;
    border: none;
    border-bottom: 2px solid transparent;
    cursor: pointer;
    font-family: inherit;
    font-size: 0.9rem;
    font-weight: 500;
    color: var(--text-secondary);
    transition: background var(--transition), color var(--transition), border-color var(--transition);
}

.env-modal-tab:hover {
    background: var(--bg-secondary);
    color: var(--text-primary);
}

.env-modal-tab:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
}

.env-modal-tab.active {
    background: var(--accent-tint);
    color: var(--accent-text);
    font-weight: 600;
    border-bottom-color: var(--accent-text);
}

.env-modal-content {
    flex: 1;
    overflow-y: auto;
    padding: 0;
    max-height: calc(90vh - 200px);
}

.env-tab-content {
    display: none;
    padding: 20px 24px;
}

.env-tab-content.active {
    display: block;
}

.env-modal-footer {
    display: flex;
    justify-content: flex-end;
    gap: 12px;
    padding: 16px 24px;
    border-top: 1px solid var(--border-color);
    background: var(--surface-sunken);
}

.env-tab-content .loading {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 40px;
    color: var(--text-muted);
}

.env-tab-content .loading .spinner {
    margin-bottom: 12px;
}

/* ============================================================
   18. Logs modal
   ============================================================ */
.logs-controls {
    display: flex;
    gap: 8px;
    margin-bottom: 16px;
    flex-wrap: wrap;
    align-items: center;
}

.logs-controls select,
.logs-search-row input {
    padding: 8px 12px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background: var(--bg-input);
    color: var(--text-primary);
    font-family: inherit;
}

.logs-search-row {
    display: flex;
    gap: 8px;
    margin-bottom: 12px;
    align-items: center;
}

.logs-search-row input {
    flex: 1;
    font-size: 0.9rem;
}

.logs-search-row input:focus,
.logs-search-row input:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}

#logsSearchCount {
    font-size: 0.85rem;
    color: var(--text-secondary);
    min-width: 80px;
}

/* Console surfaces stay dark in BOTH themes — a terminal transcript is
   read as a terminal. No transition/box-shadow here: this element
   repaints on every streamed chunk. */
.logs-container,
.topology-json {
    background: #16181d;
    color: #d8dee9;
    font-family: var(--font-mono);
    font-size: 0.85rem;
    padding: 16px;
    border-radius: var(--radius);
    max-height: 500px;
    overflow: auto;
    white-space: pre-wrap;
    word-break: break-all;
}

.topology-json {
    max-height: 60vh;
    white-space: pre;
    margin: 0;
}

/* Search hits in the log viewer. Themed rather than hardcoded amber so
   the pair survives dark mode: resting hit is the warning TINT (13.05:1
   light / 9.35:1 dark against --text-primary), the current hit is the
   warning FILL, which is amber in both themes and therefore keeps a fixed
   ink — the documented `ink` colour — at 5.17:1 light / 6.11:1 dark. */
.logs-container mark.log-highlight {
    background: var(--warning-bg);
    color: var(--text-primary);
    padding: 1px 2px;
    border-radius: 4px;
}

.logs-container mark.log-highlight.current {
    background: var(--warning);
    color: hsl(0 0% 16.9%);
}

/* ============================================================
   19. Toasts
   ============================================================ */
.toast-container {
    position: fixed;
    bottom: 20px;
    right: 20px;
    z-index: 10001;
    display: flex;
    flex-direction: column;
    gap: 10px;
}

.toast {
    padding: 14px 20px;
    border-radius: var(--radius);
    background: var(--accent-primary);
    color: #fff;
    font-size: 0.95rem;
    box-shadow: var(--shadow-hover);
    animation: slideIn 0.24s var(--ease-out);
}

.toast.success, .toast-success { background: var(--success); }
.toast.error,   .toast-error   { background: var(--danger); }
.toast.warning, .toast-warning { background: var(--warning); color: #333; }
.toast.info,    .toast-info    { background: var(--accent-primary); }

@keyframes slideIn {
    from { transform: translateX(100%); opacity: 0; }
    to   { transform: translateX(0); opacity: 1; }
}

/* ============================================================
   20. Login / loading overlays + spinner
   ============================================================ */
.login-overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: var(--bg-primary);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 2000;
}

.login-overlay.hidden {
    display: none;
}

.login-card {
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    padding: 48px;
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-hover);
    text-align: center;
    max-width: 420px;
    width: 90%;
}

.login-card h1 {
    color: var(--text-primary);
    font-family: var(--font-display);
    font-size: 1.5rem;
    font-weight: 600;
    letter-spacing: -0.01em;
    margin-bottom: 8px;
}

.login-subtitle {
    color: var(--text-secondary);
    margin-bottom: 32px;
}

.login-card .form-group {
    text-align: left;
    margin-bottom: 24px;
}

.login-card .btn-large {
    width: 100%;
}

.login-hint {
    margin-top: 16px;
    font-size: 0.85rem;
    color: var(--text-muted);
}

/* The code is READ OFF A PHONE and typed: wide tracking so 0/O and 1/l
   are not a guess, and centred because six characters in a full-width
   field read as a lost cursor. Same field chrome as every other input —
   only the type treatment differs. */
.login-code-input {
    font-family: var(--font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
    font-size: 1.5rem;
    letter-spacing: 0.35em;
    text-align: center;
    text-indent: 0.35em;   /* re-centres: tracking pads the last glyph only */
}

.login-card .login-hint .link-button {
    font-size: inherit;
}

.login-message {
    margin-top: 16px;
    padding: 12px;
    border-radius: var(--radius);
    font-size: 0.9rem;
    display: flex;
    align-items: baseline;
    gap: 8px;
    text-align: left;
}

.login-message.success {
    background: var(--success-bg);
    border: 1px solid hsl(var(--status-success-border));
    color: var(--success-text);
}

.login-message.success::before { content: '✓'; }

.login-message.error {
    background: var(--danger-bg);
    border: 1px solid hsl(var(--status-error-border));
    color: var(--danger-text);
}

.login-message.error::before { content: '✕'; }

/* The claim only a person can make, and the control that makes it. */
.env-card-installed {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
    margin-bottom: 10px;
}

.env-card-installed-text {
    color: var(--text-secondary);
    font-size: 0.85rem;
}

/* The offline bundle's state on the installation card. Same shape as
   the SSH line beside it — badge, sentence, optional way forward —
   because both answer "can I act on this install right now". */
.env-card-bundle {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
    margin-bottom: 10px;
}

.env-card-bundle-text {
    color: var(--text-secondary);
    font-size: 0.85rem;
    overflow-wrap: anywhere;
}

/* A button, like every other way out of this card. It carries .btn
   .btn-small .btn-secondary; what is left here is only its place in the
   line. */
.env-card-bundle-link {
    margin-left: auto;
    flex-shrink: 0;
}

/* Offered and refused, not missing. Same size and shape so the line
   does not reflow when the same card is read by somebody who may
   download and somebody who may not; muted colour and the cursor say
   which — never `disabled`, which would drop it out of the tab order
   along with the reason `aria-describedby` points at. */
.env-card-bundle-link.is-disabled {
    color: var(--text-muted);
    background: var(--surface-sunken);
    border-color: var(--text-muted);
    cursor: not-allowed;
}

/* Edited here, not yet on the customer's servers. Warning colour, not
   danger: nothing is broken — the two copies simply disagree, and the
   line says which one is on screen. `--warning-text` rather than
   `--warning`, which is tuned to carry white and fails as text
   (see the token block near the top of this file). */
.env-card-drift .env-card-bundle-text {
    color: var(--warning-text);
}

.env-card-drift-icon {
    display: inline-flex;
    color: var(--warning-text);
}

/* The topology moved on. Warning, not danger, and the same token pair
   the drift line above uses — nothing is broken, the installation is
   simply not up to date with the shape it was built from, which is
   exactly what the stage rail's own `stale` step means. */
.env-card-behind .env-card-bundle-text {
    color: var(--warning-text);
}

.env-card-behind-icon {
    display: inline-flex;
    color: var(--warning-text);
}

/* `unknown` is NOT a quieter warning — it is a different fact, and it
   blocks nothing, so it must not wear the tone that says an action is
   refused. It keeps `.env-card-bundle-text`'s own --text-secondary and
   carries no glyph; the override exists to say that out loud, because
   the obvious "improvement" is to give it the warning colour too. */
.env-card-behind--unknown .env-card-bundle-text {
    color: var(--text-secondary);
}

/* ── The `.env` on a customer's server ─────────────────────────────
   Rows, not a text area: the quoting rule can be enforced on a field
   and cannot be enforced on a paste. Spacing from the same scale the
   wizard's own fields use, so a panel that edits the same variables
   does not read as a different product.

   Two panes, and the width to hold them. An installation's `.env` is
   several files — the host file plus one per service — and the panel
   used to stack them all in one 500px column, so "what does
   ft-configs-service actually read?" meant scrolling past ~90 keys of
   the host file first. The rail answers that in one press.

   `:has()` rather than a class added by JS, for the reason the deploy
   panel gives: `closeModal` does not take a width class off, so one
   added here would arrive on the next dialog. */
.modal:has(.csenv-panel) { max-width: 1100px; }

.csenv-panel {
    display: grid;
    grid-template-columns: minmax(180px, 232px) 1fr;
    gap: 16px;
    align-items: start;
    margin-top: 16px;
}

/* Sticky against the modal body — the one scroller here — so the file
   you are in stays named while its keys run past. */
.csenv-rail {
    position: sticky;
    top: 0;
    display: flex;
    flex-direction: column;
    gap: 2px;
}

.csenv-rail-title {
    margin: 0 0 4px;
    font-size: 0.75rem;
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-muted);
}

.csenv-rail-item {
    display: flex;
    align-items: center;
    gap: 6px;
    width: 100%;
    padding: 6px 8px;
    border: 0;
    border-radius: var(--radius);
    background: none;
    color: var(--text-secondary);
    font: inherit;
    font-size: 0.85rem;
    text-align: left;
    cursor: pointer;
}

.csenv-rail-item:hover { background: var(--surface-sunken); }

.csenv-rail-item:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
}

/* Tint plus accent text for the current one — never the solid fill.
   The panel's one primary is `Save to the server` (DESIGN.md, the One
   Voice Rule). */
.csenv-rail-item.is-current {
    background: var(--accent-tint);
    color: var(--accent-text);
    font-weight: 600;
}

/* A file with no match for what is being searched. Quieter, not hidden:
   "not in here either" is an answer, and a rail that reflows under the
   operator loses the map they were reading.

   A muted TOKEN, never `opacity`. These are live controls — pressing
   one is how you leave a scope that has nothing in it — so the WCAG
   inactive-component exemption does not apply, and this panel's own
   neighbours have already paid for that lesson once: `.is-blocked` at
   `opacity: 0.55` measured 2.14:1 and dimmed its focus ring with it. */
.csenv-rail-item.is-empty { color: var(--text-muted); }

.csenv-rail-name {
    flex: 1 1 auto;
    min-width: 0;
    overflow-wrap: anywhere;
}

.csenv-rail-count {
    font-variant-numeric: tabular-nums;
    color: var(--text-muted);
}

.csenv-rail-item.is-current .csenv-rail-count { color: inherit; }

/* The two counts worth interrupting for: how many disagree with this
   tool's record, and how many are typed and not yet written. Both carry
   a title, and both are numbers rather than dots — "some" is not
   actionable. */
.csenv-rail-diff,
.csenv-rail-edit {
    min-width: 18px;
    padding: 0 5px;
    border-radius: 999px;
    font-size: 0.75rem;
    font-weight: 600;
    text-align: center;
    font-variant-numeric: tabular-nums;
}

.csenv-rail-diff {
    background: var(--warning-bg);
    color: var(--warning-text);
}

.csenv-rail-edit {
    background: var(--accent-tint);
    color: var(--accent-text);
}

.csenv-pane { min-width: 0; }

.csenv-pane-head {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 8px;
    padding-bottom: 8px;
    border-bottom: 1px solid var(--border-color);
}

.csenv-pane-head h4 { margin: 0; font-size: 0.95rem; }

.csenv-pane-count {
    margin-left: auto;
    font-size: 0.85rem;
    color: var(--text-muted);
    font-variant-numeric: tabular-nums;
}

/* What becomes of each kind after Save, said once. It was printed under
   EVERY row — five identical copies of "Written on the server only…"
   in a row on a real screen, and ~90 copies of one of three sentences
   on a real server. The chip on the row is what carries it after this. */
.csenv-legend {
    display: grid;
    grid-template-columns: max-content 1fr;
    gap: 4px 8px;
    margin: 12px 0 0;
    padding: 8px 12px;
    border-radius: var(--radius);
    background: var(--surface-sunken);
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.csenv-legend dt { margin: 0; }
.csenv-legend dd { margin: 0; }

.csenv-rows {
    display: flex;
    flex-direction: column;
    gap: 14px;
    margin-top: 16px;
}

/* Name beside the box rather than above it: with ~90 keys the stacked
   form ran three rows to the screenful. The note, when a row has one,
   spans both columns under them. */
.csenv-row {
    display: grid;
    /* Wide enough for the names an operator actually meets; the two or
       three longest wrap their chip to a second line, which costs one
       row of height and never the identifier itself. Fixed rather than
       `max-content` because each row is its own grid — a content-sized
       column would align with nothing above or below it. */
    grid-template-columns: minmax(0, 38ch) 1fr;
    gap: 4px 16px;
    align-items: center;
    padding: 6px 0;
    border-bottom: 1px solid var(--border-color);
}

.csenv-row:last-child { border-bottom: none; }

.csenv-row label {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
    margin-bottom: 0;
    overflow-wrap: anywhere;
}

/* Everything else about this box — padding, border, radius, fill —
   comes from the shared control rule it joins above. */
.csenv-row input { width: 100%; }

/* What the variable is for, and — when the two disagree — what this
   tool holds for it. Both sit in the VALUE column, under the box they
   are about: proximity is what says which control a sentence belongs
   to, and spanning the row put them under the name instead. They span
   at the one breakpoint, where the grid is a single column anyway. */
.csenv-desc,
.csenv-note {
    grid-column: 2;
    margin: 0;
}

.csenv-desc { color: var(--text-secondary); }

/* Why a value was not written, beside the box that holds it. The border
   the field takes from `[aria-invalid]` is the second signal, never the
   only one. */
.csenv-invalid {
    grid-column: 2;
    margin: 0;
    color: var(--danger-text);
}
.csenv-invalid::before { content: '✕ '; }

.csenv-svc {
    color: var(--text-muted);
    font-size: 0.75rem;
}

/* What becomes of this key after Save, in one word. Not a colour-only
   distinction — the word IS the information, and the tint only groups
   them. */
.csenv-kind {
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    padding: 1px 6px;
    border-radius: var(--radius);
    background: var(--surface-sunken);
    color: var(--text-secondary);
    white-space: nowrap;
}

.csenv-kind-secret {
    background: var(--warning-bg);
    color: var(--warning-text);
}

/* The disagreement between the server and this tool's record. A left
   rule rather than a fill: the row is still an ordinary editable field,
   and a filled row would read as an error the operator caused. */
.csenv-row.is-different {
    border-left: 3px solid var(--warning-text);
    padding-left: 10px;
    margin-left: -13px;
}

/* Seen before read — the count of disagreements is the finding, and it
   is why the panel was opened. */
.csenv-lead-diff,
.csenv-warn {
    background: var(--warning-bg);
    color: var(--warning-text);
    border-radius: var(--radius);
    padding: 10px 12px;
    margin-bottom: 12px;
}

.csenv-warn {
    margin-top: 12px;
    margin-bottom: 0;
}

/* An inline action inside help text, so it reads as part of the
   sentence that offers it rather than as a second control competing
   with the field above. */
.csenv-use {
    background: none;
    border: 0;
    padding: 0;
    font: inherit;
    color: var(--accent-text);
    text-decoration: underline;
    cursor: pointer;
}

/* One section per file, headed only in the `All files` scope — inside
   one file's own scope the pane header already names it. */
.csenv-file {
    margin-top: 16px;
}

.csenv-file h4 {
    margin: 0 0 4px;
}

.csenv-path {
    color: var(--text-muted);
    font-family: var(--font-mono, monospace);
    font-size: 0.75rem;
    font-weight: 400;
    overflow-wrap: anywhere;
}

/* The navigation. An operator knows the variable's name and not which
   service declares it, so this searches every file whatever the rail is
   showing — which is why it sits above both panes rather than inside
   one of them. */
.csenv-filter {
    width: 100%;
    margin-top: 12px;
}

.csenv-nomatch {
    margin-top: 16px;
}

.csenv-servers {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    margin-bottom: 12px;
}

.csenv-server {
    padding: 4px 10px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background: none;
    color: var(--text-secondary);
    font: inherit;
    font-size: 0.75rem;
    cursor: pointer;
}

.csenv-server.is-current {
    border-color: var(--accent-text);
    color: var(--accent-text);
}

/* The banner is a control, not only a statement: the row it names is
   below the fold on any real server. */
.csenv-toggle-diffs {
    background: none;
    border: 0;
    padding: 0;
    font: inherit;
    color: inherit;
    text-decoration: underline;
    cursor: pointer;
    white-space: nowrap;
}

.csenv-error {
    background: var(--danger-bg);
    border-radius: var(--radius);
    padding: 12px;
}

/* ── The kinds, as chips ────────────────────────────────────────────
   The WORD is the information — `yours` / `computed` / `undeclared` /
   `secret` — and the tint only groups them, so the neutral chip is the
   default and nothing depends on telling two tints apart. Only two
   kinds are tinted at all: the one an operator owns, and the one whose
   value never comes back. */
.csenv-kind-variable {
    background: var(--accent-tint);
    color: var(--accent-text);
}

/* ── A value nobody was asked for ───────────────────────────────────
   Shown, never in a box. The panel's first job is answering "is what is
   on the server what I think it is", and that question is asked about
   these rows too — what changes is that the answer is not an invitation
   to type over it. */
.csenv-fixed {
    font-family: var(--font-mono, monospace);
    font-size: 0.85rem;
    color: var(--text-secondary);
    overflow-wrap: anywhere;
}

/* The way in, worded as what it does. An inline action inside the value
   column, matching `.csenv-use` directly below it rather than inventing
   a second shape for "a small thing you can press beside a value". */
.csenv-unlock {
    background: none;
    border: 0;
    padding: 0;
    margin-left: 8px;
    font: inherit;
    font-size: 0.85rem;
    color: var(--accent-text);
    text-decoration: underline;
    cursor: pointer;
    white-space: nowrap;
}

/* Said where the typing happens, not in a legend the operator scrolled
   past to get here. */
.csenv-opened {
    grid-column: 2;
    margin: 0;
    color: var(--warning-text);
}

/* The group they live in, last in every file. Closed, because they are
   not what anybody came for; it opens itself whenever something inside
   has to be seen (a search, an opened row, a disagreement). */
.csenv-held {
    margin-top: 12px;
    border-top: 1px solid var(--border-color);
}

.csenv-held > summary {
    padding: 8px 0;
    color: var(--text-secondary);
    font-size: 0.85rem;
    cursor: pointer;
}

.csenv-held > summary:focus-visible {
    outline: none;
    box-shadow: var(--focus-ring);
    border-radius: var(--radius);
}

/* ── Edited, on the row ─────────────────────────────────────────────
   The rail carries a count and the count is a number to go hunting
   with; this is the box it means. A rule on the same edge as
   `.is-different`, in the accent rather than the warning, because an
   edit is the operator's own doing and not a finding about the server.
   The word is the signal; the rule only makes it findable while
   scrolling. */
.csenv-edited {
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    padding: 1px 6px;
    border-radius: var(--radius);
    background: var(--accent-tint);
    color: var(--accent-text);
    white-space: nowrap;
}

.csenv-row.is-edited {
    border-left: 3px solid var(--accent-text);
    padding-left: 10px;
    margin-left: -13px;
}

/* A row can be both, and the disagreement is the older and more urgent
   fact — it is what the panel was opened for. */
.csenv-row.is-different.is-edited {
    border-left-color: var(--warning-text);
}

/* ── The review, before anything is written ─────────────────────────
   The screen that stands between ~90 free-text boxes and a customer's
   production `.env`. It replaces the panel rather than sitting over it,
   the way the close question does: this modal scrolls, and a question
   rendered under 90 rows is a question nobody is asked. */
.csenv-rv-lead {
    margin: 0 0 16px;
}

.csenv-rv-file + .csenv-rv-file {
    margin-top: 20px;
}

.csenv-rv-file h4 {
    margin: 0 0 8px;
}

.csenv-rv-list {
    list-style: none;
    margin: 0;
    padding: 0;
}

.csenv-rv-row {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
    padding: 8px 0;
    border-bottom: 1px solid var(--border-color);
}

.csenv-rv-row:last-child { border-bottom: none; }

.csenv-rv-name {
    font-family: var(--font-mono, monospace);
    overflow-wrap: anywhere;
}

/* `was → will be`, as one unit that wraps as one — the arrow separating
   two values across a line break reads as two unrelated facts. */
.csenv-rv-change {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
    min-width: 0;
}

.csenv-rv-val {
    font-family: var(--font-mono, monospace);
    font-size: 0.85rem;
    background: var(--surface-sunken);
    border-radius: var(--radius);
    padding: 1px 6px;
    overflow-wrap: anywhere;
}

/* An absence, and a secret's "a new value", are prose rather than a
   literal — a mono box around the words `not set` reads as a value
   spelled that way. */
.csenv-rv-val.is-none {
    font-family: inherit;
    background: none;
    padding: 0;
    color: var(--text-muted);
}

.csenv-rv-arrow {
    color: var(--text-muted);
}

/* Emptying a value that is set is the case this panel exists for
   (`MAIL_HOST=` put a login page into offline mail mode) and also the
   likeliest slip, so it is said in words on the line rather than left
   to be read out of an arrow pointing at nothing. */
.csenv-rv-row.is-cleared {
    border-left: 3px solid var(--warning-text);
    padding-left: 10px;
    margin-left: -13px;
}

.csenv-rv-flag {
    color: var(--warning-text);
    font-size: 0.85rem;
}

/* ── Where to change the values ─────────────────────────────────────
   The two places are drawn as `.choice-card`s, the same component the
   Download dialog offers its two bundles with — same question shape, two
   objects and one choice, so it is the same picture. What is left here
   is only what that dialog does not have. */
.cvw-drift {
    margin: 12px 0 0;
    padding: 10px 12px;
    border-radius: var(--radius);
    background: var(--warning-bg);
    color: var(--warning-text);
}

/* The fields for values an installation is missing — rendered in the
   dialog that refused. It predates the variable editor and stays: the
   dialog is where the refusal happened, and sending somebody to another
   screen to answer a question they are already looking at is a detour,
   not a fix. */
.missing-values {
    margin-top: 20px;
}

/* Each field is its own question. Without the gap the help text of one
   sat against the label of the next and the form read as a paragraph. */
.missing-values .form-group + .form-group {
    margin-top: 20px;
}

.missing-values .form-help {
    margin-top: 6px;
}

.form-label-note {
    color: var(--text-secondary);
    font-weight: 400;
}

/* An error INSIDE a panel or modal — the failure of the thing the
   operator is looking at, not a transient toast. Same visual grammar as
   .login-message.error, which is the only other place the app had to
   say "this did not work" in place rather than in passing.

   It exists because the offline-build failure used to render as an
   ordinary paragraph: a wall of grey text where nothing said it was an
   error at all, so it read as a status. */
.notice {
    display: flex;
    gap: 10px;
    padding: 12px 14px;
    border-radius: var(--radius);
    text-align: left;
}

.notice::before {
    flex: none;
    font-weight: 700;
    line-height: 1.45;
}

.notice-error {
    background: var(--danger-bg);
    border: 1px solid hsl(var(--status-error-border));
    color: var(--danger-text);
}

.notice-error::before { content: '✕'; }

.notice-title {
    margin: 0 0 4px;
    font-weight: 600;
}

.notice-body {
    margin: 0;
    /* Registry references and generator messages are long, unspaced and
       full of slashes; without this they push the dialog wider than the
       viewport instead of wrapping. */
    overflow-wrap: anywhere;
}

.notice-hint {
    margin: 8px 0 0;
    color: var(--text-secondary);
}

.loading-overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.7);
    display: flex;
    align-items: center;
    justify-content: center;
    z-index: 3000;
}

.loading-overlay.hidden {
    display: none;
}

.loading-card {
    background: var(--bg-secondary);
    padding: 32px 48px;
    border-radius: var(--radius-lg);
    box-shadow: var(--shadow-hover);
    text-align: center;
}

.loading-card h2 {
    margin-top: 16px;
    margin-bottom: 8px;
    font-family: var(--font-display);
}

.loading-card p {
    color: var(--text-secondary);
}

.loading-progress {
    margin-top: 16px;
    width: 200px;
    height: 8px;
    background: var(--surface-sunken);
    border-radius: 999px;
    overflow: hidden;
}

/* Same contract as .resource-bar-fill: --fill (0..1) via scaleX, so the
   bar animates on the compositor instead of relaying out every frame. */
.loading-progress .bar {
    --fill: 0;
    height: 100%;
    width: 100%;
    background: var(--accent-primary);
    transform: scaleX(var(--fill));
    transform-origin: left center;
    transition: transform 0.24s var(--ease-out);
}

/* The Download dialog's two options, drawn as two identical cards so
   they can be COMPARED — same mark, name, facts, one consequence, one
   control, in the same places. The prose version stated the same things
   in sentences, which have to be read before anything can be compared.
   The archive's own state (ready / building / expired / failed) is the
   second card's state rather than a block underneath: it IS that
   option. */
.choice-cards {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
    gap: 16px;
}

/* A refused option answers ABOVE both cards, so the block sits between
   the title and the choice it belongs to. It is empty on arrival and
   the gap has to be empty with it — an unconditional margin is a hole
   in the dialog every operator who never met a refusal would see. 20px
   is what the same notice already keeps from what follows it in the
   missing-values dialog. */
#composeBundleFlavourError:not(:empty) {
    margin-bottom: 20px;
}

.choice-card {
    display: flex;
    flex-direction: column;
    gap: 8px;
    padding: 16px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
    background: var(--surface-sunken);
}

/* The archive that already exists is usually the answer, so the card
   holding it is marked — with a border and a tint, never with the tint
   alone, and the badge inside says WHY in words. */
.choice-card.is-recommended {
    border-color: var(--accent-text);
    background: var(--accent-tint);
}

/* An option that cannot be taken from here — offered and refused, not
   hidden: a control that vanishes leaves an operator with a ticket
   instead of a question. Muted with an explicit token pair, never
   `opacity`; the neighbours have paid for that lesson (0.55 measured at
   2.14:1 on a control beside this one). */
.choice-card.is-blocked {
    background: none;
}

.choice-card.is-blocked .choice-card-title,
.choice-card.is-blocked .choice-card-facts,
.choice-card.is-blocked .choice-card-icon {
    color: var(--text-muted);
}

.choice-card-head {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
}

.choice-card-icon {
    display: inline-flex;
    color: var(--accent-text);
}

.choice-card-icon svg {
    width: 20px;
    height: 20px;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.4;
    stroke-linecap: round;
    stroke-linejoin: round;
}

.choice-card-title {
    margin: 0;
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--text-primary);
}

/* The line an operator actually compares the two options on. */
.choice-card-facts {
    margin: 0;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.choice-card-note,
.choice-card-note p {
    margin: 0;
}

/* The control sits on the card's floor, so both cards' buttons share a
   baseline however long the note above them runs. */
.choice-card-action {
    margin-top: auto;
    padding-top: 4px;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 6px;
}

.choice-card-action .btn-primary,
.choice-card-action .btn-secondary {
    width: 100%;
}

/* Out of date is not an error — the archive works, it just installs
   older images. Warning tone, and the sentence names what moved. */
.bundle-stale {
    color: var(--warning-text);
}

/* A button that reads as a link — for a secondary action inside a
   sentence ("Build it again if the versions have changed"), where a
   real button would outweigh the primary one beside it. */
.link-button {
    padding: 0;
    border: 0;
    background: none;
    color: var(--accent-text);
    font: inherit;
    text-decoration: underline;
    cursor: pointer;
}

/* The offline-bundle modal wants the same bar across the dialog rather
   than the 200px badge width the loading card uses. */
.loading-progress--wide {
    width: 100%;
}

.spinner,
.loading-spinner {
    width: 40px;
    height: 40px;
    border: 3px solid var(--border-color);
    border-top-color: var(--accent-primary);
    border-radius: 50%;
    animation: spin 0.6s linear infinite;
    margin: 0 auto;
}

.spinner.small,
.loading-spinner {
    width: 20px;
    height: 20px;
    border-width: 2px;
}

.loading-spinner {
    display: inline-block;
    border-color: currentColor;
    border-top-color: transparent;
    vertical-align: middle;
    margin: 0;
}

@keyframes spin {
    from { transform: rotate(0deg); }
    to   { transform: rotate(360deg); }
}

.empty-state {
    text-align: center;
    padding: 40px 20px;
    color: var(--text-muted);
}

.empty-state p {
    margin: 0;
}

/* ============================================================
   21. Environment detail panels
   ============================================================ */
.env-details-grid {
    display: grid;
    grid-template-columns: repeat(2, 1fr);
    gap: 16px;
}

.env-details-item,
.info-item,
.pod-item,
.service-item,
.url-item,
.port-item,
.metric-item {
    background: var(--surface-sunken);
    border-radius: var(--radius);
}

.env-details-item {
    padding: 12px;
}

.env-details-item label,
.info-item label {
    font-size: 0.75rem;
    color: var(--text-muted);
    text-transform: uppercase;
    letter-spacing: 0.5px;
    display: block;
    margin-bottom: 4px;
}

.env-details-item span,
.info-item span {
    font-weight: 500;
    color: var(--text-primary);
}

.pods-table { margin-top: 16px; }
.pods-table th { font-size: 0.75rem; }
.pods-table td { font-size: 0.85rem; }

.info-section { margin-bottom: 24px; }
.info-section:last-child { margin-bottom: 0; }

.info-section h4 {
    font-size: 0.95rem;
    color: var(--text-secondary);
    margin-bottom: 12px;
    padding-bottom: 8px;
    border-bottom: 1px solid var(--border-color);
    text-transform: uppercase;
    letter-spacing: 0.5px;
}

.info-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(180px, 1fr));
    gap: 12px;
}

.info-item { padding: 12px 16px; }

.pods-section, .services-section { margin-bottom: 16px; }

.pods-section h4, .services-section h4 {
    font-size: 0.9rem;
    color: var(--text-secondary);
    margin-bottom: 12px;
}

.pods-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.pod-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 16px;
    gap: 16px;
}

.pod-info {
    display: flex;
    align-items: center;
    gap: 16px;
    flex: 1;
}

.pod-name { font-weight: 500; color: var(--text-primary); min-width: 200px; }
.pod-app { font-size: 0.85rem; color: var(--text-secondary); min-width: 100px; }
.pod-ready, .pod-restarts { font-size: 0.85rem; color: var(--text-muted); }
.pod-actions, .service-actions { display: flex; gap: 8px; }

.services-list {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
    gap: 12px;
}

.service-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 12px 16px;
}

.service-name { font-weight: 500; color: var(--text-primary); }

.service-tag {
    font-size: 0.85rem;
    color: var(--text-secondary);
    background: var(--bg-primary);
    padding: 2px 8px;
    border-radius: 999px;
}

.env-link {
    color: var(--accent-text);
    text-decoration: none;
    font-weight: 500;
    cursor: pointer;
    border-bottom: 1px solid transparent;
    transition: border-color var(--transition), color var(--transition);
}

.env-link:hover {
    border-bottom-color: var(--accent-text);
}

.env-link:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
    /* rounded.sm — the inline tier of the Radius-Steps-Down Rule. */
    border-radius: 4px;
}

.metrics-row {
    display: flex;
    gap: 16px;
    margin-bottom: 20px;
    flex-wrap: wrap;
}

.metric-item {
    padding: 16px 24px;
    text-align: center;
    min-width: 120px;
}

.metric-value {
    font-family: var(--font-mono);
    font-variant-numeric: tabular-nums;
    font-size: 1.5rem;
    font-weight: 600;
    color: var(--text-primary);
}

.metric-label {
    font-size: 0.75rem;
    color: var(--text-muted);
    text-transform: uppercase;
    margin-top: 4px;
}

.section-title {
    font-size: 1.15rem;
    color: var(--accent-text);
    margin-bottom: 16px;
    display: flex;
    align-items: center;
    gap: 8px;
}

.section-title::before {
    content: '';
    display: inline-block;
    width: 4px;
    height: 20px;
    background: var(--accent-text);
    /* A 4px-wide rail: the pill step rounds the caps exactly as the old
       one-off 2px did, and is on the documented scale. */
    border-radius: 999px;
}

.urls-title::before { background: var(--success); }

.urls-subtitle {
    font-size: 0.9rem;
    color: var(--accent-text);
    margin-bottom: 12px;
    font-weight: 500;
}

.urls-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.url-item {
    display: flex;
    align-items: center;
    padding: 12px 16px;
}

.url-label {
    font-weight: 500;
    min-width: 180px;
    color: var(--text-primary);
}

.url-link {
    color: var(--accent-text);
    text-decoration: none;
    font-family: var(--font-mono);
    font-size: 0.85rem;
    word-break: break-all;
}

.url-link:hover { text-decoration: underline; }

.url-link:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

.ports-subtitle {
    color: var(--text-muted);
    font-size: 0.85rem;
    margin-bottom: 12px;
}

.ports-subtitle strong { color: var(--accent-text); }

.port-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
    gap: 10px;
}

.port-item {
    display: flex;
    flex-direction: column;
    padding: 10px 14px;
    border-left: 3px solid var(--accent-text);
}

.port-item label {
    font-size: 0.75rem;
    color: var(--text-muted);
    margin-bottom: 4px;
}

.port-item span {
    font-family: var(--font-mono);
    font-size: 0.85rem;
    color: var(--text-primary);
}

.port-item.port-device { border-left-color: var(--success); }
.port-item.port-debug  { border-left-color: var(--warning); }
.port-item.port-parent { border-left-color: var(--text-muted); opacity: 0.85; }

.port-item.port-clickable {
    cursor: pointer;
    transition: transform var(--press), box-shadow var(--transition), background var(--transition);
}

.port-item.port-clickable:hover {
    transform: translateY(-2px);
    box-shadow: var(--shadow-md);
    background: var(--bg-secondary);
}

.port-item.port-clickable:active { transform: translateY(0); }

.port-item.port-clickable:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

.port-item .port-value { transition: color var(--transition); }

.port-item .port-value.copied {
    color: var(--success-text);
    font-weight: 600;
}

.ttl-section {
    background: var(--accent-tint);
    border: 1px solid var(--accent-text);
    border-radius: var(--radius);
    padding: 16px 20px;
    margin-bottom: 20px;
}

.ttl-section.expiring {
    background: var(--warning-bg);
    border-color: var(--warning);
}

.ttl-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 12px;
}

.ttl-header strong { font-size: 0.95rem; }
.ttl-expires { font-size: 0.9rem; color: var(--text-secondary); }
.ttl-buttons { display: flex; gap: 8px; }

.ttl-label {
    color: var(--warning-text);
    font-weight: 600;
}

.ttl-select { border-color: var(--warning); }

.ttl-warning {
    color: var(--warning-text);
    font-weight: 500;
    font-size: 0.875rem;
    margin-top: 4px;
    display: block;
}

.env-type-header {
    background: var(--surface-sunken);
    padding: 12px 16px;
    border-radius: var(--radius);
    margin-bottom: 16px;
    font-size: 0.95rem;
}

.services-manager-table { margin-top: 0; }

.services-manager-table .from-parent,
.services-manager-table .service-disabled {
    opacity: 0.5;
}

.from-parent-label {
    font-size: 0.75rem;
    color: var(--text-muted);
    font-weight: normal;
}

.services-manager-table .searchable-dropdown { min-width: 180px; }
.services-manager-table .searchable-dropdown-input { padding: 6px 10px; font-size: 0.85rem; }
.services-manager-table .searchable-dropdown-list { z-index: 200; max-height: 250px; }

.changes-summary {
    background: var(--warning-bg);
    border: 1px solid var(--warning);
    border-radius: var(--radius);
    padding: 12px 16px;
    margin: 16px 0;
}

.changes-summary ul { margin: 8px 0 0 20px; padding: 0; }
.changes-summary li { font-size: 0.9rem; }

/* ============================================================
   22. Compose create wizard (index.html)

   The step rail is cicd-manager's canonical Stepper (DESIGN.md's named
   signature component): .stepper > .step > .step-marker + .step-label,
   with `done` and `current` states and a progress rail behind the
   markers. It previously used a private `.wizard-steps li` shape with a
   ::before counter, which reproduced only the marker and had no `done`
   state at all.

   `.step-btn` is cicd's: a marker that is a button because the flow lets
   you click a visited step to jump back. The compose-create wizard on
   index.html has no such navigation (it moves through
   composeWizardNext/Back only) and so renders bare `<li>`s — the Topology
   Builder's rail does, and uses the button. Both shapes are styled here;
   which one a rail uses is a statement about whether it can be navigated.
   ============================================================ */
.stepper {
    display: flex;
    gap: 0;
    list-style: none;
    margin: 0 0 28px;
    padding: 16px 12px;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
    /* DESIGN.md's Layout section makes this mandatory for a multi-step
       flow, and step 3 is where it earns it: with every card open the page
       is ~12,700px tall, so past the first screen the operator had no step
       indicator, no Back and no Next. The values are the documented ones. */
    position: sticky;
    top: var(--nav-h, 0px);
    z-index: 5;
}

.stepper .step {
    position: relative;
    flex: 1;
    min-width: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    padding: 0 6px;
    text-align: center;
    color: var(--text-muted);
    font-size: 0.75rem;
    transition: color var(--transition);
}

/* Progress rail: a track segment from this node's centre to the next
   node's. Sits behind the solid markers; turns accent once done. */
.stepper .step:not(:last-child)::after {
    content: '';
    position: absolute;
    top: 15px;
    left: 50%;
    width: 100%;
    height: 2px;
    background: var(--border-strong);
    z-index: 0;
}

.stepper .step.done::after { background: var(--accent-primary); }

.stepper .step-marker {
    position: relative;
    z-index: 1;
    flex: 0 0 auto;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 30px;
    height: 30px;
    border-radius: 50%;
    border: 2px solid var(--border-strong);
    background: var(--bg-secondary);
    color: var(--text-muted);
    font-size: 0.85rem;
    font-weight: 600;
    line-height: 1;
    transition: background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition);
}

.stepper .step-label {
    position: relative;
    z-index: 1;
    max-width: 100%;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    text-align: center;
}

/* done — calm: outline circle with a check, secondary label. */
.stepper .step.done .step-marker {
    background: var(--bg-secondary);
    border-color: var(--accent-primary);
    color: var(--accent-text);
    font-size: 0;
}

.stepper .step.done .step-marker::after {
    content: '\2713';                 /* the check is a GLYPH, not a colour */
    font-size: 0.85rem;
    font-weight: 700;
}

.stepper .step.done .step-label { color: var(--text-secondary); }

/* current — the focal node: filled marker + halo ring, accent bold label. */
.stepper .step.current .step-marker {
    background: var(--accent-primary);
    border-color: var(--accent-primary);
    color: #fff;
    box-shadow: 0 0 0 4px var(--accent-tint);
}

.stepper .step.current .step-label {
    color: var(--accent-text);
    font-weight: 600;
}

/* A rail whose steps ARE navigable: the marker is a real button. Only the
   visited ones take a pointer, and an unreached step is `disabled` in the
   markup — it is not a destination yet, and there is nothing to explain. */
.stepper .step-btn {
    position: relative;
    z-index: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
    width: 100%;
    padding: 0 6px;
    background: transparent;
    border: none;
    cursor: default;
    text-align: center;
    color: inherit;
    font-family: inherit;
    font-size: inherit;
    transition: color var(--transition);
}
.stepper .step.done .step-btn,
.stepper .step.current .step-btn { cursor: pointer; }
.stepper .step-btn:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 1px; }
.stepper .step.done .step-btn:hover .step-label { color: var(--text-primary); }
.stepper .step.done .step-btn:hover .step-marker { box-shadow: 0 0 0 4px var(--accent-tint); }

/* How many things this step has to fix. A glyph carrying a number, so the
   count survives with colour off — the tone only repeats it. */
.stepper .step-issue {
    position: relative;
    z-index: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    min-width: 18px;
    height: 18px;
    padding: 0 5px;
    border-radius: 999px;         /* the system's pill, same as .tag / .badge */
    background: var(--warning-bg);
    color: var(--warning-text);
    font-family: var(--font-mono);
    font-size: 0.72rem;
    font-weight: 700;
    font-variant-numeric: tabular-nums;
}
/* A class selector outranks the UA's `[hidden] { display: none }`, so the
   badge has to opt back out itself — renderNav toggles the attribute. */
.stepper .step-issue[hidden] { display: none; }
.stepper .step.has-issue .step-marker { border-color: var(--warning); }

.wizard-nav .nav-right {
    display: flex;
    gap: 8px;
    margin-left: auto;
}

/* A control that cannot advance yet. Muted like a disabled button but
   still clickable — clicking it is how the operator asks why, and it
   answers by taking them to the offending row (the wizard's Next) or by
   opening the panel that fixes it (the Installations row's Deploy).

   The mute is an explicit token pair, NOT `opacity`, and the difference
   is a WCAG failure rather than a preference. `opacity: 0.55` composites
   whatever the variant paints against whatever is behind it: measured on
   the Installations card that put the Health label at 2.49:1 (light) /
   2.92:1 (dark) and Deploy at 3.23 / 4.48 — all under the 4.5:1 bar, and
   these are ACTIVE controls (deliberately neither `disabled` nor
   `aria-disabled`), so the inactive-component exemption does not apply.
   Worse, opacity dims the FOCUS RING with everything else: the blocked
   Deploy's outline measured 2.66:1 against 7.56:1 for the identical
   outline on the un-blocked button beside it, so the controls a keyboard
   operator most needs to find had the weakest indicator on the row
   (SC 1.4.11 wants 3:1). --text-muted on --surface-sunken measures
   5.07:1 light / 5.43:1 dark and does not touch the ring.

   The :hover half is not decoration: `.btn-secondary:hover:not(:disabled)`
   is (0,3,0) and would otherwise repaint the control on hover. */
.btn.is-blocked,
.btn.is-blocked:hover:not(:disabled) {
    background: var(--surface-sunken);
    color: var(--text-muted);
    /* The boundary is the ONLY thing giving this control a shape — its
       fill is the card action bar's own --surface-sunken — so 1.4.11's
       3:1 applies to it, not the hairline exemption. --border-control was
       the first choice and measured 2.72:1 against that bar in light, i.e.
       under the bar on the exact surface it was chosen for. --text-muted
       is the token already proven on this pair (5.07:1 light / 5.43:1
       dark) and matches the outlined-danger precedent, where the text and
       the border share one foreground token. */
    border-color: var(--text-muted);
    box-shadow: none;
}

.wizard-step { display: none; }
/* The four panes are the app's main flow and were the one hard cut left in
   it: `.tab-content.active` one screen over already fades, and the topology
   wizard has `wz-fade`. It restarts by itself because the pane comes back
   from `display: none`.

   Opacity ONLY, unlike every other entrance here — this is the one place
   where the 2px of `dm-fade-in` is not free. Step 3 is several hundred
   fields, and compositing a pane that size shifts the geometry underneath
   it by ~1e-4px: `e2e/compose-create.spec.js` measures a card header to
   full float precision across three work states and read 93.12493896484375
   against 93.125 while the transform was still running. The movement buys
   nothing on a full-screen pane anyway; the fade is the whole signal. */
.wizard-step.active { display: block; animation: dm-pane-in 0.18s var(--ease-out); }

@keyframes dm-pane-in {
    from { opacity: 0; }
    to   { opacity: 1; }
}
/* The pane takes focus when the step changes; it is a scroll destination,
   not a control, so it must not draw a ring. */
.wizard-step:focus { outline: none; }

/* ============================================================
   STEP 3 — the control band, and the two things that stay put
   ============================================================
   Step 3 renders several hundred fields across thirteen disclosures. Two
   consecutive screens of an expanded service showed neither the service's
   name nor the group's, and with every card open `Next` sat fifteen
   viewport heights down. The stepper (sticky, above) answers "which step";
   these answer "which service" and "how do I leave".
   ============================================================ */

/* Sits below the sticky stepper — the offset is measured in
   _composeSyncStickyOffset because the rail drops its labels at 768px. */
details.compose-service-form > summary {
    position: sticky;
    top: var(--cmp-sticky-top, 0px);
    z-index: 3;
    /* An open card's summary already carries --accent-tint; a closed one
       never scrolls under anything, so it stays transparent. */
    transition: background var(--transition);
}

.wizard-nav-sticky {
    position: sticky;
    bottom: 0;
    z-index: 4;
    margin-top: 16px;
    padding-bottom: 12px;
    background: var(--bg-secondary);
}
.cmp-band {
    margin: 0 0 16px;
    padding: 14px 16px;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
}
.cmp-band .sum-counts { margin-bottom: 12px; }
.cmp-band .sum-state { margin-bottom: 0; }
.cmp-band-tools {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px 12px;
    margin-bottom: 10px;
}
.cmp-search-label {
    font-size: 0.75rem;
    letter-spacing: 0.16em;
    text-transform: uppercase;
    font-weight: 600;
    color: var(--text-muted);
}
.cmp-search {
    flex: 1 1 16rem;
    min-width: 0;
    padding: 8px 12px;
    border: 1px solid var(--border-control);
    border-radius: var(--radius);
    background: var(--bg-input);
    color: var(--text-primary);
    font-family: inherit;
    font-size: 0.875rem;
    transition: border-color var(--transition), box-shadow var(--transition);
}
.cmp-search:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}
.cmp-chips { display: inline-flex; flex-wrap: wrap; gap: 6px; }
/* Same three words as the pills on the cards, so the filter and the thing
   it filters cannot be read as two different vocabularies. Selected state
   is a tint plus an inset ring, never a solid accent fill — a solid chip
   would outrank the primary button beside it. */
.cmp-chip {
    padding: 0.2rem 0.6rem;
    border: 1px solid var(--border-strong);
    border-radius: 999px;
    background: transparent;
    color: var(--text-secondary);
    font-family: var(--font-sans);
    font-size: 0.78rem;
    transition: background var(--transition), border-color var(--transition),
                color var(--transition), box-shadow var(--transition);
}
.cmp-chip:hover { background: var(--surface-interactive); }
.cmp-chip:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.cmp-chip.is-on {
    background: var(--accent-tint);
    border-color: var(--accent-text);
    color: var(--accent-text);
    font-weight: 600;
    box-shadow: inset 0 0 0 1px var(--accent-text);
}
.cmp-band-actions { display: inline-flex; gap: 6px; margin-left: auto; }

/* The one way a filter can lie about the state of the step. */
.cmp-filter-note {
    margin: 0 0 10px;
    font-size: 0.78rem;
    color: var(--text-muted);
}
/* Only when a hidden card still holds a blocker: "Showing 1 of 7" is a
   fact, not a warning, and painting it orange made every search look like
   a mistake. */
.cmp-filter-note.is-warning { color: var(--warning-text); }
.cmp-filter-note[hidden] { display: none; }

.cmp-empty {
    margin: 0;
    padding: 24px 16px;
    text-align: center;
    color: var(--text-muted);
    font-size: 0.9rem;
}

/* What is outstanding, in front of what merely exists. */
.cmp-card-todo {
    display: block;
    margin-top: 0.15rem;
    font-size: 0.78rem;
    font-weight: 600;
    color: var(--warning-text);
}

/* Status is a fact about a card, not a licence for it to resize. Editing a
   field flips ready → changed, and a line that appears with the new status
   shoves every card below it down while the operator is still typing. So
   the lines the meta column can show are laid out from the first paint and
   keep their box when they have nothing to say: `hidden` still removes them
   from the accessibility tree and from view, it just stops moving the page.
   One line each, elided rather than wrapped, for the same reason — a
   SCREAMING_SNAKE variable name is long enough to reflow the column on its
   own, and the full text is on `title`.

   The line-height is stated rather than inherited because these two lines
   do not share a font — the tag value is mono, whose normal line box is
   2.7px taller than the prose one, which is a jump of exactly the kind
   this rule exists to prevent. Unitless, so the smaller label span scales
   with it and the tallest inline box is still 1.35em. */
.cmp-card-todo,
.cmp-card-tag {
    min-height: 1.35em;
    line-height: 1.35;
    overflow: hidden;
    white-space: nowrap;
    text-overflow: ellipsis;
}
.cmp-card-todo[hidden],
.cmp-card-tag[hidden] {
    display: block;
    visibility: hidden;
}

/* Which build this service will run, on the closed card. Mono for the
   value because it is an identifier to compare character by character; the
   label keeps it from reading as a stray token. */
.cmp-card-tag {
    display: block;
    margin-top: 0.15rem;
    font-size: 0.78rem;
    color: var(--text-secondary);
}
/* Set apart by case and colour, not by a size — the type ramp has nothing
   below the line's own step, and inventing one is what the ramp guard is
   there to stop. */
.cmp-card-tag-label {
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-muted);
}
.cmp-card-tag-value {
    font-family: var(--font-mono);
    color: var(--text-primary);
}
details.compose-service-form[data-work-status="changed"] .cmp-card-todo {
    color: var(--accent-text);
}
details.compose-service-form[data-work-status="loading"] .cmp-card-todo {
    color: var(--text-muted);
    font-weight: 500;
}

/* A pill is a glyph plus a label; colour is the third signal, never the
   first (DESIGN.md). `.badge-external` one component over already does
   this — the status pills quietly did not. */
.cmp-card-status::before {
    margin-right: 0.35em;
    font-weight: 700;
}
.cmp-card-status-ready::before { content: '✓'; }
.cmp-card-status-needs-input::before { content: '!'; }
.cmp-card-status-changed::before { content: '●'; }
.cmp-card-status-loading::before { content: '…'; }

/* A card that is ready is not where the work is, so it gives up the line
   that QUANTIFIES work — "2 fields · 2 filled in by the generator" answers
   a question nobody has once there is nothing to do. What it keeps is the
   role line: that is the service's identity, not its status, and a card
   that stops saying what ui-backend IS the moment it goes green is harder
   to scan, not easier. Order is never touched — the list is in dependency
   order and a list that sorts itself moves under the operator's hand.

   `visibility`, not `display`: the line goes quiet without taking its
   height with it, so the card does not grow the moment an edit turns it
   from ready to changed. */
details.compose-service-form[data-work-status="ready"]:not([open]) .cmp-card-count {
    visibility: hidden;
}

.viol-more {
    color: var(--text-muted);
    font-style: italic;
}

.wizard-nav {
    margin-top: 24px;
    padding-top: 16px;
    border-top: 1px solid var(--border-color);
    display: flex;
    justify-content: flex-start;
    flex-wrap: wrap;
    gap: 8px;
}


.wizard-nav > .btn:last-child { margin-left: auto; }

.compose-image-row {
    display: flex;
    align-items: baseline;
    gap: 0.6rem;
    padding: 0.5rem 0.75rem;
    margin: 0.5rem 0;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    flex-wrap: wrap;
}

.compose-image-row label { font-weight: 500; }

.compose-image-row select.compose-image-tag {
    min-width: 14rem;
    padding: 6px 10px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background: var(--bg-input);
    color: var(--text-primary);
    font-size: 0.85rem;
}

.compose-image-row select.compose-image-tag:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}

.compose-image-row .form-help {
    color: var(--text-muted);
    font-family: var(--font-mono);
    font-size: 0.875rem;
}

/* The release picker at the head of step 3 is the same BOX as a tag row —
   one vocabulary for one idea — but its label and its note are sentences,
   not image refs. The monospace above exists for `hub.friendly-tech.com/...`
   and for tag names; a sentence set in it is the one readability defect
   this row could ship. */
#composeReleaseRow .form-help { font-family: inherit; }
#composeReleaseNote { flex: 1 1 100%; }

/* Step 4's review. It was one <pre> in monospace grey — every fact at
   the same weight, labels reading like values — in front of the button
   that generates a customer's bundle. Sections and weight carry the
   hierarchy now; monospace is kept for VALUES, which are literal
   strings somebody typed or a contract shipped. */
.rev-facts {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
    gap: 8px 24px;
    margin: 0 0 20px;
}

.rev-fact { min-width: 0; }

.rev-fact dt {
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-muted);
}

.rev-fact dd {
    margin: 2px 0 0;
    color: var(--text-primary);
    overflow-wrap: anywhere;
}

.rev-block { margin-bottom: 20px; }

.rev-head {
    margin: 0 0 8px;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-secondary);
}

.rev-service {
    margin: 12px 0 4px;
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--text-muted);
    font-family: var(--font-mono);
}

.rev-list {
    margin: 0;
    padding: 0;
    border-top: 1px solid var(--border-color);
}

.rev-list dd {
    margin: 0;
}

.rev-row,
.rev-server {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 4px 12px;
    padding: 6px 0;
    border-bottom: 1px solid var(--border-color);
}

.rev-key {
    flex: 0 0 auto;
    min-width: 14rem;
    font-family: var(--font-mono);
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-primary);
    overflow-wrap: anywhere;
}

.rev-val {
    flex: 1 1 12rem;
    font-family: var(--font-mono);
    font-size: 0.85rem;
    color: var(--text-secondary);
    overflow-wrap: anywhere;
}

.rev-fact dd code {
    font-family: var(--font-mono);
    font-size: 0.9rem;
    overflow-wrap: anywhere;
}

/* The services a host runs sit UNDER its address, not against it. It
   used to be a flex sibling with `flex: 1 1 100%`, which wrapped it to
   its own line; inside the <dd> that flex rule applies to nothing, and
   the list ran straight into the last digit of the IP. */
.rev-note {
    display: block;
    margin-top: 2px;
    font-size: 0.75rem;
    color: var(--text-muted);
}

/* An upgrade changes what the deploy DOES — migrations against a live
   customer database. As one of four equal grid facts it read as
   metadata, which is the opposite of "danger looks like danger". */
.rev-upgrade {
    margin: 0 0 16px;
    padding: 10px 14px;
    background: var(--warning-bg);
    color: var(--warning-text);
    border-radius: var(--radius);
    font-size: 0.875rem;
}

.rev-note-block,
.rev-what-happens {
    margin: 8px 0 0;
}

.rev-what-happens {
    margin-top: 16px;
}

.compose-computed-addresses { margin-top: 1rem; }

.compose-computed-addresses-title {
    margin: 0 0 0.6rem 0;
    font-size: 0.95rem;
    font-weight: 600;
    display: flex;
    align-items: baseline;
    gap: 0.5rem;
}

.compose-computed-host {
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    padding: 0.6rem 0.8rem;
    margin-bottom: 0.5rem;
}

.compose-computed-host-id {
    color: var(--text-secondary);
    margin-bottom: 0.35rem;
    font-size: 0.85rem;
    font-weight: 500;
}

.compose-computed-table { border-collapse: collapse; }

.compose-computed-table td {
    padding: 0.15rem 0.55rem 0.15rem 0;
    vertical-align: top;
    font-size: 0.85rem;
}

.compose-computed-key { color: var(--text-muted); white-space: nowrap; font-family: var(--font-mono); }
.compose-computed-eq  { color: var(--text-muted); padding: 0 0.2rem; }

.compose-computed-val code {
    background: var(--surface-sunken);
    padding: 1px 6px;
    border-radius: 4px;
    font-family: var(--font-mono);
    font-size: 0.85rem;
}

/* Globals block on step 3 */
.compose-globals {
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    padding: 0.9rem 1rem;
    margin-bottom: 1rem;
}

.compose-globals-head {
    display: flex;
    gap: 0.6rem;
    align-items: baseline;
    margin-bottom: 0.7rem;
    flex-wrap: wrap;
}

.compose-globals-head strong {
    font-weight: 600;
    color: var(--text-primary);
    font-size: 0.95rem;
}

.compose-globals-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
    gap: 0.7rem 1rem;
    align-items: start;
}
.compose-globals-grid .form-help { margin: 0.3rem 0 0; }
.compose-globals-head code {
    font-family: var(--font-mono);
    font-size: 0.9em;
}

/* Unfinished wizard drafts, above the list of finished installs. A strip
   rather than a section: it is a way back into work, not a second
   inventory, and it disappears entirely when there is nothing to resume. */
.cmp-drafts {
    margin-bottom: 16px;
    padding: 10px 12px;
    border: 1px solid var(--border-color);
    border-left: 3px solid var(--accent-primary);
    border-radius: var(--radius);
    background: var(--surface-sunken);
}
.cmp-drafts[hidden] { display: none; }
.cmp-draft-head {
    font-size: 0.75rem;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    color: var(--text-muted);
    margin-bottom: 6px;
}
.cmp-draft-row {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    flex-wrap: wrap;
    padding: 4px 0;
}
.cmp-draft-name {
    font-family: var(--font-mono);
    font-weight: 600;
    color: var(--text-primary);
}
.cmp-draft-meta {
    font-size: 0.85rem;
    color: var(--text-secondary);
    min-width: 0;
}
/* Pushed to the end edge so the buttons line up down the strip however
   long the names are. */
.cmp-draft-actions {
    margin-left: auto;
    display: inline-flex;
    gap: 0.4rem;
}
.cmp-draft-shared {
    list-style: none;
    margin: 10px 0 0;
    padding: 0;
}
.cmp-draft-shared li {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    padding: 3px 0;
    font-size: 0.85rem;
}

.console-display {
    font-size: 0.85rem;
    color: var(--text-muted);
    font-family: var(--font-mono);
    letter-spacing: 0.04em;
    margin-right: auto;
}


/* ============================================================
   24. Topologies tab
   ============================================================ */
.topology-toolbar {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    padding: 0 0 12px;
    margin-bottom: 12px;
    border-bottom: 1px solid var(--border-color);
    flex-wrap: wrap;
}

.topology-status {
    font-family: var(--font-mono);
    font-size: 0.78rem;
    color: var(--text-muted);
}

.topology-status::before {
    content: '▪ ';
    color: var(--accent-text);
}

#topologyViewContainer { padding-top: 4px; }

.topology-empty,
.env-empty {
    color: var(--text-muted);
    text-align: center;
    padding: 3rem 1rem;
    font-size: 0.9rem;
}

.topology-empty span,
.env-empty span {
    display: block;
    margin-top: 0.6rem;
    font-size: 0.875rem;
    color: var(--text-muted);
}

/* Category chip in the list view's mix column. Brand hue arrives via
   --cat / --cat-dark set inline per category (information design, not
   decoration); the theme picks which one is used. */
.topology-cat-chip {
    --cat: var(--text-muted);
    --cat-dark: var(--cat);
    --c: var(--cat);
    display: inline-block;
    font-size: 0.75rem;
    font-weight: 600;
    padding: 2px 8px;
    border-radius: 999px;
    border: 1px solid;
    border-color: color-mix(in srgb, var(--c) 45%, transparent);
    background: color-mix(in srgb, var(--c) 14%, transparent);
    color: var(--c);
    margin-right: 0.3rem;
    white-space: nowrap;
}

[data-theme="dark"] .topology-cat-chip { --c: var(--cat-dark); }

.topology-grid,
.env-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: 16px;
}

.topology-card,
.env-card {
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
    overflow: hidden;
    box-shadow: var(--shadow);
    transition: border-color var(--transition), box-shadow var(--transition);
}

/* "Which one did I just make?" — the wizard returns to a list of N cards
   and, before this, marked none of them. One ring that fades, following
   `.cdep-secret.just-filled` exactly: box-shadow only, so nothing moves and
   no row below it reflows. The card's own shadow is carried through both
   stops, otherwise it drops out for the length of the animation.

   `.topology-card:hover` USED TO BE IN THIS SELECTOR, which meant
   pointing at a topology replayed the just-created ring 1.2s at a time
   — a card announcing it was new every time the cursor crossed it,
   while an installation card two tabs away answered a hover with the
   quiet border-and-shadow below. One list, two hover languages, and the
   loud one was an accident of where a comma sat. */
.env-card.just-created {
    animation: env-card-created 1.2s var(--ease-out);
}

@keyframes env-card-created {
    from { box-shadow: var(--shadow), 0 0 0 3px var(--accent-ring); }
    to   { box-shadow: var(--shadow), 0 0 0 3px transparent; }
}

/* The two lists are the same object seen twice, so they answer a hover
   the same way. */
.topology-card:hover,
.env-card:hover {
    border-color: var(--accent-text);
    box-shadow: var(--shadow-md);
}

.topology-card-head,
.env-card-head {
    padding: 14px 18px;
    display: flex;
    align-items: flex-start;
    gap: 14px;
    border-bottom: 1px solid var(--border-color);
    flex-wrap: wrap;
}

.topology-card-name,
.env-card-name {
    margin: 0;
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 1.15rem;
    letter-spacing: -0.01em;
    color: var(--text-primary);
}

.topology-card-desc,
.env-card-desc {
    margin-top: 2px;
    color: var(--text-muted);
    font-size: 0.85rem;
}

/* Who decided this shape. Quieter than the description above it: the
   description says what the topology IS, this says who to ask about it,
   which is a second-read fact rather than a first-read one. */
.topology-card-author {
    margin-top: 4px;
    color: var(--text-muted);
    font-size: 0.75rem;
}

.topology-card-meta,
.env-card-meta {
    display: flex;
    flex-direction: column;
    gap: 2px;
    align-items: flex-end;
    font-family: var(--font-mono);
    font-size: 0.75rem;
    color: var(--text-secondary);
    margin-left: auto;
}

.topology-card-meta span:first-child,
.env-card-meta span:first-child {
    color: var(--text-primary);
    font-weight: 600;
}

.topology-card-actions {
    display: flex;
    gap: 6px;
    align-items: center;
}

.topology-card-hosts,
.env-card-hosts {
    padding: 12px;
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
    gap: 10px;
    background: var(--surface-sunken);
}

/* Host tile: a compact "server" with per-service mini-icons. */
.host-server-card {
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    overflow: hidden;
    display: flex;
    flex-direction: column;
    box-shadow: var(--shadow);
    transition: transform var(--press), box-shadow var(--transition), border-color var(--transition);
}

.host-server-card:hover {
    transform: translateY(-1px);
    border-color: var(--accent-text);
    box-shadow: var(--shadow-md);
}

.host-server-rack {
    height: 18px;
    background: var(--accent-text);
    display: flex;
    align-items: center;
    gap: 5px;
    padding: 0 8px;
}

.rack-led {
    width: 5px;
    height: 5px;
    border-radius: 50%;
    background: rgba(255, 255, 255, 0.35);
}

.rack-led-g { background: var(--rack-led-ok); }
.rack-led-a { background: var(--rack-led-warn); }

/* The LEDs are DECORATION: renderHostServer emits the same green + amber +
   dim triplet for every host, with no data behind it. On the Topologies
   tab that dresses an abstract design and claims nothing. On an
   Installations card the same three dots sit on a real customer install
   and read as per-host status — the claim the stage rail is built to
   avoid, and the one the approved decision ("no live SSH health on the
   list") ruled out. So the card's strip opts out and keeps the bar. */
.host-strip--plain .rack-led { display: none; }

.rack-count {
    margin-left: auto;
    font-family: var(--font-mono);
    font-size: 0.75rem;
    letter-spacing: 0.08em;
    text-transform: uppercase;
    /* NOT #fff. The bar is --accent-text, which is a deep blue in light
       (white measures 8.65:1) and a LIGHT blue in dark, where white
       measured 2.47:1 — an AA failure on a label that is already 10.5px.
       Themed like the LEDs above it, for the same reason. */
    color: var(--rack-count-text);
}

.host-server-services {
    padding: 8px;
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(28px, 1fr));
    gap: 4px;
    min-height: 36px;
}

/* Per-service mini icon. --svc / --svc-dark arrive inline (vendor brand
   identity + category encoding, paired with a 2-3 letter code) and the
   theme picks which one renders. */
.svc-mini {
    --svc: var(--text-muted);
    --svc-dark: var(--svc);
    aspect-ratio: 1;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    cursor: help;
    color: var(--svc);
    background: transparent;
    overflow: visible;
}

[data-theme="dark"] .svc-mini { color: var(--svc-dark); }

/* The name behind a three-letter icon, in 70ms rather than the second a
   native `title` makes you wait (js/topologies-list.js owns the timing
   and the placement; this is only its look). Fixed, on <body>, because
   every one of these icons sits inside an `overflow: hidden` card.
   `pointer-events: none` so it can never sit between the cursor and the
   icon it describes and flicker. */
.svc-tip {
    position: fixed;
    z-index: 1200;
    padding: 4px 8px;
    border-radius: var(--radius);
    background: var(--text-primary);
    color: var(--bg-secondary);
    font-size: 0.75rem;
    font-weight: 500;
    line-height: 1.3;
    white-space: nowrap;
    pointer-events: none;
    box-shadow: var(--shadow-md);
    opacity: 0;
    transition: opacity 0.12s var(--ease-out);
}

.svc-tip.is-on { opacity: 1; }

.svc-mini svg {
    width: 100%;
    height: 100%;
    display: block;
    transition: transform var(--press);
}

.svc-mini:hover svg { transform: scale(1.15); }

.host-server-label {
    padding: 8px 10px;
    background: var(--surface-sunken);
    border-top: 1px solid var(--border-color);
    display: flex;
    flex-direction: column;
    gap: 1px;
}

.host-server-name {
    font-weight: 600;
    font-size: 0.85rem;
    color: var(--text-primary);
    line-height: 1.2;
    word-break: break-all;
}

.host-server-empty {
    grid-column: 1 / -1;
    text-align: center;
    color: var(--text-muted);
    font-size: 0.75rem;
    padding: 0.6rem 0;
    font-style: italic;
}

/* Legend strip — grouped by category, one row per group. */
.topology-legend {
    margin-top: 24px;
    padding: 16px 18px;
    background: var(--bg-secondary);
    border: 1px dashed var(--border-strong);
    border-radius: var(--radius-lg);
    display: flex;
    flex-direction: column;
    gap: 10px;
}

.topology-legend-title {
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.12em;
    text-transform: uppercase;
    color: var(--text-muted);
    margin-bottom: 2px;
}

.legend-group {
    display: flex;
    flex-wrap: wrap;
    gap: 8px 14px;
    align-items: center;
    padding-top: 8px;
    border-top: 1px dotted var(--border-color);
}

.legend-group:first-of-type { border-top: none; padding-top: 0; }

.legend-group-label {
    font-size: 0.75rem;
    letter-spacing: 0.1em;
    text-transform: uppercase;
    color: var(--text-muted);
    min-width: 100px;
    font-weight: 600;
}

.legend-item {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-size: 0.78rem;
    color: var(--text-primary);
}

.legend-item .svc-mini,
.legend-swatch {
    width: 22px;
    height: 22px;
    font-size: 0.75rem;
}

.legend-label { color: var(--text-secondary); }

/* ============================================================
   25. Deploy log (SSE stream)
   No transitions and no box-shadow anywhere in this block: the pane
   repaints on every streamed event and any per-line effect would be
   paid on each one.
   ============================================================ */
/* Where the install has got to. Sits ABOVE the log because it is the
   answer to the question the operator has while the log is silent —
   `docker compose pull` and the healthcheck each say nothing for
   minutes, and a pane that has stopped scrolling reads as a hang. */
/* NO margin of its own. `.cdep-panel`'s `gap: 12px` owns every seam in
   this column, and a child that adds a margin gets both — 10.5px + 12px
   = 22.5px, which is not on the DESIGN.md scale. It was invisible while
   this block was followed by the state card; the recovery ladder moved
   INSIDE it, so the stray margin became the seam under the consent
   sentence. Measured in a browser, 2026-09-03, by a Visual/UX lens. The
   rhythm rule beside `.cdep-panel` says this in words: "the direct
   children's own margins are zeroed, because both would apply and that
   is how a rhythm drifts back out." */
.cdep-steps-panel { margin-bottom: 0; }

.cdep-steps-head {
    margin: 0 0 6px;
    font-size: 0.75rem;
    font-family: var(--font-mono);
    color: var(--text-muted);
    display: flex;
    gap: 8px;
    align-items: baseline;
}

/* `step 5 of 10`, or the honest reason there is no such sentence. The
   first question anybody watching a deploy asks, so it sits beside the
   server name rather than under the list — and it is the same string
   the live region announces. Pushed to the right edge because the
   server name is what identifies the block. */
.cdep-steps-count {
    margin-left: auto;
    font-variant-numeric: tabular-nums;
}

/* What the whole run has cost so far, beside the machine it is running
   on. The per-step clocks answer "which part is slow"; this one answers
   the question asked afterwards — "how long does an install take" —
   which nothing on this panel could answer once the step holding the
   only clock had ended. */
.cdep-steps-total {
    font-variant-numeric: tabular-nums;
}

.cdep-steps {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.cdep-step {
    display: flex;
    align-items: center;
    gap: 8px;
    font-size: 0.85rem;
    color: var(--text-muted);
}

.cdep-step svg { flex: none; }
.cdep-step-label { min-width: 0; }

/* Five states, and each says which it is in more than colour: a done
   step keeps a tick, the running one keeps the crosshair AND a clock,
   a failed one an ×, and the two quiet ones keep the fallback mark plus
   — for the final one — the word `not attempted`. Colour only weights
   what the shape already says. */
.cdep-step.is-done { color: var(--success-text); }

/* The two quiet states — not started yet, and never going to — keep the
   list's own `--text-muted` and are given NO rule of their own.
   Deliberately: they are the part of the answer that says how much is
   left, so they are text somebody reads, and dimming read text below
   the muted token is how a contrast floor gets lost to a decoration.
   The three loud states above are what carries the emphasis, and
   `not attempted` carries its own word — a dimmed mark beside a failure
   reads as "about to carry on", and only the sentence says otherwise. */

.cdep-step.is-current {
    color: var(--text-primary);
    font-weight: 600;
}

.cdep-step.is-failed { color: var(--danger-text); }

/* ── which services the wait is on ────────────────────────────────────
   `6 of 7 healthy` says how many, never which. Chips rather than rows:
   seven lines under one step stop reading as a step, and the operator
   is scanning for the one that is not green. Indented to the label
   column so they read as belonging to the step above them. */
.cdep-svcs {
    list-style: none;
    margin: 2px 0 0 26px;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
}

.cdep-svcs[hidden] { display: none; }

.cdep-svc {
    display: inline-flex;
    align-items: baseline;
    gap: 6px;
    padding: 2px 8px;
    border-radius: 999px;
    border: 1px solid var(--border-color);
    font-family: var(--font-mono);
    font-size: 0.72rem;
    color: var(--text-muted);
    font-weight: 400;
}

.cdep-svc-state { font-variant-numeric: tabular-nums; }

/* Tone carries the same four answers the checklist does, and never
   alone: every chip says its state in a word, because a colour is not
   readable to everyone looking at it. */
.cdep-svc.is-ready {
    color: var(--success-text);
    background: var(--success-bg);
    border-color: transparent;
}

/* Still starting, and it has been a while. Not a failure — the run
   carries on and the word says so ("starting · 4m"); this is the panel
   answering "should I be worried yet" before the ten-minute wait
   answers it for us. */
.cdep-svc.is-slow {
    color: var(--warning-text);
    background: var(--warning-bg);
    border-color: transparent;
}

.cdep-svc.is-bad {
    color: var(--danger-text);
    background: var(--danger-bg);
    border-color: transparent;
}

/* How long it has been running. The difference between "this is taking
   a while" and "this is stuck" — and the only honest thing the panel
   can say while the server is quiet. */
.cdep-step-elapsed {
    margin-left: auto;
    font-family: var(--font-mono);
    font-size: 0.75rem;
    color: var(--text-muted);
    font-weight: 400;
    font-variant-numeric: tabular-nums;
}

/* The deploy was refused before anything was sent — the tool is
   restarting. Replaces the log pane, so it must not look like an error
   report: `.empty-state` is the app's own "nothing here, and that is
   fine" surface, and the head is the one line that carries weight. */
.cdep-unavailable-head {
    margin: 0 0 0.5rem;
    /* Generator refusals are long and full of slashes, and the callers
       concatenate the hint into this heading. `.notice-body` was given
       the same rule for the same reason; without it a mount path runs
       off the side of a centred 1.15rem line. */
    overflow-wrap: anywhere;
    /* The `heading` step. 1rem was neither a token nor on the ramp, and
       the repo's own type-ramp gate says so. */
    font-size: 1.15rem;
    font-weight: 600;
    color: var(--text-primary);
}

/* Something true for the REST of the deploy — today: the app has been
   asked to restart and is waiting this install out. A warning tone, not
   a danger one: nothing is broken and nothing is being stopped. */
.cdep-notice {
    margin-bottom: 0.75rem;
    padding: 0.5rem 0.75rem;
    border: 1px solid hsl(var(--status-warning-border));
    /* `md` on the documented radius scale (4 / 8 / 14 / pill). 6px is
       drift that already exists three times in this file; not a fourth. */
    border-radius: 8px;
    background: hsl(var(--status-warning-bg));
    color: var(--warning-text);
    font-size: 0.85rem;
}

/* It appears mid-deploy, above a log that is already scrolling. Popping
   into place beside moving text reads as part of the stream; the app's
   own entrance keyframe says "this is new" without moving anything the
   operator is reading. Covered by the blanket reduced-motion rule. */
.cdep-notice:not([hidden]) { animation: dm-fade-in 0.2s var(--ease-out); }

/* What broke, and which machines are running, after a deploy that
   stopped partway. Above the transcript because the transcript scrolls:
   these are the facts the operator has to carry away, and they were a
   log line.

   The card is NEUTRAL and only the headline strip is tinted. Colour is
   worth what it points at, and a block that is danger-red end to end
   points at itself — the cause it carries then reads at the same weight
   as the roll-call under it, which is the defect this replaces. */
.cdep-verdict {
    margin: 8px 0 0;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    /* The strip's tint would otherwise square off the top corners. */
    overflow: hidden;
}

.cdep-verdict-top {
    display: flex;
    align-items: flex-start;
    gap: 0.5rem;
    padding: 0.7rem 0.85rem;
    background: var(--danger-bg);
}

.cdep-verdict-icon {
    flex: none;
    display: flex;
    color: var(--danger-text);
    /* The glyph's box is taller than the headline's cap height, so it
       rides high against the first line without this. */
    margin-top: 1px;
}

.cdep-verdict-head {
    margin: 0;
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--text-primary);
    /* A two-line reason breaking after one word is a headline that
       looks broken rather than wrapped. */
    text-wrap: balance;
    /* A flex child will not shrink below its longest word without this,
       so the longest sentence in the table pushed the strip wider than
       the panel instead of wrapping inside it. */
    min-width: 0;
}

/* The particulars under the headline — which server, how much was
   needed, how much there was. Inside the neutral card, not the tinted
   strip: the strip carries WHAT broke, and a second tinted line under
   it makes two headlines out of one fact. Same weight and colour as a
   roll-call row, because it is the same kind of reading: detail you go
   to after the sentence, not instead of it. Wraps rather than
   scrolls — the sentence is written by the server and can be long. */
.cdep-verdict-detail {
    margin: 0;
    padding: 0.7rem 0.85rem 0;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-secondary);
    overflow-wrap: anywhere;
}

/* Together they are one block: the rows keep their own top padding, so
   without this the detail and the first row sit a full gap apart and
   read as two sections. */
.cdep-verdict-detail + .cdep-verdict-hosts { padding-top: 0.5rem; }

.cdep-verdict-hosts {
    margin: 0;
    padding: 0.7rem 0.85rem;
    list-style: none;
}

.cdep-verdict-hosts li {
    display: flex;
    align-items: baseline;
    gap: 0.6rem;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

/* A rule BETWEEN rows, not under each: a border on every row leaves one
   under the last, which reads as the block having been cut off. */
.cdep-verdict-hosts li + li {
    margin-top: 0.5rem;
    padding-top: 0.5rem;
    border-top: 1px solid var(--border-color);
}

.cdep-verdict-id {
    min-width: 6rem;
    font-family: var(--font-mono);
    color: var(--text-primary);
}

/* Same reason as the headline: it is the part that has to wrap. */
.cdep-verdict-why { min-width: 0; }

/* What the running step COUNTS, when it counts anything — today the
   healthcheck's `3 of 7 healthy`. Sits with the label rather than at
   the right margin: it is part of what the step is doing, while the
   clock beside it is how long that has taken. */
.cdep-step-note {
    font-size: 0.75rem;
    color: var(--text-muted);
    font-weight: 400;
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

/* Both recovery actions sit at the right margin of the step that broke,
   where the clock sits on the running step, so a reader never has to
   look in two places for them. */
.cdep-step-actions {
    margin-left: auto;
    flex: none;
    display: flex;
    align-items: center;
    gap: 6px;
}

/* ── the repair confirmation ───────────────────────────────────────
   Nothing is written to a customer's production server without this
   screen, so it is the one place in the panel that is allowed to be
   long. It takes the transcript's slot (see `.cdep-log-box[hidden]`)
   and therefore owns the single scroll. */
.cdep-fix {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    /* Both, for the same reason the log box needs both: a flex item's
       automatic minimum is its MIN-CONTENT, and a `<pre>` of shell
       commands makes that the longest line. */
    min-height: 0;
    min-width: 0;
    overflow-y: auto;
    gap: 0.5rem;
    /* The sticky actions bar sits ON this box, so the last thing in it
       could never be scrolled fully clear of the bar. Measured at
       1280x600, where the second outcome is below the fold: without
       this the operator scrolls to the end and the row is still half
       covered. */
    scroll-padding-bottom: 4rem;
}

.cdep-fix[hidden] { display: none; }

.cdep-fix-head {
    margin: 0;
    font-size: 1.15rem;
    font-weight: 600;
    color: var(--text-primary);
}

.cdep-fix-error { margin: 0; color: var(--danger-text); font-size: 0.85rem; }

/* ── the repair confirmation: one question, two named outcomes ──────
   The first version offered the STEPS as checkboxes, which asks "which
   commands shall I run" — not a question the operator can answer, and
   of the four states two checkboxes could express one was incoherent
   and the DEFAULT was the trap. These are outcomes now: a radio group,
   nothing pre-selected, each consequence stated on the option itself. */
.cdep-fix-choice {
    margin: 0;
    padding: 0;
    border: 0;
    /* A fieldset's automatic minimum is min-content, which no flex
       child can shrink below. */
    min-width: 0;
    min-inline-size: 0;
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.cdep-fix-choice legend {
    padding: 0;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-primary);
}

.cdep-fix-option {
    display: flex;
    flex-direction: column;
    gap: 0.15rem;
    /* Tight, because a two-option choice has to fit ON one screen: at
       1280x600 the second outcome is what falls below the fold, and a
       choice whose alternatives are not both visible is not presented
       as a choice. Every 8px here buys that back. */
    padding: 0.35rem 0.75rem 0.55rem;
    border: 1px solid var(--border-color);
    border-radius: 8px;
    background: var(--surface-sunken);
    min-width: 0;
}

/* The outcome that interrupts the server. Three signals, because the
   tint alone measured 1.08:1 against the panel — LESS distinct than an
   ordinary passive plate: a 3px rule in the warning FOREGROUND colour
   (5.13:1 light / 7.89:1 dark, so it clears the 3:1 non-text bar on its
   own), the alert glyph on the consequence line, and the tint third. */
.cdep-fix-option.is-disruptive {
    border-color: hsl(var(--status-warning-border));
    border-left: 3px solid var(--warning-text);
    background: hsl(var(--status-warning-bg));
}

.cdep-fix-pick {
    display: flex;
    align-items: center;
    gap: 8px;
    /* The whole row is the target (the `<label for>` covers it), so it
       carries the hit area: 40px tall against a 24px floor, where the
       checkbox row it replaces measured 20px. Not 44 — that is the
       touch figure, and every 4px here decides whether both outcomes
       fit on a 600px-tall window. */
    min-height: 40px;
    font-size: 0.9rem;
    color: var(--text-primary);
    cursor: pointer;
    min-width: 0;
}

.cdep-fix-pick input { flex: none; }

.cdep-fix-option-label {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
    min-width: 0;
    font-weight: 600;
}

/* What the outcome costs, or what it leaves undone — on the option
   itself, so the consequence of the choice being made is never filed
   under the one that was not. */
.cdep-fix-consequence {
    display: flex;
    align-items: flex-start;
    gap: 6px;
    /* Indented to the label's text, so it reads as belonging to that
       option rather than to the group. */
    margin: 0 0 0 24px;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.cdep-fix-option.is-disruptive .cdep-fix-consequence {
    color: var(--warning-text);
}

.cdep-fix-mark {
    flex: none;
    display: inline-flex;
    /* Optical: the triangle's baseline sits above the text's. */
    margin-top: 1px;
}

/* Closed by default. See `_renderDeployFix`. */
.cdep-fix-commands > summary {
    cursor: pointer;
    padding: 0.35rem 0;
    font-size: 0.85rem;
    color: var(--accent-text);
    /* The disclosure triangle is the UA's; no second one is invented. */
}

.cdep-fix-commands > summary:focus-visible {
    outline: 2px solid hsl(var(--ring));
    outline-offset: 2px;
    border-radius: var(--radius);
}

.cdep-fix-command {
    display: flex;
    flex-direction: column;
    gap: 0.3rem;
    padding: 0.5rem 0 0.75rem;
    min-width: 0;
}

.cdep-fix-command-head {
    margin: 0;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-primary);
}

/* Why this part exists, in one line. Inside the disclosure now: it
   explains a COMMAND, and the decision is made on the outcomes above. */
.cdep-fix-why {
    margin: 0;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

/* Both recovery actions sit at the right margin of the step that broke,
   where the clock sits on the running step, so a reader never has to
   look in two places for them. */
.cdep-step-actions {
    margin-left: auto;
    flex: none;
    display: flex;
    align-items: center;
    gap: 6px;
}

/* Exactly what will run, and exactly what the Copy control copies. It
   runs sideways rather than wrapping — a wrapped shell line reads as
   two commands — which is why it is focusable (axe:
   scrollable-region-focusable). */
.cdep-fix-script {
    margin: 0;
    padding: 0.5rem 0.65rem;
    border-radius: var(--radius);
    /* The panel's own card surface, so the block reads as a quoted
       artifact sitting ON the step rather than as another sunken well
       inside one. */
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    font-family: var(--font-mono);
    font-size: 0.75rem;
    color: var(--text-secondary);
    white-space: pre;
    overflow-x: auto;
    min-width: 0;
}

.cdep-fix-script:focus-visible {
    outline: 2px solid hsl(var(--ring));
    outline-offset: 2px;
}

/* The one repair that stops something this tool did not start shows WHO
   holds the port, verbatim, and asks for a tick before the button does
   anything (ADR 0001). The quoted line reuses `.cdep-fix-script`
   because it is the same kind of object — the server's own text,
   unwrapped — and only needs the spacing the disclosure gave it. */
.cdep-fix-head + .form-help + .cdep-fix-command-head {
    margin-top: 0.5rem;
}

/* …and it needs one thing the disclosure gave it for free: a
   NON-FLEX parent. The step scripts sit inside `<details>`, a block
   context; this one is a direct flex child of `.cdep-fix`, so the
   default `flex-shrink: 1` reclaims exactly the scrollbar row that
   `overflow-x: auto` reserved — measured at 390px, clientHeight 14
   against scrollHeight 31, cutting the single line of `ss` output
   through the middle. That line is the ONLY evidence the operator is
   asked to check before agreeing to stop somebody's process, so it
   does not get to shrink. */
#cdepFixHolder {
    flex-shrink: 0;
}

.cdep-fix-ack {
    display: flex;
    align-items: flex-start;
    gap: 8px;
    margin: 0.75rem 0 0;
    padding: 0.6rem 0.7rem;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background: var(--bg-secondary);
    font-size: 0.85rem;
    color: var(--text-primary);
    cursor: pointer;
}

.cdep-fix-ack input {
    /* Aligned to the first line of the sentence rather than centred on
       the whole block, which drifts once the sentence wraps at a narrow
       viewport. */
    margin-top: 0.15rem;
    flex: none;
    cursor: pointer;
}

/* STICKY, at the foot of the box that scrolls. The confirmation is the
   one long screen in this panel — two scripts and a cost paragraph —
   and with the actions in the normal flow the Apply button sat below
   the fold on a 720px window: a screen whose whole job is to be read
   before anything is written, with its only way forward invisible.
   Measured in a browser, not reasoned about.

   NOT copied to the recovery block (`.cdep-recovery`, then called
   `.cdep-fail-actions`), and that was tried and measured on 2026-09-02.
   Sticky held that row 20px clear of the panel's edge at every height —
   and at 620px the price was the bar floating OVER the retry, the one
   control the failed panel exists to offer. A bar may cover a transcript's disclosure; it may not cover
   the way out. What that row needed was the 31px the stale intro was
   holding (see "When a deploy fails"), and with those it is fully
   visible at 700 and 720 with nothing pinned. */
.cdep-fix-actions {
    position: sticky;
    bottom: 0;
    display: flex;
    gap: 8px;
    padding: 0.6rem 0 0.25rem;
    /* Opaque: the scripts scroll UNDER this bar. */
    background: var(--bg-secondary);
    border-top: 1px solid var(--border-color);
    /* macOS hides its scrollbars, so a clipped step below this bar
       reads as a rendering fault rather than as "there is more". The
       shadow is what says the content runs UNDER it. */
    box-shadow: var(--shadow-hover);
}

/* Run it again, ON the step that broke — which is where the operator
   is already looking, and the only step a retry could mean. Pushed to
   the right margin the way the clock is on the running step, so the two
   never sit in different places on one list.

   `btn-secondary`, not the solid fill: this modal's one primary is the
   deploy itself, and a retry is the second-best outcome of a run that
   already failed. `flex: none` because it must not be squeezed by a
   long stage label — a button whose words wrap reads as broken. */
.cdep-step-retry {
    flex: none;
}

.cdep-step.is-current svg {
    animation: cdep-step-pulse 1.8s ease-in-out infinite;
}

@keyframes cdep-step-pulse {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0.35; }
}

/* ── the deploy panel scrolls in exactly one place ────────────────────
   The modal body scrolls by default, and the log scrolled inside it —
   two scrollbars on one screen, and the body's one pushed the step
   checklist off the top, so the operator could not see how many steps
   were left. The panel is a flex column that fills the body instead:
   everything but the transcript keeps its natural height, and the
   transcript takes what is left and scrolls inside itself. */
.modal-body:has(.cdep-panel) {
    display: flex;
    /* `auto`, not `hidden`. The log absorbs all the slack, so on any
       ordinary window this never scrolls — but on a short one the
       fixed parts plus the log's floor can still exceed the body, and
       clipping them would hide the failure the operator came for. A
       scrollbar in that case is the lesser evil; it was the DEFAULT
       one that was wrong. */
    overflow: auto;
}

.cdep-panel {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    /* BOTH, and neither is optional: `min-height: 0` lets the column
       shrink past its content so the log can scroll instead of pushing
       the panel down, and `min-width: 0` stops the widest log line
       stretching the box past the modal — a flex item's automatic
       minimum size is its MIN-CONTENT, which for `white-space: pre` is
       the longest line. Measured: a 1081px line made a 900px modal hold
       a 1102px log, clipped rather than scrollable. */
    min-height: 0;
    min-width: 0;
    /* The column had NO rhythm: the container table, the row of ways
       out and the transcript's disclosure sat flush against each other,
       so three unrelated things read as one block — measured on a real
       failure panel, 2026-08-28. `12px` is `spacing.md` from DESIGN.md,
       not a number chosen by eye. A hidden child is `display: none`, so
       it is not a flex item and leaves no gap behind it. */
    gap: 12px;
}

/* The gap owns the spacing between the panel's own blocks. A child that
   also carries a margin gets both, which is the drift this rule exists
   to stop. */
.cdep-panel > .cdep-actions-row,
.cdep-panel > .cdep-recovery,
.cdep-panel > .cdep-explain { margin-top: 0; margin-bottom: 0; }

/* deploy.sh's output is written for a terminal. At the default 500px
   every second line wrapped, which breaks the column the `[host-1]`
   prefix creates — the same reason Live Test runs at `modal-xl`. The
   finished screen keeps the same transcript, so it needs the same room:
   the panel's `.cdep-panel` is gone by then and the rule would not
   otherwise apply. */
.modal:has(.cdep-panel),
.modal:has(.cdep-success) { max-width: 900px; }

/* Never shrink: these are the answer to "where are we". */
.cdep-panel > .form-help,
.cdep-panel > .cdep-steps-panel,
.cdep-panel > .cdep-notice,
.cdep-panel > .cdep-recovery,
.cdep-panel > .cdep-explain { flex: 0 0 auto; }

/* ── after a failure the STRETCHING area is the table, not the log ───
   The transcript collapses the moment a deploy fails, and if nothing
   claims the space it gave up the modal body starts scrolling — the
   trap this file already documents four ways. So while the transcript
   is closed the container table takes the slack and scrolls inside
   itself; the moment the operator opens the transcript BACK, the log is
   the one scrolling box again and this area returns to its natural
   height. Exactly one scrolling box, in both states.

   `:has()` rather than a class set by JS, the same pattern as
   `.modal:has(.cdep-panel)` above — nothing to leak onto the next
   modal, and no second place that has to be kept in step. */
.cdep-panel > .cdep-state { flex: 0 0 auto; min-height: 0; min-width: 0; }

/* A CEILING while the transcript is open, even though that means two
   scrolling boxes on this one screen. Measured on the real failure
   panel, 2026-08-28 at 1280x900: ten checklist rows, the verdict, two
   recovery buttons and a seven-row container table left the transcript
   on its 6rem floor — three lines of a log the operator had opened on
   purpose, with the rest of it below the modal and the body scrolled
   as far as it would go. The table is the block that grows with the
   installation, so it is the one that gets a limit. */
.cdep-panel > .cdep-state:not(:empty) {
    max-height: 34vh;
    overflow: auto;
}

.cdep-panel:has(.cdep-log-box.is-closed) > .cdep-state:not(:empty) {
    flex: 1 1 auto;
    /* A FLOOR, not zero — the same bargain the transcript's box makes
       twenty rules down, and it was missed here. `flex: 1 1 auto`
       against the panel's `min-height: 0` let this card shrink to
       NOTHING once the column was full: measured at 1280x700 with the
       checklist, the verdict and the two recovery buttons above it,
       height 0 against a scrollHeight of 75 — so the card's own text
       painted over the controls below it and its border was left as a
       stray hairline above `Close`. Reported from a real panel,
       2026-09-02.

       3rem is the head and its `Check state` button, and NOT the card's
       full 75px, which is the trade this panel cannot buy out of.
       Measured at 1280x720 with nine steps and the stale intro already
       hidden: a 50px floor is the largest that keeps the modal body
       unscrolled, and 58 / 66 / 75 overflow it by 8 / 16 / 25px. The body not scrolling is the older
       invariant and the one the checklist depends on, so the card keeps
       its head and its action and the line under it scrolls INSIDE the
       card — a line that says "Container state has not been collected
       yet" beside a button that says `Check state`. Wherever there is
       room the card grows to its full height and shows it (75.2px
       measured at 1100).

       And "unscrolled" is a NINE-step claim, which is what the e2e
       pins: nine fit exactly (scrollHeight 515 against a clientHeight
       of 515, zero headroom) and the ten a real run produces overflow
       by 14px, at which point the body scrolls — which is allowed, and
       is the last resort documented under "The panel scrolls in exactly
       one place". So this floor is not what keeps the body still; it is
       the largest one that does not make the overflow worse. */
    min-height: 3rem;
    max-height: none;
    overflow: auto;
}

.cdep-state:empty { display: none; }

/* The failure REPLACES the transcript, and that is the one purpose
   motion serves on this panel: preventing a jarring change. Enter, so
   ease-out; transform and opacity only; well under 300ms.

   `display` is in the list because the area is `display: none` while it
   is empty — without `allow-discrete` the box is simply there and the
   fade never runs. The global reduced-motion block narrows the property
   list to the non-moving half, so with the OS setting on it appears
   plainly, which is the intended degradation and needs no second
   guard. */
.cdep-state {
    opacity: 1;
    transform: none;
    transition: opacity 180ms var(--ease-out),
                transform 180ms var(--ease-out),
                display 180ms allow-discrete;
}

@starting-style {
    .cdep-state:not(:empty) {
        opacity: 0;
        transform: translateY(4px);
    }
}

/* ── the run that went green: a screen, not a line ────────────────────
   Same shape as cicd-manager's `Build Released` (`.qa-success`): one
   drawn mark, one sentence, then the detail. It REPLACES the panel,
   because the panel is a progress view and the run has stopped
   progressing — and because a checklist that was already green cannot
   say "this is finished" by staying green. */
.cdep-success {
    text-align: center;
    padding: 4px 0 2px;
    display: flex;
    flex-direction: column;
    /* The modal body is a flex column that this fills; the detail below
       takes the slack so the transcript inside it still scrolls in one
       place, exactly as it did in the panel. */
    flex: 1 1 auto;
    min-height: 0;
}

/* Two strokes, drawn in order: the circle, then the tick inside it.
   Enter, so ease-out; well under 300ms each; the global
   reduced-motion rule collapses both to their finished state, and the
   mark still reads. */
.cdep-success-check {
    display: block;
    width: 60px;
    height: 60px;
    margin: 0 auto 18px;
    flex: none;
    animation: cdep-check-pop 240ms var(--ease-out) both;
}

.cdep-success-check circle {
    fill: none;
    stroke: var(--success-text);
    stroke-width: 2.5;
    stroke-dasharray: 151;
    stroke-dashoffset: 151;
    animation: cdep-check-draw 280ms var(--ease-out) forwards;
}

.cdep-success-check path {
    fill: none;
    stroke: var(--success-text);
    stroke-width: 3;
    stroke-linecap: round;
    stroke-linejoin: round;
    stroke-dasharray: 40;
    stroke-dashoffset: 40;
    animation: cdep-check-draw 220ms var(--ease-out) 240ms forwards;
}

.cdep-success-head {
    margin: 0;
    flex: none;
    font-size: 1.15rem;
    font-weight: 600;
    color: var(--text-primary);
}

.cdep-success-facts {
    margin: 4px 0 0;
    flex: none;
    font-family: var(--font-mono);
    font-size: 0.85rem;
    color: var(--text-muted);
}

/* The one line that changes what the operator does next. */
.cdep-success-claim {
    margin: 12px auto 0;
    flex: none;
    max-width: 46ch;
    font-size: 0.85rem;
    color: var(--success-text);
}

/* What ran, and what it printed — the two things carried over from the
   panel rather than rebuilt. Left-aligned: they are read, not
   announced. */
.cdep-success-detail {
    display: flex;
    flex-direction: column;
    gap: 12px;
    margin-top: 18px;
    text-align: left;
    flex: 1 1 auto;
    min-height: 0;
    min-width: 0;
}

.cdep-success-detail > .cdep-steps-panel { flex: 0 0 auto; }

/* `step 10 of 10` is a progress report, and nothing is in progress. The
   run's total is already stated above in the facts line; what the head
   still usefully carries is which machine this was. */
.cdep-success .cdep-steps-count,
.cdep-success .cdep-steps-total { display: none; }

@keyframes cdep-check-pop {
    from { transform: scale(0.6); opacity: 0; }
    to   { transform: scale(1); opacity: 1; }
}

@keyframes cdep-check-draw {
    to { stroke-dashoffset: 0; }
}

.cstate {
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    padding: 0.5rem 0.65rem 0.65rem;
    background: var(--bg-secondary);
}

.cstate-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.75rem;
    /* The longest realistic service name plus the longest state string
       at 1024px is what decides this: the heading wraps, the button
       does not shrink into an ellipsis. */
    flex-wrap: wrap;
    margin-bottom: 0.45rem;
}

.cstate-title {
    margin: 0;
    font-size: 0.9rem;
    font-weight: 600;
    /* A `qa11m-online-host-1` heading must be allowed to wrap rather
       than push the button off the row. */
    min-width: 0;
    overflow-wrap: anywhere;
}

.cstate-line {
    margin: 0.25rem 0;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.cstate-error { color: var(--danger-text); }

.cstate-stamp {
    margin: 0 0 0.4rem;
    font-size: 0.85rem;
    color: var(--text-muted);
}

/* NOT dimmed. The plan asked for `opacity: .55` on a stale table, and
   the plan's own contrast rule (4.5:1 for text, both themes) refuses
   it: computed, the worst pair falls to 2.30:1 in the light theme and
   even `.8` only reaches 3.61:1 — see the "the stale table is not
   dimmed" test in design-tokens.test.js, which pins the numbers so
   nobody puts it back by eye. The data is the last thing anybody knows
   about that server; the staleness is carried by the WORD above it,
   which is the stronger of the two signals anyway. */

.cstate-skeleton {
    display: flex;
    flex-direction: column;
    gap: 4px;
    margin-top: 0.4rem;
}

.cstate-skeleton span {
    height: 0.9rem;
    border-radius: var(--radius);
    background: var(--surface-sunken);
}

/* The disclosure. A BUTTON, never `<details>`: a details' children live
   in an internal slot box that is not a flex item, measured here at
   2555px inside a 143px box. */
.cstate-expand { width: 2.25rem; text-align: right; }

.cstate-toggle {
    border: 0;
    background: none;
    cursor: pointer;
    /* The glyph is a 5px CSS triangle; the TARGET is this. A 5px hit
       area is a control only a mouse with a steady hand can use. */
    padding: 6px 8px;
    min-width: 24px;
    min-height: 24px;
    color: var(--text-secondary);
    line-height: 1;
}

.cstate-toggle::before {
    content: '';
    display: inline-block;
    width: 0;
    height: 0;
    border-left: 5px solid currentColor;
    border-top: 4px solid transparent;
    border-bottom: 4px solid transparent;
    transition: transform 180ms var(--ease-out);
}

.cstate-toggle[aria-expanded="true"]::before { transform: rotate(90deg); }

.cstate-toggle:focus-visible {
    outline: 2px solid hsl(var(--ring));
    outline-offset: 2px;
    border-radius: var(--radius);
}

.cstate-detail-row > td { padding: 0.35rem 0.4rem 0.6rem; }

/* Height is NOT animated. A hundred lines of a container's health log
   is ~400px, and animating that inside a scrolling area drags the
   content under the operator's cursor twice. The block fades and
   settles instead — under 300ms, transform + opacity only, and the
   global reduced-motion block above narrows `transition-property` to
   the non-moving half, so the fade survives and the slide does not.

   A `transition` rather than the `@keyframes` this used to be: probes
   arrive while the row is still opening, and a keyframe restarts from
   zero on every one of them while a transition retargets. */
.cstate-detail {
    opacity: 1;
    transform: none;
    transition: opacity 160ms var(--ease-out),
                transform 160ms var(--ease-out);
}

.cstate-output {
    margin: 0;
    padding: 0.5rem;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    font-family: var(--font-mono);
    font-size: 0.78rem;
    /* Wraps rather than scrolling sideways: a second scrolling box
       inside the one scrolling area is the trap, and this output is
       fifteen short lines, not a terminal transcript. */
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

.cstate-cmd {
    margin: 0.4rem 0 0;
    font-size: 0.78rem;
    color: var(--text-muted);
}

.cstate-cmd-label {
    display: block;
    text-transform: uppercase;
    letter-spacing: 0.04em;
    font-size: 0.75rem;
    margin-bottom: 2px;
}

.cstate-cmd code {
    font-family: var(--font-mono);
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

/* The row's own actions, and the confirmation that stands in front of
   the one that writes. Both live inside `.cstate-detail`, which already
   owns the entrance animation — nothing here moves. */
.cstate-actions {
    display: flex;
    align-items: center;
    gap: 0.5rem;
    flex-wrap: wrap;
    margin-top: 0.5rem;
}

.cstate-confirm {
    padding: 0.6rem;
    border: 1px solid var(--border-color);
    /* `--warning-text`, not `--warning`: the fill token is tuned to
       carry ink ON it, and measured against this plate it comes to
       2.39:1 in light theme — a 3px rule nobody can see. The text token
       clears the 3:1 non-text bar in both themes. */
    border-left: 3px solid var(--warning-text);
    border-radius: var(--radius);
    background: var(--surface-sunken);
}

/* The confirmation REPLACES the action row, so it arrives the same way
   the other two regions do. Its own rule rather than one on
   `.cstate-confirm`: the restart confirmation shares that plate and is
   not a replacement of anything. */
.cdep-rollback-confirm {
    opacity: 1;
    transform: none;
    transition: opacity 160ms var(--ease-out),
                transform 160ms var(--ease-out);
}

@starting-style {
    .cstate-detail,
    .cdep-rollback-confirm {
        opacity: 0;
        transform: translateY(-4px);
    }
}

.cstate-confirm-title {
    margin: 0 0 0.35rem;
    font-size: 0.9rem;
}

/* The heading takes focus when the confirmation opens, so the ring has
   to be visible on it — a `tabindex="-1"` heading is focusable and by
   default draws nothing. */
.cstate-confirm-title:focus-visible {
    outline: none;
    border-radius: var(--radius);
    box-shadow: var(--focus-ring);
}

.cstate-confirm code {
    font-family: var(--font-mono);
    font-size: 0.95em;
}

/* ── the ways out, as ONE block ──────────────────────────────────────

   This replaces `.cdep-fail-actions` and, with it, the arrangement that
   put the recovery controls in four boxes at three weights. It is a
   single column: the heading, the one filled button with its own line
   of explanation, the quiet row of alternatives, and the standing
   consent sentence. See `_composeStepActionRow` for why.

   No surface of its own — no border, no fill. It sits inside the
   panel, which is already a surface, and a box inside a box is the
   look this system rejects (`design-system.md`). What separates it
   from the checklist above and the state card below is the panel's own
   `gap: 12px` rhythm, exactly as the two blocks it replaces were
   separated. */
.cdep-recovery {
    display: flex;
    flex-direction: column;
    /* `spacing.sm`. The heading, the lead and the row are ONE group and
       have to read as closer to each other than the block is to its
       neighbours (12px) — proximity is what says they belong
       together. */
    gap: 8px;
}

/* The heading is a label for a group of controls, not a section title:
   it must not out-weigh the verdict directly above it, which is the
   news. `.85rem` is the same step `.form-help` and `.hint` sit on, and
   the semibold + primary ink is what separates it from them. */
.cdep-recovery-head {
    margin: 0;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-primary);
}

/* The lead and its sentence, stacked. `align-items: flex-start` so the
   button keeps its own width instead of stretching to the column. */
.cdep-recovery-lead {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;                       /* `spacing.xs` — a label to its control */
}

/* Under the button it explains, and bounded. `.form-help` already
   carries the muted .85rem and `margin: 0`; this only stops the
   sentence running the full 900px of the modal, which puts it well past
   the 45-90 character reading band. */
.cdep-recovery-why { max-width: 62ch; }

/* Every alternative at ONE weight, wrapping. `align-items: center` so
   the `Or:` label sits on the button baseline rather than at the top of
   the row. */
.cdep-recovery-more {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px;
}

/* The row's one consent sentence, under everything. Same bound as the
   lead's line, and for the same reason. */
.cdep-recovery > .form-help { max-width: 62ch; }

/* Closing the deploy window asks one question, and it is asked IN the
   footer, in place of `Close`. As a plate in the body it was a second
   block of information with its own pair of buttons while the `Close`
   that raised it stayed on screen below — two ways out of one window,
   and the answer a mouse-journey away from the press that asked for it.
   `display: none` on the footer's own controls rather than `[hidden]`:
   `.btn` declares its own `display` and would win. */
.modal-footer.is-asking > :not(.cdep-close-ask) { display: none; }

.cdep-close-ask {
    flex: 1 1 100%;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: 16px;
    opacity: 1;
    transform: none;
    /* Exact properties, ease-out, and the same 160ms the repair
       confirmation arrives on. The global reduced-motion rule drops
       `transform` from the transition, so it lands without travel. */
    transition: opacity 160ms var(--ease-out),
                transform 160ms var(--ease-out);
}

@starting-style {
    .cdep-close-ask { opacity: 0; transform: translateY(4px); }
}

.cdep-close-ask-msg {
    flex: 1 1 320px;
    min-width: 0;
    display: flex;
    align-items: flex-start;
    gap: 0.5rem;
}

/* The triangle, in the warning ink. `--warning-text`, not `--warning`:
   the fill token is tuned to carry ink ON it and comes to 2.39:1 here,
   which is a stroke nobody can see. */
.cdep-close-ask-icon {
    flex: none;
    display: flex;
    margin-top: 1px;
    color: var(--warning-text);
}

.cdep-close-ask-text {
    margin: 0;
    font-size: 0.85rem;     /* the `.form-help` step it replaces */
    line-height: 1.45;
    color: var(--text-secondary);
}

.cdep-close-ask-text b {
    color: var(--text-primary);
    font-weight: 600;
}

/* Right-aligned even once the row wraps, where `space-between` would
   otherwise drop them at the left edge of the second line. */
.cdep-close-ask-actions {
    flex: none;
    display: flex;
    gap: 12px;              /* the footer's own gap between buttons */
    margin-left: auto;
}

/* `.cdep-explain` used to be the whole block at the bottom of the log;
   it is now a wrapper around one button INSIDE the recovery ladder, so
   the gap that separated it from the transcript would push it off the
   row's baseline. It has to be a flex item like the bare buttons beside
   it, or the span it is would sit on the text baseline instead of
   theirs. */
.cdep-recovery .cdep-explain { margin-top: 0; display: inline-flex; }

/* The two recovery actions, in a row of their OWN under the checklist.
   They used to sit at the right margin of the failed step — and after a
   repair there is no failed step left to sit on, so the button that
   says "run it again" went with the thing that broke. Left-aligned
   under the list, because they are now about the SERVER, not about one
   line of it. */
.cdep-actions-row {
    display: flex;
    align-items: center;
    gap: 6px;
    flex-wrap: wrap;
    margin-top: 0.4rem;
}

.cdep-log-box {
    display: flex;
    flex-direction: column;
    flex: 1 1 auto;
    /* A FLOOR, not zero. With `min-height: 0` the panel squeezed this
       box to nothing on a short window and the transcript was simply
       gone — worse than the scrollbar it was avoiding. The floor makes
       the panel taller than the body instead, and the body (which
       scrolls only as a last resort) shows the rest.

       Raised from 6rem: at that floor an opened transcript showed
       THREE lines under a failure — the disclosure said "Installation
       log" and then gave the operator a porthole. 12rem is eight lines
       even at this app's root font size — a paragraph of a deploy
       rather than a hint of one. */
    min-height: 12rem;
    min-width: 0;
    /* A squeezed box let the `<pre>` paint outside it and over the
       "Explain this failure" control below — measured on a 577px-tall
       window. */
    overflow: hidden;
}

/* Put away: the box stops claiming the space, and the checklist above
   it has the whole panel. */
/* The repair confirmation REPLACES the transcript while it is open, so
   this panel still scrolls in exactly one place. A class selector
   out-specifies the UA's `[hidden] { display: none }`, and this box sets
   `display: flex` — without the opt-out the log stays on screen under
   the confirmation. Same trap as `.pl-flag[hidden]` in the wizard. */
.cdep-log-box[hidden] { display: none; }

.cdep-log-box.is-closed { flex: 0 0 auto; min-height: 0; }
.cdep-log-box.is-closed .compose-deploy-log { display: none; }

.cdep-log-summary {
    align-self: flex-start;
    display: flex;
    align-items: center;
    gap: 6px;
    border: 0;
    background: none;
    cursor: pointer;
    padding: 4px 0;
    font-family: inherit;
    font-size: 0.85rem;
    font-weight: 500;
    color: var(--text-secondary);
}

/* The disclosure mark, so open/closed is a SHAPE and not only a
   layout change somebody has to notice. */
.cdep-log-summary::before {
    content: '';
    width: 0;
    height: 0;
    border-left: 5px solid currentColor;
    border-top: 4px solid transparent;
    border-bottom: 4px solid transparent;
    transform: rotate(90deg);
    transition: transform 150ms var(--ease-out);
}

.cdep-log-box.is-closed .cdep-log-summary::before {
    transform: rotate(0deg);
}

.cdep-log-summary:focus-visible {
    outline: 2px solid hsl(var(--ring));
    outline-offset: 2px;
    border-radius: var(--radius);
}

.compose-deploy-log {
    flex: 1 1 auto;
    min-width: 0;
    /* Enough to be a log even when a nine-step checklist is above it;
       the flex line decides the rest. Low enough that an ordinary
       window never has to fall back to scrolling the modal body. */
    min-height: 6rem;
    overflow: auto;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    padding: 0.75rem;
    font-family: var(--font-mono);
    font-size: 0.82rem;
    /* Not `pre-wrap`: a wrapped line breaks the column the `[host-1]`
       prefix creates, and the continuation reads as a new line. It
       scrolls sideways instead — which is why the box is focusable. */
    white-space: pre;
}

/* ...but a REFUSAL is not server output. `white-space: pre` above is
   right for what the customer's machine printed — the `[host-1]` prefix
   makes a column, and wrapping it turns a continuation into what looks
   like a new line. The error tone is this app's own sentences: a verdict
   and, on a refusal, the next action. Measured in a browser at 1280x600:
   "no image version chosen for: <six services> — Open the installation
   and set an image version..." was cut at "— O", so the half that says
   what to DO was behind a sideways scroll inside a log box. A sentence
   the operator is meant to act on may not be the thing they have to go
   looking for. `anywhere` because an install path has no spaces to break
   at. */
.cdep-log-error {
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

/* The control that hands this failure to the assistant. It sat flush
   against the log pane, which reads as part of the pane rather than as
   an action on it. `[hidden]` needs its own opt-out because a class
   selector setting `display` out-specifies the UA's `[hidden]` rule —
   without it the button shows on every deploy, failed or not. */
.cdep-explain {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
    margin-top: 12px;
}

.cdep-explain[hidden] { display: none; }

/* It arrives at the bottom of a log the operator has been watching scroll,
   at the moment a deploy failed — the one place on this screen where
   something new appearing must not read as another log line. Authored as an
   animation, not a transition + @starting-style: the box is toggled through
   `hidden`, so it re-enters from `display: none` and the keyframe replays by
   itself, and the global reduced-motion collapse reaches an animation. */
.cdep-explain:not([hidden]) { animation: dm-fade-in 0.2s var(--ease-out); }

.cdep-log-line    { padding: 0.05rem 0; }
.cdep-log-header  { color: var(--accent-text); font-weight: 600; margin-top: 0.4rem; }
.cdep-log-info    { color: var(--text-muted); }
.cdep-log-stdout  { color: var(--text-primary); }
.cdep-log-stderr  { color: var(--warning-text); }
.cdep-log-success { color: var(--success-text); font-weight: 600; }
.cdep-log-error   { color: var(--danger-text); font-weight: 600; }

.cdep-log-success::before { content: '✓ '; }
.cdep-log-error::before   { content: '✕ '; }

/* ============================================================
   26. Health dashboard
   Polling replaces these rows wholesale — no transitions here either.

   The dialog is 1000px, through the same `:has()` pattern the deploy
   panel uses (no class added by JS, so none can leak onto the next
   modal). At the shared 500px the four-column table broke its own
   headers in half — "STA TUS", "LATE NCY" — while a container name and
   a probe URL are single unbreakable words.
   ============================================================ */
.modal:has(.health-body) { max-width: 1000px; }

.health-body { display: flex; flex-direction: column; gap: 16px; }

.health-section { display: flex; flex-direction: column; gap: 8px; }

/* The heading owns its section's rhythm through the gap above, so its
   own stacking margins would double it. */
.health-section > .form-group-title,
.health-section-head > .form-group-title { margin: 0; }

.health-section-head {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
}

/* The verdict plate. A row, so the tone glyph is the first item on the
   line rather than a paragraph of its own, the collection time can be
   pushed to the far end, and the counts wrap onto a line of their own. */
.health-summary {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px 8px;
    padding: 12px 16px;
    border-radius: var(--radius);
    border-left: 4px solid var(--text-muted);
    background: var(--surface-sunken);
    font-size: 0.95rem;
}

.health-summary strong { font-size: 1.15rem; font-weight: 600; }

.health-summary::before {
    content: '○';
    color: var(--text-muted);
}

.health-summary-ok  { border-left-color: var(--success); }
.health-summary-ok::before { content: '✓'; color: var(--success-text); }

.health-summary-bad { border-left-color: var(--danger); }
.health-summary-bad::before { content: '✕'; color: var(--danger-text); }

/* When this photograph of somebody's servers was taken. Without it the
   dialog reads as live for as long as it stays open. */
.health-checked {
    margin-left: auto;
    font-size: 0.85rem;
    color: var(--text-muted);
}

.health-metrics {
    flex-basis: 100%;
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
}

/* One column, full width. Two columns of a four-column table inside a
   1000px dialog is the 500px problem again, one level down; an
   installation has a handful of servers, not a wall of them. */
.health-server-grid {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.health-server-card {
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    /* 8px inside the modal's 14px — a plate inside a plate steps down. */
    border-radius: var(--radius);
    padding: 12px;
}

.health-server-down { border-color: var(--danger); }

.health-server-head {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
    margin-bottom: 8px;
}

.health-server-id {
    display: flex;
    align-items: baseline;
    gap: 8px;
    flex-wrap: wrap;
    min-width: 0;
}

.health-server-id strong { font-size: 0.95rem; font-weight: 600; }

.health-server-fqdn {
    font-family: var(--font-mono);
    font-size: 0.85rem;
    color: var(--text-muted);
    overflow-wrap: anywhere;
}

/* Pushed to the far end, away from the name they could be read as part
   of. One of them re-runs an installer on a customer's machine. */
.health-server-actions {
    display: flex;
    gap: 8px;
    margin-left: auto;
}

.health-error { color: var(--danger-text); }
.health-error::before { content: '✕ '; }

.health-container-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 0.85rem;
}

.health-container-table th,
.health-container-table td {
    text-align: left;
    padding: 6px 8px;
    border-bottom: 1px solid var(--border-color);
    vertical-align: top;
    /* A container name has no spaces in it —
       `qa11m-online_ui-backend-secondary_1` is one word to the layout —
       and neither has a probe URL. */
    overflow-wrap: anywhere;
}

.health-container-table tbody tr:last-child > td { border-bottom: none; }

.health-container-table th {
    color: var(--text-muted);
    font-weight: 600;
    text-transform: uppercase;
    font-size: 0.75rem;
    letter-spacing: 0.04em;
}

/* The state column is a pill, so it takes what it needs and no more. */
.health-container-table .hct-state {
    width: 1%;
    white-space: nowrap;
}

.hct-service { font-weight: 600; }
.hct-status { color: var(--text-secondary); }

/* Machine text — an operator pastes this into `docker logs`. It inherits
   the table's size; a second declaration here would only drift from it. */
.hct-container {
    display: block;
    font-family: var(--font-mono);
}

/* Which image is actually running. Muted, because it is only read when
   the version somebody was told about and the tag on the machine
   disagree. */
.hct-image {
    display: block;
    font-family: var(--font-mono);
    font-size: 0.75rem;
    color: var(--text-muted);
}

/* The row keeps its state class — the pill carries the state in a glyph
   and a word, and this is a third signal for scanning a long list, NOT a
   recolouring of the row's text. Colouring every cell made a container
   name unreadable on exactly the row somebody needs to copy it from. */
.health-container-table tr.health-bad  > td:first-child {
    box-shadow: inset 3px 0 0 var(--danger);
}
.health-container-table tr.health-warn > td:first-child {
    box-shadow: inset 3px 0 0 var(--warning);
}

/* Probes: one table for the HTTP checks and the browser check alike. */
/* The address is a link now — the honest way out for a site this app
   cannot reach is "open it from a machine that can" — and an unstyled
   <a> takes the UA's own blue, which measured 1.34:1 on the dark panel.
   `--accent-text` is the token for accent-coloured TEXT and is themed. */
.hpt-name {
    display: block;
    font-weight: 600;
    color: var(--accent-text);
    text-decoration: none;
    overflow-wrap: anywhere;
}

a.hpt-name:hover,
a.hpt-name:focus-visible {
    text-decoration: underline;
}

.hpt-url {
    display: block;
    font-family: var(--font-mono);
    font-size: 0.75rem;
    color: var(--text-muted);
    overflow-wrap: anywhere;
}

.hpt-failure {
    display: block;
    margin-top: 4px;
    color: var(--danger-text);
}
.hpt-failure::before { content: '✕ '; }

/* The answer, hugging the right edge: it is the shortest cell and the
   one compared down the column. */
.health-probe-table th:last-child,
.hpt-reply {
    width: 1%;
    white-space: nowrap;
    text-align: right;
    font-variant-numeric: tabular-nums;
}

.health-pw-trigger {
    display: flex;
    justify-content: flex-end;
}

/* ============================================================
   27. Live Test panel
   ============================================================ */
.lab-meta {
    display: grid;
    grid-template-columns: max-content 1fr;
    gap: 4px 12px;
    font-size: 0.85rem;
    margin: 8px 0;
    /* Three short facts do not need 1342px of card. */
    max-width: 68ch;
}

.lab-meta dt {
    color: var(--text-muted);
    text-transform: uppercase;
    /* 0.75rem is the ramp's pill step; 0.72 is not on it. */
    font-size: 0.75rem;
    letter-spacing: 0.04em;
    align-self: center;
}

.lab-meta dd { margin: 0; color: var(--text-primary); }

/* The age, beside the clock time and quieter than it. Which run this is
   is answered by "2 h ago"; what to quote into a ticket is the date. */
.lab-meta-ago { color: var(--text-secondary); }

.lab-meta code,
.lab-plan code {
    font-family: var(--font-mono);
    font-size: 0.85em;
    background: var(--surface-sunken);
    padding: 1px 6px;
    border-radius: 4px;
}

.lab-plan { margin: 8px 0; }
.lab-plan li { margin-bottom: 4px; }

/* The xterm mount. Its height + overflow live inline (FitAddon needs a
   deterministic geometry before the terminal exists) — only the colour
   belongs here. No transitions: the terminal repaints continuously. */
.lab-terminal {
    background: var(--code-bg);
    border-radius: var(--radius);
    border: 1px solid var(--border-color);
    /* CONTENT-BOX, AND THIS IS WHY THE LAST ROW WAS CUT IN HALF.
       FitAddon divides `getComputedStyle(parent).height` by the cell
       height to get its row count — and that resolved value is reported
       in the element's OWN box-sizing, so under the app's `* {
       box-sizing: border-box }` reset it handed back the 1px border and
       the 4px padding as if they were usable space. Measured on the
       running panel, 2026-08-31: box 420px, usable 410px, and xterm
       laid out 30 rows totalling 420px inside `overflow: hidden` — a
       10px slice of the row the operator was reading, which on a live
       shell was the prompt deploy.sh was waiting on.
       Content-box makes the number FitAddon reads the number it means.
       `_labFitNow` re-checks the rendered result anyway, because a
       library computing our geometry from a property whose meaning
       depends on our reset is a bargain worth verifying. */
    box-sizing: content-box;
}

.lab-stream-mirror {
    font-family: var(--font-mono);
    font-size: 0.85rem;
}

/* ── The Test install run view ─────────────────────────────────
   A pane inside the compose tab, beside the list and the creation
   wizard — not a modal. A run lives for hours, survives a reload and
   should be shareable by link, and this app has ONE overlay, so any
   dialog opened meanwhile used to overwrite it.

   REDESIGNED 2026-08-31, against a live panel. What the operator
   reported was "the design is awful, a wall of text, everything
   floats, nothing is clear" — and each of those named a real,
   measurable defect rather than a taste:

     - 180 words stood between opening the screen and the one button on
       it, running the FULL card width — about 150 characters per line
       at 1440px, against the 45-90 band anything prose-shaped is read
       in. Every fact that should have been a row was a sentence.
     - The header was one flex row of five things at five heights under
       `align-items: center`, so no two elements shared a baseline.
       That is the "floats".
     - NINE glyphs across two vocabularies (`✓ ▸ ○ ✗` on steps,
       `✓ ✗ ◐ ? ·` on rows) in the mono face at text weight, two of
       which read as something else (`·` a bullet, `?` a question to
       the reader). The eye cannot group nine symbols into four
       meanings, so the status column had to be read rather than seen.
     - All four steps rendered their bodies at once, whichever step the
       run was on.
     - A fixed 340px black terminal, empty, on a run that had not
       reached the install — the largest element on the screen, saying
       nothing.

   The answers, in order: one lead line plus disclosures; a two-row
   header on shared baselines; ONE mark component with five states; one
   step open at a time; a terminal sized by state.

   Every gap here is a token from DESIGN.md's spacing scale (xxs 4 /
   xs 6 / sm 8 / md 12 / lg 16 / xl 20 / xxl 24 / section 28). */

/* ── Header ────────────────────────────────────────────────────
   The app's own `.card-header` shape — name left, controls right, a
   rule under both — because `#composeRunView` IS a `.card` and the
   creation wizard next door opens with exactly this pair. Every fact
   ABOUT the run is one muted line under the name, in one size, on one
   baseline. */

.run-head {
    position: sticky;
    top: var(--nav-h, 0px);
    z-index: 2;
    background: var(--bg-secondary);
    border-bottom: 1px solid var(--border-color);
    padding-bottom: 16px;
    margin-bottom: 20px;
}

/* The way out, leading, on its own line — a breadcrumb rather than a
   trailing action. It shared the title row with `Delete test servers`,
   which put a NAVIGATION control in a row with the one control on this
   screen that destroys servers, and left the header with four different
   left edges inside 50px (measured: back 49 / title 93 / pip 99 /
   started-with 49). On its own line the crumb, the title, the meta and
   the disclosure all start at the pane's edge, which is where every
   other page title in this app starts. */
/* THE UTILITY ROW: where you came FROM at the leading edge, the one
   run-level destructive action at the trailing edge, one size and one
   baseline. Back used to sit alone above the title while Delete floated
   on the title row at a different size — two controls, two rows, two
   sizes, no grouping, which is what read as thrown together. A row with
   two ends is a row; a single button above a title is an orphan.

   Both are `btn-small`: same height, same type step, so the row has one
   baseline rather than two. */
.run-crumb {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    flex-wrap: wrap;
    margin: 0 0 16px;
}

.run-head-main {
    display: flex;
    /* `flex-start`, not `center`: the left column is two lines and the
       right one is a row of buttons, so centring would hang the actions
       off the middle of the title block and put a third baseline on a
       screen that just lost two. */
    align-items: flex-start;
    gap: 12px;
    flex-wrap: wrap;
}

/* The icon back button is gone. An arrow with no word is chrome
   reasoning applied to the ONLY exit a full-screen pane offers, and it
   made this the one card in the tab that did not use
   `← Back to list`. The house control says where it goes. */

.run-head-id {
    flex: 1 1 240px;
    min-width: 0;
}

.run-head .run-title {
    margin: 0;
    /* 1.15rem is the ramp's step; 1.1 is not on it. */
    font-size: 1.15rem;
    line-height: 1.3;
    /* It WRAPS, and it did not: the title carries the screen's name
       and the installation's, so at 420px an ellipsis truncated the
       one word that says which installation this is. `balance` keeps
       the two lines even; `anywhere` is what lets a long single-token
       name break at all. */
    text-wrap: balance;
    overflow-wrap: anywhere;
}

/* The facts line. One size, one colour, one baseline — the whole point
   of the split. `align-items: center` is correct HERE and was wrong on
   the old row: every child is a single line of the same size, so
   centring and baselining agree, and the status dot needs centring. */
.run-meta {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px;
    margin: 4px 0 0;
    font-size: 0.85rem;
    line-height: 1.4;
    color: var(--text-secondary);
}

.run-status {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-weight: 500;
    color: var(--text-primary);
}

/* A DOT plus the word, not a filled pill. The pill was the tallest box
   on the old row — which is what pushed every other baseline off — and
   a second badge vocabulary buys nothing the word does not already
   say. Never colour alone: the word IS the state. */
.run-dot {
    width: 7px;
    height: 7px;
    border-radius: 999px;
    background: var(--text-muted);
    flex: 0 0 auto;
}

.run-status.is-success { color: var(--success-text); }
.run-status.is-success .run-dot { background: var(--success-text); }
.run-status.is-danger { color: var(--danger-text); }
.run-status.is-danger .run-dot { background: var(--danger-text); }
.run-status.is-info { color: var(--accent-text); }
.run-status.is-info .run-dot { background: var(--accent-text); }

/* An install in flight breathes, so a screen left open for an hour
   says at a glance whether it is still working. Opacity only — it is a
   7px dot, so a scale would be sub-pixel — and the blanket
   reduced-motion rule collapses it. */
.run-status.is-info .run-dot {
    animation: run-pulse 1.8s ease-in-out infinite;
}

@keyframes run-pulse {
    0%, 100% { opacity: 1; }
    50% { opacity: 0.35; }
}

.run-sep { color: var(--border-color); }

.run-id {
    font-family: var(--font-mono);
    font-size: 0.85rem;
    color: var(--text-secondary);
}

/* A clock that ticks must not reflow the line beside it, and
   `3 h 19 min` must not break across two of them. */
.run-elapsed {
    font-variant-numeric: tabular-nums;
    white-space: nowrap;
}

.run-head-actions {
    display: flex;
    align-items: center;
    gap: 8px;
    flex: 0 0 auto;
    margin-left: auto;
}

/* The promise that used to live in a close guard: leaving is not
   cancelling. Standing, for as long as the work is — a dialog could
   only say it on the way out. */
.run-standing {
    margin: 10px 0 0;
}


/* One disclosure vocabulary on this screen: a dotted underline, no
   marker, `--accent-text`. `.run-started-with` is a `.run-why` now, so
   it inherits that treatment and its plate rather than restating both.
   Only its own place in the header is left here. */

/* Its own ring. Left to the UA default it measured 2.11:1 on the dark
   card against the 5.13:1 every button in the same pane gets — and 3:1
   is the floor for a focus indicator. */
.run-started-with summary:focus-visible,
details.run-step > summary:focus-visible,
.run-why > summary:focus-visible,
.run-row-why > summary:focus-visible,
.run-step-body details > summary:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
    border-radius: 4px;
}

/* The progress rail is DELETED. Four pips labelled Plan · Servers ·
   Install · Result rode in the sticky header, two inches above four
   step cards headed Before you start · Servers and bundle · Install ·
   Result — the same sequence, twice, in two vocabularies, and it was
   `aria-hidden` so it was carrying nothing a screen reader had. The
   cards ARE the rail: they are in order, each wears its own state
   glyph, and the one the run is on is the only one that cannot be
   collapsed. `list`, `server` and `flag` left `ASSISTANT_ICONS` with
   it. */

/* ── Steps ─────────────────────────────────────────────────────
   ONE OPEN AT A TIME. Every step used to render its whole body
   whichever step the run was on, so the operator met four bodies
   stacked. What is behind a finished step is the RECORD, and a record
   is read on demand — so a done step is a disclosure, and a step not
   yet reached is a single dim line with nothing to open. */

.run-steps {
    display: flex;
    flex-direction: column;
    /* 12px: four CARDS need the gap that says they are four things,
       where four bordered-left lines needed only 4px to read as a
       list. `spacing.md` from DESIGN.md. */
    gap: 12px;
}

/* The patch slots `_renderPanel` rewrites one at a time. `contents` so
   the flex column above still sees the four steps as its own children
   and the gap applies between THEM — a real box here would take the
   gap itself and the steps inside it would collapse together. */
.run-slot { display: contents; }

/* THE HOUSE COLLAPSIBLE CARD — `details.compose-service-form`'s
   treatment, which is what the creation wizard uses for exactly this
   shape one tab away: 1px `--border-color`, `--radius`, the card
   surface, a 44px head. The steps were four bordered-LEFT lines, a
   panel vocabulary this app uses nowhere else, on a screen whose brief
   was "conform to the app's components".

   They are still not the app's `.stepper`: that component's steps are
   <button>s because a finished wizard step is navigable back, and here
   nothing re-runs a step — opening one reveals what happened. */
.run-step {
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background: var(--bg-secondary);
    padding: 0 16px;
    transition: border-color 240ms var(--ease-out);
}

/* EMPHASIS IS BOUND TO `.is-current`, NEVER TO `[open]`. A <section>
   can never match `[open]`, so an accent keyed on the attribute could
   only ever land on an expanded FINISHED step — the accent pointing at
   the one card that is over. */
.run-step.is-current {
    border-color: var(--accent-text);
    /* The open step gets the room; the collapsed heads around it stay
       tight, which is what makes the rhythm read as a list with one
       thing in it rather than four evenly-spaced blocks. */
    padding-bottom: 4px;
}

.run-step.is-current > .run-step-head { background: var(--accent-tint); }

.run-step.is-failed { border-color: var(--danger); }

/* The head fills the card, so the current step's tint is a band rather
   than a highlighted phrase — the same negative-margin trick
   `details.compose-service-form > summary` uses. */
.run-step-head {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 0 -16px;
    padding: 12px 16px;
    min-height: 44px;
    box-sizing: border-box;
    border-radius: var(--radius);
    font-size: 0.95rem;
    font-weight: 600;
    line-height: 1.4;
}

/* The head's own mark, from the app's icon set — `check` / `target` /
   `close` / `dot`, the vocabulary `.cdep-step` already speaks. It is
   NOT `.run-mark`: that component belongs to the rows, and a step
   wearing one put four extra members in a column whose whole value is
   that its members mean the same kind of thing. */
.run-step-icon {
    flex: 0 0 auto;
    display: inline-flex;
    color: var(--text-muted);
}

.run-step.is-done > .run-step-head .run-step-icon,
.run-step.is-done > summary .run-step-icon { color: var(--success-text); }

.run-step.is-current > .run-step-head .run-step-icon { color: var(--accent-text); }

.run-step.is-failed > .run-step-head .run-step-icon { color: var(--danger-text); }

/* A collapsible step's head is the control. The marker is suppressed —
   a UA triangle beside our own mark is two disclosure signals — and
   replaced by a chevron at the trailing edge, where it does not
   compete with the mark for the first read. */
/* The <summary> is now a wrapper around the step's <h3>, so it is the
   control and the h3 is the heading — the accessible accordion shape.
   `display: block` keeps the h3 a normal block inside it. */
details.run-step > summary.run-step-summary {
    cursor: pointer;
    list-style: none;
    display: block;
    user-select: none;
}

details.run-step > summary.run-step-summary > .run-step-head {
    transition: background-color 160ms var(--ease-out);
}

details.run-step > summary.run-step-summary::-webkit-details-marker {
    display: none;
}

details.run-step > summary.run-step-summary > .run-step-head::after {
    content: '';
    width: 7px;
    height: 7px;
    margin-left: auto;
    flex: 0 0 auto;
    border-right: 1.5px solid var(--text-muted);
    border-bottom: 1.5px solid var(--text-muted);
    transform: rotate(45deg) translate(-2px, -2px);
    transition: transform 200ms var(--ease-out);
}

details.run-step[open] > summary.run-step-summary > .run-step-head::after {
    transform: rotate(225deg) translate(-2px, -2px);
}

@media (hover: hover) and (pointer: fine) {
    details.run-step > summary.run-step-summary:hover > .run-step-head {
        background: var(--surface-sunken);
    }
}

details.run-step > summary.run-step-summary:active > .run-step-head {
    background: var(--surface-interactive-hover);
}

/* Muted rather than hidden on a step not yet reached: the operator can
   see what is coming, which is what the four-step shape is for. The
   `STEP n` eyebrow that used to sit here is gone — the card, its icon
   and its place in the column already carry the number, and
   `.cdep-step` next door has never numbered anything. */
.run-step.is-todo .run-step-head { color: var(--text-muted); }

.run-step-title {
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* What a collapsed step CONCLUDED. Folding a step away must not take
   the news with the detail. */
.run-step-sum {
    font-size: 0.85rem;
    font-weight: 400;
    color: var(--text-secondary);
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.run-step-body {
    padding: 4px 0 16px;
    /* An entrance, so the body of a step that has just become current
       arrives rather than appearing. Opacity plus 3px — the pane rule
       one section up explains why the translate stays small. */
    animation: run-body-in 220ms var(--ease-out) both;
}

@keyframes run-body-in {
    from { opacity: 0; transform: translateY(3px); }
    to   { opacity: 1; transform: translateY(0); }
}

.run-step-body > :first-child { margin-top: 0; }
.run-step-body > :last-child { margin-bottom: 0; }

/* ── The row mark ──────────────────────────────────────────────
   ONE component, SIX states, drawn in CSS, and the ROWS' alone — the
   step heads take the app's icon set (`.run-step-icon` above), because
   two levels drawn with one component is a column whose members do not
   mean the same kind of thing.

   It replaces nine mono glyphs across two vocabularies. Every state is
   a distinct SHAPE and no two differ only by fill, so the column is
   readable at a glance and in greyscale:

     is-empty     nothing has happened here          quiet dot
     is-pending   in progress                        accent ring, breathing
     is-ok        done                               filled disc + tick
     is-failed    it broke                           filled disc + cross
     is-negative  looked, and the answer is no       warning disc + !
     is-unknown   nobody looked                      warning ring + dash */

.run-mark {
    flex: 0 0 auto;
    position: relative;
    width: 16px;
    height: 16px;
    border-radius: 999px;
    border: 1.5px solid transparent;
    background: transparent;
    transition: background-color 200ms var(--ease-out),
                border-color 200ms var(--ease-out);
}

/* Neutral — nothing has happened here yet. A DOT, not the hollow ring
   the first cut drew: at 16px in a vertical list a hollow ring is a
   radio button, so the four rows step 1 previews read as a form nobody
   had filled in and invited a click that does nothing. The 16px box is
   kept for the column; only what is inside it changes.

   `is-empty`, and it was `is-none` — one character from the state
   functions' own `none`, which means the OPPOSITE: an observed
   negative. */
.run-mark.is-empty::after {
    content: '';
    position: absolute;
    inset: 5px;
    border-radius: 999px;
    /* NO `opacity`. Composited at 0.55 this measured 2.30:1 against the
       light card — below the 3:1 a status indicator owes 1.4.11, and
       exactly the trap this repo has already paid for once on the
       container table. The dot is made quiet by being 6px, not by being
       faint. */
    background: var(--text-muted);
}

/* Done — a filled disc with a tick. */
.run-mark.is-ok,
.run-mark.is-done {
    background: var(--success);
    border-color: var(--success);
}

.run-mark.is-ok::after,
.run-mark.is-done::after {
    content: '';
    position: absolute;
    left: 4.5px;
    top: 1.5px;
    width: 4px;
    height: 8px;
    border: solid #fff;
    border-width: 0 1.75px 1.75px 0;
    transform: rotate(45deg);
    /* Nothing appears from nothing: the tick scales up from a visible
       shape as a row ticks over, rather than popping into existence. */
    animation: run-tick-in 180ms var(--ease-out) both;
}

@keyframes run-tick-in {
    from { opacity: 0; transform: rotate(45deg) scale(0.6); }
    to   { opacity: 1; transform: rotate(45deg) scale(1); }
}

/* Failed — a filled disc with a cross. */
.run-mark.is-failed {
    background: var(--danger);
    border-color: var(--danger);
}

.run-mark.is-failed::before,
.run-mark.is-failed::after {
    content: '';
    position: absolute;
    left: 6px;
    top: 3px;
    width: 1.75px;
    height: 9px;
    border-radius: 1px;
    background: #fff;
}

.run-mark.is-failed::before { transform: rotate(45deg); }
.run-mark.is-failed::after { transform: rotate(-45deg); }

/* In progress — an accent ring around a breathing dot. */
.run-mark.is-current,
.run-mark.is-pending {
    border-color: var(--accent-text);
}

.run-mark.is-current::after,
.run-mark.is-pending::after {
    content: '';
    position: absolute;
    inset: 4px;
    border-radius: 999px;
    background: var(--accent-text);
    animation: run-pulse 1.6s ease-in-out infinite;
}

/* Looked, and the answer is NO. A filled warning disc with an
   exclamation — a SHAPE of its own, not the not-checked ring with a
   different fill: "nobody asked" and "we asked and it is missing" are
   the pair on this screen that must never collapse into each other,
   and a reader in greyscale would have had only fill to tell them
   apart. `--warning-text` measures 5.57:1 light / 8.17:1 dark on the
   card, and the glyph is the card's own ink, so the pair is legible
   both ways in both themes. */
.run-mark.is-negative {
    background: var(--warning-text);
    border-color: var(--warning-text);
}

.run-mark.is-negative::before {
    content: '';
    position: absolute;
    left: 6.5px;
    top: 3px;
    width: 1.75px;
    height: 5.5px;
    border-radius: 1px;
    background: var(--bg-secondary);
}

.run-mark.is-negative::after {
    content: '';
    position: absolute;
    left: 6.5px;
    top: 10.5px;
    width: 1.75px;
    height: 1.75px;
    border-radius: 1px;
    background: var(--bg-secondary);
}

/* Not checked — a warning ring with a DASH, never a `?`. A question
   mark in a status column reads as a question addressed to the reader;
   a dash reads as "no answer here", which is the fact. The word is in
   the row's own detail, so this is never colour alone. */
.run-mark.is-unknown {
    border-color: var(--warning-text);
}

.run-mark.is-unknown::after {
    content: '';
    position: absolute;
    left: 3px;
    right: 3px;
    top: 50%;
    height: 1.75px;
    margin-top: -0.875px;
    border-radius: 1px;
    background: var(--warning-text);
}

/* DARK THEME: the two solid discs move to their `-text` variants, and
   their glyphs to the card's own ink.
   MEASURED, not eyeballed: `--danger` on the dark card is 2.79:1 and
   `--success` 3.66:1, and WCAG 1.4.11 owes a non-text status indicator
   3:1 — so the FAILED mark, the one state nobody may miss, was the
   least visible thing in the column. `--danger-text` is 6.11:1 and
   `--success-text` 8.42:1; the ink on them is the same number back. */
[data-theme="dark"] .run-mark.is-ok,
[data-theme="dark"] .run-mark.is-done {
    background: var(--success-text);
    border-color: var(--success-text);
}

[data-theme="dark"] .run-mark.is-ok::after,
[data-theme="dark"] .run-mark.is-done::after {
    border-color: var(--bg-secondary);
}

[data-theme="dark"] .run-mark.is-failed {
    background: var(--danger-text);
    border-color: var(--danger-text);
}

[data-theme="dark"] .run-mark.is-failed::before,
[data-theme="dark"] .run-mark.is-failed::after {
    background: var(--bg-secondary);
}

/* ── Rows ──────────────────────────────────────────────────────
   A two-column grid, not a wrapping flex row. Under flex, a long
   detail wrapped to a full-width line and every mark sat at a
   different distance from the label beside it — so the marks did not
   form a column, which is the only thing that makes a checklist
   scannable. The grid pins the mark column at 16px and the label
   column at its content, whatever the detail does. */

/* SUBGRID, so the three columns are shared by every row rather than
   recomputed per row. Each row was its own grid, so `max-content` on
   the label column meant a different width per row — and the detail
   column, the one a reader compares down, started at a different x on
   every line. `display: contents` would do it too and is refused: it
   drops list semantics on <li> in Safari, on the one list whose states
   are carried by `.sr-only` text. */
.run-rows {
    list-style: none;
    margin: 0;
    padding: 0;
    display: grid;
    grid-template-columns: 16px minmax(0, max-content) minmax(0, 1fr);
    gap: 8px;
}

.run-row {
    display: grid;
    grid-template-columns: subgrid;
    grid-column: 1 / -1;
    align-items: baseline;
    gap: 4px 8px;
}

/* The mark is a box on a grid line that baselines TEXT, so it needs
   its own nudge to sit on the label's optical centre rather than on
   its baseline. */
.run-row .run-mark { transform: translateY(2px); }

.run-row-label { min-width: 0; }

/* Detail and caveat in ONE cell. As two grid cells they stacked, so a
   row carrying a `why` was two lines tall and the list lost the even
   rhythm that is the whole reason a checklist can be scanned. */
.run-row-rest {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 4px 8px;
    min-width: 0;
    /* Prose is capped even here: a captured-address list runs long, and
       an unbounded third column re-creates the 150-character line this
       redesign exists to remove. */
    max-width: 68ch;
}

.run-row-detail {
    color: var(--text-secondary);
    font-size: 0.85rem;
    min-width: 0;
    overflow-wrap: anywhere;
}

/* Below the comfortable width the detail takes its own line, indented
   to its OWN row — a detail starting at the container's left edge, left
   of the mark it belongs to, reads as a note about the section rather
   than about the row above it. Measured at 380px before the fix:
   detail at x=39, its own mark at x=44. */
@media (max-width: 560px) {
    .run-rows { grid-template-columns: 16px minmax(0, 1fr); }
    .run-row-rest { grid-column: 2; }
}

/* The long-form caveat, behind a control, on the row it belongs to.
   Three rows carried 25-30 word sentences inline — a paragraph per row
   in a list whose job is to be scanned. The sentences are load-bearing
   (they are what stops "not checked" being read as "isolated"), so
   they are moved, never cut.

   IT HAS ITS OWN GRID LINE, OPEN OR CLOSED, and that is the fix for
   "it jumps". It used to live inside the detail cell, promote itself to
   a full-width flex line on open (`flex-basis: 100%`), and drag that
   cell across to `grid-column: 2 / -1` through a `:has()` rule — which
   re-resolved the list's SHARED `max-content` label column, so opening
   one caveat slid every other row's detail sideways. Measured on step
   1's preview list; reported by an operator as the panel jumping.

   A row that carries a caveat is two lines tall in both states now, so
   nothing about the row changes shape on the press: the control stays
   under the words it belongs to, and only the plate below it grows. */
.run-row-why {
    grid-column: 2 / -1;
    min-width: 0;
}

/* THE HOUSE DISCLOSURE, `.cdep-log-summary` (styles.css ~6669): a
   rotating triangle plus muted text — no underline, no accent colour.
   These read as bare dotted links, which is a control vocabulary this
   app does not have anywhere else; the triangle is what says
   open/closed as a SHAPE rather than leaving it to a layout change
   somebody has to notice. */
.run-row-why > summary {
    cursor: pointer;
    list-style: none;
    /* INLINE-FLEX, not block: a <summary> is a block box, so anything
       drawn on its edge spans the whole row — measured at 639.7px for a
       three-letter control. */
    display: inline-flex;
    align-items: center;
    gap: 4px;
    font-size: 0.85rem;
    color: var(--text-secondary);
    line-height: 1.4;
}

.run-row-why > summary::before {
    content: '';
    flex: 0 0 auto;
    width: 0;
    height: 0;
    border-left: 5px solid currentColor;
    border-top: 4px solid transparent;
    border-bottom: 4px solid transparent;
    transition: transform 150ms var(--ease-out);
}

.run-row-why[open] > summary::before { transform: rotate(90deg); }

.run-row-why > summary::-webkit-details-marker { display: none; }

.run-row-why > .run-why-body { margin-top: 6px; padding: 8px 10px; }

/* THE PLATE WRAPS THE WHOLE DISCLOSURE, at both levels. It used to sit
   on `> p`, so a disclosure holding anything else — the deploy plan's
   <ol>, `What this run was started with`'s <dl> — put its tint on the
   first sentence and left the content it was opened for hanging on the
   bare card. One wrapper, one rule, three emitters, and the three
   different prose caps (76 / 68 / 62ch) collapse to the one this
   screen uses everywhere. */
.run-why-body {
    margin: 8px 0 0;
    padding: 10px 12px;
    max-width: 68ch;
    font-size: 0.85rem;
    line-height: 1.55;
    color: var(--text-secondary);
    background: var(--surface-sunken);
    border-radius: var(--radius);
    animation: run-body-in 180ms var(--ease-out) both;
}

/* IT GROWS, it does not appear. A disclosure that snaps open shoves
   everything below it in one frame, which reads as the page moving by
   itself — and this one sits in a checklist, so the four rows under it
   all jump at once. Opacity alone (what it had) softens the plate and
   does nothing about the shove.

   Height is interpolated only where the platform does it natively, so
   there is no max-height guess to go stale: `interpolate-size` makes
   `height: 0 → auto` animatable, and browsers without it keep exactly
   the fade this screen already had. The `to` keyframe is deliberately
   IMPLICIT — it resolves to the element's own computed values, so the
   row-level override below (6px/8px, not 8px/10px) survives the
   animation's `both` fill instead of being pinned to a literal here. */
@supports (interpolate-size: allow-keywords) {
    .run-why-body {
        interpolate-size: allow-keywords;
        overflow: hidden;
        animation-name: run-why-in;
    }
}

@keyframes run-why-in {
    from {
        opacity: 0;
        height: 0;
        margin-top: 0;
        padding-top: 0;
        padding-bottom: 0;
    }
}

.run-why-body > :first-child { margin-top: 0; }
.run-why-body > :last-child { margin-bottom: 0; }

/* The one-phrase "and what to do about it" a three-state answer
   carries. Beside the answer, not under it: as its own line every row
   with a hint was two lines tall and the checklist lost its rhythm. */
.run-row-hint { color: var(--text-muted); }

/* The four rows step 1 draws before there is anything to observe. They
   are the SAME rows step 2 will tick, so the screen does not change
   shape when the run begins — the marks flip and nothing moves.
   Deliberately NOT dimmed with `opacity`: at 0.75 the detail column
   composited to 4.4:1 against the light card, a WCAG AA miss on text
   the operator is being asked to read before pressing the one button on
   the screen. The neutral marks already say nothing has happened. */
.run-rows.is-preview .run-row-label { color: var(--text-secondary); }

/* ── Prose, capped ─────────────────────────────────────────────
   The single measurement behind most of "nothing is clear": body text
   ran the full card width, about 150 characters per line at 1440px,
   against the 45-90 band. Rows may be wide; sentences may not. */

.run-note,
.run-error,
.run-cta-note,
.run-standing,
.run-why > p {
    max-width: 68ch;
}

/* THE LEDE HAS ITS OWN MEASURE, wider than the 68ch the notes take.
   At 68ch this one sentence broke after `redirected` and left `here.`
   alone on a second line — a wrap with nothing to gain, inside a card
   four times its width. Reported 2026-08-31.

   76ch is not a licence to run to the edge: it is the width of THIS
   sentence, so it sets on one line and anything longer still wraps
   inside the readable band. The pane stays full width — the cure for a
   ragged line is the measure, not a narrower screen. */
.run-lede {
    margin: 0 0 12px;
    max-width: 76ch;
    font-size: 0.95rem;
    line-height: 1.6;
    /* And when it DOES wrap — a narrow window, a longer translation —
       it wraps without leaving a single word stranded. Ignored by
       browsers that do not have it, which is exactly the old behaviour. */
    text-wrap: pretty;
}

.run-note,
.run-cta-note {
    margin: 8px 0 0;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-secondary);
}

.run-error {
    margin: 8px 0 0;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--danger-text);
}

/* A disclosure for reference material that has to survive but must not
   be the first thing read. Same treatment as the row-level one, one
   size up; its plate is `.run-why-body` above. */
.run-why {
    margin: 0 0 16px;
}

.run-why > summary {
    cursor: pointer;
    list-style: none;
    display: inline-flex;
    align-items: center;
    gap: 6px;
    font-size: 0.85rem;
    font-weight: 500;
    color: var(--text-secondary);
}

.run-why > summary::before {
    content: '';
    flex: 0 0 auto;
    width: 0;
    height: 0;
    border-left: 5px solid currentColor;
    border-top: 4px solid transparent;
    border-bottom: 4px solid transparent;
    transition: transform 150ms var(--ease-out);
}

.run-why[open] > summary::before { transform: rotate(90deg); }

.run-why > summary::-webkit-details-marker { display: none; }

/* In the header rather than in a step body, so it takes a top margin
   and no bottom one. Two classes, so it wins over `.run-why` above
   whatever the order of the two blocks. */
.run-why.run-started-with { margin: 8px 0 0; }

.run-why .lab-plan { margin: 8px 0 0; }
.run-why .lab-plan li { margin-bottom: 4px; }

/* ── The action block ──────────────────────────────────────────
   One place, one shape, on every step that has an action. Step 1's
   button used to sit after ~180 words with a 30-word warning UNDER it,
   so the last thing read before pressing was a paragraph rather than
   the consequence; step 2's hung off the end of the row list with no
   separation, reading as a seventh status row. */

.run-cta {
    margin-top: 20px;
    padding-top: 16px;
    border-top: 1px solid var(--border-color);
}

.run-cta-row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 12px;
}


.run-capacity {
    margin: 0;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

/* A text control inside a sentence. It is a real <button> — this is an
   action, not navigation — but it is not a boxed one: beside the
   screen's primary, a second bordered control competes for the press
   the block exists to collect. */
/* `Who has them?` — an ACTION inside a sentence ("2 of 5 running · Who
   has them?"), so it takes a real underline rather than the DOTTED rule
   invented here: dotted-underline was a control vocabulary this app has
   nowhere else, and it read as a defect beside the two disclosures that
   carried it too. A plain underline is the one affordance every reader
   already knows. Not a `.btn`: a bordered or filled control mid-sentence
   competes with the primary for the press the block exists to collect. */
.run-link {
    font: inherit;
    color: var(--accent-text);
    background: none;
    border: 0;
    padding: 0;
    cursor: pointer;
    text-decoration: underline;
    text-underline-offset: 2px;
}

.run-link:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
    /* 4px is the scale's smallest radius; 2px is not on it. */
    border-radius: 4px;
}

/* ── The terminal ──────────────────────────────────────────────
   Sized by state. It was a fixed 340px black box whatever was in it,
   so on a run sitting at step 2 the largest element on the screen was
   an empty void — most of what made the panel read as broken. It stays
   MOUNTED in every state that has a transcript (the buffer is the
   evidence step 4's verdict is read against); only its height moves. */

/* THE STEP'S PARTS IN READING ORDER, WHICH IS NOT THE DOM ORDER — and
   that is the whole point of this container. `Open shell` / `Close
   shell` belongs at the foot of the step, under the terminal it acts
   on, where steps 1, 2 and 4 all keep their action; it has to stay
   BEFORE the terminal in the DOM because a live shell swallows forward
   Tab, and a button that is after the terminal in both orders cannot be
   reached by any key while the shell is running. Before it, Shift+Tab
   out of the terminal lands on it — which is what the hint says.

   Four members, four `order`s, so adding a fifth has a declared place
   rather than inheriting 0 and landing at the top. */
.lab-shell-stack {
    display: flex;
    flex-direction: column;
}

.lab-shell-stack > .lab-shell-lede { order: 1; margin-top: 0; }
.lab-shell-stack > .lab-terminal-wrap { order: 2; }
.lab-shell-stack > .lab-shell-hint { order: 3; }
.lab-shell-stack > .lab-shell-cta { order: 4; }

.lab-terminal-wrap {
    position: relative;
}

/* The empty state says what will fill it, instead of a blinking cursor
   in a void leaving the operator to guess whether something is wrong.
   `pointer-events: none` so it never eats a click meant for the
   terminal underneath. */
.lab-terminal-empty {
    position: absolute;
    inset: 0;
    margin: 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 8px;
    text-align: center;
    padding: 0 20px;
    font-size: 0.85rem;
    color: var(--text-muted);
    pointer-events: none;
    /* The terminal's OWN ground, not the card's. It is an overlay on a
       `--code-bg` box, so a transparent placeholder left the words on
       whatever xterm had painted and the box read as half-drawn. */
    background: var(--code-bg);
    border-radius: var(--radius);
}

/* A CLASS selector out-specifies the UA's `[hidden] { display: none }`,
   so `_labSyncTermEmpty` setting `.hidden = true` did nothing at all and
   "The install runs here, once the bundle is on the servers" painted
   across the middle of a terminal that was showing the install's own
   output. Measured on the failure state, 2026-08-31. This repo already
   carries the same fix twice (`.pl-flag[hidden]`, `.step-issue[hidden]`)
   — an element toggled by the `hidden` ATTRIBUTE whose own rule sets
   `display` needs its own opt-out. */
.lab-terminal-empty[hidden] { display: none; }

.lab-terminal-empty code {
    background: rgba(255, 255, 255, 0.08);
    color: inherit;
}

/* ── The verdict ───────────────────────────────────────────────
   SEEN, THEN READ. It was one weighted sentence in a column of
   sentences, so the single fact step 4 exists to deliver was carried
   entirely by font-weight and colour — and by colour alone for anybody
   who does not parse weight. The mark says pass or fail before the
   words are read. */

.run-verdict {
    display: flex;
    align-items: flex-start;
    gap: 8px;
    margin: 0 0 4px;
    padding: 12px 16px;
    /* Capped with the prose it holds. A tinted plate running the full
       1342px card for one 60-character sentence puts 60% of the loudest
       element on the screen on nothing. 68ch — the one cap this screen
       uses, rather than the third of three different numbers. */
    max-width: 68ch;
    border-radius: var(--radius);
    border-left: 3px solid var(--border-color);
    background: var(--surface-sunken);
}

.run-verdict > p {
    margin: 0;
    max-width: 68ch;
    font-weight: 600;
    line-height: 1.5;
}

.run-verdict .run-mark { transform: translateY(2px); }

.run-verdict.is-ok {
    border-left-color: var(--success);
    background: var(--success-bg);
}

.run-verdict.is-ok > p { color: var(--success-text); }

.run-verdict.is-bad {
    border-left-color: var(--danger);
    background: var(--danger-bg);
}

.run-verdict.is-bad > p { color: var(--danger-text); }

/* The occupancy list — the header chip's modal, and the same markup
   rendered in place on a refused Start. One builder, one treatment. */
.lab-occupancy-rows {
    list-style: none;
    margin: 8px 0;
    padding: 0;
}

.lab-occupancy-row {
    display: flex;
    align-items: baseline;
    gap: 8px;
    padding: 8px 0;
    border-bottom: 1px solid var(--border-color);
}

.lab-occupancy-row:last-child { border-bottom: none; }

/* The installation is what the reader is scanning for, so it comes
   first and takes the weight; the facts beside it are the second read.
   `min-width: 0` because a long name must ellipse rather than push the
   Open button off the row. */
.lab-occupancy-name {
    font-weight: 600;
    flex: 0 1 auto;
    min-width: 0;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.lab-occupancy-facts {
    /* Basis ZERO, not `auto`. With an auto basis the facts sentence
       claims its whole max-content width and the name is squeezed first
       — measured in the 460px modal, EVERY name ellipsed, `qa11m` down
       to `qa1…`, on the one screen whose job is to say WHICH
       installation is holding a slot. The facts are the second read and
       wrap happily; the name does not. */
    flex: 1 1 0;
    min-width: 0;
    /* An email is one unbreakable token, and a basis of zero can
       compute the box to width 0 — measured at 380px: clientWidth 0
       against scrollWidth 109, so the address painted straight over the
       installation name beside it and the row read as two superimposed
       strings. `anywhere` is what lets it break; the row wrapping below
       is what stops it needing to. */
    overflow-wrap: anywhere;
    color: var(--text-secondary);
    font-size: 0.85rem;
}

/* Below the modal's comfortable width the facts take their own line
   rather than being squeezed to nothing beside the name. */
@media (max-width: 460px) {
    .lab-occupancy-row { flex-wrap: wrap; }
    .lab-occupancy-facts { flex-basis: 100%; }
}

/* Whose run it is, marked by position AND by the word "yours" in the
   facts line — never by colour alone. */
.lab-occupancy-row.is-mine .lab-occupancy-name { color: var(--accent-text); }

/* An orphan's "name" is a sentence saying there is no name, so it does
   not take the weight a real installation does - otherwise the row a
   reader can act on least is the loudest one in the list. */
.lab-occupancy-row.is-orphan .lab-occupancy-name {
    font-weight: 400;
    color: var(--text-secondary);
    white-space: normal;
}

/* A full pool is a warning, not a failure: nothing is broken, and the
   chip's own words already say the number. */
#composeOccupancyChip.is-full { color: var(--warning-text); }

/* A refusal, standing under the button that was refused. Warning rather
   than danger — somebody else's run is holding a slot, which is normal;
   the 3px rule is what carries it, the tint only weights it. */
.lab-refusal {
    margin: 12px 0;
    padding: 12px;
    border-left: 3px solid var(--warning-text);
    background: var(--surface-sunken);
    border-radius: 4px;
}

.lab-refusal-msg {
    margin: 0 0 8px;
    color: var(--warning-text);
}


/* ============================================================
   28. Hidden utility.
   ONE rule, carrying !important, positioned after every rule above
   that sets `display` on .admin-badge / .card / .modal-overlay.
   ============================================================ */
.hidden {
    display: none !important;
}

/* ============================================================
   29. Topology Builder wizard (topology-wizard.html)

   Folded in from that page's inline <style> block. The class selectors
   moved across unprefixed — none of them can match anything on
   index.html (they are compound-scoped under wizard-only ancestors).
   Only the root/element rules needed a decision:
     :root                 → deleted; the palette is aliased onto FT
                             tokens on body.wz-body below, so dark mode
                             arrives by inheritance.
     *                     → deleted; the reset above is more thorough.
     button / input,select → deleted; re-declared scoped below, which
                             is what the wizard's own reset provided.
     html, body            → re-homed onto html.wz / body.wz-body.
     @keyframes fade       → renamed wz-fade (keyframe names are global).
   ============================================================ */

/* The wizard runs on the shared 14px root and the FT type ramp, same as
   index.html. It used to pin its own 13px root on html.wz to preserve a
   local rem voice; that pin is gone and every size below is a documented
   ramp step (DESIGN.md typography: 0.75 / 0.85 / 0.875 / 0.95 / 1.15 /
   1.5 rem). Do not reintroduce a per-page root. */

body.wz-body {
    background: var(--bg-primary);
    color: var(--text-primary);
    line-height: 1.5;
}

/* What the wizard's own reset was providing, re-declared scoped: the
   shared `*` reset above is stricter than the wizard's `box-sizing`-only
   one, so anything that relied on UA defaults is restored here. */
body.wz-body button { font-family: inherit; cursor: pointer; }
body.wz-body input,
body.wz-body select { font-family: inherit; }
body.wz-body button:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }

/* ── Visually hidden (labels a screen reader needs, an eye does not) ─── */
body.wz-body .vh {
    position: absolute; width: 1px; height: 1px;
    padding: 0; margin: -1px; overflow: hidden;
    clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* ── Step rail ─────────────────────────────────────────────────────
   Three numbered steps on the app's canonical Stepper. It used to be a
   free tablist with named-only sections; the flow is gated now, so the
   numbers are the sequence rather than decoration and the rail matches
   the compose-create wizard next door. Only the two-line label needs a
   local override — the shared component clips to one line. */
.wz-stepper {
    top: 16px;
    margin-bottom: 22px;
}
.wz-stepper .step-label {
    white-space: normal; overflow: visible; text-overflow: clip;
    font-size: 0.85rem; font-weight: 500; letter-spacing: -0.005em;
}
.wz-stepper .step-label small {
    display: block; font-size: 0.75rem; letter-spacing: 0.02em;
    color: var(--text-muted); margin-top: 2px; font-weight: 400;
}
.wz-stepper .step.current .step-label small { color: var(--text-secondary); }

/* ── Shell: work area beside a summary that never scrolls away ─────
   flex-wrap rather than a breakpoint — the rail drops under the main
   column on its own when there is no room for both. */
.wz-shell {
    display: flex; flex-wrap: wrap; align-items: flex-start; gap: 16px;
}
.wz-main { flex: 1 1 560px; min-width: 0; }

/* ── The right column: the summary, and the training rail under it ──
   The rail is `position: fixed` in the corner everywhere else, and on
   this page that made a THIRD column the layout has no room for —
   measured at 1512 with the rail up: work area 826px (1146 without it)
   beside a 310px summary column that was empty from its verdict all the
   way past the fold. So here the rail mounts INTO this column
   (`trainingRailElement`) and the two share it.

   The column is the sticky one now, not the card: two sticky boxes in a
   row cannot keep a gap between them. It is capped to the viewport so a
   long rail scrolls inside itself rather than running off the screen,
   which is what its own `max-height` did while it was fixed.

   Above 900px only. Below it the rail is a bottom sheet and the summary
   wraps under the work area — both already decided, and neither wants a
   column. */
@media (min-width: 901px) {
    .wz-side {
        flex: 0 1 310px; min-width: 260px;
        position: sticky; top: 16px;
        display: flex; flex-direction: column; gap: 16px;
        max-height: calc(100vh - 32px);
        /* AND IT DOES NOT TAKE CLICKS WHERE IT HAS NOTHING. Raising the
           column above the assistant launcher (900) fixes the rail and
           breaks the launcher: `.wz-side` is a tall box down the right
           gutter, the launcher is `fixed` in that same corner, and it is
           the ONLY way to open the assistant — which this layout assumes
           is closed, since the rail falls back to the corner when it is
           open. Empty column space must therefore stay click-through; the
           two children take their own presses back. */
        pointer-events: none;

        /* THE COLUMN CARRIES THE RAIL'S RANK, because `sticky` makes this
           a stacking context and the rail inside it is `static;
           z-index: auto` — its own 1700 is compared inside this box, not
           against the page. Left at `auto` the whole column sank below
           anything with a number: `#training-dim` (1390) painted over the
           rail for the 1600ms of every arrival, i.e. over the words the
           trainee had just been sent here to read, and
           `#assistant-launcher` (900) took the presses on the rail's own
           buttons wherever the height cap put them in its corner. Both
           measured with `elementFromPoint` on `1e9f7ead`.

           1400 is the one number that clears both and stays under the
           assistant panel (1500) and the ring's own lift (1595) — the
           rail must not cover the place it is pointing at. */
        z-index: 1400;
    }
    /* In a COLUMN, `flex-basis` is a height — the card's own `0 1 310px`
       would make it 310px tall. It also stops being the sticky one. */
    .wz-side .wz-sum { position: static; flex: 0 0 auto; }
    /* The other half of the column's `pointer-events: none` above. */
    .wz-side > * { pointer-events: auto; }
    /* THE RAIL JOINS THE COLUMN ONLY WHILE THE ASSISTANT IS AWAY. The
       panel is `fixed` in this same corner, so a rail that cannot move
       out of it is a rail the panel covers — the defect the
       `:has(#assistant-panel)` rule beside `.training-rail` was written
       for, and the rail's own Ask button is what opens the panel. With
       it open everything falls back to what every other page does: the
       rail goes fixed to the left gutter and `.main-content` keeps the
       padding that reserves it. */
    body:not(:has(#assistant-panel:not([hidden]))) .wz-side .training-rail {
        position: static; width: auto; z-index: auto;
        max-height: none; min-height: 0;
        /* PEERS, not a card and a floating panel. In the corner the rail
           is a panel over the page and its own elevation says so; in this
           column it sits under `.wz-sum` with 16px between them, and two
           different elevations there read as two unrelated things. The
           card's own tokens, so a change to `.card` moves both. */
        box-shadow: var(--shadow);
        border-radius: var(--radius-lg);
    }
    /* No gutter to reserve: the rail is in the flow. */
    body.wz-body.training-on:not(:has(#assistant-panel:not([hidden])))
        .main-content { padding-right: 0; }
}

.wz-sec { animation: wz-fade 180ms var(--ease-out); }
@keyframes wz-fade { from { opacity: 0; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }

.wz-head { margin-bottom: 20px; }
.wz-head h3 {
    font-family: var(--font-sans); font-weight: 600;
    font-size: 1.15rem; letter-spacing: -0.01em;
    color: var(--text-primary); margin: 0 0 6px;
}
.wz-head p {
    color: var(--text-muted); font-size: 0.95rem;
    margin: 0; line-height: 1.6;
}

/* ── Presets: chips that act on the catalog below, in view ────────── */
.preset-row {
    display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
    margin-bottom: 14px;
}
.preset-lead {
    font-family: var(--font-sans); font-size: 0.75rem;
    letter-spacing: 0.16em; text-transform: uppercase;
    color: var(--text-muted); font-weight: 500;
}
.preset-chips { display: flex; gap: 8px; flex-wrap: wrap; }
.preset {
    display: inline-flex; align-items: center; gap: 0.45em;
    background: var(--bg-secondary); border: 1px solid var(--border-strong);
    color: var(--text-secondary); border-radius: 999px;
    padding: 0.34rem 0.8rem; font-family: var(--font-sans); font-size: 0.82rem;
    transition: background var(--transition), border-color var(--transition),
                color var(--transition), transform var(--press);
}
.preset:hover { background: var(--surface-sunken); border-color: var(--text-muted); }
.preset:active { transform: scale(0.97); }
.preset:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
.preset .pv-mark {
    font-weight: 700; font-size: 0.85rem; line-height: 1;
    color: var(--text-muted); transition: color var(--transition);
}
.preset .pv-ct {
    font-family: var(--font-mono); font-size: 0.72rem;
    font-variant-numeric: tabular-nums; color: var(--text-muted);
}
.preset.on {
    background: var(--accent-tint); border-color: var(--accent-text);
    color: var(--accent-text); font-weight: 600;
}
.preset.on .pv-mark, .preset.on .pv-ct { color: var(--accent-text); }
.preset.partial {
    background: var(--warning-bg);
    border-color: color-mix(in srgb, var(--warning-text) 45%, var(--border-color));
    color: var(--warning-text);
}
.preset.partial .pv-mark, .preset.partial .pv-ct { color: var(--warning-text); }

/* ── Catalog toolbar ─── */
.cat-toolbar {
    display: flex; align-items: center; gap: 10px;
    background: var(--bg-secondary); border: 1px solid var(--border-color);
    padding: 8px 14px; border-radius: var(--radius); margin-bottom: 14px;
    flex-wrap: wrap;
}
.cat-search { flex: 1; min-width: 180px; max-width: 300px; }
.cat-toolbar input[type="search"] {
    width: 100%;
    padding: 0.35rem 0.55rem; padding-left: 1.8rem;
    border: 1px solid var(--border-strong); border-radius: var(--radius);
    background-color: var(--bg-primary);
    /* A data: URI cannot read a CSS variable, so the stroke colour is
       baked in — hence a second URI for the dark theme below. */
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%23666666' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='m20 20-3.5-3.5'/></svg>");
    background-repeat: no-repeat; background-position: 0.45rem center;
    font-size: 0.82rem; color: var(--text-primary);
    transition: border-color var(--transition), box-shadow var(--transition);
}
[data-theme="dark"] .cat-toolbar input[type="search"] {
    background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' width='14' height='14' viewBox='0 0 24 24' fill='none' stroke='%239e9e9e' stroke-width='2.2' stroke-linecap='round' stroke-linejoin='round'><circle cx='11' cy='11' r='7'/><path d='m20 20-3.5-3.5'/></svg>");
}
.cat-toolbar input[type="search"]:focus,
.cat-toolbar input[type="search"]:focus-visible {
    outline: none; border-color: var(--accent-text); box-shadow: var(--focus-ring);
}
.cat-stat {
    margin-left: auto;
    font-family: var(--font-mono); font-size: 0.75rem;
    color: var(--text-muted); letter-spacing: 0.04em;
    font-variant-numeric: tabular-nums;
}

/* ── Catalog ─── */
.cat-none {
    margin: 12px 0 0; padding: 18px; text-align: center;
    color: var(--text-muted); font-size: 0.85rem;
    border: 1px dashed var(--border-color); border-radius: var(--radius);
}
.cat-grid {
    display: grid; gap: 14px;
    grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
}
/* No surface of its own: these sit inside the builder card, and a card
   inside a card is the boxed look this system rejects. The heading rule
   does the separating. */
.cat-group { padding: 0 0 4px; }
/* The core tier leads the catalog and is marked, not separated. */
.cat-group.core-group { grid-column: 1 / -1; }
.cat-head {
    display: flex; align-items: center; gap: 8px;
    font-family: var(--font-sans); font-size: 0.75rem;
    letter-spacing: 0.16em; text-transform: uppercase; font-weight: 600;
    color: var(--accent-text); margin: 0 0 10px; padding-bottom: 6px;
    border-bottom: 1px solid var(--border-color);
}
.cat-ratio {
    margin-left: auto;
    font-family: var(--font-mono); font-size: 0.75rem;
    font-variant-numeric: tabular-nums; letter-spacing: 0;
    padding: 1px 7px; border-radius: 999px;
    background: var(--surface-sunken); border: 1px solid var(--border-color);
    color: var(--text-muted); text-transform: none;
}
.cat-ratio.some {
    color: var(--warning-text); background: var(--warning-bg);
    border-color: color-mix(in srgb, var(--warning-text) 40%, var(--border-color));
}
.cat-ratio.all {
    color: var(--accent-text); background: var(--accent-tint);
    border-color: color-mix(in srgb, var(--accent-text) 40%, var(--border-color));
}

.svc-row {
    display: grid; grid-template-columns: 20px minmax(0, 1fr) auto;
    gap: 12px; align-items: center;
    padding: 7px 0; border-bottom: 1px solid var(--border-color);
}
.svc-row:last-child { border-bottom: none; }
.svc-row[hidden] { display: none; }
.svc-row .chk-box { width: 18px; height: 18px; accent-color: var(--accent-primary); cursor: pointer; }
.svc-row .chk-box:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }
/* A locked row states why it cannot be unticked instead of showing a
   disabled control the operator would try to click. */
.svc-row .chk.locked {
    width: 18px; height: 18px; border-radius: 4px;
    background: var(--accent-primary); color: #fff;
    display: inline-flex; align-items: center; justify-content: center;
    font-size: 12px; font-weight: 700;
}
.svc-row .nm {
    display: block; min-width: 0;
    font-family: var(--font-sans); font-weight: 500; font-size: 0.95rem;
    color: var(--text-primary); cursor: pointer;
}
.svc-row.locked .nm { cursor: default; }
.svc-row:not(.on) .nm .svc-name { color: var(--text-muted); }
.svc-row .role {
    display: block; font-size: 0.78rem; color: var(--text-muted);
    font-weight: 400; margin-top: 1px;
}
.svc-row .role .dep { color: var(--text-muted); }
.svc-row .tag {
    display: inline-block; font-family: var(--font-sans); font-size: 0.72rem;
    padding: 0.06em 0.45em; border-radius: 999px; margin-left: 8px;
    letter-spacing: 0.04em; vertical-align: middle; font-weight: 500;
    background: var(--surface-sunken); border: 1px solid var(--border-color);
    color: var(--text-secondary);
}
.svc-row .tag.peer {
    background: var(--accent-tint); color: var(--accent-text);
    border-color: color-mix(in srgb, var(--accent-text) 40%, var(--border-color));
}
.svc-row .row-controls {
    display: flex; align-items: center; gap: 8px;
    /* One height for every control on the row: the segmented pickers and
       the replica stepper used to render 26.8px against 18px. */
    --ctl-h: 27px;
}
.svc-row .seg,
.svc-row .qty-stepper { min-height: var(--ctl-h); }
.svc-row .qty-stepper { font-size: 0.78rem; }
/* The stepper on its own does not say what it counts. */
.ctl-label {
    font-size: 0.72rem; color: var(--text-muted);
    letter-spacing: 0.04em; white-space: nowrap;
}
/* Off rows dim their stepper, but the buttons carry `disabled` too — at
   0.35 the digit falls under AA and a tab stop that does nothing is worse
   than no tab stop. */
.svc-row .qty-stepper.off { opacity: 0.5; }

/* An automatic change — a dependency, a preset member — is shown, not
   merely applied. Brief, and it never moves anything. */
.svc-row.just-changed { animation: wz-mark 900ms var(--ease-out); }
@keyframes wz-mark {
    from { background: var(--accent-tint); }
    to   { background: transparent; }
}

/* ── Where it runs ─── */
.place-bar {
    display: flex; align-items: center; gap: 18px; flex-wrap: wrap;
    background: var(--bg-secondary); border: 1px solid var(--border-color);
    border-radius: var(--radius); padding: 12px 16px; margin-bottom: 16px;
}
.place-hosts { display: flex; align-items: center; gap: 10px; }
.place-hosts .field-label { margin: 0; }
.place-rec {
    flex: 1 1 240px; margin: 0;
    font-size: 0.85rem; color: var(--text-muted); line-height: 1.5;
}
.place-rec b { color: var(--text-primary); font-weight: 600; }
/* The board was arranged WITHOUT the contract graph. It sits in the
   spinner's own accessible description because it explains the number in
   it, and it is a text slot, so it takes --warning-text (the fill token
   --warning misses 4.5:1 as text — see the token block). */
.place-degraded { color: var(--warning-text); }
.place-acts { display: flex; gap: 8px; }

.pmx-wrap { overflow-x: auto; border: 1px solid var(--border-color); border-radius: var(--radius); }
.pmx-empty { margin: 0; padding: 28px 20px; text-align: center; color: var(--text-muted); font-size: 0.95rem; }

.pmx { width: 100%; border-collapse: collapse; background: var(--bg-secondary); }
.pmx th, .pmx td { border-bottom: 1px solid var(--border-color); }
.pmx thead th {
    position: sticky; top: 0; z-index: 2;
    background: var(--surface-sunken);
    border-bottom: 1px solid var(--border-strong);
    padding: 8px 10px; text-align: center; vertical-align: bottom;
    font-weight: 500;
}
.pmx-corner {
    position: sticky; left: 0; z-index: 3;
    text-align: left !important; min-width: 190px;
    font-family: var(--font-sans); font-size: 0.75rem;
    letter-spacing: 0.16em; text-transform: uppercase; color: var(--text-muted);
}
.pmx-host { min-width: 108px; }
.pmx-host .ph-ix {
    display: block; font-family: var(--font-mono); font-size: 0.72rem;
    color: var(--text-muted); font-variant-numeric: tabular-nums;
}
.pmx-host .ph-alias {
    width: 100%; max-width: 130px; text-align: center;
    background: var(--bg-primary); border: 1px solid var(--border-color);
    border-radius: var(--radius); color: var(--text-primary);
    font-family: var(--font-mono); font-size: 0.78rem; padding: 0.2rem 0.3rem;
    margin: 2px 0;
}
.pmx-host .ph-alias:focus-visible {
    outline: none; border-color: var(--accent-text); box-shadow: var(--focus-ring);
}
.pmx-host .ph-ct {
    display: block; font-size: 0.72rem; color: var(--text-muted);
}

.pmx-label {
    position: sticky; left: 0; z-index: 1;
    background: var(--bg-secondary);
    text-align: left; padding: 7px 10px; font-weight: 500;
    border-right: 1px solid var(--border-color);
}
.pmx-row.group-start > .pmx-label,
.pmx-row.group-start > .pmx-cell { border-top: 1px solid var(--border-strong); }
.pmx-row:hover .pmx-label,
.pmx-row:hover .pmx-cell { background: var(--surface-sunken); }
.pmx-row.dragging { opacity: 0.5; }
.pmx-label .pl-name {
    font-family: var(--font-sans); font-size: 0.85rem; color: var(--text-primary);
}
.pmx-label .pl-rep {
    display: block; font-size: 0.72rem; color: var(--text-muted);
    font-variant-numeric: tabular-nums;
}
.pmx-label .tag.peer {
    display: inline-block; margin-left: 6px; font-size: 0.72rem;
    background: var(--accent-tint); color: var(--accent-text);
    border: 1px solid color-mix(in srgb, var(--accent-text) 40%, var(--border-color));
    border-radius: 999px; padding: 0.02em 0.4em;
}

.pmx-cell { padding: 0; text-align: center; position: relative; }
.pmx-cell input[type="radio"] {
    position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none;
}
.pmx-cell label {
    display: block; padding: 10px 6px; cursor: pointer;
    transition: background var(--transition);
}
.pmx-cell label:hover { background: var(--accent-tint); }
.pmx-cell .cell-mark {
    display: inline-block; width: 16px; height: 16px; border-radius: 50%;
    border: 1.5px solid var(--border-strong); background: var(--bg-primary);
    transition: background var(--transition), border-color var(--transition), transform var(--press);
}
.pmx-cell input:checked + label .cell-mark {
    background: var(--accent-primary); border-color: var(--accent-primary);
    box-shadow: inset 0 0 0 2.5px var(--bg-secondary);
}
.pmx-cell input:focus-visible + label {
    outline: 2px solid var(--accent-text); outline-offset: -2px;
}
/* The drop target sits among identical cells, so the tint alone does not
   say "here" — it gets a ring as well. */
/* Scoped through .pmx on purpose: .pmx-row.group-start > .pmx-cell sets a
   top border at 0-3-0, which outranks a bare .pmx-cell.drop and left the
   drop target's border unchanged. */
.pmx .pmx-cell.drop {
    background: var(--accent-tint);
    border-color: var(--accent-text);
    box-shadow: inset 0 0 0 2px var(--accent-text);
}
/* The same cell saying it will NOT take this drop, because the host already
   carries another replica of one of the dragged unit's services.

   NO TRANSITION, ON EITHER CLASS — decided, not defaulted. `dragover` fires
   continuously and the class flips at every cell boundary, so a fade would
   still be arriving on a cell the pointer has already left: the highlight
   would describe the WRONG cell, which is not a slower truth but a false one.
   `.drop` above has none for the same reason, and these two are siblings.

   Two signals, never tone alone (the rule this stylesheet states about itself
   two rules down): the cell also takes the native `not-allowed` cursor, and
   the dragged row says in words which service is in the way. */
.pmx .pmx-cell.drop-blocked {
    background: var(--danger-bg);
    border-color: var(--danger-text);
    box-shadow: inset 0 0 0 2px var(--danger-text);
    cursor: not-allowed;
}
/* A collision is named in the summary; here it only has to be findable,
   and it carries a glyph so tone is never the only signal. */
.pmx-cell.bad input:checked + label .cell-mark {
    background: var(--danger-text); border-color: var(--danger-text);
}
.pmx-cell.bad input:checked + label::after {
    content: "!"; position: absolute; top: 2px; right: 6px;
    color: var(--danger-text); font-weight: 700; font-size: 0.72rem;
}

/* ── Where the problem is ──────────────────────────────────────────
   "9 replicas are not assigned to a host" is a count, not a location.
   A row that is part of a blocking condition says so on the row: an
   edge on the sticky label, a tinted lane, and a flag that spells the
   problem out — so the tone is a repeat of the label, never the only
   carrier. Scoped through .pmx because .pmx-row.group-start > .pmx-cell
   is 0-3-0 and outranks anything flatter. */
.pmx .pmx-row.has-problem > .pmx-label { box-shadow: inset 3px 0 0 var(--warning); }
.pmx .pmx-row.prob-peer-collision > .pmx-label,
.pmx .pmx-row.prob-dep-cycle > .pmx-label,
.pmx .pmx-row.prob-same-host > .pmx-label,
.pmx .pmx-row.prob-port-collision > .pmx-label,
.pmx .pmx-row.prob-replica-collapse > .pmx-label { box-shadow: inset 3px 0 0 var(--danger); }
.pmx .pmx-row.prob-unplaced > .pmx-label,
.pmx .pmx-row.prob-unplaced > .pmx-cell { background: var(--warning-bg); }
.pmx-label .pl-flag {
    display: inline-block; margin-left: 6px; font-size: 0.72rem;
    background: var(--warning-bg); color: var(--warning-text);
    border-radius: 999px; padding: 0.02em 0.45em;
}
/* A class selector outranks the UA's `[hidden] { display: none }`, so the
   flag has to opt back out itself — it is toggled by the `hidden` attribute
   from updatePlaceVisuals. */
.pmx-label .pl-flag[hidden] { display: none; }
/* The dragged row's flag while a cell is refusing the drop. It is the WORDS
   half of that refusal, so it wears the same tone as the cell rather than the
   neutral warning every settled row problem wears. Cleared on dragend. */
.pmx-label .pl-flag[data-blocked] {
    background: var(--danger-bg); color: var(--danger-text);
}

/* What this replica needs, and where that is. A dependency on the same host
   is named plainly; one on another host carries an arrow, because the call
   then crosses the network between machines. Not a fault, so no danger tone —
   this is orientation, not a refusal. */
.pmx-label .pl-needs {
    display: block; margin-top: 3px;
    font-size: 0.72rem; color: var(--text-muted);
    line-height: 1.45;
}
.pmx-label .pl-needs[hidden] { display: none; }
.pmx-label .pl-needs em {
    font-family: var(--font-mono); font-style: normal;
    color: var(--text-secondary);
}
.pmx-label .pl-needs em.remote { color: var(--accent-text); }
.pmx-label .pl-needs em.unplaced { color: var(--warning-text); }
.pmx-row.prob-peer-collision .pl-flag,
.pmx-row.prob-dep-cycle .pl-flag,
.pmx-row.prob-same-host .pl-flag,
.pmx-row.prob-port-collision .pl-flag,
.pmx-row.prob-replica-collapse .pl-flag {
    background: var(--danger-bg); color: var(--danger-text);
}

/* "This one." Asked for by name from the summary panel, so it is a ring
   that holds for a beat and then lets the row's own state show again.
   The ring is static; only the settle is animated, which is why reduced
   motion loses nothing. */
.pinpoint {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
    animation: wz-pinpoint 900ms var(--ease-out);
}
tr.pinpoint > .pmx-label, tr.pinpoint > .pmx-cell { background: var(--accent-tint); }
@keyframes wz-pinpoint {
    0%   { outline-color: var(--accent-primary); outline-offset: 3px; }
    100% { outline-color: var(--accent-text);    outline-offset: -2px; }
}

/* ── The board: servers, and what crosses between them ─────────────
   One picture instead of the two that used to stack on the save step. The
   server cards are the Topologies list's own, so the placement is read in a
   visual language the operator already has; the only thing drawn on top is
   the traffic that LEAVES a machine, as an arc in a channel below the row.

   Local calls are not drawn at all — the previous diagram drew every one of
   them into an 18px gutter, which on a busy host is a smear, and the three
   that mattered were lost in it. They are counted in words instead.

   No colour lives in the SVG markup: a presentation attribute cannot read a
   custom property, so every fill and stroke is a class here and themes with
   the rest of the app. */
.cb-block { margin-top: 22px; }
.cb-wrap {
    overflow-x: auto;
    border: 1px solid var(--border-color); border-radius: var(--radius);
    background: var(--surface-sunken);
}
.cb-empty { margin: 0; padding: 16px 14px; color: var(--text-muted); font-size: 0.85rem; }
/* The positioning context the arcs are measured against. Its bottom padding
   is the channel, set in JS because only the laid-out cards know how deep
   the deepest arc has to run. */
.cb { position: relative; }
.cb-hosts { background: transparent; }
/* The grid stretches every card to the tallest; without this the name of a
   near-empty server floats mid-card while its neighbour's sits at the
   bottom, and the row loses the baseline the eye reads it along. */
.cb-hosts .host-server-label { margin-top: auto; }
.cb-links {
    position: absolute; top: 0; left: 0;
    pointer-events: none; overflow: visible;
}

/* An arc is a direction, not a rope: the head points INTO the server being
   depended upon. Tone is never the only signal — the same crossing is a ring
   on both icons and a row of words underneath. */
.cb-halo {
    fill: none; stroke: var(--surface-sunken); stroke-width: 6;
    transition: opacity var(--transition);
}
.cb-arc {
    fill: none; stroke: var(--accent-primary); stroke-width: 2;
    stroke-linecap: round;
    transition: opacity var(--transition), stroke var(--transition);
}
.cb-arrow, .cb-tail {
    fill: var(--accent-primary); stroke: none;
    transition: opacity var(--transition), fill var(--transition);
}
/* The tail dot is the start of the call. It is drawn hollow — a filled dot at
   one end and a filled head at the other read as the same mark twice. */
.cb-tail { fill: var(--surface-sunken); stroke: var(--accent-primary); stroke-width: 2; }
.cb-badge { pointer-events: auto; transition: opacity var(--transition); }
.cb-badge rect {
    fill: var(--bg-secondary);
    stroke: var(--accent-primary); stroke-width: 1;
}
.cb-badge text {
    fill: var(--accent-text); font-size: 0.75rem; font-weight: 600;
    text-anchor: middle; font-variant-numeric: tabular-nums;
}
/* Picking one pair out of the rest: the others step back, they never vanish. */
.cb-links.picking .cb-arc,
.cb-links.picking .cb-halo,
.cb-links.picking .cb-arrow,
.cb-links.picking .cb-tail,
.cb-links.picking .cb-badge { opacity: 0.22; }
.cb-links.picking .lit { opacity: 1; }
.cb-links.picking .cb-arc.lit   { stroke: var(--accent-text); }
.cb-links.picking .cb-arrow.lit { fill: var(--accent-text); }
.cb-links.picking .cb-tail.lit  { stroke: var(--accent-text); }

/* A service icon with a foot in a crossing. The ring is a shape change first
   and a colour second, so it survives a colourblind read. */
/* Quiet on purpose: on a busy board most icons are in some crossing, and a
   mark that fires on two thirds of them is decoration, not a signal. The ring
   only says "this one reaches off the box"; hovering a pair is what makes its
   own icons loud. */
.svc-mini.cb-linked {
    border-radius: 7px;
    box-shadow: 0 0 0 1.5px var(--accent-tint-strong, var(--accent-primary));
    transition: box-shadow var(--transition), background var(--transition);
}
.svc-mini.cb-linked.hot {
    background: var(--accent-tint);
    box-shadow: 0 0 0 2.5px var(--accent-text);
}

/* Asking one icon what it depends on. Two directions, two ring STYLES —
   solid for what it needs, dashed for what needs it — so the answer does not
   rest on hue, and the hint line says the same thing in words either way.
   An outline rather than a box-shadow, because a dashed ring is a border. */
.svc-mini.cb-focus {
    border-radius: 7px; background: var(--accent-tint);
    outline: 2.5px solid var(--accent-text); outline-offset: 1px;
}
.svc-mini.cb-needs {
    border-radius: 7px; background: var(--accent-tint);
    outline: 2px solid var(--accent-primary); outline-offset: 1px;
}
.svc-mini.cb-needed {
    border-radius: 7px; background: var(--accent-tint);
    outline: 2px dashed var(--accent-primary); outline-offset: 1px;
}
/* While one icon is being asked about, the rest of the board steps back so
   the answer is the only thing lit. */
.cb-hosts:has(.cb-focus) .svc-mini:not(.cb-focus):not(.cb-needs):not(.cb-needed) {
    opacity: 0.35;
}

/* The hint line: the board's affordance when nothing is hovered, and the
   answer in words when something is. */
.cb-hint {
    margin: 0 0 10px; font-size: 0.85rem; color: var(--text-muted);
    display: flex; flex-wrap: wrap; align-items: baseline; gap: 0.35em 1.1em;
    min-height: 1.4em;
}
.cb-hint-svc {
    font-family: var(--font-mono); font-weight: 600; color: var(--text-primary);
}
.cb-hint-part b { font-weight: 600; color: var(--text-secondary); }
.cb-hint-part em { font-style: normal; color: var(--text-secondary); }
/* Replicas with no server yet ride the same board, but not as a card: a rack
   bar and a service count would dress the pile up as a fourth machine. It is
   an outline, and it says what it is. */
.cb-unassigned {
    border: 1px dashed var(--warning-text); border-radius: var(--radius);
    background: transparent;
    display: flex; flex-direction: column;
}
.cb-unassigned-head {
    display: flex; align-items: center; gap: 0.5em;
    margin: 0; padding: 8px 10px;
    font-size: 0.78rem; font-weight: 600; color: var(--warning-text);
}
.cb-unassigned-head .ico {
    display: inline-flex; align-items: center; justify-content: center;
    width: 16px; height: 16px; border-radius: 50%; flex: none;
    font-size: 0.72rem; color: var(--bg-secondary); background: var(--warning-text);
}

/* The crossings in words — the board's text alternative and its detail view
   at once. The arc says how many; the row says which. */
.cb-note { margin-top: 12px; }
.cb-head {
    margin: 0 0 8px; font-size: 0.85rem; color: var(--text-secondary);
    display: flex; align-items: baseline; gap: 0.4em; flex-wrap: wrap;
}
.cb-head b { color: var(--text-primary); font-weight: 600; }
.cb-head.cb-ok   { color: var(--success-text); align-items: center; }
.cb-head.cb-wait  { color: var(--warning-text); align-items: center; }
.cb-head.cb-ok .ico, .cb-head.cb-wait .ico {
    display: inline-flex; align-items: center; justify-content: center;
    width: 18px; height: 18px; border-radius: 50%; flex: none;
    font-size: 0.72rem; color: var(--bg-secondary);
}
.cb-head.cb-ok .ico   { background: var(--success-text); }
.cb-head.cb-wait .ico { background: var(--warning-text); }
.cb-flows { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; }
.cb-flow {
    display: flex; flex-wrap: wrap; align-items: baseline; gap: 4px 14px;
    padding: 6px 8px; border-radius: var(--radius);
    transition: background var(--transition);
}
.cb-flow.lit { background: var(--accent-tint); }
.cb-pair {
    font-family: var(--font-mono); font-size: 0.78rem;
    color: var(--text-primary); white-space: nowrap;
}
.cb-to { color: var(--accent-text); padding: 0 0.4em; }
.cb-calls {
    display: flex; flex-wrap: wrap; gap: 2px 16px;
    font-size: 0.85rem; color: var(--text-muted); min-width: 0;
}
.cb-call em { font-style: normal; color: var(--text-secondary); font-weight: 600; }

/* ── Name and save ─── */
.save-name { max-width: 420px; }
/* The pre-save confirmation IS the board (.cb-block above) — one picture of
   the servers and the traffic between them, not a card row and an SVG saying
   the same thing twice. This only spaces it and what hangs off it. */
.save-preview { margin: 22px 0; }
.save-external {
    margin: 10px 0 0; font-size: 0.78rem; color: var(--text-muted);
}
.save-legend { margin-top: 12px; }
.save-legend > summary {
    cursor: pointer; font-size: 0.78rem; color: var(--text-muted);
    padding: 4px 0; width: fit-content;
}
.save-legend > summary:focus-visible {
    outline: 2px solid var(--accent-text); outline-offset: 2px;
}
.save-grid {
    display: grid; gap: 18px;
    grid-template-columns: repeat(auto-fit, minmax(330px, 1fr));
}
.save-pane { min-width: 0; }
.pane-title {
    font-family: var(--font-sans); font-size: 0.75rem;
    letter-spacing: 0.16em; text-transform: uppercase; font-weight: 600;
    color: var(--text-muted); margin: 0 0 10px;
}
.field-hint { margin: 6px 0 0; font-size: 0.78rem; color: var(--text-muted); }
.alias-input {
    width: 100%; padding: 0.45rem 0.6rem;
    border: 1px solid var(--border-strong); border-radius: var(--radius);
    background: var(--bg-primary); color: var(--text-primary);
    font-family: var(--font-mono); font-size: 0.85rem;
}
.alias-input:focus, .alias-input:focus-visible {
    outline: none; border-color: var(--accent-text); box-shadow: var(--focus-ring);
}
.alias-input[readonly] { background: var(--surface-sunken); color: var(--text-muted); }
/* A name the page suggested and nobody has typed over reads as tentative;
   the hint under it says the same in words. Typing (`is-edited`, set from
   state, not from the DOM) gives it the ordinary ink. */
.save-name .alias-input:not(.is-edited):not([readonly]) { color: var(--text-muted); }
.summary-row {
    display: flex; justify-content: space-between; gap: 14px;
    padding: 7px 0; border-bottom: 1px solid var(--border-color); font-size: 0.85rem;
}
.summary-row:last-child { border-bottom: none; }
.summary-row .k { color: var(--text-muted); }
.summary-row .v { color: var(--text-primary); font-family: var(--font-mono); text-align: right; }
#save-summary { margin-top: 18px; }

.yaml-out {
    background: var(--code-bg); color: #d8dee9;
    padding: 12px 14px; border-radius: var(--radius);
    font-family: var(--font-mono); font-size: 0.78rem;
    overflow-x: auto; line-height: 1.5; white-space: pre;
    max-height: 460px; overflow-y: auto;
    /* The shared `*` reset strips the UA `margin: 1em 0` this <pre> was
       relying on — re-declared so the pane spaces as it did. */
    margin: 1em 0 0;
}
.yaml-out .k { color: var(--code-key); }
.yaml-out .s { color: var(--code-str); }
.yaml-out .n { color: var(--code-num); }

/* ── The persistent summary ────────────────────────────────────────
   The only place that decides whether the topology is saveable. It
   reads topologyViolations, and so does the save button, so the panel
   and the button cannot disagree. */
/* .card supplies surface, border, radius and shadow — this only adds the
   sticking and the width, so the rail cannot drift from the app's cards. */
.wz-sum {
    flex: 0 1 310px; min-width: 260px;
    position: sticky; top: 16px; padding: 20px 22px;
}
.sum-title {
    font-family: var(--font-sans); font-size: 0.75rem;
    letter-spacing: 0.16em; text-transform: uppercase; font-weight: 600;
    color: var(--text-muted); margin: 0 0 14px;
}
.sum-counts { display: flex; gap: 18px; margin: 0 0 16px; }
.sum-counts div { display: flex; flex-direction: column; gap: 2px; }
.sum-counts dt {
    font-size: 0.72rem; color: var(--text-muted);
    letter-spacing: 0.06em; text-transform: uppercase;
}
.sum-counts dd {
    margin: 0; font-family: var(--font-mono); font-size: 1.15rem; font-weight: 600;
    color: var(--text-primary); font-variant-numeric: tabular-nums;
}

.sum-state { margin-bottom: 16px; }
.sum-ok, .sum-bad {
    display: flex; align-items: center; gap: 0.5em;
    margin: 0; font-size: 0.85rem; font-weight: 600;
}
.sum-ok { color: var(--success-text); animation: wz-ready 320ms var(--ease-out); }
.sum-bad { color: var(--warning-text); }
.sum-ok .ico, .sum-bad .ico {
    display: inline-flex; align-items: center; justify-content: center;
    width: 18px; height: 18px; border-radius: 50%; flex: none;
    font-size: 0.72rem; color: var(--bg-secondary);
}
.sum-ok .ico  { background: var(--success-text); }
.sum-bad .ico { background: var(--warning-text); }
/* The one authored moment: the panel settling into "ready". */
@keyframes wz-ready {
    from { opacity: 0; transform: translateY(-3px); }
    to   { opacity: 1; transform: translateY(0); }
}

.viol { list-style: none; margin: 10px 0 0; padding: 0; }
.viol li {
    font-size: 0.82rem; color: var(--text-secondary); line-height: 1.5;
    padding: 7px 0; border-bottom: 1px solid var(--border-color);
}
.viol li:last-child { border-bottom: none; }
/* The offenders, named. The list says what is wrong; this says which
   replicas and on which host, so "Show me" is a confirmation rather than a
   surprise. */
.viol-where {
    display: block; margin-top: 3px;
    font-family: var(--font-mono); font-size: 0.75rem;
    color: var(--text-muted); overflow-wrap: anywhere;
}
.viol-acts { display: flex; flex-wrap: wrap; gap: 6px; }
.viol-fix {
    display: inline-block; margin-top: 3px;
    background: transparent; border: 1px solid var(--border-strong);
    border-radius: var(--radius); color: var(--text-primary);
    padding: 0.12rem 0.5rem; font-family: var(--font-sans); font-size: 0.75rem;
    transition: background var(--transition), border-color var(--transition);
}
.viol-fix:hover { background: var(--surface-sunken); border-color: var(--accent-text); }
.viol-fix:focus-visible { outline: 2px solid var(--accent-text); outline-offset: 2px; }

.sum-note { margin: 8px 0 0; font-size: 0.75rem; color: var(--text-muted); }

/* ── Form fields ─── */
.field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 14px; }
.field-label {
    color: var(--text-muted); font-family: var(--font-sans); font-size: 0.75rem;
    letter-spacing: 0.16em; text-transform: uppercase; font-weight: 500;
}
.field-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }

/* Radio segmented */
.seg {
    display: inline-flex; border: 1px solid var(--border-strong);
    border-radius: var(--radius); overflow: hidden;
}
.seg input { position: absolute; width: 1px; height: 1px; opacity: 0; pointer-events: none; }
.seg label {
    padding: 0.3rem 0.7rem; background: var(--bg-secondary);
    color: var(--text-secondary); cursor: pointer;
    font-family: var(--font-sans); font-size: 0.78rem;
    transition: background var(--transition), color var(--transition), box-shadow var(--transition);
    border-right: 1px solid var(--border-strong);
}
.seg label:last-child { border-right: none; }
/* Fill plus weight carries the selection; the inset ring on top of the
   group's own border read as a doubled outline. Two signals remain, so
   nothing rests on colour alone. */
.seg input:checked + label {
    background: var(--accent-tint); color: var(--accent-text); font-weight: 600;
}
.seg input:focus-visible + label { outline: 2px solid var(--accent-text); outline-offset: -2px; }
.seg label:hover { background: var(--surface-sunken); }

/* Stepper number input */
.qty-stepper {
    /* stretch, not center: the three parts share one height, so the digit
       sits on the same optical line as the two signs. */
    display: inline-flex; align-items: stretch;
    border: 1px solid var(--border-strong); border-radius: var(--radius);
    background: var(--bg-secondary); overflow: hidden;
}
.qty-stepper button {
    display: inline-flex; align-items: center; justify-content: center;
    width: 26px; padding: 0; line-height: 1;
    background: var(--bg-secondary); border: none;
    color: var(--text-primary);
    font-family: var(--font-sans); font-weight: 600; font-size: 0.95rem;
    transition: background var(--transition);
}
.qty-stepper button:hover { background: var(--surface-sunken); }
.qty-stepper button:focus-visible { outline: 2px solid var(--accent-text); outline-offset: -2px; }
.qty-stepper button:disabled { opacity: 0.4; cursor: not-allowed; }
.qty-stepper input {
    width: 40px; text-align: center; border: none; background: transparent;
    color: var(--text-primary); font-family: var(--font-mono);
    font-weight: 600; font-size: 0.95rem;
    font-variant-numeric: tabular-nums;
    /* The native spin buttons appear on hover and shove the digit off
       centre; the -/+ buttons already do that job. */
    appearance: textfield; -moz-appearance: textfield;
}
.qty-stepper input::-webkit-outer-spin-button,
.qty-stepper input::-webkit-inner-spin-button {
    -webkit-appearance: none; margin: 0;
}
.qty-stepper input:focus { outline: 2px solid var(--accent-text); outline-offset: -2px; }
.qty-stepper input:disabled { color: var(--text-muted); }
.place-hosts .qty-stepper input { width: 58px; font-size: 1.15rem; }

/* Persistent error banner — used for save failures and DM-unavailable
   preflight. Survives until the user dismisses it. */
.wz-banner {
    display: none;
    background: var(--danger-bg);
    border: 1px solid color-mix(in srgb, var(--danger-text) 50%, var(--border-strong));
    padding: 0.8rem 1rem;
    margin: 0 36px 0;
    border-radius: var(--radius);
    font-family: var(--font-sans); font-size: 0.78rem;
    color: var(--text-primary); line-height: 1.5;
    /* STICKY, not relative. The reason line is the only place that names the
       concrete service pairs, and the recovery it asks for is a scroll down
       to the placement matrix — measured, the banner was 700px above the
       viewport by the time the operator reached the rows it had just named.
       Sticky is the page's existing answer to "this has to stay while you
       work" (the wizard stepper, the summary column, the matrix header); a
       second mechanism for one box would be the odd one out. It outranks
       all of them, because it is the thing that refused the work. */
    position: sticky; top: 0; z-index: 6;
    box-shadow: var(--shadow);
}
/* Declared on the BUILDER'S BODY, not in the shared `:root` token block —
   `e2e/design-system.spec.js` reads every custom property off `/` and demands
   DM's set be identical to cicd-manager's, so a DM-only token up there is a
   cross-app divergence. This is a layout offset for one page, and it belongs
   to that page. Same shape as `--cmp-sticky-top`, one scope down. */
body.wz-body { --wz-banner-h: 0px; }

/* Two sticky bars cannot both own y=0: the stepper is sticky at `top: 16px`
   with the SAME z-index the banner first took, and being later in the DOM it
   simply painted over the refusal — measured, the banner was a 60px sliver
   behind the step card. So the banner takes the higher rank above, and the
   two things pinned under it move down by its measured height while it is
   shown. `showBanner` sets the variable, `hideBanner` removes it, and the
   `:root` default of 0px is every page without a banner. */
.wz-stepper { top: calc(16px + var(--wz-banner-h, 0px)); }
@media (min-width: 901px) {
    .wz-side { top: calc(16px + var(--wz-banner-h, 0px)); }
}
.wz-banner.show { display: block; }
.wz-banner .label {
    color: var(--danger-text); font-weight: 700;
    letter-spacing: 0.08em; text-transform: uppercase;
    font-size: 0.72rem; margin-right: 0.5rem;
}
.wz-banner .label::before { content: "✕ "; }
/* A CONFIRMATION IS NOT A REFUSAL. The rename warning asks the operator
   to press Save again; painting it in the danger colour with a ✕ tells
   them the save failed, which is the one thing that did not happen.
   Same box, same position — only the colour and the mark say which of
   the two events this is. */
.wz-banner.is-check {
    background: var(--warning-bg);
    border-color: color-mix(in srgb, var(--warning-text) 50%, var(--border-strong));
}
.wz-banner.is-check .label { color: var(--warning-text); }
.wz-banner.is-check .label::before { content: "! "; }
.wz-banner .reason {
    display: block; color: var(--text-muted); margin-top: 4px;
    font-size: 0.74rem;
}
.wz-banner .dismiss {
    position: absolute; top: 6px; right: 8px;
    background: transparent; border: none; color: var(--text-muted);
    font-family: var(--font-sans); font-size: 0.95rem; cursor: pointer;
    padding: 2px 6px; line-height: 1;
    transition: color var(--transition);
}
.wz-banner .dismiss:hover { color: var(--text-primary); }
.wz-banner .dismiss:focus-visible { outline: 2px solid var(--danger-text); outline-offset: 2px; }

/* ============================================================
   30. Responsive
   ============================================================ */
/* The wizard's own grids are auto-fit and its shell is flex-wrap, so they
   reflow without a breakpoint. Only the page gutter and the sticky summary
   need one: below this width the summary has wrapped under the work area,
   where sticking it to the viewport would cover the content it describes. */
@media (max-width: 900px) {
    .wz-shell { padding: 20px 20px 48px; gap: 18px; }
    .wz-banner { margin: 0 20px; }
    /* The COLUMN is the flex item now, so the width belongs to it — left
       on `.wz-sum` it shrink-wrapped to its own 260px min and the summary
       became a narrow card under the work area (measured at 860). */
    .wz-side { flex-basis: 100%; }
    .wz-sum { position: static; flex-basis: 100%; }
}

@media (max-width: 768px) {
    .app {
        padding: 12px;
    }

    .header {
        padding: 12px 16px;
        flex-direction: column;
        gap: 12px;
        align-items: stretch;
    }

    .header-left, .header-right {
        width: 100%;
        justify-content: center;
        flex-wrap: wrap;
    }

    .logo {
        font-size: 1.15rem;
    }

    .tabs {
        flex-wrap: wrap;
    }

    .tab {
        flex: 1 1 auto;
        padding: 10px 16px;
        font-size: 0.85rem;
    }

    .card {
        padding: 16px;
    }

    .card-header {
        flex-direction: column;
        align-items: flex-start;
    }

    .resource-summary {
        justify-content: center;
    }

    .resource-item, .resource-card, .summary-card {
        min-width: 100px;
        padding: 12px 16px;
    }

    .filters-row {
        flex-direction: column;
        align-items: stretch;
    }

    .search-box {
        width: 100%;
    }

    .filter-group {
        width: 100%;
        flex-wrap: wrap;
    }

    .filter-group select {
        flex: 1;
    }

    .form-row, .form-grid, .compose-globals-grid {
        grid-template-columns: 1fr;
    }

    .services-grid, .services-list, .info-grid, .port-grid,
    .env-details-grid {
        grid-template-columns: 1fr;
    }

    /* The rail stops being a column and becomes the row of chips above
       the pane — the same list, laid out the only way that fits. */
    .csenv-panel { grid-template-columns: 1fr; }

    .csenv-rail {
        position: static;
        flex-direction: row;
        flex-wrap: wrap;
    }

    .csenv-rail-title { flex-basis: 100%; }
    .csenv-rail-item { width: auto; }
    .csenv-row { grid-template-columns: 1fr; }
    .csenv-desc,
    .csenv-note,
    .csenv-opened,
    .csenv-invalid { grid-column: 1; }

    .data-table {
        font-size: 0.85rem;
    }

    .data-table th,
    .data-table td {
        padding: 8px;
    }

    /* Collapse to markers only; the rail still shows progress. */
    .stepper .step-label { display: none; }

    .stepper {
        padding: 12px 8px;
    }

    .topology-card-head,
    .env-card-head {
        flex-direction: column;
    }

    .topology-card-meta,
    .env-card-meta { align-items: flex-start; margin-left: 0; }

}

@media (max-width: 600px) {
    .wz-stepper .step-label small { display: none; }
    .place-bar { align-items: stretch; }
    .modal, .env-modal { width: 96%; }
    .modal-footer { justify-content: stretch; }
    .modal-footer .btn { flex: 1; }
}

@media (max-width: 480px) {
    .app { padding: 8px; }
    .card { padding: 12px; }
    .topology-card-hosts,
    .env-card-hosts { grid-template-columns: 1fr; }
    .btn { padding: 9px 14px; }
    .toast-container { left: 12px; right: 12px; bottom: 12px; }
}

/* Wide screens: the topology grid earns a second column. */
@media (min-width: 1600px) {
    .topology-grid,
    .env-grid { grid-template-columns: repeat(2, 1fr); }
}

/* Coarse pointers get real touch targets. */
@media (pointer: coarse) {
    .btn, .tab, .compose-view-toggle button, .source-btn,
    .stepper .step-btn, .preset, .viol-fix { min-height: 44px; }
    .pmx-cell label { padding: 14px 6px; }
    .modal-close { min-width: 44px; min-height: 44px; }
}

/* Print: the operator hands the review page to someone. */
@media print {
    .wz-stepper, .wizard-nav,
    .tabs, .header-right, .toast-container { display: none !important; }
    .card, .save-pane, .wz-sum { box-shadow: none; border-color: #999; }
}

/* ============================================================
   31. Reduced motion
   Motion collapses to its final state — every signal above pairs
   colour with a glyph or a label, so nothing is lost with motion off.
   ============================================================ */
/* Reduced motion means less MOVEMENT, not less feedback. Flattening
   every transition to 0.01ms also removed the colour and opacity
   changes that ARE the feedback — a focus ring appearing, a button
   acknowledging a press, a row turning green when a deploy succeeds.
   None of those make anyone ill; movement does. So the properties that
   may transition are narrowed here (transform is not among them) and
   their timing is left alone; movement authored as a transform is
   removed in the enumerated block below. */
@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-property: opacity, color, background-color, border-color,
                             box-shadow, outline-color, fill, stroke !important;
        scroll-behavior: auto !important;
    }
}

@media (prefers-reduced-motion: reduce) {
    .btn:active:not(:disabled),
    .btn-primary:hover:not(:disabled),
    .badge-button:active,
    .theme-toggle:active,
    .port-item.port-clickable:hover,
    .host-server-card:hover,
    .svc-mini:hover svg {
        transform: none;
    }
    .cmp-card-chevron,
    details.form-group > summary.form-group-title::before {
        transition: none;
    }
    /* Both chevrons' motion is gated on a (0,3,2) selector, and @media
       adds no specificity — so the bare `.cmp-card-chevron { transition:
       none }` above LOSES to it (same specificity, and the motion rule
       comes later in the file) and never applies. Measured, so the
       record is right: what actually held the wizard's chevron still was
       the blanket `*` rule at the top of this section, so the leak was
       0.01ms rather than the 140ms the gated selector asks for.

       `!important` is what makes this rule do anything at all — without
       it, it cannot beat that blanket either, and the guarantee stays a
       comment. It is worth stating on these elements rather than
       inheriting it from a blanket a later edit could scope or drop. */
    details.env-card-disclosure > summary[data-disclosure-input="pointer"] .env-card-chevron,
    details.compose-service-form > summary[data-disclosure-input="pointer"] .cmp-card-chevron {
        transition: none !important;
    }
}

@media (prefers-reduced-motion: reduce) {
    .spinner,
    .loading-spinner,
    .btn.is-loading::before,
    .btn[data-loading="true"]::before,
    .data-table .loading::before {
        animation: none;
        border-top-color: var(--border-strong);
    }
}

@media (prefers-reduced-motion: reduce) {
    .tab-content.active,
    .wizard-step.active,
    .cdep-explain:not([hidden]),
    .cdep-error:not([hidden]),
    .env-card.just-created,
    .wz-sec,
    .toast {
        animation: none;
    }
}

/* Step 4's refusal: the server's own sentence first, then what to try. The
   disclosure that used to hide the exception is gone — the panel led with a
   guessed cause and filed the true one under "Technical detail".
   No measure cap: the panel matches the width of the review block it explains
   (asked for twice). A capped paragraph under a full-width <pre> reads as a
   layout that failed to fill, which costs more than the long line does. */
.cmp-step4-panel {
    padding: 12px 14px;
    border: 1px solid color-mix(in srgb, var(--warning-text) 45%, var(--border-color));
    border-radius: var(--radius);
    background: var(--warning-bg);
}
.cmp-step4-reason {
    margin: 8px 0 0;
    font-size: 0.85rem;
    color: var(--text-secondary);
    overflow-wrap: anywhere;
}

/* A secret whose NAME is declared by more than one service. deploy.sh
   prompts once per host per name, so the collision is real and silent. */
.form-help-shared-secret { color: var(--warning-text); }

/* The tag picker sits in a flex row and now carries min-width: 0, so
   without a basis it collapses to the width of whatever tag is selected —
   and the popup, which starts at 100% of it, collapses with it. */
.compose-image-row .ui-select { flex: 1 1 14rem; max-width: 22rem; }

/* ============================================================
   SSH access — the list-view indicator + the per-server panel
   ============================================================
   Deploy and Health are the two actions that reach a customer's
   servers, and both need stored credentials. Whether an install has
   them is a state worth a column: it separates an installation this
   tool can drive from one that ships as a bundle and is installed on
   site. The badge is that state AND the way into the panel that fills
   it, so on the admin path it is a real <button> — never a div with a
   click handler. */

.badge-button {
    border: 1px solid transparent;
    cursor: pointer;
    font-family: inherit;
    /* .badge sets the rest; a button resets what a span never had. */
}

.badge-button:hover {
    border-color: var(--border-control);
}

/* It is a button and it opens a panel — pressing it has to feel like
   the interface heard you. 160ms so the feedback lands inside the
   press, not after it. */
.badge-button {
    transition: transform 160ms var(--ease-out),
                border-color 160ms var(--ease-out);
}

.badge-button:active {
    transform: scale(0.97);
}

.badge-button:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}

/* The panel's verdict line: the same badge, followed by the sentence
   that says what it means for Deploy. */
.ssh-summary {
    display: flex;
    align-items: baseline;
    gap: 0.5rem;
    flex-wrap: wrap;
    margin: 0.75rem 0;
}

/* One card per server — the modal is 500px and a five-column table put
   its buttons past the right edge. Mirrors .health-server-card next
   door, which reaches for cards for the same reason. */
.ssh-server-list {
    display: flex;
    flex-direction: column;
    gap: 0.5rem;
}

.ssh-server-card {
    display: flex;
    flex-direction: column;
    gap: 0.35rem;
    padding: 0.6rem 0.75rem;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background: var(--bg-secondary);
}

/* A server with no access stored. The left edge marks it in addition to
   the "none" badge and the sentence under it — one signal is text, one
   is position, and neither is colour alone. */
.ssh-row-unset {
    border-left: 3px solid var(--warning-text);
}

.ssh-card-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 0.5rem;
}

.ssh-card-id {
    display: flex;
    flex-direction: column;
    min-width: 0;
}

.ssh-card-id .form-help { overflow-wrap: anywhere; }

.ssh-card-user { font-weight: 500; }

.ssh-card-detail { font-size: 0.85rem; }

.ssh-card-actions {
    display: flex;
    gap: 0.4rem;
    justify-content: flex-end;
}

/* Read-only identification of the server being edited. Not an input:
   which server this is was decided by the row that opened the form. */
.form-static {
    margin: 0;
    padding: 10px 0;
    font-size: 0.9rem;
    color: var(--text-primary);
}

.radio-row {
    display: flex;
    gap: 1.25rem;
    flex-wrap: wrap;
    padding: 6px 0;
}

.radio-option {
    display: inline-flex;
    align-items: center;
    gap: 0.4rem;
    font-weight: 400;
    cursor: pointer;
}

.radio-option input {
    /* .form-field input above gives every input the text-field box; a
       radio is not one. */
    width: auto;
    padding: 0;
    margin: 0;
    accent-color: var(--accent-primary);
}

/* The key textarea keeps a monospace face — a PEM body is content whose
   line structure matters, and a proportional font hides a stray wrap. */
#cssh-key {
    font-family: var(--font-mono);
    font-size: 0.75rem;   /* ramp step — see DESIGN.md typography */
    resize: vertical;
    min-height: 8rem;
}

/* ============================================================
   Icon-only buttons — the topology card's delete
   ============================================================
   A ✕ on a card reads as "dismiss this", which is what the modal's ✕
   does; the topology card's button deletes a stored topology. The bin
   states that. Square padding so the icon sits centred rather than
   inheriting the text button's wider horizontal padding. */

.btn-icon {
    padding: 6px;
    line-height: 0;
}

.icon-trash {
    width: 14px;
    height: 14px;
    display: block;
    fill: none;
    /* Follows .btn-ghost-danger's text colour, so it re-themes with the
       button instead of pinning a literal that only works in one theme. */
    stroke: currentColor;
    stroke-width: 1.2;
    stroke-linecap: round;
    stroke-linejoin: round;
}

/* ============================================================
   Step 3 — the fields a "Changed" card is talking about
   ============================================================
   The card header names one edit and counts the rest; on a service with
   67 fields that is a name to go hunting with, not an answer. These
   marks put the edits where the eye already is. Same accent the
   `Changed` status pill uses, so the card and its body read as one
   statement.

   Colour is never the only signal: the field also carries the `edited`
   chip beside its label (kept in sync by _composeSyncEditedChip), and
   the header still names it. */

.form-field-changed {
    /* The bar sits outside the control, so it marks the field without
       competing with the focus ring or the invalid border. */
    border-left: 2px solid var(--accent-text);
    padding-left: 0.6rem;
    margin-left: -0.75rem;
}

.form-field-changed > label {
    color: var(--accent-text);
}

.form-field-changed .form-field-origin[data-origin="edited"] {
    color: var(--accent-text);
    background: var(--accent-tint);
}

/* "Fill from topology" — sits on the label line, beside the name, so it
   reads as an offer attached to THIS field rather than a form action. */
.compose-suggest-btn {
    margin-left: 0.5em;
    padding: 2px 8px;
    font-size: 0.75rem;
    font-weight: 400;
    vertical-align: middle;
}

/* Which machine a service card is configuring, and what a host will run.
   Both answer the same question from opposite ends — "is this the one I
   think it is" — so they share the muted, second-read weight. */
.cmp-card-placement {
    display: block;
    margin-top: 2px;
    font-size: 0.75rem;
    color: var(--text-muted);
}

.compose-instance-runs {
    margin-top: 4px;
    font-size: 0.75rem;
    overflow-wrap: anywhere;
}


/* ============================================================
   32. Installations — the card view.

   The card SHELL is shared with the Topologies card — the two screens
   draw the same kind of object, one as a design and one as an install
   of that design — but through its OWN class names, joined onto the
   same rules above rather than by reusing `.topology-*`. Both tabs are
   in the DOM at once (the inactive one is display:none), so a shared
   class makes every global `.topology-grid` / `.topology-card` selector
   ambiguous: Playwright resolves such a locator to the FIRST match,
   which is the hidden Installations copy, and then waits for it to
   become visible forever. One treatment, two vocabularies — a
   `.topology-card` is a topology.
   ============================================================ */

/* An installation name is machine text — it is typed into deploy.sh,
   grepped in logs, and read character by character. The Topologies
   card's display face is right for a human-authored topology name and
   wrong for this. */
.env-card-name {
    font-family: var(--font-mono);
    font-size: 0.95rem;
}

.env-card-upgrade {
    margin-top: 6px;
}

/* ── The card's disclosure ───────────────────────────────────────────
   Structure and behaviour follow the wizard's per-service card
   (`details.compose-service-form` + `.cmp-card-*` above), which is the
   in-repo collapsible CARD; `details.form-group` is a form sub-section
   and the wrong precedent for this. The wizard's own look — the
   accent-tint [open] background, its border, its "Review/Hide" label —
   deliberately stays behind: this card already has a border and a head,
   and the installations list is not a wizard step.

   The summary carries the identity AND the counts, so a collapsed card
   never reads as empty, and it is the card's primary control now, hence
   the 44px floor. There is no open/close animation: <details> cannot
   animate its own height without JS, and a layout-property transition
   is forbidden here (tests/design-tokens.test.js). The chevron is the
   one thing that moves. */
details.env-card-disclosure > summary.env-card-head {
    cursor: pointer;
    list-style: none;
    min-height: 44px;
    user-select: none;
    /* One pointer-enter, one speed: `.env-card` already eases its
       border-color and box-shadow over --transition, so a tint that
       snapped under them read as two separate responses. */
    transition: background-color var(--transition);
}

details.env-card-disclosure > summary.env-card-head::-webkit-details-marker {
    display: none;
}

/* The summary takes `user-select: none` so a double-click toggles rather
   than selecting the row — but the installation name is the one thing on
   this card an operator copies: it is typed into deploy.sh and grepped in
   logs, which is why it is set in --font-mono a few rules above. It opts
   back out. A click still toggles; a drag selects. */
details.env-card-disclosure > summary.env-card-head .env-card-name {
    user-select: text;
}

details.env-card-disclosure > summary.env-card-head:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
}

/* Closed, the summary's rule and the foot's meet as two adjacent 1px
   lines with nothing between them. One border is the card's waist. */
details.env-card-disclosure:not([open]) > summary.env-card-head {
    border-bottom: none;
}

/* A CLOSED CARD'S CONTENT HAS TO BE HONESTLY ABSENT, not merely unpainted.
   Chromium hides a closed `<details>` with `::details-content {
   content-visibility: hidden }`, which skips PAINT and leaves the subtree
   reporting layout: measured on the live list, a collapsed card's
   `.env-card-hosts` answered `offsetParent` truthy, one client rect, and a
   box at y 671-790 — inside the FOOT's own 599-823, which is what is
   actually drawn there. So every consumer that asks the DOM "is this on
   screen" was told yes about a strip nobody can see or click:
   `elementFromPoint` at the host strip's centre returned `.env-card-foot`,
   Playwright called the element visible and then refused the click as
   obscured, and the training's own `appears` predicate — which tests
   exactly `offsetParent || getClientRects().length` — would have counted
   it as shown.

   `display: none` removes it from layout, so the answer to that question
   becomes the truth for everyone at once. Nothing is lost: this
   disclosure has no open/close animation to interrupt (there is nothing
   to animate — see the block above), and the content is one attribute
   away as it always was. */
details.env-card-disclosure:not([open]) > .env-card-body {
    display: none;
}

/* Press feedback, the same pair the wizard's summary uses. Without it a
   44px control answers a press with nothing but the chevron, which is
   the one thing a touch operator's finger is covering. */
details.env-card-disclosure > summary.env-card-head:active {
    background: var(--surface-interactive-hover);
}

@media (hover: hover) and (pointer: fine) {
    details.env-card-disclosure > summary.env-card-head:hover {
        background: var(--surface-interactive);
    }
}

/* Blockified inside the flex head; without this the description sits on
   the name's line, because a <span> is what <summary> may contain. */
.env-card-desc {
    display: block;
}

/* A real element rather than a `::before`: the runtime motion gate in
   e2e/design-system.spec.js walks `.app *`, which pseudo-elements are
   not in — a pseudo chevron would carry an unmeasured transition. */
.env-card-chevron {
    display: inline-flex;
    flex: 0 0 auto;
    align-self: center;
    width: 0.5rem;
    height: 0.5rem;
    border-right: 1.5px solid var(--text-muted);
    border-bottom: 1.5px solid var(--text-muted);
    transform: rotate(-45deg);
    transform-origin: center;
}

details.env-card-disclosure[open] .env-card-chevron { transform: rotate(45deg); }

/* --ease-out for a transform (DESIGN.md:119), NOT var(--transition),
   which is the colour/background hover easing. Gated on the input that
   opened it: a keyboard toggle moves without motion. */
details.env-card-disclosure > summary[data-disclosure-input="pointer"] .env-card-chevron {
    transition: transform 140ms cubic-bezier(0.23, 1, 0.32, 1);
}

/* Created and By — the two facts the deleted table carried and the card
   did not. Second-read, so they close with the detail. */
/* `Created 9 days ago · by <name>` and the button that asks who ELSE can
   change it — one line, and the three of them have to sit on ONE
   baseline.

   They did not. The name is width-capped, and a cap needs a box that is
   not a bare inline one, so it was `inline-block` + `vertical-align:
   bottom` — which aligns it with the bottom of the LINE BOX, and the
   line box is as tall as the button beside it. Measured on the running
   card: the text at 648-660 and the name at 651.5-668.3, bottom-aligned
   with the button and 3.5px below the words it belongs to. Reported
   2026-08-29.

   A baseline-aligned flex row fixes it at the root rather than by
   guessing an offset: the cap now works because a flex ITEM is not an
   inline box either, so `vertical-align` is not needed at all and there
   is nothing left to be wrong about. `gap` carries the spacing the
   literal spaces used to, since flex collapses those. */
.env-card-provenance {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 4px;
    color: var(--text-muted);
    /* 0.85rem, not 0.75. `0.75rem` is the ramp's PILL step — the size
       the uppercase chips wear — and this is a SENTENCE: at a 14px root
       it rendered at 10.5px while every comparable second-read line on
       the same card (`.form-help`, the offline-bundle text) sits at
       11.9px, so the one line naming a person was the smallest text on
       the screen. Reported 2026-08-29. */
    font-size: 0.85rem;
}

.env-card-body {
    padding: 12px 18px;
    display: flex;
    flex-direction: column;
    gap: 12px;
}

/* ── Stage bars ──────────────────────────────────────────────────────
   The design system's Stage-bars component (DESIGN.md), following
   cicd-manager's .rel-stage reference implementation. Three deliberate
   divergences from it.

   An earlier version of this comment named the wrong one: it claimed
   cicd paints its stage LABELS with the --success / --warning fill
   tokens and that DM diverges by using the *-text foregrounds. That is
   false — cicd/frontend/css/styles.css:3205-3209 uses --text-secondary
   on `done` and --accent-text on `current`, byte-identical to the rules
   below. The claim was corrected in CLAUDE.md and in js/compose-list.js
   and missed here, which left the wrong story sitting in the one file a
   developer edits the stage bars in. What actually differs:
     - 0.75rem labels, not cicd's 0.68rem, which is off the DM type ramp;
     - the `current` BAR is FILLED with --accent-text, a token DESIGN.md
       documents for text/icon/border/focus-outline and not as a fill. A
       4px bar reads as a mark rather than a surface, and the only other
       candidate (--accent-primary) is already the terminal `generated`
       bar's — two steps painted alike would erase the one distinction
       the rail exists to draw. Non-text UI, so the 3:1 bar applies;
     - no glow animation on the current step. This rail renders once per
       installation on the landing screen, so cicd's 1.8s pulse would be
       a permanent repaint on every row of a list.

   There are three steps and there is no success tone on the last one.
   See the stage contract in js/compose-list.js: nothing in the data
   distinguishes a downloaded bundle from a half-failed deploy, so the
   rail stops at "a bundle was generated" and says it in a neutral
   voice. */

.env-stages {
    display: flex;
    gap: 6px;
}

.env-stage {
    flex: 1 1 0;
    min-width: 0;
    display: flex;
    flex-direction: column;
    gap: 5px;
}

.env-stage__bar {
    height: 4px;
    border-radius: 999px;
    background: var(--surface-sunken);
}

.env-stage__label {
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.01em;
    color: var(--text-muted);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.env-stage__detail {
    font-size: 0.75rem;
    color: var(--text-muted);
    font-family: var(--font-mono);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

.env-stage--done .env-stage__bar      { background: var(--success); }
.env-stage--done .env-stage__label    { color: var(--text-secondary); }

.env-stage--current .env-stage__bar   { background: var(--accent-text); }
.env-stage--current .env-stage__label { color: var(--accent-text); }

/* Reached AND contradicted: somebody claimed this install, and a newer
   bundle has been generated or delivered since — so the site is running
   the older one. Warning, not danger: nothing is broken and no claim is
   being withdrawn; what changed is that the claim no longer describes
   the current bundle. `--warning-text` rather than `--warning`, which is
   tuned to carry white and fails as text (see the token block). The
   colour only weights it — the flag below spells it out, and there is
   deliberately no pulse: this rail renders once per installation on the
   landing screen, and a permanent animation there is repaint per row for
   a state a sentence already carries. */
.env-stage--stale .env-stage__bar   { background: var(--warning-text); }
.env-stage--stale .env-stage__label { color: var(--warning-text); }

.env-stage__flag {
    display: flex;
    align-items: flex-start;
    gap: 4px;
    font-size: 0.75rem;
    font-weight: 600;
    line-height: 1.3;
    color: var(--warning-text);
    /* The one line in this rail that must never be ellipsed away: it is
       the whole warning, and the label and detail above it are already
       truncated by design. */
    white-space: normal;
}

.env-stage__flag svg {
    flex-shrink: 0;
    width: 14px;
    height: 14px;
    /* The glyph sits on the first line's cap height rather than on the
       box top, where a 14px mark beside 0.75rem text reads as raised. */
    margin-top: 1px;
}

/* Reached, and deliberately NOT the success tone: "a bundle exists" is
   a fact; "this is deployed and healthy" is not one this data supports. */
/* `generated` was the terminal state back when the rail stopped at
   "Bundle generated" and had to avoid reading as a success — the data
   could not tell a download from a half-failed deploy. Delivered and
   Installed sit above it now, both sourced from facts (a download row,
   a person's claim), so a generated bundle is an ordinary completed
   step and is painted `done` like the rest. The class is kept as an
   alias so a cached page mid-deploy does not lose its rail. */
.env-stage--generated .env-stage__bar   { background: var(--success); }
.env-stage--generated .env-stage__label { color: var(--text-secondary); }

/* ── Reachability, on its own line ───────────────────────────────────
   Not a fourth stage: SSH access is a precondition for Deploy, not a
   step after generating the bundle. The sentence is visible because
   "1/3" alone does not say that Deploy stops at the first server it
   cannot reach. */
.env-card-ssh {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
}

.env-card-ssh-text {
    font-size: 0.85rem;
    color: var(--text-secondary);
}

/* ── What the last test install of this bundle did ───────────────────
   The SSH line's shape, because it answers the same class of question
   about the same installation and sits directly under it. Not a sixth
   pip on the stage rail: that rail is about the customer's servers, and
   a rehearsal is neither derived from them nor claimed about them.
   `margin-top` and not a `gap` on the foot, so the two lines read as a
   pair rather than as two unrelated bands. */
.env-card-lab {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
    margin-top: 4px;
}

.env-card-lab-text {
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.env-card-lab-icon {
    display: inline-flex;
    flex: 0 0 auto;
    color: var(--text-secondary);
}

/* Three signals, never colour alone: the sentence says what happened,
   the glyph marks it, and the tone weights it. A failure is danger; a
   pass whose bundle has since been rebuilt is a WARNING — nothing is
   broken, what changed is that the run no longer describes the bundle. */
.env-card-lab--passed .env-card-lab-icon { color: var(--success-text); }
.env-card-lab--failed .env-card-lab-icon,
.env-card-lab--failed .env-card-lab-text { color: var(--danger-text); }
.env-card-lab--stale .env-card-lab-icon,
.env-card-lab--stale .env-card-lab-text { color: var(--warning-text); }

/* ── Why the write actions are off ───────────────────────────────────
   Same shape as the SSH line above it and for the same reason: the
   sentence is VISIBLE rather than hiding in a `title`, because a
   collapsed card whose Deploy is greyed out must still say why, and a
   `title` is unreachable on touch. It carries the id every muted write
   button on the row points `aria-describedby` at. */
.env-card-access {
    display: flex;
    align-items: center;
    gap: 8px;
    flex-wrap: wrap;
    margin-bottom: 8px;
}

.env-card-access-text {
    font-size: 0.85rem;
    color: var(--text-secondary);
    /* The sentence, not the button, is what should give way when the
       row narrows — the way out must stay on one line. */
    flex: 1 1 12rem;
    min-width: 0;
}

/* The same sentence ONE level up, for a role that has no buttons on any
   card and would otherwise be told nothing at all. It is not inside a
   card, so it needs the plate the card's foot already gives it: same
   sunken surface and same padding as `.env-card-foot`, on the spacing
   scale. `margin-bottom` is inherited from `.env-card-access`. */
.env-list-readonly {
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: 8px;
    padding: 12px 18px;
}

/* In the BODY, beside who created it: a second-read fact, not a control
   the operator needs on every row. Pulled out of the text flow so it
   does not sit on the sentence's baseline. */
.env-card-access-who {
    margin-left: 8px;
}

/* ── "Who can change this" panel ─────────────────────────────────────
   Three answers stacked in reading order: who owns it, who else was let
   in, and what happened to my own ask. Plain rhythm — nothing here is a
   control except the one at the bottom. */
.cac-owner { margin-bottom: 14px; }

/* An installation nobody owns. Stated, not decorated: it is a fact
   about the installation, not an error of ours, so it gets the muted
   voice the rest of this panel uses for facts — and the sentence under
   it does the explaining. Sits tight to that sentence. */
.cac-owner--none {
    color: var(--text-secondary);
    margin-bottom: 4px;
}

/* The administrator's two blocks. Each opens with a label, and a label
   set directly under the sentence above it reads as the next line of
   that sentence — measured in the browser: "Nobody else has been granted
   access" and "Give write access to" arrived as one paragraph. Both get
   the air two form groups get anywhere else in this app. */
.cac-give,
.cac-adopt { margin-top: 18px; }

.cac-heading {
    margin: 0 0 6px;
    /* The ramp's smallest step (tests/design-tokens.test.js). */
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-secondary);
}

.cac-grants {
    margin: 0 0 14px;
    padding-left: 18px;
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.cac-grantee { font-weight: 500; }

/* What happened to MY request — the line the operator came back for,
   so it is set off from the list above rather than trailing it. */
.cac-mine {
    margin: 0 0 14px;
    padding: 10px 12px;
    border-radius: var(--radius);
    background: var(--surface-sunken);
    font-size: 0.95rem;
}

.cac-ask { margin-top: 4px; }

/* ── Action zone ─────────────────────────────────────────────────────
   Exactly one button here carries the solid fill
   — see composeEnvPrimaryAction. Delete is pushed to its own end of
   the row rather than sitting flush against Live Test. */
.env-actions {
    display: flex;
    gap: 6px;
    flex-wrap: wrap;
    align-items: center;
}

/* The always-visible foot. A plain block wrapper: it carries the
   border-top, the sunken surface and the padding ONCE, for both the SSH
   line and the action row. Making the sentence a flex ITEM of
   `.env-actions` instead would recreate the bug `.env-actions-note` was
   invented to fix — without a flex-basis it wrapped into a 94px column
   134px tall at 820. */
.env-card-foot {
    padding: 12px 18px;
    border-top: 1px solid var(--border-color);
    background: var(--surface-sunken);
}

/* Unchanged as a flex row; only its chrome moved up to the foot. 12px is
   the rhythm the foot's own padding and the body's gap already use. */
.env-card-actions {
    margin-top: 12px;
}

.env-action-delete {
    margin-left: auto;
}

/* Sets `Test install` apart from the two actions beside it that read a
   customer's machine.

   MEASURED, and the first cut did not set anything apart: a 1px rule at
   `--border-color` is 1.45:1 on the card foot, and 2px of margin on a
   6px gap made the whole difference 10px against 6px — 4px, which
   nobody sees. The hairline is `--text-muted` (5.07:1 on that ground,
   and it is a boundary rather than decoration), and the space around it
   is the `md` token, so the gap either side is genuinely double the
   row's own. Height, not `stretch`: the row wraps, and a full-height
   rule on a wrapped line draws past the buttons. */
.env-actions-sep {
    width: 1px;
    height: 18px;
    margin: 0 12px;
    background: var(--text-muted);
    flex: 0 0 auto;
}

.env-card-meta-shape {
    font-style: italic;
}

/* Single, short labels. Left to wrap they broke mid-word ("Se… S",
   "Depl", "Dow") and a button could not be read at all; nothing is
   gained by breaking them. */
.env-actions .btn {
    white-space: nowrap;
}

/* The author value renders the address's local part verbatim, which is
   readable where initials were not — and unbounded, where the initials
   were a width fix. So the cap is here rather than in the string: the
   value truncates, and the full address is in `title` for the one time
   it matters. 18ch fits `vadim.kasperovich`; a longer one ellipsises
   rather than widening the card, which is the problem the initials were
   introduced to solve. The class name is historical — it was a <td>, and
   `display: inline-block` is what carries the contract across the move:
   max-width, overflow and text-overflow do nothing at all on a
   non-replaced INLINE box, so in the provenance text run the cap would
   have silently stopped existing. */
/* The cap on an author's name. No `display` and no `vertical-align`:
   it is a flex item of `.env-card-provenance`, which is neither an
   inline box (so the cap applies) nor baseline-broken (so it sits with
   the words around it). Both were needed while this was a text run —
   see that rule for what they cost. */
.env-by-cell {
    max-width: 18ch;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* ── A load that failed ──────────────────────────────────────────────
   "Failed to load installations: TypeError: Failed to fetch" named the
   browser's internals at an operator who can act on neither word. The
   sentence says what is and is not known; the exception stays, below it
   and in `title`, because it is what gets pasted into a ticket. */
.env-load-error {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 8px;
}

/* An error that is typeset exactly like the empty state reads as the
   empty state — which is the one thing this box exists not to be
   mistaken for. Tone + glyph, never tone alone. */
.env-load-error-text {
    margin: 0;
    font-weight: 600;
    color: var(--danger-text);
}

.env-load-error-text::before {
    content: '⚠ ';
}

/* The container takes focus after a delete removes the control that had
   it. A scroll destination, not a control, so it draws no ring. */
#composeViewContainer:focus {
    outline: none;
}

@media (max-width: 768px) {
    /* Below this the three labels ellipse into initials and stop being
       readable, so the rail keeps its bars and drops to two lines. */
    .env-stage__label,
    .env-stage__detail {
        white-space: normal;
    }

    .env-action-delete {
        margin-left: 0;
    }

    /* The head goes column at this width (see the earlier block), which
       turns the chevron's `align-self` into a HORIZONTAL rule — it would
       centre itself on a row of its own under the content, as a stray
       glyph. It stays in the corner it occupies at every other width.
       This lives HERE and not beside that flex-direction switch because
       `.env-card-chevron` is defined between the two, and on equal
       specificity the later rule wins. */
    .env-card-chevron {
        align-self: flex-end;
    }
}

/* ── The delivery band on an installation card ────────────────────
   One statement of where the bundle got to, with the two things an
   operator does next beside it. It replaced two rows of ghost button +
   sentence that read as leftovers; a tinted plate with a drawn mark
   makes it the one place on the card that talks about the customer's
   site rather than about this tool. */
.env-card-delivery {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 12px;
    margin-bottom: 12px;
    padding: 10px 14px;
    border: 1px solid hsl(var(--status-info-border));
    border-radius: var(--radius);
    background: hsl(var(--status-info-bg));
}

.env-card-delivery.is-installed {
    border-color: hsl(var(--status-success-border));
    background: var(--success-bg);
}

.env-card-delivery-icon {
    flex: none;
    display: inline-flex;
    width: 20px;
    height: 20px;
    color: var(--accent-text);
}

.env-card-delivery.is-installed .env-card-delivery-icon {
    color: var(--success-text);
}

.env-card-delivery-icon svg {
    width: 20px;
    height: 20px;
    fill: none;
    stroke: currentColor;
    stroke-width: 1.5;
    stroke-linecap: round;
    stroke-linejoin: round;
}

.env-card-delivery-text {
    flex: 1 1 260px;
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
}

.env-card-delivery-title {
    font-size: 0.875rem;
    font-weight: 600;
    color: var(--text-primary);
}

.env-card-delivery-note {
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.env-card-delivery-actions {
    display: flex;
    align-items: center;
    gap: 8px;
    flex: none;
}

/* ── Install steps ────────────────────────────────────────────────
   A numbered rail an operator works down while logged into a customer's
   server. The commands are the content, so they get the code surface and
   the room to be selected; everything else recedes. */
.install-steps-wrap { display: block; }

.isteps-file {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 10px;
    padding-bottom: 12px;
    border-bottom: 1px solid var(--border-color);
}

/* The operator has this file in a Downloads folder, possibly beside the
   other flavour — so it is set as the literal it is. */
.isteps-file-name {
    font-family: var(--font-mono);
    font-size: 0.95rem;
    font-weight: 500;
    color: var(--text-primary);
    overflow-wrap: anywhere;
}

.isteps-intro {
    margin: 12px 0 20px;
    color: var(--text-secondary);
    font-size: 0.85rem;
}

/* The rehearsal offer, above the checklist it rehearses. A passive
   plate rather than a card: it is one offer, not a choice between two
   objects, and `composeChoiceCard` earns its shape from comparison. */
.isteps-rehearsal {
    margin: 0 0 20px;
    padding: 12px;
    background: var(--surface-sunken);
    border-left: 3px solid var(--border-control);
    border-radius: 4px;
}

.isteps-rehearsal .form-help { margin: 0 0 8px; }

.isteps-rehearsal-fact {
    margin: 0 0 4px;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-primary);
}

/* The state of the last run weights the rule, never replaces the
   sentence: a failure is the case where rehearsing again is the point. */
.isteps-rehearsal.is-failed { border-left-color: var(--danger-text); }
.isteps-rehearsal.is-failed .isteps-rehearsal-fact { color: var(--danger-text); }
.isteps-rehearsal.is-stale { border-left-color: var(--warning-text); }
.isteps-rehearsal.is-stale .isteps-rehearsal-fact { color: var(--warning-text); }
.isteps-rehearsal.is-passed { border-left-color: var(--success-text); }
.isteps-rehearsal.is-passed .isteps-rehearsal-fact { color: var(--success-text); }

.isteps {
    list-style: none;
    margin: 0;
    padding: 0;
}

/* The rail: a hairline runs from each marker to the next, so six steps
   read as one sequence rather than six cards. */
.istep {
    position: relative;
    padding: 0 0 20px 36px;
}

.istep::before {
    content: '';
    position: absolute;
    left: 12px;
    top: 26px;
    bottom: 4px;
    width: 1px;
    background: var(--border-color);
}

.istep:last-child { padding-bottom: 0; }
.istep:last-child::before { display: none; }

.istep-head {
    display: flex;
    align-items: center;
    gap: 10px;
    cursor: pointer;
    /* The whole head is the target, so a tick never needs aim. */
    padding: 2px 0;
}

.istep-marker {
    position: absolute;
    left: 0;
    top: 2px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 25px;
    height: 25px;
    border: 1px solid var(--border-control);
    border-radius: 999px;
    background: var(--bg-secondary);
    color: var(--text-secondary);
    font-size: 0.75rem;
    font-weight: 600;
    transition: background var(--transition), border-color var(--transition),
                color var(--transition);
}

.istep-num,
.istep-tick {
    position: absolute;
    transition: opacity 0.16s var(--ease-out), transform 0.16s var(--ease-out);
}

.istep-tick {
    width: 14px;
    height: 14px;
    fill: none;
    stroke: currentColor;
    stroke-width: 2;
    stroke-linecap: round;
    stroke-linejoin: round;
    opacity: 0;
    transform: scale(0.6);
}

.istep.is-done .istep-marker {
    border-color: var(--success-text);
    background: var(--success-bg);
    color: var(--success-text);
}

.istep.is-done .istep-num { opacity: 0; transform: scale(0.6); }
.istep.is-done .istep-tick { opacity: 1; transform: scale(1); }

/* Focus lives on the marker, because the input itself is screen-reader
   only — without this a keyboard user ticks a step they cannot see. */
.istep-box:focus-visible + .istep-head .istep-marker {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

.istep-head:hover .istep-marker { border-color: var(--accent-text); }

.istep-title {
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--text-primary);
}

/* Done is dimmed, never hidden: a step already run is re-read more often
   than it is scrolled past. */
.istep.is-done .istep-title { color: var(--text-muted); }
.istep.is-done .istep-body { color: var(--text-muted); }
/* The command block is NOT dimmed on a done step. `opacity` composites
   the code text along with the surface, and this app has already paid
   for that once (the blocked-button mute measured 2.49:1); a step is
   re-run often enough that its command must stay readable. The marker
   carries the state. */

.istep-body {
    margin: 4px 0 0;
    color: var(--text-secondary);
    font-size: 0.85rem;
}

.istep-cmd {
    position: relative;
    margin-top: 10px;
}

.istep-cmd pre {
    margin: 0;
    background: var(--code-bg);
    color: #d8dee9;
    padding: 12px 76px 12px 14px;
    border-radius: var(--radius);
    font-family: var(--font-mono);
    font-size: 0.85rem;
    line-height: 1.6;
    white-space: pre;
    overflow-x: auto;
}

/* The copy control sits ON the block it copies — the mapping is the
   position, so no label has to explain which command it takes. */
.istep-copy {
    position: absolute;
    top: 8px;
    right: 8px;
    padding: 4px 10px;
    border: 1px solid hsl(0 0% 100% / 0.22);
    border-radius: var(--radius);
    background: hsl(0 0% 100% / 0.08);
    color: #d8dee9;
    font-family: inherit;
    font-size: 0.75rem;
    font-weight: 500;
    line-height: 1.4;
    cursor: pointer;
    transition: background var(--transition), border-color var(--transition),
                color var(--transition), transform var(--press);
}

.istep-copy:hover { background: hsl(0 0% 100% / 0.16); }
.istep-copy:active { transform: scale(0.96); }

.istep-copy:focus-visible {
    outline: 2px solid hsl(0 0% 100% / 0.85);
    outline-offset: 2px;
}

.istep-copy.is-copied {
    border-color: var(--success-text);
    background: var(--success-bg);
    color: var(--success-text);
}

.isteps-foot {
    display: flex;
    align-items: center;
    gap: 12px;
    width: 100%;
}

/* Progress is stated in words, and it is the only thing on the left —
   the question "is this install half done" is asked from across a desk. */
.isteps-progress {
    margin-right: auto;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.isteps-progress.is-complete {
    color: var(--success-text);
    font-weight: 600;
}

/* The steps carry code lines that must not wrap mid-command. */
.modal:has(.install-steps-wrap) { max-width: 660px; }

@media (max-width: 768px) {
    .env-card-delivery-actions { width: 100%; }
    .env-card-delivery-actions .btn { flex: 1; }
}

/* ── Installations: order + filter bar ────────────────────────────
   Two controls and a count, on the page surface rather than on a plate
   of their own: they are chrome for the list below, and a second card
   here would read as a section with content of its own. */
.env-controls {
    display: flex;
    align-items: flex-end;
    flex-wrap: wrap;
    gap: 12px;
    margin-bottom: 16px;
}

/* Reset sits on the same baseline as the controls it undoes, at their
   height — a small button beside two full-height fields read as an
   afterthought pinned to the wrong line. The extra gap separates "undo"
   from the two things it undoes. */
.env-controls > .btn {
    align-self: flex-end;
    margin-left: 4px;
}

.env-controls-field {
    display: flex;
    flex-direction: column;
    gap: 6px;
    min-width: 190px;
}

.env-controls-label {
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.02em;
    text-transform: uppercase;
    color: var(--text-muted);
}

/* The count is the answer to "did my filter do anything", so it sits at
   the far end of the same row rather than under the list. */
/* The count is the answer to "did my filter do anything", so it sits at
   the far end of the same row rather than under the list — and on the
   controls' own baseline, not on the row's. */
.env-controls-count {
    margin-left: auto;
    align-self: flex-end;
    padding-bottom: 11px;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

@media (max-width: 768px) {
    .env-controls-field { flex: 1 1 100%; min-width: 0; }
    .env-controls-count { margin-left: 0; padding-bottom: 0; }
}

/* The deploy.sh questions, folded inside the Install step. Closed by
   default — an operator who has done this before must not scroll past
   four paragraphs to reach the command. */
.istep-explain {
    margin-top: 8px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background: var(--surface-sunken);
}

.istep-explain > summary {
    padding: 8px 12px;
    cursor: pointer;
    font-size: 0.85rem;
    font-weight: 500;
    color: var(--accent-text);
    list-style: none;
}

.istep-explain > summary::-webkit-details-marker { display: none; }

.istep-explain > summary::before {
    content: '▸';
    display: inline-block;
    margin-right: 8px;
    transition: transform var(--transition);
}

.istep-explain[open] > summary::before { transform: rotate(90deg); }

.istep-explain > summary:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
    border-radius: var(--radius);
}

.istep-qs {
    list-style: none;
    margin: 0;
    padding: 0 12px 4px;
    display: flex;
    flex-direction: column;
    gap: 12px;
}

.istep-q {
    display: flex;
    flex-direction: column;
    gap: 3px;
}

.istep-q-ask {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-primary);
}

.istep-q-answer {
    font-size: 0.85rem;
    color: var(--text-secondary);
}

/* A question this download will not be asked. Kept on screen rather than
   dropped: "why did it not ask me that" is the same question one screen
   later. */
.istep-q.is-skipped .istep-q-ask {
    color: var(--text-muted);
    text-decoration: line-through;
}

.istep-q-note {
    margin: 0;
    padding: 8px 12px 12px;
    font-size: 0.85rem;
    color: var(--text-muted);
}

/* ============================================================
   33. The in-app assistant.

   A panel over the page plane, 14px container / 8px control radii, one
   Steel Blue action. Every colour is a token, so the dark theme is
   inherited rather than re-declared — which is why this lives HERE and
   not in a second stylesheet: DM has exactly one, deliberately (see
   CLAUDE.md, "Design system - one stylesheet").

   Motion follows the house rules: entrances are ease-out and under
   300ms, nothing enters from scale(0) (nothing in the world appears out
   of nothing), the panel scales from the launcher it came out of rather
   than from its own centre, and no animation is the only carrier of a
   state. Closing is instant on purpose — a dismissal the operator
   already decided on should not be watched.
   ============================================================ */

/* An icon inherits the colour of the text beside it, so one asset works
   in both themes. `flex: none` because an icon in a flex row is the one
   thing that must never be squeezed. */
.assistant-icon { flex: none; display: block; }

/* How far the corner has to yield to a sticky action bar. Declared here at
   its resting value and overwritten on the root element by
   `assistantSyncLift`, which measures the bar — so a page with no
   assistant, or no JS yet, resolves it rather than falling back to it.
   0 on every screen but wizard step 3.

   The launcher LIFTS OVER the bar rather than the bar making room beside
   it: Next has to stay on the right edge of the cards it belongs to, and
   of the two elements the floating one is the one that can move. It is a
   POSITION, not a decoration — reduced motion must not flatten it back on
   top of the button, which is why it is `bottom` and not a transform (see
   DESIGN.md, "express MOVEMENT as a transform").

   Declared on `body`, NOT in the `:root` token block, and set from JS on
   `document.body` to match. It is page state — a measurement of what is
   currently on screen — not a design token, and `:root` here is a curated
   set that `e2e/design-system.spec.js` diffs against cicd-manager's
   canonical one. Putting it there failed that diff in both themes. Do not
   "tidy" it up into `:root`. */
body { --assistant-lift: 0px; }

.assistant-launcher {
    position: fixed;
    right: 24px;
    bottom: calc(24px + var(--assistant-lift));
    z-index: 900;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 44px;
    height: 44px;
    border-radius: 999px;
    border: 1px solid hsl(var(--accent));
    background: var(--accent-primary, hsl(var(--primary)));
    color: #fff;
    cursor: pointer;
    box-shadow: var(--shadow-hover);
    /* Named properties, never `all`: the shadow and the transform are the
       only two that should move. */
    transition: transform 160ms var(--ease-out), box-shadow 160ms var(--ease-out);
}

/* Hover is gated: a touch device fires it on tap, which leaves the
   button stuck in its hover state after the panel has opened. */
@media (hover: hover) and (pointer: fine) {
    .assistant-launcher:hover { transform: translateY(-1px); }
}

.assistant-launcher:active { transform: scale(0.96); }
.assistant-launcher:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; }
.assistant-launcher.is-active { background: hsl(var(--accent)); }

/* A class selector out-specifies the UA's `[hidden] { display: none }`,
   so a rule that sets `display` needs its own opt-out — the same trap
   `.pmx-label .pl-flag[hidden]` and `.stepper .step-issue[hidden]`
   document. Without it the launcher renders for every operator whose
   assistant is not configured. */
.assistant-launcher[hidden] { display: none; }

.assistant-panel {
    position: fixed;
    right: 24px;
    /* Clears the launcher, and the same sticky bar the launcher clears. */
    bottom: calc(80px + var(--assistant-lift));
    /* Above the modals (1000): the deploy dialog's "explain this failure"
       button OPENS this panel, and a panel that opens behind the thing
       that opened it reads as nothing happening. Still below the loading
       overlay (3000) and the toasts (10001). */
    z-index: 1500;
    display: flex;
    flex-direction: column;
    width: min(400px, calc(100vw - 32px));
    height: min(560px, calc(100vh - 140px));
    border: 1px solid var(--border-color);
    border-radius: 14px;
    box-shadow: var(--shadow-hover);
    overflow: hidden;

    /* A translucent layer rather than an opaque slab: the panel sits over
       the operator's work and should read as floating above it, not as a
       hole cut in the page. `color-mix` keeps it a token, so it still
       themes; the fallback line above it covers browsers without it. */
    background: var(--bg-secondary);
    /* 97%, not 85: the panel floats over an operations screen full of
       text, and a material you can read the page through is a material
       you cannot read the answer on. The blur is what says "floating";
       the opacity is what keeps it legible. */
    background: color-mix(in srgb, var(--bg-secondary) 97%, transparent);
    backdrop-filter: blur(24px) saturate(180%);
    -webkit-backdrop-filter: blur(24px) saturate(180%);

    /* It comes out of the launcher, so it grows from that corner. */
    transform-origin: bottom right;
    transition: opacity 220ms var(--ease-out), transform 220ms var(--ease-out);
    opacity: 1;
    transform: none;
}

/* The entrance. `@starting-style` keeps `hidden` as the single source of
   truth for open/closed — a class-driven animation would need a second
   flag, and the e2e suite reads the attribute. */
@starting-style {
    .assistant-panel:not([hidden]) {
        opacity: 0;
        transform: scale(0.96) translateY(8px);
    }
}

/* Standing aside. The panel already transitions `opacity`, so this is
   the same 220ms ease it opens with rather than a second animation with
   its own timing — and it must NOT touch transform or size, which would
   be a layout change the operator reads as a jump. */
.assistant-panel.is-peeking {
    opacity: 0.16;
    pointer-events: none;
}

.assistant-panel[hidden] { display: none; }

/* Translucency is a preference, not a given: when it is turned off the
   surface becomes solid rather than merely less blurred. */
@media (prefers-reduced-transparency: reduce) {
    .assistant-panel {
        background: var(--bg-secondary);
        backdrop-filter: none;
        -webkit-backdrop-filter: none;
    }
}

/* On a phone the panel IS the screen: a 400px card floating over a
   375px viewport is a card with no room around it. */
@media (max-width: 480px) {
    .assistant-panel {
        right: 0;
        left: 0;
        bottom: 0;
        width: 100%;
        height: min(80vh, 640px);
        border-radius: 14px 14px 0 0;
        transform-origin: bottom center;
    }
    .assistant-launcher { right: 16px; bottom: calc(16px + var(--assistant-lift)); }
}

.assistant-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    padding: 12px 14px;
    border-bottom: 1px solid var(--border-color);
    background: var(--surface-sunken);
}

.assistant-title {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    font-weight: 600;
    font-size: 0.95rem;
    /* Tracking is size-specific: a title tightens, body text does not. */
    letter-spacing: -0.01em;
    color: var(--text-primary);
}

/* The FT mark keeps its own colour wherever the surface behind it is
   neutral — a brand mark repainted per theme stops being the brand mark.
   On the launcher it stays white: that pill is already brand-blue, and
   orange on blue is a second brand nobody has. */
.assistant-brand { display: inline-flex; color: #F37921; }
.assistant-empty .assistant-brand { margin-bottom: 6px; }

.assistant-head-actions { display: inline-flex; gap: 2px; }

.assistant-icon-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 28px;
    height: 28px;
    border: 1px solid transparent;
    background: transparent;
    color: var(--text-muted);
    border-radius: 8px;
    cursor: pointer;
    transition: background 120ms ease, color 120ms ease, transform 120ms var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
    .assistant-icon-btn:hover { background: var(--surface-interactive); color: var(--text-primary); }
}

.assistant-icon-btn:active { transform: scale(0.94); }
.assistant-icon-btn:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }

.assistant-log {
    flex: 1;
    overflow-y: auto;
    padding: 14px;
    display: flex;
    flex-direction: column;
    gap: 10px;
    scroll-behavior: smooth;
}

/* ── the empty state ──────────────────────────────────────────────── */

.assistant-empty {
    margin: auto 0;
    padding: 8px 4px;
    text-align: center;
    color: var(--text-muted);
}

.assistant-empty .assistant-icon {
    margin: 0 auto 10px;
    color: hsl(var(--primary));
}

.assistant-empty-lead {
    margin: 0 0 6px;
    font-size: 0.875rem;
    color: var(--text-secondary);
}

.assistant-empty-note {
    margin: 0 0 12px;
    font-size: 0.75rem;
}

.assistant-starters {
    display: flex;
    flex-direction: column;
    gap: 6px;
}

.assistant-starter {
    border: 1px solid var(--border-color);
    background: var(--bg-primary);
    color: var(--text-primary);
    border-radius: 8px;
    padding: 7px 10px;
    font: inherit;
    font-size: 0.75rem;
    text-align: left;
    cursor: pointer;
    transition: background 120ms ease, transform 120ms var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
    .assistant-starter:hover { background: var(--surface-interactive); }
}

.assistant-starter:active { transform: scale(0.985); }
.assistant-starter:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }

/* ── messages ─────────────────────────────────────────────────────── */

.assistant-msg {
    position: relative;
    max-width: 92%;
    padding: 9px 12px;
    border-radius: 8px;
    font-size: 0.875rem;
    line-height: 1.5;
    word-break: break-word;
    /* Entering from a small offset, never from nothing. */
    transition: opacity 180ms var(--ease-out), transform 180ms var(--ease-out);
}

@starting-style {
    .assistant-msg {
        opacity: 0;
        transform: translateY(6px);
    }
}

.assistant-msg-user {
    align-self: flex-end;
    background: var(--surface-interactive);
    color: var(--text-primary);
    border: 1px solid var(--border-color);
    padding-right: 30px;   /* room for the copy control */
}

.assistant-msg-bot {
    align-self: flex-start;
    background: var(--bg-primary);
    color: var(--text-primary);
    border: 1px solid var(--border-color);
    padding-right: 30px;   /* room for the copy control */
}

.assistant-msg-bot.is-pending { padding-right: 12px; }

/* An answer while it is still being written.
 *
 * It is RENDERED MARKDOWN now, not raw text (2026-09-07): a reader
 * watching `**Available topologies:**` and bare table pipes arrive is
 * reading the model's source rather than this product's voice, and the
 * finished answer then looked like a different message. No animation on
 * the arriving text either — the arrival IS the feedback, and a fade per
 * token would put back the lag this phase removed. */
.assistant-msg-bot.is-streaming { padding-right: 12px; }

.assistant-stream {
    /* NO `white-space: pre-wrap` any more. It belonged to the raw-text
       stream; over rendered markdown it turns every newline between
       blocks into a visible blank line, so the answer grew a gap under
       each paragraph and then closed it at the end — the shape change
       this rendering exists to remove. */
    /* A pasted command line has no spaces to break at. */
    overflow-wrap: anywhere;
}

/* The agent's "ask this next" chips, as the same control the empty state
 * offers - one gesture, one component. */
.assistant-followups { margin-top: 10px; }

.assistant-followups-lead {
    font-size: 0.75rem;
    color: var(--text-muted);
    letter-spacing: 0.01em;
}

.assistant-msg-bot.is-error {
    background: hsl(var(--status-error-bg));
    color: hsl(var(--status-error-text));
    border-color: hsl(var(--status-error-border));
    padding-right: 12px;
}

.assistant-msg p { margin: 0 0 8px; }
.assistant-msg p:last-child { margin-bottom: 0; }
.assistant-msg h3, .assistant-msg h4 { margin: 10px 0 6px; font-size: 0.95rem; letter-spacing: -0.01em; }
/* The panel is ~330px wide, so a list's own indent is the difference
   between a readable answer and one whose markers sit on the bubble's
   edge. `outside` markers are drawn INSIDE this padding, so it has to
   hold the widest one — "10." for an ordered list — plus the gap that
   makes it read as a list rather than as a stray number. Sighted on the
   running panel, 2026-09-07. */
.assistant-msg ul { margin: 6px 0; padding-left: 22px; }
.assistant-msg li { margin: 3px 0; }
/* A model writing `FT_CONFIGS_BOOTSTRAP_ADMIN_PASSWORD` gives the line no
   space to break at, and in a 330px panel that word would run out of the
   bubble. `break-word`, not `anywhere`: it breaks only a word that cannot
   fit on a line of its own, so ordinary prose still breaks at its spaces
   instead of mid-word wherever the edge happens to fall. */
.assistant-msg li { overflow-wrap: break-word; }
.assistant-msg a { color: var(--accent-text); }

/* A step that leaves this page. Sized and coloured like the panel's own
   controls rather than like body text: it is an action inside a
   sentence, and an operator scanning a numbered list has to find it
   without reading the line. */
.assistant-page {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    /* The chip renders as an <a> for a page and a <button> for an action
       on this one; a button inherits neither font from the page. */
    font-family: inherit;
    padding: 2px 8px 2px 6px;
    border: 1px solid var(--border-color);
    border-radius: 999px;
    background: var(--surface-sunken);
    color: var(--accent-text);
    font-size: 0.85rem;
    font-weight: 500;
    line-height: 1.5;
    text-decoration: none;
    vertical-align: baseline;
    transition: background 120ms ease, border-color 120ms ease;
}

.assistant-page .assistant-icon { flex: none; opacity: 0.75; }

@media (hover: hover) and (pointer: fine) {
    .assistant-page:hover {
        background: var(--surface-interactive);
        border-color: var(--accent-text);
    }
}

.assistant-page:focus-visible {
    outline: 2px solid hsl(var(--ring));
    outline-offset: 2px;
}
.assistant-msg code {
    font-family: var(--font-mono);
    font-size: 0.85rem;
    background: var(--surface-sunken);
    border-radius: 4px;
    padding: 1px 4px;
}

.assistant-code {
    margin: 8px 0;
    padding: 10px;
    border-radius: 8px;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    /* WRAPS, it does not scroll. A 400px panel cannot show a docker
       command on one line, and the horizontal scroll is what left the
       copy control nowhere safe to sit: at any offset the text ran
       under it. Wrapped text stays inside the box, so the strip
       reserved on the right (below) holds for EVERY line rather than
       only for the end of the longest one. The deploy log wraps for
       the same reason. */
    white-space: pre-wrap;
    overflow-wrap: anywhere;
}

.assistant-code code { background: none; padding: 0; font-size: 0.85rem; }

/* The control sits INSIDE the block, in its corner, and the block
   reserves a strip for it. That strip is only honest because the block
   wraps: with `overflow-x: auto` the padding landed at the END of the
   longest line, so every other line still ran underneath. */
.assistant-code-wrap { position: relative; }

.assistant-code-wrap .assistant-code { padding-right: 38px; }

.assistant-code-wrap .assistant-copy {
    top: 14px;
    right: 6px;
    opacity: 1;
    background: var(--surface-sunken);
}

.assistant-msg ol { margin: 6px 0; padding-left: 28px; }

/* A table wider than the panel scrolls inside its own box; the panel
   itself must never scroll sideways. */
.assistant-table-wrap {
    margin: 8px 0;
    overflow-x: auto;
    border: 1px solid var(--border-color);
    border-radius: 8px;
}

.assistant-table {
    width: 100%;
    border-collapse: collapse;
    font-size: 0.75rem;
}

.assistant-table th,
.assistant-table td {
    padding: 6px 8px;
    text-align: left;
    vertical-align: top;
    border-bottom: 1px solid var(--border-color);
}

.assistant-table th {
    background: var(--surface-sunken);
    font-weight: 600;
    white-space: nowrap;
}

.assistant-table tbody tr:last-child td { border-bottom: none; }

/* Copy sits in the corner of the answer it copies — proximity is the
   mapping, so it needs no label. */
.assistant-copy {
    position: absolute;
    top: 6px;
    right: 6px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 22px;
    height: 22px;
    border: 1px solid transparent;
    border-radius: 6px;
    background: transparent;
    color: var(--text-muted);
    cursor: pointer;
    opacity: 0.65;
    transition: opacity 120ms ease, background 120ms ease, color 120ms ease;
}

@media (hover: hover) and (pointer: fine) {
    .assistant-msg:hover .assistant-copy { opacity: 1; }
    .assistant-copy:hover { background: var(--surface-interactive); color: var(--text-primary); }
}

.assistant-copy:focus-visible { opacity: 1; outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }

/* Confirmation in place, and not by colour alone: the control also says
   "Copied" through its title/tooltip. */
.assistant-copy.is-copied {
    opacity: 1;
    color: hsl(var(--status-success-text));
    background: hsl(var(--status-success-bg));
}

/* The one action a failed answer can offer. It sits inside the error
   message because that is what it undoes. */
.assistant-retry {
    display: block;
    margin-top: 8px;
    border: 1px solid currentColor;
    background: transparent;
    color: inherit;
    border-radius: 8px;
    padding: 3px 10px;
    font: inherit;
    font-size: 0.75rem;
    font-weight: 600;
    cursor: pointer;
    transition: background 120ms ease, transform 120ms var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
    .assistant-retry:hover { background: hsl(var(--status-error-border)); }
}

.assistant-retry:active { transform: scale(0.97); }
.assistant-retry:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; }

@media (prefers-reduced-motion: reduce) {
    .assistant-retry { transition: background 120ms ease; }
    .assistant-retry:active { transform: none; }
}

.assistant-ctx-note {
    display: block;
    margin-top: 4px;
    font-size: 0.75rem;
    color: var(--text-muted);
}

.assistant-sources {
    margin: 10px 0 0;
    padding: 8px 0 0 18px;
    border-top: 1px dashed var(--border-color);
    font-size: 0.75rem;
    color: var(--text-muted);
}

/* ── thinking ─────────────────────────────────────────────────────── */

/* Three dots pulsing in place. Staggered, because three dots blinking in
   unison reads as a broken element rather than as work in progress. The
   send button is disabled at the same time, so motion is never the only
   signal that something is running. */
.assistant-dots {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    height: 1.5em;
}

.assistant-dots i {
    width: 6px;
    height: 6px;
    border-radius: 999px;
    background: var(--text-muted);
    animation: assistant-pulse 1.1s var(--ease-in-out) infinite;
}

.assistant-dots i:nth-child(2) { animation-delay: 0.13s; }
.assistant-dots i:nth-child(3) { animation-delay: 0.26s; }

@keyframes assistant-pulse {
    0%, 100% { opacity: 0.35; transform: scale(0.85); }
    45%      { opacity: 1;    transform: scale(1); }
}

/* ── attached context ─────────────────────────────────────────────── */

.assistant-context { padding: 0 14px 6px; }

.assistant-chip {
    display: inline-flex;
    align-items: center;
    gap: 4px;
    max-width: 100%;
    padding: 3px 4px 3px 10px;
    border-radius: 999px;
    background: hsl(var(--status-info-bg));
    color: hsl(var(--status-info-text));
    border: 1px solid hsl(var(--status-info-border));
    font-size: 0.75rem;
}

.assistant-chip-x {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 18px;
    height: 18px;
    border: none;
    border-radius: 999px;
    background: none;
    color: inherit;
    cursor: pointer;
    padding: 0;
    transition: background 120ms ease, transform 120ms var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
    .assistant-chip-x:hover { background: hsl(var(--status-info-border)); }
}

.assistant-chip-x:active { transform: scale(0.9); }
.assistant-chip-x:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }

/* ── the composer ─────────────────────────────────────────────────── */

.assistant-input {
    display: flex;
    gap: 8px;
    align-items: center;
    padding: 10px 14px 14px;
    border-top: 1px solid var(--border-color);
}

.assistant-input textarea {
    flex: 1;
    resize: none;
    font: inherit;
    font-size: 0.875rem;
    line-height: 1.45;
    padding: 8px 10px;
    border-radius: 8px;
    border: 1px solid var(--border-control, var(--border-color));
    background: var(--bg-primary);
    color: var(--text-primary);
    transition: border-color 120ms ease;
}

.assistant-input textarea:focus-visible {
    outline: 2px solid hsl(var(--ring));
    outline-offset: 1px;
}

/* The one primary action in the panel, and it is round because it is an
   icon: a square would need a label to explain its own edges. */
.assistant-send {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    width: 34px;
    height: 34px;
    border-radius: 999px;
    border: 1px solid hsl(var(--accent));
    background: var(--accent-primary, hsl(var(--primary)));
    color: #fff;
    cursor: pointer;
    transition: transform 140ms var(--ease-out), background 140ms ease, opacity 140ms ease;
}

@media (hover: hover) and (pointer: fine) {
    .assistant-send:hover:not(:disabled) { background: hsl(var(--primary-hover)); }
}

.assistant-send:active:not(:disabled) { transform: scale(0.94); }
.assistant-send:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; }

.assistant-send:disabled {
    opacity: 1;
    cursor: default;
    background: var(--surface-sunken);
    border-color: var(--text-muted);
    color: var(--text-muted);
}

/* Attaching evidence sits BESIDE the send button and stays quiet: it is
   the second thing an operator does, not the first, and a second filled
   circle would read as two primary actions. */
.assistant-attach {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    width: 34px;
    height: 34px;
    border-radius: 999px;
    border: 1px solid var(--border-control, var(--border-color));
    background: transparent;
    color: var(--text-secondary);
    cursor: pointer;
    transition: transform 140ms var(--ease-out), background 140ms ease, color 140ms ease;
}

@media (hover: hover) and (pointer: fine) {
    .assistant-attach:hover {
        background: var(--surface-sunken);
        color: var(--text-primary);
    }
}

.assistant-attach:active { transform: scale(0.94); }
.assistant-attach:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 2px; }

/* A drop target has to say it is one WHILE the file is over it - the
   cursor alone leaves the operator guessing whether the panel takes it. */
.assistant-panel.is-dropping {
    outline: 2px dashed hsl(var(--primary));
    outline-offset: -6px;
}

/* What was masked before anything travelled. Quieter than the label: it
   is a reassurance, not the name of the attachment. */
.assistant-chip-note { opacity: 0.75; }

/* Filing a defect from where it was seen. Not the primary action of the
   panel - an answer is usually an answer, not a bug - so it stays quiet
   until pointed at. */
.assistant-report {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    margin-top: 10px;
    border: 1px solid var(--border-color);
    background: transparent;
    color: var(--text-muted);
    border-radius: 999px;
    padding: 3px 10px;
    font: inherit;
    font-size: 0.75rem;
    cursor: pointer;
    transition: background 120ms ease, color 120ms ease, transform 120ms var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
    .assistant-report:hover:not(:disabled) {
        background: var(--surface-interactive);
        color: var(--text-primary);
    }
}

.assistant-report:active:not(:disabled) { transform: scale(0.97); }
.assistant-report:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }
.assistant-report:disabled { cursor: default; color: var(--text-muted); }

/* What was filed, and the way into it. */
.assistant-filed { margin-top: 10px; }

/* Sources are evidence to check, not part of the answer: closed until
   asked for, so the next question stays on screen. */
.assistant-sources-box { margin-top: 8px; }

.assistant-sources-box > summary {
    cursor: pointer;
    font-size: 0.75rem;
    color: var(--text-muted);
    list-style-position: inside;
}

.assistant-sources-box > summary:focus-visible {
    outline: 2px solid hsl(var(--ring));
    outline-offset: 2px;
    border-radius: 4px;
}

/* A screenshot out of the corpus. Bounded by the panel, never by its own
   pixel width. */
.assistant-shot {
    display: block;
    max-width: 100%;
    height: auto;
    margin: 8px 0;
    border-radius: 8px;
    border: 1px solid var(--border-color);
}

/* The draft, before anything is filed. It sits inside the answer it was
   written from, because that is the evidence the operator is checking it
   against - a modal would hide exactly what they need to compare with. */
.assistant-draft {
    margin-top: 8px;
    padding: 10px;
    border-radius: 8px;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
}

.assistant-draft-label {
    display: block;
    margin: 8px 0 3px;
    font-size: 0.75rem;
    font-weight: 600;
    color: var(--text-muted);
}

/* What the box wants, next to its name: "pick one" is the difference
   between a select and a text field that a rendered box does not show. */
.assistant-draft-hint {
    margin-left: 6px;
    font-weight: 400;
    font-style: italic;
    opacity: 0.85;
}

.assistant-draft-label:first-child { margin-top: 0; }

.assistant-draft input[type="text"],
.assistant-draft textarea,
.assistant-draft select {
    width: 100%;
    padding: 6px 8px;
    border-radius: 6px;
    border: 1px solid var(--border-color);
    background: var(--bg-primary);
    color: var(--text-primary);
    font-size: 0.85rem;
    font-family: inherit;
}

/* The body is read as a ticket will read it: monospace, wrapping, and
   resizable when it is longer than the box. */
.assistant-draft textarea {
    font-family: var(--font-mono);
    line-height: 1.45;
    resize: vertical;
}

.assistant-draft-note {
    margin: 8px 0 0;
    font-size: 0.75rem;
    color: var(--text-muted);
}

.assistant-draft-actions {
    display: flex;
    flex-wrap: wrap;
    gap: 6px;
    margin-top: 10px;
}

.assistant-draft-actions .assistant-report { margin: 0; }

/* `display` on the class out-specifies the UA's `[hidden]`, the same trap
   `.assistant-launcher[hidden]` documents above: without this the button
   that opened the draft stays on screen above it. */
.assistant-report[hidden] { display: none; }

/* A refused report explains itself inside the answer it belongs to,
   rather than in a toast that is gone before the operator has read which
   fields Jira wanted. */
.assistant-report-error {
    margin-top: 8px;
    padding: 8px 10px;
    border-radius: 8px;
    background: hsl(var(--status-error-bg));
    color: hsl(var(--status-error-text));
    border: 1px solid hsl(var(--status-error-border));
    font-size: 0.85rem;
    line-height: 1.45;
}

.assistant-report-error p { margin: 0 0 6px; }
.assistant-report-error .assistant-report { margin: 0; }

/* Filing succeeded: say WHAT happened, then offer the one thing to do
   next. The key was a small underlined link doing both jobs at once and
   reading as neither. */
.assistant-filed-head {
    margin: 0 0 6px;
    font-size: 0.85rem;
    font-weight: 600;
    color: hsl(var(--status-success-text));
}

.assistant-report-link {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    padding: 5px 10px;
    border-radius: 6px;
    border: 1px solid hsl(var(--status-success-border));
    background: hsl(var(--status-success-bg));
    color: hsl(var(--status-success-text));
    font-size: 0.85rem;
    font-weight: 600;
    text-decoration: none;
}

@media (hover: hover) and (pointer: fine) {
    .assistant-report-link:hover { text-decoration: underline; }
}

.assistant-report-link:focus-visible {
    outline: 2px solid hsl(var(--ring));
    outline-offset: 2px;
}

/* What the ticket still needs from the operator. Warning tone, because
   it is an outstanding action, not a confirmation. */
.assistant-filed-missing {
    margin: 6px 0 0;
    padding: 6px 8px;
    border-radius: 8px;
    background: hsl(var(--status-warning-bg));
    color: hsl(var(--status-warning-text));
    border: 1px solid hsl(var(--status-warning-border));
    font-size: 0.85rem;
    line-height: 1.45;
}

.assistant-filed-missing strong { font-weight: 600; }

@media (prefers-reduced-motion: reduce) {
    .assistant-report { transition: background 120ms ease, color 120ms ease; }
    .assistant-report:active:not(:disabled) { transform: none; }
}

/* ── "show me" ────────────────────────────────────────────────────── */

.assistant-targets {
    margin: 10px 0 0;
    padding-top: 8px;
    border-top: 1px dashed var(--border-color);
    font-size: 0.75rem;
    color: var(--text-muted);
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px;
}

.assistant-target-btn {
    display: inline-flex;
    align-items: center;
    gap: 5px;
    border: 1px solid var(--border-color);
    background: var(--surface-interactive);
    color: var(--text-primary);
    border-radius: 999px;
    padding: 3px 10px;
    font: inherit;
    font-size: 0.75rem;
    cursor: pointer;
    transition: background 120ms ease, transform 120ms var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
    .assistant-target-btn:hover { background: var(--surface-interactive-hover); }
}

.assistant-target-btn:active { transform: scale(0.97); }
.assistant-target-btn:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }

/* The mark left on whatever was shown. The ring is the locator; the fade
   is only what makes it noticeable, so removing the animation loses
   nothing that carries meaning.

   Named for the assistant, applied by both "Show me" buttons — the chip in
   the panel and step 3's blocker button, through `spotlightElement` in
   js/spotlight.js. Kept under the old name rather than renamed: the class
   is what the e2e suite watches for, and the operator sees a gesture, not
   a selector. */
.assistant-spotlight {
    outline: 2px solid hsl(var(--ring));
    outline-offset: 3px;
    border-radius: 8px;
    /* Two rhythms on one element. The RING that travels outward three
       times is what an eye catches on a screen where nothing else has
       moved - a static outline on a page of outlined controls is a mark
       you have to go looking for, which is the opposite of what "show
       me" is for. The slow outline fade underneath it is what stops the
       mark vanishing on a hard cut once the pulses are done.

       It follows `.cdep-secret.just-filled` exactly: `box-shadow` only,
       so nothing moves and nothing below it reflows. No `transform`,
       no size - a card that grows and shrinks drags the rows under it. */
    animation: assistant-spotlight-pulse 820ms linear 3,
               assistant-spotlight-fade 2.6s var(--ease-out) 1;
}

@keyframes assistant-spotlight-pulse {
    /* Two rings, not one: the tight one is the mark, the wide one is the
       movement. A single soft ring at 45% read as a glow rather than as
       a pulse on a screen that is mostly pale surfaces. */
    0%   { box-shadow: 0 0 0 0 hsl(var(--ring) / 0.80),
                       0 0 0 0 hsl(var(--ring) / 0.40); }
    /* Held bright through the first third. `--ease-out` was wrong here:
       it spends the visible part of the pulse in the first 90ms, which
       is a flicker rather than a beat. `linear` keeps the travelling
       ring on screen long enough to be seen as movement. */
    35%  { box-shadow: 0 0 0 5px hsl(var(--ring) / 0.55),
                       0 0 0 11px hsl(var(--ring) / 0.22); }
    70%  { box-shadow: 0 0 0 9px hsl(var(--ring) / 0.18),
                       0 0 0 20px hsl(var(--ring) / 0); }
    100% { box-shadow: 0 0 0 0 hsl(var(--ring) / 0),
                       0 0 0 0 hsl(var(--ring) / 0); }
}

@keyframes assistant-spotlight-fade {
    0%   { outline-color: hsl(var(--ring)); }
    70%  { outline-color: hsl(var(--ring)); }
    100% { outline-color: transparent; }
}

/* The button that asks the assistant about one field or one failing step. */
.assistant-hint-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 20px;
    height: 20px;
    border: 1px solid transparent;
    background: transparent;
    color: var(--text-muted);
    border-radius: 999px;
    cursor: pointer;
    padding: 0;
    vertical-align: middle;
    transition: background 120ms ease, color 120ms ease, transform 120ms var(--ease-out);
}

@media (hover: hover) and (pointer: fine) {
    .assistant-hint-btn:hover {
        background: var(--surface-interactive);
        color: var(--text-primary);
    }
}

.assistant-hint-btn:active { transform: scale(0.9); }
.assistant-hint-btn:focus-visible { outline: 2px solid hsl(var(--ring)); outline-offset: 1px; }

/* Reduced motion keeps every state and every colour change; what goes is
   movement — the pulse, the spotlight fade, the scale on press. */
@media (prefers-reduced-motion: reduce) {
    .assistant-panel,
    .assistant-msg { transition: opacity 120ms ease; }
    .assistant-dots i { animation: none; opacity: 0.7; }
    .assistant-spotlight { animation: none; }
    .assistant-launcher,
    .assistant-icon-btn,
    .assistant-send,
    .assistant-target-btn,
    .assistant-starter,
    .assistant-chip-x,
    .assistant-hint-btn { transition: background 120ms ease, color 120ms ease; }
    .assistant-launcher:active,
    .assistant-icon-btn:active,
    .assistant-send:active:not(:disabled),
    .assistant-target-btn:active,
    .assistant-starter:active,
    .assistant-chip-x:active,
    .assistant-hint-btn:active { transform: none; }
    .assistant-log { scroll-behavior: auto; }
}

/* ── The download's outcome, beside the button that started it ────
   A 617 MB file leaves through a navigation, so the page never learns
   it arrived. What it CAN say — checked, started, or the server no
   longer has it — is said here rather than nowhere.
   (Re-added after a concurrent edit rewrote the tail of this file; the
   classes below are referenced by compose-image-bundle.js.) */
/* ── Offline-bundle dialog: progress and hand-off ─────────────────────
   Three lines instead of one paragraph. The phase and the percent share
   the bar's line; the numbers sit under it, quieter; the "why is it
   quiet" note only appears while compressing. Four facts glued into one
   sentence is what sent an operator hunting for the number. */
.bundle-progress-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 12px;
    margin-bottom: 8px;
}

.bundle-phase {
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--text-primary);
}

/* Tabular figures: a percent that reflows its own width as it counts up
   reads as jitter. */
.bundle-percent {
    font-size: 0.85rem;
    font-variant-numeric: tabular-nums;
    color: var(--text-secondary);
}

.bundle-progress-detail {
    margin: 10px 0 0;
    font-size: 0.85rem;
    font-variant-numeric: tabular-nums;
    color: var(--text-secondary);
}

/* The hand-off: the outcome the operator asked about, stated once and
   centred, with the offer of the next step in the footer beside Close.
   The mark arrives rather than appearing — 200ms on the app's own
   curve, and the global prefers-reduced-motion collapse flattens it. */
.bundle-handoff {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 4px;
    padding: 8px 0 4px;
    text-align: center;
}

.bundle-handoff-mark {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 40px;
    height: 40px;
    margin-bottom: 4px;
    border-radius: 999px;
    background: var(--success-bg);
    color: var(--success-text);
    font-size: 1.15rem;
    font-weight: 700;
    animation: bundle-handoff-mark 0.2s var(--ease-out) both;
}

@keyframes bundle-handoff-mark {
    from { opacity: 0; transform: scale(0.7); }
    to   { opacity: 1; transform: scale(1); }
}

.bundle-handoff-title {
    margin: 0;
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--text-primary);
}

.bundle-handoff-sub {
    margin: 0;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

/* The numbers and the explanation are different registers; without
   this they read as one run-on block. */
.bundle-progress-detail + .form-help { margin-top: 10px; }

.bundle-handoff-note {
    max-width: 42ch;
    margin: 12px 0 0;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-muted);
}

.bundle-dl-state {
    display: inline-flex;
    align-items: center;
    gap: 8px;
    margin-right: auto;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.bundle-dl-state.is-ok { color: var(--success-text); }
.bundle-dl-state.is-error { color: var(--danger-text); }

.bundle-dl-spinner {
    width: 14px;
    height: 14px;
    border: 2px solid var(--border-color);
    border-top-color: var(--accent-text);
    border-radius: 999px;
    animation: bundle-dl-spin 0.7s linear infinite;
}

@keyframes bundle-dl-spin { to { transform: rotate(360deg); } }

/* The tick arrives rather than appearing — one authored moment, 180ms,
   the curve the rest of the app uses. */
.bundle-dl-tick {
    display: inline-block;
    font-weight: 700;
    animation: bundle-dl-tick 0.18s var(--ease-out) both;
}

@keyframes bundle-dl-tick {
    from { opacity: 0; transform: scale(0.6); }
    to   { opacity: 1; transform: scale(1); }
}

/* The SSH panel's firewall requirement. It is an action item sitting
   under a paragraph of reassurance, in the same 12px grey — without a
   gap the two render as one wall and "open TCP 22 from us" reads as
   more boilerplate. */
.ssh-source-note {
    margin-top: 12px;
}

/* The address is the one thing a reader is scanning this paragraph
   for. Inline <code> here rendered as plain grey monospace — the same
   chip the computed-values and lab panels already use makes it findable
   without adding a second colour. */
.ssh-source-note code {
    background: var(--surface-sunken);
    padding: 1px 6px;
    border-radius: 4px;
    font-family: var(--font-mono);
    font-size: 0.85em;
}

/* Step 2's resolve check. Buried in the intro paragraph nobody found it;
   always open, it pushed the fields it explains off the screen. It is an
   occasional question, so it is asked as one and answered on demand. */
.step2-check {
    margin: 8px 0 16px;
    font-size: 0.85rem;
    color: var(--text-muted);
}

.step2-check > summary {
    cursor: pointer;
    width: fit-content;
}

.step2-check > p {
    margin: 6px 0 0;
}

.step2-check code {
    /* --surface-sunken, not --bg-secondary: the latter resolves to the
       card's own colour, so the chip measured 1.00:1 against what is
       behind it — padding and radius painting nothing. */
    background: var(--surface-sunken);
    padding: 1px 6px;
    border-radius: 4px;
    font-family: var(--font-mono);
    font-size: 0.85em;
}

/* ── Security screen (compose-security.js) ───────────────────────────
   audit-plan.md §9, audit-api-contract.md §6. Appended as a
   self-contained block, every selector prefixed `.sec-`, because the
   Audit tab's `.audit-` block lands in this same file from another
   change. No colour of its own: every value below is an existing token,
   so both themes are already correct and the contrast pairs are the
   ones the token block already carries. */
.sec-heading {
    margin: 1.75rem 0 0.5rem;
    font-size: 1.15rem;
}

.sec-controls {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    gap: 0.75rem;
    margin-bottom: 1rem;
}

.sec-filter {
    margin: 0;
    gap: 0.25rem;
}

.sec-muted { color: var(--text-muted); margin: 0.5rem 0; }
.sec-failed { color: var(--danger-text); margin: 0.5rem 0; }
.sec-refused { color: var(--warning-text); margin: 0.5rem 0; }

/* The one sentence this screen exists to get right: a stream with no
   writer is not a quiet stream. It is set as a notice rather than an
   empty line so it cannot be skimmed past as "nothing here". */
.sec-notice {
    margin: 0.5rem 0;
    padding: 0.75rem 1rem;
    color: var(--text-primary);
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    /* `--warning-text`, not `--warning`: the fill token is tuned to
       carry ink ON it, and measured against this plate it comes to
       2.39:1 in light theme — a 3px rule nobody can see. The text token
       clears the 3:1 non-text bar in both themes. */
    border-left: 3px solid var(--warning-text);
    border-radius: var(--radius);
}

/* ── Security: two real tables ─────────────────────────────────────
   Same anatomy as Records — a bounded `.ops-table` region, a sticky
   header, a sticky first column, a footer with the count and numbered
   pagination. Two lists of `<details>` under no header at all is what
   this screen had, and it is why the owner called it raw. */
.ops-table.sec-table {
    --sec-w-when: 12rem;
    --sec-w-who: 16rem;
    --sec-w-rest: 10rem;
    /* Both class names: `.ops-table` is declared later at equal
       specificity and would otherwise win. */
    max-height: 420px;
    margin-top: 0.5rem;
}

.ops-table.sec-table table { min-width: 62rem; }

.sec-table th,
.sec-table td { white-space: nowrap; }

.sec-table .sec-when { width: var(--sec-w-when); }
.sec-table .sec-who { width: var(--sec-w-who); }
.sec-table .sec-reason,
.sec-table .sec-detail,
.sec-table .sec-agent { width: 18rem; white-space: normal; }

/* THE FIRST COLUMN STAYS. Nine columns do not fit at any supported
   viewport, so the region scrolls sideways — and a row whose instant has
   scrolled away is a row nobody can place. The head cell is sticky on
   BOTH axes, so it layers above the body's sticky column. */
.sec-table th[scope="row"],
.sec-table thead th:first-child {
    position: sticky;
    left: 0;
    background: var(--bg-secondary);
    text-align: left;
    font-weight: 400;
}

.sec-table th[scope="row"] { z-index: 1; }
.sec-table thead th:first-child { z-index: 3; }
.sec-table thead th { z-index: 2; }

.sec-row > * {
    color: var(--text-primary);
    /* Exact property, ease-out, well under 300ms. Never `all`. */
    transition: background-color 0.12s var(--ease-out);
}

.sec-row:hover > * { background: var(--surface-sunken); }

.sec-when {
    color: var(--text-secondary);
    font-variant-numeric: tabular-nums;
}

.sec-who { overflow-wrap: anywhere; }
.sec-where { color: var(--text-muted); overflow-wrap: anywhere; }

/* The master-password warning is NOT evidence and does not hide behind
   the disclosure: a row signed that way names somebody who may not have
   been there, and that has to be visible on the strip. */
.sec-flag {
    color: var(--warning-text);
    font-size: 0.75rem;
    white-space: nowrap;
}

/* The master-password line (audit-plan.md §4.2). It qualifies the name
   on the row above it, so it reads before the parameters, not after. */
.sec-note {
    margin: 0 0 0.6rem;
    color: var(--warning-text);
}

.sec-params {
    display: grid;
    grid-template-columns: minmax(6rem, max-content) 1fr;
    gap: 0.2rem 1rem;
    margin: 0;
}

.sec-params dt { color: var(--text-muted); }
.sec-params dd { margin: 0; overflow-wrap: anywhere; }

.sec-detail {
    margin: 0.6rem 0 0;
    color: var(--text-primary);
    overflow-wrap: anywhere;
}

.sec-more {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 0.75rem;
    padding: 0.9rem 0.5rem 0;
}

/* ============================================================
   Audit tab (compose-audit.js) — the screen that reads the journal.

   Self-contained on purpose: every selector is prefixed `.audit-`, and
   every colour is an EXISTING token, so both themes are already correct
   and nothing here can reach another surface. Appended after the
   reduced-motion guard above, which still outranks it — that block wins
   on `!important`, not on order.
   ============================================================ */
/* The way back to the summary a figure was opened from. Above the
   heading because it is where the reader CAME FROM, not one more thing
   to do here; empty on every other way into this tab, and an empty
   container must not leave a gap behind. */
.audit-return { margin: 0 0 12px; }
.audit-return:empty { display: none; }

.audit-filters {
    display: flex;
    flex-wrap: wrap;
    gap: 12px;
    align-items: flex-end;
    margin-bottom: 12px;
}

/* `.journal-field` rides the SAME declarations rather than copying their
   values: the Journal's shared bar and the Records refine form sit on one
   screen, so two sets of metrics would be visibly two bars. One selector
   list is what makes drifting impossible rather than merely unlikely. */
.audit-field,
/* A class selector outranks the UA's `[hidden] { display: none }`, so
   `.journal-field` being `display: flex` would keep a hidden field on
   screen. The bar hides the installation box outright on the views that
   count the whole estate. */
.journal-field[hidden] { display: none; }
.journal-field {
    display: flex;
    flex-direction: column;
    gap: 4px;
    min-width: 11rem;
}

.audit-field label,
.journal-field label {
    /* 0.85rem, the ramp's second-read step. 0.8rem is off the ramp and
       `design-tokens.test.js` fails the build on it. */
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-secondary);
}

/* THE METRICS OF A CONTROL, DECLARED ONCE FOR THE PAIR. `.ui-select`
   (ft-ui-core) reads these two variables and falls back to the estate's
   `10px 14px / 0.95rem`; Operations runs its filters a size smaller, and
   before this the input beside the dropdown was 34px tall against its
   40.6px — measured in a browser, and visible as a step in the row. */
.audit-field,
.journal-field {
    --ui-control-pad: 8px 10px;
    --ui-control-font: 0.9rem;
    /* ONE HEIGHT FOR THE ROW, from the two variables above rather than a
       number: a native `type="date"` carries its own picker button and
       comes out taller than a text box with the same padding — measured
       37.6px against 35.6px — so From/To stepped above Installation in
       the same bar. Written against `--ui-control-font` rather than
       `1.4em`: `em` resolves per element, so the Apply button — a size
       larger by design — computed its own taller height off the same
       expression and stepped out of the row it was meant to join. The
       terms are the line box at the bar's font size, the padding above
       and below, and the two borders. */
    --ui-control-h: calc(1.4 * var(--ui-control-font) + 18px);
}

/* The Apply button rides the same row, so it takes the same height —
   otherwise the one control that ends the row is the one that steps out
   of it. Scoped to these bars; `.btn`'s own metrics are the estate's. */
.audit-field.audit-bar-actions .btn,
.journal-field.journal-bar-actions .btn {
    box-sizing: border-box;
    height: var(--ui-control-h);
}

/* The suggestion slot takes NO ROW, and that is measured rather than
   tidy: the `<span id="…ListSlot">` rendered nothing and STILL occupied
   a line in this column flex, its gap pushing Installation 4px above
   its neighbours in a bar aligned on the bottom edge (35.6 / 37.6 /
   38.0 px tall, bottoms at 551.1 and 555.1 — pinned by
   `e2e/compose-audit.spec.js`). All three bars carry one (Records,
   Audit, Activity), so this is written for the class.

   It was `display: none`, which was right while the span really did
   hold a `<datalist>` and render nothing. It holds the combobox popup
   now, and `none` would hide that popup outright — so `contents`: the
   span leaves the layout entirely, exactly as before, and its child
   positions against the FIELD instead. */
.journal-field > span[id$="ListSlot"],
.act-field > span[id$="ListSlot"],
.audit-field > span[id$="ListSlot"] { display: contents; }

.audit-field input,
.audit-field select,
.journal-field input,
.journal-field select,
/* THE ASSISTANT-AUDIT BAR IS THE SAME KIND OF BAR, and it had only the
   FOCUS half of this rule -- so its text box was the browser's default
   next to controls that were not, and it did not share their height, so
   `align-items: flex-end` lined a short input up against a taller
   button. Joined to the selector list rather than given a third copy of
   the same declarations. */
.asa-field input,
.asa-field select {
    box-sizing: border-box;
    /* `height`, not `min-height`: the point is to bring the taller
       control DOWN to the row. A native date input is 2px taller than a
       text box with identical padding, and `min-height` cannot shrink
       it. */
    height: var(--ui-control-h);
    padding: var(--ui-control-pad);
    /* --border-control, NOT --border-color: this border is the SHAPE of an
       interactive control, so SC 1.4.11's 3:1 applies to it rather than the
       hairline exemption. Measured on the rendered cascade 2026-09-01, the
       hairline came to 1.30:1 in light and 1.70:1 in dark -- the very defect
       --border-control was added for (see its comment at the token). */
    border: 1px solid var(--border-control);
    border-radius: var(--radius);
    background: var(--bg-primary);
    color: var(--text-primary);
    font-family: inherit;
    font-size: var(--ui-control-font);
    /* The last 1.6px of the step: the trigger sets `line-height: 1.4`
       and an input left on `normal` computes ~1.27 at this size. Same
       padding and same font are not the same height unless the leading
       matches too. */
    line-height: 1.4;
}

.audit-field input:focus-visible,
.audit-field select:focus-visible,
.journal-field input:focus-visible,
.journal-field select:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}

.audit-actions {
    flex-direction: row;
    gap: 8px;
    min-width: 0;
}

/* Two exports, side by side, each under its own sentence. They are
   never one control with a flag: which file carries our colleagues'
   addresses is the only thing a person has to get right here. */
.audit-exports {
    display: flex;
    flex-wrap: wrap;
    gap: 16px 24px;
    margin: 12px 0 16px;
    padding: 12px;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
}

.audit-export {
    flex: 1 1 18rem;
}

.audit-export .form-help {
    margin: 6px 0 0;
    max-width: 44ch;
}

 /* Not pressable, so unlike Deploy and Health this really is inactive
   and may look it. The pair is the one already proven on this app's
   blocked controls — `--text-muted` on `--surface-sunken`, 5.07:1 light
   / 5.43:1 dark — and the border carries the same token, because the
   fill matches the plate underneath and the boundary is then the only
   thing giving the control a shape (SC 1.4.11, 3:1). Never `opacity`,
   which composites the ring away with it. */
.audit-export-btn.is-disabled,
.audit-export-btn.is-disabled:hover {
    background: var(--surface-sunken);
    color: var(--text-muted);
    border-color: var(--text-muted);
    box-shadow: none;
    cursor: not-allowed;
}

/* One record per `<details>`; the strip above aligns to the same grid.
   `minmax(0, …)` on every track so a long address or a long action name
   ellipsises instead of pushing the row wider than the card. */
/* ── Records: a real table ─────────────────────────────────────────
   It was a list of `<details>` under a row of `aria-hidden` spans faking
   a header — so a screen reader had no way to know which value was
   which, and the Operations contract asks every data table for a real
   `<table>`, a sticky header, a bounded scrolling region and a visible
   footer with the count and the pagination.

   SIX COLUMNS, and the number is the point. The first cut promoted every
   field onto the strip and reached eleven; the next wire field would
   have made it twelve. The strip answers the scanning questions, the
   dialog answers everything else, and a column added to `audit_events`
   now costs this table nothing.

   COLUMN WIDTHS ARE TOKENS, not eyeballed pixels: six columns of guesses
   drift apart the first time one is edited, and the longest realistic
   value in each (a 60-character address, a full failure sentence, a long
   installation name) is what each number was chosen against. */
.ops-table.audit-table {
    /* 6.5rem, down from 11.5: the cell prints `11:13` now, and the date
       it used to repeat on every row is the separator above the run. */
    --audit-w-when: 6.5rem;
    --audit-w-who: 16rem;
    --audit-w-what: 13rem;
    --audit-w-where: 12rem;
    --audit-w-outcome: 7.5rem;
    --audit-w-reason: 20rem;
    --audit-w-details: 5.5rem;
    /* Taller than the shared 330px: Records is the screen somebody reads
       a whole page of, while the four figure views show a handful of
       rows. A modifier rather than an edit to `.ops-table`, which is the
       contract those views share — and it needs BOTH class names,
       because `.ops-table` is declared later in this file at equal
       specificity and would otherwise win. Measured, not reasoned. */
    max-height: 560px;
}

.ops-table.audit-table table { min-width: 69rem; }

/* ── the date at the head of a run of rows ────────────────────────────
   A `th[scope="rowgroup"]` spanning every column. The LABEL is what is
   pinned, not the cell: the table scrolls sideways at 901-1023px, and a
   date that slides away while the instants beside it stay pinned reads
   as though the run had ended. It carries its own background for the
   same reason `th[scope="row"]` does — a transparent sticky label shows
   the scrolled rows through itself. */
.audit-table .audit-day > th {
    padding: 10px 13px 6px;
    background: var(--surface-sunken);
    border-bottom: 1px solid var(--border-color);
    text-align: left;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-secondary);
}

.audit-table .audit-day-l {
    position: sticky;
    left: 0;
    background: var(--surface-sunken);
}

/* The zone control, above the table with the rest of the reading aids.
   Not inside the sticky `<thead>`: a control in a pinned header scrolls
   under itself. The idiom is `.csenv-server`'s — plain buttons, accent
   border and accent ink for the current one, no fill — which is this
   system's quietest segmented control and the one the nav segments
   already reuse. */
.audit-tz {
    display: flex;
    align-items: center;
    gap: 8px;
    margin: 0 0 8px;
}

.audit-tz-k { font-size: 0.85rem; color: var(--text-secondary); }

/* ── the picker: a filter you tick ────────────────────────────────────
   Same trigger shape as `.ui-select-trigger` next to it — one metric,
   read from the same two variables — so a row of filters does not
   contain two different-looking closed controls. */
.audit-pick { position: relative; }

.audit-pick-t {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    width: 100%;
    padding: var(--ui-control-pad);
    border: 1px solid var(--border-control);
    border-radius: var(--radius);
    background: var(--bg-primary);
    color: var(--text-primary);
    font-family: inherit;
    font-size: var(--ui-control-font);
    line-height: 1.4;
    cursor: pointer;
    text-align: left;
}

.audit-pick-t:hover { border-color: var(--border-strong); }

.audit-pick-t:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}

/* The summary never becomes the list: that is the defect this control
   was built to end, so it truncates rather than growing the row. */
.audit-pick-sum {
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.audit-pick-c { color: var(--text-muted); }

.audit-pick-p {
    position: absolute;
    z-index: 20;
    top: calc(100% + 4px);
    left: 0;
    min-width: 100%;
    max-height: 18rem;
    overflow-y: auto;
    padding: 4px;
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    background: var(--bg-primary);
    /* `--shadow-md`, the token this system actually declares — an
       invented `--shadow-lg` with a fallback renders fine and is
       invisible until somebody changes the palette and this one panel
       does not follow. `design-tokens.test.js` fails the build on it. */
    box-shadow: var(--shadow-md);
}

.audit-pick-o {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 8px;
    border-radius: calc(var(--radius) - 2px);
    font-size: 0.85rem;
    white-space: nowrap;
    cursor: pointer;
}

.audit-pick-o:hover { background: var(--surface-interactive); }

.audit-pick-o input { cursor: pointer; }

/* The wire name rides beside the sentence here too, for the same reason
   it does in the table: one greppable token across the screens. */
.audit-pick-o code { color: var(--text-muted); }

/* The columns control sits in the reading-aids row, so it is sized by
   that row rather than by the filter row it shares a shape with. */
.audit-cols { display: inline-block; }

.audit-cols .audit-pick-t {
    width: auto;
    padding: 3px 10px;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.audit-cols .audit-pick-p { left: auto; right: 0; }

.audit-pick-note {
    margin: 4px 0 0;
    padding: 6px 8px;
    border-top: 1px solid var(--border-color);
    font-size: 0.85rem;
    color: var(--text-muted);
    white-space: nowrap;
}

.audit-pick-empty {
    margin: 0;
    padding: 8px;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

/* Pushed to the far end of the same row: it is the second thing that
   decides what this list SHOWS, and the two belong together rather than
   in two places a reader has to find separately. */
.audit-reads {
    display: inline-flex;
    align-items: center;
    gap: 6px;
    margin-left: auto;
    font-size: 0.85rem;
    color: var(--text-secondary);
    cursor: pointer;
}

.audit-reads input { cursor: pointer; }

.audit-reads:hover { color: var(--text-primary); }

.audit-tz-set { display: inline-flex; gap: 4px; }

.audit-tz-btn {
    padding: 3px 10px;
    border: 1px solid var(--border-control);
    border-radius: var(--radius);
    background: var(--bg-primary);
    color: var(--text-secondary);
    font-family: inherit;
    font-size: 0.85rem;
    cursor: pointer;
    transition: color 0.12s var(--ease-out),
                border-color 0.12s var(--ease-out);
}

.audit-tz-btn:hover { color: var(--text-primary); }

.audit-tz-btn:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

.audit-tz-btn.is-on {
    border-color: var(--accent-text);
    color: var(--accent-text);
    font-weight: 600;
}

@media (prefers-reduced-motion: reduce) {
    .audit-tz-btn { transition: none; }
}

.audit-table .audit-when { width: var(--audit-w-when); }
.audit-table .audit-who { width: var(--audit-w-who); }
.audit-table .audit-what { width: var(--audit-w-what); }
.audit-table .audit-where { width: var(--audit-w-where); }
.audit-table .audit-outcome { width: var(--audit-w-outcome); }
.audit-table .audit-reason { width: var(--audit-w-reason); }
.audit-table .audit-details { width: var(--audit-w-details); }

.audit-table .audit-when,
.audit-table .audit-outcome,
.audit-table .audit-details { white-space: nowrap; }

/* THE FIRST COLUMN STAYS. Six columns fit at 1440 but not at 1024, so
   the region can scroll sideways — and a row whose instant has scrolled
   away is a row nobody can place. `z-index` is layered rather than
   shared: the header cell is sticky on BOTH axes and has to sit above
   the body's sticky column, or the two overlap while scrolling. */
.audit-table th[scope="row"],
.audit-table thead th:first-child {
    position: sticky;
    left: 0;
    background: var(--bg-secondary);
    text-align: left;
    font-weight: 400;
}

.audit-table th[scope="row"] { z-index: 1; }
.audit-table thead th:first-child { z-index: 3; }
.audit-table thead th { z-index: 2; }

.audit-row > * { color: var(--text-primary); }

.audit-row:hover > * { background: var(--surface-sunken); }

/* Exact property, ease-out, well under 300ms. Never `all`. */
.audit-row > * { transition: background-color 0.12s var(--ease-out); }

/* The row opens the record, so it says so under the pointer. The
   keyboard path is unchanged — the `Details` button in the last cell is
   still the control — which is why this is a cursor and a hover tint
   rather than a `tabindex` on the `<tr>`. */
.audit-table .audit-row { cursor: pointer; }

.audit-when {
    font-family: var(--font-mono);
    font-size: 0.75rem;
    color: var(--text-secondary);
}

.audit-code {
    font-family: var(--font-mono);
    font-size: 0.75rem;
    color: var(--text-muted);
}

/* The server under the installation it belongs to, not beside it: two
   identifiers on one line at this width leaves neither scannable. */
.audit-sub {
    display: block;
    color: var(--text-muted);
    font-size: 0.75rem;
}

/* `text-overflow` only ever fires on the three `nowrap` columns above;
   the wrapping ones need `overflow-wrap` instead, or a long unbreakable
   token (a route, a 60-character address) is clipped with no ellipsis
   and no way to read the rest. Wrapping over ellipsis here is deliberate:
   the DoD forbids hiding a value behind an ellipsis unless the whole of
   it is reachable on hover AND focus AND the keyboard, and a wrapped
   cell owes nobody a tooltip. */
.audit-c {
    overflow: hidden;
    text-overflow: ellipsis;
    overflow-wrap: anywhere;
}

/* `.audit-open` is the CONTROL; `.audit-details` is the column it sits
   in. They were one token, and a selector for the button resolved to
   the cell — a click that did nothing, in a browser, with every unit
   test green because the unit test called the handler directly. */
.audit-open:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

/* ── one record, in the dialog ─────────────────────────────────────── */

/* 8rem for the label track, not 10: the labels left in this grid are the
   technical ones ("Record", "Build", "Correlation"), and a 10rem track
   put a 5-character value 160px from its own name. */
.audit-record {
    display: grid;
    grid-template-columns: minmax(0, 8rem) minmax(0, 1fr);
    gap: 6px 12px;
    margin: 8px 0 0;
    font-size: 0.85rem;
}

.audit-record dt {
    color: var(--text-secondary);
    font-weight: 600;
}

.audit-record dd {
    margin: 0;
    color: var(--text-primary);
    overflow-wrap: anywhere;
}

.audit-record-h {
    margin: 18px 0 6px;
    color: var(--text-secondary);
    font-size: 0.85rem;
}

/* ── the footer: count, pagination, page size ──────────────────────── */

.audit-foot {
    display: flex;
    flex-wrap: wrap;
    gap: 8px 16px;
    align-items: center;
    justify-content: space-between;
    padding: 10px 2px 0;
}

.audit-count {
    margin: 0;
    color: var(--text-secondary);
    font-size: 0.85rem;
    font-variant-numeric: tabular-nums;
}

.audit-pager {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    align-items: center;
}

.audit-pageno {
    color: var(--text-secondary);
    font-size: 0.85rem;
    font-variant-numeric: tabular-nums;
}

.audit-page-btn[disabled] {
    opacity: 0.55;
    cursor: not-allowed;
}

.audit-size {
    display: flex;
    gap: 8px;
    align-items: center;
    color: var(--text-secondary);
    font-size: 0.85rem;
}

.audit-size select {
    padding: 4px 8px;
    border: 1px solid var(--border-color);
    border-radius: calc(var(--radius) - 4px);
    background: var(--bg-secondary);
    color: var(--text-primary);
    font: inherit;
}

.audit-size select:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

/* The count was the one aggregate this module computed, and it lived in
   a `.sr-only` div -- so the only reader who was ever told how many
   records were on screen was a screen reader. It is a visible line now,
   and still the SAME single `role="status"`: one sentence, never the
   rows container, which a live region would read out whole on every
   repaint.

   `:empty` because the idle state announces nothing on purpose, and an
   empty box would otherwise leave a gap above the table. */
.audit-status {
    margin: 0 0 8px;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.audit-status:empty { display: none; }

.audit-params {
    display: grid;
    grid-template-columns: minmax(0, 12rem) minmax(0, 1fr);
    gap: 2px 12px;
    margin: 8px 0 0;
    font-size: 0.85rem;
}

.audit-params dt {
    font-weight: 600;
    color: var(--text-secondary);
}

.audit-params dd {
    margin: 0;
    color: var(--text-primary);
    overflow-wrap: anywhere;
}

.audit-detail {
    margin: 10px 0 0;
    color: var(--text-primary);
    overflow-wrap: anywhere;
}

/* The five states all land in this one paragraph shape, so none of them
   can be styled as if it were another. */
.audit-note {
    color: var(--text-muted);
    margin: 12px 0;
}

.audit-error,
.audit-refused {
    padding: 12px;
    border-radius: var(--radius);
    color: var(--text-primary);
}

.audit-error {
    background: var(--danger-bg);
    /* The TEXT token, not the fill token: `--danger` / `--warning` are
       tuned to carry white as a solid button, and measured against their
       own tint the warning border came out at 2.52:1 (light) — under the
       3:1 floor a boundary owes. This is the outlined-danger button's
       rule, which shares one foreground token between text and border. */
    border: 1px solid var(--danger-text);
}

.audit-refused {
    background: var(--warning-bg);
    border: 1px solid var(--warning-text);
}

.audit-error p,
.audit-refused p {
    margin: 0 0 8px;
}

.audit-error p:last-child,
.audit-refused p:last-child {
    margin-bottom: 0;
}

.audit-more {
    display: flex;
    flex-wrap: wrap;
    gap: 12px;
    align-items: center;
    margin-top: 12px;
}

.audit-more .audit-note {
    margin: 0;
}

.audit-asof {
    font-size: 0.85rem;
}

@media (max-width: 900px) {
    /* Below this the six columns stop being columns. Each cell carries
       its own text, so stacking loses nothing but the alignment. */
    .audit-head {
        display: none;
    }

    .audit-row > summary {
        grid-template-columns: minmax(0, 1fr);
        gap: 2px;
    }

    .audit-c {
        white-space: normal;
    }
}

/* ============================================================
   The Journal — one bar, five views (the Insights rework,
   docs/deployment-manager/insights-redesign-2026-09-02.md §5.6, §6.2).

   Nothing here invents a value: every colour, radius, duration and type
   step is an existing token, and the bar's field metrics are literally
   the `.audit-field` declarations above rather than a copy of them.
   ============================================================ */

/* THE THIRD WEIGHT, and it is a hard constraint of this design system.

   There is ONE current-selection FILL (`--accent-tint`, the One Voice
   rule at :2375) and it belongs to the GROUP on the top row. The leaf
   segment already spends the quieter idiom — an accent BORDER and accent
   ink, no fill (`.nav-seg-item`, :535). The view row is the THIRD level
   of "current" on one screen, so it takes the quietest weight left:
   INK AND WEIGHT ONLY, no border and no fill. Painting it like
   `.nav-seg-item` would put two visually identical segmented rows one
   above the other, which is exactly what the One Voice rule forbids and
   what makes a reader unable to say which row they are in.

   Still three signals, never colour alone: ink, weight, and
   `aria-selected` — which is what a screen reader is told. */
.journal-views {
    display: flex;
    flex-wrap: wrap;
    gap: 4px 20px;
    margin-top: 12px;
    padding-top: 12px;
    border-top: 1px solid var(--border-color);
}

.journal-view-item {
    padding: 4px 0;
    border: 0;
    background: none;
    color: var(--text-secondary);
    font-family: inherit;
    font-size: 0.9rem;
    font-weight: 500;
    line-height: 1.2;
    cursor: pointer;
    /* The exact property, never `all`. Colour is the only thing that
       moves here, so it is the only thing named. */
    transition: color var(--transition);
}

/* The `hidden` ATTRIBUTE is how a view above this reader's rank leaves
   the row (journal.js), so it is written down here rather than left to
   the UA rule — the same guard `.tabs .tab[hidden]` carries at :509. */
.journal-view-item[hidden] { display: none; }

.journal-view-item:hover:not([aria-selected="true"]) {
    color: var(--text-primary);
}

.journal-view-item:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

.journal-view-item:active {
    transform: translateY(1px);
    transition: transform var(--press);
}

.journal-view-item[aria-selected="true"] {
    color: var(--accent-text);
    font-weight: 600;
}

@media (prefers-reduced-motion: reduce) {
    /* This stylesheet has no global collapse — the blanket at :9653
       narrows which PROPERTIES may transition and leaves timings alone,
       and movement authored as a transform is removed per block. This is
       this block's. */
    .journal-view-item { transition: none; }
    .journal-view-item:active { transform: none; transition: none; }
}

/* The bar. A wrapping row of the same fields the Records refine form
   uses, so the two cannot look like two different applications. */
.journal-bar {
    display: flex;
    flex-wrap: wrap;
    gap: 12px;
    align-items: flex-end;
    margin: 12px 0 0;
}

.journal-field-env { min-width: 14rem; }

.journal-bar-actions {
    flex-direction: row;
    gap: 8px;
    min-width: 0;
}

/* A control that goes grey without saying why teaches distrust of the
   screen, so the reason is a visible sentence beside it (journal.js
   writes it) and the DISABLED state keeps a real shape: the boundary is
   the only thing the control has left, so it takes a token that clears
   3:1 rather than an `opacity` that composites the shape away with it.
   Same pair, and the same argument, as `.audit-export-btn.is-disabled`. */
.journal-field input:disabled,
.journal-field select:disabled {
    background: var(--surface-sunken);
    color: var(--text-muted);
    border-color: var(--text-muted);
    cursor: not-allowed;
}

.journal-env-reason {
    margin: 8px 0 0;
    max-width: 72ch;
}

.journal-env-reason:empty { display: none; }

/* The 90-day ceiling, stated at all times rather than only after a 400.
   `.is-warning` is added when the chosen period reaches past it — the
   line is one live region that always holds a sentence, because a
   `role="status"` that is silent and then suddenly speaks reads as an
   alarm. */
.journal-ceiling {
    margin: 8px 0 0;
    max-width: 72ch;
}

.journal-ceiling.is-warning {
    color: var(--warning-text);
    font-weight: 500;
}

/* The four views that have not moved yet. One honest line naming where
   the screen still is — never an empty pane, which reads as broken, and
   never a failure, which would be a claim about work that has not
   started. */
.journal-soon {
    margin: 0;
    color: var(--text-secondary);
    max-width: 72ch;
}

/* ── Records ─────────────────────────────────────────────────────────
   THE TWO-COLUMN GRID THAT WAS HERE IS DELETED. `.jr-grid` / `.jr-side`
   / `.jr-main` described a 8fr/4fr split at 1440 — the rows wide, the
   refine disclosure and the notes narrow — and no js or html file ever
   rendered any of the three. What survived it was the column layout it
   implied for the filter form, which is what pushed the table off the
   screen (see the note further down). Records is one column: the bar,
   the filters, the chips, the rows. */
.jr-intro { max-width: 72ch; }

/* The exports are LAST in the markup and PINNED. The table appends on a
   `before_id` cursor, so exports placed after the last row are pushed
   off screen by every "Show more" — and they are what you do AFTER
   finding the rows, not before. */
.journal-records-card .audit-exports {
    position: sticky;
    bottom: 0;
    z-index: 1;
    margin-bottom: 0;
}

/* ── one record, in full ──────────────────────────────────────────────
   The dialog was sixteen label/value pairs in one flat grid, seven of
   them repeating the row the reader had just pressed — it answered
   "what fields does a record have", a question about our schema, rather
   than "what happened, why, and what do I do now". These are the four
   parts it answers with now: the event, the reason, what was asked for,
   and the technical half behind a disclosure. */
.audit-rec-sub {
    margin: 0 0 2px;
    color: var(--text-primary);
    font-weight: 600;
}

.audit-rec-when {
    margin: 0 0 12px;
    color: var(--text-secondary);
    font-size: 0.85rem;
}

/* The outcome badge and the action, one line, before any prose: what
   happened has to be visible before it is read. */
.audit-rec-what {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
    margin: 0 0 16px;
}

.audit-rec-reason { margin: 0 0 8px; }

/* Closed, and quiet: the record id, the build, the correlation. Present
   for the reader who needs to quote one at support, out of the way of
   the nineteen in twenty who do not. */
.audit-rec-tech { margin-top: 16px; }

/* Where a reader goes next, as a sentence rather than as a row of
   buttons: the labels carry an installation name and an address, so as
   `.btn`s they were 200-400px each and wrapped into a staircase in a
   dialog this narrow. Separated from the record by the same rule the
   modal footer uses, because it is the same kind of boundary. */
.audit-rec-go {
    margin: 16px 0 0;
    padding-top: 12px;
    border-top: 1px solid var(--border-color);
    font-size: 0.85rem;
    color: var(--text-secondary);
    line-height: 1.6;
}

.audit-rec-go-sep { color: var(--text-muted); }

.audit-rec-tech > summary {
    cursor: pointer;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-secondary);
    padding: 6px 0;
}

.audit-rec-tech > summary:hover { color: var(--text-primary); }

.audit-rec-tech > summary:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

/* THE REFINE DISCLOSURE IS GONE, and so is the rule that made it costly.
   `.audit-refine .audit-filters` set `flex-direction: column` for a
   narrow track that was never built (`.jr-grid`, deleted above): opened
   at 1440 the five fields were 1300px wide and ~470px tall, and the
   table they filter started below the fold. The five are a plain
   wrapping row now — `.audit-filters` at the top of this file already
   describes it — and what the disclosure was hiding, WHETHER A FILTER IS
   SET, is answered by the chips below the form instead.

   The chips. Same gesture the assistant's context chip already uses
   (`.assistant-chip` / `.assistant-chip-x`, js/assistant.js:1156): a
   value with the key that owns it and one × that drops it. */
@media (prefers-reduced-motion: reduce) {
    .audit-row > *, .sec-row > * { transition: none; }
    .audit-chip { transition: none; }
}

.audit-chips {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 8px;
    margin: 0 0 12px;
}

.audit-chips:empty { display: none; }

.audit-chip {
    display: inline-flex;
    align-items: center;
    /* A ceiling, because a value can be an installation name or an
       address somebody else chose: past it the chip truncates and the
       full value is the `title`. Without it one chip took the whole row
       and the × was off the end of it. */
    max-width: 26rem;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
    gap: 6px;
    /* 24px tall at this padding and font size — the floor for a pointer
       target, and the row's own gap keeps them from touching. */
    padding: 4px 10px;
    border: 1px solid var(--border-control);
    border-radius: 999px;
    background: var(--bg-secondary);
    color: var(--text-primary);
    font-size: 0.85rem;
    font-family: inherit;
    line-height: 1.4;
    cursor: pointer;
    transition: background-color 0.12s var(--ease-out);
}

/* The period is not a control: there is always one, and it belongs to
   the bar. Same shape so the row reads as one set, no pointer, no ×. */
.audit-chip.is-fixed { cursor: default; background: var(--surface-sunken); }

.audit-chip:hover:not(.is-fixed) { background: var(--surface-interactive); }

.audit-chip:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

/* The key is the quiet half: "Outcome failed" reads as one phrase, and
   the value is what the eye is looking for. */
.audit-chip-k { color: var(--text-secondary); font-weight: 600; }

.audit-chip-x { color: var(--text-muted); font-size: 0.95rem; }

.audit-chip:hover:not(.is-fixed) .audit-chip-x { color: var(--text-primary); }

.audit-chip-clear { font-size: 0.85rem; }

/* ── Training: the rail, and the mark on the real screen ─────────────
   The training does not have a screen of its own. It puts a ring on a
   place in the RUNNING application and waits for the operator to act
   there, so everything here has one job: be legible beside the product
   without covering it, disabling it, or moving it.

   Why this is not `.assistant-spotlight`. That mark clears itself after
   2.6s, which is right for "show me" and wrong here — a step can take a
   minute, and a ring that fades leaves somebody hunting for a place
   nobody is pointing at any more. Same gesture, no fade, no pulse count
   that ends: this one holds until the step does. */
.training-target {
    /* `box-shadow`, never a size or a transform: this ring lands on a
       live control inside a live layout, and an element that grows drags
       every row under it. Same rule `.cdep-secret.just-filled` and the
       spotlight already follow.

       AND NEVER `outline`, which is the half this got wrong. The ring
       used to be an outline at `outline-offset: 3px`, and `outline` is
       also how this app draws FOCUS — so the moment the rail handed the
       caret to the ringed control (which it does on arrival, by design)
       the tab's own `:focus-visible` rule won and redrew the ring at
       `-2px`, INSIDE the control's edge. Two different rings for one
       step, and the owner sighted both: tight on arrival, offset again
       after tabbing away and back. Measured on the running app —
       focused `outline-offset: -2px`, blurred `3px`. Drawn as a shadow,
       the ring is one thing in both states and the focus outline stays
       free to mean focus.

       Three stops: the gap in the surface behind it, the ring, the soft
       halo. The gap is `--bg-secondary` because that is the ground under
       every place a step points at (the tab bar, a card, the wizard) —
       on a sunken panel it is a shade off and reads as part of the
       ring. */
    /* NO `border-radius` HERE, and that was the whole of "the assistant
       icon goes square while the step points at it". This class lands on
       whatever the step names, and an 8px radius imposed on the host
       RESHAPES it: the launcher is a 44px circle (`border-radius: 999px`)
       and wore a rounded square for as long as it was marked. The ring is
       painted by the layer now, which takes the element's own radius, so
       the host's shape is nobody's business but the host's. */
    /* `!important` FOR THE SAME REASON `.hidden` carries it: this class
       lands on whatever live control a step names, and half of them
       paint a `box-shadow` of their own from a more specific selector —
       measured, `.tabs .tab.active` (0,2,1) beat `.training-target`
       (0,1,0) and the ring did not render at all. The host's own shadow
       is lost for the length of the step, which is the trade: a ring
       that always draws, against an elevation that comes back when the
       step ends. */
    box-shadow: 0 0 0 3px var(--bg-secondary),
                0 0 0 5px hsl(var(--ring)),
                0 0 0 11px hsl(var(--ring) / 0.18) !important;
}

/* THE RING IS PAINTED BY A LAYER WHEN THE PLACE CANNOT PAINT IT.
   `.env-card` clips its overflow, and T1.2 rings `.env-card-head` — the
   top of that card — so three sides of an outside ring fell outside the
   card and the trainee was sent to press "the row with the name in it"
   by one blue line under it.

   Drawing it INSIDE the element was the wrong answer to the right
   question: the mark then looked unlike every other mark in the app —
   the beat turned inwards, and the corner it wore was the card's rather
   than its own. So the ring leaves the element instead. `#mark-ring` is
   fixed to the viewport, outside every clipping ancestor, and wears
   exactly the ring and the pulse the unclipped case wears; JS puts it
   over the place and keeps it there. Shared with the assistant's "Show
   me", which lands on the same clipped places.

   It measures nothing and answers nothing: `pointer-events: none`, and
   `aria-hidden` in the markup — the element keeps its own class, which
   is what the watcher, the tests and `aria-describedby` all read. */
.mark-ring {
    position: fixed;
    /* ABOVE THE ASSISTANT PANEL (1500), because a step can point at
       something INSIDE it — T3.2 rings the question box — and the layer
       that paints every ring now would otherwise be drawn behind the very
       panel it is marking. Below the rail (1700), which carries the
       instruction and must never be covered by the mark for it. */
    z-index: 1595;
    pointer-events: none;
    outline: 2px solid hsl(var(--ring));
    border-radius: 8px;
    box-shadow: 0 0 0 6px hsl(var(--ring) / 0.18);
}

/* The place stops painting a ring nobody can see. Everything else about
   it — the lift, the focus, the class the step is watched by — stays. */
.training-target.is-ring-elsewhere,
.assistant-spotlight.is-ring-elsewhere {
    outline: none;
    /* `!important`, because the rule it is overriding is `!important` —
       without it the place kept painting its own ring UNDER the layer's,
       two rings for one mark, and nothing in the cascade to stop it. */
    box-shadow: none !important;
    animation: none;
}

/* The same two beats, on the layer. `assistant-spotlight-pulse` is the
   keyframe both marks already share; only the count differs — two for
   the training's arrival, three for "Show me". */
.mark-ring.is-arriving-2 { animation: assistant-spotlight-pulse 820ms linear 2; }
.mark-ring.is-arriving-3 { animation: assistant-spotlight-pulse 820ms linear 3; }

/* ── the arrival: a spotlight, not an outline ────────────────────────
   The owner walked the flow and said the ring on T1.2 is almost
   unreadable, and asked for what the assistant's "Show me" does, with the
   page dimmed while it runs. A browser then measured the other half of the
   same complaint: entering a module left the ring 8px below the fold on
   T1.2 and 373px below it on T9.1, so the first thing a trainee saw was an
   instruction and no ring at all.

   So arriving at a step is three things in order, and none of them lasts:
     0ms     the page scrolls the place into view
     0ms     the scrim fades in over 180ms; the ring pulses on the SAME
             beat "Show me" uses — `assistant-spotlight-pulse`, reused
             rather than a second vocabulary written beside it
     1600ms  the scrim fades out over 200ms; the pulse stops
   and what remains is the static ring, which holds until the step ends.

   1600ms is two full pulses: one reads as a flicker, three is a wait. The
   scrim is for the ARRIVAL only — the whole design is that the trainee
   works in the real application, so the application stays live under it
   (`pointer-events: none`) and the scrim sits BELOW the rail, which is
   where the words are and which therefore never dims. Reduced motion
   drops the scrim entirely in JS rather than letting the blanket rule
   flash it on and off in 0.01ms; the ring and the scroll stay, because
   somebody who cannot have animation still has to see where to go. */
.training-dim {
    position: fixed;
    inset: 0;
    z-index: 1390;
    /* Never a barrier. The application under it is the training. */
    pointer-events: none;
    /* THE ALPHA IS MEASURED, AND IT IS A DECLARED TRADE. A scrim
       composites over the text beneath it, so ANY visible dim costs
       contrast on the whole page — there is no alpha that both darkens
       the ground and leaves the text on it untouched. Measured on
       rendered pairs, worst legitimate pair on the installations screen:

         alpha   light            dark
         0.05    5.02 -> 4.86     -
         0.10    5.02 -> 4.69     5.13 -> 4.51
         0.14    5.02 -> 4.54     5.13 -> 4.26

       0.05 meets a 4.87 floor and is invisible, which buys nothing. 0.14
       is what the owner asked for and can be seen; it is between a
       quarter and a third of what `.modal-overlay` in this same file
       already puts over the page. It is acceptable because of what it is
       NOT: it lasts 1600ms, it is non-interactive, it is absent entirely
       under `prefers-reduced-motion`, and it is never over the two things
       anybody is reading at that moment — the rail is above it, and so is
       the ringed control. One number to dial if that judgement is wrong. */
    background: rgba(0, 0, 0, 0.14);
    opacity: 0;
    transition: opacity 180ms var(--ease-out);
}

.training-dim.is-on { opacity: 1; }

/* Above the scrim, so the marked place is the one bright thing. Applied
   from JS only when the element's computed position is `static` —
   overriding `absolute` or `sticky` on a live control would move it. */
.training-target-lift {
    position: relative;
    z-index: 1395;
}

/* A place the layout has ALREADY positioned takes the layer and nothing
   else: `position` is what would move it, `z-index` is what lifts it. The
   assistant launcher is the case that named this one — `fixed`, `z-index:
   900`, so it sat under the 1390 scrim while the step pointed at it. */
.training-target-lift-z {
    z-index: 1395;
}

/* THE SAME KEYFRAMES THE ASSISTANT'S "SHOW ME" USES. Two beats rather than
   three: this one is followed by a ring that stays, so the pulse only has
   to carry the eye there. */
.training-target-arriving {
    animation: assistant-spotlight-pulse 820ms linear 2;
}

/* ── the cue: where the hand goes ────────────────────────────────────
   The rail is in the corner and the work is in the middle of the screen.
   A ring says where without saying that anything is expected; a rail says
   what without saying where. `aria-hidden` because it repeats the live
   region word for word, and the ringed element already points at that
   region through `aria-describedby`. */
.training-cue {
    position: fixed;
    /* One above the ring it labels, for the same reasons. */
    z-index: 1596;
    pointer-events: none;
    padding: 3px 8px;
    border-radius: 6px;
    font-size: 0.75rem;
    font-weight: 600;
    white-space: nowrap;
    /* The pair inverts with the theme rather than being pinned: white on
       `hsl(var(--ring))` measured 5.35 in light and 2.92 in dark, because
       the ring token lightens there. `--accent-text` against the page's
       own ground is legible in both by construction. */
    color: var(--bg-primary);
    background: var(--accent-text);
    box-shadow: 0 2px 6px rgba(0, 0, 0, 0.28);
    animation: dm-fade-in 160ms var(--ease-out) 1;
}

/* The tail, pointing at the thing. `::after` on a fixed pill, so it costs
   no element and cannot be left behind by a render. */
.training-cue::after {
    content: "";
    position: absolute;
    left: 10px;
    top: 100%;
    border: 5px solid transparent;
    border-top-color: var(--accent-text);
}

.training-cue.is-below::after {
    top: auto;
    bottom: 100%;
    border-top-color: transparent;
    border-bottom-color: var(--accent-text);
}

/* ── what the step now says ──────────────────────────────────────────
   Р20 cancels Р18: as much text as it takes for somebody to understand
   where to press, what to type and why. `why` is on the screen rather
   than behind a control the owner never found. */
/* THE MOVES OUTRANK THE REASONING. `do` and `why` were the same size,
   weight and colour, so on a long step — T1.1 is `say` 114 + `why` 818 +
   `do` 143 characters — the three things a hand actually does sat under a
   22-line grey slab, in the same grey, and read as its last paragraph.
   The whole point of drawing `do` at all is that it is the part somebody
   acts on, so it takes the primary text colour and a rule down its left
   edge to separate it from the prose above. Both are existing tokens; the
   pair measures 15.9:1 light and 12.1:1 dark. */
.training-do {
    margin: 0;
    padding-left: 18px;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-primary);
}

.training-do li { margin: 2px 0; }
.training-do li::marker { color: var(--text-muted); }


/* WHOSE FAULT IT IS, IN THE TONE. `--warning-text` for everything the note
   ever said put "we never gave you this installation" in the same orange
   as "that did not save", under a normal step line, and it read as an
   accusation at the person reading it. Ours keeps the rail's ordinary
   secondary voice — the same pair `.training-why` beside it already uses,
   so no new colour pair — and carries an accent edge instead. */
.training-note--ours {
    color: var(--text-secondary);
    border-left: 2px solid var(--accent-text);
    padding-left: 8px;
}

/* ── progress that reacts ────────────────────────────────────────────
   A count in the corner changes silently. This is the only thing on the
   rail that moves when a step closes, and moving is what the owner said
   the whole flow was missing. `scaleX` from the left: transform only, so
   nothing under it reflows. */
.training-bar {
    height: 3px;
    border-radius: 999px;
    background: var(--surface-sunken);
    overflow: hidden;
}

.training-bar > i {
    display: block;
    height: 100%;
    background: var(--accent-text);
    transform-origin: left center;
    transition: transform 260ms var(--ease-out);
}

.training-count.just-done {
    color: var(--success-text);
    animation: cdep-check-pop 220ms var(--ease-out) 1;
}

.training-finished-mark {
    display: inline-block;
    margin-right: 6px;
    color: var(--success-text);
    animation: cdep-check-pop 260ms var(--ease-out) 1;
}

/* Secondary, not muted: how many steps were shown or blocked is
   information, and it sits beside a count in the same voice. */
.training-module-tally { color: var(--text-secondary); }

/* A CLASS SELECTOR OUT-SPECIFIES THE UA'S `[hidden] { display: none }`,
   and `display: flex` below is exactly such a selector. So `rail.hidden =
   true` emptied the four sections and left the box itself on screen: the
   owner closed the step panel and was left looking at "an empty white
   plate" on the right of the Installations tab. Same trap as
   `.cdep-log-box[hidden]` and `.pl-flag[hidden]`, third sighting. */
.training-rail[hidden] { display: none; }

.training-rail {
    position: fixed;
    /* BELOW the tab bar, and the number is MEASURED, twice: at 72px the
       rail covered 280px of the tab row — including `.tabs`, which is
       what T1.4 rings, so the training covered the thing it pointed at —
       and 148px was still 16px short of the row's real bottom (166px at
       1280x800). An e2e assertion holds it now, so the next person who
       changes the header does not have to remember this. */
    /* 216, not 188: the nav has a SECOND LEVEL now — one segment row in
       the content area, carrying the current group's leaves — and the
       rail has to clear whichever of the two is lower. Re-measured
       rather than nudged, at 1280x800 as before, for a support_admin
       and a global_admin: `.tabs` bottom is 166px (down from 182 — six
       entries no longer wrap the row where twelve did), and the segment
       row's bottom is 210.3px on a grouped leaf, which is the worst
       case. 216 is that plus the same ~6px of air. A screen with no
       group current therefore starts the rail lower than it strictly
       needs to; the assertion below holds BOTH cases, and one number is
       cheaper than a rail that follows the layout it cannot read.
       Below 900px this is moot — the rail docks to the bottom. */
    top: 216px;
    right: 24px;
    width: 280px;
    max-height: calc(100vh - 240px);
    overflow-y: auto;
    /* OVER EVERYTHING THE TRAINING DRAWS, and that now includes the ring
       layer (1595), which had to rise above the assistant panel. The rail
       is where the instruction is; a mark drawn on top of the sentence
       explaining it is the wrong way round. */
    z-index: 1700;
    display: flex;
    flex-direction: column;
    gap: 8px;
    padding: 12px;
    background: var(--bg-secondary);
    border: 1px solid hsl(var(--border));
    border-radius: 10px;
    box-shadow: var(--shadow-hover);
    animation: dm-fade-in 160ms var(--ease-out) 1;
}

/* ONE of the two has to yield, and until it was measured neither did.
   The assistant panel is `fixed; right: 24px; z-index: 1500` in the same
   corner: at 1280x800 it covered the step pips and half the Stuck/Ask
   row, and at 390px it covered the entire rail — the instruction line
   included — in T3, the module that TEACHES the assistant, and every
   time the rail's own Ask button was pressed.

   The rail moves out of the corner rather than winning the z-index: the
   assistant is a channel this rail offers, and a help panel with a rail
   sitting on top of it is help nobody can read. `:has()` on the body is
   the same pattern `.modal:has(.cdep-panel)` already uses here, so no
   class has to be kept in step from JS. */
body:has(#assistant-panel:not([hidden])) .training-rail {
    right: auto;
    left: 24px;
}

@media (max-width: 900px) {
    .training-rail {
        top: auto;
        bottom: 0;
        left: 0;
        right: 0;
        width: auto;
        max-height: 45vh;
        border-radius: 10px 10px 0 0;
    }

    /* There is no gutter to move into on a phone, and the panel fills
       the screen — so here the rail goes ABOVE it and shrinks to the one
       line that matters. Being unable to read the instruction is worse
       than the assistant losing its top few millimetres. */
    body:has(#assistant-panel:not([hidden])) .training-rail {
        top: 0;
        bottom: auto;
        left: 0;
        right: 0;
        max-height: 40vh;
        z-index: 1700;
        border-radius: 0 0 10px 10px;
    }
}

.training-head {
    display: flex;
    flex-direction: column;
    gap: 6px;
}

.training-head-row {
    display: flex;
    align-items: baseline;
    gap: 6px;
}

.training-module {
    flex: 1 1 auto;
    min-width: 0;
    font-weight: 600;
    font-size: 0.85rem;
    color: var(--text-primary);
}

.training-count {
    /* IT MUST NOT WRAP. `T11 · Who may touch this installation` squeezed
       this to nothing and rendered "2 /" above "2" — measured at 1280x800
       and 1440x900. The title has `min-width: 0` and gives way instead;
       the count is three characters and has nowhere to go. */
    flex: 0 0 auto;
    white-space: nowrap;
    font-size: 0.75rem;
    color: var(--text-secondary);
    font-variant-numeric: tabular-nums;
}

.training-close {
    flex: 0 0 auto;
    background: none;
    border: none;
    cursor: pointer;
    font-size: 0.95rem;
    line-height: 1;
    color: var(--text-secondary);
    padding: 2px 4px;
}

.training-close:hover { color: var(--text-primary); }

/* ONE line, and it is the loudest text on the rail. Everything else here
   is second-read: the point of the training is the screen behind it. */
.training-say {
    margin: 0;
    font-size: 0.95rem;
    line-height: 1.45;
    color: var(--text-primary);
}

.training-why,
.training-visited,
.training-note {
    margin: 0;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-secondary);
}

.training-note {
    color: var(--warning-text);
}

/* What the module just opened, and where it hands you on. Second-read,
   like `.training-why` beside it — the loud line is still `Module
   finished.` */
.training-opens {
    margin: 0;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-secondary);
}

/* RIGHT-ALIGNED, and the one primary is the last child — the same
   reading as every modal footer in this app: the ways out on the left,
   the action that moves you on under the reader's hand. The rail used to
   lead with its primary, which put the loudest control at the start of
   the row. `wrap` because three controls at 280px is one word from
   overflowing on a narrow rail. */
.training-actions {
    display: flex;
    flex-wrap: wrap;
    justify-content: flex-end;
    gap: 6px;
}

/* ── the step pips ───────────────────────────────────────────────────
   FIVE states, and never colour alone: `done` carries a check, `skipped`
   a dash, `blocked` the app's own alert mark, `current` a filled dot,
   `locked` a hollow one. A trainee with a colour deficiency has to be
   able to tell a step they skipped from one our lab made impossible —
   those two are the pair that would otherwise share a tone. */
.training-steps {
    display: flex;
    flex-wrap: wrap;
    gap: 4px;
    margin: 0;
    padding: 0;
    list-style: none;
}

.training-pip button {
    min-width: 30px;
    min-height: 24px;
    padding: 2px 6px;
    font-size: 0.75rem;
    font-variant-numeric: tabular-nums;
    border-radius: 6px;
    border: 1px solid var(--border-control);
    background: var(--surface-sunken);
    color: var(--text-secondary);
    cursor: pointer;
}

.training-pip button::before {
    margin-right: 3px;
}

.training-pip--done button { color: var(--success-text); }
.training-pip--done button::before { content: "\2713"; }

.training-pip--skipped button { color: var(--text-muted); }
.training-pip--skipped button::before { content: "\2013"; }

.training-pip--blocked button { color: var(--warning-text); }
.training-pip--blocked button::before { content: "\26A0"; }

.training-pip--current button {
    color: var(--accent-text);
    border-color: var(--accent-text);
}
.training-pip--current button::before { content: "\25CF"; }

.training-pip--locked button::before { content: "\25CB"; }

/* A step ahead is not a destination — and it keeps BOTH `disabled` and
   `aria-disabled` off, so a keyboard operator can still reach it and be
   told why. Muted with a token rather than `opacity`: these are live
   controls, so the WCAG inactive-component exemption does not apply, and
   this app has already measured `opacity: .55` at 2.14:1 one component
   over. */
.training-pip button.is-blocked {
    color: var(--text-muted);
    cursor: default;
}

.training-pip.is-showing button {
    outline: 2px solid hsl(var(--ring));
    outline-offset: 1px;
}

/* ── the Training tab ────────────────────────────────────────────── */
.training-lead {
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.training-modules {
    list-style: none;
    margin: 0;
    /* The assistant's launcher is `fixed; right: 24px; bottom: 24px`, so
       it hangs over the bottom-right of the VIEWPORT at every scroll
       position — and that is exactly where this list keeps its buttons.
       Measured on the first visit: the last row's Start rendered as
       "Star..." behind an orange circle. The band is reserved on both
       edges rather than only at the end, because any row can be the one
       under it. 56px is the launcher's 44 plus its 24 of inset, less the
       card's own padding. */
    padding: 0 56px 56px 0;
}

/* A GRID, NOT A FLEX ROW. With `flex: 1 1 auto` the name column sized
   itself to its own text, so the description started at a different x on
   every one of thirteen rows and the column read as centred rubbish —
   ragged on both edges, and the single thing that made the tab look
   unfinished. Columns now line up because they are columns. */
.training-module-row {
    display: grid;
    /* THE LAST TWO ARE FIXED, and that is the point. Each row is its own
       grid container, so `auto` sizes itself per row: the two modules
       whose count carried no tally got a narrower count column and their
       description started 25px right of the other nine. Measured. A
       shared width is the only thing that makes eleven separate grids
       line up. */
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.15fr) 132px 104px;
    align-items: center;
    gap: 8px;
    padding: 8px 0;
    border-bottom: 1px solid hsl(var(--border));
}

@media (max-width: 700px) {
    .training-module-row {
        grid-template-columns: minmax(0, 1fr) 104px;
    }
    .training-module-opens { grid-column: 1 / -1; }
}

.training-module-row:last-child { border-bottom: none; }

.training-module-name {
    min-width: 0;
    font-size: 0.95rem;
    color: var(--text-primary);
}

.training-module-opens {
    min-width: 0;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.training-module-count {
    text-align: right;
    white-space: nowrap;
    font-size: 0.85rem;
    color: var(--text-secondary);
    font-variant-numeric: tabular-nums;
}

/* A module that is not open yet, muted the way the step pips are — the
   same reasoning, so the same treatment: it keeps BOTH `disabled` and
   `aria-disabled` off, because pressing it is how you ask which module
   comes first. A token rather than `opacity`, since a live control gets
   no WCAG inactive-component exemption and this app has already measured
   `opacity: .55` at 2.14:1 one component over. */
.training-module-row .btn.is-blocked {
    color: var(--text-muted);
    border-color: var(--text-muted);
    background: var(--surface-sunken);
    cursor: default;
}

/* ── the rail is a COLUMN, not a sheet over the product ──────────────
   `training-plan.md` §2 draws the rail beside the real screen and says
   in as many words that it is not an overlay. It was one, and the cost
   was measured in a browser at 1440x900: on the Training tab it sat on
   the first module's row, hiding that row's count and its button; on
   Installations it covered `#composeCreateBtn` — which is the element
   step T4.1 RINGS — so `elementFromPoint` at the ringed button's centre
   returned a step pip, and a trainee's click on the highlighted control
   did nothing at all, silently. A training that covers what it points
   at cannot be walked.

   The gutter is reserved rather than the rail moved. The rail has to
   stay in view while the page scrolls, so it stays `fixed`; what was
   missing is that nothing was told to get out from under it. 320px =
   the rail's 280 + its 24 of inset + 16 of air.

   It follows the rail through every placement `.training-rail` takes —
   the corner it yields to the assistant, and the sheet it becomes on a
   narrow screen — because a gutter reserved on the wrong side is the
   same defect with the page pushed sideways as well. The HEADER and the
   tab bar keep their width: the rail starts below them (`top: 188px`,
   re-measured when an eighth tab arrived), so narrowing them would be
   movement that buys nothing. */
body.training-on .main-content { padding-right: 320px; }

body.training-on:has(#assistant-panel:not([hidden])) .main-content {
    padding-right: 0;
    padding-left: 320px;
}

@media (max-width: 900px) {
    body.training-on .main-content,
    body.training-on:has(#assistant-panel:not([hidden])) .main-content {
        padding-right: 0;
        padding-left: 0;
        /* The sheet's own `max-height`, plus air. */
        padding-bottom: 47vh;
    }

    /* With the assistant open the rail goes to the TOP instead. */
    body.training-on:has(#assistant-panel:not([hidden])) .main-content {
        padding-bottom: 0;
        padding-top: 42vh;
    }
}


/* ── Where the team is (the Training tab's second section) ───────────
   A collapsible card, following the installations list's own
   disclosure: native <details>/<summary>, no class toggle, so the
   control already takes Enter and Space and already exposes its state.
   The section is a roster of colleagues, so it is CLOSED by default and
   reads nothing until it is opened. */
/* THE ASSISTANT'S BUTTON IS FIXED OVER THE BOTTOM-RIGHT CORNER (44px at
   right/bottom 24px, `.assistant-launcher`) and it is not this screen's
   to move. What this screen does about it is two things, and both are
   about never putting anything IRRETRIEVABLE under that disc: the card
   ends with enough room that scrolling to the bottom leaves the corner
   empty, and the row lines below are held to a measure so a person's
   address and state never run beneath it on a wide window. */
.tr-report-card {
    margin-top: 12px;
    margin-bottom: 68px;
}

.tr-report > summary.tr-report-head {
    cursor: pointer;
    list-style: none;
    min-height: 44px;
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 8px;
    user-select: none;
    padding: 4px;
    margin: -4px;
    border-radius: var(--radius);
    transition: background-color var(--transition);
}

.tr-report > summary.tr-report-head::-webkit-details-marker {
    display: none;
}

/* The disclosure's own mark. A rotation rather than two glyphs, so the
   open and closed states are one object moving instead of two symbols
   the reader has to learn. */
.tr-report > summary.tr-report-head::before {
    content: "\25B8";
    color: var(--text-muted);
    transition: transform 0.18s var(--ease-out);
}

.tr-report[open] > summary.tr-report-head::before {
    transform: rotate(90deg);
}

.tr-report > summary.tr-report-head:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

.tr-report > summary.tr-report-head:active {
    background: var(--surface-interactive-hover);
}

@media (hover: hover) and (pointer: fine) {
    .tr-report > summary.tr-report-head:hover {
        background: var(--surface-interactive);
    }
}

.tr-report-title {
    font-family: var(--font-display);
    font-weight: 600;
    font-size: 1.15rem;
    letter-spacing: -0.01em;
    color: var(--text-primary);
}

.tr-report-sub {
    font-size: 0.85rem;
    color: var(--text-secondary);
}

/* The app's one entrance keyframe. A closed <details> reopening is
   exactly the display:none -> shown transition a CSS transition cannot
   hang off, which is why this app animates entrances and never
   transitions them. */
.tr-report[open] > .tr-report-body {
    animation: dm-fade-in 0.18s var(--ease-out);
    margin-top: 12px;
}

.tr-report-count {
    margin: 0;
    font-size: 0.95rem;
    color: var(--text-primary);
}

.tr-report-legend {
    margin-top: 4px;
}

.tr-people {
    list-style: none;
    margin: 12px 0 0;
    padding: 0;
}

.tr-person {
    padding: 12px 0;
    border-bottom: 1px solid var(--border-color);
}

.tr-person:last-child { border-bottom: none; }

/* The gutter is the assistant's button: this row's last item is pushed
   to the right edge with `margin-left: auto`, which is the one column
   the fixed 44px launcher sits in. Without it a person's timestamp is
   under the disc on whichever rows happen to be at the bottom. */
.tr-person-head {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 8px;
    padding-right: 68px;
}

/* The address, in full and verbatim. There is no display name to render
   — ft-flask-auth carries none — and a list deciding who gets a
   customer's production must not abbreviate the one identifier it has. */
.tr-person-name {
    font-size: 0.95rem;
    color: var(--text-primary);
    word-break: break-all;
}

/* When they last moved. The installations card's own meta treatment —
   0.75rem in --text-secondary, not --text-muted: it is a timestamp
   rather than a second-read aside, and muted measures 4.72:1 on the
   dark card against 6.03:1 here. */
.tr-person-moved {
    margin-left: auto;
    font-size: 0.75rem;
    color: var(--text-secondary);
}

.tr-person-state {
    margin: 4px 0 0;
    font-size: 0.85rem;
    color: var(--text-primary);
    max-width: 68ch;
}

.tr-person-count,
.tr-person-skipped,
.tr-person-blocked,
.tr-person-exempt {
    margin: 4px 0 0;
    font-size: 0.85rem;
    /* The measure the panel prose already uses — and here it also keeps
       every row line clear of the assistant's fixed button. */
    max-width: 68ch;
}

.tr-person-count { color: var(--text-secondary); }

/* THE TWO LINES THAT MUST NEVER MERGE. `skipped` is a question for the
   trainee — we showed them the answer — and `blocked_by_infra` is a
   question for us: our own lab was down, and the person it happened to
   did not own it. Three signals apart, not one: a different sentence, a
   different glyph from the rail's own vocabulary, and a different tone.
   Folded into one number, the second silently becomes the trainee's
   fault. */
.tr-person-skipped { color: var(--text-muted); }
.tr-person-blocked { color: var(--warning-text); }
/* NOT the accent. The accent is this app's link ink, and the exemption
   line is not a link — it went nowhere and had nothing to open. It is a
   recorded decision, read at the same weight as the counts above it. */
.tr-person-exempt  { color: var(--text-primary); }

.tr-person-count::before,
.tr-person-skipped::before,
.tr-person-blocked::before,
.tr-person-exempt::before {
    margin-right: 4px;
}

.tr-person-count::before   { content: "\2713"; }
.tr-person-skipped::before { content: "\2013"; }
.tr-person-blocked::before { content: "\26A0"; }
.tr-person-exempt::before  { content: "\25C6"; }

/* The legend reuses the row classes so the words are coloured by the
   same rule that colours the lines they describe; inline there, so the
   glyph would be a second mark in the middle of a sentence. */
.tr-report-legend .tr-person-skipped::before,
.tr-report-legend .tr-person-blocked::before {
    content: none;
}

/* An unreadable read. It is a plate rather than a line because the one
   thing it must not do is read as a fact about the team — "nobody has
   been trained" is a sentence somebody would act on. */
.tr-report-error {
    padding: 12px;
    border-radius: var(--radius);
    border-left: 3px solid var(--danger-text);
    background: var(--surface-sunken);
}

.tr-report-error-title {
    margin: 0;
    font-size: 0.95rem;
    color: var(--danger-text);
}

.tr-report-error-why {
    margin: 4px 0 12px;
    font-size: 0.85rem;
    color: var(--text-secondary);
}


/* ── "What's new" — the product changelog (replaces the update strip) ─
   A page, not a strip. The banner this replaces cost ~230px of every
   working screen to carry one entry; the news now lives on its own leaf
   (`#whatsnew`) drawn on Operations' own frame, and the header carries a
   count so the reader learns there IS news without a line of the screen
   being spent on it.

   Every surface below is the app's: `.ops-panel`'s plate for an entry,
   `.badge` for both pills, `.journal-head`/`.journal-workspace` for the
   frame. Nothing new was invented for this page, which is why it needs
   so little CSS. */
.cl-list {
    display: grid;
    gap: 14px;
}

/* The entry plate — `.ops-panel`'s own values, said once here rather than
   by adding a class to every article. */
.cl-entry {
    min-width: 0;
    padding: 20px;
    background: var(--bg-primary);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    box-shadow: 0 1px 2px rgb(0 0 0 / 0.035);
}

/* An unread entry carries the accent as a LEFT EDGE, never a filled
   band: a coloured block across a card reads as an alert, and news is
   exactly what an alert is not. Three signals, never colour alone — the
   edge, the `New` pill, and the count in the header. */
.cl-entry--new {
    border-left: 3px solid var(--accent-text);
    padding-left: 18px;
}

.cl-meta {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 8px;
    margin: 0 0 6px;
    font-size: 0.78rem;
    color: var(--text-muted);
}

.cl-date { font-variant-numeric: tabular-nums; }

.cl-area {
    color: var(--accent-text);
    font-weight: 600;
    text-transform: uppercase;
    letter-spacing: 0.06em;
    font-size: 0.72rem;
}

.cl-new {
    background: var(--accent-tint);
    color: var(--accent-text);
}

.cl-title {
    margin: 0 0 10px;
    font-size: 1.15rem;
    line-height: 1.35;
    letter-spacing: -0.01em;
    color: var(--text-primary);
}

.cl-body {
    /* 68ch, the measure `.journal-head .form-help` already uses — a line
       of prose that runs the full width of a 1600px page is a line the
       eye loses on the way back. */
    max-width: 68ch;
    margin: 0 0 8px;
    color: var(--text-secondary);
    line-height: 1.55;
}

.cl-body:last-of-type { margin-bottom: 0; }
.cl-go { margin: 12px 0 0; }

/* The two sentences the page says about the READING rather than about the
   product: "we cannot tell what you have read", and "that did not save".
   Muted, above the list, and absent whenever there is nothing to say. */
.cl-notice {
    margin: 0 0 14px;
    color: var(--text-muted);
    font-size: 0.85rem;
}

/* The count beside the header control.

   IT MUST NOT SET THE BUTTON'S HEIGHT. `.badge` carries its own padding
   and a 1.4 line-height, which made "What's new" a taller control than
   "Your month" standing next to it — a row of buttons at two heights,
   sighted by the owner on the running app. So the pill takes the line
   box it is given: no vertical padding, a line-height under the button's
   own, and a `block` display so its box is exactly its text. */
.cl-badge {
    display: inline-block;
    margin-left: 6px;
    padding: 0 6px;
    font-size: 0.75rem;
    line-height: 1.35;
    background: var(--accent-tint);
    color: var(--accent-text);
}

/* A class selector outranks the UA's `[hidden] { display: none }`, and
   `.badge` sets `display: inline-flex` — so without this the count stayed
   on screen after it had been read, showing an unread number to somebody
   who had just read them. Caught by `e2e/changelog.spec.js`, not by any
   unit test: the attribute WAS set, and only a browser applies a cascade.
   The same trap `.stepper .step-issue[hidden]` (:4949) documents. */
.cl-badge[hidden] { display: none; }


/* ── Reduced motion, once, for everything ────────────────────────────
   The app had reduced-motion blocks scattered per component, which is
   the arrangement that leaves a new animation moving for somebody who
   asked the OS for stillness — the guard has to be the default, not a
   thing each author remembers. Duration rather than `animation: none`:
   a spinner that vanishes reads as a broken page, while one that holds
   still is simply not spinning.

   It was the last block in the file when it was written, and it is not
   any more (the welcome's own rules follow it). That is harmless and
   worth stating rather than leaving the reader to check: every selector
   here is `*`, so specificity decides nothing and `!important` decides
   everything — a later component rule of ANY specificity loses to it
   unless it is `!important` too. What a later block CAN legitimately do
   is override one declaration with its own `!important`, which is
   exactly what the welcome's ring does. */
@media (prefers-reduced-motion: reduce) {
    *,
    *::before,
    *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
        transition-delay: 0s !important;
        animation-delay: 0s !important;
        scroll-behavior: auto !important;
    }
}

/* ── the check at the end of a module ────────────────────────────────
   One question per subtopic, after the last step. The options are real
   buttons in the rail — which is also the one place a keyboard reaches
   everything this module asked about, since the reading steps it checks
   have no control of their own at all.

   Stacked, not a row: an answer is a sentence, and three sentences side
   by side wrap into columns nobody can scan. `text-align: left` for the
   same reason — `.btn` centres, which is right for `Deploy` and wrong
   for a line of prose. */
.training-answers {
    list-style: none;
    margin: 10px 0 0;
    padding: 0;
    display: flex;
    flex-direction: column;
    gap: 6px;
}

.training-answers .btn {
    width: 100%;
    text-align: left;
    white-space: normal;
}

/* ── The training welcome ───────────────────────────────────────────
   The first screen somebody meets, and the one dialog in this app that
   a role the gate applies to cannot close. That is why the header cross
   is hidden rather than left to do nothing: a control that refuses is
   read as a broken screen, and the guard already turns Esc and the
   overlay click away. */
#modal-overlay.dm-welcome-gate .modal-close { display: none; }

/* Keyed on the CONTENT, the same `:has()` pattern the deploy panel and
   the bundle dialog use, so no class is added by JS and none can leak
   onto the next modal. The accent rule is the one piece of decoration on
   it: this dialog is the product's front door and every other surface in
   the app is outlined before it is lifted, so the welcome is marked the
   same way rather than with a colour nothing else here uses. */
.modal:has(.dm-welcome) {
    max-width: 560px;
    border-top: 3px solid var(--accent-primary);
}

/* THE ONE DIALOG WHOSE WAY ON IS CENTRED. Every other footer in this
   app is right-aligned, and that is right where a dialog is a form with
   a way out on its left — the eye ends at the bottom-right, which is
   where the commit belongs. This body is a centred mark with a centred
   headline under it, and a button hard against the right edge of that
   reads as belonging to a different screen. Keyed on the CONTENT, the
   `:has()` pattern the deploy panel and the bundle dialog already use,
   so no class is added by JS and none can leak onto the next modal. */
.modal:has(.dm-welcome) .modal-footer {
    justify-content: center;
}

/* The autofocused offer, before anybody has touched a key. It IS
   focused — Enter starts the training, and in the optional variant that
   matters, because the other button dismisses it — it just does not
   announce it with an outline nobody asked for. `trainingRenderWelcome`
   drops the attribute on the first keypress, and the ring comes back
   with it. Never `outline: none` unconditionally: that is the one
   change here that would leave a keyboard user with no idea where they
   are. */
#training-welcome-start[data-autofocus]:focus-visible {
    outline: none;
}

/* THE ONE SCREEN IN THIS APP WHERE AN ENTRANCE IS EARNED. Everything
   else here is seen dozens of times a day and animates at 160ms or not
   at all; this is seen ONCE, by somebody deciding whether to start, and
   an interface that arrives rather than appears is the difference the
   owner asked for. Still inside the house rules: opacity and transform
   only, ease-out, under 300ms, and the blanket reduced-motion block at
   the end of this file collapses every duration below. */
@keyframes dm-welcome-in {
    from { opacity: 0; transform: translateY(10px) scale(0.985); }
    to   { opacity: 1; transform: translateY(0) scale(1); }
}

@keyframes dm-welcome-line {
    from { opacity: 0; transform: translateY(6px); }
    to   { opacity: 1; transform: translateY(0); }
}

/* THE MARK RISES OUT OF ITS OWN BOX. `.dm-welcome-mark` is a clipping
   frame the exact size of the glyph, so the sprout starts below the
   floor rather than fading in on the spot — the shape GROWS, which is
   what the mark itself draws. Transform only: a `clip-path` reveal
   reads identically and leaves the house rule (transform/opacity)
   behind for the next person to argue about. */
@keyframes dm-welcome-sprout {
    from { transform: translateY(100%); }
    to   { transform: translateY(0); }
}

/* One ring, once, behind the mark, arriving a beat after it has landed.
   It is the only decorative motion in the app and it is spent on the
   one screen seen exactly once. Never a loop: a pulse under a dialog
   somebody is reading is a repaint that never stops asking for
   attention.

   It starts INVISIBLE, and that is not a rounding detail: `both` fills
   the delay with the 0% frame, so a ring that opens at `opacity: .5`
   sits there as a small static circle for the whole 300ms wait and then
   jumps into motion. Measured in the browser at 140ms — it read as a
   stray outline around the mark, which is the opposite of a flourish. */
@keyframes dm-welcome-ring {
    0%   { opacity: 0;    transform: scale(0.5); }
    35%  { opacity: 0.45; }
    100% { opacity: 0;    transform: scale(1.2); }
}

.modal-overlay:has(.dm-welcome) .modal {
    /* Never from scale(0): nothing in the world appears from nothing,
       and a modal is not anchored to a trigger, so it grows from its own
       centre rather than from a corner. */
    animation: dm-welcome-in 240ms var(--ease-out) 1;
    transform-origin: center;
}

/* The lines arrive after the plate they sit on, 50ms apart — inside the
   30-80ms band a cascade reads as one movement in, and settled well
   under half a second. Decorative only: nothing waits on it, and the
   footer button is pressable from the first frame. */
.dm-welcome-lead,
.dm-welcome-meta {
    animation: dm-welcome-line 220ms var(--ease-out) both;
}


/* The blanket rule above already zeroes `animation-delay` along with
   the durations, so there is nothing to restate here — a per-element
   copy of it read as a guarantee this block was providing, and a rule
   that only looks load-bearing is worse than no rule. What the blanket
   cannot do is remove the ring: it collapses the duration to 0.01ms,
   which still paints a frame of it. */
@media (prefers-reduced-motion: reduce) {
    .dm-welcome-sigil::before { animation: none; opacity: 0; }
}

/* CENTRED, and only once the text was cut to two lines. A centred
   paragraph is hard to read and a centred list is worse — which is why
   every other surface in this app is left-aligned and stays that way.
   What is left here is a headline and a row of three facts, both of
   which centre correctly under a mark that is the point of the screen. */
.dm-welcome {
    display: flex;
    flex-direction: column;
    align-items: center;
    text-align: center;
    gap: 14px;
    padding: 12px 0 4px;
}

/* The company mark, in the brand orange the assistant header already
   paints it — this dialog is the product's front door, and the one place
   in the app where saying whose product it is belongs. Decorative, so
   the colour answers to nothing but itself.

   It is a LETTERHEAD, not a line: mark above name, centred, and the
   largest thing on the screen. Beside the sentence it was a 26px shape
   competing with a paragraph and losing — and the paragraph is gone
   now, so the mark is what the screen actually is. */
.dm-welcome-brand {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 12px;
    margin: 0 0 6px;
    color: #F37921;
}

/* The ring's stage. It must NOT clip (the ring grows past it) and it
   must not be the clipping box (that is `.dm-welcome-mark`, one level
   in) — two jobs, two elements. */
/* Sized by the GLYPH, never by the ring. A 64px stage around a 38px
   mark puts 13px of empty box under it, and that dead space adds itself
   to the gap below — measured in the browser, the name sat further from
   the mark than the flex `gap` says. The ring is given its room by a
   negative inset instead, since it is absolute and free to overflow. */
.dm-welcome-sigil {
    position: relative;
    display: flex;
    align-items: center;
    justify-content: center;
}

.dm-welcome-sigil::before {
    content: "";
    position: absolute;
    /* Centred by margin, not by `translate`, because the keyframe owns
       `transform` — a centring translate there is a centring translate
       the scale wipes out. */
    top: 50%;
    left: 50%;
    width: 84px;
    height: 84px;
    margin: -42px 0 0 -42px;
    border: 1px solid currentColor;
    border-radius: 50%;
    opacity: 0;                       /* the keyframe owns it; nothing at rest */
    animation: dm-welcome-ring 1100ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
    pointer-events: none;
}

/* The floor the mark grows out of. Sized by its own content, so it stays
   correct if the glyph's height changes. */
.dm-welcome-mark {
    display: block;
    overflow: hidden;
    line-height: 0;
}

/* SLOWER THAN EVERY OTHER MOTION IN THIS APP, ON PURPOSE, and not with
   `--ease-out`. That curve is easeOutQuint — nearly the whole distance
   is covered in its first fifth, which is exactly right for a dropdown
   answering a click and exactly wrong here: the owner watched this and
   said it was over before he could see what had happened. This is
   easeOutCubic, which spends its time in the middle of the travel where
   the eye can follow it, over 600ms. The <300ms house rule is about
   motion seen dozens of times a day; this is seen once, by somebody who
   has not started reading yet, and it is the app introducing itself. */
.dm-welcome-mark svg {
    display: block;
    animation: dm-welcome-sprout 600ms cubic-bezier(0.22, 0.61, 0.36, 1) both;
}

/* The name is the app's own quiet label voice, not the brand colour: two
   orange things in a row would make the signature the loudest thing on a
   screen whose loudest thing is the sentence under it. */
.dm-welcome-name {
    font-size: 0.75rem;
    font-weight: 600;
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-secondary);
    animation: dm-welcome-line 220ms var(--ease-out) both;
}

/* THE MARK WAITS FOR THE DIALOG. It used to start at 60ms, while the
   modal itself was still arriving (240ms) — two things moving at once,
   and the one worth watching lost. It starts after the plate has
   landed, and the words follow it 50ms apart: inside the 30-80ms band a
   cascade reads as one movement rather than as five things arriving.
   Nothing waits on any of it — the button is pressable from the first
   frame.

   THIS BLOCK MUST STAY BELOW EVERY `animation:` SHORTHAND ON THESE
   SELECTORS, and it did not: the three rules that declare the shorthand
   (`.dm-welcome-sigil::before`, `.dm-welcome-mark svg`,
   `.dm-welcome-name`) sit ~80 lines further down, same specificity, and
   a shorthand resets every longhand it omits — so all three delays were
   silently `0s` and the mark sprouted alongside the modal, which is the
   exact regression the paragraph above claims to have fixed. It shipped
   green: nothing asserted a delay, and a screenshot cannot show one.
   The guard is in `e2e/training-welcome.spec.js`, reading
   `getComputedStyle().animationDelay`. A new `animation:` shorthand on
   any `.dm-welcome*` selector goes ABOVE here, or carries its own delay
   as the second time value. */
.dm-welcome-mark svg { animation-delay: 220ms; }
.dm-welcome-sigil::before { animation-delay: 640ms; }
.dm-welcome-name { animation-delay: 760ms; }
.dm-welcome-lead { animation-delay: 810ms; }
.dm-welcome-meta { animation-delay: 870ms; }

/* The one loud line: what this tool is, and what the training does with
   it. Everything under it is second-read. */
.dm-welcome-lead {
    margin: 0;
    /* The ramp's top step. It is the only sentence on the screen, so
       it is allowed to be the headline rather than a paragraph that
       happens to be first. */
    font-size: 1.5rem;
    line-height: 1.3;
    color: var(--text-primary);
}

/* THE WHOLE COMMITMENT IN ONE ROW. How big, may I stop, must I — three
   questions whose honest answers are three or four words each, so they
   are three cells of one muted line rather than three sentences. It has
   been a list of module cards, then three ticked facts, then two
   paragraphs; each rewrite was better prose and the note from its owner
   never changed, because the note was never about the words. The middle
   dot is the app's own separator — the installation cards use it for
   exactly this. */
.dm-welcome-meta {
    margin: 0;
    /* 58ch, MEASURED, not chosen for looking modest. 46ch resolved to
       368px against a natural single-line width of 451px and a 520px
       body, so the row wrapped at every viewport — breaking after the
       second `·`, which left line 1 ending on a separator with nothing
       after it and stranded "required for your role", the gated role's
       whole explanation, alone on line 2. 58ch is ~464px: one line here
       and one line with a three-digit module count (~465px), still
       inside the body, and still wrapping gracefully at 320px. */
    max-width: 58ch;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-muted);
}


/* ============================================================
   The Activity tab — the journal folded.

   Self-contained: every selector is prefixed `.act-`, and every colour
   is an EXISTING token, so both themes are already correct and the
   global reduced-motion guard already covers the two transitions here.

   TWO THINGS THAT LOOK LIKE STYLE AND ARE NOT:

   1. `.act-bar` carries ONE fill. It is a magnitude, not a share --
      a part-to-whole bar is a percentage drawn instead of written, and
      1 of 2 renders identically to 40 of 80. Failures sit BESIDE it as
      a count, never as a second segment inside it.
   2. `.act-people` has no bar of any kind. Decision D1 forbids ranking
      colleagues, and a ranking is built by INTENSITY just as well as by
      sorting -- a neutral-hued volume bar would still be one.
   ============================================================ */

/* The heading and the filters share ONE band.

   The filter row used to sit under the heading on a line of its own and
   cost about 65px of the first screen -- which pushed the chart, the one
   non-numeric encoding here, past the fold at 1280x800. A control the
   reader touches once a visit does not get a band above the news. It
   wraps below the heading under ~720px, where the two no longer fit. */
.act-head {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    justify-content: space-between;
    gap: 12px 24px;
    margin: 0 0 16px;
}

.act-head-text { flex: 1 1 22rem; min-width: 0; }
.act-head-text h2 { margin: 0; }
.act-head-text .form-help { margin: 4px 0 0; }

.act-filters {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    gap: 12px 16px;
    margin: 0;
}

.act-field {
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.act-field label {
    /* 0.85rem is the ramp's second-read step; 0.8rem is off it and
       `design-tokens.test.js` fails the build on it. */
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-secondary);
}

.act-field input:focus-visible,
.act-field select:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}

.act-actions { justify-content: flex-end; }

/* The custom range: two native date boxes, side by side, on the same
   bottom edge as every other control in the row. `display` has to be
   restated under `[hidden]` because a `display` declaration outranks the
   attribute's own UA rule -- the trap that leaves a "hidden" element
   fully visible. */
.act-custom {
    display: flex;
    align-items: flex-end;
    gap: 12px 16px;
}

.act-custom[hidden] { display: none; }

.act-status {
    margin: 0 0 12px;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.act-status:empty { display: none; }

/* ── the three tiles ─────────────────────────────────────────────── */

.act-tiles {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: 12px;
    margin: 0 0 20px;
}

/* A BUTTON, because pressing it opens the rows the number was counted
   from. Every figure on this screen has to be openable, or it is a
   number nobody can check. */
.act-tile {
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: 16px;
    text-align: left;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
    cursor: pointer;
    font: inherit;
    color: var(--text-primary);
    /* Exact property, ease-out, well under 300ms -- the same rule the
       journal's own rows wear. Never `all`. */
    transition: background-color 0.12s var(--ease-out);
}

.act-tile:hover { background: var(--surface-interactive); }

.act-tile:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
}

.act-tile-n {
    /* The ramp's TITLE step, and the largest the system has -- a hero
       number would want more, and inventing a size is how a design
       system stops being one (DESIGN.md's ramp is pinned by
       `design-tokens.test.js`). Proportional figures, not
       `tabular-nums`: equal-width digits make 121 look loose here. */
    font-size: 1.5rem;
    font-weight: 600;
    line-height: 1.1;
}

/* ABOVE the number, because a figure means nothing until the reader
   knows what it counts. Below it, the tile asked them to take the number
   on faith and then told them what it was. */
.act-tile-l {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-secondary);
}

.act-tile-d {
    font-size: 0.85rem;
    color: var(--text-secondary);
}

/* THE NEWS IS TINTED, and only the figure. A whole red plate would make
   an ordinary week look like an incident and would spend the one alarm
   colour on the screen's background rather than on its foreground; the
   figure alone is the smallest mark that separates news from volume.
   `--danger-text`, never `--danger`: a fill token in a `color:` slot
   measured 2.79:1 on the dark card, which is the rule this stylesheet
   states about those three tokens. */
.act-tile-bad .act-tile-n { color: var(--danger-text); }

/* A tile with NO per-day series says so in words. It rendered as a gap
   between two tiles that both draw a line, and a gap reads as flat --
   which is the absence-as-zero collapse this screen exists to refuse. */
.act-tile-none {
    display: block;
    min-height: 24px;
    margin: 4px 0 2px;
    max-width: 20rem;
    font-size: 0.75rem;
    line-height: 1.3;
    color: var(--text-muted);
}

/* The tile's own run of days.

   Deliberately WITHOUT a scale, an axis or a label: it says "it climbed"
   or "it was one bad Tuesday" before a word is read, and the delta line
   under it says by how much. `--text-muted` rather than a status hue --
   this is context for the figure above, not a second verdict beside it,
   and `stroke` is a class because an SVG presentation attribute cannot
   resolve a custom property. */
.act-spark {
    display: block;
    /* Narrow enough to read as a small chart rather than as a rule
       drawn across the tile: full width, it was a divider between the
       figure and the sentence under it. */
    width: 100%;
    max-width: 132px;
    height: 24px;
    margin: 4px 0 2px;
    overflow: visible;
}

.act-spark polyline {
    fill: none;
    stroke: var(--text-muted);
    stroke-width: 1.5;
    stroke-linecap: round;
    stroke-linejoin: round;
}

/* AFTER the rule above, not before it: same specificity, so the later
   one wins and a modifier written first is a modifier that does
   nothing. The failures tile's line takes the chart's own failure
   colour, so a reader who has learned "red is broke" one block down
   does not learn it twice. Everything else stays de-emphasised -- a
   sparkline is context for the figure above it, not a second verdict. */
.act-spark-bad polyline { stroke: var(--danger); }

/* ── blocks ──────────────────────────────────────────────────────── */

.act-block { margin: 28px 0 0; }

.act-block h3 {
    margin: 0 0 4px;
    font-size: 1.15rem;
    font-weight: 600;
}

/* The chart is the FIRST thing under the figures, so it carries no
   top margin of its own -- the tiles' own bottom margin is the gap. */
.act-block-chart { margin-top: 0; }

/* Title and legend on one line: the legend belongs TO the chart, and a
   line of its own between the title and the plot reads as a third
   thing. It drops under the title below ~560px. */
.act-block-head {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    justify-content: space-between;
    gap: 4px 16px;
    margin: 0 0 8px;
}

.act-block-head h3 { margin: 0; }

/* What the word "work" means: needed once, then never again. Open, it
   was four lines of prose standing between the figures and the shape. */
.act-counted { margin: 16px 0 0; }

.act-counted > summary {
    cursor: pointer;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.act-counted .act-note { margin: 8px 0 0; max-width: 68ch; }

/* The same disclosure, sitting in a block's own heading row rather than
   under the whole screen. */
.act-counted-inline { margin: 0; }

.act-note {
    margin: 4px 0 12px;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-secondary);
}

.act-list {
    list-style: none;
    margin: 0;
    padding: 0;
}

/* ── the chart ───────────────────────────────────────────────────── */

/* The legend sits on the PLOT'S OWN SURFACE, not on the card.

   Measured: the `done` and `broke` swatches came to 2.73:1 and 2.79:1
   against the dark card -- below the 3:1 a non-text indicator owes --
   while the very same fills measure 3.14:1 and 3.21:1 against the sunken
   plate they are a sample OF. The swatch's whole job is to be matched
   against a segment, so putting it on any other ground was both the
   contrast failure and the wrong picture. */
.act-legend {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: 6px 16px;
    list-style: none;
    margin: 0;
    padding: 4px 10px;
    background: var(--surface-sunken);
    border-radius: var(--radius);
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.act-legend li { display: flex; align-items: center; gap: 6px; }

/* A SWATCH and a word. The word is the non-colour channel -- the three
   segments must never be told apart by hue alone, and green-against-red
   is the pair colour vision most often cannot separate. (It was a `#`
   glyph set in text; a swatch is the same mark at a size the reader can
   actually match against a 2px-tall segment.) */
.act-key {
    display: block;
    width: 10px;
    height: 10px;
    /* The system's `rounded.sm` step, as the volume bar uses. */
    border-radius: 3px;
    flex: none;
}

/* Scale on the left, plot and day axis on the right. */
.act-plot {
    display: flex;
    align-items: stretch;
    gap: 8px;
}

.act-plot-body { flex: 1 1 auto; min-width: 0; }

/* Three whole numbers, sitting exactly on the three rules inside the
   plot: top, half, zero. `space-between` over the plot's own height,
   with each label pulled up by half its line so its MIDDLE lands on the
   rule rather than its top edge. */
.act-yaxis {
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    align-items: flex-end;
    flex: none;
    width: 2.75rem;
    height: 200px;
    padding: 4px 0 0;
    font-size: 0.75rem;
    line-height: 1;
    color: var(--text-muted);
    font-variant-numeric: tabular-nums;
}

.act-yaxis span { transform: translateY(-0.5em); }
.act-yaxis span:first-child { transform: none; }

/* The plot area is a SURFACE, not a boxed widget.

   It was a sunken plate with a full border, which read as a component
   sitting on the card rather than as the card's own content. What is
   left is the ground the segments are measured against (the e2e checks
   3:1 against exactly this fill) and a hairline baseline.

   NO interior gridlines, and that is a decision rather than an omission:
   there is no y-axis to label them against, so they would be decoration
   -- and this stylesheet bans every gradient function outright (pinned
   by `design-tokens.test.js`, which greps the source, comments and all),
   which is the only way to paint them without inventing two elements per
   chart. The
   tallest day carries a direct label and the table below carries every
   value, which is the order the mark spec asks for anyway. */
.act-chart {
    position: relative;
    display: flex;
    align-items: flex-end;
    justify-content: flex-start;
    gap: 3px;
    height: 200px;
    padding: 4px 8px 0;
    background: var(--surface-sunken);
    border: 0;
    border-radius: var(--radius) var(--radius) 0 0;
}

/* Three solid hairlines, one step off the surface, never dashed: the
   scale on the left is only readable if the reader can see where its
   numbers fall. Behind the marks, and out of the flex flow, so a
   gridline can never be mistaken for a column of its own. */
.act-grid {
    position: absolute;
    inset: 4px 0 0;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    pointer-events: none;
}

/* THE TWO INTERIOR RULES STAY RECESSIVE ON PURPOSE, and the numbers say
   why the decision is defensible rather than convenient. Measured
   against the plate: 1.28:1 light, 1.45:1 dark -- under the 3:1 that
   WCAG 1.4.11 asks of a graphical object REQUIRED to understand the
   content. These are not required: every column carries its own value
   while there is room, the y-axis prints three whole numbers at 4.7:1
   or better, and the `<details>` table carries every figure. A gridline
   loud enough to clear 3:1 competes with the data it is behind, which
   is the "heavy gridlines" defect one rung down. */
.act-grid i {
    display: block;
    height: 1px;
    background: var(--border-color);
}

/* THE BASELINE IS DIFFERENT, and it does clear 3:1. It is the edge every
   column is measured from -- the one line the chart genuinely cannot be
   read without -- so it takes a text-weight token (5.02:1 light,
   4.72:1 dark) rather than the hairline the two above it wear. */
.act-grid i:last-child { background: var(--text-muted); }

/* THE SLOT IS THE COLUMN'S SHARE OF THE WIDTH; the MARK inside it is
   capped. Capping the slot instead is what a first cut did, and seven
   days then huddled into 190px at the left edge of a 1180px plot with
   the rest of it empty -- which reads as a chart that failed to draw.
   The cap belongs on the mark ("never fill the slot; let the band's
   leftover be air"), and the axis below shares this basis so a label
   stays centred under its own day. */
.act-col {
    position: relative;
    display: flex;
    flex: 1 1 0;
    flex-direction: column;
    justify-content: flex-end;
    align-items: stretch;
    min-width: 0;
    height: 100%;
}

.act-stack {
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
    flex: 1 1 auto;
    /* The mark spec's ceiling. Centred in the slot, so a seven-day
       period is seven marks spread across the plot rather than seven
       blocks jammed against its left edge. */
    max-width: 24px;
    width: 100%;
    margin: 0 auto;
    /* A 2px surface gap between fills, never a border drawn around
       them: a border adds a colour the data does not have. */
    gap: 2px;
    min-height: 0;
}

/* Square at the baseline, rounded at the data end -- and the data end is
   the FIRST segment in the stack, since the column is laid out bottom-up
   from `flex-end`. Rounding every segment made each one read as its own
   floating bar. */
.act-seg { display: block; min-height: 2px; }
.act-seg:first-child { border-radius: 3px 3px 0 0; }

/* THE PALETTE, and why it is not green-against-red.

   1. "done" is VOLUME, not a verdict. Ordinary work finishing is not
      good news needing a green tick -- it is the mass of the chart, and
      it wears the product's own accent, the same hue the installation
      bars below use for the same meaning. One hue for "how much work"
      across both charts on this screen.
   2. "broke" keeps `--danger`, because that one IS news, and a single
      saturated colour against a calm mass is what makes it findable.
      Status tokens for status; nothing else on this chart claims one.
   3. Green-against-red measured 1.11:1 against each other in the light
      theme -- the pair colour vision most often cannot separate, and
      the two segments that most need telling apart. Blue-against-red is
      the standard warm/cool split and separates under every simulated
      deficiency.
   4. "no outcome recorded" is an ABSENCE, so it takes a neutral rather
      than a third hue: the reserved tones must not be spent saying "we
      do not know". */
.act-seg-ok     { background: var(--accent-primary); }
.act-seg-failed { background: var(--danger); }
.act-seg-open   { background: var(--text-muted); }

/* THE CAUSES CHART: one hue in five steps, not five hues.

   Every band of that chart is a failure. A categorical palette would
   draw a distinction between them that does not exist -- and it would
   also spend the two reserved tones above, which already mean "work"
   and "broke" on the chart directly overhead. What separates the bands
   is ORDER (biggest against the axis) and the 2px surface gap the stack
   already leaves between fills; the legend names each one, so nothing
   rides on telling step 3 from step 4 by eye.

   Literal values rather than tokens: the shared token block is pinned
   byte-identical against cicd-manager's canonical set
   (`design-tokens.test.js`), and a ramp only this chart uses is not a
   reason to fork it. The hue is `--danger`'s own, in both themes.

   "Everything else" takes the desaturated step, never `--text-muted`:
   grey on this chart means an ABSENCE ("no outcome recorded"), and the
   causes below the fourth are not an absence. */
.act-cause-1     { background: hsl(4 72% 40%); }
.act-cause-2     { background: hsl(4 66% 52%); }
.act-cause-3     { background: hsl(6 58% 64%); }
.act-cause-4     { background: hsl(8 50% 75%); }
.act-cause-other { background: hsl(4 18% 72%); }

/* The dark ramp is WIDER than a mirror of the light one. Measured on the
   first cut: adjacent bands sat at 1.26-1.39:1 against each other and the
   top band at 1.19:1 against the `--danger` of the chart directly above,
   which leaves the 2px gap doing all the separating. Spread out, the
   steps are 1.51-1.90:1 and the top band 1.51:1 against that red — still
   one hue, now legible as five. (Measured by the visual lens on the
   finished diff, 2026-09-07.) */
[data-theme="dark"] .act-cause-1     { background: hsl(4 78% 66%); }
[data-theme="dark"] .act-cause-2     { background: hsl(4 68% 52%); }
[data-theme="dark"] .act-cause-3     { background: hsl(6 55% 38%); }
[data-theme="dark"] .act-cause-4     { background: hsl(8 45% 26%); }
[data-theme="dark"] .act-cause-other { background: hsl(4 14% 42%); }

/* The way to the refusals the server cut. A link in flow, not a button
   that looks like an action: pressing it OPENS rows, the same as every
   other figure on this screen. */
.act-more {
    border: 0;
    padding: 0;
    background: none;
    font: inherit;
    color: var(--accent-primary);
    text-decoration: underline;
    text-underline-offset: 2px;
    cursor: pointer;
}
.act-more:hover { color: var(--accent-primary-hover); }
.act-more:focus-visible {
    outline: 2px solid var(--accent-primary);
    outline-offset: 2px;
    border-radius: 2px;
}

/* The value, sitting ON its own cap.

   Absolutely positioned against the column and offset by the same
   percentage the stack is tall, because in the flex flow it landed at
   the top of the PLOT instead: a row of numbers floating far above the
   marks they belong to, which is a pairing the reader has to guess. */
.act-col-n {
    position: absolute;
    left: 0;
    right: 0;
    /* Clear of the cap by the gap the stack's own segments use. */
    margin-bottom: 3px;
    font-size: 0.75rem;
    line-height: 1.1;
    text-align: center;
    color: var(--text-secondary);
    font-variant-numeric: tabular-nums;
}

.act-axis {
    display: flex;
    gap: 3px;
    padding: 0 8px;
    margin-top: 6px;
    font-size: 0.75rem;
    color: var(--text-muted);
}

.act-axis span {
    flex: 1 1 0;
    min-width: 0;
    text-align: center;
    /* One slot per day, so a label sits centred under the mark it names.
       It may spill into the air of the empty slots beside it, which is
       why only every Nth is printed at ninety days -- clipping it to the
       slot would ellipse "27 Aug" to nothing. */
    overflow: visible;
    white-space: nowrap;
}

.act-table { margin-top: 8px; }
.act-table > summary { cursor: pointer; font-size: 0.85rem; }

/* Three of this screen's tab stops are `<summary>` elements, and they
   were the only ones wearing the browser's own 1px ring instead of the
   app's. A focus indicator that changes shape between neighbouring
   stops is one a keyboard reader has to re-find at each. */
.act-block summary:focus-visible,
.act-counted > summary:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
    border-radius: 2px;
}

/* The table twin and the people table can both scroll sideways, and a
   scrolling box a keyboard cannot reach is a box a keyboard cannot read
   (axe `scrollable-region-focusable`). Scoped to this screen's own
   containers rather than to `.table-container` globally: that class is
   worn across the app and adding a tab stop to every one of them is a
   change to every screen's tab order. */
.act-block .table-container:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

/* ── installations ───────────────────────────────────────────────── */

.act-inst {
    display: grid;
    /* The badge column is FIXED, not `auto`: sized by its content, a row
       carrying a `deleted` badge pushed its bar right and the three bars
       stopped sharing a left edge — which is the edge the eye compares
       their lengths against. */
    grid-template-columns:
        minmax(0, 14rem) 5.5rem minmax(0, 1fr) 3rem 9rem;
    align-items: center;
    gap: 8px 12px;
    padding: 6px 0;
    border-bottom: 1px solid var(--border-color);
}

.act-inst-name,
.act-fail-code,
.act-ref-who,
.act-person {
    padding: 0;
    background: none;
    border: 0;
    font: inherit;
    text-align: left;
    color: var(--accent-text);
    cursor: pointer;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* UNDERLINED BY DEFAULT, not on hover. These four are the screen's
   twenty-odd doors into the journal, and inside a table or a list their
   only difference from the text beside them was HUE -- WCAG 1.4.1, and
   invisible to a colour-deficient reader, to touch, and to a keyboard
   until focus happens to land. Hover then has to say something else, so
   it drops the underline: the link is already marked, and the change is
   what answers "is this the one under my cursor". */
.act-inst-name,
.act-fail-code,
.act-ref-who,
.act-person {
    text-decoration: underline;
    text-underline-offset: 2px;
    text-decoration-thickness: 1px;
}

.act-inst-name:hover,
.act-fail-code:hover,
.act-ref-who:hover,
.act-person:hover { text-decoration: none; }

.act-inst-name:focus-visible,
.act-fail-code:focus-visible,
.act-ref-who:focus-visible,
.act-person:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
    border-radius: 2px;
}

/* ONE series. See the block comment at the top of this section. */
.act-bar {
    display: block;
    height: 10px;
    background: var(--bg-input);
    /* The system's `rounded.sm` step (DESIGN.md). A literal because the
       stylesheet declares only --radius (8px) and --radius-lg (14px),
       and 8px on a 10px-tall bar reads as a lozenge. */
    border-radius: 4px;
    overflow: hidden;
}

.act-bar-fill {
    display: block;
    height: 100%;
    background: var(--accent-primary);
    /* Square where it grows from, rounded where it ends -- the mark spec
       both bars on this screen follow. The track's own `overflow:hidden`
       rounds the left end back when the bar is full, which is the one
       case where the two ends genuinely are the same edge. */
    border-radius: 0 4px 4px 0;
}

/* The empty half of the badge cell. Present so the five-column grid
   stays five columns on a row that has no badge. */
.act-inst-gap { display: block; }

.act-inst-n {
    text-align: right;
    font-variant-numeric: tabular-nums;
}

.act-inst-bad {
    padding: 0;
    background: none;
    border: 0;
    font: inherit;
    font-size: 0.85rem;
    color: var(--danger-text);
    cursor: pointer;
    white-space: nowrap;
}

.act-inst-bad:hover { text-decoration: underline; }

.act-inst-bad:focus-visible {
    outline: 2px solid var(--danger-text);
    outline-offset: 2px;
    border-radius: 2px;
}

/* ── what broke, and who was told no ─────────────────────────────── */

.act-fail,
.act-ref {
    display: grid;
    align-items: baseline;
    gap: 4px 12px;
    padding: 6px 0;
    border-bottom: 1px solid var(--border-color);
    font-size: 0.9rem;
}

/* COUNT FIRST on both, and the two rows now share a shape: they answer
   the same question ("how many, of what, by whom") and reading them as
   two different tables was the reader's job, not the data's. The count
   is the column the eye scans, so it is the column the eye meets. */
/* The third column holds our own vocabulary -- a `fail:*` code on one
   row set, the refusal's reason on the other -- and 11rem ellipsed
   `fail:healthcheck_timeout` to `fail:healthcheck_tim...`, which is a
   value truncated with no way to read the rest of it. */
.act-fail { grid-template-columns: 3rem minmax(0, 20rem) minmax(0, 14rem) minmax(0, 1fr); }
.act-ref  { grid-template-columns: 3rem minmax(0, 20rem) minmax(0, 14rem) minmax(0, 1fr); }

.act-fail-n,
.act-ref-n {
    text-align: right;
    font-variant-numeric: tabular-nums;
    font-weight: 600;
}

.act-fail-who,
.act-ref-why {
    font-size: 0.85rem;
    color: var(--text-secondary);
}

/* Our own vocabulary, printed beside the sentence rather than instead of
   it. The sentence is what an operator acts on; the code is what they
   grep the journal with, and dropping it left them nothing to search. */
.act-code,
.act-ref-route {
    font-family: var(--font-mono);
    /* The ramp's MONO step. It holds a route pattern, which is machine
       text, and that is what the step is for. */
    font-size: 0.85rem;
    color: var(--text-secondary);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* ── people ──────────────────────────────────────────────────────── */

.act-people th[scope="row"] {
    font-weight: 400;
    white-space: normal;
}

.act-role {
    display: block;
    font-size: 0.75rem;
    color: var(--text-muted);
}

.act-people td { font-variant-numeric: tabular-nums; }

.act-top {
    font-variant-numeric: normal;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.act-error {
    padding: 12px;
    background: var(--danger-bg);
    border: 1px solid var(--danger-text);
    border-radius: var(--radius);
}

.act-error p { margin: 0 0 8px; }

@media (max-width: 900px) {
    .act-inst,
    .act-fail,
    .act-ref { grid-template-columns: 1fr; }
    .act-axis { display: none; }
}


/* ============================================================
   The Team tab — who has an account, and the one place a role
   is changed.

   Self-contained: every selector is prefixed `.ppl-`, and every colour
   is an EXISTING token, so both themes are already correct.

   TWO THINGS THAT LOOK LIKE STYLE AND ARE NOT:

   1. EVERY ROLE WEARS THE SAME PILL. `.badge-secondary` is one of the
      two tones this system reserves for a NAME rather than a state --
      it is glyph-free for exactly that reason -- and the role's own
      word is the whole channel. Four tinted pills would encode rank as
      hue, which is both a ranking of colleagues (decision D1, the same
      argument that keeps a bar out of the Activity people table) and a
      meaning a colour-deficient reader cannot read.
   2. THE CONFIRM BUTTON IS `.btn-danger` FOR A PROMOTION TOO. What is
      being confirmed is a privilege change over customers' production
      servers, in both directions; a promotion drawn as the friendly
      choice is the one an operator stops reading.
   ============================================================ */

.ppl-head { margin: 0 0 16px; }
.ppl-head h2 { margin: 0; }
.ppl-head .form-help { margin: 4px 0 0; }

.ppl-status {
    margin: 0 0 12px;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.ppl-status:empty { display: none; }

.ppl-note {
    margin: 4px 0 12px;
    max-width: 68ch;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-secondary);
}

/* The sentence a reader below support_admin gets INSTEAD of the roster.
   It is the answer, not a footnote, so it leads. */
.ppl-scope { margin-top: 0; }

.ppl-partial { margin-bottom: 4px; }

.ppl-caveat { margin-bottom: 16px; }

.ppl-who { font-weight: 600; }

/* Timestamps and counts line up column-wise or they cannot be compared
   at a glance. `--text-secondary`, never `--text-muted`: this is text,
   and the muted token is not held to 4.5:1. */
.ppl-when,
.ppl-n {
    font-variant-numeric: tabular-nums;
    color: var(--text-secondary);
    white-space: nowrap;
}

.ppl-n { text-align: right; }

/* AN ABSENCE IS NOT A ZERO, and it does not look like one either: the
   words say it, the italic is the second channel. */
.ppl-unknown {
    font-style: italic;
    text-align: left;
}

.ppl-sub {
    display: block;
    font-weight: 400;
    font-size: 0.75rem;
    color: var(--text-secondary);
}

.ppl-do { white-space: nowrap; }

/* Why there is no button on this row. The server's own sentence, at the
   size of a caption -- it is an explanation, not an alarm, so it takes
   no danger tone. */
.ppl-blocked {
    display: inline-block;
    max-width: 26rem;
    font-size: 0.85rem;
    line-height: 1.4;
    color: var(--text-secondary);
}

.ppl-meaning { margin: 16px 0 0; }

.ppl-meaning > summary {
    cursor: pointer;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.ppl-meaning > summary:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
    border-radius: 2px;
}

.ppl-list {
    margin: 8px 0 0;
    padding: 0 0 0 20px;
    max-width: 68ch;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-secondary);
}

.ppl-list li { margin: 0 0 4px; }

/* Same box as the journal's and the Activity screen's, so a failure
   looks the same wherever it is met. */
.ppl-error {
    padding: 12px;
    background: var(--danger-bg);
    border: 1px solid var(--danger-text);
    border-radius: var(--radius);
}

.ppl-error p { margin: 0 0 8px; }

/* ── the confirmation ────────────────────────────────────────────── */

.ppl-dialog-who { margin: 0 0 12px; }

/* BORDERLESS on purpose. A box drawn round the choice would be a
   non-text object owing 3:1, and the only token thin enough to read as a
   grouping line is the decorative hairline that does not clear it. The
   legend and the indent are the grouping; nothing is lost. */
.ppl-choice {
    margin: 0;
    padding: 0;
    border: 0;
}

.ppl-choice legend {
    padding: 0;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-secondary);
}

.ppl-opt {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 4px 0;
    cursor: pointer;
}

.ppl-opt input:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
}

/* The refusal stays in the dialog the question was asked in. */
.ppl-dialog-error {
    margin: 12px 0 0;
    padding: 8px 12px;
    background: var(--danger-bg);
    border-radius: var(--radius);
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--danger-text);
}

/* The exact change, written out, between the choice and the note. It
   used to be the confirm button's own label; a sentence that long made
   the button wrap to the dialog's full width and fall below Cancel. */
.ppl-dialog-change { margin: 14px 0 0; }
.ppl-dialog-note { margin: 12px 0 0; }

@media (max-width: 900px) {
    .ppl-blocked { max-width: none; }
}


/* ============================================================
   The Assistant tab — what the AI was asked, refused, and cost.

   Self-contained: every selector is prefixed `.asa-`, and every colour
   is an EXISTING token, so both themes are already correct.

   TWO THINGS THAT LOOK LIKE STYLE AND ARE NOT:

   1. There is no bar, no meter and no chart in this section. Decision
      D1 forbids a composite score and a verdict, and a part-to-whole bar
      IS a percentage drawn instead of written -- 1 of 2 renders exactly
      like 40 of 80, which is the figure §8 rule 2 refuses to print below
      its threshold. The figures here are counts and sentences.
   2. Only ONE thing on this screen is tinted: `.asa-warn`, the sentence
      that says a figure is not being collected. It is not a failure and
      not an alarm -- it is the difference between "nothing happened" and
      "nothing was recorded", which is the one confusion this screen
      exists to prevent, so it gets the one mark that is not black text.
   ============================================================ */

.asa-head {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    justify-content: space-between;
    gap: 12px 24px;
    margin: 0 0 16px;
}

.asa-head-text { flex: 1 1 22rem; min-width: 0; }
.asa-head-text h2 { margin: 0; }
.asa-head-text .form-help { margin: 4px 0 0; }

.asa-filters {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    gap: 12px 16px;
    /* Clear of the paragraph above: the label sat on the last line of
       the prose, so the field read as part of the sentence. 16px is
       `spacing.lg` from DESIGN.md. Zero on top is kept for the bar at
       the head of the page, which supplies its own. */
    margin: 16px 0 0;
}
.asa-page-head + .asa-filters,
.asa-bar .asa-filters { margin-top: 0; }

.asa-field {
    /* The same three the journal bar sets, so the two filter rows are
       one control size rather than two. `--ui-control-h` is derived, not
       typed: it is what brings a taller native control DOWN to the row. */
    --ui-control-pad: 8px 10px;
    --ui-control-font: 0.9rem;
    --ui-control-h: calc(1.4 * var(--ui-control-font) + 18px);
    display: flex;
    flex-direction: column;
    gap: 4px;
    min-width: 0;
}

.asa-field label {
    /* 0.85rem is the ramp's second-read step; 0.8rem is off it and
       `design-tokens.test.js` fails the build on it. */
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-secondary);
}

.asa-field input:focus-visible,
.asa-field select:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}

.asa-actions { justify-content: flex-end; }

.asa-status {
    margin: 0 0 12px;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.asa-status:empty { display: none; }

/* ── the figures ─────────────────────────────────────────────────── */

.asa-tiles {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
    gap: 12px;
    margin: 0 0 12px;
}

/* NOT a button. Every tile on the Activity screen opens the rows it was
   counted from; these do not, because the rows behind them name the
   colleague who asked -- and a click that opened them would be the
   by-person breakdown §15.1 forbids. The denominator is printed under
   the figure instead. */
.asa-tile {
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: 16px;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
}

/* ABOVE the number: a figure means nothing until the reader knows what
   it counts. */
.asa-tile-l {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-secondary);
}

.asa-tile-n {
    /* The ramp's title step, the largest the system has. Proportional
       figures, not `tabular-nums`: these are read, not compared down a
       column. */
    font-size: 1.5rem;
    font-weight: 600;
    line-height: 1.1;
    color: var(--text-primary);
}

.asa-tile-d {
    font-size: 0.85rem;
    color: var(--text-secondary);
}

/* `.asa-tile-bad` was HERE and is deleted with the class that set it.
   It fired on `refused > 0`, so one refusal in ninety days painted a
   danger figure -- a verdict with no threshold, which D1 forbids and
   which the section comment above already said this screen does not do.
   The comment was right and the code was wrong. */

/* ── blocks, lists, sentences ────────────────────────────────────── */

.asa-block { margin: 28px 0 0; }

.asa-block h3 {
    margin: 0 0 8px;
    font-size: 1.15rem;
}

.asa-sub {
    margin: 16px 0 4px;
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--text-secondary);
}

.asa-note {
    margin: 8px 0 0;
    max-width: 68ch;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-secondary);
}

/* THE ONE TINTED SENTENCE. See the block comment above: it separates
   "nothing was collected" from "nothing happened".

   `--warning-text`, not `--danger-text`. The comment above calls this
   sentence "not a failure and not an alarm" and the token said the
   opposite: danger is the app's word for something that broke, and
   nothing here has. Warning is the token for a fact that changes how a
   figure is read. */
.asa-warn {
    margin: 8px 0 0;
    max-width: 68ch;
    font-size: 0.85rem;
    line-height: 1.5;
    font-weight: 600;
    color: var(--warning-text);
}

.asa-list {
    margin: 8px 0 0;
    padding: 0;
    list-style: none;
    max-width: 44rem;
}

.asa-row {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 16px;
    padding: 6px 0;
    border-bottom: 1px solid var(--border-color);
    font-size: 0.85rem;
}

.asa-row:last-child { border-bottom: none; }

.asa-name {
    min-width: 0;
    overflow-wrap: anywhere;
    color: var(--text-primary);
}

/* The phase a refusal happened in. Secondary to the bucket beside it --
   two facts, one row, and the reader wants the bucket first. */
.asa-where { color: var(--text-muted); }

.asa-count {
    flex: 0 0 auto;
    font-variant-numeric: tabular-nums;
    font-weight: 600;
    color: var(--text-primary);
}

.asa-rev {
    margin: 16px 0 0;
    padding: 0 0 0 12px;
    border-left: 2px solid var(--border-color);
}

.asa-table { width: 100%; }

.asa-error {
    margin: 8px 0 0;
    padding: 12px 16px;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
    max-width: 68ch;
}

.asa-error p { margin: 0 0 8px; }
.asa-error .asa-note { margin: 0 0 8px; }

/* ── the transcripts ─────────────────────────────────────────────── */

.asa-scripts {
    margin: 12px 0 0;
    padding: 0;
    list-style: none;
}

.asa-script {
    padding: 12px 16px;
    margin: 0 0 8px;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
}

.asa-script-who {
    margin: 0;
    font-size: 0.75rem;
    color: var(--text-muted);
}

.asa-q {
    margin: 6px 0 0;
    font-size: 0.95rem;
    font-weight: 600;
    line-height: 1.5;
    color: var(--text-primary);
    overflow-wrap: anywhere;
}

.asa-a {
    margin: 6px 0 0;
    max-width: 68ch;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-secondary);
    overflow-wrap: anywhere;
    /* An answer runs to a few thousand characters and a page of them is
       a wall. Scrolled inside its own card rather than truncated: a
       transcript cut in the middle is evidence somebody has to go
       elsewhere to finish reading. */
    max-height: 22em;
    overflow-y: auto;
}

@media (max-width: 900px) {
    .asa-head { align-items: flex-start; }
}

/* ============================================================
   31. THE ENGINEERS SCREEN (compose-team.js)

   Self-contained: every selector is prefixed `.team-`, and every colour
   is an EXISTING token, so both themes are already correct.

   THREE THINGS THAT LOOK LIKE STYLE AND ARE NOT:

   1. There is no bar, no meter and no colour scale here, and there
      never may be. Decision D1 forbids a composite score and a verdict,
      and a part-to-whole bar IS a percentage drawn instead of written
      -- 1 of 2 renders exactly like 40 of 80, which is the figure §8
      rule 2 refuses to print below its threshold. Neither is there a
      tint keyed on a figure being large: a red "failures" cell is a
      verdict painted rather than stated.
   2. The two tinted things are both ABSENCES, not alarms: `.team-since`
      (this column has no data for the period asked) and `.team-none`
      (nothing was measured). They wear `--text-muted`, because the one
      thing they must not read as is a zero -- and a zero would be
      ordinary text, so they are quieter than ordinary text rather than
      louder.
   3. `.team-warn` is the caveat that a figure is NOT being collected,
      or is systematically biased. It is the one mark on this screen
      that is not ordinary text, for the same reason `.asa-warn` is:
      the difference between "nothing happened" and "nothing was
      recorded" is the confusion this screen exists to prevent.
   ============================================================ */

.team-head {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    justify-content: space-between;
    gap: 12px 24px;
    margin: 0 0 16px;
}

.team-head-text { flex: 1 1 22rem; min-width: 0; }
.team-head-text h2 { margin: 0; }
.team-head-text .form-help { margin: 4px 0 0; }

.team-filters {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    gap: 12px 16px;
    margin: 0;
}

.team-field {
    display: flex;
    flex-direction: column;
    gap: 4px;
    min-width: 0;
}

.team-field label {
    /* 0.85rem is the ramp's second-read step. */
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-secondary);
}

.team-field select:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}

.team-actions { justify-content: flex-end; }

.team-status {
    margin: 0 0 12px;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.team-status:empty { display: none; }

/* ── blocks, sentences ───────────────────────────────────────────── */

.team-block { margin: 28px 0 0; }
.team-block:first-child { margin-top: 0; }

.team-block h3 {
    margin: 0 0 8px;
    font-size: 1.15rem;
    font-weight: 600;
    color: var(--text-primary);
}

.team-sub {
    margin: 16px 0 6px;
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--text-primary);
}

.team-note {
    margin: 8px 0 0;
    max-width: 72ch;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-secondary);
}

.team-lead {
    margin: 8px 0 0;
    max-width: 72ch;
    font-size: 0.95rem;
    line-height: 1.5;
    color: var(--text-primary);
}

.team-warn {
    margin: 8px 0 0;
    max-width: 72ch;
    padding: 8px 12px;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--warning-text);
    background: var(--surface-sunken);
    border-left: 3px solid var(--warning-text);
    border-radius: 0 var(--radius-lg) var(--radius-lg) 0;
}

.team-code {
    font-family: var(--font-mono);
    font-size: 0.85rem;
    color: var(--text-secondary);
    overflow-wrap: anywhere;
}

/* ── what each column counts ─────────────────────────────────────── */

.team-counted {
    margin: 8px 0 12px;
    padding: 8px 12px;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
}

.team-counted > summary {
    cursor: pointer;
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-secondary);
}

.team-counted > summary:focus-visible {
    outline: none;
    box-shadow: var(--focus-ring);
    border-radius: var(--radius);
}

.team-defs { margin: 8px 0 0; }

.team-def { margin: 0 0 10px; }

.team-def dt {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-primary);
}

.team-def dd {
    margin: 2px 0 0;
    max-width: 78ch;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-secondary);
}

/* ── the table ───────────────────────────────────────────────────── */

.team-table th { white-space: nowrap; }

.team-table td { vertical-align: top; }

/* A header IS the sort control. A plain-text header beside a sortable
   one would say the two columns behave differently, and they do not. */
.team-sort {
    display: inline-flex;
    align-items: baseline;
    gap: 4px;
    padding: 0;
    font: inherit;
    font-weight: 600;
    color: var(--text-secondary);
    background: none;
    border: 0;
    cursor: pointer;
    text-align: left;
}

.team-sort:hover { color: var(--text-primary); }

.team-sort:focus-visible {
    outline: none;
    box-shadow: var(--focus-ring);
    border-radius: var(--radius);
}

/* AFTER `.team-sort`, not before it: a modifier written above the rule
   it modifies loses on source order and does nothing at all. */
.team-sort.is-sorted { color: var(--text-primary); }

.team-arrow {
    font-size: 0.75rem;
    color: var(--accent-text);
}

/* The person, and the figure, are both doors into the journal. They are
   underlined at rest rather than only on hover: a link distinguished
   from its neighbours by colour alone fails WCAG 1.4.1, and this table
   is made of them. */
.team-person,
.team-figure {
    padding: 0;
    font: inherit;
    color: var(--accent-text);
    background: none;
    border: 0;
    cursor: pointer;
    text-decoration: underline;
    text-underline-offset: 2px;
}

.team-person:hover,
.team-figure:hover { color: var(--text-primary); }

.team-person:focus-visible,
.team-figure:focus-visible {
    outline: none;
    box-shadow: var(--focus-ring);
    border-radius: var(--radius);
}

.team-role {
    display: block;
    font-size: 0.75rem;
    font-weight: 400;
    color: var(--text-muted);
}

.team-figure-flat { color: var(--text-primary); }

/* THE TWO ABSENCES. Quieter than an ordinary figure, never louder: the
   one reading they must not carry is "zero", and a zero is ordinary. */
.team-figure-flat.team-since,
.team-figure-flat.team-none {
    font-size: 0.75rem;
    color: var(--text-muted);
}

.team-share {
    display: block;
    font-size: 0.75rem;
    color: var(--text-secondary);
}

/* ── lists ───────────────────────────────────────────────────────── */

.team-list {
    margin: 8px 0 0;
    padding: 0;
    list-style: none;
}

.team-topic,
.team-check {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    gap: 4px 12px;
    padding: 6px 0;
    border-bottom: 1px solid var(--border-color);
}

.team-topic:last-child,
.team-check:last-child { border-bottom: 0; }

.team-topic-n {
    min-width: 2.5rem;
    font-size: 0.95rem;
    font-weight: 600;
    font-variant-numeric: tabular-nums;
    color: var(--text-primary);
}

.team-check-name {
    font-size: 0.95rem;
    color: var(--text-primary);
}

.team-topic-who,
.team-topic-do {
    font-size: 0.85rem;
    color: var(--text-secondary);
}

/* ── the states ──────────────────────────────────────────────────── */

.team-error {
    padding: 16px;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-left: 3px solid var(--danger-text);
    border-radius: var(--radius-lg);
}

.team-error p { margin: 0 0 8px; color: var(--text-primary); }
.team-error p:last-of-type { margin-bottom: 12px; }
.team-error .team-note { color: var(--text-secondary); }

@media (max-width: 900px) {
    .team-head { align-items: flex-start; }
}


/* ============================================================
   The Product health tab — how much of what broke was OURS.

   Self-contained: every selector here is prefixed `.hlth-`, and every
   colour is an EXISTING token, so both themes are already correct and
   the global reduced-motion guard already covers the one transition.

   THE CHART IS NOT HERE, AND THAT IS THE POINT. This screen emits the
   Activity tab's `.act-plot` / `.act-chart` / `.act-col` markup and
   calls its `activityScaleTop` — one chart grammar in this product, not
   two. A second vocabulary would have to be kept in step with the
   first, and the way that goes is that it is not: the axis of three
   whole integers, the label-per-column threshold, blue for volume and
   red only for the news are decisions already made and already
   measured. The coupling is real and is named rather than hidden;
   `compose-activity.js` loads before `compose-product-health.js` in
   `index.html` for exactly this reason.

   TWO THINGS THAT LOOK LIKE STYLE AND ARE NOT:

   1. `.hlth-absent` is a PLATE, not a muted line. "Not collected" and
      "nothing happened" are different facts, and the second is the one
      a reader assumes when a figure is simply missing or greyed out.
      It reads at the same weight as the figures beside it because it
      is one of the answers, not a footnote about them.
   2. `.hlth-tile-note` is the denominator's own qualification and is
      never dropped at a narrow width. Every figure here is a share, and
      a share without what it was counted over is the one thing §12.6
      forbids outright.
   ============================================================ */

/* The heading and the period share ONE band, exactly as `.act-head`
   does: a control the reader touches once a visit does not get a band
   above the news. It wraps below the heading under ~720px. */
.hlth-head {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    justify-content: space-between;
    gap: 12px 24px;
    margin: 0 0 16px;
}

.hlth-head-text { flex: 1 1 22rem; min-width: 0; }
.hlth-head-text h2 { margin: 0; }
.hlth-head-text .form-help { margin: 4px 0 0; }

.hlth-filters {
    display: flex;
    flex-wrap: wrap;
    align-items: flex-end;
    gap: 12px 16px;
    margin: 0;
}

.hlth-field {
    display: flex;
    flex-direction: column;
    gap: 4px;
}

.hlth-field label {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-secondary);
}

.hlth-field select:focus-visible {
    outline: none;
    border-color: var(--accent-text);
    box-shadow: var(--focus-ring);
}

.hlth-actions { justify-content: flex-end; }

.hlth-status {
    margin: 0 0 12px;
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.hlth-status:empty { display: none; }

/* ── the three figures ───────────────────────────────────────────── */

.hlth-tiles {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
    gap: 12px;
    margin: 0 0 20px;
}

/* A BUTTON, because pressing it opens the rows the number was counted
   from. A figure nobody can open is a number nobody can check. */
.hlth-tile {
    display: flex;
    flex-direction: column;
    gap: 4px;
    padding: 16px;
    text-align: left;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-radius: var(--radius-lg);
    cursor: pointer;
    font: inherit;
    color: var(--text-primary);
    /* Exact property, ease-out, well under 300ms. Never `all`. */
    transition: background-color 0.12s var(--ease-out);
}

.hlth-tile:hover { background: var(--surface-interactive); }

.hlth-tile:active { background: var(--surface-sunken); }

.hlth-tile:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: -2px;
}

/* ABOVE the number, because a figure means nothing until the reader
   knows what it counts. */
.hlth-tile-l {
    font-size: 0.85rem;
    font-weight: 600;
    color: var(--text-secondary);
}

.hlth-tile-n {
    /* The ramp's TITLE step, the largest the system has. */
    font-size: 1.5rem;
    font-weight: 600;
    line-height: 1.1;
}

.hlth-tile-d {
    font-size: 0.85rem;
    color: var(--text-secondary);
}

.hlth-tile-note {
    margin-top: 4px;
    font-size: 0.75rem;
    line-height: 1.4;
    color: var(--text-muted);
}

/* ── blocks ──────────────────────────────────────────────────────── */

.hlth-block { margin: 28px 0 0; }

.hlth-block h3 {
    margin: 0 0 4px;
    font-size: 1.15rem;
    font-weight: 600;
}

.hlth-sub {
    margin: 16px 0 4px;
    font-size: 0.95rem;
    font-weight: 600;
    color: var(--text-secondary);
}

.hlth-note {
    margin: 4px 0 12px;
    /* The 45-90 character band. A full-width paragraph on a 1600px
       screen is a line nobody finishes. */
    max-width: 68ch;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-secondary);
}

/* One figure standing alone, where a tile would be a row of one. */
.hlth-figure {
    margin: 0;
    font-size: 1.5rem;
    font-weight: 600;
    line-height: 1.1;
}

/* NOT COLLECTED — a plate rather than a grey line. See the block
   comment at the top: a missing figure reads as a zero, and this is the
   one rendering that cannot. `--warning-text` on `--warning-bg`, the
   pair this stylesheet already uses for "nothing is broken, and you
   should know this before you act on the screen". */
.hlth-absent {
    margin: 4px 0 12px;
    padding: 10px 12px;
    max-width: 68ch;
    background: var(--warning-bg);
    border-left: 3px solid var(--warning-text);
    border-radius: 0 var(--radius) var(--radius) 0;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-primary);
}

.hlth-list {
    list-style: none;
    margin: 0;
    padding: 0;
}

.hlth-list li {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 12px;
    padding: 6px 0;
    border-bottom: 1px solid var(--border-color);
    font-size: 0.85rem;
}

.hlth-list li:last-child { border-bottom: 0; }

.hlth-list b {
    flex: none;
    font-variant-numeric: tabular-nums;
}

.hlth-list code {
    font-size: 0.75rem;
    color: var(--text-muted);
}

/* Duration by deploy stage. The stage name is the row; p90 and the
   sample count sit under it rather than beside it, because the nine
   names are long enough to push the number off a narrow card, and a
   duration that wraps away from its label is a duration nobody can
   attribute. `align-items: flex-start` because the row is now two
   lines tall on the left and one on the right. */
.hlth-stages li { align-items: flex-start; }

.hlth-stages small {
    display: block;
    margin-top: 2px;
    font-size: 0.75rem;
    line-height: 1.4;
    color: var(--text-muted);
    overflow-wrap: anywhere;
}

.hlth-names {
    margin: 0 0 8px;
    max-width: 68ch;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-secondary);
    /* A long service list must wrap rather than run off the card. */
    overflow-wrap: anywhere;
}

/* A figure inside a list that opens the rows behind it. Not a bare
   underlined link: every other way into the journal on this screen is a
   control, and one link among them reads as an afterthought. */
.hlth-link {
    padding: 0;
    background: none;
    border: 0;
    font: inherit;
    font-size: 0.85rem;
    color: var(--accent-text);
    text-align: left;
    cursor: pointer;
}

.hlth-link:hover { text-decoration: underline; }

.hlth-link:focus-visible {
    outline: 2px solid var(--accent-text);
    outline-offset: 2px;
    border-radius: 2px;
}

.hlth-error {
    padding: 12px;
    background: var(--danger-bg);
    border: 1px solid var(--danger-text);
    border-radius: var(--radius);
}

.hlth-error p { margin: 0 0 8px; }

.hlth-error .hlth-note { color: var(--text-secondary); }

@media (max-width: 900px) {
    .hlth-head { align-items: flex-start; }
}

/* ── "Your month" — the work summary dialog and its turn-of-month card ──
   Moved here from a JS string in compose-month.js, which carried it as a
   stated deviation ("the whole feature is one file plus one script tag")
   and said folding it in later would be a copy rather than a rewrite.
   The trade stopped being worth it when the builder page picked the
   feature up: a runtime-injected <style> is a second place rules live on
   BOTH pages, and `e2e/design-system.spec.js` asserts the builder ships
   none — a guard that was right, so the injection went rather than the
   guard. Every value here was already an existing token. */
.cmp-month-metric { margin: 0 0 1rem; }
.cmp-month-metric.is-primary { padding-bottom: 1rem;
  border-bottom: 1px solid var(--border-color); }
.cmp-month-value { display: block; font-size: 1.15rem; font-weight: 600;
  line-height: 1.1; color: var(--text-primary); }
.cmp-month-metric.is-primary .cmp-month-value { font-size: 1.5rem; }
.cmp-month-label { display: block; color: var(--text-primary); }
.cmp-month-denominator { display: block; margin-top: 0.25rem;
  font-size: 0.85rem; color: var(--text-muted); max-width: 60ch; }
.cmp-month-rest { display: grid; gap: 0.5rem 1.5rem;
  grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
  margin-top: 1rem; }
.cmp-month-heading { margin: 1.5rem 0 0.5rem; }
.cmp-month-gaps { margin: 0; padding-left: 1.25rem; }
.cmp-month-gaps li { margin-bottom: 0.375rem; }
.cmp-month-empty { color: var(--text-muted); }
.cmp-month-card { display: flex; flex-wrap: wrap; gap: 1rem;
  align-items: center; justify-content: space-between;
  margin-bottom: 1rem; padding: 0.75rem 1rem;
  background: var(--surface-sunken);
  border: 1px solid var(--border-color); border-radius: 8px; }
.cmp-month-card .cmp-month-value { display: inline; font-size: 1.5rem; }
.cmp-month-card .cmp-month-label { display: inline; }
.cmp-month-card-actions { display: flex; gap: 0.5rem; }

/* ── "Your month" — the PAGE (`.ym-*`) ─────────────────────────────────
   The dialog above became a leaf at `/?tab=month`, drawn on OPERATIONS'
   OWN FRAME: `.journal-head`, `.journal-bar`, `.journal-workspace`,
   `.ops-dashboard`, `.ops-metrics`, `.ops-panel`, `.ops-table`,
   `.ops-state`, `.ops-empty-inline`, `.ops-loading`, and the Activity
   chart's `.act-*` for the days. A reader who has seen "What broke" has
   seen this page, so almost nothing is needed here — what follows is
   only the handful of shapes that frame has no rule for.

   The `.cmp-month-*` block above is NOT dead: the turn-of-month card
   still wears it, on the installations screen. */

/* FIVE plates, not four: the headline plus the four context figures
   §7.6 lists. The strip's own 4-column rule stays for Operations.

   TWO CLASSES, and that is not decoration. `.ops-metrics` (further down
   this file, ~:17292) sets `repeat(4, ...)` at the SAME specificity, and
   a later rule of equal specificity wins — so a single `.ym-metrics`
   selector lost silently and the page rendered four plates with the
   fifth alone on a second row beside a grey gap. Measured in the browser
   at 1280x800, not inferred. The breakpoints below have to be doubled up
   for the same reason: a higher-specificity rule beats a media query
   whatever its width, so `.ops-metrics`'s own 900px and 560px steps
   would no longer reach this strip. */
.ops-metrics.ym-metrics { grid-template-columns: repeat(5, minmax(0, 1fr)); }
@media (max-width: 1100px) {
    .ops-metrics.ym-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }
}
@media (max-width: 900px) {
    .ops-metrics.ym-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

/* The headline plate: the strip's accent rule at full strength — the
   same move `.is-danger` / `.is-warning` already make for their tones,
   rather than a sixth colour invented for "this is the one that
   matters". */
.ops-metric.is-primary::before { opacity: 1; }

/* Room under an `.ops-state` heading for the sentence below it. The
   frame's own `margin: 3px 0 0` was written for a panel head, where an
   eyebrow sits above and nothing sits below. */
.ops-state h3 { margin: 4px 0 8px; }

/* A signal row that carries an action on the RIGHT. `.ops-signal-list`
   styles every `<button>` in it as the whole-row control (width 100%),
   which is right for Next steps and wrong for a row whose button is one
   control at the end — so those rows get their own list with the same
   separator and the same padding, and nothing else new. */
.ym-signals { list-style: none; margin: 0; padding: 0; }
.ym-signals li + li { border-top: 1px solid var(--border-color); }
.ym-signal {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    padding: 13px 2px;
    font-size: 0.85rem;
}
.ym-signal > span:first-child,
.ops-signal-list button > span:first-child {
    display: flex;
    flex: 1 1 auto;
    flex-direction: column;
    min-width: 0;
    gap: 2px;
}
/* The text wraps; the control never does. A long
   `HAZELCAST_PUBLIC_ADDRESS` line must not push its own button off the
   row. */
.ym-signal .btn,
.ym-signal .badge,
.ym-signal-act,
.ops-signal-list button .badge { flex: none; }
.ym-signal-t { font-weight: 500; }
/* THE FRAME'S SECOND-READ STEP, not a size picked by eye. `0.8rem` is
   off the DESIGN.md ramp (0.75 / 0.85 / 0.875 / 0.95 / 1.15 / 1.5) and
   `design-tokens.test.js` catches it. The neighbour to match is the one
   on the SAME frame — `.ops-metric-note`, the denominator under a plate,
   which sits at the 0.75 step. `.claude/rules/installations.md` argues
   0.85 for the same kind of line on an installation CARD, and both are
   right: the rule there is to match the comparable line on the surface
   you are on, and this surface is the Operations frame. */
.ym-signal-s,
.ym-sub {
    display: block;
    color: var(--text-muted);
    font-size: 0.75rem;
    line-height: 1.45;
}
/* The `variable` row's action, as TEXT rather than as a button — SDD
   OPEN-1: it has no destination yet, and an inert control is worse than
   a sentence. Same size as the row, quieter than a control, and
   deliberately not `.btn`-shaped so nobody presses it. */
/* It INHERITS the row's 0.85rem — no size of its own. It stands in for
   a control on every other row, so making it the smallest text on the
   row would say "less important than a button", which is the opposite of
   what OPEN-1 means. Quieter by COLOUR, the same weight as the row. */
.ym-signal-act { color: var(--text-secondary); }
.ym-on { color: var(--text-muted); font-weight: 400; }
.ops-signal-list button { text-align: left; }

/* The rest of a long list, behind the disclosure the chart already uses
   for its table. The rows inside keep their rules. */
.ym-more { margin-top: 0; border-top: 1px solid var(--border-color); }
.ym-more > summary { padding: 13px 2px; }
.ym-more[open] > summary { border-bottom: 1px solid var(--border-color); }

/* The count as a meter beside its number: the Activity chart's own two
   fills (accent for volume, danger for news), so a proportion is SEEN
   before the figure is read. */
.ym-meter { display: flex; align-items: center; gap: 10px; margin-top: 6px; }
.ym-meter i {
    display: block;
    flex: 0 1 auto;
    height: 8px;
    max-width: 220px;
    border-radius: 0 4px 4px 0;
    background: var(--accent-primary);
}
.ym-meter i.is-bad { background: var(--danger); }
.ym-meter strong { font-size: 0.85rem; font-variant-numeric: tabular-nums; }

/* Two stacked bars. The same two fills as the day chart for "done" and
   "broke", so the chart's red keeps meaning "broke" and nothing else;
   the two NEW meanings take the success tone and the accent tint.

   `--success-solid`, NOT `--success`. `--success` is tuned as TEXT on a
   dark card: measured against white it is 3.44:1 in the dark theme, a
   WCAG AA fail for a filled segment carrying a label, which is exactly
   the case `--success-solid` exists for (5.79:1 light, 5.48:1 dark).
   The other three: accent 5.33 / 4.63, danger 5.21 / 4.54 with white,
   and the tint 9.05 / 9.07 with `--text-primary`. */
.ym-outcome + .ym-outcome { margin-top: 12px; }
.ym-outcome-l { margin: 0 0 6px; color: var(--text-secondary); font-size: 0.85rem; }
.ym-outcome-bar {
    display: flex;
    gap: 2px;
    height: 22px;
    border-radius: 4px;
    overflow: hidden;
}
.ym-outcome-bar > span {
    display: flex;
    align-items: center;
    padding-left: 8px;
    color: #fff;
    /* The 0.75 step: a label inside a 22px bar, the same step
       `.ops-row-count` wears for the count beside a panel head. */
    font-size: 0.75rem;
    font-weight: 600;
    white-space: nowrap;
    overflow: hidden;
}
.ym-outcome-bar > .is-ok  { background: var(--accent-primary); }
.ym-outcome-bar > .is-bad { background: var(--danger); }
.ym-outcome-bar > .is-wo  { background: var(--success-solid); }
.ym-outcome-bar > .is-nc  {
    background: var(--accent-tint-strong);
    color: var(--text-primary);
}

.ym-gap-top {
    margin-top: 18px;
    padding-top: 14px;
    border-top: 1px solid var(--border-color);
}
.ym-gap-top > .ops-eyebrow { margin-bottom: 2px; }
.ym-gap-top .ops-empty-inline { min-height: 96px; }

.ym-state-actions { display: flex; gap: 10px; margin-top: 6px; }
.ops-table .actions-cell { text-align: right; white-space: nowrap; }

/* ── the arrival ───────────────────────────────────────────────────────
   The frame's own vocabulary and nothing else: opacity plus a few px,
   `var(--ease-out)`, under 300ms, transitions + `@starting-style` — as
   `.cdep-state` does — so a fast second Apply RETARGETS instead of
   restarting. Scoped to `.ym-live`, which is this page's workspace and
   nothing else.

   In reading order, all inside 300ms of the data landing: the plates
   (180ms, 30ms apart), then the panels (180ms, from 120ms), and inside
   them the columns grow from the baseline and the bars from the left
   (220ms, from 150ms) — a proportion is SEEN as one before the number
   is read. The column labels fade in once the column has its height.

   What deliberately does NOT move: the headline figure (no count-up — a
   number that ticks upward is a scoreboard, and §7.6 is explicit that
   this screen is not one), the "worked out" badge (the event was weeks
   ago; a tick drawing itself would celebrate it), the disclosure
   (instant, like the chart's own "Show as a table" — the two
   disclosures on one page must behave the same), and the EXIT on a
   month switch (straight to the skeleton: the arrival masks the swap,
   an exit would only delay the answer that was asked for). */
.ym-live .ops-metric,
.ym-live .ops-panel {
    opacity: 1;
    transform: none;
    transition: opacity 180ms var(--ease-out) var(--ym-d, 0ms),
                transform 180ms var(--ease-out) var(--ym-d, 0ms);
}
.ym-live .ops-metric:nth-child(2) { --ym-d: 30ms; }
.ym-live .ops-metric:nth-child(3) { --ym-d: 60ms; }
.ym-live .ops-metric:nth-child(4) { --ym-d: 90ms; }
.ym-live .ops-metric:nth-child(5) { --ym-d: 120ms; }
.ym-live .ops-panel { --ym-d: 120ms; }
.ym-live .act-seg,
.ym-live .ym-outcome-bar > span,
.ym-live .ym-meter i {
    transform: none;
    transition: transform 220ms var(--ease-out) 150ms;
}
.ym-live .act-seg { transform-origin: bottom; }
.ym-live .ym-outcome-bar > span,
.ym-live .ym-meter i { transform-origin: left; }
.ym-live .act-col-n {
    opacity: 1;
    transition: opacity 180ms var(--ease-out) 300ms;
}
@starting-style {
    .ym-live .ops-metric,
    .ym-live .ops-panel { opacity: 0; transform: translateY(3px); }
    .ym-live .act-seg { transform: scaleY(0); }
    .ym-live .ym-outcome-bar > span,
    .ym-live .ym-meter i { transform: scaleX(0); }
    .ym-live .act-col-n { opacity: 0; }
}

/* ── a column under the pointer (Activity's chart too) ─────────────────
   The slot takes the interactive tint and the total on its cap gives way
   to the split, in the column's own words, on a small plate above the
   mark. 125ms, opacity plus 2px, ease-out; the first and last columns
   pin the plate inside the plot so it cannot hang off the edge.

   `.act-col` is the Activity chart's class, so Operations › Activity
   gets the same hover — one chart, one rule, and it is named in the
   commit as a change to that screen rather than smuggled into it.

   HOOKED ON THE PLATE'S PRESENCE, not on a marker class. A slot with
   nothing in it emits no plate (nothing to say) and therefore takes no
   tint either; a slot with something in it emits one and gives up its
   native `title`, so the browser tooltip and this plate can never fire
   together saying the same thing twice. The `<details>` table under the
   chart remains the keyboard's and the screen reader's path, so nothing
   here carries information that is only available on hover. */
.act-col-tip {
    position: absolute;
    left: 50%;
    z-index: 1;
    margin-bottom: 22px;
    padding: 4px 8px;
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    border-radius: 6px;
    box-shadow: var(--shadow);
    color: var(--text-primary);
    font-size: 0.75rem;
    line-height: 1.3;
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
    opacity: 0;
    transform: translate(-50%, 2px);
    pointer-events: none;
    transition: opacity 125ms var(--ease-out),
                transform 125ms var(--ease-out);
}
.act-col:nth-child(-n+5) .act-col-tip { left: 0; transform: translate(0, 2px); }
.act-col:nth-last-child(-n+4) .act-col-tip {
    left: auto;
    right: 0;
    transform: translate(0, 2px);
}
.act-col:has(> .act-col-tip) {
    border-radius: 4px;
    transition: background-color 120ms var(--ease-out);
}
.act-col-n { transition: opacity 120ms var(--ease-out); }
@media (hover: hover) and (pointer: fine) {
    .act-col:has(> .act-col-tip):hover { background: var(--surface-interactive); }
    .act-col:has(> .act-col-tip):hover .act-col-n { opacity: 0; }
    .act-col:hover .act-col-tip { opacity: 1; transform: translate(-50%, 0); }
    .act-col:nth-child(-n+5):hover .act-col-tip,
    .act-col:nth-last-child(-n+4):hover .act-col-tip { transform: translate(0, 0); }
    .ops-signal-list button { transition: color var(--transition); }
}

/* Fewer and gentler, not none: the fades stay, the travel goes. */
@media (prefers-reduced-motion: reduce) {
    .ym-live .ops-metric,
    .ym-live .ops-panel { transition-property: opacity; }
    .ym-live .act-seg,
    .ym-live .ym-outcome-bar > span,
    .ym-live .ym-meter i { transition: none; }
    .ym-live .act-col-n { transition: none; }
    .act-col-tip {
        transition: opacity 125ms var(--ease-out);
        transform: translate(-50%, 0);
    }
    .act-col:nth-child(-n+5) .act-col-tip,
    .act-col:nth-last-child(-n+4) .act-col-tip { transform: none; }
}

/* `+ Build topology` is an <a>, and a link cannot be `:disabled`, so its
   mute is a class. The SAME numbers as `.btn:disabled` rather than new
   ones: two controls refused for one reason must not look differently
   refused. `pointer-events: none` is what makes the mute real for the
   mouse; the href is removed in JS for the keyboard. */
.btn.is-muted {
    opacity: 0.5;
    cursor: not-allowed;
    pointer-events: none;
}
/* Operations and Assistant use one stable analytical page frame. */
/* NO WIDTH OF THEIR OWN. `.app` is `max-width: 1600px` with 20px of
   padding, and every other screen ends where that ends. These three
   capped themselves at 1440 as well, so above a 1480px viewport they
   sat 120px narrower than the tab bar and the update banner directly
   above them — inset from the two elements a reader lines them up
   against, for no reason any comment here ever gave. Nothing in them is
   a prose column that a narrower measure would help; they are charts
   and tables, and the tables scroll inside their own containers.
   (Measured at 1920px: 240..1680 under a banner at 180..1740.) */
.journal-page,
#assistant-audit {
    margin: 0 auto;
}

.journal-page.active,
#assistant-audit.active {
    display: block;
}

.journal-head,
.asa-page-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 24px;
    padding: 28px 30px 22px;
    background: var(--bg-primary);
    border: 1px solid var(--border-color);
    border-bottom: 0;
    border-radius: var(--radius-lg) var(--radius-lg) 0 0;
}

.journal-head h2,
.asa-page-head h2 {
    margin: 2px 0 6px;
    font-size: clamp(1.45rem, 2vw, 2rem);
    letter-spacing: -0.025em;
}

.journal-head .form-help,
.asa-page-head .form-help { max-width: 68ch; margin: 0; }
.journal-kicker,
.asa-kicker,
.ops-eyebrow {
    margin: 0;
    color: var(--accent-text);
    font-size: 0.72rem;
    font-weight: 700;
    letter-spacing: 0.09em;
    text-transform: uppercase;
}
.journal-freshness,
.asa-freshness {
    flex: none;
    margin: 3px 0 0;
    color: var(--text-muted);
    font-size: 0.78rem;
}

.journal-views,
.asa-views {
    display: flex;
    align-items: center;
    gap: 4px;
    /* 46, not 45: `min-height` includes the 1px bottom border, so a 45
       left the 44px item plus its 2px selected underline one pixel
       taller than the content box — which is exactly the pixel the
       vertical scrollbar was rendered for. */
    min-height: 46px;
    padding: 0 24px;
    /* `overflow-x: auto` ALONE makes this a scroll container on BOTH
       axes — `overflow-y: visible` computes to `auto` the moment the
       other axis is not visible — and a 44px item in a 45px row then
       grew a vertical scrollbar down the whole width of the row, at
       every viewport. The horizontal scroll is deliberate (a narrow
       screen keeps every view reachable rather than hiding some); the
       vertical one was never asked for. */
    overflow-x: auto;
    overflow-y: hidden;
    background: var(--bg-primary);
    border-inline: 1px solid var(--border-color);
    border-bottom: 1px solid var(--border-color);
}
.journal-view-item,
.asa-views [role="tab"] {
    position: relative;
    flex: none;
    min-height: 44px;
    padding: 0 13px;
    border: 0;
    background: transparent;
    color: var(--text-secondary);
    font: inherit;
    font-size: 0.83rem;
    font-weight: 600;
    cursor: pointer;
}
.journal-view-item::after,
.asa-views [role="tab"]::after {
    position: absolute;
    right: 12px;
    bottom: -1px;
    left: 12px;
    height: 2px;
    content: "";
    background: transparent;
}
.journal-view-item:hover,
.asa-views [role="tab"]:hover { color: var(--text-primary); background: var(--surface-interactive); }
.journal-view-item[aria-selected="true"],
.asa-views [aria-selected="true"] { color: var(--accent-text); }
.journal-view-item[aria-selected="true"]::after,
.asa-views [aria-selected="true"]::after { background: var(--accent-primary); }
.journal-view-item:focus-visible,
.asa-views [role="tab"]:focus-visible { box-shadow: var(--focus-ring); outline: none; }
.journal-view-item:active,
.asa-views [role="tab"]:active { transform: scale(0.98); }

.journal-filter-shell,
.asa-filter-shell {
    padding: 14px 30px 12px;
    background: var(--bg-secondary);
    border: 1px solid var(--border-color);
    border-top: 0;
}
.journal-bar,
.asa-filter-shell .asa-filters { align-items: flex-end; margin: 0; }
.journal-field label,
.asa-filter-shell label {
    color: var(--text-muted);
    font-size: 0.75rem;
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
}
.journal-ceiling { margin: 8px 0 0; }
.journal-custom[hidden] { display: none; }
.journal-workspace,
.asa-workspace {
    min-height: 540px;
    padding: 18px 30px 30px;
    background: var(--surface-sunken);
    border: 1px solid var(--border-color);
    border-top: 0;
    border-radius: 0 0 var(--radius-lg) var(--radius-lg);
}

.ops-dashboard {
    display: grid;
    grid-template-columns: minmax(0, 2fr) minmax(240px, 1fr);
    gap: 14px;
}
.ops-metrics {
    display: grid;
    grid-column: 1 / -1;
    grid-template-columns: repeat(4, minmax(0, 1fr));
    gap: 1px;
    margin: 0;
    overflow: hidden;
    background: var(--border-color);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
}
.ops-metric {
    position: relative;
    min-height: 108px;
    padding: 17px 18px;
    background: var(--bg-primary);
}
.ops-metric::before {
    position: absolute;
    top: 0;
    right: 0;
    left: 0;
    height: 2px;
    content: "";
    background: var(--accent-primary);
    opacity: 0.35;
}
.ops-metric.is-danger::before { background: var(--danger-text); opacity: 1; }
.ops-metric.is-warning::before { background: var(--warning-text); opacity: 1; }
.ops-metric dt { color: var(--text-secondary); font-size: 0.75rem; font-weight: 600; }
.ops-metric dd {
    margin: 7px 0 0;
    color: var(--text-primary);
    font-size: clamp(1.45rem, 2.2vw, 2rem);
    font-weight: 650;
    letter-spacing: -0.04em;
    line-height: 1;
    font-variant-numeric: tabular-nums;
}
/* Inside the `<dd>`, not beside it: a `<div>` in a `<dl>` may hold only
   `<dt>` and `<dd>`. It therefore has to undo the figure's own display
   type, weight and tracking rather than inheriting them. */
.ops-metric-note {
    display: block;
    margin-top: 7px;
    color: var(--text-muted);
    font-size: 0.72rem;
    font-weight: 400;
    letter-spacing: normal;
    line-height: 1.45;
    font-variant-numeric: normal;
}

.ops-panel,
.ops-state,
.asa-block {
    min-width: 0;
    padding: 20px;
    background: var(--bg-primary);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
    box-shadow: 0 1px 2px rgb(0 0 0 / 0.035);
}
.ops-primary { grid-column: 1; min-height: 276px; }
.ops-rail { grid-column: 2; min-height: 276px; }
.ops-wide { grid-column: 1 / -1; }

/* AFTER `.ops-dashboard`, not before it, and that is load-bearing:
   `.ppl-dashboard` restates `grid-template-columns` at the same
   specificity, so written earlier in the file the generic rule wins
   and the picker takes two thirds of the screen. Measured in the
   browser, 2026-09-07. */
/* ── Operations › People: the picker and one person's page ─────────
   Replaces a 19x13 grid. See `js/journal-people.js` for what the table
   was doing to the reader and which rules the picker may not break.

   THE GRID IS THE ONE REAL ADDITION. `.ops-dashboard` is 2fr/1fr with
   the rail on the RIGHT; this screen wants a narrow PICKER on the left
   and the person on the right, so `.ppl-dashboard` restates the columns
   and nothing else. Under 1024px it is one column. */
.ppl-dashboard { grid-template-columns: minmax(232px, 1fr) minmax(0, 3fr); align-items: start; }
.ppl-picker { grid-column: 1; }
.ppl-detail { grid-column: 2; }
.ops-metrics.ppl-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); }

/* The names, and only the names. Capped against the VIEWPORT rather
   than at a row count: the list is as long as the team, and a fixed
   pixel height that fits fifteen people cuts a smaller team's card off
   in the middle of nothing. `overscroll-behavior` so reaching the end
   does not then scroll the page out from under the reader. */
.ppl-scroll { max-height: 58vh; overflow-y: auto; overscroll-behavior: contain; }
.ppl-scroll:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; border-radius: var(--radius); }

.ppl-list { list-style: none; margin: 0 0 10px; padding: 0; }
.ppl-list li + li { border-top: 1px solid var(--border-color); }
.ppl-item {
    display: grid;
    /* Two columns, not three. The third held a bare count -- a figure
       with no denominator and no drill (audit-plan §8 rule 1) and,
       scanned down nineteen rows in tabular numerals, the hidden title
       rule 6 forbids however alphabetical the order is. */
    grid-template-columns: minmax(0, 1fr) auto;
    align-items: center;
    gap: 10px;
    width: 100%;
    padding: 9px 8px;
    border: 0;
    background: none;
    font: inherit;
    text-align: left;
    color: var(--text-primary);
    cursor: pointer;
    border-radius: var(--radius);
}
.ppl-item:hover { background: var(--surface-interactive); }
.ppl-item:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: -2px; }
/* The CURRENT person, marked by a bar rather than by a fill: one filled
   row beside three unfilled ones reads as a rank. */
.ppl-item.is-current { box-shadow: inset 3px 0 0 var(--accent-primary); background: var(--surface-interactive); }
.ppl-item-who { display: flex; flex-direction: column; min-width: 0; gap: 1px; }
.ppl-item-who strong { font-weight: 600; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ppl-item-role { font-size: 0.75rem; color: var(--text-muted); }

/* Each person's OWN days. No scale, and none shared with the rows above
   or below: a common y-axis would turn the list into a bar chart of
   people, which is the same ranking by another route. */
/* NO GAP, and that is measured rather than preferred: a 30-day period
   is 31 bars, and at 2px apart the gaps alone need 60px inside a 46px
   box -- every bar collapses to nothing and the row reads as empty.
   Seen in the browser, 2026-09-07. A sparkline over a long period is a
   silhouette; the gap belongs to the day chart, which has the room. */
.ppl-spark { display: flex; align-items: flex-end; height: 20px; width: 52px; overflow: hidden; }
.ppl-spark i { flex: 1 1 0; min-height: 1px; background: var(--text-muted); opacity: 0.55; }

.ppl-group { margin: 14px 0 4px; font-size: 0.75rem; text-transform: uppercase; letter-spacing: 0.04em; color: var(--text-muted); }

.ppl-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; padding-bottom: 12px; border-bottom: 1px solid var(--border-color); }
.ppl-head h3 { margin: 0 0 4px; overflow-wrap: anywhere; }
.ppl-head-note { margin: 0; color: var(--text-muted); font-size: 0.85rem; display: flex; align-items: center; gap: 8px; flex-wrap: wrap; }
.ppl-body { display: flex; flex-direction: column; gap: 14px; padding-top: 14px; }
.ppl-cols { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; }

.ppl-envs { list-style: none; display: flex; flex-wrap: wrap; gap: 8px; margin: 0 0 8px; padding: 0; }
.ppl-env { display: inline-flex; align-items: center; gap: 8px; padding: 5px 10px; border: 1px solid var(--border-color); border-radius: 999px; background: var(--bg-primary); font: inherit; font-size: 0.85rem; color: var(--text-primary); cursor: pointer; }
.ppl-env span { font-variant-numeric: tabular-nums; color: var(--text-muted); }
.ppl-env:hover { border-color: var(--accent-primary); }
.ppl-env:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }

/* SEPARATE CARDS, not one bordered slab with hairline gaps.
   The slab needed a fixed column count to look right, and the count was
   picked for the ten columns §8 names -- there are THIRTEEN once
   Activity's three are merged, so the last row ended in two cells of
   bare grid background that read as two figures that failed to load.
   Seen in the running app, 2026-09-07. `auto-fit` cannot leave a dead
   cell, because with no backdrop there is nothing behind one. */
.ppl-figures { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; margin: 0 0 12px; }
.ppl-figure { position: relative; padding: 11px 13px; background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--radius); }
/* A figure nobody could compute is an ABSENCE, and it is drawn as one:
   an em dash in the value's own slot, the reason in the note. It used
   to print "data from 26 Aug" as the value, in the figure's typographic
   slot -- ten of thirteen did, and the block became the wall of one
   repeated sentence this screen was built to remove. */
.ppl-figure.is-absent dd { color: var(--text-muted); font-weight: 400; }
.ppl-figure dt { display: flex; align-items: flex-start; gap: 6px; font-size: 0.75rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.02em; }

/* The question mark, and what it reveals. A native `<details>` rather
   than a tooltip or a popover of ours: the sentence is §8 rule 1's
   "every figure names what it is counted out of", so it must be in the
   DOM whether or not any service answers, and the keyboard and the
   screen reader must reach it without a handler. */
.ppl-why { margin-left: auto; }
.ppl-why > summary {
    list-style: none;
    display: grid;
    place-items: center;
    width: 16px;
    height: 16px;
    border: 1px solid var(--border-color);
    border-radius: 50%;
    font-size: 0.75rem;
    line-height: 1;
    color: var(--text-muted);
    cursor: pointer;
    text-transform: none;
}
.ppl-why > summary::-webkit-details-marker { display: none; }
.ppl-why > summary:hover { color: var(--text-primary); border-color: var(--accent-primary); }
.ppl-why > summary:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; }
.ppl-why[open] > summary { color: var(--text-primary); border-color: var(--accent-primary); }
.ppl-why-body {
    /* Out of the `<dt>`'s flex row and across the whole tile: the
       sentence is a paragraph, and a paragraph in a 16px column is a
       column of single words.

       BELOW the tile, not inside it. Flowing from the `<dt>` put the
       panel straight over the figure it was explaining -- the reader
       pressed "?" about a number and the number disappeared. */
    position: absolute;
    top: calc(100% - 4px);
    left: 0;
    right: 0;
    padding: 10px 12px;
    background: var(--bg-primary);
    border: 1px solid var(--accent-primary);
    border-radius: var(--radius);
    box-shadow: var(--shadow-md, 0 4px 14px rgb(0 0 0 / 12%));
    text-transform: none;
    letter-spacing: normal;
    z-index: 3;
}
.ppl-why-body p { margin: 0; font-size: 0.75rem; line-height: 1.5; color: var(--text-secondary); }
.ppl-why-ask {
    margin-top: 8px;
    border: 0;
    padding: 0;
    background: none;
    font: inherit;
    font-size: 0.75rem;
    color: var(--accent-primary);
    text-decoration: underline;
    text-underline-offset: 2px;
    cursor: pointer;
}
.ppl-why-ask:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; border-radius: 2px; }
.ppl-figure dd { margin: 3px 0 0; font-size: 1.125rem; font-weight: 600; font-variant-numeric: tabular-nums; }
.ppl-figure-open { border: 0; padding: 0; background: none; font: inherit; font-size: 1.125rem; font-weight: 600; font-variant-numeric: tabular-nums; color: var(--accent-primary); text-decoration: underline; text-underline-offset: 3px; cursor: pointer; }
.ppl-figure-open:focus-visible { outline: 2px solid var(--accent-primary); outline-offset: 2px; border-radius: 2px; }
.ppl-figure-note { display: block; margin-top: 3px; font-size: 0.75rem; font-weight: 400; color: var(--text-muted); }

.ppl-detail-empty { display: flex; align-items: center; justify-content: center; min-height: 320px; }
.ppl-invite { max-width: 46ch; text-align: center; }
.ppl-invite-lead { margin: 0 0 6px; font-size: 1.15rem; font-weight: 600; }

@media (max-width: 1024px) {
    .ppl-dashboard { grid-template-columns: minmax(0, 1fr); }
    .ppl-picker, .ppl-detail { grid-column: 1; }
    .ppl-cols { grid-template-columns: minmax(0, 1fr); }
}

/* A panel that hosts a section reused from the screens Operations
   absorbed. Those sections bring their own `<h3>` and their own notes, so
   the frame supplies the card and the grid slot and nothing else — a
   second heading above their own read as two titles for one block. Their
   leading margin goes, because the card already provides the gap. */
.ops-shell > .act-block:first-child,
.ops-shell > .team-block:first-child,
.ops-shell > .hlth-block:first-child { margin-top: 0; }
.ops-shell > :last-child { margin-bottom: 0; }

/* The KPI row of a view whose tiles come from those same screens; it
   spans the grid exactly as `.ops-metrics` does. */
/* `.ops-tiles` is `.hlth-tiles` placed inside the Operations dashboard,
   and `.hlth-tiles` carries `margin-bottom: 20px` for the Product-health
   page, where nothing else provides the space. Inside `.ops-dashboard`
   something does -- its own `gap` -- so the two added up: 34px under
   this row against 14px under every other one, which is the uneven band
   a reader sees before they can name it. Same rule the deploy panel's
   rhythm test already states in words: a child of a gapped container
   does not add a margin on top of the gap. (Reported, then measured,
   2026-09-07.) */
.ops-tiles { grid-column: 1 / -1; margin-bottom: 0; }

.ops-panel-head {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 16px;
    margin-bottom: 18px;
}
.ops-panel h3,
.ops-state h3,
.asa-block h3 { margin: 3px 0 0; font-size: 0.95rem; }
.ops-row-count { color: var(--text-muted); font-size: 0.75rem; white-space: nowrap; }
/* ON THE SCALE, and not negative. `-5px` pulled this note up under
   whatever it followed: under `.ops-duration` it sat on the tiles' own
   bottom border, and under `.hlth-figure` it collided with the figure
   it explains. It was tuned for the one case where the note follows a
   heading — and even there a caller had already overridden it inline,
   which is the tell. 12/16 are `spacing.md`/`spacing.lg` from
   DESIGN.md, the same rhythm `.act-note` (4/12) already keeps. */
.ops-context { max-width: 68ch; margin: 12px 0 16px; color: var(--text-secondary); font-size: 0.85rem; line-height: 1.5; }

.ops-trend {
    display: flex;
    align-items: flex-end;
    gap: clamp(3px, 0.7vw, 8px);
    height: 168px;
    padding: 14px 6px 0;
    border-bottom: 1px solid var(--border-color);
    background: var(--bg-secondary);
}
.ops-trend-column { display: flex; align-items: flex-end; flex: 1; height: 100%; }
.ops-trend-column i { display: block; width: 100%; min-height: 4px; background: var(--accent-primary); border-radius: 2px 2px 0 0; opacity: 0.78; }
.ops-trend-column:hover i { opacity: 1; }
.ops-trend-axis { display: flex; justify-content: space-between; padding-top: 7px; color: var(--text-muted); font-size: 0.75rem; }

.ops-signal-list { list-style: none; margin: 0; padding: 0; }
.ops-signal-list li + li { border-top: 1px solid var(--border-color); }
.ops-signal-list button {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 12px;
    width: 100%;
    padding: 13px 2px;
    border: 0;
    background: transparent;
    color: var(--text-primary);
    font: inherit;
    font-size: 0.85rem;
    text-align: left;
    cursor: pointer;
}
.ops-signal-list button:hover { color: var(--accent-text); }
.ops-signal-list button:focus-visible { box-shadow: var(--focus-ring); outline: none; }
.ops-signal-list strong { font-variant-numeric: tabular-nums; }

.ops-table { max-height: 330px; overflow: auto; border: 1px solid var(--border-color); border-radius: calc(var(--radius) - 2px); }
.ops-table table { width: 100%; min-width: 620px; border-collapse: separate; border-spacing: 0; }
.ops-table thead th {
    position: sticky;
    top: 0;
    z-index: 1;
    background: var(--bg-secondary);
    color: var(--text-muted);
    font-size: 0.75rem;
    letter-spacing: 0.04em;
    text-align: left;
    text-transform: uppercase;
}
.ops-table th,
.ops-table td { padding: 11px 13px; border-bottom: 1px solid var(--border-color); font-size: 0.85rem; vertical-align: top; }
.ops-table tbody tr:last-child > * { border-bottom: 0; }
.ops-table tbody tr:hover > * { background: var(--surface-interactive); }
.ops-table th small { display: block; margin-top: 3px; color: var(--text-muted); font-weight: 400; }
.ops-number { text-align: right !important; font-variant-numeric: tabular-nums; }
.ops-person { display: block; }
.link-button { border: 0; padding: 0; color: var(--accent-text); background: transparent; cursor: pointer; font: inherit; text-align: left; }
.link-button:hover { text-decoration: underline; }
.link-button:focus-visible { box-shadow: var(--focus-ring); outline: none; }
.link-button:active { transform: scale(0.98); }
.ops-duration { display: grid; grid-template-columns: 1fr 1fr; gap: 1px; overflow: hidden; margin-top: 32px; background: var(--border-color); border: 1px solid var(--border-color); border-radius: var(--radius); }
.ops-duration span { padding: 18px; background: var(--bg-secondary); color: var(--text-muted); font-size: 0.75rem; }
.ops-duration strong { display: block; margin-top: 6px; color: var(--text-primary); font-size: 1.5rem; font-variant-numeric: tabular-nums; }
.ops-empty-inline { display: grid; min-height: 150px; place-items: center; padding: 24px; color: var(--text-muted); text-align: center; }
.ops-state { min-height: 240px; display: grid; align-content: center; justify-items: start; max-width: none; }
.ops-state p { max-width: 58ch; color: var(--text-secondary); }
.ops-error { border-left: 3px solid var(--danger-text); }
.ops-loading { grid-template-columns: repeat(3, 1fr); }
.ops-skeleton { min-height: 150px; border: 1px solid var(--border-color); border-radius: var(--radius); background: var(--surface-interactive); animation: ops-pulse 1.2s ease-in-out infinite alternate; }
@keyframes ops-pulse { to { opacity: 0.46; } }

@media (max-width: 900px) {
    .journal-head, .asa-page-head { padding-inline: 22px; }
    .journal-filter-shell, .asa-filter-shell, .journal-workspace, .asa-workspace { padding-inline: 22px; }
    .ops-metrics { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .ops-dashboard { grid-template-columns: minmax(0, 1fr); }
    .ops-primary, .ops-rail, .ops-wide { grid-column: 1; }
    .ops-rail { min-height: auto; }
}
@media (max-width: 560px) {
    .journal-head, .asa-page-head { display: block; padding: 20px 16px 16px; }
    .journal-freshness, .asa-freshness { margin-top: 12px; }
    .journal-views, .asa-views { padding-inline: 8px; }
    .journal-filter-shell, .asa-filter-shell { padding: 12px 16px; }
    .journal-workspace, .asa-workspace { min-height: 420px; padding: 12px 10px 18px; }
    .journal-bar, .asa-filter-shell .asa-filters { display: grid; grid-template-columns: 1fr 1fr; }
    .journal-field-env { grid-column: 1 / -1; }
    .ops-metrics { grid-template-columns: 1fr 1fr; }
    .ops-metric { min-height: 96px; padding: 14px; }
    .ops-panel, .ops-state, .asa-block { padding: 15px; }
    .ops-loading { grid-template-columns: 1fr; }
}
@media (prefers-reduced-motion: reduce) {
    .ops-skeleton { animation: none; }
    .journal-view-item, .asa-views [role="tab"], .link-button { transition: none; }
}

/* Assistant composition on the shared analytical frame. */
.asa-page { margin: 0 auto; }   /* see `.journal-page` above */
.asa-workspace > .asa-status { margin: 0 0 10px; }
.asa-view-panel[hidden] { display: none; }
.asa-view-panel > .asa-note:first-child { margin-top: 0; }
.asa-view-panel > .asa-tiles {
    display: grid;
    grid-template-columns: repeat(3, minmax(0, 1fr));
    gap: 1px;
    margin: 0 0 14px;
    overflow: hidden;
    background: var(--border-color);
    border: 1px solid var(--border-color);
    border-radius: var(--radius);
}
.asa-view-panel > .asa-tiles .asa-tile {
    min-height: 112px;
    padding: 18px;
    background: var(--bg-primary);
    border: 0;
    border-radius: 0;
}
.asa-view-panel > .asa-tiles .asa-tile-n {
    margin: 3px 0;
    font-size: clamp(1.45rem, 2.2vw, 2rem);
    letter-spacing: -0.04em;
}
.asa-dashboard {
    display: grid;
    grid-template-columns: minmax(0, 2fr) minmax(240px, 1fr);
    gap: 14px;
}
.asa-primary-column,
.asa-rail-column { display: grid; align-content: start; gap: 14px; min-width: 0; }
.asa-block { margin: 0; }
.asa-block .asa-tiles { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.asa-block .asa-tile { padding: 13px; border-radius: var(--radius); }
.asa-block .table-container { max-height: 330px; margin-top: 14px; overflow: auto; border: 1px solid var(--border-color); border-radius: var(--radius); }
.asa-table { min-width: 760px; border-collapse: separate; border-spacing: 0; }
.asa-table thead th { position: sticky; top: 0; z-index: 1; background: var(--bg-secondary); color: var(--text-muted); font-size: 0.75rem; text-transform: uppercase; }
.asa-table th, .asa-table td { padding: 10px 12px; border-bottom: 1px solid var(--border-color); text-align: left; font-size: 0.75rem; }
.asa-table tbody tr:last-child td { border-bottom: 0; }
.asa-table tbody tr:hover td { background: var(--surface-interactive); }
.asa-confirm { width: min(480px, calc(100vw - 32px)); padding: 24px; color: var(--text-primary); background: var(--bg-primary); border: 1px solid var(--border-color); border-radius: var(--radius-lg); box-shadow: var(--shadow); }
.asa-confirm::backdrop { background: rgb(0 0 0 / 0.48); }
.asa-confirm h3 { margin: 0 0 8px; }
.asa-confirm p { color: var(--text-secondary); line-height: 1.5; }
.asa-confirm form { display: flex; justify-content: flex-end; gap: 8px; margin-top: 20px; }

@media (max-width: 900px) {
    .asa-dashboard { grid-template-columns: 1fr; }
}
@media (max-width: 560px) {
    .asa-view-panel > .asa-tiles { grid-template-columns: 1fr; }
    .asa-view-panel > .asa-tiles .asa-tile { min-height: 90px; }
    .asa-block .asa-tiles { grid-template-columns: 1fr; }
}

/* ── the installation combobox ───────────────────────────────────────
   The field is an ordinary text input and stays one: the value is free
   text on purpose (an installation can be deleted while its journal
   rows remain, so a name this tool no longer hosts must stay typeable).
   Only the POPUP is ours, and it borrows `.ui-select-popup` and its
   rows from ft-ui-core rather than restating them — a filter that looks
   NEARLY like the select beside it is worse than one that looks plainly
   different.

   The host is the empty <span> that used to hold the <datalist>. It
   sits directly after the input, so a zero-height positioned block
   there puts `top: calc(100% + 4px)` exactly where the browser used to
   draw its own list. */
/* The POSITIONING CONTEXT is the field, not the slot: the slot is
   `display: contents` (above) so it has no box of its own to position
   against. `top: calc(100% + 4px)` then resolves against the field —
   4px under the input, which is where the browser drew its own list. */
.journal-field-env,
.act-field:has(> span[id$="ListSlot"]),
.audit-field:has(> span[id$="ListSlot"]) {
    position: relative;
}

/* Nothing matched what was typed — and that is NOT a refusal, because
   the value is still a legal filter. Muted like every other absence on
   these screens, never the danger tone. */
.env-combo-none {
    margin: 0;
    padding: 8px 12px;
    font-size: 0.85rem;
    line-height: 1.5;
    color: var(--text-muted);
}
.env-combo-none[hidden] { display: none; }

/* Access refused: one row per PERSON, with why and where inside it.
   The key used to be (code, route, actor), so one address filled ten
   rows and the sort scattered them — the reader could not answer the
   question the block exists for. The row keeps its four columns; the
   two right-hand ones now hold a short list each rather than a single
   value. */
.act-ref-why,
.act-ref-where {
    display: block;
    min-width: 0;
    overflow-wrap: anywhere;
}
/* The count beside each reason and each route: nine on one route and
   one on another is a different story from five and five, and the row's
   own total cannot tell them apart. Quieter than the value it counts. */
.act-ref-x,
.act-ref-more { color: var(--text-muted); }
